diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 00000000000..713c7dc2872 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,49 @@ + +## The problem + + + +## Environment + + +- Home Assistant Core release with the issue: +- Last working Home Assistant Core release (if known): +- Operating environment (Home Assistant/Supervised/Docker/venv): +- Integration causing this issue: +- Link to integration documentation on our website: + +## Problem-relevant `configuration.yaml` + + +```yaml + +``` + +## Traceback/Error logs + + +```txt + +``` + +## Additional information + diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.md b/.github/ISSUE_TEMPLATE/BUG_REPORT.md index 977abc6ef6b..9bfecda724f 100644 --- a/.github/ISSUE_TEMPLATE/BUG_REPORT.md +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.md @@ -1,10 +1,10 @@ --- -name: Report a bug with Home Assistant -about: Report an issue with Home Assistant +name: Report a bug with Home Assistant Core +about: Report an issue with Home Assistant Core --- @@ -23,9 +23,9 @@ about: Report an issue with Home Assistant Home Assistant frontend: Developer tools -> Info. --> -- Home Assistant release with the issue: -- Last working Home Assistant release (if known): -- Operating environment (Hass.io/Docker/Windows/etc.): +- Home Assistant Core release with the issue: +- Last working Home Assistant Core release (if known): +- Operating environment (Home Assistant/Supervised/Docker/venv): - Integration causing this issue: - Link to integration documentation on our website: diff --git a/CODEOWNERS b/CODEOWNERS index 5ea7c376329..4598c6f049d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -187,6 +187,7 @@ homeassistant/components/intesishome/* @jnimmo homeassistant/components/ios/* @robbiet480 homeassistant/components/iperf3/* @rohankapoorcom homeassistant/components/ipma/* @dgomes @abmantis +homeassistant/components/ipp/* @ctalkington homeassistant/components/iqvia/* @bachya homeassistant/components/irish_rail_transport/* @ttroy50 homeassistant/components/izone/* @Swamp-Ig @@ -369,7 +370,7 @@ homeassistant/components/switchmate/* @danielhiversen homeassistant/components/syncthru/* @nielstron homeassistant/components/synology_srm/* @aerialls homeassistant/components/syslog/* @fabaff -homeassistant/components/tado/* @michaelarnauts +homeassistant/components/tado/* @michaelarnauts @bdraco homeassistant/components/tahoma/* @philklei homeassistant/components/tankerkoenig/* @guillempages homeassistant/components/tautulli/* @ludeeus diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index 710b4af1cd8..26bd10535d0 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -215,12 +215,14 @@ class AuthManager: return user - async def async_create_user(self, name: str) -> models.User: + async def async_create_user( + self, name: str, group_ids: Optional[List[str]] = None + ) -> models.User: """Create a user.""" kwargs: Dict[str, Any] = { "name": name, "is_active": True, - "group_ids": [GROUP_ID_ADMIN], + "group_ids": group_ids or [], } if await self._user_should_be_owner(): diff --git a/homeassistant/components/airvisual/.translations/it.json b/homeassistant/components/airvisual/.translations/it.json index 9db76248a36..7d309fdb22a 100644 --- a/homeassistant/components/airvisual/.translations/it.json +++ b/homeassistant/components/airvisual/.translations/it.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Questa chiave API \u00e8 gi\u00e0 in uso." + "already_configured": "Queste coordinate sono gi\u00e0 state registrate." }, "error": { "invalid_api_key": "Chiave API non valida" diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index b840a1b7171..f3f2397d214 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -41,7 +41,6 @@ _LOGGER = logging.getLogger(__name__) DATA_CONFIG = "config" DEFAULT_SOCKET_MIN_RETRY = 15 -DEFAULT_WATCHDOG_SECONDS = 5 * 60 TYPE_24HOURRAININ = "24hourrainin" TYPE_BAROMABSIN = "baromabsin" @@ -342,7 +341,6 @@ class AmbientStation: self._config_entry = config_entry self._entry_setup_complete = False self._hass = hass - self._watchdog_listener = None self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY self.client = client self.stations = {} @@ -359,21 +357,9 @@ class AmbientStation: async def ws_connect(self): """Register handlers and connect to the websocket.""" - async def _ws_reconnect(event_time): - """Forcibly disconnect from and reconnect to the websocket.""" - _LOGGER.debug("Watchdog expired; forcing socket reconnection") - await self.client.websocket.disconnect() - await self._attempt_connect() - def on_connect(): """Define a handler to fire when the websocket is connected.""" _LOGGER.info("Connected to websocket") - _LOGGER.debug("Watchdog starting") - if self._watchdog_listener is not None: - self._watchdog_listener() - self._watchdog_listener = async_call_later( - self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect - ) def on_data(data): """Define a handler to fire when the data is received.""" @@ -385,12 +371,6 @@ class AmbientStation: self._hass, f"ambient_station_data_update_{mac_address}" ) - _LOGGER.debug("Resetting watchdog") - self._watchdog_listener() - self._watchdog_listener = async_call_later( - self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect - ) - def on_disconnect(): """Define a handler to fire when the websocket is disconnected.""" _LOGGER.info("Disconnected from websocket") diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index a6572070a5e..8c4bc1b3cc0 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -3,7 +3,7 @@ "name": "Ambient Weather Station", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ambient_station", - "requirements": ["aioambient==1.0.4"], + "requirements": ["aioambient==1.1.0"], "dependencies": [], "codeowners": ["@bachya"] } diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index 3f7dc41ffef..e2cc0dd31e2 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -1021,16 +1021,16 @@ class BluesoundPlayer(MediaPlayerDevice): async def async_volume_up(self): """Volume up the media player.""" current_vol = self.volume_level - if not current_vol or current_vol < 0: + if not current_vol or current_vol >= 1: return - return self.async_set_volume_level(((current_vol * 100) + 1) / 100) + return await self.async_set_volume_level(current_vol + 0.01) async def async_volume_down(self): """Volume down the media player.""" current_vol = self.volume_level - if not current_vol or current_vol < 0: + if not current_vol or current_vol <= 0: return - return self.async_set_volume_level(((current_vol * 100) - 1) / 100) + return await self.async_set_volume_level(current_vol - 0.01) async def async_set_volume_level(self, volume): """Send volume_up command to media player.""" diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py index 361367ffb4d..d5bbb60e27d 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -13,11 +13,6 @@ SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( {vol.Required("type"): WS_TYPE_DELETE, vol.Required("user_id"): str} ) -WS_TYPE_CREATE = "config/auth/create" -SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( - {vol.Required("type"): WS_TYPE_CREATE, vol.Required("name"): str} -) - async def async_setup(hass): """Enable the Home Assistant views.""" @@ -27,9 +22,7 @@ async def async_setup(hass): hass.components.websocket_api.async_register_command( WS_TYPE_DELETE, websocket_delete, SCHEMA_WS_DELETE ) - hass.components.websocket_api.async_register_command( - WS_TYPE_CREATE, websocket_create, SCHEMA_WS_CREATE - ) + hass.components.websocket_api.async_register_command(websocket_create) hass.components.websocket_api.async_register_command(websocket_update) return True @@ -70,9 +63,16 @@ async def websocket_delete(hass, connection, msg): @websocket_api.require_admin @websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "config/auth/create", + vol.Required("name"): str, + vol.Optional("group_ids"): [str], + } +) async def websocket_create(hass, connection, msg): """Create a user.""" - user = await hass.auth.async_create_user(msg["name"]) + user = await hass.auth.async_create_user(msg["name"], msg.get("group_ids")) connection.send_message( websocket_api.result_message(msg["id"], {"user": _user_info(user)}) diff --git a/homeassistant/components/cover/.translations/de.json b/homeassistant/components/cover/.translations/de.json index c40a7d074f5..9a9f0be21e2 100644 --- a/homeassistant/components/cover/.translations/de.json +++ b/homeassistant/components/cover/.translations/de.json @@ -2,7 +2,11 @@ "device_automation": { "action_type": { "close": "Schlie\u00dfe {entity_name}", - "open": "\u00d6ffne {entity_name}" + "close_tilt": "{entity_name} gekippt schlie\u00dfen", + "open": "\u00d6ffne {entity_name}", + "open_tilt": "{entity_name} gekippt \u00f6ffnen", + "set_position": "Position von {entity_name} setzen", + "set_tilt_position": "Neigeposition von {entity_name} einstellen" }, "condition_type": { "is_closed": "{entity_name} ist geschlossen", diff --git a/homeassistant/components/directv/.translations/de.json b/homeassistant/components/directv/.translations/de.json index 4fecc58dafb..b6074c732f6 100644 --- a/homeassistant/components/directv/.translations/de.json +++ b/homeassistant/components/directv/.translations/de.json @@ -15,7 +15,8 @@ "one": "eins", "other": "andere" }, - "description": "M\u00f6chten Sie {name} einrichten?" + "description": "M\u00f6chten Sie {name} einrichten?", + "title": "Stellen Sie eine Verbindung zum DirecTV-Empf\u00e4nger her" }, "user": { "data": { diff --git a/homeassistant/components/doorbird/.translations/de.json b/homeassistant/components/doorbird/.translations/de.json index 4582c469cb9..8676359e5ca 100644 --- a/homeassistant/components/doorbird/.translations/de.json +++ b/homeassistant/components/doorbird/.translations/de.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Dieser DoorBird ist bereits konfiguriert" + }, "error": { "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut", "invalid_auth": "Ung\u00fcltige Authentifizierung", @@ -23,7 +26,8 @@ "init": { "data": { "events": "Durch Kommas getrennte Liste von Ereignissen." - } + }, + "description": "F\u00fcgen Sie f\u00fcr jedes Ereignis, das Sie verfolgen m\u00f6chten, einen durch Kommas getrennten Ereignisnamen hinzu. Nachdem Sie sie hier eingegeben haben, verwenden Sie die DoorBird-App, um sie einem bestimmten Ereignis zuzuweisen. Weitere Informationen finden Sie in der Dokumentation unter https://www.home-assistant.io/integrations/doorbird/#events. Beispiel: jemand_hat_den_knopf_gedr\u00fcckt, bewegung" } } } diff --git a/homeassistant/components/doorbird/.translations/it.json b/homeassistant/components/doorbird/.translations/it.json new file mode 100644 index 00000000000..6d1a80424bf --- /dev/null +++ b/homeassistant/components/doorbird/.translations/it.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "already_configured": "Questo DoorBird \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "host": "Host (indirizzo IP)", + "name": "Nome del dispositivo", + "password": "Password", + "username": "Nome utente" + }, + "title": "Connetti a DoorBird" + } + }, + "title": "DoorBird" + }, + "options": { + "step": { + "init": { + "data": { + "events": "Elenco di eventi separati da virgole." + }, + "description": "Aggiungere un nome di evento separato da virgola per ogni evento che si desidera monitorare. Dopo averli inseriti qui, usa l'applicazione DoorBird per assegnarli a un evento specifico. Consultare la documentazione su https://www.home-assistant.io/integrations/doorbird/#events. Esempio: qualcuno_premuto_il_pulsante, movimento" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index dd96191e4fe..d6bc3b1eaa1 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -4,6 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecobee", "dependencies": [], - "requirements": ["python-ecobee-api==0.2.3"], + "requirements": ["python-ecobee-api==0.2.5"], "codeowners": ["@marthoc"] } diff --git a/homeassistant/components/elkm1/.translations/de.json b/homeassistant/components/elkm1/.translations/de.json index 3afcef8c464..40e6cff4460 100644 --- a/homeassistant/components/elkm1/.translations/de.json +++ b/homeassistant/components/elkm1/.translations/de.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "address_already_configured": "Ein ElkM1 mit dieser Adresse ist bereits konfiguriert", + "already_configured": "Ein ElkM1 mit diesem Pr\u00e4fix ist bereits konfiguriert" + }, "error": { "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut", "invalid_auth": "Ung\u00fcltige Authentifizierung", @@ -8,9 +12,14 @@ "step": { "user": { "data": { + "address": "Die IP-Adresse, die Domain oder der serielle Port bei einer seriellen Verbindung.", + "password": "Passwort (Nur sicher).", + "prefix": "Ein eindeutiges Pr\u00e4fix (leer lassen, wenn Sie nur einen ElkM1 haben).", "protocol": "Protokoll", - "temperature_unit": "Die von ElkM1 verwendete Temperatureinheit." + "temperature_unit": "Die von ElkM1 verwendete Temperatureinheit.", + "username": "Benutzername (Nur sicher)." }, + "description": "Die Adresszeichenfolge muss in der Form 'adresse[:port]' f\u00fcr 'sicher' und 'nicht sicher' vorliegen. Beispiel: '192.168.1.1'. Der Port ist optional und standardm\u00e4\u00dfig 2101 f\u00fcr \"nicht sicher\" und 2601 f\u00fcr \"sicher\". F\u00fcr das serielle Protokoll muss die Adresse die Form 'tty[:baud]' haben. Beispiel: '/dev/ttyS1'. Der Baudrate ist optional und standardm\u00e4\u00dfig 115200.", "title": "Stellen Sie eine Verbindung zur Elk-M1-Steuerung her" } }, diff --git a/homeassistant/components/elkm1/.translations/es.json b/homeassistant/components/elkm1/.translations/es.json new file mode 100644 index 00000000000..6602ff3da2e --- /dev/null +++ b/homeassistant/components/elkm1/.translations/es.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "cannot_connect": "No se pudo conectar, por favor, int\u00e9ntalo de nuevo", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "protocol": "Protocolo" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/.translations/it.json b/homeassistant/components/elkm1/.translations/it.json new file mode 100644 index 00000000000..c3f1941d8b5 --- /dev/null +++ b/homeassistant/components/elkm1/.translations/it.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "address_already_configured": "Un ElkM1 con questo indirizzo \u00e8 gi\u00e0 configurato", + "already_configured": "Un ElkM1 con questo prefisso \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "address": "L'indirizzo IP o il dominio o la porta seriale se ci si connette tramite seriale.", + "password": "Password (solo sicura).", + "prefix": "Un prefisso univoco (lasciare vuoto se si dispone di un solo ElkM1).", + "protocol": "Protocollo", + "temperature_unit": "L'unit\u00e0 di temperatura utilizzata da ElkM1.", + "username": "Nome utente (solo sicuro)." + }, + "description": "La stringa di indirizzi deve essere nella forma \"address[:port]\" per \"secure\" e \"non secure\". Esempio: '192.168.1.1.1'. La porta \u00e8 facoltativa e il valore predefinito \u00e8 2101 per 'non sicuro' e 2601 per 'sicuro'. Per il protocollo seriale, l'indirizzo deve essere nella forma 'tty[:baud]'. Esempio: '/dev/ttyS1'. Il baud \u00e8 opzionale e il valore predefinito \u00e8 115200.", + "title": "Collegamento al controllo Elk-M1" + } + }, + "title": "Controllo Elk-M1" + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/.translations/no.json b/homeassistant/components/elkm1/.translations/no.json new file mode 100644 index 00000000000..86a4e67801b --- /dev/null +++ b/homeassistant/components/elkm1/.translations/no.json @@ -0,0 +1,28 @@ +{ + "config": { + "abort": { + "address_already_configured": "En ElkM1 med denne adressen er allerede konfigurert", + "already_configured": "En ElkM1 med dette prefikset er allerede konfigurert" + }, + "error": { + "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "address": "IP-adressen eller domenet eller seriell port hvis du kobler til via seriell.", + "password": "Passord (bare sikkert).", + "prefix": "Et unikt prefiks (la v\u00e6re tomt hvis du bare har en ElkM1).", + "protocol": "protokoll", + "temperature_unit": "Temperaturenheten ElkM1 bruker.", + "username": "Brukernavn (bare sikkert)." + }, + "description": "Adressestrengen m\u00e5 v\u00e6re i formen 'adresse [: port]' for 'sikker' og 'ikke-sikker'. Eksempel: '192.168.1.1'. Porten er valgfri og er standard til 2101 for 'ikke-sikker' og 2601 for 'sikker'. For den serielle protokollen m\u00e5 adressen v\u00e6re i formen 'tty [: baud]'. Eksempel: '/ dev / ttyS1'. Baud er valgfri og er standard til 115200.", + "title": "Koble til Elk-M1-kontroll" + } + }, + "title": "Elk-M1 kontroll" + } +} \ No newline at end of file diff --git a/homeassistant/components/elkm1/.translations/ru.json b/homeassistant/components/elkm1/.translations/ru.json new file mode 100644 index 00000000000..1575b47ed68 --- /dev/null +++ b/homeassistant/components/elkm1/.translations/ru.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "title": "Elk-M1 Control" + } + }, + "title": "Elk-M1 Control" + } +} \ No newline at end of file diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index 8720f67f396..fb031359693 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -11,11 +11,7 @@ from homeassistant.components.climate.const import ( SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.components.modbus import ( - CONF_HUB, - DEFAULT_HUB, - DOMAIN as MODBUS_DOMAIN, -) +from homeassistant.components.modbus.const import CONF_HUB, DEFAULT_HUB, MODBUS_DOMAIN from homeassistant.const import ( ATTR_TEMPERATURE, CONF_NAME, diff --git a/homeassistant/components/fortios/manifest.json b/homeassistant/components/fortios/manifest.json index dd0fdae9619..4073f1bbb36 100644 --- a/homeassistant/components/fortios/manifest.json +++ b/homeassistant/components/fortios/manifest.json @@ -1,6 +1,6 @@ { "domain": "fortios", - "name": "Home Assistant Device Tracker to support FortiOS", + "name": "FortiOS", "documentation": "https://www.home-assistant.io/integrations/fortios/", "requirements": ["fortiosapi==0.10.8"], "dependencies": [], diff --git a/homeassistant/components/freebox/.translations/de.json b/homeassistant/components/freebox/.translations/de.json index 7b8b417634a..72caccf49dc 100644 --- a/homeassistant/components/freebox/.translations/de.json +++ b/homeassistant/components/freebox/.translations/de.json @@ -10,6 +10,7 @@ }, "step": { "link": { + "description": "Klicken Sie auf \"Senden\" und ber\u00fchren Sie dann den Pfeil nach rechts auf dem Router, um Freebox bei Home Assistant zu registrieren. \n\n ![Position der Schaltfl\u00e4che am Router]\n (/static/images/config_freebox.png)", "title": "Link Freebox Router" }, "user": { diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index e5f6c3e2a26..e1ae4bca255 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200318.1" + "home-assistant-frontend==20200330.0" ], "dependencies": [ "api", @@ -20,4 +20,4 @@ "@home-assistant/frontend" ], "quality_scale": "internal" -} +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py index 7d29f5ed3ec..ed0b9f9f714 100644 --- a/homeassistant/components/geonetnz_quakes/geo_location.py +++ b/homeassistant/components/geonetnz_quakes/geo_location.py @@ -96,7 +96,8 @@ class GeonetnzQuakesEvent(GeolocationEvent): self._remove_signal_update() # Remove from entity registry. entity_registry = await async_get_registry(self.hass) - entity_registry.async_remove(self.entity_id) + if self.entity_id in entity_registry.entities: + entity_registry.async_remove(self.entity_id) @callback def _delete_callback(self): diff --git a/homeassistant/components/griddy/.translations/de.json b/homeassistant/components/griddy/.translations/de.json index 44ef7989afe..f2012615267 100644 --- a/homeassistant/components/griddy/.translations/de.json +++ b/homeassistant/components/griddy/.translations/de.json @@ -9,6 +9,10 @@ }, "step": { "user": { + "data": { + "loadzone": "Ladezone (Abwicklungspunkt)" + }, + "description": "Ihre Ladezone befindet sich in Ihrem Griddy-Konto unter \"Konto > Messger\u00e4t > Ladezone\".", "title": "Richten Sie Ihre Griddy Ladezone ein" } }, diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py index 7fcbf519bf3..7540740a737 100644 --- a/homeassistant/components/history/__init__.py +++ b/homeassistant/components/history/__init__.py @@ -39,7 +39,7 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -SIGNIFICANT_DOMAINS = ("thermostat", "climate", "water_heater") +SIGNIFICANT_DOMAINS = ("climate", "device_tracker", "thermostat", "water_heater") IGNORE_DOMAINS = ("zone", "scene") @@ -50,6 +50,7 @@ def get_significant_states( entity_ids=None, filters=None, include_start_time_state=True, + significant_changes_only=True, ): """ Return states changes during UTC period start_time - end_time. @@ -61,13 +62,16 @@ def get_significant_states( timer_start = time.perf_counter() with session_scope(hass=hass) as session: - query = session.query(States).filter( - ( - States.domain.in_(SIGNIFICANT_DOMAINS) - | (States.last_changed == States.last_updated) + if significant_changes_only: + query = session.query(States).filter( + ( + States.domain.in_(SIGNIFICANT_DOMAINS) + | (States.last_changed == States.last_updated) + ) + & (States.last_updated > start_time) ) - & (States.last_updated > start_time) - ) + else: + query = session.query(States).filter(States.last_updated > start_time) if filters: query = filters.apply(query, entity_ids) @@ -327,6 +331,9 @@ class HistoryPeriodView(HomeAssistantView): if entity_ids: entity_ids = entity_ids.lower().split(",") include_start_time_state = "skip_initial_state" not in request.query + significant_changes_only = ( + request.query.get("significant_changes_only", "1") != "0" + ) hass = request.app["hass"] @@ -338,6 +345,7 @@ class HistoryPeriodView(HomeAssistantView): entity_ids, self.filters, include_start_time_state, + significant_changes_only, ) result = list(result.values()) if _LOGGER.isEnabledFor(logging.DEBUG): diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index 82ec296da4b..ac421913f6f 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,10 +1,12 @@ """Constants used be the HomeKit component.""" # #### Misc #### DEBOUNCE_TIMEOUT = 0.5 +DEVICE_PRECISION_LEEWAY = 6 DOMAIN = "homekit" HOMEKIT_FILE = ".homekit.state" HOMEKIT_NOTIFY_ID = 4663548 + # #### Attributes #### ATTR_DISPLAY_NAME = "display_name" ATTR_VALUE = "value" @@ -106,6 +108,7 @@ CHAR_CURRENT_POSITION = "CurrentPosition" CHAR_CURRENT_HUMIDITY = "CurrentRelativeHumidity" CHAR_CURRENT_SECURITY_STATE = "SecuritySystemCurrentState" CHAR_CURRENT_TEMPERATURE = "CurrentTemperature" +CHAR_CURRENT_TILT_ANGLE = "CurrentHorizontalTiltAngle" CHAR_CURRENT_VISIBILITY_STATE = "CurrentVisibilityState" CHAR_FIRMWARE_REVISION = "FirmwareRevision" CHAR_HEATING_THRESHOLD_TEMPERATURE = "HeatingThresholdTemperature" @@ -141,6 +144,7 @@ CHAR_TARGET_HEATING_COOLING = "TargetHeatingCoolingState" CHAR_TARGET_POSITION = "TargetPosition" CHAR_TARGET_SECURITY_STATE = "SecuritySystemTargetState" CHAR_TARGET_TEMPERATURE = "TargetTemperature" +CHAR_TARGET_TILT_ANGLE = "TargetHorizontalTiltAngle" CHAR_TEMP_DISPLAY_UNITS = "TemperatureDisplayUnits" CHAR_VALVE_TYPE = "ValveType" CHAR_VOLUME = "Volume" diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index d77ea22dc96..97940952171 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -5,8 +5,11 @@ from pyhap.const import CATEGORY_GARAGE_DOOR_OPENER, CATEGORY_WINDOW_COVERING from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, + ATTR_TILT_POSITION, DOMAIN, + SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, ) from homeassistant.const import ( @@ -15,6 +18,7 @@ from homeassistant.const import ( SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER, STATE_CLOSED, STATE_CLOSING, @@ -27,9 +31,12 @@ from .accessories import HomeAccessory, debounce from .const import ( CHAR_CURRENT_DOOR_STATE, CHAR_CURRENT_POSITION, + CHAR_CURRENT_TILT_ANGLE, CHAR_POSITION_STATE, CHAR_TARGET_DOOR_STATE, CHAR_TARGET_POSITION, + CHAR_TARGET_TILT_ANGLE, + DEVICE_PRECISION_LEEWAY, SERV_GARAGE_DOOR_OPENER, SERV_WINDOW_COVERING, ) @@ -94,9 +101,28 @@ class WindowCovering(HomeAccessory): def __init__(self, *args): """Initialize a WindowCovering accessory object.""" super().__init__(*args, category=CATEGORY_WINDOW_COVERING) - self._homekit_target = None - serv_cover = self.add_preload_service(SERV_WINDOW_COVERING) + self._homekit_target = None + self._homekit_target_tilt = None + + serv_cover = self.add_preload_service( + SERV_WINDOW_COVERING, + chars=[CHAR_TARGET_TILT_ANGLE, CHAR_CURRENT_TILT_ANGLE], + ) + + features = self.hass.states.get(self.entity_id).attributes.get( + ATTR_SUPPORTED_FEATURES, 0 + ) + + self._supports_tilt = features & SUPPORT_SET_TILT_POSITION + if self._supports_tilt: + self.char_target_tilt = serv_cover.configure_char( + CHAR_TARGET_TILT_ANGLE, setter_callback=self.set_tilt + ) + self.char_current_tilt = serv_cover.configure_char( + CHAR_CURRENT_TILT_ANGLE, value=0 + ) + self.char_current_position = serv_cover.configure_char( CHAR_CURRENT_POSITION, value=0 ) @@ -107,6 +133,20 @@ class WindowCovering(HomeAccessory): CHAR_POSITION_STATE, value=2 ) + @debounce + def set_tilt(self, value): + """Set tilt to value if call came from HomeKit.""" + self._homekit_target_tilt = value + _LOGGER.info("%s: Set tilt to %d", self.entity_id, value) + + # HomeKit sends values between -90 and 90. + # We'll have to normalize to [0,100] + value = round((value + 90) / 180.0 * 100.0) + + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_TILT_POSITION: value} + + self.call_service(DOMAIN, SERVICE_SET_COVER_TILT_POSITION, params, value) + @debounce def move_cover(self, value): """Move cover to value if call came from HomeKit.""" @@ -117,14 +157,20 @@ class WindowCovering(HomeAccessory): self.call_service(DOMAIN, SERVICE_SET_COVER_POSITION, params, value) def update_state(self, new_state): - """Update cover position after state changed.""" + """Update cover position and tilt after state changed.""" current_position = new_state.attributes.get(ATTR_CURRENT_POSITION) if isinstance(current_position, (float, int)): current_position = int(current_position) self.char_current_position.set_value(current_position) + + # We have to assume that the device has worse precision than HomeKit. + # If it reports back a state that is only _close_ to HK's requested + # state, we'll "fix" what HomeKit requested so that it won't appear + # out of sync. if ( self._homekit_target is None - or abs(current_position - self._homekit_target) < 6 + or abs(current_position - self._homekit_target) + < DEVICE_PRECISION_LEEWAY ): self.char_target_position.set_value(current_position) self._homekit_target = None @@ -135,6 +181,25 @@ class WindowCovering(HomeAccessory): else: self.char_position_state.set_value(2) + # update tilt + current_tilt = new_state.attributes.get(ATTR_CURRENT_TILT_POSITION) + if isinstance(current_tilt, (float, int)): + # HomeKit sends values between -90 and 90. + # We'll have to normalize to [0,100] + current_tilt = (current_tilt / 100.0 * 180.0) - 90.0 + current_tilt = int(current_tilt) + self.char_current_tilt.set_value(current_tilt) + + # We have to assume that the device has worse precision than HomeKit. + # If it reports back a state that is only _close_ to HK's requested + # state, we'll "fix" what HomeKit requested so that it won't appear + # out of sync. + if self._homekit_target_tilt is None or abs( + current_tilt - self._homekit_target_tilt < DEVICE_PRECISION_LEEWAY + ): + self.char_target_tilt.set_value(current_tilt) + self._homekit_target_tilt = None + @TYPES.register("WindowCoveringBasic") class WindowCoveringBasic(HomeAccessory): diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 392495e34ea..009dc285150 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[IP]==0.2.35"], + "requirements": ["aiohomekit[IP]==0.2.37"], "dependencies": [], "zeroconf": ["_hap._tcp.local."], "codeowners": ["@Jc2k"] diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py index 8e6ca1f75fe..0407a1a0fe2 100644 --- a/homeassistant/components/homematicip_cloud/device.py +++ b/homeassistant/components/homematicip_cloud/device.py @@ -166,12 +166,19 @@ class HomematicipGenericDevice(Entity): def name(self) -> str: """Return the name of the generic device.""" name = self._device.label - if self._home.name is not None and self._home.name != "": + if name and self._home.name: name = f"{self._home.name} {name}" - if self.post is not None and self.post != "": + if name and self.post: name = f"{name} {self.post}" return name + def _get_label_by_channel(self, channel: int) -> str: + """Return the name of the channel.""" + name = self._device.functionalChannels[channel].label + if name and self._home.name: + name = f"{self._home.name} {name}" + return name + @property def should_poll(self) -> bool: """No polling needed.""" diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index cead186db95..42c18239ac2 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -71,6 +71,14 @@ class HomematicipLight(HomematicipGenericDevice, Light): """Initialize the light device.""" super().__init__(hap, device) + @property + def name(self) -> str: + """Return the name of the multi switch channel.""" + label = self._get_label_by_channel(1) + if label: + return label + return super().name + @property def is_on(self) -> bool: """Return true if device is on.""" @@ -193,6 +201,9 @@ class HomematicipNotificationLight(HomematicipGenericDevice, Light): @property def name(self) -> str: """Return the name of the generic device.""" + label = self._get_label_by_channel(self.channel) + if label: + return label return f"{super().name} Notification" @property diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 45adf54df2b..79f7b9dfa5c 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -153,6 +153,14 @@ class HomematicipMultiSwitch(HomematicipGenericDevice, SwitchDevice): self.channel = channel super().__init__(hap, device, f"Channel{channel}") + @property + def name(self) -> str: + """Return the name of the multi switch channel.""" + label = self._get_label_by_channel(self.channel) + if label: + return label + return super().name + @property def unique_id(self) -> str: """Return a unique ID.""" diff --git a/homeassistant/components/ipp/.translations/en.json b/homeassistant/components/ipp/.translations/en.json new file mode 100644 index 00000000000..df84cbefa29 --- /dev/null +++ b/homeassistant/components/ipp/.translations/en.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "This printer is already configured.", + "connection_error": "Failed to connect to printer.", + "connection_upgrade": "Failed to connect to printer due to connection upgrade being required." + }, + "error": { + "connection_error": "Failed to connect to printer.", + "connection_upgrade": "Failed to connect to printer. Please try again with SSL/TLS option checked." + }, + "flow_title": "Printer: {name}", + "step": { + "user": { + "data": { + "base_path": "Relative path to the printer", + "host": "Host or IP address", + "port": "Port", + "ssl": "Printer supports communication over SSL/TLS", + "verify_ssl": "Printer uses a proper SSL certificate" + }, + "description": "Set up your printer via Internet Printing Protocol (IPP) to integrate with Home Assistant.", + "title": "Link your printer" + }, + "zeroconf_confirm": { + "description": "Do you want to add the printer named `{name}` to Home Assistant?", + "title": "Discovered printer" + } + }, + "title": "Internet Printing Protocol (IPP)" + } +} \ No newline at end of file diff --git a/homeassistant/components/ipp/__init__.py b/homeassistant/components/ipp/__init__.py new file mode 100644 index 00000000000..447665a3676 --- /dev/null +++ b/homeassistant/components/ipp/__init__.py @@ -0,0 +1,190 @@ +"""The Internet Printing Protocol (IPP) integration.""" +import asyncio +from datetime import timedelta +import logging +from typing import Any, Dict + +from pyipp import IPP, IPPError, Printer as IPPPrinter + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_NAME, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_SOFTWARE_VERSION, + CONF_BASE_PATH, + DOMAIN, +) + +PLATFORMS = [SENSOR_DOMAIN] +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: Dict) -> bool: + """Set up the IPP component.""" + hass.data.setdefault(DOMAIN, {}) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up IPP from a config entry.""" + + # Create IPP instance for this entry + coordinator = IPPDataUpdateCoordinator( + hass, + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + base_path=entry.data[CONF_BASE_PATH], + tls=entry.data[CONF_SSL], + verify_ssl=entry.data[CONF_VERIFY_SSL], + ) + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = coordinator + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +class IPPDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching IPP data from single endpoint.""" + + def __init__( + self, + hass: HomeAssistant, + *, + host: str, + port: int, + base_path: str, + tls: bool, + verify_ssl: bool, + ): + """Initialize global IPP data updater.""" + self.ipp = IPP( + host=host, + port=port, + base_path=base_path, + tls=tls, + verify_ssl=verify_ssl, + session=async_get_clientsession(hass, verify_ssl), + ) + + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> IPPPrinter: + """Fetch data from IPP.""" + try: + return await self.ipp.printer() + except IPPError as error: + raise UpdateFailed(f"Invalid response from API: {error}") + + +class IPPEntity(Entity): + """Defines a base IPP entity.""" + + def __init__( + self, + *, + entry_id: str, + coordinator: IPPDataUpdateCoordinator, + name: str, + icon: str, + enabled_default: bool = True, + ) -> None: + """Initialize the IPP entity.""" + self._enabled_default = enabled_default + self._entry_id = entry_id + self._icon = icon + self._name = name + self._unsub_dispatcher = None + self.coordinator = coordinator + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self) -> str: + """Return the mdi icon of the entity.""" + return self._icon + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.coordinator.last_update_success + + @property + def entity_registry_enabled_default(self) -> bool: + """Return if the entity should be enabled when first added to the entity registry.""" + return self._enabled_default + + @property + def should_poll(self) -> bool: + """Return the polling requirement of the entity.""" + return False + + async def async_added_to_hass(self) -> None: + """Connect to dispatcher listening for entity data notifications.""" + self.coordinator.async_add_listener(self.async_write_ha_state) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect from update signal.""" + self.coordinator.async_remove_listener(self.async_write_ha_state) + + async def async_update(self) -> None: + """Update an IPP entity.""" + await self.coordinator.async_request_refresh() + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this IPP device.""" + return { + ATTR_IDENTIFIERS: {(DOMAIN, self.coordinator.data.info.uuid)}, + ATTR_NAME: self.coordinator.data.info.name, + ATTR_MANUFACTURER: self.coordinator.data.info.manufacturer, + ATTR_MODEL: self.coordinator.data.info.model, + ATTR_SOFTWARE_VERSION: self.coordinator.data.info.version, + } diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py new file mode 100644 index 00000000000..395a5f0db58 --- /dev/null +++ b/homeassistant/components/ipp/config_flow.py @@ -0,0 +1,144 @@ +"""Config flow to configure the IPP integration.""" +import logging +from typing import Any, Dict, Optional + +from pyipp import IPP, IPPConnectionError, IPPConnectionUpgradeRequired +import voluptuous as vol + +from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_SSL, + CONF_VERIFY_SSL, +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .const import CONF_BASE_PATH, CONF_UUID +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass: HomeAssistantType, data: dict) -> Dict[str, Any]: + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + session = async_get_clientsession(hass) + ipp = IPP( + host=data[CONF_HOST], + port=data[CONF_PORT], + base_path=data[CONF_BASE_PATH], + tls=data[CONF_SSL], + verify_ssl=data[CONF_VERIFY_SSL], + session=session, + ) + + printer = await ipp.printer() + + return {CONF_UUID: printer.info.uuid} + + +class IPPFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle an IPP config flow.""" + + VERSION = 1 + CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Set up the instance.""" + self.discovery_info = {} + + async def async_step_user( + self, user_input: Optional[ConfigType] = None + ) -> Dict[str, Any]: + """Handle a flow initiated by the user.""" + if user_input is None: + return self._show_setup_form() + + try: + info = await validate_input(self.hass, user_input) + except IPPConnectionUpgradeRequired: + return self._show_setup_form({"base": "connection_upgrade"}) + except IPPConnectionError: + return self._show_setup_form({"base": "connection_error"}) + user_input[CONF_UUID] = info[CONF_UUID] + + await self.async_set_unique_id(user_input[CONF_UUID]) + self._abort_if_unique_id_configured(updates={CONF_HOST: user_input[CONF_HOST]}) + + return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) + + async def async_step_zeroconf(self, discovery_info: ConfigType) -> Dict[str, Any]: + """Handle zeroconf discovery.""" + # Hostname is format: EPSON123456.local. + host = discovery_info["hostname"].rstrip(".") + port = discovery_info["port"] + name, _ = host.rsplit(".") + tls = discovery_info["type"] == "_ipps._tcp.local." + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context.update({"title_placeholders": {"name": name}}) + + self.discovery_info.update( + { + CONF_HOST: host, + CONF_PORT: port, + CONF_SSL: tls, + CONF_VERIFY_SSL: False, + CONF_BASE_PATH: "/" + + discovery_info["properties"].get("rp", "ipp/print"), + CONF_NAME: name, + CONF_UUID: discovery_info["properties"].get("UUID"), + } + ) + + try: + info = await validate_input(self.hass, self.discovery_info) + except IPPConnectionUpgradeRequired: + return self.async_abort(reason="connection_upgrade") + except IPPConnectionError: + return self.async_abort(reason="connection_error") + + self.discovery_info[CONF_UUID] = info[CONF_UUID] + + await self.async_set_unique_id(self.discovery_info[CONF_UUID]) + self._abort_if_unique_id_configured( + updates={CONF_HOST: self.discovery_info[CONF_HOST]} + ) + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: ConfigType = None + ) -> Dict[str, Any]: + """Handle a confirmation flow initiated by zeroconf.""" + if user_input is None: + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={"name": self.discovery_info[CONF_NAME]}, + errors={}, + ) + + return self.async_create_entry( + title=self.discovery_info[CONF_NAME], data=self.discovery_info, + ) + + def _show_setup_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]: + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=631): int, + vol.Required(CONF_BASE_PATH, default="/ipp/print"): str, + vol.Required(CONF_SSL, default=False): bool, + vol.Required(CONF_VERIFY_SSL, default=False): bool, + } + ), + errors=errors or {}, + ) diff --git a/homeassistant/components/ipp/const.py b/homeassistant/components/ipp/const.py new file mode 100644 index 00000000000..7caf60b7edd --- /dev/null +++ b/homeassistant/components/ipp/const.py @@ -0,0 +1,25 @@ +"""Constants for the IPP integration.""" + +# Integration domain +DOMAIN = "ipp" + +# Attributes +ATTR_COMMAND_SET = "command_set" +ATTR_IDENTIFIERS = "identifiers" +ATTR_INFO = "info" +ATTR_LOCATION = "location" +ATTR_MANUFACTURER = "manufacturer" +ATTR_MARKER_TYPE = "marker_type" +ATTR_MARKER_LOW_LEVEL = "marker_low_level" +ATTR_MARKER_HIGH_LEVEL = "marker_high_level" +ATTR_MODEL = "model" +ATTR_SERIAL = "serial" +ATTR_SOFTWARE_VERSION = "sw_version" +ATTR_STATE_MESSAGE = "state_message" +ATTR_STATE_REASON = "state_reason" +ATTR_URI_SUPPORTED = "uri_supported" + +# Config Keys +CONF_BASE_PATH = "base_path" +CONF_TLS = "tls" +CONF_UUID = "uuid" diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json new file mode 100644 index 00000000000..beb6679e308 --- /dev/null +++ b/homeassistant/components/ipp/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ipp", + "name": "Internet Printing Protocol (IPP)", + "documentation": "https://www.home-assistant.io/integrations/ipp", + "requirements": ["pyipp==0.8.1"], + "dependencies": [], + "codeowners": ["@ctalkington"], + "config_flow": true, + "quality_scale": "platinum", + "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] +} diff --git a/homeassistant/components/ipp/sensor.py b/homeassistant/components/ipp/sensor.py new file mode 100644 index 00000000000..1ce162500c5 --- /dev/null +++ b/homeassistant/components/ipp/sensor.py @@ -0,0 +1,178 @@ +"""Support for IPP sensors.""" +from datetime import timedelta +from typing import Any, Callable, Dict, List, Optional, Union + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_CLASS_TIMESTAMP, UNIT_PERCENTAGE +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util.dt import utcnow + +from . import IPPDataUpdateCoordinator, IPPEntity +from .const import ( + ATTR_COMMAND_SET, + ATTR_INFO, + ATTR_LOCATION, + ATTR_MARKER_HIGH_LEVEL, + ATTR_MARKER_LOW_LEVEL, + ATTR_MARKER_TYPE, + ATTR_SERIAL, + ATTR_STATE_MESSAGE, + ATTR_STATE_REASON, + ATTR_URI_SUPPORTED, + DOMAIN, +) + + +async def async_setup_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities: Callable[[List[Entity], bool], None], +) -> None: + """Set up IPP sensor based on a config entry.""" + coordinator: IPPDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + sensors = [] + + sensors.append(IPPPrinterSensor(entry.entry_id, coordinator)) + sensors.append(IPPUptimeSensor(entry.entry_id, coordinator)) + + for marker_index in range(len(coordinator.data.markers)): + sensors.append(IPPMarkerSensor(entry.entry_id, coordinator, marker_index)) + + async_add_entities(sensors, True) + + +class IPPSensor(IPPEntity): + """Defines an IPP sensor.""" + + def __init__( + self, + *, + coordinator: IPPDataUpdateCoordinator, + enabled_default: bool = True, + entry_id: str, + icon: str, + key: str, + name: str, + unit_of_measurement: Optional[str] = None, + ) -> None: + """Initialize IPP sensor.""" + self._unit_of_measurement = unit_of_measurement + self._key = key + + super().__init__( + entry_id=entry_id, + coordinator=coordinator, + name=name, + icon=icon, + enabled_default=enabled_default, + ) + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return f"{self.coordinator.data.info.uuid}_{self._key}" + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + +class IPPMarkerSensor(IPPSensor): + """Defines an IPP marker sensor.""" + + def __init__( + self, entry_id: str, coordinator: IPPDataUpdateCoordinator, marker_index: int + ) -> None: + """Initialize IPP marker sensor.""" + self.marker_index = marker_index + + super().__init__( + coordinator=coordinator, + entry_id=entry_id, + icon="mdi:water", + key=f"marker_{marker_index}", + name=f"{coordinator.data.info.name} {coordinator.data.markers[marker_index].name}", + unit_of_measurement=UNIT_PERCENTAGE, + ) + + @property + def device_state_attributes(self) -> Optional[Dict[str, Any]]: + """Return the state attributes of the entity.""" + return { + ATTR_MARKER_HIGH_LEVEL: self.coordinator.data.markers[ + self.marker_index + ].high_level, + ATTR_MARKER_LOW_LEVEL: self.coordinator.data.markers[ + self.marker_index + ].low_level, + ATTR_MARKER_TYPE: self.coordinator.data.markers[ + self.marker_index + ].marker_type, + } + + @property + def state(self) -> Union[None, str, int, float]: + """Return the state of the sensor.""" + return self.coordinator.data.markers[self.marker_index].level + + +class IPPPrinterSensor(IPPSensor): + """Defines an IPP printer sensor.""" + + def __init__(self, entry_id: str, coordinator: IPPDataUpdateCoordinator) -> None: + """Initialize IPP printer sensor.""" + super().__init__( + coordinator=coordinator, + entry_id=entry_id, + icon="mdi:printer", + key="printer", + name=coordinator.data.info.name, + unit_of_measurement=None, + ) + + @property + def device_state_attributes(self) -> Optional[Dict[str, Any]]: + """Return the state attributes of the entity.""" + return { + ATTR_INFO: self.coordinator.data.info.printer_info, + ATTR_SERIAL: self.coordinator.data.info.serial, + ATTR_LOCATION: self.coordinator.data.info.location, + ATTR_STATE_MESSAGE: self.coordinator.data.state.message, + ATTR_STATE_REASON: self.coordinator.data.state.reasons, + ATTR_COMMAND_SET: self.coordinator.data.info.command_set, + ATTR_URI_SUPPORTED: self.coordinator.data.info.printer_uri_supported, + } + + @property + def state(self) -> Union[None, str, int, float]: + """Return the state of the sensor.""" + return self.coordinator.data.state.printer_state + + +class IPPUptimeSensor(IPPSensor): + """Defines a IPP uptime sensor.""" + + def __init__(self, entry_id: str, coordinator: IPPDataUpdateCoordinator) -> None: + """Initialize IPP uptime sensor.""" + super().__init__( + coordinator=coordinator, + enabled_default=False, + entry_id=entry_id, + icon="mdi:clock-outline", + key="uptime", + name=f"{coordinator.data.info.name} Uptime", + ) + + @property + def state(self) -> Union[None, str, int, float]: + """Return the state of the sensor.""" + uptime = utcnow() - timedelta(seconds=self.coordinator.data.info.uptime) + return uptime.replace(microsecond=0).isoformat() + + @property + def device_class(self) -> Optional[str]: + """Return the class of this sensor.""" + return DEVICE_CLASS_TIMESTAMP diff --git a/homeassistant/components/ipp/strings.json b/homeassistant/components/ipp/strings.json new file mode 100644 index 00000000000..afd82d1f454 --- /dev/null +++ b/homeassistant/components/ipp/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "title": "Internet Printing Protocol (IPP)", + "flow_title": "Printer: {name}", + "step": { + "user": { + "title": "Link your printer", + "description": "Set up your printer via Internet Printing Protocol (IPP) to integrate with Home Assistant.", + "data": { + "host": "Host or IP address", + "port": "Port", + "base_path": "Relative path to the printer", + "ssl": "Printer supports communication over SSL/TLS", + "verify_ssl": "Printer uses a proper SSL certificate" + } + }, + "zeroconf_confirm": { + "description": "Do you want to add the printer named `{name}` to Home Assistant?", + "title": "Discovered printer" + } + }, + "error": { + "connection_error": "Failed to connect to printer.", + "connection_upgrade": "Failed to connect to printer. Please try again with SSL/TLS option checked." + }, + "abort": { + "already_configured": "This printer is already configured.", + "connection_error": "Failed to connect to printer.", + "connection_upgrade": "Failed to connect to printer due to connection upgrade being required." + } + } +} diff --git a/homeassistant/components/kef/manifest.json b/homeassistant/components/kef/manifest.json index 135f8e1cf54..4af0626ace9 100644 --- a/homeassistant/components/kef/manifest.json +++ b/homeassistant/components/kef/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/integrations/kef", "dependencies": [], "codeowners": ["@basnijholt"], - "requirements": ["aiokef==0.2.7", "getmac==0.8.1"] + "requirements": ["aiokef==0.2.9", "getmac==0.8.1"] } diff --git a/homeassistant/components/kef/media_player.py b/homeassistant/components/kef/media_player.py index d4a1d7a4df3..2a227212006 100644 --- a/homeassistant/components/kef/media_player.py +++ b/homeassistant/components/kef/media_player.py @@ -1,11 +1,13 @@ """Platform for the KEF Wireless Speakers.""" +import asyncio from datetime import timedelta from functools import partial import ipaddress import logging from aiokef import AsyncKefSpeaker +from aiokef.aiokef import DSP_OPTION_MAPPING from getmac import get_mac_address import voluptuous as vol @@ -31,7 +33,8 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, entity_platform +from homeassistant.helpers.event import async_track_time_interval _LOGGER = logging.getLogger(__name__) @@ -55,6 +58,17 @@ CONF_INVERSE_SPEAKER_MODE = "inverse_speaker_mode" CONF_SUPPORTS_ON = "supports_on" CONF_STANDBY_TIME = "standby_time" +SERVICE_MODE = "set_mode" +SERVICE_DESK_DB = "set_desk_db" +SERVICE_WALL_DB = "set_wall_db" +SERVICE_TREBLE_DB = "set_treble_db" +SERVICE_HIGH_HZ = "set_high_hz" +SERVICE_LOW_HZ = "set_low_hz" +SERVICE_SUB_DB = "set_sub_db" +SERVICE_UPDATE_DSP = "update_dsp" + +DSP_SCAN_INTERVAL = 3600 + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -118,6 +132,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= inverse_speaker_mode, supports_on, sources, + speaker_type, ioloop=hass.loop, unique_id=unique_id, ) @@ -128,6 +143,36 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= hass.data[DOMAIN][host] = media_player async_add_entities([media_player], update_before_add=True) + platform = entity_platform.current_platform.get() + + platform.async_register_entity_service( + SERVICE_MODE, + { + vol.Optional("desk_mode"): cv.boolean, + vol.Optional("wall_mode"): cv.boolean, + vol.Optional("phase_correction"): cv.boolean, + vol.Optional("high_pass"): cv.boolean, + vol.Optional("sub_polarity"): vol.In(["-", "+"]), + vol.Optional("bass_extension"): vol.In(["Less", "Standard", "Extra"]), + }, + "set_mode", + ) + platform.async_register_entity_service(SERVICE_UPDATE_DSP, {}, "update_dsp") + + def add_service(name, which, option): + platform.async_register_entity_service( + name, + {vol.Required(option): vol.In(DSP_OPTION_MAPPING[which])}, + f"set_{which}", + ) + + add_service(SERVICE_DESK_DB, "desk_db", "db_value") + add_service(SERVICE_WALL_DB, "wall_db", "db_value") + add_service(SERVICE_TREBLE_DB, "treble_db", "db_value") + add_service(SERVICE_HIGH_HZ, "high_hz", "hz_value") + add_service(SERVICE_LOW_HZ, "low_hz", "hz_value") + add_service(SERVICE_SUB_DB, "sub_db", "db_value") + class KefMediaPlayer(MediaPlayerDevice): """Kef Player Object.""" @@ -143,6 +188,7 @@ class KefMediaPlayer(MediaPlayerDevice): inverse_speaker_mode, supports_on, sources, + speaker_type, ioloop, unique_id, ): @@ -160,12 +206,15 @@ class KefMediaPlayer(MediaPlayerDevice): ) self._unique_id = unique_id self._supports_on = supports_on + self._speaker_type = speaker_type self._state = None self._muted = None self._source = None self._volume = None self._is_online = None + self._dsp = None + self._update_dsp_task_remover = None @property def name(self): @@ -190,6 +239,9 @@ class KefMediaPlayer(MediaPlayerDevice): state = await self._speaker.get_state() self._source = state.source self._state = STATE_ON if state.is_on else STATE_OFF + if self._dsp is None: + # Only do this when necessary because it is a slow operation + await self.update_dsp() else: self._muted = None self._source = None @@ -291,11 +343,11 @@ class KefMediaPlayer(MediaPlayerDevice): async def async_media_play(self): """Send play command.""" - await self._speaker.play_pause() + await self._speaker.set_play_pause() async def async_media_pause(self): """Send pause command.""" - await self._speaker.play_pause() + await self._speaker.set_play_pause() async def async_media_previous_track(self): """Send previous track command.""" @@ -304,3 +356,87 @@ class KefMediaPlayer(MediaPlayerDevice): async def async_media_next_track(self): """Send next track command.""" await self._speaker.next_track() + + async def update_dsp(self) -> None: + """Update the DSP settings.""" + if self._speaker_type == "LS50" and self._state == STATE_OFF: + # The LSX is able to respond when off the LS50 has to be on. + return + + (mode, *rest) = await asyncio.gather( + self._speaker.get_mode(), + self._speaker.get_desk_db(), + self._speaker.get_wall_db(), + self._speaker.get_treble_db(), + self._speaker.get_high_hz(), + self._speaker.get_low_hz(), + self._speaker.get_sub_db(), + ) + keys = ["desk_db", "wall_db", "treble_db", "high_hz", "low_hz", "sub_db"] + self._dsp = dict(zip(keys, rest), **mode._asdict()) + + async def async_added_to_hass(self): + """Subscribe to DSP updates.""" + self._update_dsp_task_remover = async_track_time_interval( + self.hass, self.update_dsp, DSP_SCAN_INTERVAL + ) + + async def async_will_remove_from_hass(self): + """Unsubscribe to DSP updates.""" + self._update_dsp_task_remover() + self._update_dsp_task_remover = None + + @property + def device_state_attributes(self): + """Return the DSP settings of the KEF device.""" + return self._dsp or {} + + async def set_mode( + self, + desk_mode=None, + wall_mode=None, + phase_correction=None, + high_pass=None, + sub_polarity=None, + bass_extension=None, + ): + """Set the speaker mode.""" + await self._speaker.set_mode( + desk_mode=desk_mode, + wall_mode=wall_mode, + phase_correction=phase_correction, + high_pass=high_pass, + sub_polarity=sub_polarity, + bass_extension=bass_extension, + ) + self._dsp = None + + async def set_desk_db(self, db_value): + """Set desk_db of the KEF speakers.""" + await self._speaker.set_desk_db(db_value) + self._dsp = None + + async def set_wall_db(self, db_value): + """Set wall_db of the KEF speakers.""" + await self._speaker.set_wall_db(db_value) + self._dsp = None + + async def set_treble_db(self, db_value): + """Set treble_db of the KEF speakers.""" + await self._speaker.set_treble_db(db_value) + self._dsp = None + + async def set_high_hz(self, hz_value): + """Set high_hz of the KEF speakers.""" + await self._speaker.set_high_hz(hz_value) + self._dsp = None + + async def set_low_hz(self, hz_value): + """Set low_hz of the KEF speakers.""" + await self._speaker.set_low_hz(hz_value) + self._dsp = None + + async def set_sub_db(self, db_value): + """Set sub_db of the KEF speakers.""" + await self._speaker.set_sub_db(db_value) + self._dsp = None diff --git a/homeassistant/components/kef/services.yaml b/homeassistant/components/kef/services.yaml new file mode 100644 index 00000000000..2226d3b6c2d --- /dev/null +++ b/homeassistant/components/kef/services.yaml @@ -0,0 +1,97 @@ +update_dsp: + description: Update all DSP settings. + fields: + entity_id: + description: The entity_id of the KEF speaker. + example: media_player.kef_lsx + +set_mode: + description: Set the mode of the speaker. + fields: + entity_id: + description: The entity_id of the KEF speaker. + example: media_player.kef_lsx + desk_mode: + description: > + "Desk mode" (true or false) + example: true + wall_mode: + description: > + "Wall mode" (true or false) + example: true + phase_correction: + description: > + "Phase correction" (true or false) + example: true + high_pass: + description: > + "High-pass mode" (true or false) + example: true + sub_polarity: + description: > + "Sub polarity" ("-" or "+") + example: "+" + bass_extension: + description: > + "Bass extension" selector ("Less", "Standard", or "Extra") + example: "Extra" + +set_desk_db: + description: Set the "Desk mode" slider of the speaker in dB. + fields: + entity_id: + description: The entity_id of the KEF speaker. + example: media_player.kef_lsx + db_value: + description: Value of the slider (-6 to 0 with steps of 0.5) + example: 0.0 + +set_wall_db: + description: Set the "Wall mode" slider of the speaker in dB. + fields: + entity_id: + description: The entity_id of the KEF speaker. + example: media_player.kef_lsx + db_value: + description: Value of the slider (-6 to 0 with steps of 0.5) + example: 0.0 + +set_treble_db: + description: Set desk the "Treble trim" slider of the speaker in dB. + fields: + entity_id: + description: The entity_id of the KEF speaker. + example: media_player.kef_lsx + db_value: + description: Value of the slider (-2 to 2 with steps of 0.5) + example: 0.0 + +set_high_hz: + description: Set the "High-pass mode" slider of the speaker in Hz. + fields: + entity_id: + description: The entity_id of the KEF speaker. + example: media_player.kef_lsx + hz_value: + description: Value of the slider (50 to 120 with steps of 5) + example: 95 + +set_low_hz: + description: Set the "Sub out low-pass frequency" slider of the speaker in Hz. + fields: + entity_id: + description: The entity_id of the KEF speaker. + example: media_player.kef_lsx + hz_value: + description: Value of the slider (40 to 250 with steps of 5) + example: 80 + +set_sub_db: + description: Set the "Sub gain" slider of the speaker in dB. + fields: + entity_id: + description: The entity_id of the KEF speaker. + example: media_player.kef_lsx + db_value: + description: Value of the slider (-10 to 10 with steps of 1) + example: 0 diff --git a/homeassistant/components/konnected/.translations/de.json b/homeassistant/components/konnected/.translations/de.json index ab29e9d1f08..ffd8f3219fe 100644 --- a/homeassistant/components/konnected/.translations/de.json +++ b/homeassistant/components/konnected/.translations/de.json @@ -91,11 +91,12 @@ "data": { "activation": "Ausgabe, wenn eingeschaltet", "momentary": "Impulsdauer (ms) (optional)", + "more_states": "Konfigurieren Sie zus\u00e4tzliche Zust\u00e4nde f\u00fcr diese Zone", "name": "Name (optional)", "pause": "Pause zwischen Impulsen (ms) (optional)", "repeat": "Zeit zum Wiederholen (-1 = unendlich) (optional)" }, - "description": "Bitte w\u00e4hlen Sie die Ausgabeoptionen f\u00fcr {zone}" + "description": "Bitte w\u00e4hlen Sie die Ausgabeoptionen f\u00fcr {zone} : Status {state}" } }, "title": "Konnected Alarm Panel-Optionen" diff --git a/homeassistant/components/konnected/.translations/en.json b/homeassistant/components/konnected/.translations/en.json index fd0a8e84e37..ae41b64ad98 100644 --- a/homeassistant/components/konnected/.translations/en.json +++ b/homeassistant/components/konnected/.translations/en.json @@ -91,11 +91,12 @@ "data": { "activation": "Output when on", "momentary": "Pulse duration (ms) (optional)", + "more_states": "Configure additional states for this zone", "name": "Name (optional)", "pause": "Pause between pulses (ms) (optional)", "repeat": "Times to repeat (-1=infinite) (optional)" }, - "description": "Please select the output options for {zone}", + "description": "Please select the output options for {zone}: state {state}", "title": "Configure Switchable Output" } }, diff --git a/homeassistant/components/konnected/.translations/no.json b/homeassistant/components/konnected/.translations/no.json index 9e3b3bdb7c9..71c0fa1de6e 100644 --- a/homeassistant/components/konnected/.translations/no.json +++ b/homeassistant/components/konnected/.translations/no.json @@ -91,11 +91,12 @@ "data": { "activation": "Utgang n\u00e5r den er p\u00e5", "momentary": "Pulsvarighet (ms) (valgfritt)", + "more_states": "Konfigurere flere tilstander for denne sonen", "name": "Navn (valgfritt)", "pause": "Pause mellom pulser (ms) (valgfritt)", "repeat": "Tider \u00e5 gjenta (-1 = uendelig) (valgfritt)" }, - "description": "Velg outputalternativer for {zone}", + "description": "Velg outputalternativer for {zone} : state {state}", "title": "Konfigurere Valgbare Utgang" } }, diff --git a/homeassistant/components/konnected/.translations/zh-Hant.json b/homeassistant/components/konnected/.translations/zh-Hant.json index 4c1bec691db..f3aa89fe877 100644 --- a/homeassistant/components/konnected/.translations/zh-Hant.json +++ b/homeassistant/components/konnected/.translations/zh-Hant.json @@ -91,6 +91,7 @@ "data": { "activation": "\u958b\u555f\u6642\u8f38\u51fa", "momentary": "\u6301\u7e8c\u6642\u9593\uff08ms\uff09\uff08\u9078\u9805\uff09", + "more_states": "\u8a2d\u5b9a\u6b64\u5340\u57df\u7684\u9644\u52a0\u72c0\u614b", "name": "\u540d\u7a31\uff08\u9078\u9805\uff09", "pause": "\u66ab\u505c\u9593\u8ddd\uff08ms\uff09\uff08\u9078\u9805\uff09", "repeat": "\u91cd\u8907\u6642\u9593\uff08-1=\u7121\u9650\uff09\uff08\u9078\u9805\uff09" diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py index cb9004c9efe..172f60cd42d 100644 --- a/homeassistant/components/konnected/config_flow.py +++ b/homeassistant/components/konnected/config_flow.py @@ -57,6 +57,10 @@ CONF_IO_BIN = "Binary Sensor" CONF_IO_DIG = "Digital Sensor" CONF_IO_SWI = "Switchable Output" +CONF_MORE_STATES = "more_states" +CONF_YES = "Yes" +CONF_NO = "No" + KONN_MANUFACTURER = "konnected.io" KONN_PANEL_MODEL_NAMES = { KONN_MODEL: "Konnected Alarm Panel", @@ -117,7 +121,7 @@ SWITCH_SCHEMA = vol.Schema( vol.Required(CONF_ZONE): vol.In(ZONES), vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): vol.All( - vol.Lower, vol.Any(STATE_HIGH, STATE_LOW) + vol.Lower, vol.In([STATE_HIGH, STATE_LOW]) ), vol.Optional(CONF_MOMENTARY): vol.All(vol.Coerce(int), vol.Range(min=10)), vol.Optional(CONF_PAUSE): vol.All(vol.Coerce(int), vol.Range(min=10)), @@ -361,6 +365,8 @@ class OptionsFlowHandler(config_entries.OptionsFlow): self.new_opt = {CONF_IO: {}} self.active_cfg = None self.io_cfg = {} + self.current_states = [] + self.current_state = 1 @callback def get_current_cfg(self, io_type, zone): @@ -666,12 +672,21 @@ class OptionsFlowHandler(config_entries.OptionsFlow): if user_input is not None: zone = {"zone": self.active_cfg} zone.update(user_input) + del zone[CONF_MORE_STATES] self.new_opt[CONF_SWITCHES] = self.new_opt.get(CONF_SWITCHES, []) + [zone] - self.io_cfg.pop(self.active_cfg) - self.active_cfg = None + + # iterate through multiple switch states + if self.current_states: + self.current_states.pop(0) + + # only go to next zone if all states are entered + self.current_state += 1 + if user_input[CONF_MORE_STATES] == CONF_NO: + self.io_cfg.pop(self.active_cfg) + self.active_cfg = None if self.active_cfg: - current_cfg = self.get_current_cfg(CONF_SWITCHES, self.active_cfg) + current_cfg = next(iter(self.current_states), {}) return self.async_show_form( step_id="options_switch", data_schema=vol.Schema( @@ -682,7 +697,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): vol.Optional( CONF_ACTIVATION, default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH), - ): vol.All(vol.Lower, vol.Any(STATE_HIGH, STATE_LOW)), + ): vol.All(vol.Lower, vol.In([STATE_HIGH, STATE_LOW])), vol.Optional( CONF_MOMENTARY, default=current_cfg.get(CONF_MOMENTARY, vol.UNDEFINED), @@ -695,12 +710,19 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_REPEAT, default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED), ): vol.All(vol.Coerce(int), vol.Range(min=-1)), + vol.Required( + CONF_MORE_STATES, + default=CONF_YES + if len(self.current_states) > 1 + else CONF_NO, + ): vol.In([CONF_YES, CONF_NO]), } ), description_placeholders={ "zone": f"Zone {self.active_cfg}" if len(self.active_cfg) < 3 - else self.active_cfg.upper() + else self.active_cfg.upper(), + "state": str(self.current_state), }, errors=errors, ) @@ -709,7 +731,13 @@ class OptionsFlowHandler(config_entries.OptionsFlow): for key, value in self.io_cfg.items(): if value == CONF_IO_SWI: self.active_cfg = key - current_cfg = self.get_current_cfg(CONF_SWITCHES, self.active_cfg) + self.current_states = [ + cfg + for cfg in self.current_opt.get(CONF_SWITCHES, []) + if cfg[CONF_ZONE] == self.active_cfg + ] + current_cfg = next(iter(self.current_states), {}) + self.current_state = 1 return self.async_show_form( step_id="options_switch", data_schema=vol.Schema( @@ -720,7 +748,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): ): str, vol.Optional( CONF_ACTIVATION, - default=current_cfg.get(CONF_ACTIVATION, "high"), + default=current_cfg.get(CONF_ACTIVATION, STATE_HIGH), ): vol.In(["low", "high"]), vol.Optional( CONF_MOMENTARY, @@ -734,12 +762,19 @@ class OptionsFlowHandler(config_entries.OptionsFlow): CONF_REPEAT, default=current_cfg.get(CONF_REPEAT, vol.UNDEFINED), ): vol.All(vol.Coerce(int), vol.Range(min=-1)), + vol.Required( + CONF_MORE_STATES, + default=CONF_YES + if len(self.current_states) > 1 + else CONF_NO, + ): vol.In([CONF_YES, CONF_NO]), } ), description_placeholders={ "zone": f"Zone {self.active_cfg}" if len(self.active_cfg) < 3 - else self.active_cfg.upper() + else self.active_cfg.upper(), + "state": str(self.current_state), }, errors=errors, ) diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json index 4d923238df4..f1d7ef43ddc 100644 --- a/homeassistant/components/konnected/strings.json +++ b/homeassistant/components/konnected/strings.json @@ -80,13 +80,14 @@ }, "options_switch": { "title": "Configure Switchable Output", - "description": "Please select the output options for {zone}", + "description": "Please select the output options for {zone}: state {state}", "data": { "name": "Name (optional)", "activation": "Output when on", "momentary": "Pulse duration (ms) (optional)", "pause": "Pause between pulses (ms) (optional)", - "repeat": "Times to repeat (-1=infinite) (optional)" + "repeat": "Times to repeat (-1=infinite) (optional)", + "more_states": "Configure additional states for this zone" } }, "options_misc": { diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index a4e67fda929..45a042c1f2e 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -72,6 +72,7 @@ async def websocket_lovelace_config(hass, connection, msg, config): return await config.async_load(msg["force"]) +@websocket_api.require_admin @websocket_api.async_response @websocket_api.websocket_command( { @@ -86,6 +87,7 @@ async def websocket_lovelace_save_config(hass, connection, msg, config): await config.async_save(msg["config"]) +@websocket_api.require_admin @websocket_api.async_response @websocket_api.websocket_command( { diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index b7647f5d97b..2edbf980f36 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -83,9 +83,14 @@ SENSOR_TYPES = { } CONDITION_CLASSES = { - "clear-night": ["Nuit Claire"], + "clear-night": ["Nuit Claire", "Nuit claire"], "cloudy": ["Très nuageux"], - "fog": ["Brume ou bancs de brouillard", "Brouillard", "Brouillard givrant"], + "fog": [ + "Brume ou bancs de brouillard", + "Brume", + "Brouillard", + "Brouillard givrant", + ], "hail": ["Risque de grêle"], "lightning": ["Risque d'orages", "Orages"], "lightning-rainy": ["Pluie orageuses", "Pluies orageuses", "Averses orageuses"], diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 9b055155306..869d9f7ac67 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -25,7 +25,7 @@ from homeassistant.const import ( ) import homeassistant.helpers.config_validation as cv -from .const import ( # DEFAULT_HUB, +from .const import ( ATTR_ADDRESS, ATTR_HUB, ATTR_UNIT, @@ -34,16 +34,12 @@ from .const import ( # DEFAULT_HUB, CONF_BYTESIZE, CONF_PARITY, CONF_STOPBITS, + DEFAULT_HUB, MODBUS_DOMAIN, SERVICE_WRITE_COIL, SERVICE_WRITE_REGISTER, ) -# Kept for compatibility with other integrations, TO BE REMOVED -CONF_HUB = "hub" -DEFAULT_HUB = "default" -DOMAIN = MODBUS_DOMAIN - _LOGGER = logging.getLogger(__name__) BASE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string}) diff --git a/homeassistant/components/monoprice/.translations/de.json b/homeassistant/components/monoprice/.translations/de.json index 176b1f5c1ac..ea2b8cdc6c4 100644 --- a/homeassistant/components/monoprice/.translations/de.json +++ b/homeassistant/components/monoprice/.translations/de.json @@ -1,11 +1,16 @@ { "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, "error": { + "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut", "unknown": "Unerwarteter Fehler" }, "step": { "user": { "data": { + "port": "Serielle Schnittstelle", "source_1": "Name der Quelle #1", "source_2": "Name der Quelle #2", "source_3": "Name der Quelle #3", @@ -15,7 +20,8 @@ }, "title": "Stellen Sie eine Verbindung zum Ger\u00e4t her" } - } + }, + "title": "Monoprice 6-Zonen-Verst\u00e4rker" }, "options": { "step": { diff --git a/homeassistant/components/monoprice/.translations/it.json b/homeassistant/components/monoprice/.translations/it.json new file mode 100644 index 00000000000..c3c8770d2ad --- /dev/null +++ b/homeassistant/components/monoprice/.translations/it.json @@ -0,0 +1,41 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "port": "Porta seriale", + "source_1": "Nome della fonte n. 1", + "source_2": "Nome della fonte n. 2", + "source_3": "Nome della fonte n. 3", + "source_4": "Nome della fonte n. 4", + "source_5": "Nome della fonte n. 5", + "source_6": "Nome della fonte n. 6" + }, + "title": "Connettersi al dispositivo" + } + }, + "title": "Amplificatore a 6 zone Monoprice" + }, + "options": { + "step": { + "init": { + "data": { + "source_1": "Nome della fonte n. 1", + "source_2": "Nome della fonte n. 2", + "source_3": "Nome della fonte n. 3", + "source_4": "Nome della fonte n. 4", + "source_5": "Nome della fonte n. 5", + "source_6": "Nome della fonte n. 6" + }, + "title": "Configurare le sorgenti" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 90dd21ae307..ee14fe432b5 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -56,7 +56,7 @@ from .const import ( DEFAULT_QOS, PROTOCOL_311, ) -from .discovery import MQTT_DISCOVERY_UPDATED, clear_discovery_hash +from .discovery import MQTT_DISCOVERY_UPDATED, clear_discovery_hash, set_discovery_hash from .models import Message, MessageCallbackType, PublishPayloadType from .subscription import async_subscribe_topics, async_unsubscribe_topics @@ -1181,10 +1181,12 @@ class MqttDiscoveryUpdate(Entity): self._discovery_data = discovery_data self._discovery_update = discovery_update self._remove_signal = None + self._removed_from_hass = False async def async_added_to_hass(self) -> None: """Subscribe to discovery updates.""" await super().async_added_to_hass() + self._removed_from_hass = False discovery_hash = ( self._discovery_data[ATTR_DISCOVERY_HASH] if self._discovery_data else None ) @@ -1217,6 +1219,8 @@ class MqttDiscoveryUpdate(Entity): await self._discovery_update(payload) if discovery_hash: + # Set in case the entity has been removed and is re-added + set_discovery_hash(self.hass, discovery_hash) self._remove_signal = async_dispatcher_connect( self.hass, MQTT_DISCOVERY_UPDATED.format(discovery_hash), @@ -1225,7 +1229,7 @@ class MqttDiscoveryUpdate(Entity): async def async_removed_from_registry(self) -> None: """Clear retained discovery topic in broker.""" - if self._discovery_data: + if not self._removed_from_hass: discovery_topic = self._discovery_data[ATTR_DISCOVERY_TOPIC] publish( self.hass, discovery_topic, "", retain=True, @@ -1237,9 +1241,9 @@ class MqttDiscoveryUpdate(Entity): def _cleanup_on_remove(self) -> None: """Stop listening to signal and cleanup discovery data.""" - if self._discovery_data: + if self._discovery_data and not self._removed_from_hass: clear_discovery_hash(self.hass, self._discovery_data[ATTR_DISCOVERY_HASH]) - self._discovery_data = None + self._removed_from_hass = True if self._remove_signal: self._remove_signal() diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index ac20ba7a4a8..3bcd8594ebe 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -64,6 +64,11 @@ def clear_discovery_hash(hass, discovery_hash): del hass.data[ALREADY_DISCOVERED][discovery_hash] +def set_discovery_hash(hass, discovery_hash): + """Clear entry in ALREADY_DISCOVERED list.""" + hass.data[ALREADY_DISCOVERED][discovery_hash] = {} + + class MQTTConfig(dict): """Dummy class to allow adding attributes.""" diff --git a/homeassistant/components/myq/.translations/it.json b/homeassistant/components/myq/.translations/it.json new file mode 100644 index 00000000000..4f495e670f1 --- /dev/null +++ b/homeassistant/components/myq/.translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "MyQ \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "title": "Connettersi al gateway MyQ" + } + }, + "title": "MyQ" + } +} \ No newline at end of file diff --git a/homeassistant/components/nexia/.translations/de.json b/homeassistant/components/nexia/.translations/de.json index 123cfa26a67..bda92cc7fe3 100644 --- a/homeassistant/components/nexia/.translations/de.json +++ b/homeassistant/components/nexia/.translations/de.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Dieses Nexia Home ist bereits konfiguriert" + }, "error": { "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut", "invalid_auth": "Ung\u00fcltige Authentifizierung", diff --git a/homeassistant/components/nexia/.translations/it.json b/homeassistant/components/nexia/.translations/it.json new file mode 100644 index 00000000000..5fdd9a6095e --- /dev/null +++ b/homeassistant/components/nexia/.translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Questo Nexia Home \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente" + }, + "title": "Connettersi a mynexia.com" + } + }, + "title": "Nexia" + } +} \ No newline at end of file diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py index fba84c936f5..57b9bdb61fa 100644 --- a/homeassistant/components/nissan_leaf/__init__.py +++ b/homeassistant/components/nissan_leaf/__init__.py @@ -380,7 +380,10 @@ class LeafDataStore: ) return server_info except CarwingsError: - _LOGGER.error("An error occurred getting battery status.") + _LOGGER.error("An error occurred getting battery status") + return None + except KeyError: + _LOGGER.error("An error occurred parsing response from server") return None async def async_get_climate(self): diff --git a/homeassistant/components/nuheat/.translations/de.json b/homeassistant/components/nuheat/.translations/de.json index 358b5a76254..adbc63b8157 100644 --- a/homeassistant/components/nuheat/.translations/de.json +++ b/homeassistant/components/nuheat/.translations/de.json @@ -15,7 +15,9 @@ "password": "Passwort", "serial_number": "Seriennummer des Thermostats.", "username": "Benutzername" - } + }, + "description": "Sie m\u00fcssen die numerische Seriennummer oder ID Ihres Thermostats erhalten, indem Sie sich bei https://MyNuHeat.com anmelden und Ihre Thermostate ausw\u00e4hlen.", + "title": "Stellen Sie eine Verbindung zu NuHeat her" } }, "title": "NuHeat" diff --git a/homeassistant/components/nuheat/.translations/it.json b/homeassistant/components/nuheat/.translations/it.json new file mode 100644 index 00000000000..a98f24a9651 --- /dev/null +++ b/homeassistant/components/nuheat/.translations/it.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_configured": "Il termostato \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare", + "invalid_auth": "Autenticazione non valida", + "invalid_thermostat": "Il numero di serie del termostato non \u00e8 valido.", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "password": "Password", + "serial_number": "Numero di serie del termostato.", + "username": "Nome utente" + }, + "description": "\u00c8 necessario ottenere il numero di serie o l'ID numerico del termostato accedendo a https://MyNuHeat.com e selezionando il termostato.", + "title": "Connettersi al NuHeat" + } + }, + "title": "NuHeat" + } +} \ No newline at end of file diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index f97f7212e10..1b602954414 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -220,6 +220,8 @@ class NUTSensor(Entity): self._name = "{} {}".format(name, SENSOR_TYPES[sensor_type][0]) self._unit = SENSOR_TYPES[sensor_type][1] self._state = None + self._display_state = None + self._available = False @property def name(self): @@ -241,38 +243,44 @@ class NUTSensor(Entity): """Return the unit of measurement of this entity, if any.""" return self._unit + @property + def available(self): + """Return if the device is polling successfully.""" + return self._available + @property def device_state_attributes(self): """Return the sensor attributes.""" - attr = dict() - attr[ATTR_STATE] = self.display_state() - return attr - - def display_state(self): - """Return UPS display state.""" - if self._data.status is None: - return STATE_TYPES["OFF"] - try: - return " ".join( - STATE_TYPES[state] for state in self._data.status[KEY_STATUS].split() - ) - except KeyError: - return STATE_UNKNOWN + return {ATTR_STATE: self._display_state} def update(self): """Get the latest status and use it to update our sensor state.""" - if self._data.status is None: - self._state = None + status = self._data.status + + if status is None: + self._available = False return + self._available = True + self._display_state = _format_display_state(status) # In case of the display status sensor, keep a human-readable form # as the sensor state. if self.type == KEY_STATUS_DISPLAY: - self._state = self.display_state() - elif self.type not in self._data.status: + self._state = self._display_state + elif self.type not in status: self._state = None else: - self._state = self._data.status[self.type] + self._state = status[self.type] + + +def _format_display_state(status): + """Return UPS display state.""" + if status is None: + return STATE_TYPES["OFF"] + try: + return " ".join(STATE_TYPES[state] for state in status[KEY_STATUS].split()) + except KeyError: + return STATE_UNKNOWN class PyNUTData: diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 8eac430ac49..fa859861fb7 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -3,6 +3,7 @@ import asyncio import voluptuous as vol +from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import callback @@ -99,7 +100,7 @@ class UserOnboardingView(_BaseOnboardingView): provider = _async_get_hass_provider(hass) await provider.async_initialize() - user = await hass.auth.async_create_user(data["name"]) + user = await hass.auth.async_create_user(data["name"], [GROUP_ID_ADMIN]) await hass.async_add_executor_job( provider.data.add_auth, data["username"], data["password"] ) diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json index 40ab3a8a7ed..0ba1ad6c9e3 100644 --- a/homeassistant/components/opencv/manifest.json +++ b/homeassistant/components/opencv/manifest.json @@ -2,7 +2,10 @@ "domain": "opencv", "name": "OpenCV", "documentation": "https://www.home-assistant.io/integrations/opencv", - "requirements": ["numpy==1.18.1", "opencv-python-headless==4.1.2.30"], + "requirements": [ + "numpy==1.18.1", + "opencv-python-headless==4.2.0.32" + ], "dependencies": [], "codeowners": [] -} +} \ No newline at end of file diff --git a/homeassistant/components/pvpc_hourly_pricing/.translations/de.json b/homeassistant/components/pvpc_hourly_pricing/.translations/de.json index f8bf787b685..2e80e3da6e6 100644 --- a/homeassistant/components/pvpc_hourly_pricing/.translations/de.json +++ b/homeassistant/components/pvpc_hourly_pricing/.translations/de.json @@ -1,11 +1,15 @@ { "config": { + "abort": { + "already_configured": "Die Integration ist bereits mit einem vorhandenen Sensor mit diesem Tarif konfiguriert" + }, "step": { "user": { "data": { "name": "Sensorname", "tariff": "Vertragstarif (1, 2 oder 3 Perioden)" }, + "description": "Dieser Sensor verwendet die offizielle API, um [st\u00fcndliche Strompreise (PVPC)] (https://www.esios.ree.es/es/pvpc) in Spanien zu erhalten. \nWeitere Informationen finden Sie in den [Integrations-Dokumentation] (https://www.home-assistant.io/integrations/pvpc_hourly_pricing/). \n\nW\u00e4hlen Sie den vertraglich vereinbarten Tarif basierend auf der Anzahl der Abrechnungsperioden pro Tag aus: \n - 1 Periode: Normal \n - 2 Perioden: Diskriminierung (Nachttarif) \n - 3 Perioden: Elektroauto (Nachttarif von 3 Perioden)", "title": "Tarifauswahl" } }, diff --git a/homeassistant/components/pvpc_hourly_pricing/.translations/it.json b/homeassistant/components/pvpc_hourly_pricing/.translations/it.json new file mode 100644 index 00000000000..5e0c6acef50 --- /dev/null +++ b/homeassistant/components/pvpc_hourly_pricing/.translations/it.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "already_configured": "L'integrazione \u00e8 gi\u00e0 configurata con un sensore esistente con quella tariffa" + }, + "step": { + "user": { + "data": { + "name": "Nome del sensore", + "tariff": "Tariffa contrattuale (1, 2 o 3 periodi)" + }, + "description": "Questo sensore utilizza l'API ufficiale per ottenere [prezzi orari dell'elettricit\u00e0 (PVPC)](https://www.esios.ree.es/es/pvpc) in Spagna.\nPer una spiegazione pi\u00f9 precisa, visitare la [documentazione di integrazione](https://www.home-assistant.io/integrations/pvpc_hourly_pricing/).\n\nSelezionare la tariffa contrattuale in base al numero di periodi di fatturazione al giorno:\n- 1 periodo: normale\n- 2 periodi: discriminazione (tariffa notturna)\n- 3 periodi: auto elettrica (tariffa notturna di 3 periodi)", + "title": "Selezione della tariffa" + } + }, + "title": "Prezzo orario dell'elettricit\u00e0 in Spagna (PVPC)" + } +} \ No newline at end of file diff --git a/homeassistant/components/rachio/.translations/de.json b/homeassistant/components/rachio/.translations/de.json index 79c15a43dc4..05bf5fbe4dd 100644 --- a/homeassistant/components/rachio/.translations/de.json +++ b/homeassistant/components/rachio/.translations/de.json @@ -13,9 +13,19 @@ "data": { "api_key": "Der API-Schl\u00fcssel f\u00fcr das Rachio-Konto." }, + "description": "Sie ben\u00f6tigen den API-Schl\u00fcssel von https://app.rach.io/. W\u00e4hlen Sie \"Kontoeinstellungen\" und klicken Sie dann auf \"API-SCHL\u00dcSSEL ERHALTEN\".", "title": "Stellen Sie eine Verbindung zu Ihrem Rachio-Ger\u00e4t her" } }, "title": "Rachio" + }, + "options": { + "step": { + "init": { + "data": { + "manual_run_mins": "Wie lange, in Minuten, um eine Station einzuschalten, wenn der Schalter aktiviert ist." + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json index 72e2b0267d2..86785868170 100644 --- a/homeassistant/components/slack/manifest.json +++ b/homeassistant/components/slack/manifest.json @@ -2,7 +2,7 @@ "domain": "slack", "name": "Slack", "documentation": "https://www.home-assistant.io/integrations/slack", - "requirements": ["slacker==0.14.0"], + "requirements": ["slackclient==2.5.0"], "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 20daa261b8f..fe6f7ab0d26 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -1,10 +1,11 @@ """Slack platform for notify component.""" +import asyncio import logging +import os +from urllib.parse import urlparse -import requests -from requests.auth import HTTPBasicAuth, HTTPDigestAuth -import slacker -from slacker import Slacker +from slack import WebClient +from slack.errors import SlackApiError import voluptuous as vol from homeassistant.components.notify import ( @@ -15,157 +16,138 @@ from homeassistant.components.notify import ( BaseNotificationService, ) from homeassistant.const import CONF_API_KEY, CONF_ICON, CONF_USERNAME -import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_CHANNEL = "default_channel" -CONF_TIMEOUT = 15 - -# Top level attributes in 'data' ATTR_ATTACHMENTS = "attachments" +ATTR_BLOCKS = "blocks" ATTR_FILE = "file" -# Attributes contained in file -ATTR_FILE_URL = "url" -ATTR_FILE_PATH = "path" -ATTR_FILE_USERNAME = "username" -ATTR_FILE_PASSWORD = "password" -ATTR_FILE_AUTH = "auth" -# Any other value or absence of 'auth' lead to basic authentication being used -ATTR_FILE_AUTH_DIGEST = "digest" + +CONF_DEFAULT_CHANNEL = "default_channel" + +DEFAULT_TIMEOUT_SECONDS = 15 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_CHANNEL): cv.string, + vol.Required(CONF_DEFAULT_CHANNEL): cv.string, vol.Optional(CONF_ICON): cv.string, vol.Optional(CONF_USERNAME): cv.string, } ) -def get_service(hass, config, discovery_info=None): - """Get the Slack notification service.""" - - channel = config.get(CONF_CHANNEL) - api_key = config.get(CONF_API_KEY) - username = config.get(CONF_USERNAME) - icon = config.get(CONF_ICON) +async def async_get_service(hass, config, discovery_info=None): + """Set up the Slack notification service.""" + session = aiohttp_client.async_get_clientsession(hass) + client = WebClient(token=config[CONF_API_KEY], run_async=True, session=session) try: - return SlackNotificationService( - channel, api_key, username, icon, hass.config.is_allowed_path - ) + await client.auth_test() + except SlackApiError as err: + _LOGGER.error("Error while setting up integration: %s", err) + return - except slacker.Error: - _LOGGER.exception("Authentication failed") - return None + return SlackNotificationService( + hass, + client, + config[CONF_DEFAULT_CHANNEL], + username=config.get(CONF_USERNAME), + icon=config.get(CONF_ICON), + ) + + +@callback +def _async_sanitize_channel_names(channel_list): + """Remove any # symbols from a channel list.""" + return [channel.lstrip("#") for channel in channel_list] class SlackNotificationService(BaseNotificationService): - """Implement the notification service for Slack.""" - - def __init__(self, default_channel, api_token, username, icon, is_allowed_path): - """Initialize the service.""" + """Define the Slack notification logic.""" + def __init__(self, hass, client, default_channel, username, icon): + """Initialize.""" + self._client = client self._default_channel = default_channel - self._api_token = api_token - self._username = username + self._hass = hass self._icon = icon - if self._username or self._icon: + + if username or self._icon: self._as_user = False else: self._as_user = True - self.is_allowed_path = is_allowed_path - self.slack = Slacker(self._api_token) - self.slack.auth.test() + async def _async_send_local_file_message(self, path, targets, message, title): + """Upload a local file (with message) to Slack.""" + if not self._hass.config.is_allowed_path(path): + _LOGGER.error("Path does not exist or is not allowed: %s", path) + return - def send_message(self, message="", **kwargs): - """Send a message to a user.""" + parsed_url = urlparse(path) + filename = os.path.basename(parsed_url.path) - if kwargs.get(ATTR_TARGET) is None: - targets = [self._default_channel] - else: - targets = kwargs.get(ATTR_TARGET) - - data = kwargs.get(ATTR_DATA) - attachments = data.get(ATTR_ATTACHMENTS) if data else None - file = data.get(ATTR_FILE) if data else None - title = kwargs.get(ATTR_TITLE) - - for target in targets: - try: - if file is not None: - # Load from file or URL - file_as_bytes = self.load_file( - url=file.get(ATTR_FILE_URL), - local_path=file.get(ATTR_FILE_PATH), - username=file.get(ATTR_FILE_USERNAME), - password=file.get(ATTR_FILE_PASSWORD), - auth=file.get(ATTR_FILE_AUTH), - ) - # Choose filename - if file.get(ATTR_FILE_URL): - filename = file.get(ATTR_FILE_URL) - else: - filename = file.get(ATTR_FILE_PATH) - # Prepare structure for Slack API - data = { - "content": None, - "filetype": None, - "filename": filename, - # If optional title is none use the filename - "title": title if title else filename, - "initial_comment": message, - "channels": target, - } - # Post to slack - self.slack.files.post( - "files.upload", data=data, files={"file": file_as_bytes} - ) - else: - self.slack.chat.post_message( - target, - message, - as_user=self._as_user, - username=self._username, - icon_emoji=self._icon, - attachments=attachments, - link_names=True, - ) - except slacker.Error as err: - _LOGGER.error("Could not send notification. Error: %s", err) - - def load_file( - self, url=None, local_path=None, username=None, password=None, auth=None - ): - """Load image/document/etc from a local path or URL.""" try: - if url: - # Check whether authentication parameters are provided - if username: - # Use digest or basic authentication - if ATTR_FILE_AUTH_DIGEST == auth: - auth_ = HTTPDigestAuth(username, password) - else: - auth_ = HTTPBasicAuth(username, password) - # Load file from URL with authentication - req = requests.get(url, auth=auth_, timeout=CONF_TIMEOUT) - else: - # Load file from URL without authentication - req = requests.get(url, timeout=CONF_TIMEOUT) - return req.content + await self._client.files_upload( + channels=",".join(targets), + file=path, + filename=filename, + initial_comment=message, + title=title or filename, + ) + except SlackApiError as err: + _LOGGER.error("Error while uploading file-based message: %s", err) - if local_path: - # Check whether path is whitelisted in configuration.yaml - if self.is_allowed_path(local_path): - return open(local_path, "rb") - _LOGGER.warning("'%s' is not secure to load data from!", local_path) - else: - _LOGGER.warning("Neither URL nor local path found in parameters!") + async def _async_send_text_only_message( + self, targets, message, title, attachments, blocks + ): + """Send a text-only message.""" + tasks = { + target: self._client.chat_postMessage( + channel=target, + text=message, + as_user=self._as_user, + attachments=attachments, + blocks=blocks, + icon_emoji=self._icon, + link_names=True, + ) + for target in targets + } - except OSError as error: - _LOGGER.error("Can't load from URL or local path: %s", error) + results = await asyncio.gather(*tasks.values(), return_exceptions=True) + for target, result in zip(tasks, results): + if isinstance(result, SlackApiError): + _LOGGER.error( + "There was a Slack API error while sending to %s: %s", + target, + result, + ) - return None + async def async_send_message(self, message, **kwargs): + """Send a message to Slack.""" + data = kwargs[ATTR_DATA] or {} + title = kwargs.get(ATTR_TITLE) + targets = _async_sanitize_channel_names( + kwargs.get(ATTR_TARGET, [self._default_channel]) + ) + + if ATTR_FILE in data: + return await self._async_send_local_file_message( + data[ATTR_FILE], targets, message, title + ) + + attachments = data.get(ATTR_ATTACHMENTS, {}) + if attachments: + _LOGGER.warning( + "Attachments are deprecated and part of Slack's legacy API; support " + "for them will be dropped in 0.114.0. In most cases, Blocks should be " + "used instead: https://www.home-assistant.io/integrations/slack/" + ) + blocks = data.get(ATTR_BLOCKS, {}) + + return await self._async_send_text_only_message( + targets, message, title, attachments, blocks + ) diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 1d82c38d088..2f64a2d3605 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -437,15 +437,25 @@ class SoundTouchDevice(MediaPlayerDevice): # slaves for some reason. To compensate for this shortcoming we have to fetch # the zone info from the master when the current device is a slave until this is # fixed in the SoundTouch API or libsoundtouch, or of course until somebody has a - # better idea on how to fix this - if zone_status.is_master: + # better idea on how to fix this. + # In addition to this shortcoming, libsoundtouch seems to report the "is_master" + # property wrong on some slaves, so the only reliable way to detect if the current + # devices is the master, is by comparing the master_id of the zone with the device_id + if zone_status.master_id == self._device.config.device_id: return self._build_zone_info(self.entity_id, zone_status.slaves) - master_instance = self._get_instance_by_ip(zone_status.master_ip) - master_zone_status = master_instance.device.zone_status() - return self._build_zone_info( - master_instance.entity_id, master_zone_status.slaves - ) + # The master device has to be searched by it's ID and not IP since libsoundtouch / BOSE API + # do not return the IP of the master for some slave objects/responses + master_instance = self._get_instance_by_id(zone_status.master_id) + if master_instance is not None: + master_zone_status = master_instance.device.zone_status() + return self._build_zone_info( + master_instance.entity_id, master_zone_status.slaves + ) + + # We should never end up here since this means we haven't found a master device to get the + # correct zone info from. In this case, assume current device is master + return self._build_zone_info(self.entity_id, zone_status.slaves) def _get_instance_by_ip(self, ip_address): """Search and return a SoundTouchDevice instance by it's IP address.""" @@ -454,6 +464,13 @@ class SoundTouchDevice(MediaPlayerDevice): return instance return None + def _get_instance_by_id(self, instance_id): + """Search and return a SoundTouchDevice instance by it's ID (aka MAC address).""" + for instance in self.hass.data[DATA_SOUNDTOUCH]: + if instance and instance.device.config.device_id == instance_id: + return instance + return None + def _build_zone_info(self, master, zone_slaves): """Build the exposed zone attributes.""" slaves = [] diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py index 956e629dd9d..3712b47671f 100644 --- a/homeassistant/components/stiebel_eltron/__init__.py +++ b/homeassistant/components/stiebel_eltron/__init__.py @@ -5,11 +5,7 @@ import logging from pystiebeleltron import pystiebeleltron import voluptuous as vol -from homeassistant.components.modbus import ( - CONF_HUB, - DEFAULT_HUB, - DOMAIN as MODBUS_DOMAIN, -) +from homeassistant.components.modbus.const import CONF_HUB, DEFAULT_HUB, MODBUS_DOMAIN from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index e5e3d1d409c..46dba04a77e 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -1,9 +1,9 @@ """Support for the (unofficial) Tado API.""" from datetime import timedelta import logging -import urllib from PyTado.interface import Tado +from requests import RequestException import voluptuous as vol from homeassistant.components.climate.const import PRESET_AWAY, PRESET_HOME @@ -110,7 +110,7 @@ class TadoConnector: """Connect to Tado and fetch the zones.""" try: self.tado = Tado(self._username, self._password) - except (RuntimeError, urllib.error.HTTPError) as exc: + except (RuntimeError, RequestException) as exc: _LOGGER.error("Unable to connect: %s", exc) return False @@ -135,9 +135,14 @@ class TadoConnector: _LOGGER.debug("Updating %s %s", sensor_type, sensor) try: if sensor_type == "zone": - data = self.tado.getState(sensor) + data = self.tado.getZoneState(sensor) elif sensor_type == "device": - data = self.tado.getDevices()[0] + devices_data = self.tado.getDevices() + if not devices_data: + _LOGGER.info("There are no devices to setup on this tado account.") + return + + data = devices_data[0] else: _LOGGER.debug("Unknown sensor: %s", sensor_type) return @@ -174,29 +179,40 @@ class TadoConnector: def set_zone_overlay( self, - zone_id, - overlay_mode, + zone_id=None, + overlay_mode=None, temperature=None, duration=None, device_type="HEATING", mode=None, + fan_speed=None, ): """Set a zone overlay.""" _LOGGER.debug( - "Set overlay for zone %s: mode=%s, temp=%s, duration=%s, type=%s, mode=%s", + "Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s, type=%s, mode=%s fan_speed=%s", zone_id, overlay_mode, temperature, duration, device_type, mode, + fan_speed, ) + try: self.tado.setZoneOverlay( - zone_id, overlay_mode, temperature, duration, device_type, "ON", mode + zone_id, + overlay_mode, + temperature, + duration, + device_type, + "ON", + mode, + fan_speed, ) - except urllib.error.HTTPError as exc: - _LOGGER.error("Could not set zone overlay: %s", exc.read()) + + except RequestException as exc: + _LOGGER.error("Could not set zone overlay: %s", exc) self.update_sensor("zone", zone_id) @@ -206,7 +222,7 @@ class TadoConnector: self.tado.setZoneOverlay( zone_id, overlay_mode, None, None, device_type, "OFF" ) - except urllib.error.HTTPError as exc: - _LOGGER.error("Could not set zone overlay: %s", exc.read()) + except RequestException as exc: + _LOGGER.error("Could not set zone overlay: %s", exc) self.update_sensor("zone", zone_id) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index e202cc49da4..224960ea3eb 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -3,21 +3,13 @@ import logging from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( - CURRENT_HVAC_COOL, - CURRENT_HVAC_HEAT, - CURRENT_HVAC_IDLE, CURRENT_HVAC_OFF, - FAN_HIGH, - FAN_LOW, - FAN_MIDDLE, - FAN_OFF, - HVAC_MODE_AUTO, - HVAC_MODE_COOL, + FAN_AUTO, HVAC_MODE_HEAT, - HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, PRESET_AWAY, PRESET_HOME, + SUPPORT_FAN_MODE, SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) @@ -27,49 +19,30 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED from .const import ( + CONST_FAN_AUTO, + CONST_FAN_OFF, + CONST_MODE_AUTO, + CONST_MODE_COOL, + CONST_MODE_HEAT, CONST_MODE_OFF, CONST_MODE_SMART_SCHEDULE, CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_MODE, - CONST_OVERLAY_TIMER, DATA, + HA_TO_TADO_FAN_MODE_MAP, + HA_TO_TADO_HVAC_MODE_MAP, + ORDERED_KNOWN_TADO_MODES, + SUPPORT_PRESET, + TADO_HVAC_ACTION_TO_HA_HVAC_ACTION, + TADO_MODES_WITH_NO_TEMP_SETTING, + TADO_TO_HA_FAN_MODE_MAP, + TADO_TO_HA_HVAC_MODE_MAP, TYPE_AIR_CONDITIONING, TYPE_HEATING, ) _LOGGER = logging.getLogger(__name__) -FAN_MAP_TADO = {"HIGH": FAN_HIGH, "MIDDLE": FAN_MIDDLE, "LOW": FAN_LOW} - -HVAC_MAP_TADO_HEAT = { - CONST_OVERLAY_MANUAL: HVAC_MODE_HEAT, - CONST_OVERLAY_TIMER: HVAC_MODE_HEAT, - CONST_OVERLAY_TADO_MODE: HVAC_MODE_HEAT, - CONST_MODE_SMART_SCHEDULE: HVAC_MODE_AUTO, - CONST_MODE_OFF: HVAC_MODE_OFF, -} -HVAC_MAP_TADO_COOL = { - CONST_OVERLAY_MANUAL: HVAC_MODE_COOL, - CONST_OVERLAY_TIMER: HVAC_MODE_COOL, - CONST_OVERLAY_TADO_MODE: HVAC_MODE_COOL, - CONST_MODE_SMART_SCHEDULE: HVAC_MODE_AUTO, - CONST_MODE_OFF: HVAC_MODE_OFF, -} -HVAC_MAP_TADO_HEAT_COOL = { - CONST_OVERLAY_MANUAL: HVAC_MODE_HEAT_COOL, - CONST_OVERLAY_TIMER: HVAC_MODE_HEAT_COOL, - CONST_OVERLAY_TADO_MODE: HVAC_MODE_HEAT_COOL, - CONST_MODE_SMART_SCHEDULE: HVAC_MODE_AUTO, - CONST_MODE_OFF: HVAC_MODE_OFF, -} - -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE -SUPPORT_HVAC_HEAT = [HVAC_MODE_HEAT, HVAC_MODE_AUTO, HVAC_MODE_OFF] -SUPPORT_HVAC_COOL = [HVAC_MODE_COOL, HVAC_MODE_AUTO, HVAC_MODE_OFF] -SUPPORT_HVAC_HEAT_COOL = [HVAC_MODE_HEAT_COOL, HVAC_MODE_AUTO, HVAC_MODE_OFF] -SUPPORT_FAN = [FAN_HIGH, FAN_MIDDLE, FAN_LOW, FAN_OFF] -SUPPORT_PRESET = [PRESET_AWAY, PRESET_HOME] - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Tado climate platform.""" @@ -96,29 +69,80 @@ def create_climate_entity(tado, name: str, zone_id: int): _LOGGER.debug("Capabilities for zone %s: %s", zone_id, capabilities) zone_type = capabilities["type"] + support_flags = SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE + supported_hvac_modes = [ + TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_OFF], + TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_SMART_SCHEDULE], + ] + supported_fan_modes = None + heat_temperatures = None + cool_temperatures = None - ac_support_heat = False if zone_type == TYPE_AIR_CONDITIONING: - # Only use heat if available - # (you don't have to setup a heat mode, but cool is required) # Heat is preferred as it generally has a lower minimum temperature - if "HEAT" in capabilities: - temperatures = capabilities["HEAT"]["temperatures"] - ac_support_heat = True - else: - temperatures = capabilities["COOL"]["temperatures"] - elif "temperatures" in capabilities: - temperatures = capabilities["temperatures"] + for mode in ORDERED_KNOWN_TADO_MODES: + if mode not in capabilities: + continue + + supported_hvac_modes.append(TADO_TO_HA_HVAC_MODE_MAP[mode]) + if not capabilities[mode].get("fanSpeeds"): + continue + + support_flags |= SUPPORT_FAN_MODE + + if supported_fan_modes: + continue + + supported_fan_modes = [ + TADO_TO_HA_FAN_MODE_MAP[speed] + for speed in capabilities[mode]["fanSpeeds"] + ] + + cool_temperatures = capabilities[CONST_MODE_COOL]["temperatures"] else: - _LOGGER.debug("Not adding zone %s since it has no temperature", name) + supported_hvac_modes.append(HVAC_MODE_HEAT) + + if CONST_MODE_HEAT in capabilities: + heat_temperatures = capabilities[CONST_MODE_HEAT]["temperatures"] + + if heat_temperatures is None and "temperatures" in capabilities: + heat_temperatures = capabilities["temperatures"] + + if cool_temperatures is None and heat_temperatures is None: + _LOGGER.debug("Not adding zone %s since it has no temperatures", name) return None - min_temp = float(temperatures["celsius"]["min"]) - max_temp = float(temperatures["celsius"]["max"]) - step = temperatures["celsius"].get("step", PRECISION_TENTHS) + heat_min_temp = None + heat_max_temp = None + heat_step = None + cool_min_temp = None + cool_max_temp = None + cool_step = None + + if heat_temperatures is not None: + heat_min_temp = float(heat_temperatures["celsius"]["min"]) + heat_max_temp = float(heat_temperatures["celsius"]["max"]) + heat_step = heat_temperatures["celsius"].get("step", PRECISION_TENTHS) + + if cool_temperatures is not None: + cool_min_temp = float(cool_temperatures["celsius"]["min"]) + cool_max_temp = float(cool_temperatures["celsius"]["max"]) + cool_step = cool_temperatures["celsius"].get("step", PRECISION_TENTHS) entity = TadoClimate( - tado, name, zone_id, zone_type, min_temp, max_temp, step, ac_support_heat, + tado, + name, + zone_id, + zone_type, + heat_min_temp, + heat_max_temp, + heat_step, + cool_min_temp, + cool_max_temp, + cool_step, + supported_hvac_modes, + supported_fan_modes, + support_flags, ) return entity @@ -132,10 +156,15 @@ class TadoClimate(ClimateDevice): zone_name, zone_id, zone_type, - min_temp, - max_temp, - step, - ac_support_heat, + heat_min_temp, + heat_max_temp, + heat_step, + cool_min_temp, + cool_max_temp, + cool_step, + supported_hvac_modes, + supported_fan_modes, + support_flags, ): """Initialize of Tado climate entity.""" self._tado = tado @@ -146,49 +175,51 @@ class TadoClimate(ClimateDevice): self._unique_id = f"{zone_type} {zone_id} {tado.device_id}" self._ac_device = zone_type == TYPE_AIR_CONDITIONING - self._ac_support_heat = ac_support_heat - self._cooling = False + self._supported_hvac_modes = supported_hvac_modes + self._supported_fan_modes = supported_fan_modes + self._support_flags = support_flags - self._active = False - self._device_is_active = False + self._available = False self._cur_temp = None self._cur_humidity = None - self._is_away = False - self._min_temp = min_temp - self._max_temp = max_temp - self._step = step + + self._heat_min_temp = heat_min_temp + self._heat_max_temp = heat_max_temp + self._heat_step = heat_step + + self._cool_min_temp = cool_min_temp + self._cool_max_temp = cool_max_temp + self._cool_step = cool_step + self._target_temp = None - if tado.fallback: - # Fallback to Smart Schedule at next Schedule switch - self._default_overlay = CONST_OVERLAY_TADO_MODE - else: - # Don't fallback to Smart Schedule, but keep in manual mode - self._default_overlay = CONST_OVERLAY_MANUAL + self._current_tado_fan_speed = CONST_FAN_OFF + self._current_tado_hvac_mode = CONST_MODE_OFF + self._current_tado_hvac_action = CURRENT_HVAC_OFF - self._current_fan = CONST_MODE_OFF - self._current_operation = CONST_MODE_SMART_SCHEDULE - self._overlay_mode = CONST_MODE_SMART_SCHEDULE + self._undo_dispatcher = None + self._tado_zone_data = None + self._async_update_zone_data() + + async def async_will_remove_from_hass(self): + """When entity will be removed from hass.""" + if self._undo_dispatcher: + self._undo_dispatcher() async def async_added_to_hass(self): """Register for sensor updates.""" - @callback - def async_update_callback(): - """Schedule an entity update.""" - self.async_schedule_update_ha_state(True) - - async_dispatcher_connect( + self._undo_dispatcher = async_dispatcher_connect( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format("zone", self.zone_id), - async_update_callback, + self._async_update_callback, ) @property def supported_features(self): """Return the list of supported features.""" - return SUPPORT_FLAGS + return self._support_flags @property def name(self): @@ -208,12 +239,12 @@ class TadoClimate(ClimateDevice): @property def current_humidity(self): """Return the current humidity.""" - return self._cur_humidity + return self._tado_zone_data.current_humidity @property def current_temperature(self): """Return the sensor temperature.""" - return self._cur_temp + return self._tado_zone_data.current_temp @property def hvac_mode(self): @@ -221,11 +252,7 @@ class TadoClimate(ClimateDevice): Need to be one of HVAC_MODE_*. """ - if self._ac_device and self._ac_support_heat: - return HVAC_MAP_TADO_HEAT_COOL.get(self._current_operation) - if self._ac_device and not self._ac_support_heat: - return HVAC_MAP_TADO_COOL.get(self._current_operation) - return HVAC_MAP_TADO_HEAT.get(self._current_operation) + return TADO_TO_HA_HVAC_MODE_MAP.get(self._current_tado_hvac_mode, HVAC_MODE_OFF) @property def hvac_modes(self): @@ -233,11 +260,7 @@ class TadoClimate(ClimateDevice): Need to be a subset of HVAC_MODES. """ - if self._ac_device: - if self._ac_support_heat: - return SUPPORT_HVAC_HEAT_COOL - return SUPPORT_HVAC_COOL - return SUPPORT_HVAC_HEAT + return self._supported_hvac_modes @property def hvac_action(self): @@ -245,40 +268,30 @@ class TadoClimate(ClimateDevice): Need to be one of CURRENT_HVAC_*. """ - if not self._device_is_active: - return CURRENT_HVAC_OFF - if self._ac_device: - if self._active: - if self._ac_support_heat and not self._cooling: - return CURRENT_HVAC_HEAT - return CURRENT_HVAC_COOL - return CURRENT_HVAC_IDLE - if self._active: - return CURRENT_HVAC_HEAT - return CURRENT_HVAC_IDLE + return TADO_HVAC_ACTION_TO_HA_HVAC_ACTION.get( + self._tado_zone_data.current_hvac_action, CURRENT_HVAC_OFF + ) @property def fan_mode(self): """Return the fan setting.""" if self._ac_device: - return FAN_MAP_TADO.get(self._current_fan) + return TADO_TO_HA_FAN_MODE_MAP.get(self._current_tado_fan_speed, FAN_AUTO) return None @property def fan_modes(self): """List of available fan modes.""" - if self._ac_device: - return SUPPORT_FAN - return None + return self._supported_fan_modes def set_fan_mode(self, fan_mode: str): """Turn fan on/off.""" - pass + self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) @property def preset_mode(self): """Return the current preset mode (home, away).""" - if self._is_away: + if self._tado_zone_data.is_away: return PRESET_AWAY return PRESET_HOME @@ -299,12 +312,18 @@ class TadoClimate(ClimateDevice): @property def target_temperature_step(self): """Return the supported step of target temperature.""" - return self._step + if self._tado_zone_data.current_hvac_mode == CONST_MODE_COOL: + return self._cool_step or self._heat_step + return self._heat_step or self._cool_step @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._target_temp + # If the target temperature will be None + # if the device is performing an action + # that does not affect the temperature or + # the device is switching states + return self._tado_zone_data.target_temp or self._tado_zone_data.current_temp def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -312,174 +331,149 @@ class TadoClimate(ClimateDevice): if temperature is None: return - self._current_operation = self._default_overlay - self._overlay_mode = None - self._target_temp = temperature - self._control_heating() + if self._current_tado_hvac_mode not in ( + CONST_MODE_OFF, + CONST_MODE_AUTO, + CONST_MODE_SMART_SCHEDULE, + ): + self._control_hvac(target_temp=temperature) + return + + new_hvac_mode = CONST_MODE_COOL if self._ac_device else CONST_MODE_HEAT + self._control_hvac(target_temp=temperature, hvac_mode=new_hvac_mode) def set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" - mode = None - if hvac_mode == HVAC_MODE_OFF: - mode = CONST_MODE_OFF - elif hvac_mode == HVAC_MODE_AUTO: - mode = CONST_MODE_SMART_SCHEDULE - elif hvac_mode == HVAC_MODE_HEAT: - mode = self._default_overlay - elif hvac_mode == HVAC_MODE_COOL: - mode = self._default_overlay - elif hvac_mode == HVAC_MODE_HEAT_COOL: - mode = self._default_overlay + self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode]) - self._current_operation = mode - self._overlay_mode = None - - # Set a target temperature if we don't have any - # This can happen when we switch from Off to On - if self._target_temp is None: - if self._ac_device: - self._target_temp = self.max_temp - else: - self._target_temp = self.min_temp - self.schedule_update_ha_state() - - self._control_heating() + @property + def available(self): + """Return if the device is available.""" + return self._tado_zone_data.available @property def min_temp(self): """Return the minimum temperature.""" - return self._min_temp + if ( + self._current_tado_hvac_mode == CONST_MODE_COOL + and self._cool_min_temp is not None + ): + return self._cool_min_temp + if self._heat_min_temp is not None: + return self._heat_min_temp + + return self._cool_min_temp @property def max_temp(self): """Return the maximum temperature.""" - return self._max_temp - - def update(self): - """Handle update callbacks.""" - _LOGGER.debug("Updating climate platform for zone %d", self.zone_id) - data = self._tado.data["zone"][self.zone_id] - - if "sensorDataPoints" in data: - sensor_data = data["sensorDataPoints"] - - if "insideTemperature" in sensor_data: - temperature = float(sensor_data["insideTemperature"]["celsius"]) - self._cur_temp = temperature - - if "humidity" in sensor_data: - humidity = float(sensor_data["humidity"]["percentage"]) - self._cur_humidity = humidity - - # temperature setting will not exist when device is off if ( - "temperature" in data["setting"] - and data["setting"]["temperature"] is not None + self._current_tado_hvac_mode == CONST_MODE_HEAT + and self._heat_max_temp is not None ): - setting = float(data["setting"]["temperature"]["celsius"]) - self._target_temp = setting + return self._heat_max_temp + if self._heat_max_temp is not None: + return self._heat_max_temp - if "tadoMode" in data: - mode = data["tadoMode"] - self._is_away = mode == "AWAY" + return self._heat_max_temp - if "setting" in data: - power = data["setting"]["power"] - if power == "OFF": - self._current_operation = CONST_MODE_OFF - self._current_fan = CONST_MODE_OFF - # There is no overlay, the mode will always be - # "SMART_SCHEDULE" - self._overlay_mode = CONST_MODE_SMART_SCHEDULE - self._device_is_active = False - else: - self._device_is_active = True + @callback + def _async_update_zone_data(self): + """Load tado data into zone.""" + self._tado_zone_data = self._tado.data["zone"][self.zone_id] + self._current_tado_fan_speed = self._tado_zone_data.current_fan_speed + self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode + self._current_tado_hvac_action = self._tado_zone_data.current_hvac_action - active = False - if "activityDataPoints" in data: - activity_data = data["activityDataPoints"] - if self._ac_device: - if "acPower" in activity_data and activity_data["acPower"] is not None: - if not activity_data["acPower"]["value"] == "OFF": - active = True - else: - if ( - "heatingPower" in activity_data - and activity_data["heatingPower"] is not None - ): - if float(activity_data["heatingPower"]["percentage"]) > 0.0: - active = True - self._active = active + @callback + def _async_update_callback(self): + """Load tado data and update state.""" + self._async_update_zone_data() + self.async_write_ha_state() - overlay = False - overlay_data = None - termination = CONST_MODE_SMART_SCHEDULE - cooling = False - fan_speed = CONST_MODE_OFF + def _normalize_target_temp_for_hvac_mode(self): + # Set a target temperature if we don't have any + # This can happen when we switch from Off to On + if self._target_temp is None: + self._target_temp = self._tado_zone_data.current_temp + elif self._current_tado_hvac_mode == CONST_MODE_COOL: + if self._target_temp > self._cool_max_temp: + self._target_temp = self._cool_max_temp + elif self._target_temp < self._cool_min_temp: + self._target_temp = self._cool_min_temp + elif self._current_tado_hvac_mode == CONST_MODE_HEAT: + if self._target_temp > self._heat_max_temp: + self._target_temp = self._heat_max_temp + elif self._target_temp < self._heat_min_temp: + self._target_temp = self._heat_min_temp - if "overlay" in data: - overlay_data = data["overlay"] - overlay = overlay_data is not None - - if overlay: - termination = overlay_data["termination"]["type"] - setting = False - setting_data = None - - if "setting" in overlay_data: - setting_data = overlay_data["setting"] - setting = setting_data is not None - - if setting: - if "mode" in setting_data: - cooling = setting_data["mode"] == "COOL" - - if "fanSpeed" in setting_data: - fan_speed = setting_data["fanSpeed"] - - if self._device_is_active: - # If you set mode manually to off, there will be an overlay - # and a termination, but we want to see the mode "OFF" - self._overlay_mode = termination - self._current_operation = termination - - self._cooling = cooling - self._current_fan = fan_speed - - def _control_heating(self): + def _control_hvac(self, hvac_mode=None, target_temp=None, fan_mode=None): """Send new target temperature to Tado.""" - if self._current_operation == CONST_MODE_SMART_SCHEDULE: + + if hvac_mode: + self._current_tado_hvac_mode = hvac_mode + + if target_temp: + self._target_temp = target_temp + + if fan_mode: + self._current_tado_fan_speed = fan_mode + + self._normalize_target_temp_for_hvac_mode() + + # tado does not permit setting the fan speed to + # off, you must turn off the device + if ( + self._current_tado_fan_speed == CONST_FAN_OFF + and self._current_tado_hvac_mode != CONST_MODE_OFF + ): + self._current_tado_fan_speed = CONST_FAN_AUTO + + if self._current_tado_hvac_mode == CONST_MODE_OFF: + _LOGGER.debug( + "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id + ) + self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type) + return + + if self._current_tado_hvac_mode == CONST_MODE_SMART_SCHEDULE: _LOGGER.debug( "Switching to SMART_SCHEDULE for zone %s (%d)", self.zone_name, self.zone_id, ) self._tado.reset_zone_overlay(self.zone_id) - self._overlay_mode = self._current_operation - return - - if self._current_operation == CONST_MODE_OFF: - _LOGGER.debug( - "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id - ) - self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type) - self._overlay_mode = self._current_operation return _LOGGER.debug( "Switching to %s for zone %s (%d) with temperature %s °C", - self._current_operation, + self._current_tado_hvac_mode, self.zone_name, self.zone_id, self._target_temp, ) - self._tado.set_zone_overlay( - self.zone_id, - self._current_operation, - self._target_temp, - None, - self.zone_type, - "COOL" if self._ac_device else None, + + # Fallback to Smart Schedule at next Schedule switch if we have fallback enabled + overlay_mode = ( + CONST_OVERLAY_TADO_MODE if self._tado.fallback else CONST_OVERLAY_MANUAL + ) + + temperature_to_send = self._target_temp + if self._current_tado_hvac_mode in TADO_MODES_WITH_NO_TEMP_SETTING: + # A temperature cannot be passed with these modes + temperature_to_send = None + + self._tado.set_zone_overlay( + zone_id=self.zone_id, + overlay_mode=overlay_mode, # What to do when the period ends + temperature=temperature_to_send, + duration=None, + device_type=self.zone_type, + mode=self._current_tado_hvac_mode, + fan_speed=( + self._current_tado_fan_speed + if (self._support_flags & SUPPORT_FAN_MODE) + else None + ), # api defaults to not sending fanSpeed if not specified ) - self._overlay_mode = self._current_operation diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index 8d67e3bf9f8..542437d0af0 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -1,5 +1,48 @@ """Constant values for the Tado component.""" +from PyTado.const import ( + CONST_HVAC_COOL, + CONST_HVAC_DRY, + CONST_HVAC_FAN, + CONST_HVAC_HEAT, + CONST_HVAC_HOT_WATER, + CONST_HVAC_IDLE, + CONST_HVAC_OFF, +) + +from homeassistant.components.climate.const import ( + CURRENT_HVAC_COOL, + CURRENT_HVAC_DRY, + CURRENT_HVAC_FAN, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_HOME, +) + +TADO_HVAC_ACTION_TO_HA_HVAC_ACTION = { + CONST_HVAC_HEAT: CURRENT_HVAC_HEAT, + CONST_HVAC_DRY: CURRENT_HVAC_DRY, + CONST_HVAC_FAN: CURRENT_HVAC_FAN, + CONST_HVAC_COOL: CURRENT_HVAC_COOL, + CONST_HVAC_IDLE: CURRENT_HVAC_IDLE, + CONST_HVAC_OFF: CURRENT_HVAC_OFF, + CONST_HVAC_HOT_WATER: CURRENT_HVAC_HEAT, +} + # Configuration CONF_FALLBACK = "fallback" DATA = "data" @@ -10,10 +53,81 @@ TYPE_HEATING = "HEATING" TYPE_HOT_WATER = "HOT_WATER" # Base modes +CONST_MODE_OFF = "OFF" CONST_MODE_SMART_SCHEDULE = "SMART_SCHEDULE" # Use the schedule -CONST_MODE_OFF = "OFF" # Switch off heating in a zone +CONST_MODE_AUTO = "AUTO" +CONST_MODE_COOL = "COOL" +CONST_MODE_HEAT = "HEAT" +CONST_MODE_DRY = "DRY" +CONST_MODE_FAN = "FAN" + +CONST_LINK_OFFLINE = "OFFLINE" + +CONST_FAN_OFF = "OFF" +CONST_FAN_AUTO = "AUTO" +CONST_FAN_LOW = "LOW" +CONST_FAN_MIDDLE = "MIDDLE" +CONST_FAN_HIGH = "HIGH" + # When we change the temperature setting, we need an overlay mode CONST_OVERLAY_TADO_MODE = "TADO_MODE" # wait until tado changes the mode automatic CONST_OVERLAY_MANUAL = "MANUAL" # the user has change the temperature or mode manually CONST_OVERLAY_TIMER = "TIMER" # the temperature will be reset after a timespan + + +# Heat always comes first since we get the +# min and max tempatures for the zone from +# it. +# Heat is preferred as it generally has a lower minimum temperature +ORDERED_KNOWN_TADO_MODES = [ + CONST_MODE_HEAT, + CONST_MODE_COOL, + CONST_MODE_AUTO, + CONST_MODE_DRY, + CONST_MODE_FAN, +] + +TADO_MODES_TO_HA_CURRENT_HVAC_ACTION = { + CONST_MODE_HEAT: CURRENT_HVAC_HEAT, + CONST_MODE_DRY: CURRENT_HVAC_DRY, + CONST_MODE_FAN: CURRENT_HVAC_FAN, + CONST_MODE_COOL: CURRENT_HVAC_COOL, +} + +# These modes will not allow a temp to be set +TADO_MODES_WITH_NO_TEMP_SETTING = [CONST_MODE_AUTO, CONST_MODE_DRY, CONST_MODE_FAN] +# +# HVAC_MODE_HEAT_COOL is mapped to CONST_MODE_AUTO +# This lets tado decide on a temp +# +# HVAC_MODE_AUTO is mapped to CONST_MODE_SMART_SCHEDULE +# This runs the smart schedule +# +HA_TO_TADO_HVAC_MODE_MAP = { + HVAC_MODE_OFF: CONST_MODE_OFF, + HVAC_MODE_HEAT_COOL: CONST_MODE_AUTO, + HVAC_MODE_AUTO: CONST_MODE_SMART_SCHEDULE, + HVAC_MODE_HEAT: CONST_MODE_HEAT, + HVAC_MODE_COOL: CONST_MODE_COOL, + HVAC_MODE_DRY: CONST_MODE_DRY, + HVAC_MODE_FAN_ONLY: CONST_MODE_FAN, +} + +HA_TO_TADO_FAN_MODE_MAP = { + FAN_AUTO: CONST_FAN_AUTO, + FAN_OFF: CONST_FAN_OFF, + FAN_LOW: CONST_FAN_LOW, + FAN_MEDIUM: CONST_FAN_MIDDLE, + FAN_HIGH: CONST_FAN_HIGH, +} + +TADO_TO_HA_HVAC_MODE_MAP = { + value: key for key, value in HA_TO_TADO_HVAC_MODE_MAP.items() +} + +TADO_TO_HA_FAN_MODE_MAP = {value: key for key, value in HA_TO_TADO_FAN_MODE_MAP.items()} + +DEFAULT_TADO_PRECISION = 0.1 + +SUPPORT_PRESET = [PRESET_AWAY, PRESET_HOME] diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index ab0be2d4346..e84072b5985 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -7,6 +7,6 @@ ], "dependencies": [], "codeowners": [ - "@michaelarnauts" + "@michaelarnauts", "@bdraco" ] } diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 2cd40bee3fa..fea81dcb586 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -31,6 +31,7 @@ ZONE_SENSORS = { "ac", "tado mode", "overlay", + "open window", ], TYPE_HOT_WATER: ["power", "link", "tado mode", "overlay"], } @@ -46,20 +47,27 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for tado in api_list: # Create zone sensors + zones = tado.zones + devices = tado.devices + + for zone in zones: + zone_type = zone["type"] + if zone_type not in ZONE_SENSORS: + _LOGGER.warning("Unknown zone type skipped: %s", zone_type) + continue - for zone in tado.zones: entities.extend( [ - create_zone_sensor(tado, zone["name"], zone["id"], variable) - for variable in ZONE_SENSORS.get(zone["type"]) + TadoZoneSensor(tado, zone["name"], zone["id"], variable) + for variable in ZONE_SENSORS[zone_type] ] ) # Create device sensors - for home in tado.devices: + for device in devices: entities.extend( [ - create_device_sensor(tado, home["name"], home["id"], variable) + TadoDeviceSensor(tado, device["name"], device["id"], variable) for variable in DEVICE_SENSORS ] ) @@ -67,46 +75,38 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(entities, True) -def create_zone_sensor(tado, name, zone_id, variable): - """Create a zone sensor.""" - return TadoSensor(tado, name, "zone", zone_id, variable) - - -def create_device_sensor(tado, name, device_id, variable): - """Create a device sensor.""" - return TadoSensor(tado, name, "device", device_id, variable) - - -class TadoSensor(Entity): +class TadoZoneSensor(Entity): """Representation of a tado Sensor.""" - def __init__(self, tado, zone_name, sensor_type, zone_id, zone_variable): + def __init__(self, tado, zone_name, zone_id, zone_variable): """Initialize of the Tado Sensor.""" self._tado = tado self.zone_name = zone_name self.zone_id = zone_id self.zone_variable = zone_variable - self.sensor_type = sensor_type self._unique_id = f"{zone_variable} {zone_id} {tado.device_id}" self._state = None self._state_attributes = None + self._tado_zone_data = None + self._undo_dispatcher = None + + async def async_will_remove_from_hass(self): + """When entity will be removed from hass.""" + if self._undo_dispatcher: + self._undo_dispatcher() async def async_added_to_hass(self): """Register for sensor updates.""" - @callback - def async_update_callback(): - """Schedule an entity update.""" - self.async_schedule_update_ha_state(True) - - async_dispatcher_connect( + self._undo_dispatcher = async_dispatcher_connect( self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self.sensor_type, self.zone_id), - async_update_callback, + SIGNAL_TADO_UPDATE_RECEIVED.format("zone", self.zone_id), + self._async_update_callback, ) + self._async_update_zone_data() @property def unique_id(self): @@ -138,7 +138,7 @@ class TadoSensor(Entity): if self.zone_variable == "heating": return UNIT_PERCENTAGE if self.zone_variable == "ac": - return "" + return None @property def icon(self): @@ -149,97 +149,143 @@ class TadoSensor(Entity): return "mdi:water-percent" @property - def should_poll(self) -> bool: + def should_poll(self): """Do not poll.""" return False - def update(self): + @callback + def _async_update_callback(self): + """Update and write state.""" + self._async_update_zone_data() + self.async_write_ha_state() + + @callback + def _async_update_zone_data(self): """Handle update callbacks.""" try: - data = self._tado.data[self.sensor_type][self.zone_id] + self._tado_zone_data = self._tado.data["zone"][self.zone_id] except KeyError: return - unit = TEMP_CELSIUS - if self.zone_variable == "temperature": - if "sensorDataPoints" in data: - sensor_data = data["sensorDataPoints"] - temperature = float(sensor_data["insideTemperature"]["celsius"]) - - self._state = self.hass.config.units.temperature(temperature, unit) - self._state_attributes = { - "time": sensor_data["insideTemperature"]["timestamp"], - "setting": 0, # setting is used in climate device - } - - # temperature setting will not exist when device is off - if ( - "temperature" in data["setting"] - and data["setting"]["temperature"] is not None - ): - temperature = float(data["setting"]["temperature"]["celsius"]) - - self._state_attributes[ - "setting" - ] = self.hass.config.units.temperature(temperature, unit) + self._state = self.hass.config.units.temperature( + self._tado_zone_data.current_temp, TEMP_CELSIUS + ) + self._state_attributes = { + "time": self._tado_zone_data.current_temp_timestamp, + "setting": 0, # setting is used in climate device + } elif self.zone_variable == "humidity": - if "sensorDataPoints" in data: - sensor_data = data["sensorDataPoints"] - self._state = float(sensor_data["humidity"]["percentage"]) - self._state_attributes = {"time": sensor_data["humidity"]["timestamp"]} + self._state = self._tado_zone_data.current_humidity + self._state_attributes = { + "time": self._tado_zone_data.current_humidity_timestamp + } elif self.zone_variable == "power": - if "setting" in data: - self._state = data["setting"]["power"] + self._state = self._tado_zone_data.power elif self.zone_variable == "link": - if "link" in data: - self._state = data["link"]["state"] + self._state = self._tado_zone_data.link elif self.zone_variable == "heating": - if "activityDataPoints" in data: - activity_data = data["activityDataPoints"] - - if ( - "heatingPower" in activity_data - and activity_data["heatingPower"] is not None - ): - self._state = float(activity_data["heatingPower"]["percentage"]) - self._state_attributes = { - "time": activity_data["heatingPower"]["timestamp"] - } + self._state = self._tado_zone_data.heating_power_percentage + self._state_attributes = { + "time": self._tado_zone_data.heating_power_timestamp + } elif self.zone_variable == "ac": - if "activityDataPoints" in data: - activity_data = data["activityDataPoints"] - - if "acPower" in activity_data and activity_data["acPower"] is not None: - self._state = activity_data["acPower"]["value"] - self._state_attributes = { - "time": activity_data["acPower"]["timestamp"] - } + self._state = self._tado_zone_data.ac_power + self._state_attributes = {"time": self._tado_zone_data.ac_power_timestamp} elif self.zone_variable == "tado bridge status": - if "connectionState" in data: - self._state = data["connectionState"]["value"] + self._state = self._tado_zone_data.connection elif self.zone_variable == "tado mode": - if "tadoMode" in data: - self._state = data["tadoMode"] + self._state = self._tado_zone_data.tado_mode elif self.zone_variable == "overlay": - self._state = "overlay" in data and data["overlay"] is not None + self._state = self._tado_zone_data.overlay_active self._state_attributes = ( - {"termination": data["overlay"]["termination"]["type"]} - if self._state + {"termination": self._tado_zone_data.overlay_termination_type} + if self._tado_zone_data.overlay_active else {} ) elif self.zone_variable == "early start": - self._state = "preparation" in data and data["preparation"] is not None + self._state = self._tado_zone_data.preparation elif self.zone_variable == "open window": - self._state = "openWindow" in data and data["openWindow"] is not None - self._state_attributes = data["openWindow"] if self._state else {} + self._state = self._tado_zone_data.open_window + self._state_attributes = self._tado_zone_data.open_window_attr + + +class TadoDeviceSensor(Entity): + """Representation of a tado Sensor.""" + + def __init__(self, tado, device_name, device_id, device_variable): + """Initialize of the Tado Sensor.""" + self._tado = tado + + self.device_name = device_name + self.device_id = device_id + self.device_variable = device_variable + + self._unique_id = f"{device_variable} {device_id} {tado.device_id}" + + self._state = None + self._state_attributes = None + self._tado_device_data = None + self._undo_dispatcher = None + + async def async_will_remove_from_hass(self): + """When entity will be removed from hass.""" + if self._undo_dispatcher: + self._undo_dispatcher() + + async def async_added_to_hass(self): + """Register for sensor updates.""" + + self._undo_dispatcher = async_dispatcher_connect( + self.hass, + SIGNAL_TADO_UPDATE_RECEIVED.format("device", self.device_id), + self._async_update_callback, + ) + self._async_update_device_data() + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self.device_name} {self.device_variable}" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def should_poll(self): + """Do not poll.""" + return False + + @callback + def _async_update_callback(self): + """Update and write state.""" + self._async_update_device_data() + self.async_write_ha_state() + + @callback + def _async_update_device_data(self): + """Handle update callbacks.""" + try: + data = self._tado.data["device"][self.device_id] + except KeyError: + return + + if self.device_variable == "tado bridge status": + self._state = data.get("connectionState", {}).get("value", False) diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index fc3a9ce9cf4..51ff2ede57d 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -12,6 +12,9 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED from .const import ( + CONST_HVAC_HEAT, + CONST_MODE_AUTO, + CONST_MODE_HEAT, CONST_MODE_OFF, CONST_MODE_SMART_SCHEDULE, CONST_OVERLAY_MANUAL, @@ -33,6 +36,7 @@ WATER_HEATER_MAP_TADO = { CONST_OVERLAY_MANUAL: MODE_HEAT, CONST_OVERLAY_TIMER: MODE_HEAT, CONST_OVERLAY_TADO_MODE: MODE_HEAT, + CONST_HVAC_HEAT: MODE_HEAT, CONST_MODE_SMART_SCHEDULE: MODE_AUTO, CONST_MODE_OFF: MODE_OFF, } @@ -50,7 +54,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for tado in api_list: for zone in tado.zones: - if zone["type"] in [TYPE_HOT_WATER]: + if zone["type"] == TYPE_HOT_WATER: entity = create_water_heater_entity(tado, zone["name"], zone["id"]) entities.append(entity) @@ -61,6 +65,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): def create_water_heater_entity(tado, name: str, zone_id: int): """Create a Tado water heater device.""" capabilities = tado.get_capabilities(zone_id) + supports_temperature_control = capabilities["canSetTemperature"] if supports_temperature_control and "temperatures" in capabilities: @@ -98,7 +103,6 @@ class TadoWaterHeater(WaterHeaterDevice): self._unique_id = f"{zone_id} {tado.device_id}" self._device_is_active = False - self._is_away = False self._supports_temperature_control = supports_temperature_control self._min_temperature = min_temp @@ -110,29 +114,25 @@ class TadoWaterHeater(WaterHeaterDevice): if self._supports_temperature_control: self._supported_features |= SUPPORT_TARGET_TEMPERATURE - if tado.fallback: - # Fallback to Smart Schedule at next Schedule switch - self._default_overlay = CONST_OVERLAY_TADO_MODE - else: - # Don't fallback to Smart Schedule, but keep in manual mode - self._default_overlay = CONST_OVERLAY_MANUAL - - self._current_operation = CONST_MODE_SMART_SCHEDULE + self._current_tado_hvac_mode = CONST_MODE_SMART_SCHEDULE self._overlay_mode = CONST_MODE_SMART_SCHEDULE + self._tado_zone_data = None + self._undo_dispatcher = None + + async def async_will_remove_from_hass(self): + """When entity will be removed from hass.""" + if self._undo_dispatcher: + self._undo_dispatcher() async def async_added_to_hass(self): """Register for sensor updates.""" - @callback - def async_update_callback(): - """Schedule an entity update.""" - self.async_schedule_update_ha_state(True) - - async_dispatcher_connect( + self._undo_dispatcher = async_dispatcher_connect( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format("zone", self.zone_id), - async_update_callback, + self._async_update_callback, ) + self._async_update_data() @property def supported_features(self): @@ -157,17 +157,17 @@ class TadoWaterHeater(WaterHeaterDevice): @property def current_operation(self): """Return current readable operation mode.""" - return WATER_HEATER_MAP_TADO.get(self._current_operation) + return WATER_HEATER_MAP_TADO.get(self._current_tado_hvac_mode) @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._target_temp + return self._tado_zone_data.target_temp @property def is_away_mode_on(self): """Return true if away mode is on.""" - return self._is_away + return self._tado_zone_data.is_away @property def operation_list(self): @@ -198,16 +198,9 @@ class TadoWaterHeater(WaterHeaterDevice): elif operation_mode == MODE_AUTO: mode = CONST_MODE_SMART_SCHEDULE elif operation_mode == MODE_HEAT: - mode = self._default_overlay + mode = CONST_MODE_HEAT - self._current_operation = mode - self._overlay_mode = None - - # Set a target temperature if we don't have any - if mode == CONST_OVERLAY_TADO_MODE and self._target_temp is None: - self._target_temp = self.min_temp - - self._control_heater() + self._control_heater(hvac_mode=mode) def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -215,88 +208,75 @@ class TadoWaterHeater(WaterHeaterDevice): if not self._supports_temperature_control or temperature is None: return - self._current_operation = self._default_overlay - self._overlay_mode = None - self._target_temp = temperature - self._control_heater() - - def update(self): - """Handle update callbacks.""" - _LOGGER.debug("Updating water_heater platform for zone %d", self.zone_id) - data = self._tado.data["zone"][self.zone_id] - - if "tadoMode" in data: - mode = data["tadoMode"] - self._is_away = mode == "AWAY" - - if "setting" in data: - power = data["setting"]["power"] - if power == "OFF": - self._current_operation = CONST_MODE_OFF - # There is no overlay, the mode will always be - # "SMART_SCHEDULE" - self._overlay_mode = CONST_MODE_SMART_SCHEDULE - self._device_is_active = False - else: - self._device_is_active = True - - # temperature setting will not exist when device is off - if ( - "temperature" in data["setting"] - and data["setting"]["temperature"] is not None + if self._current_tado_hvac_mode not in ( + CONST_MODE_OFF, + CONST_MODE_AUTO, + CONST_MODE_SMART_SCHEDULE, ): - setting = float(data["setting"]["temperature"]["celsius"]) - self._target_temp = setting + self._control_heater(target_temp=temperature) + return - overlay = False - overlay_data = None - termination = CONST_MODE_SMART_SCHEDULE + self._control_heater(target_temp=temperature, hvac_mode=CONST_MODE_HEAT) - if "overlay" in data: - overlay_data = data["overlay"] - overlay = overlay_data is not None + @callback + def _async_update_callback(self): + """Load tado data and update state.""" + self._async_update_data() + self.async_write_ha_state() - if overlay: - termination = overlay_data["termination"]["type"] + @callback + def _async_update_data(self): + """Load tado data.""" + _LOGGER.debug("Updating water_heater platform for zone %d", self.zone_id) + self._tado_zone_data = self._tado.data["zone"][self.zone_id] + self._current_tado_hvac_mode = self._tado_zone_data.current_hvac_mode - if self._device_is_active: - # If you set mode manually to off, there will be an overlay - # and a termination, but we want to see the mode "OFF" - self._overlay_mode = termination - self._current_operation = termination - - def _control_heater(self): + def _control_heater(self, hvac_mode=None, target_temp=None): """Send new target temperature.""" - if self._current_operation == CONST_MODE_SMART_SCHEDULE: + + if hvac_mode: + self._current_tado_hvac_mode = hvac_mode + + if target_temp: + self._target_temp = target_temp + + # Set a target temperature if we don't have any + if self._target_temp is None: + self._target_temp = self.min_temp + + if self._current_tado_hvac_mode == CONST_MODE_SMART_SCHEDULE: _LOGGER.debug( "Switching to SMART_SCHEDULE for zone %s (%d)", self.zone_name, self.zone_id, ) self._tado.reset_zone_overlay(self.zone_id) - self._overlay_mode = self._current_operation return - if self._current_operation == CONST_MODE_OFF: + if self._current_tado_hvac_mode == CONST_MODE_OFF: _LOGGER.debug( "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id ) self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER) - self._overlay_mode = self._current_operation return + # Fallback to Smart Schedule at next Schedule switch if we have fallback enabled + overlay_mode = ( + CONST_OVERLAY_TADO_MODE if self._tado.fallback else CONST_OVERLAY_MANUAL + ) + _LOGGER.debug( "Switching to %s for zone %s (%d) with temperature %s", - self._current_operation, + self._current_tado_hvac_mode, self.zone_name, self.zone_id, self._target_temp, ) self._tado.set_zone_overlay( - self.zone_id, - self._current_operation, - self._target_temp, - None, - TYPE_HOT_WATER, + zone_id=self.zone_id, + overlay_mode=overlay_mode, + temperature=self._target_temp, + duration=None, + device_type=TYPE_HOT_WATER, ) - self._overlay_mode = self._current_operation + self._overlay_mode = self._current_tado_hvac_mode diff --git a/homeassistant/components/tesla/.translations/it.json b/homeassistant/components/tesla/.translations/it.json index 0e254cf2843..e9bf5e2d4fe 100644 --- a/homeassistant/components/tesla/.translations/it.json +++ b/homeassistant/components/tesla/.translations/it.json @@ -22,6 +22,7 @@ "step": { "init": { "data": { + "enable_wake_on_start": "Forza il risveglio delle auto all'avvio", "scan_interval": "Secondi tra le scansioni" } } diff --git a/homeassistant/components/vizio/.translations/de.json b/homeassistant/components/vizio/.translations/de.json index f7c0916ef7b..a3b69526943 100644 --- a/homeassistant/components/vizio/.translations/de.json +++ b/homeassistant/components/vizio/.translations/de.json @@ -71,6 +71,7 @@ "timeout": "API Request Timeout (Sekunden)", "volume_step": "Lautst\u00e4rken-Schrittgr\u00f6\u00dfe" }, + "description": "Wenn Sie \u00fcber ein Smart-TV-Ger\u00e4t verf\u00fcgen, k\u00f6nnen Sie Ihre Quellliste optional filtern, indem Sie ausw\u00e4hlen, welche Apps in Ihre Quellliste aufgenommen oder ausgeschlossen werden sollen.", "title": "Aktualisieren Sie die Vizo SmartCast-Optionen" } }, diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index ea5586ef96f..f3b6e2eebd9 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -950,6 +950,15 @@ def async_load_api(hass): schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_GROUP_COMMAND], ) + def _get_ias_wd_channel(zha_device): + """Get the IASWD channel for a device.""" + cluster_channels = { + ch.name: ch + for pool in zha_device.channels.pools + for ch in pool.claimed_channels.values() + } + return cluster_channels.get(CHANNEL_IAS_WD) + async def warning_device_squawk(service): """Issue the squawk command for an IAS warning device.""" ieee = service.data[ATTR_IEEE] @@ -959,9 +968,9 @@ def async_load_api(hass): zha_device = zha_gateway.get_device(ieee) if zha_device is not None: - channel = zha_device.cluster_channels.get(CHANNEL_IAS_WD) + channel = _get_ias_wd_channel(zha_device) if channel: - await channel.squawk(mode, strobe, level) + await channel.issue_squawk(mode, strobe, level) else: _LOGGER.error( "Squawking IASWD: %s: [%s] is missing the required IASWD channel!", @@ -1003,9 +1012,9 @@ def async_load_api(hass): zha_device = zha_gateway.get_device(ieee) if zha_device is not None: - channel = zha_device.cluster_channels.get(CHANNEL_IAS_WD) + channel = _get_ias_wd_channel(zha_device) if channel: - await channel.start_warning( + await channel.issue_start_warning( mode, strobe, level, duration, duty_mode, intensity ) else: diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index 2616161de03..822ae8dd911 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -51,7 +51,7 @@ class IasWd(ZigbeeChannel): """Get the specified bit from the value.""" return (value & (1 << bit)) != 0 - async def squawk( + async def issue_squawk( self, mode=WARNING_DEVICE_SQUAWK_MODE_ARMED, strobe=WARNING_DEVICE_STROBE_YES, @@ -76,7 +76,7 @@ class IasWd(ZigbeeChannel): await self.squawk(value) - async def start_warning( + async def issue_start_warning( self, mode=WARNING_DEVICE_MODE_EMERGENCY, strobe=WARNING_DEVICE_STROBE_YES, diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 14a6a5c839e..21f2f636128 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -474,7 +474,6 @@ class ZHAGateway: """Update the devices in the store.""" for device in self.devices.values(): self.zha_storage.async_update_device(device) - await self.zha_storage.async_save() async def async_device_initialized(self, device: zha_typing.ZigpyDeviceType): """Handle device joined and basic information discovered (async).""" diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 8bb7c3c2149..ea1bc1bbb2f 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -6,7 +6,7 @@ "requirements": [ "bellows-homeassistant==0.14.0", "zha-quirks==0.0.37", - "zigpy-cc==0.3.0", + "zigpy-cc==0.3.1", "zigpy-deconz==0.7.0", "zigpy-homeassistant==0.16.0", "zigpy-xbee-homeassistant==0.10.0", diff --git a/homeassistant/const.py b/homeassistant/const.py index 74095a2583b..2f1cc75e4a5 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -184,6 +184,7 @@ EVENT_CORE_CONFIG_UPDATE = "core_config_updated" EVENT_HOMEASSISTANT_CLOSE = "homeassistant_close" EVENT_HOMEASSISTANT_START = "homeassistant_start" EVENT_HOMEASSISTANT_STOP = "homeassistant_stop" +EVENT_HOMEASSISTANT_FINAL_WRITE = "homeassistant_final_write" EVENT_LOGBOOK_ENTRY = "logbook_entry" EVENT_PLATFORM_DISCOVERED = "platform_discovered" EVENT_SCRIPT_STARTED = "script_started" diff --git a/homeassistant/core.py b/homeassistant/core.py index fd894fd6c05..9265c57bbf3 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -47,6 +47,7 @@ from homeassistant.const import ( EVENT_CALL_SERVICE, EVENT_CORE_CONFIG_UPDATE, EVENT_HOMEASSISTANT_CLOSE, + EVENT_HOMEASSISTANT_FINAL_WRITE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_SERVICE_REGISTERED, @@ -151,6 +152,7 @@ class CoreState(enum.Enum): starting = "STARTING" running = "RUNNING" stopping = "STOPPING" + writing_data = "WRITING_DATA" def __str__(self) -> str: """Return the event.""" @@ -412,7 +414,7 @@ class HomeAssistant: # regardless of the state of the loop. if self.state == CoreState.not_running: # just ignore return - if self.state == CoreState.stopping: + if self.state == CoreState.stopping or self.state == CoreState.writing_data: _LOGGER.info("async_stop called twice: ignored") return if self.state == CoreState.starting: @@ -426,6 +428,11 @@ class HomeAssistant: await self.async_block_till_done() # stage 2 + self.state = CoreState.writing_data + self.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) + await self.async_block_till_done() + + # stage 3 self.state = CoreState.not_running self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE) await self.async_block_till_done() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 05bc4a7ba4a..2b96c63f4d7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -54,6 +54,7 @@ FLOWS = [ "ifttt", "ios", "ipma", + "ipp", "iqvia", "izone", "konnected", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 968a73588e7..46b3a9943f8 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -25,6 +25,12 @@ ZEROCONF = { "_hap._tcp.local.": [ "homekit_controller" ], + "_ipp._tcp.local.": [ + "ipp" + ], + "_ipps._tcp.local.": [ + "ipp" + ], "_printer._tcp.local.": [ "brother" ], diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index d57d3ad9920..0757770d2f7 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -4,7 +4,10 @@ from datetime import datetime, timedelta import logging from typing import Any, Awaitable, Dict, List, Optional, Set, cast -from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + EVENT_HOMEASSISTANT_FINAL_WRITE, + EVENT_HOMEASSISTANT_START, +) from homeassistant.core import ( CoreState, HomeAssistant, @@ -184,7 +187,9 @@ class RestoreStateData: async_track_time_interval(self.hass, _async_dump_states, STATE_DUMP_INTERVAL) # Dump states when stopping hass - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_dump_states) + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_FINAL_WRITE, _async_dump_states + ) @callback def async_restore_entity_added(self, entity_id: str) -> None: diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index 1cad8eec473..5885aa01e6f 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -5,7 +5,7 @@ import logging import os from typing import Any, Callable, Dict, List, Optional, Type, Union -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_call_later from homeassistant.loader import bind_hass @@ -153,7 +153,7 @@ class Store: """Ensure that we write if we quit before delay has passed.""" if self._unsub_stop_listener is None: self._unsub_stop_listener = self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, self._async_callback_stop_write + EVENT_HOMEASSISTANT_FINAL_WRITE, self._async_callback_stop_write ) @callback diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 26571ab7a27..35423033cf9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.32.2 -home-assistant-frontend==20200318.1 +home-assistant-frontend==20200330.0 importlib-metadata==1.5.0 jinja2>=2.11.1 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 55ed045a709..46fd27fa1fd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -140,7 +140,7 @@ aio_geojson_nsw_rfs_incidents==0.3 aio_georss_gdacs==0.3 # homeassistant.components.ambient_station -aioambient==1.0.4 +aioambient==1.1.0 # homeassistant.components.asuswrt aioasuswrt==1.2.3 @@ -168,7 +168,7 @@ aioftp==0.12.0 aioharmony==0.1.13 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.35 +aiohomekit[IP]==0.2.37 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -184,7 +184,7 @@ aioimaplib==0.7.15 aiokafka==0.5.1 # homeassistant.components.kef -aiokef==0.2.7 +aiokef==0.2.9 # homeassistant.components.lifx aiolifx==0.6.7 @@ -704,7 +704,7 @@ hole==0.5.1 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200318.1 +home-assistant-frontend==20200330.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -964,7 +964,7 @@ onkyo-eiscp==1.2.7 onvif-zeep-async==0.2.0 # homeassistant.components.opencv -# opencv-python-headless==4.1.2.30 +# opencv-python-headless==4.2.0.32 # homeassistant.components.openevse openevsewifi==0.4 @@ -1335,6 +1335,9 @@ pyintesishome==1.7.1 # homeassistant.components.ipma pyipma==2.0.5 +# homeassistant.components.ipp +pyipp==0.8.1 + # homeassistant.components.iqvia pyiqvia==0.2.1 @@ -1596,7 +1599,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.13.2 # homeassistant.components.ecobee -python-ecobee-api==0.2.3 +python-ecobee-api==0.2.5 # homeassistant.components.eq3btsmart # python-eq3bt==0.1.11 @@ -1876,7 +1879,7 @@ sisyphus-control==2.2.1 skybellpy==0.4.0 # homeassistant.components.slack -slacker==0.14.0 +slackclient==2.5.0 # homeassistant.components.sleepiq sleepyq==0.7 @@ -2182,7 +2185,7 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-cc==0.3.0 +zigpy-cc==0.3.1 # homeassistant.components.zha zigpy-deconz==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 210725ef045..09542dddd54 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -47,7 +47,7 @@ aio_geojson_nsw_rfs_incidents==0.3 aio_georss_gdacs==0.3 # homeassistant.components.ambient_station -aioambient==1.0.4 +aioambient==1.1.0 # homeassistant.components.asuswrt aioasuswrt==1.2.3 @@ -72,7 +72,7 @@ aiofreepybox==0.0.8 aioharmony==0.1.13 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.35 +aiohomekit[IP]==0.2.37 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -282,7 +282,7 @@ hole==0.5.1 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200318.1 +home-assistant-frontend==20200330.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 @@ -518,6 +518,9 @@ pyicloud==0.9.6.1 # homeassistant.components.ipma pyipma==2.0.5 +# homeassistant.components.ipp +pyipp==0.8.1 + # homeassistant.components.iqvia pyiqvia==0.2.1 @@ -602,7 +605,7 @@ pysonos==0.0.25 pyspcwebgw==0.4.0 # homeassistant.components.ecobee -python-ecobee-api==0.2.3 +python-ecobee-api==0.2.5 # homeassistant.components.darksky python-forecastio==1.4.0 @@ -616,6 +619,9 @@ python-miio==0.4.8 # homeassistant.components.nest python-nest==4.1.0 +# homeassistant.components.tado +python-tado==0.5.0 + # homeassistant.components.twitch python-twitch-client==0.6.0 @@ -792,7 +798,7 @@ zeroconf==0.24.5 zha-quirks==0.0.37 # homeassistant.components.zha -zigpy-cc==0.3.0 +zigpy-cc==0.3.1 # homeassistant.components.zha zigpy-deconz==0.7.0 diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 82c0c0dbdbd..edcd01d51e1 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -899,8 +899,8 @@ async def test_async_remove_user(hass): assert events[0].data["user_id"] == user.id -async def test_new_users_admin(mock_hass): - """Test newly created users are admin.""" +async def test_new_users(mock_hass): + """Test newly created users.""" manager = await auth.auth_manager_from_config( mock_hass, [ @@ -911,7 +911,17 @@ async def test_new_users_admin(mock_hass): "username": "test-user", "password": "test-pass", "name": "Test Name", - } + }, + { + "username": "test-user-2", + "password": "test-pass", + "name": "Test Name", + }, + { + "username": "test-user-3", + "password": "test-pass", + "name": "Test Name", + }, ], } ], @@ -920,7 +930,18 @@ async def test_new_users_admin(mock_hass): ensure_auth_manager_loaded(manager) user = await manager.async_create_user("Hello") + # first user in the system is owner and admin + assert user.is_owner assert user.is_admin + assert user.groups == [] + + user = await manager.async_create_user("Hello 2") + assert not user.is_admin + assert user.groups == [] + + user = await manager.async_create_user("Hello 3", ["system-admin"]) + assert user.is_admin + assert user.groups[0].id == "system-admin" user_cred = await manager.async_get_or_create_user( auth_models.Credentials( diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index b07df39a8fe..53defb4cd6e 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -156,8 +156,35 @@ async def test_create(hass, hass_ws_client, hass_access_token): assert len(await hass.auth.async_get_users()) == 1 + await client.send_json({"id": 5, "type": "config/auth/create", "name": "Paulus"}) + + result = await client.receive_json() + assert result["success"], result + assert len(await hass.auth.async_get_users()) == 2 + data_user = result["result"]["user"] + user = await hass.auth.async_get_user(data_user["id"]) + assert user is not None + assert user.name == data_user["name"] + assert user.is_active + assert user.groups == [] + assert not user.is_admin + assert not user.is_owner + assert not user.system_generated + + +async def test_create_user_group(hass, hass_ws_client, hass_access_token): + """Test create user with a group.""" + client = await hass_ws_client(hass, hass_access_token) + + assert len(await hass.auth.async_get_users()) == 1 + await client.send_json( - {"id": 5, "type": auth_config.WS_TYPE_CREATE, "name": "Paulus"} + { + "id": 5, + "type": "config/auth/create", + "name": "Paulus", + "group_ids": ["system-admin"], + } ) result = await client.receive_json() @@ -168,6 +195,8 @@ async def test_create(hass, hass_ws_client, hass_access_token): assert user is not None assert user.name == data_user["name"] assert user.is_active + assert user.groups[0].id == "system-admin" + assert user.is_admin assert not user.is_owner assert not user.system_generated @@ -176,7 +205,7 @@ async def test_create_requires_admin(hass, hass_ws_client, hass_read_only_access """Test create command requires an admin.""" client = await hass_ws_client(hass, hass_read_only_access_token) - await client.send_json({"id": 5, "type": auth_config.WS_TYPE_CREATE, "name": "YO"}) + await client.send_json({"id": 5, "type": "config/auth/create", "name": "YO"}) result = await client.receive_json() assert not result["success"], result diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 51f1e3cb2ac..64b438a29fc 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -522,6 +522,62 @@ class TestComponentHistory(unittest.TestCase): ) assert list(hist.keys()) == entity_ids + def test_get_significant_states_only(self): + """Test significant states when significant_states_only is set.""" + self.init_recorder() + entity_id = "sensor.test" + + def set_state(state, **kwargs): + """Set the state.""" + self.hass.states.set(entity_id, state, **kwargs) + wait_recording_done(self.hass) + return self.hass.states.get(entity_id) + + start = dt_util.utcnow() - timedelta(minutes=4) + points = [] + for i in range(1, 4): + points.append(start + timedelta(minutes=i)) + + states = [] + with patch( + "homeassistant.components.recorder.dt_util.utcnow", return_value=start + ): + set_state("123", attributes={"attribute": 10.64}) + + with patch( + "homeassistant.components.recorder.dt_util.utcnow", return_value=points[0] + ): + # Attributes are different, state not + states.append(set_state("123", attributes={"attribute": 21.42})) + + with patch( + "homeassistant.components.recorder.dt_util.utcnow", return_value=points[1] + ): + # state is different, attributes not + states.append(set_state("32", attributes={"attribute": 21.42})) + + with patch( + "homeassistant.components.recorder.dt_util.utcnow", return_value=points[2] + ): + # everything is different + states.append(set_state("412", attributes={"attribute": 54.23})) + + hist = history.get_significant_states( + self.hass, start, significant_changes_only=True + ) + + assert len(hist[entity_id]) == 2 + assert states[0] not in hist[entity_id] + assert states[1] in hist[entity_id] + assert states[2] in hist[entity_id] + + hist = history.get_significant_states( + self.hass, start, significant_changes_only=False + ) + + assert len(hist[entity_id]) == 3 + assert states == hist[entity_id] + def check_significant_states(self, zero, four, states, config): """Check if significant states are retrieved.""" filters = history.Filters() diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 87d4fbdcc2b..eb7429aa47e 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -5,8 +5,11 @@ import pytest from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, + ATTR_CURRENT_TILT_POSITION, ATTR_POSITION, + ATTR_TILT_POSITION, DOMAIN, + SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, ) from homeassistant.components.homekit.const import ATTR_VALUE @@ -14,6 +17,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, EVENT_HOMEASSISTANT_START, + SERVICE_SET_COVER_TILT_POSITION, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, @@ -193,6 +197,72 @@ async def test_window_set_cover_position(hass, hk_driver, cls, events): assert events[-1].data[ATTR_VALUE] == 75 +async def test_window_cover_set_tilt(hass, hk_driver, cls, events): + """Test if accessory and HA update slat tilt accordingly.""" + entity_id = "cover.window" + + hass.states.async_set( + entity_id, STATE_UNKNOWN, {ATTR_SUPPORTED_FEATURES: SUPPORT_SET_TILT_POSITION} + ) + await hass.async_block_till_done() + acc = cls.window(hass, hk_driver, "Cover", entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 14 # CATEGORY_WINDOW_COVERING + + assert acc.char_current_tilt.value == 0 + assert acc.char_target_tilt.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: None}) + await hass.async_block_till_done() + assert acc.char_current_tilt.value == 0 + assert acc.char_target_tilt.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 100}) + await hass.async_block_till_done() + assert acc.char_current_tilt.value == 90 + assert acc.char_target_tilt.value == 90 + + hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 50}) + await hass.async_block_till_done() + assert acc.char_current_tilt.value == 0 + assert acc.char_target_tilt.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN, {ATTR_CURRENT_TILT_POSITION: 0}) + await hass.async_block_till_done() + assert acc.char_current_tilt.value == -90 + assert acc.char_target_tilt.value == -90 + + # set from HomeKit + call_set_tilt_position = async_mock_service( + hass, DOMAIN, SERVICE_SET_COVER_TILT_POSITION + ) + + # HomeKit sets tilts between -90 and 90 (degrees), whereas + # Homeassistant expects a % between 0 and 100. Keep that in mind + # when comparing + await hass.async_add_job(acc.char_target_tilt.client_update_value, 90) + await hass.async_block_till_done() + assert call_set_tilt_position[0] + assert call_set_tilt_position[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_tilt_position[0].data[ATTR_TILT_POSITION] == 100 + assert acc.char_current_tilt.value == -90 + assert acc.char_target_tilt.value == 90 + assert len(events) == 1 + assert events[-1].data[ATTR_VALUE] == 100 + + await hass.async_add_job(acc.char_target_tilt.client_update_value, 45) + await hass.async_block_till_done() + assert call_set_tilt_position[1] + assert call_set_tilt_position[1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_tilt_position[1].data[ATTR_TILT_POSITION] == 75 + assert acc.char_current_tilt.value == -90 + assert acc.char_target_tilt.value == 45 + assert len(events) == 2 + assert events[-1].data[ATTR_VALUE] == 75 + + async def test_window_open_close(hass, hk_driver, cls, events): """Test if accessory and HA are updated accordingly.""" entity_id = "cover.window" diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 3cb45182399..71efac3a7c9 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -26,11 +26,11 @@ async def test_hmip_load_all_supported_devices(hass, default_mock_hap_factory): async def test_hmip_remove_device(hass, default_mock_hap_factory): """Test Remove of hmip device.""" - entity_id = "light.treppe" - entity_name = "Treppe" + entity_id = "light.treppe_ch" + entity_name = "Treppe CH" device_model = "HmIP-BSL" mock_hap = await default_mock_hap_factory.async_get_mock_hap( - test_devices=[entity_name] + test_devices=["Treppe"] ) ha_state, hmip_device = get_and_check_entity_basics( @@ -58,11 +58,11 @@ async def test_hmip_remove_device(hass, default_mock_hap_factory): async def test_hmip_add_device(hass, default_mock_hap_factory, hmip_config_entry): """Test Remove of hmip device.""" - entity_id = "light.treppe" - entity_name = "Treppe" + entity_id = "light.treppe_ch" + entity_name = "Treppe CH" device_model = "HmIP-BSL" mock_hap = await default_mock_hap_factory.async_get_mock_hap( - test_devices=[entity_name] + test_devices=["Treppe"] ) ha_state, hmip_device = get_and_check_entity_basics( @@ -137,11 +137,11 @@ async def test_all_devices_unavailable_when_hap_not_connected( hass, default_mock_hap_factory ): """Test make all devices unavaulable when hap is not connected.""" - entity_id = "light.treppe" - entity_name = "Treppe" + entity_id = "light.treppe_ch" + entity_name = "Treppe CH" device_model = "HmIP-BSL" mock_hap = await default_mock_hap_factory.async_get_mock_hap( - test_devices=[entity_name] + test_devices=["Treppe"] ) ha_state, hmip_device = get_and_check_entity_basics( @@ -161,11 +161,11 @@ async def test_all_devices_unavailable_when_hap_not_connected( async def test_hap_reconnected(hass, default_mock_hap_factory): """Test reconnect hap.""" - entity_id = "light.treppe" - entity_name = "Treppe" + entity_id = "light.treppe_ch" + entity_name = "Treppe CH" device_model = "HmIP-BSL" mock_hap = await default_mock_hap_factory.async_get_mock_hap( - test_devices=[entity_name] + test_devices=["Treppe"] ) ha_state, hmip_device = get_and_check_entity_basics( @@ -192,8 +192,8 @@ async def test_hap_reconnected(hass, default_mock_hap_factory): async def test_hap_with_name(hass, mock_connection, hmip_config_entry): """Test hap with name.""" home_name = "TestName" - entity_id = f"light.{home_name.lower()}_treppe" - entity_name = f"{home_name} Treppe" + entity_id = f"light.{home_name.lower()}_treppe_ch" + entity_name = f"{home_name} Treppe CH" device_model = "HmIP-BSL" hmip_config_entry.data = {**hmip_config_entry.data, "name": home_name} diff --git a/tests/components/homematicip_cloud/test_light.py b/tests/components/homematicip_cloud/test_light.py index 8909e469ee9..8ab62019c3d 100644 --- a/tests/components/homematicip_cloud/test_light.py +++ b/tests/components/homematicip_cloud/test_light.py @@ -27,11 +27,11 @@ async def test_manually_configured_platform(hass): async def test_hmip_light(hass, default_mock_hap_factory): """Test HomematicipLight.""" - entity_id = "light.treppe" - entity_name = "Treppe" + entity_id = "light.treppe_ch" + entity_name = "Treppe CH" device_model = "HmIP-BSL" mock_hap = await default_mock_hap_factory.async_get_mock_hap( - test_devices=[entity_name] + test_devices=["Treppe"] ) ha_state, hmip_device = get_and_check_entity_basics( @@ -66,8 +66,8 @@ async def test_hmip_light(hass, default_mock_hap_factory): async def test_hmip_notification_light(hass, default_mock_hap_factory): """Test HomematicipNotificationLight.""" - entity_id = "light.treppe_top_notification" - entity_name = "Treppe Top Notification" + entity_id = "light.alarm_status" + entity_name = "Alarm Status" device_model = "HmIP-BSL" mock_hap = await default_mock_hap_factory.async_get_mock_hap( test_devices=["Treppe"] diff --git a/tests/components/ipp/__init__.py b/tests/components/ipp/__init__.py new file mode 100644 index 00000000000..6bf162725e1 --- /dev/null +++ b/tests/components/ipp/__init__.py @@ -0,0 +1,95 @@ +"""Tests for the IPP integration.""" +import os + +from homeassistant.components.ipp.const import CONF_BASE_PATH, CONF_UUID, DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PORT, + CONF_SSL, + CONF_TYPE, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + +ATTR_HOSTNAME = "hostname" +ATTR_PROPERTIES = "properties" + +IPP_ZEROCONF_SERVICE_TYPE = "_ipp._tcp.local." +IPPS_ZEROCONF_SERVICE_TYPE = "_ipps._tcp.local." + +ZEROCONF_NAME = "EPSON123456" +ZEROCONF_HOST = "1.2.3.4" +ZEROCONF_HOSTNAME = "EPSON123456.local." +ZEROCONF_PORT = 631 + + +MOCK_USER_INPUT = { + CONF_HOST: "EPSON123456.local", + CONF_PORT: 361, + CONF_SSL: False, + CONF_VERIFY_SSL: False, + CONF_BASE_PATH: "/ipp/print", +} + +MOCK_ZEROCONF_IPP_SERVICE_INFO = { + CONF_TYPE: IPP_ZEROCONF_SERVICE_TYPE, + CONF_NAME: ZEROCONF_NAME, + CONF_HOST: ZEROCONF_HOST, + ATTR_HOSTNAME: ZEROCONF_HOSTNAME, + CONF_PORT: ZEROCONF_PORT, + ATTR_PROPERTIES: {"rp": "ipp/print"}, +} + +MOCK_ZEROCONF_IPPS_SERVICE_INFO = { + CONF_TYPE: IPPS_ZEROCONF_SERVICE_TYPE, + CONF_NAME: ZEROCONF_NAME, + CONF_HOST: ZEROCONF_HOST, + ATTR_HOSTNAME: ZEROCONF_HOSTNAME, + CONF_PORT: ZEROCONF_PORT, + ATTR_PROPERTIES: {"rp": "ipp/print"}, +} + + +def load_fixture_binary(filename): + """Load a binary fixture.""" + path = os.path.join(os.path.dirname(__file__), "..", "..", "fixtures", filename) + with open(path, "rb") as fptr: + return fptr.read() + + +async def init_integration( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, skip_setup: bool = False, +) -> MockConfigEntry: + """Set up the IPP integration in Home Assistant.""" + + fixture = "ipp/get-printer-attributes.bin" + aioclient_mock.post( + "http://EPSON123456.local:631/ipp/print", + content=load_fixture_binary(fixture), + headers={"Content-Type": "application/ipp"}, + ) + + entry = MockConfigEntry( + domain=DOMAIN, + unique_id="cfe92100-67c4-11d4-a45f-f8d027761251", + data={ + CONF_HOST: "EPSON123456.local", + CONF_PORT: 631, + CONF_SSL: False, + CONF_VERIFY_SSL: True, + CONF_BASE_PATH: "/ipp/print", + CONF_UUID: "cfe92100-67c4-11d4-a45f-f8d027761251", + }, + ) + + entry.add_to_hass(hass) + + if not skip_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/ipp/test_config_flow.py b/tests/components/ipp/test_config_flow.py new file mode 100644 index 00000000000..505ba618505 --- /dev/null +++ b/tests/components/ipp/test_config_flow.py @@ -0,0 +1,306 @@ +"""Tests for the IPP config flow.""" +import aiohttp +from pyipp import IPPConnectionUpgradeRequired + +from homeassistant import data_entry_flow +from homeassistant.components.ipp import config_flow +from homeassistant.components.ipp.const import CONF_BASE_PATH, CONF_UUID +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SSL +from homeassistant.core import HomeAssistant + +from . import ( + MOCK_USER_INPUT, + MOCK_ZEROCONF_IPP_SERVICE_INFO, + MOCK_ZEROCONF_IPPS_SERVICE_INFO, + init_integration, + load_fixture_binary, +) + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_show_user_form(hass: HomeAssistant) -> None: + """Test that the user set up form is served.""" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + +async def test_show_zeroconf_confirm_form(hass: HomeAssistant) -> None: + """Test that the zeroconf confirmation form is served.""" + flow = config_flow.IPPFlowHandler() + flow.hass = hass + flow.context = {"source": SOURCE_ZEROCONF} + flow.discovery_info = {CONF_NAME: "EPSON123456"} + + result = await flow.async_step_zeroconf_confirm() + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["description_placeholders"] == {CONF_NAME: "EPSON123456"} + + +async def test_show_zeroconf_form( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test that the zeroconf confirmation form is served.""" + aioclient_mock.post( + "http://EPSON123456.local:631/ipp/print", + content=load_fixture_binary("ipp/get-printer-attributes.bin"), + headers={"Content-Type": "application/ipp"}, + ) + + flow = config_flow.IPPFlowHandler() + flow.hass = hass + flow.context = {"source": SOURCE_ZEROCONF} + + discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + result = await flow.async_step_zeroconf(discovery_info) + + assert flow.discovery_info[CONF_HOST] == "EPSON123456.local" + assert flow.discovery_info[CONF_NAME] == "EPSON123456" + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["description_placeholders"] == {CONF_NAME: "EPSON123456"} + + +async def test_connection_error( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we show user form on IPP connection error.""" + aioclient_mock.post( + "http://EPSON123456.local:631/ipp/print", exc=aiohttp.ClientError + ) + + user_input = MOCK_USER_INPUT.copy() + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": SOURCE_USER}, data=user_input, + ) + + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "connection_error"} + + +async def test_zeroconf_connection_error( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort zeroconf flow on IPP connection error.""" + aioclient_mock.post("http://EPSON123456.local/ipp/print", exc=aiohttp.ClientError) + + discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "connection_error" + + +async def test_zeroconf_confirm_connection_error( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort zeroconf flow on IPP connection error.""" + aioclient_mock.post("http://EPSON123456.local/ipp/print", exc=aiohttp.ClientError) + + discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={ + "source": SOURCE_ZEROCONF, + CONF_HOST: "EPSON123456.local", + CONF_NAME: "EPSON123456", + }, + data=discovery_info, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "connection_error" + + +async def test_user_connection_upgrade_required( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we show the user form if connection upgrade required by server.""" + aioclient_mock.post( + "http://EPSON123456.local:631/ipp/print", exc=IPPConnectionUpgradeRequired + ) + + user_input = MOCK_USER_INPUT.copy() + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": SOURCE_USER}, data=user_input, + ) + + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "connection_upgrade"} + + +async def test_zeroconf_connection_upgrade_required( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort zeroconf flow on IPP connection error.""" + aioclient_mock.post( + "http://EPSON123456.local/ipp/print", exc=IPPConnectionUpgradeRequired + ) + + discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "connection_upgrade" + + +async def test_user_device_exists_abort( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort user flow if printer already configured.""" + await init_integration(hass, aioclient_mock) + + user_input = MOCK_USER_INPUT.copy() + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": SOURCE_USER}, data=user_input, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_device_exists_abort( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort zeroconf flow if printer already configured.""" + await init_integration(hass, aioclient_mock) + + discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_zeroconf_with_uuid_device_exists_abort( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test we abort zeroconf flow if printer already configured.""" + await init_integration(hass, aioclient_mock) + + discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + discovery_info["properties"]["UUID"] = "cfe92100-67c4-11d4-a45f-f8d027761251" + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_full_user_flow_implementation( + hass: HomeAssistant, aioclient_mock +) -> None: + """Test the full manual user flow from start to finish.""" + aioclient_mock.post( + "http://EPSON123456.local:631/ipp/print", + content=load_fixture_binary("ipp/get-printer-attributes.bin"), + headers={"Content-Type": "application/ipp"}, + ) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": SOURCE_USER}, + ) + + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "EPSON123456.local", CONF_BASE_PATH: "/ipp/print"}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "EPSON123456.local" + + assert result["data"] + assert result["data"][CONF_HOST] == "EPSON123456.local" + assert result["data"][CONF_UUID] == "cfe92100-67c4-11d4-a45f-f8d027761251" + + +async def test_full_zeroconf_flow_implementation( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the full manual user flow from start to finish.""" + aioclient_mock.post( + "http://EPSON123456.local:631/ipp/print", + content=load_fixture_binary("ipp/get-printer-attributes.bin"), + headers={"Content-Type": "application/ipp"}, + ) + + flow = config_flow.IPPFlowHandler() + flow.hass = hass + flow.context = {"source": SOURCE_ZEROCONF} + + discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy() + result = await flow.async_step_zeroconf(discovery_info) + + assert flow.discovery_info + assert flow.discovery_info[CONF_HOST] == "EPSON123456.local" + assert flow.discovery_info[CONF_NAME] == "EPSON123456" + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + result = await flow.async_step_zeroconf_confirm( + user_input={CONF_HOST: "EPSON123456.local"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "EPSON123456" + + assert result["data"] + assert result["data"][CONF_HOST] == "EPSON123456.local" + assert result["data"][CONF_UUID] == "cfe92100-67c4-11d4-a45f-f8d027761251" + assert not result["data"][CONF_SSL] + + +async def test_full_zeroconf_tls_flow_implementation( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the full manual user flow from start to finish.""" + aioclient_mock.post( + "https://EPSON123456.local:631/ipp/print", + content=load_fixture_binary("ipp/get-printer-attributes.bin"), + headers={"Content-Type": "application/ipp"}, + ) + + flow = config_flow.IPPFlowHandler() + flow.hass = hass + flow.context = {"source": SOURCE_ZEROCONF} + + discovery_info = MOCK_ZEROCONF_IPPS_SERVICE_INFO.copy() + result = await flow.async_step_zeroconf(discovery_info) + + assert result["step_id"] == "zeroconf_confirm" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["description_placeholders"] == {CONF_NAME: "EPSON123456"} + + result = await flow.async_step_zeroconf_confirm( + user_input={CONF_HOST: "EPSON123456.local"} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "EPSON123456" + + assert result["data"] + assert result["data"][CONF_HOST] == "EPSON123456.local" + assert result["data"][CONF_NAME] == "EPSON123456" + assert result["data"][CONF_UUID] == "cfe92100-67c4-11d4-a45f-f8d027761251" + assert result["data"][CONF_SSL] diff --git a/tests/components/ipp/test_init.py b/tests/components/ipp/test_init.py new file mode 100644 index 00000000000..7d3d0692e28 --- /dev/null +++ b/tests/components/ipp/test_init.py @@ -0,0 +1,42 @@ +"""Tests for the IPP integration.""" +import aiohttp + +from homeassistant.components.ipp.const import DOMAIN +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_RETRY, +) +from homeassistant.core import HomeAssistant + +from tests.components.ipp import init_integration +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_config_entry_not_ready( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the IPP configuration entry not ready.""" + aioclient_mock.post( + "http://EPSON123456.local:631/ipp/print", exc=aiohttp.ClientError + ) + + entry = await init_integration(hass, aioclient_mock) + assert entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_unload_config_entry( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the IPP configuration entry unloading.""" + entry = await init_integration(hass, aioclient_mock) + + assert hass.data[DOMAIN] + assert entry.entry_id in hass.data[DOMAIN] + assert entry.state == ENTRY_STATE_LOADED + + await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state == ENTRY_STATE_NOT_LOADED diff --git a/tests/components/ipp/test_sensor.py b/tests/components/ipp/test_sensor.py new file mode 100644 index 00000000000..b7db606d870 --- /dev/null +++ b/tests/components/ipp/test_sensor.py @@ -0,0 +1,96 @@ +"""Tests for the IPP sensor platform.""" +from datetime import datetime + +from asynctest import patch + +from homeassistant.components.ipp.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, UNIT_PERCENTAGE +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.components.ipp import init_integration +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_sensors( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the creation and values of the IPP sensors.""" + entry = await init_integration(hass, aioclient_mock, skip_setup=True) + registry = await hass.helpers.entity_registry.async_get_registry() + + # Pre-create registry entries for disabled by default sensors + registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + "cfe92100-67c4-11d4-a45f-f8d027761251_uptime", + suggested_object_id="epson_xp_6000_series_uptime", + disabled_by=None, + ) + + test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=dt_util.UTC) + with patch("homeassistant.components.ipp.sensor.utcnow", return_value=test_time): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.epson_xp_6000_series") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:printer" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + + state = hass.states.get("sensor.epson_xp_6000_series_black_ink") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:water" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UNIT_PERCENTAGE + assert state.state == "58" + + state = hass.states.get("sensor.epson_xp_6000_series_photo_black_ink") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:water" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UNIT_PERCENTAGE + assert state.state == "98" + + state = hass.states.get("sensor.epson_xp_6000_series_cyan_ink") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:water" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UNIT_PERCENTAGE + assert state.state == "91" + + state = hass.states.get("sensor.epson_xp_6000_series_yellow_ink") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:water" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UNIT_PERCENTAGE + assert state.state == "95" + + state = hass.states.get("sensor.epson_xp_6000_series_magenta_ink") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:water" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is UNIT_PERCENTAGE + assert state.state == "73" + + state = hass.states.get("sensor.epson_xp_6000_series_uptime") + assert state + assert state.attributes.get(ATTR_ICON) == "mdi:clock-outline" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert state.state == "2019-10-26T15:37:00+00:00" + + entry = registry.async_get("sensor.epson_xp_6000_series_uptime") + assert entry + assert entry.unique_id == "cfe92100-67c4-11d4-a45f-f8d027761251_uptime" + + +async def test_disabled_by_default_sensors( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test the disabled by default IPP sensors.""" + await init_integration(hass, aioclient_mock) + registry = await hass.helpers.entity_registry.async_get_registry() + + state = hass.states.get("sensor.epson_xp_6000_series_uptime") + assert state is None + + entry = registry.async_get("sensor.epson_xp_6000_series_uptime") + assert entry + assert entry.disabled + assert entry.disabled_by == "integration" diff --git a/tests/components/konnected/test_config_flow.py b/tests/components/konnected/test_config_flow.py index 3638f40735b..35814154f47 100644 --- a/tests/components/konnected/test_config_flow.py +++ b/tests/components/konnected/test_config_flow.py @@ -403,6 +403,14 @@ async def test_import_existing_config(hass, mock_panel): "pause": 100, "repeat": 4, }, + { + "zone": 8, + "name": "alarm", + "activation": "low", + "momentary": 100, + "pause": 100, + "repeat": -1, + }, {"zone": "out1"}, {"zone": "alarm1"}, ], @@ -463,6 +471,14 @@ async def test_import_existing_config(hass, mock_panel): "pause": 100, "repeat": 4, }, + { + "zone": "8", + "name": "alarm", + "activation": "low", + "momentary": 100, + "pause": 100, + "repeat": -1, + }, {"activation": "high", "zone": "out1"}, {"activation": "high", "zone": "alarm1"}, ], @@ -713,6 +729,7 @@ async def test_option_flow(hass, mock_panel): assert result["step_id"] == "options_switch" assert result["description_placeholders"] == { "zone": "Zone 4", + "state": "1", } # zone 4 @@ -723,6 +740,7 @@ async def test_option_flow(hass, mock_panel): assert result["step_id"] == "options_switch" assert result["description_placeholders"] == { "zone": "OUT", + "state": "1", } # zone out @@ -734,6 +752,27 @@ async def test_option_flow(hass, mock_panel): "momentary": 50, "pause": 100, "repeat": 4, + "more_states": "Yes", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "options_switch" + assert result["description_placeholders"] == { + "zone": "OUT", + "state": "2", + } + + # zone out - state 2 + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "name": "alarm", + "activation": "low", + "momentary": 100, + "pause": 100, + "repeat": -1, + "more_states": "No", }, ) @@ -768,6 +807,14 @@ async def test_option_flow(hass, mock_panel): "pause": 100, "repeat": 4, }, + { + "zone": "out", + "name": "alarm", + "activation": "low", + "momentary": 100, + "pause": 100, + "repeat": -1, + }, ], } @@ -977,6 +1024,14 @@ async def test_option_flow_import(hass, mock_panel): "pause": 100, "repeat": 4, }, + { + "zone": "3", + "name": "alarm", + "activation": "low", + "momentary": 100, + "pause": 100, + "repeat": -1, + }, ], } ) @@ -1056,8 +1111,9 @@ async def test_option_flow_import(hass, mock_panel): assert schema["momentary"] == 50 assert schema["pause"] == 100 assert schema["repeat"] == 4 + assert schema["more_states"] == "Yes" result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={"activation": "high"} + result["flow_id"], user_input={"activation": "high", "more_states": "No"} ) assert result["type"] == "form" assert result["step_id"] == "options_misc" diff --git a/tests/components/konnected/test_init.py b/tests/components/konnected/test_init.py index e410aa9d60a..a678716bc03 100644 --- a/tests/components/konnected/test_init.py +++ b/tests/components/konnected/test_init.py @@ -124,7 +124,7 @@ async def test_config_schema(hass): } } - # check pin to zone + # check pin to zone and multiple output config = { konnected.DOMAIN: { konnected.CONF_ACCESS_TOKEN: "abcdefgh", @@ -135,6 +135,22 @@ async def test_config_schema(hass): {"pin": 2, "type": "door"}, {"zone": 1, "type": "door"}, ], + "switches": [ + { + "zone": 3, + "name": "Beep Beep", + "momentary": 65, + "pause": 55, + "repeat": 4, + }, + { + "zone": 3, + "name": "Warning", + "momentary": 100, + "pause": 100, + "repeat": -1, + }, + ], } ], } @@ -153,7 +169,7 @@ async def test_config_schema(hass): "11": "Disabled", "12": "Disabled", "2": "Binary Sensor", - "3": "Disabled", + "3": "Switchable Output", "4": "Disabled", "5": "Disabled", "6": "Disabled", @@ -169,6 +185,24 @@ async def test_config_schema(hass): {"inverse": False, "type": "door", "zone": "2"}, {"inverse": False, "type": "door", "zone": "1"}, ], + "switches": [ + { + "zone": "3", + "activation": "high", + "name": "Beep Beep", + "momentary": 65, + "pause": 55, + "repeat": 4, + }, + { + "zone": "3", + "activation": "high", + "name": "Warning", + "momentary": 100, + "pause": 100, + "repeat": -1, + }, + ], }, "id": "aabbccddeeff", } diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 93036335e16..45c123fa2fe 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -13,7 +13,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) -from .common import ( +from .test_common import ( help_test_availability_without_topic, help_test_custom_availability_payload, help_test_default_availability_payload, @@ -25,7 +25,8 @@ from .common import ( help_test_entity_device_info_update, help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, - help_test_entity_id_update, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_unique_id, @@ -481,8 +482,15 @@ async def test_entity_device_info_remove(hass, mqtt_mock): ) -async def test_entity_id_update(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - await help_test_entity_id_update( + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """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 ) diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 7b104089073..a73919844c1 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -15,7 +15,7 @@ import homeassistant.core as ha from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .common import ( +from .test_common import ( help_test_availability_without_topic, help_test_custom_availability_payload, help_test_default_availability_payload, @@ -27,7 +27,8 @@ from .common import ( help_test_entity_device_info_update, help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, - help_test_entity_id_update, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_unique_id, @@ -495,8 +496,15 @@ async def test_entity_device_info_remove(hass, mqtt_mock): ) -async def test_entity_id_update(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - await help_test_entity_id_update( + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """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 ) diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index 96ea9b3005f..21f552b4163 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -5,7 +5,7 @@ from homeassistant.components import camera, mqtt from homeassistant.components.mqtt.discovery import async_start from homeassistant.setup import async_setup_component -from .common import ( +from .test_common import ( help_test_availability_without_topic, help_test_custom_availability_payload, help_test_default_availability_payload, @@ -17,7 +17,8 @@ from .common import ( help_test_entity_device_info_update, help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, - help_test_entity_id_update, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_unique_id, @@ -194,8 +195,15 @@ async def test_entity_device_info_remove(hass, mqtt_mock): ) -async def test_entity_id_update(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - await help_test_entity_id_update( + await help_test_entity_id_update_subscriptions( hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG, ["test_topic"] ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, camera.DOMAIN, DEFAULT_CONFIG + ) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 29f97af7725..ce21aa53d27 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -25,7 +25,7 @@ from homeassistant.components.climate.const import ( ) from homeassistant.const import STATE_OFF -from .common import ( +from .test_common import ( help_test_availability_without_topic, help_test_custom_availability_payload, help_test_default_availability_payload, @@ -37,7 +37,8 @@ from .common import ( help_test_entity_device_info_update, help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, - help_test_entity_id_update, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_unique_id, @@ -885,7 +886,7 @@ async def test_entity_device_info_remove(hass, mqtt_mock): ) -async def test_entity_id_update(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" config = { CLIMATE_DOMAIN: { @@ -895,11 +896,18 @@ async def test_entity_id_update(hass, mqtt_mock): "availability_topic": "avty-topic", } } - await help_test_entity_id_update( + await help_test_entity_id_update_subscriptions( hass, mqtt_mock, CLIMATE_DOMAIN, config, ["test-topic", "avty-topic"] ) +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG + ) + + async def test_precision_default(hass, mqtt_mock): """Test that setting precision to tenths works as intended.""" assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG) diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/test_common.py similarity index 90% rename from tests/components/mqtt/common.py rename to tests/components/mqtt/test_common.py index 702a38928a2..2f437174299 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/test_common.py @@ -440,7 +440,9 @@ async def help_test_entity_device_info_update(hass, mqtt_mock, domain, config): assert device.name == "Milk" -async def help_test_entity_id_update(hass, mqtt_mock, domain, config, topics=None): +async def help_test_entity_id_update_subscriptions( + hass, mqtt_mock, domain, config, topics=None +): """Test MQTT subscriptions are managed when entity_id is updated.""" # Add unique_id to config config = copy.deepcopy(config) @@ -473,3 +475,47 @@ async def help_test_entity_id_update(hass, mqtt_mock, domain, config, topics=Non assert state is not None for topic in topics: mock_mqtt.async_subscribe.assert_any_call(topic, ANY, ANY, ANY) + + +async def help_test_entity_id_update_discovery_update( + hass, mqtt_mock, domain, config, topic=None +): + """Test MQTT discovery update after entity_id is updated.""" + # Add unique_id to config + config = copy.deepcopy(config) + config[domain]["unique_id"] = "TOTALLY_UNIQUE" + + if topic is None: + # Add default topic to config + config[domain]["availability_topic"] = "avty-topic" + topic = "avty-topic" + + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, entry) + ent_registry = mock_registry(hass, {}) + + data = json.dumps(config[domain]) + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, topic, "online") + state = hass.states.get(f"{domain}.test") + assert state.state != STATE_UNAVAILABLE + + async_fire_mqtt_message(hass, topic, "offline") + state = hass.states.get(f"{domain}.test") + assert state.state == STATE_UNAVAILABLE + + ent_registry.async_update_entity(f"{domain}.test", new_entity_id=f"{domain}.milk") + await hass.async_block_till_done() + + config[domain]["availability_topic"] = f"{topic}_2" + data = json.dumps(config[domain]) + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) + await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(domain)) == 1 + + async_fire_mqtt_message(hass, f"{topic}_2", "online") + state = hass.states.get(f"{domain}.milk") + assert state.state != STATE_UNAVAILABLE diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 2e5e232cdd5..7749c419ca0 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -22,7 +22,7 @@ from homeassistant.const import ( ) from homeassistant.setup import async_setup_component -from .common import ( +from .test_common import ( help_test_availability_without_topic, help_test_custom_availability_payload, help_test_default_availability_payload, @@ -34,7 +34,8 @@ from .common import ( help_test_entity_device_info_update, help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, - help_test_entity_id_update, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_unique_id, @@ -1749,6 +1750,15 @@ async def test_entity_device_info_remove(hass, mqtt_mock): ) -async def test_entity_id_update(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - await help_test_entity_id_update(hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG) + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG + ) diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 0ecc6a25d6f..460c99618bd 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -3,7 +3,7 @@ from homeassistant.components import fan from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component -from .common import ( +from .test_common import ( help_test_availability_without_topic, help_test_custom_availability_payload, help_test_default_availability_payload, @@ -15,7 +15,8 @@ from .common import ( help_test_entity_device_info_update, help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, - help_test_entity_id_update, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_unique_id, @@ -496,6 +497,15 @@ async def test_entity_device_info_remove(hass, mqtt_mock): ) -async def test_entity_id_update(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - await help_test_entity_id_update(hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG) + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG + ) diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 1bbefa35478..14ab79b2d20 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -19,7 +19,7 @@ from homeassistant.components.vacuum import ( from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_OFF, STATE_ON from homeassistant.setup import async_setup_component -from .common import ( +from .test_common import ( help_test_availability_without_topic, help_test_custom_availability_payload, help_test_default_availability_payload, @@ -31,7 +31,8 @@ from .common import ( help_test_entity_device_info_update, help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, - help_test_entity_id_update, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_unique_id, @@ -606,7 +607,7 @@ async def test_entity_device_info_remove(hass, mqtt_mock): ) -async def test_entity_id_update(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" config = { vacuum.DOMAIN: { @@ -618,6 +619,13 @@ async def test_entity_id_update(hass, mqtt_mock): "availability_topic": "avty-topic", } } - await help_test_entity_id_update( + await help_test_entity_id_update_subscriptions( hass, mqtt_mock, vacuum.DOMAIN, config, ["test-topic", "avty-topic"] ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """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 + ) diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index ba4078f5374..bc4f5fc3393 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -162,7 +162,7 @@ from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON import homeassistant.core as ha from homeassistant.setup import async_setup_component -from .common import ( +from .test_common import ( help_test_availability_without_topic, help_test_custom_availability_payload, help_test_default_availability_payload, @@ -174,7 +174,8 @@ from .common import ( help_test_entity_device_info_update, help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, - help_test_entity_id_update, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_unique_id, @@ -1345,6 +1346,15 @@ async def test_entity_device_info_remove(hass, mqtt_mock): ) -async def test_entity_id_update(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - await help_test_entity_id_update(hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG) + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + ) diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index c07cec47ecc..f71791e019f 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -101,7 +101,7 @@ from homeassistant.const import ( import homeassistant.core as ha from homeassistant.setup import async_setup_component -from .common import ( +from .test_common import ( help_test_availability_without_topic, help_test_custom_availability_payload, help_test_default_availability_payload, @@ -113,7 +113,8 @@ from .common import ( help_test_entity_device_info_update, help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, - help_test_entity_id_update, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_unique_id, @@ -1121,6 +1122,15 @@ async def test_entity_device_info_remove(hass, mqtt_mock): ) -async def test_entity_id_update(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - await help_test_entity_id_update(hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG) + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + ) diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 4f89ca77847..c9612a7ded7 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -38,7 +38,7 @@ from homeassistant.const import ( import homeassistant.core as ha from homeassistant.setup import async_setup_component -from .common import ( +from .test_common import ( help_test_availability_without_topic, help_test_custom_availability_payload, help_test_default_availability_payload, @@ -50,7 +50,8 @@ from .common import ( help_test_entity_device_info_update, help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, - help_test_entity_id_update, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_unique_id, @@ -944,6 +945,15 @@ async def test_entity_device_info_remove(hass, mqtt_mock): ) -async def test_entity_id_update(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - await help_test_entity_id_update(hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG) + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG + ) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 4c34db6ea20..151021a45f8 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -3,7 +3,7 @@ from homeassistant.components import lock from homeassistant.const import ATTR_ASSUMED_STATE, STATE_LOCKED, STATE_UNLOCKED from homeassistant.setup import async_setup_component -from .common import ( +from .test_common import ( help_test_availability_without_topic, help_test_custom_availability_payload, help_test_default_availability_payload, @@ -15,7 +15,8 @@ from .common import ( help_test_entity_device_info_update, help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, - help_test_entity_id_update, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_unique_id, @@ -386,6 +387,15 @@ async def test_entity_device_info_remove(hass, mqtt_mock): ) -async def test_entity_id_update(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - await help_test_entity_id_update(hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG) + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG + ) diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 0455c5f9c7c..061e53250cb 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -11,7 +11,7 @@ import homeassistant.core as ha from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from .common import ( +from .test_common import ( help_test_availability_without_topic, help_test_custom_availability_payload, help_test_default_availability_payload, @@ -23,7 +23,8 @@ from .common import ( help_test_entity_device_info_update, help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, - help_test_entity_id_update, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_unique_id, @@ -392,9 +393,18 @@ async def test_entity_device_info_remove(hass, mqtt_mock): ) -async def test_entity_id_update(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - await help_test_entity_id_update(hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG) + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG + ) async def test_entity_device_info_with_hub(hass, mqtt_mock): diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index 1b1150985a2..ecb38ef5774 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -30,7 +30,7 @@ from homeassistant.const import ( ) from homeassistant.setup import async_setup_component -from .common import ( +from .test_common import ( help_test_availability_without_topic, help_test_custom_availability_payload, help_test_default_availability_payload, @@ -42,7 +42,8 @@ from .common import ( help_test_entity_device_info_update, help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, - help_test_entity_id_update, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_unique_id, @@ -441,6 +442,15 @@ async def test_entity_device_info_remove(hass, mqtt_mock): ) -async def test_entity_id_update(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - await help_test_entity_id_update(hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2) + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_2 + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """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 + ) diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 5f5c69d5a22..d8ca8031390 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -7,7 +7,7 @@ from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON import homeassistant.core as ha from homeassistant.setup import async_setup_component -from .common import ( +from .test_common import ( help_test_availability_without_topic, help_test_custom_availability_payload, help_test_default_availability_payload, @@ -19,7 +19,8 @@ from .common import ( help_test_entity_device_info_update, help_test_entity_device_info_with_connection, help_test_entity_device_info_with_identifier, - help_test_entity_id_update, + help_test_entity_id_update_discovery_update, + help_test_entity_id_update_subscriptions, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_unique_id, @@ -353,6 +354,15 @@ async def test_entity_device_info_remove(hass, mqtt_mock): ) -async def test_entity_id_update(hass, mqtt_mock): +async def test_entity_id_update_subscriptions(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - await help_test_entity_id_update(hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG) + await help_test_entity_id_update_subscriptions( + hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + ) + + +async def test_entity_id_update_discovery_update(hass, mqtt_mock): + """Test MQTT discovery update when entity_id is updated.""" + await help_test_entity_id_update_discovery_update( + hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG + ) diff --git a/tests/components/soundtouch/test_media_player.py b/tests/components/soundtouch/test_media_player.py index e69cec12ba3..ea580656b24 100644 --- a/tests/components/soundtouch/test_media_player.py +++ b/tests/components/soundtouch/test_media_player.py @@ -33,6 +33,8 @@ from homeassistant.setup import async_setup_component 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"): @@ -60,20 +62,22 @@ def one_device_fixture(): def two_zones_fixture(): """Mock one master and one slave.""" device_1 = MockDevice( + DEVICE_1_ID, MockZoneStatus( is_master=True, - master_id=1, + 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=1, + 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( @@ -112,9 +116,9 @@ async def setup_soundtouch(hass, config): class MockDevice(STD): """Mock device.""" - def __init__(self, zone_status=None): + def __init__(self, id=None, zone_status=None): """Init the class.""" - self._config = MockConfig() + self._config = MockConfig(id) self._zone_status = zone_status or MockZoneStatus() def zone_status(self, refresh=True): @@ -125,9 +129,10 @@ class MockDevice(STD): class MockConfig(Config): """Mock config.""" - def __init__(self): + def __init__(self, id=None): """Init class.""" self._name = "name" + self._id = id or DEVICE_1_ID class MockZoneStatus(ZoneStatus): diff --git a/tests/components/tado/__init__.py b/tests/components/tado/__init__.py new file mode 100644 index 00000000000..11d199f01a1 --- /dev/null +++ b/tests/components/tado/__init__.py @@ -0,0 +1 @@ +"""Tests for the tado integration.""" diff --git a/tests/components/tado/test_climate.py b/tests/components/tado/test_climate.py new file mode 100644 index 00000000000..602f4d8424f --- /dev/null +++ b/tests/components/tado/test_climate.py @@ -0,0 +1,59 @@ +"""The sensor tests for the tado platform.""" + +from .util import async_init_integration + + +async def test_air_con(hass): + """Test creation of aircon climate.""" + + await async_init_integration(hass) + + state = hass.states.get("climate.air_conditioning") + assert state.state == "cool" + + expected_attributes = { + "current_humidity": 60.9, + "current_temperature": 24.8, + "fan_mode": "auto", + "fan_modes": ["auto", "high", "medium", "low"], + "friendly_name": "Air Conditioning", + "hvac_action": "cooling", + "hvac_modes": ["off", "auto", "heat", "cool", "heat_cool", "dry", "fan_only"], + "max_temp": 31.0, + "min_temp": 16.0, + "preset_mode": "home", + "preset_modes": ["away", "home"], + "supported_features": 25, + "target_temp_step": 1, + "temperature": 17.8, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all(item in state.attributes.items() for item in expected_attributes.items()) + + +async def test_heater(hass): + """Test creation of heater climate.""" + + await async_init_integration(hass) + + state = hass.states.get("climate.baseboard_heater") + assert state.state == "heat" + + expected_attributes = { + "current_humidity": 45.2, + "current_temperature": 20.6, + "friendly_name": "Baseboard Heater", + "hvac_action": "idle", + "hvac_modes": ["off", "auto", "heat"], + "max_temp": 31.0, + "min_temp": 16.0, + "preset_mode": "home", + "preset_modes": ["away", "home"], + "supported_features": 17, + "target_temp_step": 1, + "temperature": 20.5, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all(item in state.attributes.items() for item in expected_attributes.items()) diff --git a/tests/components/tado/test_sensor.py b/tests/components/tado/test_sensor.py new file mode 100644 index 00000000000..2ea2c0508ee --- /dev/null +++ b/tests/components/tado/test_sensor.py @@ -0,0 +1,96 @@ +"""The sensor tests for the tado platform.""" + +from .util import async_init_integration + + +async def test_air_con_create_sensors(hass): + """Test creation of aircon sensors.""" + + await async_init_integration(hass) + + state = hass.states.get("sensor.air_conditioning_power") + assert state.state == "ON" + + state = hass.states.get("sensor.air_conditioning_link") + assert state.state == "ONLINE" + + state = hass.states.get("sensor.air_conditioning_link") + assert state.state == "ONLINE" + + state = hass.states.get("sensor.air_conditioning_tado_mode") + assert state.state == "HOME" + + state = hass.states.get("sensor.air_conditioning_temperature") + assert state.state == "24.76" + + state = hass.states.get("sensor.air_conditioning_ac") + assert state.state == "ON" + + state = hass.states.get("sensor.air_conditioning_overlay") + assert state.state == "True" + + state = hass.states.get("sensor.air_conditioning_humidity") + assert state.state == "60.9" + + state = hass.states.get("sensor.air_conditioning_open_window") + assert state.state == "False" + + +async def test_heater_create_sensors(hass): + """Test creation of heater sensors.""" + + await async_init_integration(hass) + + state = hass.states.get("sensor.baseboard_heater_power") + assert state.state == "ON" + + state = hass.states.get("sensor.baseboard_heater_link") + assert state.state == "ONLINE" + + state = hass.states.get("sensor.baseboard_heater_link") + assert state.state == "ONLINE" + + state = hass.states.get("sensor.baseboard_heater_tado_mode") + assert state.state == "HOME" + + state = hass.states.get("sensor.baseboard_heater_temperature") + assert state.state == "20.65" + + state = hass.states.get("sensor.baseboard_heater_early_start") + assert state.state == "False" + + state = hass.states.get("sensor.baseboard_heater_overlay") + assert state.state == "True" + + state = hass.states.get("sensor.baseboard_heater_humidity") + assert state.state == "45.2" + + state = hass.states.get("sensor.baseboard_heater_open_window") + assert state.state == "False" + + +async def test_water_heater_create_sensors(hass): + """Test creation of water heater sensors.""" + + await async_init_integration(hass) + + state = hass.states.get("sensor.water_heater_tado_mode") + assert state.state == "HOME" + + state = hass.states.get("sensor.water_heater_link") + assert state.state == "ONLINE" + + state = hass.states.get("sensor.water_heater_overlay") + assert state.state == "False" + + state = hass.states.get("sensor.water_heater_power") + assert state.state == "ON" + + +async def test_home_create_sensors(hass): + """Test creation of home sensors.""" + + await async_init_integration(hass) + + state = hass.states.get("sensor.home_name_tado_bridge_status") + assert state.state == "True" diff --git a/tests/components/tado/test_water_heater.py b/tests/components/tado/test_water_heater.py new file mode 100644 index 00000000000..03dfaaef0df --- /dev/null +++ b/tests/components/tado/test_water_heater.py @@ -0,0 +1,47 @@ +"""The sensor tests for the tado platform.""" + +from .util import async_init_integration + + +async def test_water_heater_create_sensors(hass): + """Test creation of water heater.""" + + await async_init_integration(hass) + + state = hass.states.get("water_heater.water_heater") + assert state.state == "auto" + + expected_attributes = { + "current_temperature": None, + "friendly_name": "Water Heater", + "max_temp": 31.0, + "min_temp": 16.0, + "operation_list": ["auto", "heat", "off"], + "operation_mode": "auto", + "supported_features": 3, + "target_temp_high": None, + "target_temp_low": None, + "temperature": 65.0, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all(item in state.attributes.items() for item in expected_attributes.items()) + + state = hass.states.get("water_heater.second_water_heater") + assert state.state == "heat" + + expected_attributes = { + "current_temperature": None, + "friendly_name": "Second Water Heater", + "max_temp": 31.0, + "min_temp": 16.0, + "operation_list": ["auto", "heat", "off"], + "operation_mode": "heat", + "supported_features": 3, + "target_temp_high": None, + "target_temp_low": None, + "temperature": 30.0, + } + # Only test for a subset of attributes in case + # HA changes the implementation and a new one appears + assert all(item in state.attributes.items() for item in expected_attributes.items()) diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py new file mode 100644 index 00000000000..7ee4c17058d --- /dev/null +++ b/tests/components/tado/util.py @@ -0,0 +1,86 @@ +"""Tests for the tado integration.""" + +import requests_mock + +from homeassistant.components.tado import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import load_fixture + + +async def async_init_integration( + hass: HomeAssistant, skip_setup: bool = False, +): + """Set up the tado integration in Home Assistant.""" + + token_fixture = "tado/token.json" + devices_fixture = "tado/devices.json" + me_fixture = "tado/me.json" + zones_fixture = "tado/zones.json" + # Water Heater 2 + zone_4_state_fixture = "tado/tadov2.water_heater.heating.json" + zone_4_capabilities_fixture = "tado/water_heater_zone_capabilities.json" + + # Smart AC + zone_3_state_fixture = "tado/smartac3.cool_mode.json" + zone_3_capabilities_fixture = "tado/zone_capabilities.json" + + # Water Heater + zone_2_state_fixture = "tado/tadov2.water_heater.auto_mode.json" + zone_2_capabilities_fixture = "tado/water_heater_zone_capabilities.json" + + zone_1_state_fixture = "tado/tadov2.heating.manual_mode.json" + zone_1_capabilities_fixture = "tado/tadov2.zone_capabilities.json" + + with requests_mock.mock() as m: + m.post("https://auth.tado.com/oauth/token", text=load_fixture(token_fixture)) + m.get( + "https://my.tado.com/api/v2/me", text=load_fixture(me_fixture), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/devices", + text=load_fixture(devices_fixture), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones", + text=load_fixture(zones_fixture), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/4/capabilities", + text=load_fixture(zone_4_capabilities_fixture), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/3/capabilities", + text=load_fixture(zone_3_capabilities_fixture), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/2/capabilities", + text=load_fixture(zone_2_capabilities_fixture), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/1/capabilities", + text=load_fixture(zone_1_capabilities_fixture), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/4/state", + text=load_fixture(zone_4_state_fixture), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/3/state", + text=load_fixture(zone_3_state_fixture), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/2/state", + text=load_fixture(zone_2_state_fixture), + ) + m.get( + "https://my.tado.com/api/v2/homes/1/zones/1/state", + text=load_fixture(zone_1_state_fixture), + ) + if not skip_setup: + assert await async_setup_component( + hass, DOMAIN, {DOMAIN: {CONF_USERNAME: "mock", CONF_PASSWORD: "mock"}} + ) + await hass.async_block_till_done() diff --git a/tests/fixtures/homematicip_cloud.json b/tests/fixtures/homematicip_cloud.json index a97b1247e2c..e85401aa1ec 100644 --- a/tests/fixtures/homematicip_cloud.json +++ b/tests/fixtures/homematicip_cloud.json @@ -1591,7 +1591,7 @@ "groupIndex": 1, "groups": [], "index": 1, - "label": "", + "label": "Treppe CH", "on": true, "profileMode": "AUTOMATIC", "userDesiredProfileMode": "AUTOMATIC" @@ -1603,7 +1603,7 @@ "groupIndex": 2, "groups": [], "index": 2, - "label": "", + "label": "Alarm Status", "on": null, "profileMode": "AUTOMATIC", "simpleRGBColorState": "RED", @@ -4576,7 +4576,7 @@ "00000000-0000-0000-0000-000000000042" ], "index": 1, - "label": "", + "label": "SW1", "on": false, "profileMode": "AUTOMATIC", "userDesiredProfileMode": "AUTOMATIC" @@ -4590,7 +4590,7 @@ "00000000-0000-0000-0000-000000000040" ], "index": 2, - "label": "", + "label": "SW2", "on": false, "profileMode": "AUTOMATIC", "userDesiredProfileMode": "AUTOMATIC" diff --git a/tests/fixtures/ipp/get-printer-attributes.bin b/tests/fixtures/ipp/get-printer-attributes.bin new file mode 100644 index 00000000000..24b903efc5d Binary files /dev/null and b/tests/fixtures/ipp/get-printer-attributes.bin differ diff --git a/tests/fixtures/tado/ac_issue_32294.heat_mode.json b/tests/fixtures/tado/ac_issue_32294.heat_mode.json new file mode 100644 index 00000000000..098afd018aa --- /dev/null +++ b/tests/fixtures/tado/ac_issue_32294.heat_mode.json @@ -0,0 +1,60 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 71.28, + "timestamp": "2020-02-29T22:51:05.016Z", + "celsius": 21.82, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-02-29T22:51:05.016Z", + "percentage": 40.4, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": null, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-02-29T22:50:34.850Z", + "type": "POWER", + "value": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-01T00:00:00.000Z" + }, + "preparation": null, + "overlayType": null, + "nextScheduleChange": { + "start": "2020-03-01T00:00:00Z", + "setting": { + "type": "AIR_CONDITIONING", + "mode": "HEAT", + "power": "ON", + "temperature": { + "fahrenheit": 59.0, + "celsius": 15.0 + } + } + }, + "setting": { + "type": "AIR_CONDITIONING", + "mode": "HEAT", + "power": "ON", + "temperature": { + "fahrenheit": 77.0, + "celsius": 25.0 + } + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/devices.json b/tests/fixtures/tado/devices.json new file mode 100644 index 00000000000..5fc43adc903 --- /dev/null +++ b/tests/fixtures/tado/devices.json @@ -0,0 +1,22 @@ +[ + { + "deviceType" : "WR02", + "currentFwVersion" : "59.4", + "accessPointWiFi" : { + "ssid" : "tado8480" + }, + "characteristics" : { + "capabilities" : [ + "INSIDE_TEMPERATURE_MEASUREMENT", + "IDENTIFY" + ] + }, + "serialNo" : "WR1", + "commandTableUploadState" : "FINISHED", + "connectionState" : { + "timestamp" : "2020-03-23T18:30:07.377Z", + "value" : true + }, + "shortSerialNo" : "WR1" + } +] diff --git a/tests/fixtures/tado/hvac_action_heat.json b/tests/fixtures/tado/hvac_action_heat.json new file mode 100644 index 00000000000..9cbf1fd5f82 --- /dev/null +++ b/tests/fixtures/tado/hvac_action_heat.json @@ -0,0 +1,67 @@ +{ + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "AIR_CONDITIONING", + "power": "ON", + "mode": "HEAT", + "temperature": { + "celsius": 16.11, + "fahrenheit": 61.00 + }, + "fanSpeed": "AUTO" + }, + "overlayType": "MANUAL", + "overlay": { + "type": "MANUAL", + "setting": { + "type": "AIR_CONDITIONING", + "power": "ON", + "mode": "HEAT", + "temperature": { + "celsius": 16.11, + "fahrenheit": 61.00 + }, + "fanSpeed": "AUTO" + }, + "termination": { + "type": "TADO_MODE", + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null + } + }, + "openWindow": null, + "nextScheduleChange": null, + "nextTimeBlock": { + "start": "2020-03-07T04:00:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-06T17:38:30.302Z", + "type": "POWER", + "value": "OFF" + } + }, + "sensorDataPoints": { + "insideTemperature": { + "celsius": 21.40, + "fahrenheit": 70.52, + "timestamp": "2020-03-06T18:06:09.546Z", + "type": "TEMPERATURE", + "precision": { + "celsius": 0.1, + "fahrenheit": 0.1 + } + }, + "humidity": { + "type": "PERCENTAGE", + "percentage": 50.40, + "timestamp": "2020-03-06T18:06:09.546Z" + } + } +} diff --git a/tests/fixtures/tado/me.json b/tests/fixtures/tado/me.json new file mode 100644 index 00000000000..4707b3f04d4 --- /dev/null +++ b/tests/fixtures/tado/me.json @@ -0,0 +1,28 @@ +{ + "id" : "5", + "mobileDevices" : [ + { + "name" : "nick Android", + "deviceMetadata" : { + "platform" : "Android", + "locale" : "en", + "osVersion" : "10", + "model" : "OnePlus_GM1917" + }, + "settings" : { + "geoTrackingEnabled" : false + }, + "id" : 1 + } + ], + "homes" : [ + { + "name" : "home name", + "id" : 1 + } + ], + "name" : "name", + "locale" : "en_US", + "email" : "user@domain.tld", + "username" : "user@domain.tld" +} diff --git a/tests/fixtures/tado/smartac3.auto_mode.json b/tests/fixtures/tado/smartac3.auto_mode.json new file mode 100644 index 00000000000..254b409ddd9 --- /dev/null +++ b/tests/fixtures/tado/smartac3.auto_mode.json @@ -0,0 +1,57 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 76.64, + "timestamp": "2020-03-05T03:55:38.160Z", + "celsius": 24.8, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T03:55:38.160Z", + "percentage": 62.5, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null, + "type": "TADO_MODE" + }, + "setting": { + "type": "AIR_CONDITIONING", + "mode": "AUTO", + "power": "ON" + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T03:56:38.627Z", + "type": "POWER", + "value": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "type": "AIR_CONDITIONING", + "mode": "AUTO", + "power": "ON" + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.cool_mode.json b/tests/fixtures/tado/smartac3.cool_mode.json new file mode 100644 index 00000000000..a7db2cc75bc --- /dev/null +++ b/tests/fixtures/tado/smartac3.cool_mode.json @@ -0,0 +1,67 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 76.57, + "timestamp": "2020-03-05T03:57:38.850Z", + "celsius": 24.76, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T03:57:38.850Z", + "percentage": 60.9, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null, + "type": "TADO_MODE" + }, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "COOL", + "power": "ON", + "temperature": { + "fahrenheit": 64.0, + "celsius": 17.78 + } + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T04:01:07.162Z", + "type": "POWER", + "value": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "COOL", + "power": "ON", + "temperature": { + "fahrenheit": 64.0, + "celsius": 17.78 + } + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.dry_mode.json b/tests/fixtures/tado/smartac3.dry_mode.json new file mode 100644 index 00000000000..d04612d1105 --- /dev/null +++ b/tests/fixtures/tado/smartac3.dry_mode.json @@ -0,0 +1,57 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 77.02, + "timestamp": "2020-03-05T04:02:07.396Z", + "celsius": 25.01, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T04:02:07.396Z", + "percentage": 62.0, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null, + "type": "TADO_MODE" + }, + "setting": { + "type": "AIR_CONDITIONING", + "mode": "DRY", + "power": "ON" + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T04:02:40.867Z", + "type": "POWER", + "value": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "type": "AIR_CONDITIONING", + "mode": "DRY", + "power": "ON" + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.fan_mode.json b/tests/fixtures/tado/smartac3.fan_mode.json new file mode 100644 index 00000000000..6907c31c517 --- /dev/null +++ b/tests/fixtures/tado/smartac3.fan_mode.json @@ -0,0 +1,57 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 77.02, + "timestamp": "2020-03-05T04:02:07.396Z", + "celsius": 25.01, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T04:02:07.396Z", + "percentage": 62.0, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null, + "type": "TADO_MODE" + }, + "setting": { + "type": "AIR_CONDITIONING", + "mode": "FAN", + "power": "ON" + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T04:03:44.328Z", + "type": "POWER", + "value": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "type": "AIR_CONDITIONING", + "mode": "FAN", + "power": "ON" + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.heat_mode.json b/tests/fixtures/tado/smartac3.heat_mode.json new file mode 100644 index 00000000000..06b5a350d83 --- /dev/null +++ b/tests/fixtures/tado/smartac3.heat_mode.json @@ -0,0 +1,67 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 76.57, + "timestamp": "2020-03-05T03:57:38.850Z", + "celsius": 24.76, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T03:57:38.850Z", + "percentage": 60.9, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null, + "type": "TADO_MODE" + }, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "HEAT", + "power": "ON", + "temperature": { + "fahrenheit": 61.0, + "celsius": 16.11 + } + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T03:59:36.390Z", + "type": "POWER", + "value": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "HEAT", + "power": "ON", + "temperature": { + "fahrenheit": 61.0, + "celsius": 16.11 + } + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.hvac_off.json b/tests/fixtures/tado/smartac3.hvac_off.json new file mode 100644 index 00000000000..83e9d1a83d5 --- /dev/null +++ b/tests/fixtures/tado/smartac3.hvac_off.json @@ -0,0 +1,55 @@ +{ + "tadoMode": "AWAY", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 70.59, + "timestamp": "2020-03-05T01:21:44.089Z", + "celsius": 21.44, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T01:21:44.089Z", + "percentage": 48.2, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "MANUAL", + "projectedExpiry": null, + "type": "MANUAL" + }, + "setting": { + "type": "AIR_CONDITIONING", + "power": "OFF" + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-02-29T05:34:10.318Z", + "type": "POWER", + "value": "OFF" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T04:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "type": "AIR_CONDITIONING", + "power": "OFF" + } +} diff --git a/tests/fixtures/tado/smartac3.manual_off.json b/tests/fixtures/tado/smartac3.manual_off.json new file mode 100644 index 00000000000..a9538f30dbe --- /dev/null +++ b/tests/fixtures/tado/smartac3.manual_off.json @@ -0,0 +1,55 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 77.02, + "timestamp": "2020-03-05T04:02:07.396Z", + "celsius": 25.01, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T04:02:07.396Z", + "percentage": 62.0, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "MANUAL", + "projectedExpiry": null, + "type": "MANUAL" + }, + "setting": { + "type": "AIR_CONDITIONING", + "power": "OFF" + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T04:05:08.804Z", + "type": "POWER", + "value": "OFF" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "type": "AIR_CONDITIONING", + "power": "OFF" + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.offline.json b/tests/fixtures/tado/smartac3.offline.json new file mode 100644 index 00000000000..fda1e6468eb --- /dev/null +++ b/tests/fixtures/tado/smartac3.offline.json @@ -0,0 +1,71 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 77.09, + "timestamp": "2020-03-03T21:23:57.846Z", + "celsius": 25.05, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-03T21:23:57.846Z", + "percentage": 61.6, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "OFFLINE", + "reason": { + "code": "disconnectedDevice", + "title": "There is a disconnected device." + } + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null, + "type": "TADO_MODE" + }, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "COOL", + "power": "ON", + "temperature": { + "fahrenheit": 64.0, + "celsius": 17.78 + } + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-02-29T18:42:26.683Z", + "type": "POWER", + "value": "OFF" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "COOL", + "power": "ON", + "temperature": { + "fahrenheit": 64.0, + "celsius": 17.78 + } + } +} diff --git a/tests/fixtures/tado/smartac3.smart_mode.json b/tests/fixtures/tado/smartac3.smart_mode.json new file mode 100644 index 00000000000..357a1a96658 --- /dev/null +++ b/tests/fixtures/tado/smartac3.smart_mode.json @@ -0,0 +1,50 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 75.97, + "timestamp": "2020-03-05T03:50:24.769Z", + "celsius": 24.43, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T03:50:24.769Z", + "percentage": 60.0, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": null, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T03:52:22.253Z", + "type": "POWER", + "value": "OFF" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": null, + "nextScheduleChange": null, + "setting": { + "fanSpeed": "MIDDLE", + "type": "AIR_CONDITIONING", + "mode": "COOL", + "power": "ON", + "temperature": { + "fahrenheit": 68.0, + "celsius": 20.0 + } + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.turning_off.json b/tests/fixtures/tado/smartac3.turning_off.json new file mode 100644 index 00000000000..0c16f85811a --- /dev/null +++ b/tests/fixtures/tado/smartac3.turning_off.json @@ -0,0 +1,55 @@ +{ + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "AIR_CONDITIONING", + "power": "OFF" + }, + "overlayType": "MANUAL", + "overlay": { + "type": "MANUAL", + "setting": { + "type": "AIR_CONDITIONING", + "power": "OFF" + }, + "termination": { + "type": "MANUAL", + "typeSkillBasedApp": "MANUAL", + "projectedExpiry": null + } + }, + "openWindow": null, + "nextScheduleChange": null, + "nextTimeBlock": { + "start": "2020-03-07T04:00:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-06T19:05:21.835Z", + "type": "POWER", + "value": "ON" + } + }, + "sensorDataPoints": { + "insideTemperature": { + "celsius": 21.40, + "fahrenheit": 70.52, + "timestamp": "2020-03-06T19:06:13.185Z", + "type": "TEMPERATURE", + "precision": { + "celsius": 0.1, + "fahrenheit": 0.1 + } + }, + "humidity": { + "type": "PERCENTAGE", + "percentage": 49.20, + "timestamp": "2020-03-06T19:06:13.185Z" + } + } +} diff --git a/tests/fixtures/tado/tadov2.heating.auto_mode.json b/tests/fixtures/tado/tadov2.heating.auto_mode.json new file mode 100644 index 00000000000..34464051f1e --- /dev/null +++ b/tests/fixtures/tado/tadov2.heating.auto_mode.json @@ -0,0 +1,58 @@ +{ + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 20.00, + "fahrenheit": 68.00 + } + }, + "overlayType": null, + "overlay": null, + "openWindow": null, + "nextScheduleChange": { + "start": "2020-03-10T17:00:00Z", + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 21.00, + "fahrenheit": 69.80 + } + } + }, + "nextTimeBlock": { + "start": "2020-03-10T17:00:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": { + "heatingPower": { + "type": "PERCENTAGE", + "percentage": 0.00, + "timestamp": "2020-03-10T07:47:45.978Z" + } + }, + "sensorDataPoints": { + "insideTemperature": { + "celsius": 20.65, + "fahrenheit": 69.17, + "timestamp": "2020-03-10T07:44:11.947Z", + "type": "TEMPERATURE", + "precision": { + "celsius": 0.1, + "fahrenheit": 0.1 + } + }, + "humidity": { + "type": "PERCENTAGE", + "percentage": 45.20, + "timestamp": "2020-03-10T07:44:11.947Z" + } + } +} diff --git a/tests/fixtures/tado/tadov2.heating.manual_mode.json b/tests/fixtures/tado/tadov2.heating.manual_mode.json new file mode 100644 index 00000000000..a62499d7dd4 --- /dev/null +++ b/tests/fixtures/tado/tadov2.heating.manual_mode.json @@ -0,0 +1,73 @@ +{ + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 20.50, + "fahrenheit": 68.90 + } + }, + "overlayType": "MANUAL", + "overlay": { + "type": "MANUAL", + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 20.50, + "fahrenheit": 68.90 + } + }, + "termination": { + "type": "MANUAL", + "typeSkillBasedApp": "MANUAL", + "projectedExpiry": null + } + }, + "openWindow": null, + "nextScheduleChange": { + "start": "2020-03-10T17:00:00Z", + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 21.00, + "fahrenheit": 69.80 + } + } + }, + "nextTimeBlock": { + "start": "2020-03-10T17:00:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": { + "heatingPower": { + "type": "PERCENTAGE", + "percentage": 0.00, + "timestamp": "2020-03-10T07:47:45.978Z" + } + }, + "sensorDataPoints": { + "insideTemperature": { + "celsius": 20.65, + "fahrenheit": 69.17, + "timestamp": "2020-03-10T07:44:11.947Z", + "type": "TEMPERATURE", + "precision": { + "celsius": 0.1, + "fahrenheit": 0.1 + } + }, + "humidity": { + "type": "PERCENTAGE", + "percentage": 45.20, + "timestamp": "2020-03-10T07:44:11.947Z" + } + } +} diff --git a/tests/fixtures/tado/tadov2.heating.off_mode.json b/tests/fixtures/tado/tadov2.heating.off_mode.json new file mode 100644 index 00000000000..e22805abc73 --- /dev/null +++ b/tests/fixtures/tado/tadov2.heating.off_mode.json @@ -0,0 +1,67 @@ +{ + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "HEATING", + "power": "OFF", + "temperature": null + }, + "overlayType": "MANUAL", + "overlay": { + "type": "MANUAL", + "setting": { + "type": "HEATING", + "power": "OFF", + "temperature": null + }, + "termination": { + "type": "MANUAL", + "typeSkillBasedApp": "MANUAL", + "projectedExpiry": null + } + }, + "openWindow": null, + "nextScheduleChange": { + "start": "2020-03-10T17:00:00Z", + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 21.00, + "fahrenheit": 69.80 + } + } + }, + "nextTimeBlock": { + "start": "2020-03-10T17:00:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": { + "heatingPower": { + "type": "PERCENTAGE", + "percentage": 0.00, + "timestamp": "2020-03-10T07:47:45.978Z" + } + }, + "sensorDataPoints": { + "insideTemperature": { + "celsius": 20.65, + "fahrenheit": 69.17, + "timestamp": "2020-03-10T07:44:11.947Z", + "type": "TEMPERATURE", + "precision": { + "celsius": 0.1, + "fahrenheit": 0.1 + } + }, + "humidity": { + "type": "PERCENTAGE", + "percentage": 45.20, + "timestamp": "2020-03-10T07:44:11.947Z" + } + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/tadov2.water_heater.auto_mode.json b/tests/fixtures/tado/tadov2.water_heater.auto_mode.json new file mode 100644 index 00000000000..7df4e3f5ea6 --- /dev/null +++ b/tests/fixtures/tado/tadov2.water_heater.auto_mode.json @@ -0,0 +1,33 @@ +{ + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "HOT_WATER", + "power": "ON", + "temperature": { + "celsius": 65.00, + "fahrenheit": 149.00 + } + }, + "overlayType": null, + "overlay": null, + "openWindow": null, + "nextScheduleChange": { + "start": "2020-03-10T22:00:00Z", + "setting": { + "type": "HOT_WATER", + "power": "OFF", + "temperature": null + } + }, + "nextTimeBlock": { + "start": "2020-03-10T22:00:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": {}, + "sensorDataPoints": {} +} diff --git a/tests/fixtures/tado/tadov2.water_heater.heating.json b/tests/fixtures/tado/tadov2.water_heater.heating.json new file mode 100644 index 00000000000..8eecc79d63c --- /dev/null +++ b/tests/fixtures/tado/tadov2.water_heater.heating.json @@ -0,0 +1,51 @@ +{ + "activityDataPoints" : {}, + "preparation" : null, + "openWindow" : null, + "tadoMode" : "HOME", + "nextScheduleChange" : { + "setting" : { + "temperature" : { + "fahrenheit" : 149, + "celsius" : 65 + }, + "type" : "HOT_WATER", + "power" : "ON" + }, + "start" : "2020-03-26T05:00:00Z" + }, + "nextTimeBlock" : { + "start" : "2020-03-26T05:00:00.000Z" + }, + "overlay" : { + "setting" : { + "temperature" : { + "celsius" : 30, + "fahrenheit" : 86 + }, + "type" : "HOT_WATER", + "power" : "ON" + }, + "termination" : { + "type" : "TADO_MODE", + "projectedExpiry" : "2020-03-26T05:00:00Z", + "typeSkillBasedApp" : "TADO_MODE" + }, + "type" : "MANUAL" + }, + "geolocationOverride" : false, + "geolocationOverrideDisableTime" : null, + "sensorDataPoints" : {}, + "overlayType" : "MANUAL", + "link" : { + "state" : "ONLINE" + }, + "setting" : { + "type" : "HOT_WATER", + "temperature" : { + "fahrenheit" : 86, + "celsius" : 30 + }, + "power" : "ON" + } +} diff --git a/tests/fixtures/tado/tadov2.water_heater.manual_mode.json b/tests/fixtures/tado/tadov2.water_heater.manual_mode.json new file mode 100644 index 00000000000..21972a55d6d --- /dev/null +++ b/tests/fixtures/tado/tadov2.water_heater.manual_mode.json @@ -0,0 +1,48 @@ +{ + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "HOT_WATER", + "power": "ON", + "temperature": { + "celsius": 55.00, + "fahrenheit": 131.00 + } + }, + "overlayType": "MANUAL", + "overlay": { + "type": "MANUAL", + "setting": { + "type": "HOT_WATER", + "power": "ON", + "temperature": { + "celsius": 55.00, + "fahrenheit": 131.00 + } + }, + "termination": { + "type": "MANUAL", + "typeSkillBasedApp": "MANUAL", + "projectedExpiry": null + } + }, + "openWindow": null, + "nextScheduleChange": { + "start": "2020-03-10T22:00:00Z", + "setting": { + "type": "HOT_WATER", + "power": "OFF", + "temperature": null + } + }, + "nextTimeBlock": { + "start": "2020-03-10T22:00:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": {}, + "sensorDataPoints": {} +} diff --git a/tests/fixtures/tado/tadov2.water_heater.off_mode.json b/tests/fixtures/tado/tadov2.water_heater.off_mode.json new file mode 100644 index 00000000000..12698db601b --- /dev/null +++ b/tests/fixtures/tado/tadov2.water_heater.off_mode.json @@ -0,0 +1,42 @@ +{ + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "HOT_WATER", + "power": "OFF", + "temperature": null + }, + "overlayType": "MANUAL", + "overlay": { + "type": "MANUAL", + "setting": { + "type": "HOT_WATER", + "power": "OFF", + "temperature": null + }, + "termination": { + "type": "MANUAL", + "typeSkillBasedApp": "MANUAL", + "projectedExpiry": null + } + }, + "openWindow": null, + "nextScheduleChange": { + "start": "2020-03-10T22:00:00Z", + "setting": { + "type": "HOT_WATER", + "power": "OFF", + "temperature": null + } + }, + "nextTimeBlock": { + "start": "2020-03-10T22:00:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": {}, + "sensorDataPoints": {} +} diff --git a/tests/fixtures/tado/tadov2.zone_capabilities.json b/tests/fixtures/tado/tadov2.zone_capabilities.json new file mode 100644 index 00000000000..a908b699e64 --- /dev/null +++ b/tests/fixtures/tado/tadov2.zone_capabilities.json @@ -0,0 +1,19 @@ +{ + "type" : "HEATING", + "HEAT" : { + "temperatures" : { + "celsius" : { + "max" : 31, + "step" : 1, + "min" : 16 + }, + "fahrenheit" : { + "step" : 1, + "max" : 88, + "min" : 61 + } + } + }, + "AUTO" : {}, + "FAN" : {} +} diff --git a/tests/fixtures/tado/token.json b/tests/fixtures/tado/token.json new file mode 100644 index 00000000000..1e0089a1c9a --- /dev/null +++ b/tests/fixtures/tado/token.json @@ -0,0 +1,8 @@ +{ + "expires_in" : 599, + "scope" : "home.user", + "token_type" : "bearer", + "refresh_token" : "refresh", + "access_token" : "access", + "jti" : "jti" +} diff --git a/tests/fixtures/tado/water_heater_zone_capabilities.json b/tests/fixtures/tado/water_heater_zone_capabilities.json new file mode 100644 index 00000000000..f3f0daa6c09 --- /dev/null +++ b/tests/fixtures/tado/water_heater_zone_capabilities.json @@ -0,0 +1,17 @@ +{ + "canSetTemperature" : true, + "DRY" : {}, + "type" : "HOT_WATER", + "temperatures" : { + "celsius" : { + "min" : 16, + "max" : 31, + "step" : 1 + }, + "fahrenheit" : { + "step" : 1, + "max" : 88, + "min" : 61 + } + } +} diff --git a/tests/fixtures/tado/zone_capabilities.json b/tests/fixtures/tado/zone_capabilities.json new file mode 100644 index 00000000000..8435094ecca --- /dev/null +++ b/tests/fixtures/tado/zone_capabilities.json @@ -0,0 +1,46 @@ +{ + "type" : "AIR_CONDITIONING", + "HEAT" : { + "fanSpeeds" : [ + "AUTO", + "HIGH", + "MIDDLE", + "LOW" + ], + "temperatures" : { + "celsius" : { + "max" : 31, + "step" : 1, + "min" : 16 + }, + "fahrenheit" : { + "step" : 1, + "max" : 88, + "min" : 61 + } + } + }, + "AUTO" : {}, + "DRY" : {}, + "FAN" : {}, + "COOL" : { + "temperatures" : { + "celsius" : { + "min" : 16, + "step" : 1, + "max" : 31 + }, + "fahrenheit" : { + "min" : 61, + "max" : 88, + "step" : 1 + } + }, + "fanSpeeds" : [ + "AUTO", + "HIGH", + "MIDDLE", + "LOW" + ] + } +} diff --git a/tests/fixtures/tado/zone_state.json b/tests/fixtures/tado/zone_state.json new file mode 100644 index 00000000000..c206dc9d081 --- /dev/null +++ b/tests/fixtures/tado/zone_state.json @@ -0,0 +1,55 @@ +{ + "openWindow" : null, + "nextScheduleChange" : null, + "geolocationOverrideDisableTime" : null, + "sensorDataPoints" : { + "insideTemperature" : { + "celsius" : 22.43, + "type" : "TEMPERATURE", + "precision" : { + "fahrenheit" : 0.1, + "celsius" : 0.1 + }, + "timestamp" : "2020-03-23T18:30:07.377Z", + "fahrenheit" : 72.37 + }, + "humidity" : { + "timestamp" : "2020-03-23T18:30:07.377Z", + "percentage" : 60.2, + "type" : "PERCENTAGE" + } + }, + "overlay" : { + "type" : "MANUAL", + "termination" : { + "projectedExpiry" : null, + "typeSkillBasedApp" : "MANUAL", + "type" : "MANUAL" + }, + "setting" : { + "power" : "OFF", + "type" : "AIR_CONDITIONING" + } + }, + "geolocationOverride" : false, + "overlayType" : "MANUAL", + "activityDataPoints" : { + "acPower" : { + "type" : "POWER", + "timestamp" : "2020-03-11T15:08:23.604Z", + "value" : "OFF" + } + }, + "tadoMode" : "HOME", + "link" : { + "state" : "ONLINE" + }, + "setting" : { + "power" : "OFF", + "type" : "AIR_CONDITIONING" + }, + "nextTimeBlock" : { + "start" : "2020-03-24T03:00:00.000Z" + }, + "preparation" : null +} diff --git a/tests/fixtures/tado/zones.json b/tests/fixtures/tado/zones.json new file mode 100644 index 00000000000..8d7265ade50 --- /dev/null +++ b/tests/fixtures/tado/zones.json @@ -0,0 +1,179 @@ +[ + { + "deviceTypes" : [ + "WR02" + ], + "type" : "HEATING", + "reportAvailable" : false, + "dazzleMode" : { + "enabled" : true, + "supported" : true + }, + "name" : "Baseboard Heater", + "supportsDazzle" : true, + "id" : 1, + "devices" : [ + { + "duties" : [ + "ZONE_UI", + "ZONE_DRIVER", + "ZONE_LEADER" + ], + "currentFwVersion" : "59.4", + "deviceType" : "WR02", + "serialNo" : "WR4", + "shortSerialNo" : "WR4", + "commandTableUploadState" : "FINISHED", + "connectionState" : { + "value" : true, + "timestamp" : "2020-03-23T18:30:07.377Z" + }, + "accessPointWiFi" : { + "ssid" : "tado8480" + }, + "characteristics" : { + "capabilities" : [ + "INSIDE_TEMPERATURE_MEASUREMENT", + "IDENTIFY" + ] + } + } + ], + "dateCreated" : "2019-11-28T15:58:48.968Z", + "dazzleEnabled" : true + }, + { + "type" : "HOT_WATER", + "reportAvailable" : false, + "deviceTypes" : [ + "WR02" + ], + "devices" : [ + { + "connectionState" : { + "value" : true, + "timestamp" : "2020-03-23T18:30:07.377Z" + }, + "accessPointWiFi" : { + "ssid" : "tado8480" + }, + "characteristics" : { + "capabilities" : [ + "INSIDE_TEMPERATURE_MEASUREMENT", + "IDENTIFY" + ] + }, + "duties" : [ + "ZONE_UI", + "ZONE_DRIVER", + "ZONE_LEADER" + ], + "currentFwVersion" : "59.4", + "deviceType" : "WR02", + "serialNo" : "WR4", + "shortSerialNo" : "WR4", + "commandTableUploadState" : "FINISHED" + } + ], + "dazzleEnabled" : true, + "dateCreated" : "2019-11-28T15:58:48.968Z", + "name" : "Water Heater", + "dazzleMode" : { + "enabled" : true, + "supported" : true + }, + "id" : 2, + "supportsDazzle" : true + }, + { + "dazzleMode" : { + "supported" : true, + "enabled" : true + }, + "name" : "Air Conditioning", + "id" : 3, + "supportsDazzle" : true, + "devices" : [ + { + "deviceType" : "WR02", + "shortSerialNo" : "WR4", + "serialNo" : "WR4", + "commandTableUploadState" : "FINISHED", + "duties" : [ + "ZONE_UI", + "ZONE_DRIVER", + "ZONE_LEADER" + ], + "currentFwVersion" : "59.4", + "characteristics" : { + "capabilities" : [ + "INSIDE_TEMPERATURE_MEASUREMENT", + "IDENTIFY" + ] + }, + "accessPointWiFi" : { + "ssid" : "tado8480" + }, + "connectionState" : { + "timestamp" : "2020-03-23T18:30:07.377Z", + "value" : true + } + } + ], + "dazzleEnabled" : true, + "dateCreated" : "2019-11-28T15:58:48.968Z", + "openWindowDetection" : { + "timeoutInSeconds" : 900, + "enabled" : true, + "supported" : true + }, + "deviceTypes" : [ + "WR02" + ], + "reportAvailable" : false, + "type" : "AIR_CONDITIONING" + }, + { + "type" : "HOT_WATER", + "reportAvailable" : false, + "deviceTypes" : [ + "WR02" + ], + "devices" : [ + { + "connectionState" : { + "value" : true, + "timestamp" : "2020-03-23T18:30:07.377Z" + }, + "accessPointWiFi" : { + "ssid" : "tado8480" + }, + "characteristics" : { + "capabilities" : [ + "INSIDE_TEMPERATURE_MEASUREMENT", + "IDENTIFY" + ] + }, + "duties" : [ + "ZONE_UI", + "ZONE_DRIVER", + "ZONE_LEADER" + ], + "currentFwVersion" : "59.4", + "deviceType" : "WR02", + "serialNo" : "WR4", + "shortSerialNo" : "WR4", + "commandTableUploadState" : "FINISHED" + } + ], + "dazzleEnabled" : true, + "dateCreated" : "2019-11-28T15:58:48.968Z", + "name" : "Second Water Heater", + "dazzleMode" : { + "enabled" : true, + "supported" : true + }, + "id" : 4, + "supportsDazzle" : true + } +] diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index 8c8d370e4b4..dcadd4d4369 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -6,7 +6,7 @@ from unittest.mock import Mock, patch import pytest -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import EVENT_HOMEASSISTANT_FINAL_WRITE from homeassistant.helpers import storage from homeassistant.util import dt @@ -85,7 +85,7 @@ async def test_saving_on_stop(hass, hass_storage): store.async_delay_save(lambda: MOCK_DATA, 1) assert store.key not in hass_storage - hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + hass.bus.async_fire(EVENT_HOMEASSISTANT_FINAL_WRITE) await hass.async_block_till_done() assert hass_storage[store.key] == { "version": MOCK_VERSION, diff --git a/tests/ignore_uncaught_exceptions.py b/tests/ignore_uncaught_exceptions.py index e6dc2f7e4f5..58531b251e0 100644 --- a/tests/ignore_uncaught_exceptions.py +++ b/tests/ignore_uncaught_exceptions.py @@ -51,8 +51,6 @@ IGNORE_UNCAUGHT_EXCEPTIONS = [ ("tests.components.dyson.test_fan", "test_purecool_update_state_filter_inv"), ("tests.components.dyson.test_fan", "test_purecool_component_setup_only_once"), ("tests.components.dyson.test_sensor", "test_purecool_component_setup_only_once"), - ("tests.components.geonetnz_quakes.test_geo_location", "test_setup"), - ("tests.components.geonetnz_quakes.test_sensor", "test_setup"), ("test_homeassistant_bridge", "test_homeassistant_bridge_fan_setup"), ("tests.components.hue.test_bridge", "test_handle_unauthorized"), ("tests.components.hue.test_init", "test_security_vuln_check"), @@ -123,35 +121,6 @@ IGNORE_UNCAUGHT_EXCEPTIONS = [ ("tests.components.yr.test_sensor", "test_default_setup"), ("tests.components.yr.test_sensor", "test_custom_setup"), ("tests.components.yr.test_sensor", "test_forecast_setup"), - ("tests.components.zha.test_api", "test_device_clusters"), - ("tests.components.zha.test_api", "test_device_cluster_attributes"), - ("tests.components.zha.test_api", "test_device_cluster_commands"), - ("tests.components.zha.test_api", "test_list_devices"), - ("tests.components.zha.test_api", "test_device_not_found"), - ("tests.components.zha.test_api", "test_list_groups"), - ("tests.components.zha.test_api", "test_get_group"), - ("tests.components.zha.test_api", "test_get_group_not_found"), - ("tests.components.zha.test_api", "test_list_groupable_devices"), - ("tests.components.zha.test_api", "test_add_group"), - ("tests.components.zha.test_api", "test_remove_group"), - ("tests.components.zha.test_binary_sensor", "test_binary_sensor"), - ("tests.components.zha.test_cover", "test_cover"), - ("tests.components.zha.test_device_action", "test_get_actions"), - ("tests.components.zha.test_device_action", "test_action"), - ("tests.components.zha.test_device_tracker", "test_device_tracker"), - ("tests.components.zha.test_device_trigger", "test_triggers"), - ("tests.components.zha.test_device_trigger", "test_no_triggers"), - ("tests.components.zha.test_device_trigger", "test_if_fires_on_event"), - ("tests.components.zha.test_device_trigger", "test_exception_no_triggers"), - ("tests.components.zha.test_device_trigger", "test_exception_bad_trigger"), - ("tests.components.zha.test_discover", "test_devices"), - ("tests.components.zha.test_discover", "test_device_override"), - ("tests.components.zha.test_fan", "test_fan"), - ("tests.components.zha.test_gateway", "test_gateway_group_methods"), - ("tests.components.zha.test_light", "test_light"), - ("tests.components.zha.test_lock", "test_lock"), - ("tests.components.zha.test_sensor", "test_sensor"), - ("tests.components.zha.test_switch", "test_switch"), ("tests.components.zwave.test_init", "test_power_schemes"), ( "tests.helpers.test_entity_platform", diff --git a/tests/test_core.py b/tests/test_core.py index f5a6f4718cd..5e6bb090821 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -21,6 +21,7 @@ from homeassistant.const import ( EVENT_CALL_SERVICE, EVENT_CORE_CONFIG_UPDATE, EVENT_HOMEASSISTANT_CLOSE, + EVENT_HOMEASSISTANT_FINAL_WRITE, EVENT_HOMEASSISTANT_STOP, EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, @@ -151,10 +152,14 @@ def test_stage_shutdown(): """Simulate a shutdown, test calling stuff.""" hass = get_test_home_assistant() test_stop = [] + test_final_write = [] test_close = [] test_all = [] hass.bus.listen(EVENT_HOMEASSISTANT_STOP, lambda event: test_stop.append(event)) + hass.bus.listen( + EVENT_HOMEASSISTANT_FINAL_WRITE, lambda event: test_final_write.append(event) + ) hass.bus.listen(EVENT_HOMEASSISTANT_CLOSE, lambda event: test_close.append(event)) hass.bus.listen("*", lambda event: test_all.append(event)) @@ -162,7 +167,8 @@ def test_stage_shutdown(): assert len(test_stop) == 1 assert len(test_close) == 1 - assert len(test_all) == 1 + assert len(test_final_write) == 1 + assert len(test_all) == 2 class TestHomeAssistant(unittest.TestCase):