diff --git a/.coveragerc b/.coveragerc
index f340202cdb8..162e0c65f06 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -8,10 +8,6 @@ omit =
homeassistant/scripts/*.py
# omit pieces of code that rely on external devices being present
- homeassistant/components/accuweather/__init__.py
- homeassistant/components/accuweather/const.py
- homeassistant/components/accuweather/sensor.py
- homeassistant/components/accuweather/weather.py
homeassistant/components/acer_projector/switch.py
homeassistant/components/actiontec/device_tracker.py
homeassistant/components/acmeda/__init__.py
@@ -29,6 +25,7 @@ omit =
homeassistant/components/ads/*
homeassistant/components/aftership/sensor.py
homeassistant/components/agent_dvr/__init__.py
+ homeassistant/components/agent_dvr/alarm_control_panel.py
homeassistant/components/agent_dvr/camera.py
homeassistant/components/agent_dvr/const.py
homeassistant/components/agent_dvr/helpers.py
@@ -103,11 +100,11 @@ omit =
homeassistant/components/braviatv/__init__.py
homeassistant/components/braviatv/const.py
homeassistant/components/braviatv/media_player.py
+ homeassistant/components/broadlink/__init__.py
homeassistant/components/broadlink/const.py
- homeassistant/components/broadlink/device.py
homeassistant/components/broadlink/remote.py
- homeassistant/components/broadlink/sensor.py
homeassistant/components/broadlink/switch.py
+ homeassistant/components/broadlink/updater.py
homeassistant/components/brottsplatskartan/sensor.py
homeassistant/components/browser/*
homeassistant/components/brunt/cover.py
@@ -169,7 +166,9 @@ omit =
homeassistant/components/deutsche_bahn/sensor.py
homeassistant/components/devolo_home_control/__init__.py
homeassistant/components/devolo_home_control/binary_sensor.py
+ homeassistant/components/devolo_home_control/climate.py
homeassistant/components/devolo_home_control/const.py
+ homeassistant/components/devolo_home_control/cover.py
homeassistant/components/devolo_home_control/devolo_device.py
homeassistant/components/devolo_home_control/devolo_multi_level_switch.py
homeassistant/components/devolo_home_control/light.py
@@ -221,7 +220,6 @@ omit =
homeassistant/components/emby/media_player.py
homeassistant/components/emoncms/sensor.py
homeassistant/components/emoncms_history/*
- homeassistant/components/emulated_hue/upnp.py
homeassistant/components/enigma2/media_player.py
homeassistant/components/enocean/__init__.py
homeassistant/components/enocean/binary_sensor.py
@@ -309,8 +307,6 @@ omit =
homeassistant/components/gc100/*
homeassistant/components/geniushub/*
homeassistant/components/geizhals/sensor.py
- homeassistant/components/gios/__init__.py
- homeassistant/components/gios/air_quality.py
homeassistant/components/github/sensor.py
homeassistant/components/gitlab_ci/sensor.py
homeassistant/components/gitter/sensor.py
@@ -397,7 +393,17 @@ omit =
homeassistant/components/ihc/*
homeassistant/components/imap/sensor.py
homeassistant/components/imap_email_content/sensor.py
- homeassistant/components/insteon/*
+ homeassistant/components/insteon/binary_sensor.py
+ homeassistant/components/insteon/climate.py
+ homeassistant/components/insteon/const.py
+ homeassistant/components/insteon/cover.py
+ homeassistant/components/insteon/fan.py
+ homeassistant/components/insteon/insteon_entity.py
+ homeassistant/components/insteon/ipdb.py
+ homeassistant/components/insteon/light.py
+ homeassistant/components/insteon/schemas.py
+ homeassistant/components/insteon/switch.py
+ homeassistant/components/insteon/utils.py
homeassistant/components/incomfort/*
homeassistant/components/intesishome/*
homeassistant/components/ios/*
@@ -440,6 +446,7 @@ omit =
homeassistant/components/knx/climate.py
homeassistant/components/knx/cover.py
homeassistant/components/kodi/__init__.py
+ homeassistant/components/kodi/browse_media.py
homeassistant/components/kodi/const.py
homeassistant/components/kodi/media_player.py
homeassistant/components/kodi/notify.py
@@ -581,8 +588,7 @@ omit =
homeassistant/components/nuki/lock.py
homeassistant/components/nut/sensor.py
homeassistant/components/nx584/alarm_control_panel.py
- homeassistant/components/nzbget/__init__.py
- homeassistant/components/nzbget/sensor.py
+ homeassistant/components/nzbget/coordinator.py
homeassistant/components/obihai/*
homeassistant/components/octoprint/*
homeassistant/components/oem/climate.py
@@ -617,6 +623,9 @@ omit =
homeassistant/components/openuv/sensor.py
homeassistant/components/openweathermap/sensor.py
homeassistant/components/openweathermap/weather.py
+ homeassistant/components/openweathermap/forecast_update_coordinator.py
+ homeassistant/components/openweathermap/weather_update_coordinator.py
+ homeassistant/components/openweathermap/abstract_owm_sensor.py
homeassistant/components/opnsense/*
homeassistant/components/opple/light.py
homeassistant/components/orangepi_gpio/*
@@ -647,19 +656,16 @@ omit =
homeassistant/components/plaato/*
homeassistant/components/plex/media_player.py
homeassistant/components/plex/sensor.py
- homeassistant/components/plugwise/__init__.py
- homeassistant/components/plugwise/binary_sensor.py
- homeassistant/components/plugwise/climate.py
- homeassistant/components/plugwise/sensor.py
- homeassistant/components/plugwise/switch.py
homeassistant/components/plum_lightpad/light.py
homeassistant/components/pocketcasts/sensor.py
homeassistant/components/point/*
homeassistant/components/poolsense/__init__.py
homeassistant/components/poolsense/sensor.py
homeassistant/components/poolsense/binary_sensor.py
- homeassistant/components/prezzibenzina/sensor.py
homeassistant/components/proliphix/climate.py
+ homeassistant/components/progettihwsw/__init__.py
+ homeassistant/components/progettihwsw/binary_sensor.py
+ homeassistant/components/progettihwsw/switch.py
homeassistant/components/prometheus/*
homeassistant/components/prowl/notify.py
homeassistant/components/proxmoxve/*
@@ -711,6 +717,10 @@ omit =
homeassistant/components/roomba/roomba.py
homeassistant/components/roomba/sensor.py
homeassistant/components/roomba/vacuum.py
+ homeassistant/components/roon/__init__.py
+ homeassistant/components/roon/const.py
+ homeassistant/components/roon/media_player.py
+ homeassistant/components/roon/server.py
homeassistant/components/route53/*
homeassistant/components/rova/sensor.py
homeassistant/components/rpi_camera/*
@@ -735,7 +745,6 @@ omit =
homeassistant/components/sensehat/light.py
homeassistant/components/sensehat/sensor.py
homeassistant/components/sensibo/climate.py
- homeassistant/components/sentry/__init__.py
homeassistant/components/serial/sensor.py
homeassistant/components/serial_pm/sensor.py
homeassistant/components/sesame/lock.py
@@ -743,6 +752,13 @@ omit =
homeassistant/components/seventeentrack/sensor.py
homeassistant/components/shiftr/*
homeassistant/components/shodan/sensor.py
+ homeassistant/components/shelly/__init__.py
+ homeassistant/components/shelly/binary_sensor.py
+ homeassistant/components/shelly/cover.py
+ homeassistant/components/shelly/entity.py
+ homeassistant/components/shelly/light.py
+ homeassistant/components/shelly/sensor.py
+ homeassistant/components/shelly/switch.py
homeassistant/components/sht31/sensor.py
homeassistant/components/sigfox/sensor.py
homeassistant/components/simplepush/notify.py
@@ -801,7 +817,8 @@ omit =
homeassistant/components/streamlabswater/*
homeassistant/components/suez_water/*
homeassistant/components/supervisord/sensor.py
- homeassistant/components/surepetcare/*.py
+ homeassistant/components/surepetcare/__init__.py
+ homeassistant/components/surepetcare/sensor.py
homeassistant/components/swiss_hydrological_data/sensor.py
homeassistant/components/swiss_public_transport/sensor.py
homeassistant/components/swisscom/device_tracker.py
@@ -921,6 +938,7 @@ omit =
homeassistant/components/vesync/__init__.py
homeassistant/components/vesync/common.py
homeassistant/components/vesync/const.py
+ homeassistant/components/vesync/fan.py
homeassistant/components/vesync/switch.py
homeassistant/components/viaggiatreno/sensor.py
homeassistant/components/vicare/*
@@ -1010,7 +1028,6 @@ omit =
homeassistant/components/supla/*
homeassistant/components/zwave/util.py
homeassistant/components/ozw/__init__.py
- homeassistant/components/ozw/discovery.py
homeassistant/components/ozw/entity.py
homeassistant/components/ozw/services.py
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
index f873f250da8..5a64b0d5b73 100644
--- a/.github/ISSUE_TEMPLATE.md
+++ b/.github/ISSUE_TEMPLATE.md
@@ -16,7 +16,7 @@
- Home Assistant Core release with the issue:
diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.md b/.github/ISSUE_TEMPLATE/BUG_REPORT.md
index e60aa00a448..bdadc5678ff 100644
--- a/.github/ISSUE_TEMPLATE/BUG_REPORT.md
+++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.md
@@ -20,7 +20,7 @@ about: Report an issue with Home Assistant Core
- Home Assistant Core release with the issue:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 1ada6d3af86..e7412e6ba8e 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -13,7 +13,7 @@
## Proposed change
-
@@ -98,6 +98,29 @@ The integration reached or maintains the following [Integration Quality Scale][q
- [ ] 🥇 Gold
- [ ] 🏆 Platinum
+
+
+To help with the load of incoming pull requests:
+
+- [ ] I have reviewed two other [open pull requests][prs] in this repository.
+
+[prs]: https://github.com/home-assistant/core/pulls?q=is%3Aopen+is%3Apr+-author%3A%40me+-draft%3Atrue+-label%3Awaiting-for-upstream+sort%3Acreated-asc+-review%3Aapproved
+
%s",
+ media_type,
+ media_id,
+ )
diff --git a/homeassistant/components/roon/server.py b/homeassistant/components/roon/server.py
new file mode 100644
index 00000000000..df6051c287a
--- /dev/null
+++ b/homeassistant/components/roon/server.py
@@ -0,0 +1,153 @@
+"""Code to handle the api connection to a Roon server."""
+import asyncio
+import logging
+
+from roon import RoonApi
+
+from homeassistant.const import CONF_API_KEY, CONF_HOST
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.util.dt import utcnow
+
+from .const import ROON_APPINFO
+
+_LOGGER = logging.getLogger(__name__)
+FULL_SYNC_INTERVAL = 30
+
+
+class RoonServer:
+ """Manages a single Roon Server."""
+
+ def __init__(self, hass, config_entry):
+ """Initialize the system."""
+ self.config_entry = config_entry
+ self.hass = hass
+ self.roonapi = None
+ self.all_player_ids = set()
+ self.all_playlists = []
+ self.offline_devices = set()
+ self._exit = False
+
+ @property
+ def host(self):
+ """Return the host of this server."""
+ return self.config_entry.data[CONF_HOST]
+
+ async def async_setup(self, tries=0):
+ """Set up a roon server based on host parameter."""
+ host = self.host
+ hass = self.hass
+ token = self.config_entry.data[CONF_API_KEY]
+ _LOGGER.debug("async_setup: %s %s", token, host)
+ self.roonapi = RoonApi(ROON_APPINFO, token, host, blocking_init=False)
+ self.roonapi.register_state_callback(
+ self.roonapi_state_callback, event_filter=["zones_changed"]
+ )
+
+ # initialize media_player platform
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(
+ self.config_entry, "media_player"
+ )
+ )
+
+ # Initialize Roon background polling
+ asyncio.create_task(self.async_do_loop())
+
+ return True
+
+ async def async_reset(self):
+ """Reset this connection to default state.
+
+ Will cancel any scheduled setup retry and will unload
+ the config entry.
+ """
+ self.stop_roon()
+ return True
+
+ @property
+ def zones(self):
+ """Return list of zones."""
+ return self.roonapi.zones
+
+ def stop_roon(self):
+ """Stop background worker."""
+ self.roonapi.stop()
+ self._exit = True
+
+ def roonapi_state_callback(self, event, changed_zones):
+ """Callbacks from the roon api websockets."""
+ self.hass.add_job(self.async_update_changed_players(changed_zones))
+
+ async def async_do_loop(self):
+ """Background work loop."""
+ self._exit = False
+ while not self._exit:
+ await self.async_update_players()
+ # await self.async_update_playlists()
+ await asyncio.sleep(FULL_SYNC_INTERVAL)
+
+ async def async_update_changed_players(self, changed_zones_ids):
+ """Update the players which were reported as changed by the Roon API."""
+ for zone_id in changed_zones_ids:
+ if zone_id not in self.roonapi.zones:
+ # device was removed ?
+ continue
+ zone = self.roonapi.zones[zone_id]
+ for device in zone["outputs"]:
+ dev_name = device["display_name"]
+ if dev_name == "Unnamed" or not dev_name:
+ # ignore unnamed devices
+ continue
+ player_data = await self.async_create_player_data(zone, device)
+ dev_id = player_data["dev_id"]
+ player_data["is_available"] = True
+ if dev_id in self.offline_devices:
+ # player back online
+ self.offline_devices.remove(dev_id)
+ async_dispatcher_send(self.hass, "roon_media_player", player_data)
+ self.all_player_ids.add(dev_id)
+
+ async def async_update_players(self):
+ """Periodic full scan of all devices."""
+ zone_ids = self.roonapi.zones.keys()
+ await self.async_update_changed_players(zone_ids)
+ # check for any removed devices
+ all_devs = {}
+ for zone in self.roonapi.zones.values():
+ for device in zone["outputs"]:
+ player_data = await self.async_create_player_data(zone, device)
+ dev_id = player_data["dev_id"]
+ all_devs[dev_id] = player_data
+ for dev_id in self.all_player_ids:
+ if dev_id in all_devs:
+ continue
+ # player was removed!
+ player_data = {"dev_id": dev_id}
+ player_data["is_available"] = False
+ async_dispatcher_send(self.hass, "roon_media_player", player_data)
+ self.offline_devices.add(dev_id)
+
+ async def async_update_playlists(self):
+ """Store lists in memory with all playlists - could be used by a custom lovelace card."""
+ all_playlists = []
+ roon_playlists = self.roonapi.playlists()
+ if roon_playlists and "items" in roon_playlists:
+ all_playlists += [item["title"] for item in roon_playlists["items"]]
+ roon_playlists = self.roonapi.internet_radio()
+ if roon_playlists and "items" in roon_playlists:
+ all_playlists += [item["title"] for item in roon_playlists["items"]]
+ self.all_playlists = all_playlists
+
+ async def async_create_player_data(self, zone, output):
+ """Create player object dict by combining zone with output."""
+ new_dict = zone.copy()
+ new_dict.update(output)
+ new_dict.pop("outputs")
+ new_dict["host"] = self.host
+ new_dict["is_synced"] = len(zone["outputs"]) > 1
+ new_dict["zone_name"] = zone["display_name"]
+ new_dict["display_name"] = output["display_name"]
+ new_dict["last_changed"] = utcnow()
+ # we don't use the zone_id or output_id for now as unique id as I've seen cases were it changes for some reason
+ new_dict["dev_id"] = f"roon_{self.host}_{output['display_name']}"
+ return new_dict
diff --git a/homeassistant/components/roon/strings.json b/homeassistant/components/roon/strings.json
new file mode 100644
index 00000000000..741fc01605f
--- /dev/null
+++ b/homeassistant/components/roon/strings.json
@@ -0,0 +1,26 @@
+{
+ "title": "Roon",
+ "config": {
+ "step": {
+ "user": {
+ "title": "Configure Roon Server",
+ "description": "Please enter your Roon server Hostname or IP.",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ }
+ },
+ "link": {
+ "title": "Authorize HomeAssistant in Roon",
+ "description": "You must authorize Home Assistant in Roon. After you click submit, go to the Roon Core application, open Settings and enable HomeAssistant on the Extensions tab."
+ }
+ },
+ "error": {
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]",
+ "duplicate_entry": "That host has already been added."
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ }
+}
diff --git a/homeassistant/components/roon/translations/ca.json b/homeassistant/components/roon/translations/ca.json
new file mode 100644
index 00000000000..b783c9a6229
--- /dev/null
+++ b/homeassistant/components/roon/translations/ca.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositiu ja est\u00e0 configurat"
+ },
+ "error": {
+ "duplicate_entry": "Aquest amfitri\u00f3 ja ha estat afegit.",
+ "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida",
+ "unknown": "Error inesperat"
+ },
+ "step": {
+ "link": {
+ "description": "Has d'autoritzar Home Assistant a Roon. Despr\u00e9s de fer clic a envia, ves a l'aplicaci\u00f3 Roon Core, obre la Configuraci\u00f3 i activa Home Assistant a la pestanya d'Extensions.",
+ "title": "Autoritza Home Assistant a Roon"
+ },
+ "user": {
+ "data": {
+ "host": "Amfitri\u00f3"
+ },
+ "description": "Introdueix el nom d'amfitri\u00f3 o la IP del servidor Roon",
+ "title": "Configura un servidor Roon"
+ }
+ }
+ },
+ "title": "Roon"
+}
\ No newline at end of file
diff --git a/homeassistant/components/roon/translations/en.json b/homeassistant/components/roon/translations/en.json
new file mode 100644
index 00000000000..b7ae64f0de1
--- /dev/null
+++ b/homeassistant/components/roon/translations/en.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Device is already configured"
+ },
+ "error": {
+ "duplicate_entry": "That host has already been added.",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "step": {
+ "link": {
+ "description": "You must authorize Home Assistant in Roon. After you click submit, go to the Roon Core application, open Settings and enable HomeAssistant on the Extensions tab.",
+ "title": "Authorize HomeAssistant in Roon"
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Please enter your Roon server Hostname or IP.",
+ "title": "Configure Roon Server"
+ }
+ }
+ },
+ "title": "Roon"
+}
\ No newline at end of file
diff --git a/homeassistant/components/roon/translations/es.json b/homeassistant/components/roon/translations/es.json
new file mode 100644
index 00000000000..9c8f4346118
--- /dev/null
+++ b/homeassistant/components/roon/translations/es.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositivo ya est\u00e1 configurado"
+ },
+ "error": {
+ "duplicate_entry": "Ese host ya ha sido a\u00f1adido.",
+ "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida",
+ "unknown": "Error inesperado"
+ },
+ "step": {
+ "link": {
+ "description": "Debes autorizar Home Assistant en Roon. Despu\u00e9s de pulsar en Enviar, ve a la aplicaci\u00f3n Roon Core, abre Configuraci\u00f3n y activa HomeAssistant en la pesta\u00f1a Extensiones.",
+ "title": "Autorizar HomeAssistant en Roon"
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Introduce el nombre de Host o IP del servidor Roon.",
+ "title": "Configurar el Servidor Roon"
+ }
+ }
+ },
+ "title": "Roon"
+}
\ No newline at end of file
diff --git a/homeassistant/components/roon/translations/fr.json b/homeassistant/components/roon/translations/fr.json
new file mode 100644
index 00000000000..f41c7728954
--- /dev/null
+++ b/homeassistant/components/roon/translations/fr.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9"
+ },
+ "error": {
+ "duplicate_entry": "Cet h\u00f4te a d\u00e9j\u00e0 \u00e9t\u00e9 ajout\u00e9.",
+ "invalid_auth": "Authentification invalide",
+ "unknown": "Erreur inattendue"
+ },
+ "step": {
+ "link": {
+ "description": "Vous devez autoriser Home Assistant dans Roon. Apr\u00e8s avoir cliqu\u00e9 sur soumettre, acc\u00e9dez \u00e0 l'application Roon Core, ouvrez Param\u00e8tres et activez Home Assistant dans l'onglet Extensions.",
+ "title": "Autoriser Home Assistant dans Roon"
+ },
+ "user": {
+ "data": {
+ "host": "H\u00f4te"
+ },
+ "description": "Veuillez entrer votre nom d\u2019h\u00f4te ou votre adresse IP sur votre serveur Roon.",
+ "title": "Configurer le serveur Roon"
+ }
+ }
+ },
+ "title": "Roon"
+}
\ No newline at end of file
diff --git a/homeassistant/components/roon/translations/it.json b/homeassistant/components/roon/translations/it.json
new file mode 100644
index 00000000000..cfa1f7a3844
--- /dev/null
+++ b/homeassistant/components/roon/translations/it.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato"
+ },
+ "error": {
+ "duplicate_entry": "Questo host \u00e8 gi\u00e0 stato aggiunto.",
+ "invalid_auth": "Autenticazione non valida",
+ "unknown": "Errore imprevisto"
+ },
+ "step": {
+ "link": {
+ "description": "\u00c8 necessario autorizzare l'Assistente Home in Roon. Dopo aver fatto clic su Invia, passare all'applicazione Roon Core, aprire Impostazioni e abilitare HomeAssistant nella scheda Estensioni.",
+ "title": "Autorizzare HomeAssistant in Roon"
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Inserisci il nome host o l'IP del tuo server Roon.",
+ "title": "Configurare Il server Roon"
+ }
+ }
+ },
+ "title": "Roon"
+}
\ No newline at end of file
diff --git a/homeassistant/components/roon/translations/ko.json b/homeassistant/components/roon/translations/ko.json
new file mode 100644
index 00000000000..50c22e9e256
--- /dev/null
+++ b/homeassistant/components/roon/translations/ko.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roon/translations/lb.json b/homeassistant/components/roon/translations/lb.json
new file mode 100644
index 00000000000..e8c3bc1825b
--- /dev/null
+++ b/homeassistant/components/roon/translations/lb.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Apparat ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "duplicate_entry": "D\u00ebsen Apparat gouf scho dob\u00e4igesat.",
+ "invalid_auth": "Ong\u00eblteg Authentifikatioun",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "step": {
+ "link": {
+ "description": "Du muss Home Assistant am Roon autoris\u00e9ieren. Nodeems Du op ofsch\u00e9cke geklickt hues, g\u00e9i an d'Roon Applikatioun, an d'Astellungen an aktiv\u00e9ier HomeAssistant an den Extensiounen.",
+ "title": "HomeAssistant am Roon erlaaben"
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "G\u00ebff den Numm oder IP-Adress vun dengem Roon Server un.",
+ "title": "Roon Server ariichten"
+ }
+ }
+ },
+ "title": "Roon"
+}
\ No newline at end of file
diff --git a/homeassistant/components/roon/translations/nl.json b/homeassistant/components/roon/translations/nl.json
new file mode 100644
index 00000000000..73abdb3d49b
--- /dev/null
+++ b/homeassistant/components/roon/translations/nl.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Apparaat is al toegevoegd"
+ },
+ "error": {
+ "duplicate_entry": "Die host is al toegevoegd.",
+ "invalid_auth": "Ongeldige authencatie",
+ "unknown": "Onverwachte fout"
+ },
+ "step": {
+ "link": {
+ "description": "U moet Home Assistant autoriseren in Roon. Nadat je op verzenden hebt geklikt, ga je naar de Roon Core-applicatie, open je Instellingen en schakel je Home Assistant in op het tabblad Extensies.",
+ "title": "Autoriseer Home Assistant in Roon"
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Voer de hostnaam of het IP-adres van uw Roon-server in.",
+ "title": "Configureer Roon Server"
+ }
+ }
+ },
+ "title": "Roon"
+}
\ No newline at end of file
diff --git a/homeassistant/components/roon/translations/no.json b/homeassistant/components/roon/translations/no.json
new file mode 100644
index 00000000000..cdf62b8c3c2
--- /dev/null
+++ b/homeassistant/components/roon/translations/no.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Enheten er allerede konfigurert"
+ },
+ "error": {
+ "duplicate_entry": "Denne verten er allerede lagt til.",
+ "invalid_auth": "Ugyldig godkjenning",
+ "unknown": "Uventet feil"
+ },
+ "step": {
+ "link": {
+ "description": "Du m\u00e5 autorisere home assistant i Roon. N\u00e5r du klikker send inn, g\u00e5r du til Roon Core-programmet, \u00e5pner Innstillinger og aktiverer HomeAssistant p\u00e5 Utvidelser-fanen.",
+ "title": "Autoriser HomeAssistant i Roon"
+ },
+ "user": {
+ "data": {
+ "host": "Vert"
+ },
+ "description": "Vennligst skriv inn Roon-serverens vertsnavn eller IP.",
+ "title": "Konfigurer Roon Server"
+ }
+ }
+ },
+ "title": "Roon"
+}
\ No newline at end of file
diff --git a/homeassistant/components/roon/translations/pt-BR.json b/homeassistant/components/roon/translations/pt-BR.json
new file mode 100644
index 00000000000..5a11826f285
--- /dev/null
+++ b/homeassistant/components/roon/translations/pt-BR.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O dispositivo j\u00e1 foi configurado"
+ },
+ "error": {
+ "duplicate_entry": "Esse host j\u00e1 foi adicionado.",
+ "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida",
+ "unknown": "Ocorreu um erro inexperado"
+ },
+ "step": {
+ "link": {
+ "description": "Voc\u00ea deve autorizar o Home Assistant no Roon. Depois de clicar em enviar, v\u00e1 para o aplicativo Roon principal, abra Configura\u00e7\u00f5es e habilite o HomeAssistant na aba Extens\u00f5es.",
+ "title": "Autorizar HomeAssistant no Roon"
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Por favor, digite seu hostname ou IP do servidor Roon.",
+ "title": "Configurar Servidor Roon"
+ }
+ }
+ },
+ "title": "Roon"
+}
\ No newline at end of file
diff --git a/homeassistant/components/roon/translations/pt.json b/homeassistant/components/roon/translations/pt.json
new file mode 100644
index 00000000000..6e36ff769cd
--- /dev/null
+++ b/homeassistant/components/roon/translations/pt.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado"
+ },
+ "error": {
+ "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida",
+ "unknown": "Erro inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Servidor"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/roon/translations/ru.json b/homeassistant/components/roon/translations/ru.json
new file mode 100644
index 00000000000..cfed11dd4cf
--- /dev/null
+++ b/homeassistant/components/roon/translations/ru.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant."
+ },
+ "error": {
+ "duplicate_entry": "\u042d\u0442\u043e\u0442 \u0445\u043e\u0441\u0442 \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d.",
+ "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.",
+ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "step": {
+ "link": {
+ "description": "\u041f\u043e\u0441\u043b\u0435 \u043d\u0430\u0436\u0430\u0442\u0438\u044f \u043a\u043d\u043e\u043f\u043a\u0438 \u00ab\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c\u00bb \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u0432 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 Roon Core, \u043e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 \u00ab\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u00bb \u0438 \u0432\u043a\u043b\u044e\u0447\u0438\u0442\u0435 HomeAssistant \u043d\u0430 \u0432\u043a\u043b\u0430\u0434\u043a\u0435 \u00ab\u0420\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u044f\u00bb.",
+ "title": "Roon"
+ },
+ "user": {
+ "data": {
+ "host": "\u0425\u043e\u0441\u0442"
+ },
+ "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441 \u0441\u0435\u0440\u0432\u0435\u0440\u0430 Roon",
+ "title": "\u0421\u0435\u0440\u0432\u0435\u0440 Roon"
+ }
+ }
+ },
+ "title": "Roon"
+}
\ No newline at end of file
diff --git a/homeassistant/components/roon/translations/zh-Hant.json b/homeassistant/components/roon/translations/zh-Hant.json
new file mode 100644
index 00000000000..894da32c39c
--- /dev/null
+++ b/homeassistant/components/roon/translations/zh-Hant.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "duplicate_entry": "\u8a72\u4e3b\u6a5f\u7aef\u5df2\u7d93\u8a2d\u5b9a\u3002",
+ "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "step": {
+ "link": {
+ "description": "\u5fc5\u9808\u65bc Roon \u4e2d\u8a8d\u8b49 Home Assistant\u3002\u9ede\u9078\u50b3\u9001\u5f8c\u3001\u958b\u555f Roon Core \u61c9\u7528\u7a0b\u5f0f\u3001\u6253\u958b\u8a2d\u5b9a\u4e26\u65bc\u64f4\u5145\uff08Extensions\uff09\u4e2d\u555f\u7528 HomeAssistant\u3002",
+ "title": "\u65bc Roon \u4e2d\u8a8d\u8b49 HomeAssistant"
+ },
+ "user": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u7aef"
+ },
+ "description": "\u8acb\u8f38\u5165 Roon \u4f3a\u670d\u5668\u4e3b\u6a5f\u540d\u7a31\u6216 IP\u3002",
+ "title": "\u8a2d\u5b9a Roon \u4f3a\u670d\u5668"
+ }
+ }
+ },
+ "title": "Roon"
+}
\ No newline at end of file
diff --git a/homeassistant/components/route53/__init__.py b/homeassistant/components/route53/__init__.py
index 5355ed15f38..1061b7979ba 100644
--- a/homeassistant/components/route53/__init__.py
+++ b/homeassistant/components/route53/__init__.py
@@ -66,6 +66,12 @@ def setup(hass, config):
return True
+def _get_fqdn(record, domain):
+ if record == ".":
+ return domain
+ return f"{record}.{domain}"
+
+
def _update_route53(
aws_access_key_id: str,
aws_secret_access_key: str,
@@ -98,7 +104,7 @@ def _update_route53(
{
"Action": "UPSERT",
"ResourceRecordSet": {
- "Name": f"{record}.{domain}",
+ "Name": _get_fqdn(record, domain),
"Type": "A",
"TTL": ttl,
"ResourceRecords": [{"Value": ipaddress}],
diff --git a/homeassistant/components/rpi_gpio/__init__.py b/homeassistant/components/rpi_gpio/__init__.py
index b2c3cf6b7bb..b3a83646f0e 100644
--- a/homeassistant/components/rpi_gpio/__init__.py
+++ b/homeassistant/components/rpi_gpio/__init__.py
@@ -8,6 +8,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_S
_LOGGER = logging.getLogger(__name__)
DOMAIN = "rpi_gpio"
+PLATFORMS = ["binary_sensor", "cover", "switch"]
def setup(hass, config):
diff --git a/homeassistant/components/rpi_gpio/binary_sensor.py b/homeassistant/components/rpi_gpio/binary_sensor.py
index a7ecaa3d36c..527da2a7096 100644
--- a/homeassistant/components/rpi_gpio/binary_sensor.py
+++ b/homeassistant/components/rpi_gpio/binary_sensor.py
@@ -7,6 +7,9 @@ from homeassistant.components import rpi_gpio
from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity
from homeassistant.const import DEVICE_DEFAULT_NAME
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.reload import setup_reload_service
+
+from . import DOMAIN, PLATFORMS
_LOGGER = logging.getLogger(__name__)
@@ -33,6 +36,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Raspberry PI GPIO devices."""
+
+ setup_reload_service(hass, DOMAIN, PLATFORMS)
+
pull_mode = config.get(CONF_PULL_MODE)
bouncetime = config.get(CONF_BOUNCETIME)
invert_logic = config.get(CONF_INVERT_LOGIC)
diff --git a/homeassistant/components/rpi_gpio/cover.py b/homeassistant/components/rpi_gpio/cover.py
index 56e76959ecc..fab35da31fb 100644
--- a/homeassistant/components/rpi_gpio/cover.py
+++ b/homeassistant/components/rpi_gpio/cover.py
@@ -8,6 +8,9 @@ from homeassistant.components import rpi_gpio
from homeassistant.components.cover import PLATFORM_SCHEMA, CoverEntity
from homeassistant.const import CONF_NAME
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.reload import setup_reload_service
+
+from . import DOMAIN, PLATFORMS
_LOGGER = logging.getLogger(__name__)
@@ -49,6 +52,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the RPi cover platform."""
+
+ setup_reload_service(hass, DOMAIN, PLATFORMS)
+
relay_time = config.get(CONF_RELAY_TIME)
state_pull_mode = config.get(CONF_STATE_PULL_MODE)
invert_state = config.get(CONF_INVERT_STATE)
diff --git a/homeassistant/components/rpi_gpio/services.yaml b/homeassistant/components/rpi_gpio/services.yaml
new file mode 100644
index 00000000000..d0564941cdb
--- /dev/null
+++ b/homeassistant/components/rpi_gpio/services.yaml
@@ -0,0 +1,2 @@
+reload:
+ description: Reload all rpi_gpio entities.
diff --git a/homeassistant/components/rpi_gpio/switch.py b/homeassistant/components/rpi_gpio/switch.py
index 03cb9f083ce..047bff39c95 100644
--- a/homeassistant/components/rpi_gpio/switch.py
+++ b/homeassistant/components/rpi_gpio/switch.py
@@ -8,6 +8,9 @@ from homeassistant.components.switch import PLATFORM_SCHEMA
from homeassistant.const import DEVICE_DEFAULT_NAME
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import ToggleEntity
+from homeassistant.helpers.reload import setup_reload_service
+
+from . import DOMAIN, PLATFORMS
_LOGGER = logging.getLogger(__name__)
@@ -29,6 +32,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Raspberry PI GPIO devices."""
+
+ setup_reload_service(hass, DOMAIN, PLATFORMS)
+
invert_logic = config.get(CONF_INVERT_LOGIC)
switches = []
diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py
index cd27b33271f..3976d8985cd 100644
--- a/homeassistant/components/rtorrent/sensor.py
+++ b/homeassistant/components/rtorrent/sensor.py
@@ -59,9 +59,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
try:
rtorrent = xmlrpc.client.ServerProxy(url)
- except (xmlrpc.client.ProtocolError, ConnectionRefusedError):
+ except (xmlrpc.client.ProtocolError, ConnectionRefusedError) as ex:
_LOGGER.error("Connection to rtorrent daemon failed")
- raise PlatformNotReady
+ raise PlatformNotReady from ex
dev = []
for variable in config[CONF_MONITORED_VARIABLES]:
dev.append(RTorrentSensor(variable, rtorrent, name))
diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py
index b939479a45a..9c18600670c 100644
--- a/homeassistant/components/samsungtv/config_flow.py
+++ b/homeassistant/components/samsungtv/config_flow.py
@@ -77,7 +77,10 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
}
if self._bridge.token:
data[CONF_TOKEN] = self._bridge.token
- return self.async_create_entry(title=self._title, data=data,)
+ return self.async_create_entry(
+ title=self._title,
+ data=data,
+ )
def _try_connect(self):
"""Try to connect and check auth."""
diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py
index 7eb0f50efc2..6e406f60ec4 100644
--- a/homeassistant/components/samsungtv/media_player.py
+++ b/homeassistant/components/samsungtv/media_player.py
@@ -34,7 +34,14 @@ from homeassistant.helpers.script import Script
from homeassistant.util import dt as dt_util
from .bridge import SamsungTVBridge
-from .const import CONF_MANUFACTURER, CONF_MODEL, CONF_ON_ACTION, DOMAIN, LOGGER
+from .const import (
+ CONF_MANUFACTURER,
+ CONF_MODEL,
+ CONF_ON_ACTION,
+ DEFAULT_NAME,
+ DOMAIN,
+ LOGGER,
+)
KEY_PRESS_TIMEOUT = 1.2
SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"}
@@ -63,19 +70,27 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
and hass.data[DOMAIN][ip_address][CONF_ON_ACTION]
):
turn_on_action = hass.data[DOMAIN][ip_address][CONF_ON_ACTION]
- on_script = Script(hass, turn_on_action)
+ on_script = Script(
+ hass, turn_on_action, config_entry.data.get(CONF_NAME, DEFAULT_NAME), DOMAIN
+ )
# Initialize bridge
data = config_entry.data.copy()
bridge = SamsungTVBridge.get_bridge(
- data[CONF_METHOD], data[CONF_HOST], data[CONF_PORT], data.get(CONF_TOKEN),
+ data[CONF_METHOD],
+ data[CONF_HOST],
+ data[CONF_PORT],
+ data.get(CONF_TOKEN),
)
if bridge.port is None and bridge.default_port is not None:
# For backward compat, set default port for websocket tv
data[CONF_PORT] = bridge.default_port
hass.config_entries.async_update_entry(config_entry, data=data)
bridge = SamsungTVBridge.get_bridge(
- data[CONF_METHOD], data[CONF_HOST], data[CONF_PORT], data.get(CONF_TOKEN),
+ data[CONF_METHOD],
+ data[CONF_HOST],
+ data[CONF_PORT],
+ data.get(CONF_TOKEN),
)
async_add_entities([SamsungTVDevice(bridge, config_entry, on_script)])
@@ -104,11 +119,13 @@ class SamsungTVDevice(MediaPlayerEntity):
self._bridge.register_reauth_callback(self.access_denied)
def access_denied(self):
- """Access denied callbck."""
+ """Access denied callback."""
LOGGER.debug("Access denied in getting remote object")
self.hass.add_job(
self.hass.config_entries.flow.async_init(
- DOMAIN, context={"source": "reauth"}, data=self._config_entry.data,
+ DOMAIN,
+ context={"source": "reauth"},
+ data=self._config_entry.data,
)
)
@@ -245,7 +262,7 @@ class SamsungTVDevice(MediaPlayerEntity):
async def async_turn_on(self):
"""Turn the media player on."""
if self._on_script:
- await self._on_script.async_run()
+ await self._on_script.async_run(context=self._context)
def select_source(self, source):
"""Select input source."""
diff --git a/homeassistant/components/samsungtv/translations/fr.json b/homeassistant/components/samsungtv/translations/fr.json
index 0e9abe80f82..43fa17aa315 100644
--- a/homeassistant/components/samsungtv/translations/fr.json
+++ b/homeassistant/components/samsungtv/translations/fr.json
@@ -3,7 +3,7 @@
"abort": {
"already_configured": "Ce t\u00e9l\u00e9viseur Samsung est d\u00e9j\u00e0 configur\u00e9.",
"already_in_progress": "La configuration du t\u00e9l\u00e9viseur Samsung est d\u00e9j\u00e0 en cours.",
- "auth_missing": "Home Assistant n'est pas authentifi\u00e9 pour se connecter \u00e0 ce t\u00e9l\u00e9viseur Samsung.",
+ "auth_missing": "Home Assistant n'est pas autoris\u00e9 \u00e0 se connecter \u00e0 ce t\u00e9l\u00e9viseur Samsung. Veuillez v\u00e9rifier les param\u00e8tres de votre t\u00e9l\u00e9viseur pour autoriser Home Assistant.",
"not_successful": "Impossible de se connecter \u00e0 cet appareil Samsung TV.",
"not_supported": "Ce t\u00e9l\u00e9viseur Samsung n'est actuellement pas pris en charge."
},
diff --git a/homeassistant/components/samsungtv/translations/no.json b/homeassistant/components/samsungtv/translations/no.json
index e0420ba74af..022dddcd00b 100644
--- a/homeassistant/components/samsungtv/translations/no.json
+++ b/homeassistant/components/samsungtv/translations/no.json
@@ -10,8 +10,7 @@
"flow_title": "Samsung TV: {model}",
"step": {
"confirm": {
- "description": "Vil du sette opp Samsung TV {model} ? Hvis du aldri har koblet til Home Assistant f\u00f8r, vil en popup p\u00e5 TVen be om godkjenning. Manuelle konfigurasjoner for denne TVen vil bli overskrevet.",
- "title": ""
+ "description": "Vil du sette opp Samsung TV {model} ? Hvis du aldri har koblet til Home Assistant f\u00f8r, vil en popup p\u00e5 TVen be om godkjenning. Manuelle konfigurasjoner for denne TVen vil bli overskrevet."
},
"user": {
"data": {
diff --git a/homeassistant/components/scene/translations/no.json b/homeassistant/components/scene/translations/no.json
deleted file mode 100644
index d8a4c453015..00000000000
--- a/homeassistant/components/scene/translations/no.json
+++ /dev/null
@@ -1,3 +0,0 @@
-{
- "title": ""
-}
\ No newline at end of file
diff --git a/homeassistant/components/schluter/climate.py b/homeassistant/components/schluter/climate.py
index 88630cb99db..c7f8dc1d23d 100644
--- a/homeassistant/components/schluter/climate.py
+++ b/homeassistant/components/schluter/climate.py
@@ -17,7 +17,11 @@ from homeassistant.components.climate.const import (
SUPPORT_TARGET_TEMPERATURE,
)
from homeassistant.const import ATTR_TEMPERATURE, CONF_SCAN_INTERVAL
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.helpers.update_coordinator import (
+ CoordinatorEntity,
+ DataUpdateCoordinator,
+ UpdateFailed,
+)
from . import DATA_SCHLUTER_API, DATA_SCHLUTER_SESSION, DOMAIN
@@ -40,7 +44,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
api.get_thermostats, session_id
)
except RequestException as err:
- raise UpdateFailed(f"Error communicating with Schluter API: {err}")
+ raise UpdateFailed(f"Error communicating with Schluter API: {err}") from err
if thermostats is None:
return {}
@@ -63,27 +67,17 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
)
-class SchluterThermostat(ClimateEntity):
+class SchluterThermostat(CoordinatorEntity, ClimateEntity):
"""Representation of a Schluter thermostat."""
def __init__(self, coordinator, serial_number, api, session_id):
"""Initialize the thermostat."""
- self._coordinator = coordinator
+ super().__init__(coordinator)
self._serial_number = serial_number
self._api = api
self._session_id = session_id
self._support_flags = SUPPORT_TARGET_TEMPERATURE
- @property
- def available(self):
- """Return if thermostat is available."""
- return self._coordinator.last_update_success
-
- @property
- def should_poll(self):
- """Return if platform should poll."""
- return False
-
@property
def supported_features(self):
"""Return the list of supported features."""
@@ -97,7 +91,7 @@ class SchluterThermostat(ClimateEntity):
@property
def name(self):
"""Return the name of the thermostat."""
- return self._coordinator.data[self._serial_number].name
+ return self.coordinator.data[self._serial_number].name
@property
def temperature_unit(self):
@@ -107,7 +101,7 @@ class SchluterThermostat(ClimateEntity):
@property
def current_temperature(self):
"""Return the current temperature."""
- return self._coordinator.data[self._serial_number].temperature
+ return self.coordinator.data[self._serial_number].temperature
@property
def hvac_mode(self):
@@ -119,14 +113,14 @@ class SchluterThermostat(ClimateEntity):
"""Return current operation. Can only be heating or idle."""
return (
CURRENT_HVAC_HEAT
- if self._coordinator.data[self._serial_number].is_heating
+ if self.coordinator.data[self._serial_number].is_heating
else CURRENT_HVAC_IDLE
)
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
- return self._coordinator.data[self._serial_number].set_point_temp
+ return self.coordinator.data[self._serial_number].set_point_temp
@property
def hvac_modes(self):
@@ -136,12 +130,12 @@ class SchluterThermostat(ClimateEntity):
@property
def min_temp(self):
"""Identify min_temp in Schluter API."""
- return self._coordinator.data[self._serial_number].min_temp
+ return self.coordinator.data[self._serial_number].min_temp
@property
def max_temp(self):
"""Identify max_temp in Schluter API."""
- return self._coordinator.data[self._serial_number].max_temp
+ return self.coordinator.data[self._serial_number].max_temp
async def async_set_hvac_mode(self, hvac_mode):
"""Mode is always heating, so do nothing."""
@@ -150,7 +144,7 @@ class SchluterThermostat(ClimateEntity):
"""Set new target temperature."""
target_temp = None
target_temp = kwargs.get(ATTR_TEMPERATURE)
- serial_number = self._coordinator.data[self._serial_number].serial_number
+ serial_number = self.coordinator.data[self._serial_number].serial_number
_LOGGER.debug("Setting thermostat temperature: %s", target_temp)
try:
@@ -158,11 +152,3 @@ class SchluterThermostat(ClimateEntity):
self._api.set_temperature(self._session_id, serial_number, target_temp)
except RequestException as ex:
_LOGGER.error("An error occurred while setting temperature: %s", ex)
-
- async def async_added_to_hass(self):
- """When entity is added to hass."""
- self._coordinator.async_add_listener(self.async_write_ha_state)
-
- async def async_will_remove_from_hass(self):
- """When entity will be removed from hass."""
- self._coordinator.async_remove_listener(self.async_write_ha_state)
diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json
index 7a76512f01d..dcb3a8fdff0 100644
--- a/homeassistant/components/scrape/manifest.json
+++ b/homeassistant/components/scrape/manifest.json
@@ -2,7 +2,7 @@
"domain": "scrape",
"name": "Scrape",
"documentation": "https://www.home-assistant.io/integrations/scrape",
- "requirements": ["beautifulsoup4==4.9.0"],
+ "requirements": ["beautifulsoup4==4.9.1"],
"after_dependencies": ["rest"],
"codeowners": ["@fabaff"]
}
diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py
index e12e2abd312..eab30e01ee2 100644
--- a/homeassistant/components/script/__init__.py
+++ b/homeassistant/components/script/__init__.py
@@ -12,6 +12,7 @@ from homeassistant.const import (
CONF_ICON,
CONF_MODE,
CONF_SEQUENCE,
+ CONF_VARIABLES,
SERVICE_RELOAD,
SERVICE_TOGGLE,
SERVICE_TURN_OFF,
@@ -28,6 +29,7 @@ from homeassistant.helpers.script import (
ATTR_MAX,
ATTR_MODE,
CONF_MAX,
+ CONF_MAX_EXCEEDED,
SCRIPT_MODE_SINGLE,
Script,
make_script_schema,
@@ -58,6 +60,7 @@ SCRIPT_ENTRY_SCHEMA = make_script_schema(
vol.Optional(CONF_ICON): cv.icon,
vol.Required(CONF_SEQUENCE): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_DESCRIPTION, default=""): cv.string,
+ vol.Optional(CONF_VARIABLES): cv.SCRIPT_VARIABLES_SCHEMA,
vol.Optional(CONF_FIELDS, default={}): {
cv.string: {
vol.Optional(CONF_DESCRIPTION): cv.string,
@@ -74,7 +77,7 @@ CONFIG_SCHEMA = vol.Schema(
SCRIPT_SERVICE_SCHEMA = vol.Schema(dict)
SCRIPT_TURN_ONOFF_SCHEMA = make_entity_service_schema(
- {vol.Optional(ATTR_VARIABLES): dict}
+ {vol.Optional(ATTR_VARIABLES): {str: cv.match_all}}
)
RELOAD_SERVICE_SCHEMA = vol.Schema({})
@@ -255,10 +258,14 @@ class ScriptEntity(ToggleEntity):
hass,
cfg[CONF_SEQUENCE],
cfg.get(CONF_ALIAS, object_id),
- self.async_change_listener,
- cfg[CONF_MODE],
- cfg[CONF_MAX],
- logging.getLogger(f"{__name__}.{object_id}"),
+ DOMAIN,
+ running_description="script sequence",
+ change_listener=self.async_change_listener,
+ script_mode=cfg[CONF_MODE],
+ max_runs=cfg[CONF_MAX],
+ max_exceeded=cfg[CONF_MAX_EXCEEDED],
+ logger=logging.getLogger(f"{__name__}.{object_id}"),
+ variables=cfg.get(CONF_VARIABLES),
)
self._changed = asyncio.Event()
diff --git a/homeassistant/components/script/config.py b/homeassistant/components/script/config.py
new file mode 100644
index 00000000000..3860a4d0119
--- /dev/null
+++ b/homeassistant/components/script/config.py
@@ -0,0 +1,50 @@
+"""Config validation helper for the script integration."""
+import asyncio
+
+import voluptuous as vol
+
+from homeassistant.config import async_log_exception
+from homeassistant.const import CONF_SEQUENCE
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.script import async_validate_action_config
+
+from . import DOMAIN, SCRIPT_ENTRY_SCHEMA
+
+
+async def async_validate_config_item(hass, config, full_config=None):
+ """Validate config item."""
+ config = SCRIPT_ENTRY_SCHEMA(config)
+ config[CONF_SEQUENCE] = await asyncio.gather(
+ *[
+ async_validate_action_config(hass, action)
+ for action in config[CONF_SEQUENCE]
+ ]
+ )
+
+ return config
+
+
+async def _try_async_validate_config_item(hass, object_id, config, full_config=None):
+ """Validate config item."""
+ try:
+ cv.slug(object_id)
+ config = await async_validate_config_item(hass, config, full_config)
+ except (vol.Invalid, HomeAssistantError) as ex:
+ async_log_exception(ex, DOMAIN, full_config or config, hass)
+ return None
+
+ return config
+
+
+async def async_validate_config(hass, config):
+ """Validate config."""
+ if DOMAIN in config:
+ validated_config = {}
+ for object_id, cfg in config[DOMAIN].items():
+ cfg = await _try_async_validate_config_item(hass, object_id, cfg, config)
+ if cfg is not None:
+ validated_config[object_id] = cfg
+ config[DOMAIN] = validated_config
+
+ return config
diff --git a/homeassistant/components/script/logbook.py b/homeassistant/components/script/logbook.py
index 72ff0d15fc7..f75584540d3 100644
--- a/homeassistant/components/script/logbook.py
+++ b/homeassistant/components/script/logbook.py
@@ -12,10 +12,11 @@ def async_describe_events(hass, async_describe_event):
@callback
def async_describe_logbook_event(event):
"""Describe the logbook event."""
+ data = event.data
return {
- "name": event.data.get(ATTR_NAME),
+ "name": data.get(ATTR_NAME),
"message": "started",
- "entity_id": event.data.get(ATTR_ENTITY_ID),
+ "entity_id": data.get(ATTR_ENTITY_ID),
}
async_describe_event(DOMAIN, EVENT_SCRIPT_STARTED, async_describe_logbook_event)
diff --git a/homeassistant/components/script/translations/no.json b/homeassistant/components/script/translations/no.json
index 28122450085..6cace1e1570 100644
--- a/homeassistant/components/script/translations/no.json
+++ b/homeassistant/components/script/translations/no.json
@@ -4,6 +4,5 @@
"off": "Av",
"on": "P\u00e5"
}
- },
- "title": ""
+ }
}
\ No newline at end of file
diff --git a/homeassistant/components/season/translations/sensor.cs.json b/homeassistant/components/season/translations/sensor.cs.json
index a13e4c1b3c3..31a4b2ef5a9 100644
--- a/homeassistant/components/season/translations/sensor.cs.json
+++ b/homeassistant/components/season/translations/sensor.cs.json
@@ -1,5 +1,11 @@
{
"state": {
+ "season__season": {
+ "autumn": "Podzim",
+ "spring": "Jaro",
+ "summer": "L\u00e9to",
+ "winter": "Zima"
+ },
"season__season__": {
"autumn": "Podzim",
"spring": "Jaro",
diff --git a/homeassistant/components/sendgrid/manifest.json b/homeassistant/components/sendgrid/manifest.json
index 52717c55124..d09ec684e52 100644
--- a/homeassistant/components/sendgrid/manifest.json
+++ b/homeassistant/components/sendgrid/manifest.json
@@ -2,6 +2,6 @@
"domain": "sendgrid",
"name": "SendGrid",
"documentation": "https://www.home-assistant.io/integrations/sendgrid",
- "requirements": ["sendgrid==6.2.1"],
+ "requirements": ["sendgrid==6.4.6"],
"codeowners": []
}
diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py
index f295b0d926c..17d0074c836 100644
--- a/homeassistant/components/sense/__init__.py
+++ b/homeassistant/components/sense/__init__.py
@@ -104,14 +104,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
except SenseAuthenticationException:
_LOGGER.error("Could not authenticate with sense server")
return False
- except SENSE_TIMEOUT_EXCEPTIONS:
- raise ConfigEntryNotReady
+ except SENSE_TIMEOUT_EXCEPTIONS as err:
+ raise ConfigEntryNotReady from err
sense_devices_data = SenseDevicesData()
try:
sense_discovered_devices = await gateway.get_discovered_device_data()
- except SENSE_TIMEOUT_EXCEPTIONS:
- raise ConfigEntryNotReady
+ except SENSE_TIMEOUT_EXCEPTIONS as err:
+ raise ConfigEntryNotReady from err
trends_coordinator = DataUpdateCoordinator(
hass,
diff --git a/homeassistant/components/sense/config_flow.py b/homeassistant/components/sense/config_flow.py
index 7a5b04229a1..f8b8ede6a4c 100644
--- a/homeassistant/components/sense/config_flow.py
+++ b/homeassistant/components/sense/config_flow.py
@@ -8,7 +8,6 @@ from homeassistant import config_entries, core
from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT
from .const import ACTIVE_UPDATE_RATE, DEFAULT_TIMEOUT, SENSE_TIMEOUT_EXCEPTIONS
-
from .const import DOMAIN # pylint:disable=unused-import; pylint:disable=unused-import
_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py
index 07dacc288b5..783fcb5508a 100644
--- a/homeassistant/components/sense/const.py
+++ b/homeassistant/components/sense/const.py
@@ -55,7 +55,7 @@ MDI_ICONS = {
"papershredder": "shredder",
"printer": "printer",
"pump": "water-pump",
- "settings": "settings",
+ "settings": "cog",
"skillet": "pot",
"smartcamera": "webcam",
"socket": "power-plug",
diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json
index deadf759f06..c0c568a8e3d 100644
--- a/homeassistant/components/sense/manifest.json
+++ b/homeassistant/components/sense/manifest.json
@@ -2,7 +2,7 @@
"domain": "sense",
"name": "Sense",
"documentation": "https://www.home-assistant.io/integrations/sense",
- "requirements": ["sense_energy==0.7.2"],
+ "requirements": ["sense_energy==0.8.0"],
"codeowners": ["@kbickar"],
"config_flow": true
}
diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py
index 8ccb2ad12ff..538267aa355 100644
--- a/homeassistant/components/sense/sensor.py
+++ b/homeassistant/components/sense/sensor.py
@@ -208,7 +208,13 @@ class SenseTrendsSensor(Entity):
"""Implementation of a Sense energy sensor."""
def __init__(
- self, data, name, sensor_type, is_production, trends_coordinator, unique_id,
+ self,
+ data,
+ name,
+ sensor_type,
+ is_production,
+ trends_coordinator,
+ unique_id,
):
"""Initialize the Sense sensor."""
name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME
diff --git a/homeassistant/components/sense/translations/pt.json b/homeassistant/components/sense/translations/pt.json
index 2933743c867..196be985b6a 100644
--- a/homeassistant/components/sense/translations/pt.json
+++ b/homeassistant/components/sense/translations/pt.json
@@ -1,5 +1,8 @@
{
"config": {
+ "error": {
+ "unknown": "Erro inesperado"
+ },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/sensehat/sensor.py b/homeassistant/components/sensehat/sensor.py
index 29aa4af967e..3966e52f1a8 100644
--- a/homeassistant/components/sensehat/sensor.py
+++ b/homeassistant/components/sensehat/sensor.py
@@ -10,8 +10,8 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import (
CONF_DISPLAY_OPTIONS,
CONF_NAME,
+ PERCENTAGE,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -26,7 +26,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
SENSOR_TYPES = {
"temperature": ["temperature", TEMP_CELSIUS],
- "humidity": ["humidity", UNIT_PERCENTAGE],
+ "humidity": ["humidity", PERCENTAGE],
"pressure": ["pressure", "mb"],
}
diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py
index 29aa0c5380e..26276650752 100644
--- a/homeassistant/components/sensibo/climate.py
+++ b/homeassistant/components/sensibo/climate.py
@@ -100,9 +100,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
aiohttp.client_exceptions.ClientConnectorError,
asyncio.TimeoutError,
pysensibo.SensiboError,
- ):
+ ) as err:
_LOGGER.exception("Failed to connect to Sensibo servers")
- raise PlatformNotReady
+ raise PlatformNotReady from err
if not devices:
return
diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py
index 83711a07759..46462019749 100644
--- a/homeassistant/components/sensor/__init__.py
+++ b/homeassistant/components/sensor/__init__.py
@@ -7,13 +7,17 @@ import voluptuous as vol
from homeassistant.const import (
DEVICE_CLASS_BATTERY,
+ DEVICE_CLASS_CURRENT,
+ DEVICE_CLASS_ENERGY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_POWER,
+ DEVICE_CLASS_POWER_FACTOR,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_SIGNAL_STRENGTH,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TIMESTAMP,
+ DEVICE_CLASS_VOLTAGE,
)
from homeassistant.helpers.config_validation import ( # noqa: F401
PLATFORM_SCHEMA,
@@ -32,6 +36,8 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}"
SCAN_INTERVAL = timedelta(seconds=30)
DEVICE_CLASSES = [
DEVICE_CLASS_BATTERY, # % of battery that is left
+ DEVICE_CLASS_CURRENT, # current (A)
+ DEVICE_CLASS_ENERGY, # energy (kWh, Wh)
DEVICE_CLASS_HUMIDITY, # % of humidity in the air
DEVICE_CLASS_ILLUMINANCE, # current light level (lx/lm)
DEVICE_CLASS_SIGNAL_STRENGTH, # signal strength (dB/dBm)
@@ -39,6 +45,8 @@ DEVICE_CLASSES = [
DEVICE_CLASS_TIMESTAMP, # timestamp (ISO8601)
DEVICE_CLASS_PRESSURE, # pressure (hPa/mbar)
DEVICE_CLASS_POWER, # power (W/kW)
+ DEVICE_CLASS_POWER_FACTOR, # power factor (%)
+ DEVICE_CLASS_VOLTAGE, # voltage (V)
]
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES))
diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py
index 3c4337c1dba..d58381a2f40 100644
--- a/homeassistant/components/sensor/device_condition.py
+++ b/homeassistant/components/sensor/device_condition.py
@@ -14,13 +14,17 @@ from homeassistant.const import (
CONF_ENTITY_ID,
CONF_TYPE,
DEVICE_CLASS_BATTERY,
+ DEVICE_CLASS_CURRENT,
+ DEVICE_CLASS_ENERGY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_POWER,
+ DEVICE_CLASS_POWER_FACTOR,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_SIGNAL_STRENGTH,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TIMESTAMP,
+ DEVICE_CLASS_VOLTAGE,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import condition, config_validation as cv
@@ -37,24 +41,32 @@ from . import DOMAIN
DEVICE_CLASS_NONE = "none"
CONF_IS_BATTERY_LEVEL = "is_battery_level"
+CONF_IS_CURRENT = "is_current"
+CONF_IS_ENERGY = "is_energy"
CONF_IS_HUMIDITY = "is_humidity"
CONF_IS_ILLUMINANCE = "is_illuminance"
CONF_IS_POWER = "is_power"
+CONF_IS_POWER_FACTOR = "is_power_factor"
CONF_IS_PRESSURE = "is_pressure"
CONF_IS_SIGNAL_STRENGTH = "is_signal_strength"
CONF_IS_TEMPERATURE = "is_temperature"
CONF_IS_TIMESTAMP = "is_timestamp"
+CONF_IS_VOLTAGE = "is_voltage"
CONF_IS_VALUE = "is_value"
ENTITY_CONDITIONS = {
DEVICE_CLASS_BATTERY: [{CONF_TYPE: CONF_IS_BATTERY_LEVEL}],
+ DEVICE_CLASS_CURRENT: [{CONF_TYPE: CONF_IS_CURRENT}],
+ DEVICE_CLASS_ENERGY: [{CONF_TYPE: CONF_IS_ENERGY}],
DEVICE_CLASS_HUMIDITY: [{CONF_TYPE: CONF_IS_HUMIDITY}],
DEVICE_CLASS_ILLUMINANCE: [{CONF_TYPE: CONF_IS_ILLUMINANCE}],
DEVICE_CLASS_POWER: [{CONF_TYPE: CONF_IS_POWER}],
+ DEVICE_CLASS_POWER_FACTOR: [{CONF_TYPE: CONF_IS_POWER_FACTOR}],
DEVICE_CLASS_PRESSURE: [{CONF_TYPE: CONF_IS_PRESSURE}],
DEVICE_CLASS_SIGNAL_STRENGTH: [{CONF_TYPE: CONF_IS_SIGNAL_STRENGTH}],
DEVICE_CLASS_TEMPERATURE: [{CONF_TYPE: CONF_IS_TEMPERATURE}],
DEVICE_CLASS_TIMESTAMP: [{CONF_TYPE: CONF_IS_TIMESTAMP}],
+ DEVICE_CLASS_VOLTAGE: [{CONF_TYPE: CONF_IS_VOLTAGE}],
DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_IS_VALUE}],
}
@@ -65,13 +77,17 @@ CONDITION_SCHEMA = vol.All(
vol.Required(CONF_TYPE): vol.In(
[
CONF_IS_BATTERY_LEVEL,
+ CONF_IS_CURRENT,
+ CONF_IS_ENERGY,
CONF_IS_HUMIDITY,
CONF_IS_ILLUMINANCE,
CONF_IS_POWER,
+ CONF_IS_POWER_FACTOR,
CONF_IS_PRESSURE,
CONF_IS_SIGNAL_STRENGTH,
CONF_IS_TEMPERATURE,
CONF_IS_TIMESTAMP,
+ CONF_IS_VOLTAGE,
CONF_IS_VALUE,
]
),
diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py
index 57c32da97e9..77f6afd9acf 100644
--- a/homeassistant/components/sensor/device_trigger.py
+++ b/homeassistant/components/sensor/device_trigger.py
@@ -1,11 +1,13 @@
"""Provides device triggers for sensors."""
import voluptuous as vol
-import homeassistant.components.automation.numeric_state as numeric_state_automation
from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA
from homeassistant.components.device_automation.exceptions import (
InvalidDeviceAutomationConfig,
)
+from homeassistant.components.homeassistant.triggers import (
+ numeric_state as numeric_state_trigger,
+)
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT,
@@ -15,13 +17,17 @@ from homeassistant.const import (
CONF_FOR,
CONF_TYPE,
DEVICE_CLASS_BATTERY,
+ DEVICE_CLASS_CURRENT,
+ DEVICE_CLASS_ENERGY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_POWER,
+ DEVICE_CLASS_POWER_FACTOR,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_SIGNAL_STRENGTH,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TIMESTAMP,
+ DEVICE_CLASS_VOLTAGE,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_registry import async_entries_for_device
@@ -33,24 +39,32 @@ from . import DOMAIN
DEVICE_CLASS_NONE = "none"
CONF_BATTERY_LEVEL = "battery_level"
+CONF_CURRENT = "current"
+CONF_ENERGY = "energy"
CONF_HUMIDITY = "humidity"
CONF_ILLUMINANCE = "illuminance"
CONF_POWER = "power"
+CONF_POWER_FACTOR = "power_factor"
CONF_PRESSURE = "pressure"
CONF_SIGNAL_STRENGTH = "signal_strength"
CONF_TEMPERATURE = "temperature"
CONF_TIMESTAMP = "timestamp"
+CONF_VOLTAGE = "voltage"
CONF_VALUE = "value"
ENTITY_TRIGGERS = {
DEVICE_CLASS_BATTERY: [{CONF_TYPE: CONF_BATTERY_LEVEL}],
+ DEVICE_CLASS_CURRENT: [{CONF_TYPE: CONF_CURRENT}],
+ DEVICE_CLASS_ENERGY: [{CONF_TYPE: CONF_ENERGY}],
DEVICE_CLASS_HUMIDITY: [{CONF_TYPE: CONF_HUMIDITY}],
DEVICE_CLASS_ILLUMINANCE: [{CONF_TYPE: CONF_ILLUMINANCE}],
DEVICE_CLASS_POWER: [{CONF_TYPE: CONF_POWER}],
+ DEVICE_CLASS_POWER_FACTOR: [{CONF_TYPE: CONF_POWER_FACTOR}],
DEVICE_CLASS_PRESSURE: [{CONF_TYPE: CONF_PRESSURE}],
DEVICE_CLASS_SIGNAL_STRENGTH: [{CONF_TYPE: CONF_SIGNAL_STRENGTH}],
DEVICE_CLASS_TEMPERATURE: [{CONF_TYPE: CONF_TEMPERATURE}],
DEVICE_CLASS_TIMESTAMP: [{CONF_TYPE: CONF_TIMESTAMP}],
+ DEVICE_CLASS_VOLTAGE: [{CONF_TYPE: CONF_VOLTAGE}],
DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_VALUE}],
}
@@ -62,13 +76,17 @@ TRIGGER_SCHEMA = vol.All(
vol.Required(CONF_TYPE): vol.In(
[
CONF_BATTERY_LEVEL,
+ CONF_CURRENT,
+ CONF_ENERGY,
CONF_HUMIDITY,
CONF_ILLUMINANCE,
CONF_POWER,
+ CONF_POWER_FACTOR,
CONF_PRESSURE,
CONF_SIGNAL_STRENGTH,
CONF_TEMPERATURE,
CONF_TIMESTAMP,
+ CONF_VOLTAGE,
CONF_VALUE,
]
),
@@ -84,18 +102,18 @@ TRIGGER_SCHEMA = vol.All(
async def async_attach_trigger(hass, config, action, automation_info):
"""Listen for state changes based on configuration."""
numeric_state_config = {
- numeric_state_automation.CONF_PLATFORM: "numeric_state",
- numeric_state_automation.CONF_ENTITY_ID: config[CONF_ENTITY_ID],
+ numeric_state_trigger.CONF_PLATFORM: "numeric_state",
+ numeric_state_trigger.CONF_ENTITY_ID: config[CONF_ENTITY_ID],
}
if CONF_ABOVE in config:
- numeric_state_config[numeric_state_automation.CONF_ABOVE] = config[CONF_ABOVE]
+ numeric_state_config[numeric_state_trigger.CONF_ABOVE] = config[CONF_ABOVE]
if CONF_BELOW in config:
- numeric_state_config[numeric_state_automation.CONF_BELOW] = config[CONF_BELOW]
+ numeric_state_config[numeric_state_trigger.CONF_BELOW] = config[CONF_BELOW]
if CONF_FOR in config:
numeric_state_config[CONF_FOR] = config[CONF_FOR]
- numeric_state_config = numeric_state_automation.TRIGGER_SCHEMA(numeric_state_config)
- return await numeric_state_automation.async_attach_trigger(
+ numeric_state_config = numeric_state_trigger.TRIGGER_SCHEMA(numeric_state_config)
+ return await numeric_state_trigger.async_attach_trigger(
hass, numeric_state_config, action, automation_info, platform_type="device"
)
diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json
index 024f942280c..76ea9efabc3 100644
--- a/homeassistant/components/sensor/strings.json
+++ b/homeassistant/components/sensor/strings.json
@@ -10,6 +10,10 @@
"is_signal_strength": "Current {entity_name} signal strength",
"is_temperature": "Current {entity_name} temperature",
"is_timestamp": "Current {entity_name} timestamp",
+ "is_current": "Current {entity_name} current",
+ "is_energy": "Current {entity_name} energy",
+ "is_power_factor": "Current {entity_name} power factor",
+ "is_voltage": "Current {entity_name} voltage",
"is_value": "Current {entity_name} value"
},
"trigger_type": {
@@ -21,6 +25,10 @@
"signal_strength": "{entity_name} signal strength changes",
"temperature": "{entity_name} temperature changes",
"timestamp": "{entity_name} timestamp changes",
+ "current": "{entity_name} current changes",
+ "energy": "{entity_name} energy changes",
+ "power_factor": "{entity_name} power factor changes",
+ "voltage": "{entity_name} voltage changes",
"value": "{entity_name} value changes"
}
},
diff --git a/homeassistant/components/sensor/translations/ca.json b/homeassistant/components/sensor/translations/ca.json
index d80fd795698..b351aed3049 100644
--- a/homeassistant/components/sensor/translations/ca.json
+++ b/homeassistant/components/sensor/translations/ca.json
@@ -2,25 +2,33 @@
"device_automation": {
"condition_type": {
"is_battery_level": "Nivell de bateria actual de {entity_name}",
+ "is_current": "Intensitat actual de {entity_name}",
+ "is_energy": "Energia actual de {entity_name}",
"is_humidity": "Humitat actual de {entity_name}",
"is_illuminance": "Il\u00b7luminaci\u00f3 actual de {entity_name}",
"is_power": "Pot\u00e8ncia actual de {entity_name}",
+ "is_power_factor": "Factor de pot\u00e8ncia actual de {entity_name}",
"is_pressure": "Pressi\u00f3 actual de {entity_name}",
"is_signal_strength": "Pot\u00e8ncia de senyal actual de {entity_name}",
"is_temperature": "Temperatura actual de {entity_name}",
"is_timestamp": "Marca temporal actual de {entity_name}",
- "is_value": "Valor actual de {entity_name}"
+ "is_value": "Valor actual de {entity_name}",
+ "is_voltage": "Voltatge actual de {entity_name}"
},
"trigger_type": {
"battery_level": "Canvia el nivell de bateria de {entity_name}",
+ "current": "Canvia la intensitat de {entity_name}",
+ "energy": "Canvia l'energia de {entity_name}",
"humidity": "Canvia la humitat de {entity_name}",
"illuminance": "Canvia la il\u00b7luminaci\u00f3 de {entity_name}",
"power": "Canvia la pot\u00e8ncia de {entity_name}",
+ "power_factor": "Canvia el factor de pot\u00e8ncia de {entity_name}",
"pressure": "Canvia la pressi\u00f3 de {entity_name}",
"signal_strength": "Canvia la pot\u00e8ncia de senyal de {entity_name}",
"temperature": "Canvia la temperatura de {entity_name}",
"timestamp": "Canvia la marca temporal de {entity_name}",
- "value": "Canvia el valor de {entity_name}"
+ "value": "Canvia el valor de {entity_name}",
+ "voltage": "Canvia el voltatge de {entity_name}"
}
},
"state": {
diff --git a/homeassistant/components/sensor/translations/cs.json b/homeassistant/components/sensor/translations/cs.json
index 53a2da0872b..add81cbe07e 100644
--- a/homeassistant/components/sensor/translations/cs.json
+++ b/homeassistant/components/sensor/translations/cs.json
@@ -9,7 +9,8 @@
"is_signal_strength": "Aktu\u00e1ln\u00ed s\u00edla sign\u00e1lu {entity_name}",
"is_temperature": "Aktu\u00e1ln\u00ed teplota {entity_name}",
"is_timestamp": "Aktu\u00e1ln\u00ed \u010dasov\u00e9 raz\u00edtko {entity_name}",
- "is_value": "Aktu\u00e1ln\u00ed hodnota {entity_name}"
+ "is_value": "Aktu\u00e1ln\u00ed hodnota {entity_name}",
+ "is_voltage": "Aktu\u00e1ln\u00ed nap\u011bt\u00ed {entity_name}"
},
"trigger_type": {
"battery_level": "\u00farove\u0148 baterie {entity_name} se zm\u011bn\u00ed",
@@ -20,7 +21,8 @@
"signal_strength": "s\u00edla sign\u00e1lu {entity_name} se zm\u011bn\u00ed",
"temperature": "teplota {entity_name} se zm\u011bn\u00ed",
"timestamp": "\u010dasov\u00e9 raz\u00edtko {entity_name} se zm\u011bn\u00ed",
- "value": "hodnota {entity_name} se zm\u011bn\u00ed"
+ "value": "hodnota {entity_name} se zm\u011bn\u00ed",
+ "voltage": "P\u0159i zm\u011bn\u011b nap\u011bt\u00ed {entity_name}"
}
},
"state": {
diff --git a/homeassistant/components/sensor/translations/en.json b/homeassistant/components/sensor/translations/en.json
index 69138fec001..ae0c32df574 100644
--- a/homeassistant/components/sensor/translations/en.json
+++ b/homeassistant/components/sensor/translations/en.json
@@ -2,25 +2,33 @@
"device_automation": {
"condition_type": {
"is_battery_level": "Current {entity_name} battery level",
+ "is_current": "Current {entity_name} current",
+ "is_energy": "Current {entity_name} energy",
"is_humidity": "Current {entity_name} humidity",
"is_illuminance": "Current {entity_name} illuminance",
"is_power": "Current {entity_name} power",
+ "is_power_factor": "Current {entity_name} power factor",
"is_pressure": "Current {entity_name} pressure",
"is_signal_strength": "Current {entity_name} signal strength",
"is_temperature": "Current {entity_name} temperature",
"is_timestamp": "Current {entity_name} timestamp",
- "is_value": "Current {entity_name} value"
+ "is_value": "Current {entity_name} value",
+ "is_voltage": "Current {entity_name} voltage"
},
"trigger_type": {
"battery_level": "{entity_name} battery level changes",
+ "current": "{entity_name} current changes",
+ "energy": "{entity_name} energy changes",
"humidity": "{entity_name} humidity changes",
"illuminance": "{entity_name} illuminance changes",
"power": "{entity_name} power changes",
+ "power_factor": "{entity_name} power factor changes",
"pressure": "{entity_name} pressure changes",
"signal_strength": "{entity_name} signal strength changes",
"temperature": "{entity_name} temperature changes",
"timestamp": "{entity_name} timestamp changes",
- "value": "{entity_name} value changes"
+ "value": "{entity_name} value changes",
+ "voltage": "{entity_name} voltage changes"
}
},
"state": {
diff --git a/homeassistant/components/sensor/translations/es-419.json b/homeassistant/components/sensor/translations/es-419.json
index e724fe3a106..acf91a79104 100644
--- a/homeassistant/components/sensor/translations/es-419.json
+++ b/homeassistant/components/sensor/translations/es-419.json
@@ -10,11 +10,5 @@
"value": "{entity_name} cambios de valor"
}
},
- "state": {
- "_": {
- "off": "",
- "on": ""
- }
- },
"title": "Sensor"
}
\ No newline at end of file
diff --git a/homeassistant/components/sensor/translations/es.json b/homeassistant/components/sensor/translations/es.json
index 0a6335f97a0..b2db3151abf 100644
--- a/homeassistant/components/sensor/translations/es.json
+++ b/homeassistant/components/sensor/translations/es.json
@@ -2,25 +2,33 @@
"device_automation": {
"condition_type": {
"is_battery_level": "Nivel de bater\u00eda actual de {entity_name}",
+ "is_current": "Corriente actual de {entity_name}",
+ "is_energy": "Energ\u00eda actual de {entity_name}",
"is_humidity": "Humedad actual de {entity_name}",
"is_illuminance": "Luminosidad actual de {entity_name}",
"is_power": "Potencia actual de {entity_name}",
+ "is_power_factor": "Factor de potencia actual de {entity_name}",
"is_pressure": "Presi\u00f3n actual de {entity_name}",
"is_signal_strength": "Intensidad de la se\u00f1al actual de {entity_name}",
"is_temperature": "Temperatura actual de {entity_name}",
"is_timestamp": "Marca de tiempo actual de {entity_name}",
- "is_value": "Valor actual de {entity_name}"
+ "is_value": "Valor actual de {entity_name}",
+ "is_voltage": "Voltaje actual de {entity_name}"
},
"trigger_type": {
"battery_level": "Cambios de nivel de bater\u00eda de {entity_name}",
+ "current": "Cambio de corriente en {entity_name}",
+ "energy": "Cambio de energ\u00eda en {entity_name}",
"humidity": "Cambios de humedad de {entity_name}",
"illuminance": "Cambios de luminosidad de {entity_name}",
"power": "Cambios de potencia de {entity_name}",
+ "power_factor": "Cambio de factor de potencia en {entity_name}",
"pressure": "Cambios de presi\u00f3n de {entity_name}",
"signal_strength": "cambios de la intensidad de se\u00f1al de {entity_name}",
"temperature": "{entity_name} cambios de temperatura",
"timestamp": "{entity_name} cambios de fecha y hora",
- "value": "Cambios de valor de la {entity_name}"
+ "value": "Cambios de valor de la {entity_name}",
+ "voltage": "Cambio de voltaje en {entity_name}"
}
},
"state": {
diff --git a/homeassistant/components/sensor/translations/fr.json b/homeassistant/components/sensor/translations/fr.json
index 8f7cbf0f8aa..4705d28b5c3 100644
--- a/homeassistant/components/sensor/translations/fr.json
+++ b/homeassistant/components/sensor/translations/fr.json
@@ -2,25 +2,33 @@
"device_automation": {
"condition_type": {
"is_battery_level": "Niveau de la batterie de {entity_name}",
+ "is_current": "Courant actuel pour {entity_name}",
+ "is_energy": "\u00c9nergie actuelle pour {entity_name}",
"is_humidity": "Humidit\u00e9 de {entity_name}",
"is_illuminance": "\u00c9clairement de {entity_name}",
"is_power": "Puissance de {entity_name}",
+ "is_power_factor": "Facteur de puissance actuel pour {entity_name}",
"is_pressure": "Pression de {entity_name}",
"is_signal_strength": "Force du signal de {entity_name}",
"is_temperature": "Temp\u00e9rature de {entity_name}",
"is_timestamp": "Horodatage de {entity_name}",
- "is_value": "La valeur actuelle de {entity_name}"
+ "is_value": "La valeur actuelle de {entity_name}",
+ "is_voltage": "Tension actuelle pour {entity_name}"
},
"trigger_type": {
"battery_level": "{entity_name} modification du niveau de batterie",
+ "current": "{entity_name} changement de courant",
+ "energy": "{entity_name} changement d'\u00e9nergie",
"humidity": "{entity_name} modification de l'humidit\u00e9",
"illuminance": "{entity_name} modification de l'\u00e9clairement",
"power": "{entity_name} modification de la puissance",
+ "power_factor": "{entity_name} changement de facteur de puissance",
"pressure": "{entity_name} modification de la pression",
"signal_strength": "{entity_name} modification de la force du signal",
"temperature": "{entity_name} modification de temp\u00e9rature",
"timestamp": "{entity_name} modification d'horodatage",
- "value": "Changements de valeur de {entity_name}"
+ "value": "Changements de valeur de {entity_name}",
+ "voltage": "{entity_name} changement de tension"
}
},
"state": {
diff --git a/homeassistant/components/sensor/translations/it.json b/homeassistant/components/sensor/translations/it.json
index e8cd7046231..84a8b2773a5 100644
--- a/homeassistant/components/sensor/translations/it.json
+++ b/homeassistant/components/sensor/translations/it.json
@@ -2,25 +2,33 @@
"device_automation": {
"condition_type": {
"is_battery_level": "Livello della batteria attuale di {entity_name}",
+ "is_current": "Corrente attuale di {entity_name}",
+ "is_energy": "Energia attuale di {entity_name}",
"is_humidity": "Umidit\u00e0 attuale di {entity_name}",
"is_illuminance": "Illuminazione attuale di {entity_name}",
"is_power": "Alimentazione attuale di {entity_name}",
+ "is_power_factor": "Fattore di potenza attuale di {entity_name}",
"is_pressure": "Pressione attuale di {entity_name}",
"is_signal_strength": "Potenza del segnale attuale di {entity_name}",
"is_temperature": "Temperatura attuale di {entity_name}",
"is_timestamp": "Data e ora attuali di {entity_name}",
- "is_value": "Valore attuale di {entity_name}"
+ "is_value": "Valore attuale di {entity_name}",
+ "is_voltage": "Tensione attuale di {entity_name}"
},
"trigger_type": {
"battery_level": "variazioni del livello di batteria di {entity_name} ",
+ "current": "variazioni di corrente di {entity_name}",
+ "energy": "variazioni di energia di {entity_name}",
"humidity": "variazioni di umidit\u00e0 di {entity_name} ",
"illuminance": "variazioni dell'illuminazione di {entity_name}",
"power": "variazioni di alimentazione di {entity_name}",
+ "power_factor": "variazioni del fattore di potenza di {entity_name}",
"pressure": "variazioni della pressione di {entity_name}",
"signal_strength": "variazioni della potenza del segnale di {entity_name}",
"temperature": "variazioni di temperatura di {entity_name}",
"timestamp": "variazioni di data e ora di {entity_name}",
- "value": "{entity_name} valori cambiati"
+ "value": "{entity_name} valori cambiati",
+ "voltage": "variazioni di tensione di {entity_name}"
}
},
"state": {
diff --git a/homeassistant/components/sensor/translations/lb.json b/homeassistant/components/sensor/translations/lb.json
index e57edbb656b..97c0e2f0a6b 100644
--- a/homeassistant/components/sensor/translations/lb.json
+++ b/homeassistant/components/sensor/translations/lb.json
@@ -2,25 +2,33 @@
"device_automation": {
"condition_type": {
"is_battery_level": "Aktuell {entity_name} Batterie niveau",
+ "is_current": "Aktuell {entity_name} Stroum",
+ "is_energy": "Aktuell {entity_name} Energie",
"is_humidity": "Aktuell {entity_name} Fiichtegkeet",
"is_illuminance": "Aktuell {entity_name} Beliichtung",
"is_power": "Aktuell {entity_name} Leeschtung",
+ "is_power_factor": "Aktuell {entity_name} Leeschtung Faktor",
"is_pressure": "Aktuell {entity_name} Drock",
"is_signal_strength": "Aktuell {entity_name} Signal St\u00e4erkt",
"is_temperature": "Aktuell {entity_name} Temperatur",
"is_timestamp": "Aktuelle {entity_name} Z\u00e4itstempel",
- "is_value": "Aktuelle {entity_name} W\u00e4ert"
+ "is_value": "Aktuelle {entity_name} W\u00e4ert",
+ "is_voltage": "Aktuell {entity_name} Spannung"
},
"trigger_type": {
"battery_level": "{entity_name} Batterie niveau \u00e4nnert",
+ "current": "{entity_name} Stroum \u00e4nnert",
+ "energy": "{entity_name} Energie \u00e4nnert",
"humidity": "{entity_name} Fiichtegkeet \u00e4nnert",
"illuminance": "{entity_name} Beliichtung \u00e4nnert",
"power": "{entity_name} Leeschtung \u00e4nnert",
+ "power_factor": "{entity_name} Leeschtung Faktor \u00e4nnert",
"pressure": "{entity_name} Drock \u00e4nnert",
"signal_strength": "{entity_name} Signal St\u00e4erkt \u00e4nnert",
"temperature": "{entity_name} Temperatur \u00e4nnert",
"timestamp": "{entity_name} Z\u00e4itstempel \u00e4nnert",
- "value": "{entity_name} W\u00e4ert \u00e4nnert"
+ "value": "{entity_name} W\u00e4ert \u00e4nnert",
+ "voltage": "{entity_name} Spannung \u00e4nnert"
}
},
"state": {
diff --git a/homeassistant/components/sensor/translations/nb.json b/homeassistant/components/sensor/translations/nb.json
index 28122450085..6cace1e1570 100644
--- a/homeassistant/components/sensor/translations/nb.json
+++ b/homeassistant/components/sensor/translations/nb.json
@@ -4,6 +4,5 @@
"off": "Av",
"on": "P\u00e5"
}
- },
- "title": ""
+ }
}
\ No newline at end of file
diff --git a/homeassistant/components/sensor/translations/no.json b/homeassistant/components/sensor/translations/no.json
index 80b6822607a..d8d05b81042 100644
--- a/homeassistant/components/sensor/translations/no.json
+++ b/homeassistant/components/sensor/translations/no.json
@@ -2,25 +2,33 @@
"device_automation": {
"condition_type": {
"is_battery_level": "Gjeldende {entity_name} batteriniv\u00e5",
+ "is_current": "Gjeldende {entity_name} gjeldende",
+ "is_energy": "Gjeldende {entity_name} energi",
"is_humidity": "Gjeldende {entity_name} fuktighet",
"is_illuminance": "Gjeldende {entity_name} belysningsstyrke",
"is_power": "Gjeldende {entity_name}-effekt",
+ "is_power_factor": "Gjeldende {entity_name} effektfaktor",
"is_pressure": "Gjeldende {entity_name} trykk",
"is_signal_strength": "Gjeldende {entity_name} signalstyrke",
"is_temperature": "Gjeldende {entity_name} temperatur",
"is_timestamp": "Gjeldende {entity_name} tidsstempel",
- "is_value": "Gjeldende {entity_name} verdi"
+ "is_value": "Gjeldende {entity_name} verdi",
+ "is_voltage": "Gjeldende spenning p\u00e5 {entity_name}"
},
"trigger_type": {
"battery_level": "{entity_name} batteriniv\u00e5 endres",
+ "current": "{entity_name} gjeldende endringer",
+ "energy": "{entity_name} energiendringer",
"humidity": "{entity_name} fuktighets endringer",
"illuminance": "{entity_name} belysningsstyrke endringer",
"power": "{entity_name} effektendringer",
+ "power_factor": "{entity_name} effektfaktorendringer",
"pressure": "{entity_name} trykk endringer",
"signal_strength": "{entity_name} signalstyrkeendringer",
"temperature": "{entity_name} temperaturendringer",
"timestamp": "{entity_name} tidsstempel endringer",
- "value": "{entity_name} verdi endringer"
+ "value": "{entity_name} verdi endringer",
+ "voltage": "{entity_name} spenningsendringer"
}
},
"state": {
@@ -28,6 +36,5 @@
"off": "Av",
"on": "P\u00e5"
}
- },
- "title": ""
+ }
}
\ No newline at end of file
diff --git a/homeassistant/components/sensor/translations/pt-BR.json b/homeassistant/components/sensor/translations/pt-BR.json
index 5da527abbab..337a4d9f318 100644
--- a/homeassistant/components/sensor/translations/pt-BR.json
+++ b/homeassistant/components/sensor/translations/pt-BR.json
@@ -1,4 +1,12 @@
{
+ "device_automation": {
+ "condition_type": {
+ "is_humidity": "Humidade atual do(a) {entity_name}",
+ "is_pressure": "Press\u00e3o atual do(a) {entity_name}",
+ "is_signal_strength": "For\u00e7a do sinal atual do(a) {entity_name}",
+ "is_temperature": "Temperatura atual do(a) {entity_name}"
+ }
+ },
"state": {
"_": {
"off": "Desligado",
diff --git a/homeassistant/components/sensor/translations/ru.json b/homeassistant/components/sensor/translations/ru.json
index 576affdf40d..ae84c843bc3 100644
--- a/homeassistant/components/sensor/translations/ru.json
+++ b/homeassistant/components/sensor/translations/ru.json
@@ -2,25 +2,33 @@
"device_automation": {
"condition_type": {
"is_battery_level": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
+ "is_current": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0441\u0438\u043b\u044b \u0442\u043e\u043a\u0430",
+ "is_energy": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438",
"is_humidity": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
"is_illuminance": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
"is_power": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
+ "is_power_factor": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043a\u043e\u044d\u0444\u0444\u0438\u0446\u0438\u0435\u043d\u0442\u0430 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438",
"is_pressure": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
"is_signal_strength": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
"is_temperature": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
"is_timestamp": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
- "is_value": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435"
+ "is_value": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
+ "is_voltage": "{entity_name} \u0438\u043c\u0435\u0435\u0442 \u0442\u0435\u043a\u0443\u0449\u0435\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f"
},
"trigger_type": {
"battery_level": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
+ "current": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u0441\u0438\u043b\u044b \u0442\u043e\u043a\u0430",
+ "energy": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438",
"humidity": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
"illuminance": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
"power": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
+ "power_factor": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u043a\u043e\u044d\u0444\u0444\u0438\u0446\u0438\u0435\u043d\u0442 \u043c\u043e\u0449\u043d\u043e\u0441\u0442\u0438",
"pressure": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
"signal_strength": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
"temperature": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
"timestamp": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
- "value": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435"
+ "value": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435",
+ "voltage": "{entity_name} \u0438\u0437\u043c\u0435\u043d\u044f\u0435\u0442 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435 \u043d\u0430\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f"
}
},
"state": {
diff --git a/homeassistant/components/sensor/translations/zh-Hant.json b/homeassistant/components/sensor/translations/zh-Hant.json
index 20221a91d60..56350e501ef 100644
--- a/homeassistant/components/sensor/translations/zh-Hant.json
+++ b/homeassistant/components/sensor/translations/zh-Hant.json
@@ -2,25 +2,33 @@
"device_automation": {
"condition_type": {
"is_battery_level": "\u76ee\u524d{entity_name}\u96fb\u91cf",
+ "is_current": "\u76ee\u524d{entity_name}\u96fb\u6d41",
+ "is_energy": "\u76ee\u524d{entity_name}\u96fb\u529b",
"is_humidity": "\u76ee\u524d{entity_name}\u6fd5\u5ea6",
"is_illuminance": "\u76ee\u524d{entity_name}\u7167\u5ea6",
"is_power": "\u76ee\u524d{entity_name}\u96fb\u529b",
+ "is_power_factor": "\u76ee\u524d{entity_name}\u529f\u7387\u56e0\u6578",
"is_pressure": "\u76ee\u524d{entity_name}\u58d3\u529b",
"is_signal_strength": "\u76ee\u524d{entity_name}\u8a0a\u865f\u5f37\u5ea6",
"is_temperature": "\u76ee\u524d{entity_name}\u6eab\u5ea6",
"is_timestamp": "\u76ee\u524d{entity_name}\u6642\u9593\u6a19\u8a18",
- "is_value": "\u76ee\u524d{entity_name}\u503c"
+ "is_value": "\u76ee\u524d{entity_name}\u503c",
+ "is_voltage": "\u76ee\u524d{entity_name}\u96fb\u58d3"
},
"trigger_type": {
"battery_level": "{entity_name}\u96fb\u91cf\u8b8a\u66f4",
+ "current": "\u76ee\u524d{entity_name}\u96fb\u6d41\u8b8a\u66f4",
+ "energy": "\u76ee\u524d{entity_name}\u96fb\u529b\u8b8a\u66f4",
"humidity": "{entity_name}\u6fd5\u5ea6\u8b8a\u66f4",
"illuminance": "{entity_name}\u7167\u5ea6\u8b8a\u66f4",
"power": "{entity_name}\u96fb\u529b\u8b8a\u66f4",
+ "power_factor": "\u76ee\u524d{entity_name}\u529f\u7387\u56e0\u6578\u8b8a\u66f4",
"pressure": "{entity_name}\u58d3\u529b\u8b8a\u66f4",
"signal_strength": "{entity_name}\u8a0a\u865f\u5f37\u5ea6\u8b8a\u66f4",
"temperature": "{entity_name}\u6eab\u5ea6\u8b8a\u66f4",
"timestamp": "{entity_name}\u6642\u9593\u6a19\u8a18\u8b8a\u66f4",
- "value": "{entity_name}\u503c\u8b8a\u66f4"
+ "value": "{entity_name}\u503c\u8b8a\u66f4",
+ "voltage": "\u76ee\u524d{entity_name}\u96fb\u58d3\u8b8a\u66f4"
}
},
"state": {
diff --git a/homeassistant/components/sentry/__init__.py b/homeassistant/components/sentry/__init__.py
index 8ce23248832..eecac0281e6 100644
--- a/homeassistant/components/sentry/__init__.py
+++ b/homeassistant/components/sentry/__init__.py
@@ -1,58 +1,214 @@
"""The sentry integration."""
-import logging
+import re
+from typing import Dict, Union
import sentry_sdk
+from sentry_sdk.integrations.aiohttp import AioHttpIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
-import voluptuous as vol
+from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
-from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import __version__
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_STARTED,
+ __version__ as current_version,
+)
from homeassistant.core import HomeAssistant
-from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import config_validation as cv, entity_platform
+from homeassistant.loader import Integration, async_get_custom_components
-from .const import CONF_DSN, CONF_ENVIRONMENT, DOMAIN
-
-CONFIG_SCHEMA = vol.Schema(
- {
- DOMAIN: vol.Schema(
- {vol.Required(CONF_DSN): cv.string, CONF_ENVIRONMENT: cv.string}
- )
- },
- extra=vol.ALLOW_EXTRA,
+from .const import (
+ CONF_DSN,
+ CONF_ENVIRONMENT,
+ CONF_EVENT_CUSTOM_COMPONENTS,
+ CONF_EVENT_HANDLED,
+ CONF_EVENT_THIRD_PARTY_PACKAGES,
+ CONF_LOGGING_EVENT_LEVEL,
+ CONF_LOGGING_LEVEL,
+ CONF_TRACING,
+ CONF_TRACING_SAMPLE_RATE,
+ DEFAULT_LOGGING_EVENT_LEVEL,
+ DEFAULT_LOGGING_LEVEL,
+ DEFAULT_TRACING_SAMPLE_RATE,
+ DOMAIN,
+ ENTITY_COMPONENTS,
)
+CONFIG_SCHEMA = cv.deprecated(DOMAIN, invalidation_version="0.117")
-async def async_setup(hass: HomeAssistant, config: dict):
+
+LOGGER_INFO_REGEX = re.compile(r"^(\w+)\.?(\w+)?\.?(\w+)?\.?(\w+)?(?:\..*)?$")
+
+
+async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the Sentry component."""
- conf = config.get(DOMAIN)
- if conf is not None:
- hass.async_create_task(
- hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_IMPORT}
- )
- )
-
return True
-async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Sentry from a config entry."""
- conf = entry.data
- hass.data[DOMAIN] = conf
+ # Migrate environment from config entry data to config entry options
+ if (
+ CONF_ENVIRONMENT not in entry.options
+ and CONF_ENVIRONMENT in entry.data
+ and entry.data[CONF_ENVIRONMENT]
+ ):
+ options = {**entry.options, CONF_ENVIRONMENT: entry.data[CONF_ENVIRONMENT]}
+ data = entry.data.copy()
+ data.pop(CONF_ENVIRONMENT)
+ hass.config_entries.async_update_entry(entry, data=data, options=options)
# https://docs.sentry.io/platforms/python/logging/
sentry_logging = LoggingIntegration(
- level=logging.INFO, # Capture info and above as breadcrumbs
- event_level=logging.ERROR, # Send errors as events
+ level=entry.options.get(CONF_LOGGING_LEVEL, DEFAULT_LOGGING_LEVEL),
+ event_level=entry.options.get(
+ CONF_LOGGING_EVENT_LEVEL, DEFAULT_LOGGING_EVENT_LEVEL
+ ),
)
+ # Additional/extra data collection
+ channel = get_channel(current_version)
+ huuid = await hass.helpers.instance_id.async_get()
+ system_info = await hass.helpers.system_info.async_get_system_info()
+ custom_components = await async_get_custom_components(hass)
+
+ tracing = {}
+ if entry.options.get(CONF_TRACING):
+ tracing = {
+ "traces_sample_rate": entry.options.get(
+ CONF_TRACING_SAMPLE_RATE, DEFAULT_TRACING_SAMPLE_RATE
+ ),
+ }
+
sentry_sdk.init(
- dsn=conf.get(CONF_DSN),
- environment=conf.get(CONF_ENVIRONMENT),
- integrations=[sentry_logging],
- release=f"homeassistant-{__version__}",
+ dsn=entry.data[CONF_DSN],
+ environment=entry.options.get(CONF_ENVIRONMENT),
+ integrations=[sentry_logging, AioHttpIntegration(), SqlalchemyIntegration()],
+ release=current_version,
+ before_send=lambda event, hint: process_before_send(
+ hass,
+ entry.options,
+ channel,
+ huuid,
+ system_info,
+ custom_components,
+ event,
+ hint,
+ ),
+ **tracing,
)
+ async def update_system_info(now):
+ nonlocal system_info
+ system_info = await hass.helpers.system_info.async_get_system_info()
+
+ # Update system info every hour
+ hass.helpers.event.async_call_later(3600, update_system_info)
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STARTED, update_system_info)
+
return True
+
+
+def get_channel(version: str) -> str:
+ """Find channel based on version number."""
+ if "dev0" in version:
+ return "dev"
+ if "dev" in version:
+ return "nightly"
+ if "b" in version:
+ return "beta"
+ return "stable"
+
+
+def process_before_send(
+ hass: HomeAssistant,
+ options,
+ channel: str,
+ huuid: str,
+ system_info: Dict[str, Union[bool, str]],
+ custom_components: Dict[str, Integration],
+ event,
+ hint,
+):
+ """Process a Sentry event before sending it to Sentry."""
+ # Filter out handled events by default
+ if (
+ "tags" in event
+ and event.tags.get("handled", "no") == "yes"
+ and not options.get(CONF_EVENT_HANDLED)
+ ):
+ return None
+
+ # Additional tags to add to the event
+ additional_tags = {
+ "channel": channel,
+ "installation_type": system_info["installation_type"],
+ "uuid": huuid,
+ }
+
+ # Find out all integrations in use, filter "auth", because it
+ # triggers security rules, hiding all data.
+ integrations = [
+ integration
+ for integration in hass.config.components
+ if integration != "auth" and "." not in integration
+ ]
+
+ # Add additional tags based on what caused the event.
+ platform = entity_platform.current_platform.get()
+ if platform is not None:
+ # This event happened in a platform
+ additional_tags["custom_component"] = "no"
+ additional_tags["integration"] = platform.platform_name
+ additional_tags["platform"] = platform.domain
+ elif "logger" in event:
+ # Logger event, try to get integration information from the logger name.
+ matches = LOGGER_INFO_REGEX.findall(event["logger"])
+ if matches:
+ group1, group2, group3, group4 = matches[0]
+ # Handle the "homeassistant." package differently
+ if group1 == "homeassistant" and group2 and group3:
+ if group2 == "components":
+ # This logger is from a component
+ additional_tags["custom_component"] = "no"
+ additional_tags["integration"] = group3
+ if group4 and group4 in ENTITY_COMPONENTS:
+ additional_tags["platform"] = group4
+ else:
+ # Not a component, could be helper, or something else.
+ additional_tags[group2] = group3
+ else:
+ # Not the "homeassistant" package, this third-party
+ if not options.get(CONF_EVENT_THIRD_PARTY_PACKAGES):
+ return None
+ additional_tags["package"] = group1
+
+ # If this event is caused by an integration, add a tag if this
+ # integration is custom or not.
+ if (
+ "integration" in additional_tags
+ and additional_tags["integration"] in custom_components
+ ):
+ if not options.get(CONF_EVENT_CUSTOM_COMPONENTS):
+ return None
+ additional_tags["custom_component"] = "yes"
+
+ # Update event with the additional tags
+ event.setdefault("tags", {}).update(additional_tags)
+
+ # Set user context to the installation UUID
+ event.setdefault("user", {}).update({"id": huuid})
+
+ # Update event data with Home Assistant Context
+ event.setdefault("contexts", {}).update(
+ {
+ "Home Assistant": {
+ "channel": channel,
+ "custom_components": "\n".join(sorted(custom_components)),
+ "integrations": "\n".join(sorted(integrations)),
+ **system_info,
+ },
+ }
+ )
+ return event
diff --git a/homeassistant/components/sentry/config_flow.py b/homeassistant/components/sentry/config_flow.py
index 194aa527d63..a308423f40b 100644
--- a/homeassistant/components/sentry/config_flow.py
+++ b/homeassistant/components/sentry/config_flow.py
@@ -1,56 +1,137 @@
"""Config flow for sentry integration."""
+from __future__ import annotations
+
import logging
+from typing import Any, Dict, Optional
from sentry_sdk.utils import BadDsn, Dsn
import voluptuous as vol
-from homeassistant import config_entries, core
+from homeassistant import config_entries
+from homeassistant.core import callback
-from .const import CONF_DSN, DOMAIN # pylint: disable=unused-import
+from .const import ( # pylint: disable=unused-import
+ CONF_DSN,
+ CONF_ENVIRONMENT,
+ CONF_EVENT_CUSTOM_COMPONENTS,
+ CONF_EVENT_HANDLED,
+ CONF_EVENT_THIRD_PARTY_PACKAGES,
+ CONF_LOGGING_EVENT_LEVEL,
+ CONF_LOGGING_LEVEL,
+ CONF_TRACING,
+ CONF_TRACING_SAMPLE_RATE,
+ DEFAULT_LOGGING_EVENT_LEVEL,
+ DEFAULT_LOGGING_LEVEL,
+ DEFAULT_TRACING_SAMPLE_RATE,
+ DOMAIN,
+ LOGGING_LEVELS,
+)
_LOGGER = logging.getLogger(__name__)
DATA_SCHEMA = vol.Schema({vol.Required(CONF_DSN): str})
-async def validate_input(hass: core.HomeAssistant, data):
- """Validate the DSN input allows us to connect.
-
- Data has the keys from DATA_SCHEMA with values provided by the user.
- """
- # validate the dsn
- Dsn(data["dsn"])
-
- return {"title": "Sentry"}
-
-
-class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+class SentryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a Sentry config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
- async def async_step_user(self, user_input=None):
+ @staticmethod
+ @callback
+ def async_get_options_flow(
+ config_entry: config_entries.ConfigEntry,
+ ) -> SentryOptionsFlow:
+ """Get the options flow for this handler."""
+ return SentryOptionsFlow(config_entry)
+
+ async def async_step_user(
+ self, user_input: Optional[Dict[str, Any]] = None
+ ) -> Dict[str, Any]:
"""Handle a user config flow."""
if self._async_current_entries():
- return self.async_abort(reason="already_configured")
+ return self.async_abort(reason="single_instance_allowed")
errors = {}
if user_input is not None:
try:
- info = await validate_input(self.hass, user_input)
-
- return self.async_create_entry(title=info["title"], data=user_input)
+ Dsn(user_input["dsn"])
except BadDsn:
errors["base"] = "bad_dsn"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
+ else:
+ return self.async_create_entry(title="Sentry", data=user_input)
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
- async def async_step_import(self, import_config):
- """Import a config entry from configuration.yaml."""
- return await self.async_step_user(import_config)
+
+class SentryOptionsFlow(config_entries.OptionsFlow):
+ """Handle Sentry options."""
+
+ def __init__(self, config_entry: config_entries.ConfigEntry):
+ """Initialize Sentry options flow."""
+ self.config_entry = config_entry
+
+ async def async_step_init(
+ self, user_input: Optional[Dict[str, Any]] = None
+ ) -> Dict[str, Any]:
+ """Manage Sentry options."""
+ if user_input is not None:
+ return self.async_create_entry(title="", data=user_input)
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=vol.Schema(
+ {
+ vol.Optional(
+ CONF_LOGGING_EVENT_LEVEL,
+ default=self.config_entry.options.get(
+ CONF_LOGGING_EVENT_LEVEL, DEFAULT_LOGGING_EVENT_LEVEL
+ ),
+ ): vol.In(LOGGING_LEVELS),
+ vol.Optional(
+ CONF_LOGGING_LEVEL,
+ default=self.config_entry.options.get(
+ CONF_LOGGING_LEVEL, DEFAULT_LOGGING_LEVEL
+ ),
+ ): vol.In(LOGGING_LEVELS),
+ vol.Optional(
+ CONF_ENVIRONMENT,
+ default=self.config_entry.options.get(CONF_ENVIRONMENT),
+ ): str,
+ vol.Optional(
+ CONF_EVENT_HANDLED,
+ default=self.config_entry.options.get(
+ CONF_EVENT_HANDLED, False
+ ),
+ ): bool,
+ vol.Optional(
+ CONF_EVENT_CUSTOM_COMPONENTS,
+ default=self.config_entry.options.get(
+ CONF_EVENT_CUSTOM_COMPONENTS, False
+ ),
+ ): bool,
+ vol.Optional(
+ CONF_EVENT_THIRD_PARTY_PACKAGES,
+ default=self.config_entry.options.get(
+ CONF_EVENT_THIRD_PARTY_PACKAGES, False
+ ),
+ ): bool,
+ vol.Optional(
+ CONF_TRACING,
+ default=self.config_entry.options.get(CONF_TRACING, False),
+ ): bool,
+ vol.Optional(
+ CONF_TRACING_SAMPLE_RATE,
+ default=self.config_entry.options.get(
+ CONF_TRACING_SAMPLE_RATE, DEFAULT_TRACING_SAMPLE_RATE
+ ),
+ ): vol.All(vol.Coerce(float), vol.Range(min=0.0, max=1.0)),
+ }
+ ),
+ )
diff --git a/homeassistant/components/sentry/const.py b/homeassistant/components/sentry/const.py
index 1f799eb7fb9..419bbc4e097 100644
--- a/homeassistant/components/sentry/const.py
+++ b/homeassistant/components/sentry/const.py
@@ -1,6 +1,52 @@
"""Constants for the sentry integration."""
+import logging
+
DOMAIN = "sentry"
CONF_DSN = "dsn"
CONF_ENVIRONMENT = "environment"
+CONF_EVENT_CUSTOM_COMPONENTS = "event_custom_components"
+CONF_EVENT_HANDLED = "event_handled"
+CONF_EVENT_THIRD_PARTY_PACKAGES = "event_third_party_packages"
+CONF_LOGGING_EVENT_LEVEL = "logging_event_level"
+CONF_LOGGING_LEVEL = "logging_level"
+CONF_TRACING = "tracing"
+CONF_TRACING_SAMPLE_RATE = "tracing_sample_rate"
+
+DEFAULT_LOGGING_EVENT_LEVEL = logging.ERROR
+DEFAULT_LOGGING_LEVEL = logging.WARNING
+DEFAULT_TRACING_SAMPLE_RATE = 1.0
+
+LOGGING_LEVELS = {
+ logging.DEBUG: "debug",
+ logging.INFO: "info",
+ logging.WARNING: "warning",
+ logging.ERROR: "error",
+ logging.CRITICAL: "critical",
+}
+
+ENTITY_COMPONENTS = [
+ "air_quality",
+ "alarm_control_panel",
+ "binary_sensor",
+ "calendar",
+ "camera",
+ "climate",
+ "cover",
+ "device_tracker",
+ "fan",
+ "geo_location",
+ "group",
+ "humidifier",
+ "light",
+ "lock",
+ "media_player",
+ "remote",
+ "scene",
+ "sensor",
+ "switch",
+ "vacuum",
+ "water_heater",
+ "weather",
+]
diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json
index 12c808b6d7b..471a349a4df 100644
--- a/homeassistant/components/sentry/manifest.json
+++ b/homeassistant/components/sentry/manifest.json
@@ -3,6 +3,6 @@
"name": "Sentry",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sentry",
- "requirements": ["sentry-sdk==0.13.5"],
- "codeowners": ["@dcramer"]
+ "requirements": ["sentry-sdk==0.17.3"],
+ "codeowners": ["@dcramer", "@frenck"]
}
diff --git a/homeassistant/components/sentry/strings.json b/homeassistant/components/sentry/strings.json
index 97a945a5a9d..593a8c5c8d0 100644
--- a/homeassistant/components/sentry/strings.json
+++ b/homeassistant/components/sentry/strings.json
@@ -1,9 +1,31 @@
{
"config": {
"step": {
- "user": { "title": "Sentry", "description": "Enter your Sentry DSN" }
+ "user": {
+ "title": "Sentry",
+ "description": "Enter your Sentry DSN",
+ "data": { "dsn": "DSN" }
+ }
},
"error": { "unknown": "Unexpected error", "bad_dsn": "Invalid DSN" },
- "abort": { "already_configured": "Sentry is already configured" }
+ "abort": {
+ "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "environment": "Optional name of the environment.",
+ "event_custom_components": "Send events from custom components",
+ "event_handled": "Send handled events",
+ "event_third_party_packages": "Send events from third-party packages",
+ "logging_event_level": "The log level Sentry will register an event for",
+ "logging_level": "The log level Sentry will record logs as breadcrums for",
+ "tracing": "Enable performance tracing",
+ "tracing_sample_rate": "Tracing sample rate; between 0.0 and 1.0 (1.0 = 100%)"
+ }
+ }
+ }
}
}
diff --git a/homeassistant/components/sentry/translations/ca.json b/homeassistant/components/sentry/translations/ca.json
index 43302714b97..7b24dba7c77 100644
--- a/homeassistant/components/sentry/translations/ca.json
+++ b/homeassistant/components/sentry/translations/ca.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Sentry ja est\u00e0 configurat"
+ "already_configured": "Sentry ja est\u00e0 configurat",
+ "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3."
},
"error": {
"bad_dsn": "DSN inv\u00e0lid",
@@ -9,9 +10,28 @@
},
"step": {
"user": {
+ "data": {
+ "dsn": "DSN"
+ },
"description": "Introdueix el DSN de Sentry",
"title": "Sentry"
}
}
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "environment": "Nom opcional de l'entorn.",
+ "event_custom_components": "Envia esdeveniments de components personalitzats",
+ "event_handled": "Envia esdeveniments gestionats",
+ "event_third_party_packages": "Envia esdeveniments de tercers",
+ "logging_event_level": "El nivell (log level) en que Sentry registrar\u00e0 un esdeveniment",
+ "logging_level": "El nivell (log level) en que Sentry registrar\u00e0 en miques",
+ "tracing": "Activa el seguiment de rendiment",
+ "tracing_sample_rate": "Freq\u00fc\u00e8ncia de mostreig del rastreig; entre 0.0 i 1.0 (1.0 = 100%)"
+ }
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/translations/et.json b/homeassistant/components/sentry/translations/cs.json
similarity index 66%
rename from homeassistant/components/tradfri/translations/et.json
rename to homeassistant/components/sentry/translations/cs.json
index 5d0a728407a..81105c211fd 100644
--- a/homeassistant/components/tradfri/translations/et.json
+++ b/homeassistant/components/sentry/translations/cs.json
@@ -1,9 +1,9 @@
{
"config": {
"step": {
- "auth": {
+ "user": {
"data": {
- "host": ""
+ "dsn": "DSN"
}
}
}
diff --git a/homeassistant/components/sentry/translations/en.json b/homeassistant/components/sentry/translations/en.json
index 13387488ea6..9ffeb30000b 100644
--- a/homeassistant/components/sentry/translations/en.json
+++ b/homeassistant/components/sentry/translations/en.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Sentry is already configured"
+ "already_configured": "Sentry is already configured",
+ "single_instance_allowed": "Already configured. Only a single configuration possible."
},
"error": {
"bad_dsn": "Invalid DSN",
@@ -9,9 +10,28 @@
},
"step": {
"user": {
+ "data": {
+ "dsn": "DSN"
+ },
"description": "Enter your Sentry DSN",
"title": "Sentry"
}
}
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "environment": "Optional name of the environment.",
+ "event_custom_components": "Send events from custom components",
+ "event_handled": "Send handled events",
+ "event_third_party_packages": "Send events from third-party packages",
+ "logging_event_level": "The log level Sentry will register an event for",
+ "logging_level": "The log level Sentry will record logs as breadcrums for",
+ "tracing": "Enable performance tracing",
+ "tracing_sample_rate": "Tracing sample rate; between 0.0 and 1.0 (1.0 = 100%)"
+ }
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/sentry/translations/es.json b/homeassistant/components/sentry/translations/es.json
index 445d7566240..47caae6c541 100644
--- a/homeassistant/components/sentry/translations/es.json
+++ b/homeassistant/components/sentry/translations/es.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Sentry ya est\u00e1 configurado"
+ "already_configured": "Sentry ya est\u00e1 configurado",
+ "single_instance_allowed": "Ya configurado. Solo es posible una \u00fanica configuraci\u00f3n."
},
"error": {
"bad_dsn": "DSN no v\u00e1lido",
@@ -9,9 +10,28 @@
},
"step": {
"user": {
+ "data": {
+ "dsn": "DSN"
+ },
"description": "Introduzca su DSN Sentry",
"title": "Sentry"
}
}
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "environment": "Nombre opcional del entorno.",
+ "event_custom_components": "Env\u00eda eventos desde componentes personalizados",
+ "event_handled": "Enviar eventos controlados",
+ "event_third_party_packages": "Env\u00eda eventos desde paquetes de terceros",
+ "logging_event_level": "El nivel de registro Sentry registrar\u00e1 un evento para",
+ "logging_level": "El nivel de registro Sentry registrar\u00e1 registros como migas de pan para",
+ "tracing": "Habilitar el seguimiento del rendimiento",
+ "tracing_sample_rate": "Seguimiento de la frecuencia de muestreo; entre 0.0 y 1.0 (1.0 = 100%)"
+ }
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/sentry/translations/fr.json b/homeassistant/components/sentry/translations/fr.json
index fce57ec4b9c..073a3cd0963 100644
--- a/homeassistant/components/sentry/translations/fr.json
+++ b/homeassistant/components/sentry/translations/fr.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Sentry est d\u00e9j\u00e0 configur\u00e9"
+ "already_configured": "Sentry est d\u00e9j\u00e0 configur\u00e9",
+ "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible."
},
"error": {
"bad_dsn": "DSN invalide",
@@ -9,9 +10,28 @@
},
"step": {
"user": {
+ "data": {
+ "dsn": "DSN"
+ },
"description": "Entrez votre DSN Sentry",
"title": "Sentry"
}
}
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "environment": "Nom facultatif de l'environnement.",
+ "event_custom_components": "Envoyer des \u00e9v\u00e9nements \u00e0 partir de composants personnalis\u00e9s",
+ "event_handled": "Envoyer des \u00e9v\u00e9nements g\u00e9r\u00e9s",
+ "event_third_party_packages": "Envoyer des \u00e9v\u00e9nements \u00e0 partir de paquets de tiers",
+ "logging_event_level": "Le niveau de journal de Sentry enregistrera un \u00e9v\u00e9nement pour",
+ "logging_level": "Le niveau de journal de Sentry enregistrera un \u00e9v\u00e9nement pour",
+ "tracing": "Activer le suivi des performances",
+ "tracing_sample_rate": "Taux d'\u00e9chantillonnage de suivi ; entre 0,0 et 1,0 (1,0 = 100%)"
+ }
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/sentry/translations/it.json b/homeassistant/components/sentry/translations/it.json
index 44090de0b62..2f022129022 100644
--- a/homeassistant/components/sentry/translations/it.json
+++ b/homeassistant/components/sentry/translations/it.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Sentry \u00e8 gi\u00e0 configurato"
+ "already_configured": "Sentry \u00e8 gi\u00e0 configurato",
+ "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione."
},
"error": {
"bad_dsn": "DSN non valido",
@@ -9,9 +10,28 @@
},
"step": {
"user": {
+ "data": {
+ "dsn": "DSN"
+ },
"description": "Inserisci il tuo DSN Sentry",
"title": "Sentry"
}
}
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "environment": "Nome opzionale dell'ambiente.",
+ "event_custom_components": "Inviare eventi da componenti personalizzati",
+ "event_handled": "Inviare eventi gestiti",
+ "event_third_party_packages": "Inviare eventi da pacchetti di terze parti",
+ "logging_event_level": "Il livello di registro Sentry registrer\u00e0 un evento per",
+ "logging_level": "Il livello di registro Sentry registrer\u00e0 i registri cos\u00ec granulari per",
+ "tracing": "Attivare il tracciamento delle prestazioni",
+ "tracing_sample_rate": "Frequenza di campionamento del tracciamento; tra 0,0 e 1,0 (1,0 = 100%)"
+ }
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/sentry/translations/lb.json b/homeassistant/components/sentry/translations/lb.json
index 92baa5502d7..169461e52f7 100644
--- a/homeassistant/components/sentry/translations/lb.json
+++ b/homeassistant/components/sentry/translations/lb.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Sentry ass scho konfigur\u00e9iert"
+ "already_configured": "Sentry ass scho konfigur\u00e9iert",
+ "single_instance_allowed": "Scho konfigur\u00e9iert. N\u00ebmmen eng eenzeg Konfiguratioun m\u00e9iglech."
},
"error": {
"bad_dsn": "Ong\u00eblteg DSN",
@@ -9,9 +10,26 @@
},
"step": {
"user": {
+ "data": {
+ "dsn": "DSN"
+ },
"description": "Gitt \u00e4r Sentry DSN un",
"title": "Sentry"
}
}
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "environment": "Optionelle Numm vum Environnement",
+ "event_custom_components": "Sch\u00e9ck Evenementer aus personalis\u00e9ierten Komponenten",
+ "event_handled": "Sch\u00e9ck ger\u00e9iert Evenementer",
+ "event_third_party_packages": "Sch\u00e9ck Evenementer vun Dr\u00ebtt Partei Packagen",
+ "tracing": "Aktiv\u00e9ier Leeschtung Tracing",
+ "tracing_sample_rate": "Echantillon erm\u00ebttelen; t\u00ebschent 0,0 an 1.0"
+ }
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/sentry/translations/no.json b/homeassistant/components/sentry/translations/no.json
index 26a469ce341..48504931104 100644
--- a/homeassistant/components/sentry/translations/no.json
+++ b/homeassistant/components/sentry/translations/no.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Sentry er allerede konfigurert"
+ "already_configured": "Sentry er allerede konfigurert",
+ "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig."
},
"error": {
"bad_dsn": "Ugyldig datakildenavn (DSN)",
@@ -9,8 +10,26 @@
},
"step": {
"user": {
- "description": "Fyll inn din Sentry DNS",
- "title": ""
+ "data": {
+ "dsn": "DSN"
+ },
+ "description": "Fyll inn din Sentry DNS"
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "environment": "Valgfritt navn p\u00e5 milj\u00f8et.",
+ "event_custom_components": "Send hendelser fra egendefinerte komponenter",
+ "event_handled": "Send h\u00e5ndterte hendelser",
+ "event_third_party_packages": "Send hendelser fra tredjepartspakker",
+ "logging_event_level": "Loggniv\u00e5et Sentry vil registrere en hendelse for",
+ "logging_level": "Loggniv\u00e5et Sentry vil registrere logger som br\u00f8dsmuler for",
+ "tracing": "Aktivere ytelsessporing",
+ "tracing_sample_rate": "Sporing av samplingsfrekvens; mellom 0,0 og 1,0 (1,0 = 100 %)"
+ }
}
}
}
diff --git a/homeassistant/components/sentry/translations/pl.json b/homeassistant/components/sentry/translations/pl.json
index a66e217b850..fa87b8510c7 100644
--- a/homeassistant/components/sentry/translations/pl.json
+++ b/homeassistant/components/sentry/translations/pl.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Sentry jest ju\u017c skonfigurowane."
+ "already_configured": "Sentry jest ju\u017c skonfigurowane.",
+ "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja."
},
"error": {
"bad_dsn": "Nieprawid\u0142owy DSN",
diff --git a/homeassistant/components/sentry/translations/pt.json b/homeassistant/components/sentry/translations/pt.json
new file mode 100644
index 00000000000..65db7f4018f
--- /dev/null
+++ b/homeassistant/components/sentry/translations/pt.json
@@ -0,0 +1,10 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "J\u00e1 configurado. Apenas uma \u00fanica configura\u00e7\u00e3o \u00e9 poss\u00edvel."
+ },
+ "error": {
+ "unknown": "Erro inesperado"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sentry/translations/ru.json b/homeassistant/components/sentry/translations/ru.json
index 29ae44ca9eb..ab8023e505c 100644
--- a/homeassistant/components/sentry/translations/ru.json
+++ b/homeassistant/components/sentry/translations/ru.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
+ "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e."
},
"error": {
"bad_dsn": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 DSN.",
@@ -9,9 +10,28 @@
},
"step": {
"user": {
+ "data": {
+ "dsn": "DSN"
+ },
"description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0412\u0430\u0448 DSN Sentry",
"title": "Sentry"
}
}
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "environment": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435",
+ "event_custom_components": "\u041e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0442\u044c \u0441\u043e\u0431\u044b\u0442\u0438\u044f \u0438\u0437 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c\u0441\u043a\u0438\u0445 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u043e\u0432",
+ "event_handled": "\u041e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0442\u044c \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u0430\u043d\u043d\u044b\u0435 \u0441\u043e\u0431\u044b\u0442\u0438\u044f",
+ "event_third_party_packages": "\u041e\u0442\u043f\u0440\u0430\u0432\u043b\u044f\u0442\u044c \u0441\u043e\u0431\u044b\u0442\u0438\u044f \u0438\u0437 \u0441\u0442\u043e\u0440\u043e\u043d\u043d\u0438\u0445 \u043f\u0430\u043a\u0435\u0442\u043e\u0432",
+ "logging_event_level": "\u0417\u0430\u043f\u0438\u0441\u044b\u0432\u0430\u0442\u044c \u0436\u0443\u0440\u043d\u0430\u043b\u044b \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0445 \u0441\u043e\u0431\u044b\u0442\u0438\u0439",
+ "logging_level": "\u0417\u0430\u043f\u0438\u0441\u044b\u0432\u0430\u0442\u044c \u0436\u0443\u0440\u043d\u0430\u043b\u044b \u0432 \u0432\u0438\u0434\u0435 \u043d\u0430\u0432\u0438\u0433\u0430\u0446\u0438\u043e\u043d\u043d\u044b\u0445 \u0446\u0435\u043f\u043e\u0447\u0435\u043a",
+ "tracing": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u043d\u0438\u0435 \u043f\u0440\u043e\u0438\u0437\u0432\u043e\u0434\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0441\u0442\u0438",
+ "tracing_sample_rate": "\u0427\u0430\u0441\u0442\u043e\u0442\u0430 \u0434\u0438\u0441\u043a\u0440\u0435\u0442\u0438\u0437\u0430\u0446\u0438\u0438 \u0442\u0440\u0430\u0441\u0441\u0438\u0440\u043e\u0432\u043a\u0438; \u043e\u0442 0,0 \u0434\u043e 1,0 (1,0 = 100%)"
+ }
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/sentry/translations/zh-Hant.json b/homeassistant/components/sentry/translations/zh-Hant.json
index 277bff66dcb..5c77442a03e 100644
--- a/homeassistant/components/sentry/translations/zh-Hant.json
+++ b/homeassistant/components/sentry/translations/zh-Hant.json
@@ -1,7 +1,8 @@
{
"config": {
"abort": {
- "already_configured": "Sentry \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ "already_configured": "Sentry \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u8a2d\u5099\u3002"
},
"error": {
"bad_dsn": "DSN \u7121\u6548",
@@ -9,9 +10,28 @@
},
"step": {
"user": {
+ "data": {
+ "dsn": "DSN"
+ },
"description": "\u8f38\u5165 Sentry DSN",
"title": "Sentry"
}
}
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "environment": "\u74b0\u5883\u9078\u9805\u540d\u7a31",
+ "event_custom_components": "\u50b3\u9001\u81ea\u8a02\u5143\u4ef6\u4e8b\u4ef6",
+ "event_handled": "\u50b3\u9001\u5df2\u8655\u7406\u4e8b\u4ef6",
+ "event_third_party_packages": "\u50b3\u9001\u7b2c\u4e09\u65b9\u5c01\u5305\u4e8b\u4ef6",
+ "logging_event_level": "\u65e5\u8a8c\u7b49\u7d1a\u76e3\u63a7\u5c07\u6703\u8a3b\u518a\u4e8b\u4ef6\u70ba",
+ "logging_level": "\u65e5\u8a8c\u7b49\u7d1a\u76e3\u63a7\u5c07\u6703\u7d00\u9304\u4e8b\u4ef6\u70ba\u6a94\u6848\u5c0e\u822a\u70ba",
+ "tracing": "\u958b\u555f\u6548\u80fd\u8ffd\u8e64",
+ "tracing_sample_rate": "\u8ffd\u8e64\u63a1\u6a23\u7bc4\u570d\uff0c\u4ecb\u65bc 0.0 \u53ca 1.0 \u4e4b\u9593\uff081.0 = 100%\uff09"
+ }
+ }
+ }
}
}
\ No newline at end of file
diff --git a/homeassistant/components/seven_segments/manifest.json b/homeassistant/components/seven_segments/manifest.json
index 352b96b5f22..4996ba29f83 100644
--- a/homeassistant/components/seven_segments/manifest.json
+++ b/homeassistant/components/seven_segments/manifest.json
@@ -2,6 +2,6 @@
"domain": "seven_segments",
"name": "Seven Segments OCR",
"documentation": "https://www.home-assistant.io/integrations/seven_segments",
- "requirements": ["pillow==7.1.2"],
+ "requirements": ["pillow==7.2.0"],
"codeowners": ["@fabaff"]
}
diff --git a/homeassistant/components/sharkiq/__init__.py b/homeassistant/components/sharkiq/__init__.py
new file mode 100644
index 00000000000..968ee91f8d6
--- /dev/null
+++ b/homeassistant/components/sharkiq/__init__.py
@@ -0,0 +1,114 @@
+"""Shark IQ Integration."""
+
+import asyncio
+
+import async_timeout
+from sharkiqpy import (
+ AylaApi,
+ SharkIqAuthError,
+ SharkIqAuthExpiringError,
+ SharkIqNotAuthedError,
+ get_ayla_api,
+)
+
+from homeassistant import exceptions
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+
+from .const import API_TIMEOUT, COMPONENTS, DOMAIN, LOGGER
+from .update_coordinator import SharkIqUpdateCoordinator
+
+
+class CannotConnect(exceptions.HomeAssistantError):
+ """Error to indicate we cannot connect."""
+
+
+async def async_setup(hass, config):
+ """Set up the sharkiq environment."""
+ hass.data.setdefault(DOMAIN, {})
+ return True
+
+
+async def async_connect_or_timeout(ayla_api: AylaApi) -> bool:
+ """Connect to vacuum."""
+ try:
+ with async_timeout.timeout(API_TIMEOUT):
+ LOGGER.debug("Initialize connection to Ayla networks API")
+ await ayla_api.async_sign_in()
+ except SharkIqAuthError:
+ LOGGER.error("Authentication error connecting to Shark IQ api")
+ return False
+ except asyncio.TimeoutError as exc:
+ LOGGER.error("Timeout expired")
+ raise CannotConnect from exc
+
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Initialize the sharkiq platform via config entry."""
+ ayla_api = get_ayla_api(
+ username=config_entry.data[CONF_USERNAME],
+ password=config_entry.data[CONF_PASSWORD],
+ websession=hass.helpers.aiohttp_client.async_get_clientsession(),
+ )
+
+ try:
+ if not await async_connect_or_timeout(ayla_api):
+ return False
+ except CannotConnect as exc:
+ raise exceptions.ConfigEntryNotReady from exc
+
+ shark_vacs = await ayla_api.async_get_devices(False)
+ device_names = ", ".join([d.name for d in shark_vacs])
+ LOGGER.debug("Found %d Shark IQ device(s): %s", len(shark_vacs), device_names)
+ coordinator = SharkIqUpdateCoordinator(hass, config_entry, ayla_api, shark_vacs)
+
+ await coordinator.async_refresh()
+
+ if not coordinator.last_update_success:
+ raise exceptions.ConfigEntryNotReady
+
+ hass.data[DOMAIN][config_entry.entry_id] = coordinator
+
+ for component in COMPONENTS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(config_entry, component)
+ )
+
+ return True
+
+
+async def async_disconnect_or_timeout(coordinator: SharkIqUpdateCoordinator):
+ """Disconnect to vacuum."""
+ LOGGER.debug("Disconnecting from Ayla Api")
+ with async_timeout.timeout(5):
+ try:
+ await coordinator.ayla_api.async_sign_out()
+ except (SharkIqAuthError, SharkIqAuthExpiringError, SharkIqNotAuthedError):
+ pass
+
+
+async def async_update_options(hass, config_entry):
+ """Update options."""
+ await hass.config_entries.async_reload(config_entry.entry_id)
+
+
+async def async_unload_entry(hass, config_entry):
+ """Unload a config entry."""
+ unload_ok = all(
+ await asyncio.gather(
+ *[
+ hass.config_entries.async_forward_entry_unload(config_entry, component)
+ for component in COMPONENTS
+ ]
+ )
+ )
+ if unload_ok:
+ domain_data = hass.data[DOMAIN][config_entry.entry_id]
+ try:
+ await async_disconnect_or_timeout(coordinator=domain_data)
+ except SharkIqAuthError:
+ pass
+ hass.data[DOMAIN].pop(config_entry.entry_id)
+
+ return unload_ok
diff --git a/homeassistant/components/sharkiq/config_flow.py b/homeassistant/components/sharkiq/config_flow.py
new file mode 100644
index 00000000000..4b2e54d3e38
--- /dev/null
+++ b/homeassistant/components/sharkiq/config_flow.py
@@ -0,0 +1,110 @@
+"""Config flow for Shark IQ integration."""
+
+import asyncio
+from typing import Dict, Optional
+
+import aiohttp
+import async_timeout
+from sharkiqpy import SharkIqAuthError, get_ayla_api
+import voluptuous as vol
+
+from homeassistant import config_entries, core, exceptions
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+
+from .const import DOMAIN, LOGGER # pylint:disable=unused-import
+
+SHARKIQ_SCHEMA = vol.Schema(
+ {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
+)
+
+
+async def validate_input(hass: core.HomeAssistant, data):
+ """Validate the user input allows us to connect."""
+ ayla_api = get_ayla_api(
+ username=data[CONF_USERNAME],
+ password=data[CONF_PASSWORD],
+ websession=hass.helpers.aiohttp_client.async_get_clientsession(hass),
+ )
+
+ try:
+ with async_timeout.timeout(10):
+ LOGGER.debug("Initialize connection to Ayla networks API")
+ await ayla_api.async_sign_in()
+ except (asyncio.TimeoutError, aiohttp.ClientError) as errors:
+ raise CannotConnect from errors
+ except SharkIqAuthError as error:
+ raise InvalidAuth from error
+
+ # Return info that you want to store in the config entry.
+ return {"title": data[CONF_USERNAME]}
+
+
+class SharkIqConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Shark IQ."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ async def _async_validate_input(self, user_input):
+ """Validate form input."""
+ errors = {}
+ info = None
+
+ # noinspection PyBroadException
+ try:
+ info = await validate_input(self.hass, user_input)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except Exception: # pylint: disable=broad-except
+ LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ return info, errors
+
+ async def async_step_user(self, user_input: Optional[Dict] = None):
+ """Handle the initial step."""
+ errors = {}
+ if user_input is not None:
+ info, errors = await self._async_validate_input(user_input)
+ if info:
+ await self.async_set_unique_id(user_input[CONF_USERNAME])
+ self._abort_if_unique_id_configured()
+ return self.async_create_entry(title=info["title"], data=user_input)
+
+ return self.async_show_form(
+ step_id="user", data_schema=SHARKIQ_SCHEMA, errors=errors
+ )
+
+ async def async_step_reauth(self, user_input: Optional[dict] = None):
+ """Handle re-auth if login is invalid."""
+ errors = {}
+
+ if user_input is not None:
+ _, errors = await self._async_validate_input(user_input)
+
+ if not errors:
+ for entry in self._async_current_entries():
+ if entry.unique_id == self.unique_id:
+ self.hass.config_entries.async_update_entry(
+ entry, data=user_input
+ )
+
+ return self.async_abort(reason="reauth_successful")
+
+ if errors["base"] != "invalid_auth":
+ return self.async_abort(reason=errors["base"])
+
+ return self.async_show_form(
+ step_id="reauth",
+ data_schema=SHARKIQ_SCHEMA,
+ errors=errors,
+ )
+
+
+class CannotConnect(exceptions.HomeAssistantError):
+ """Error to indicate we cannot connect."""
+
+
+class InvalidAuth(exceptions.HomeAssistantError):
+ """Error to indicate there is invalid auth."""
diff --git a/homeassistant/components/sharkiq/const.py b/homeassistant/components/sharkiq/const.py
new file mode 100644
index 00000000000..9160fc710a2
--- /dev/null
+++ b/homeassistant/components/sharkiq/const.py
@@ -0,0 +1,11 @@
+"""Shark IQ Constants."""
+
+from datetime import timedelta
+import logging
+
+API_TIMEOUT = 20
+COMPONENTS = ["vacuum"]
+DOMAIN = "sharkiq"
+LOGGER = logging.getLogger(__package__)
+SHARK = "Shark"
+UPDATE_INTERVAL = timedelta(seconds=30)
diff --git a/homeassistant/components/sharkiq/manifest.json b/homeassistant/components/sharkiq/manifest.json
new file mode 100644
index 00000000000..ee98ccfe32e
--- /dev/null
+++ b/homeassistant/components/sharkiq/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "sharkiq",
+ "name": "Shark IQ",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/sharkiq",
+ "requirements": ["sharkiqpy==0.1.8"],
+ "codeowners": ["@ajmarks"]
+}
diff --git a/homeassistant/components/sharkiq/strings.json b/homeassistant/components/sharkiq/strings.json
new file mode 100644
index 00000000000..114087697ad
--- /dev/null
+++ b/homeassistant/components/sharkiq/strings.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ }
+ },
+ "reauth": {
+ "data": {
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]",
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "reauth_successful": "[%key:common::config_flow::data::access_token%] updated successfully",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ }
+ }
+}
diff --git a/homeassistant/components/sharkiq/translations/ca.json b/homeassistant/components/sharkiq/translations/ca.json
new file mode 100644
index 00000000000..b6e98056863
--- /dev/null
+++ b/homeassistant/components/sharkiq/translations/ca.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured_account": "El compte ja ha estat configurat",
+ "cannot_connect": "Ha fallat la connexi\u00f3",
+ "reauth_successful": "Token d'acc\u00e9s actualitzat correctament",
+ "unknown": "Error inesperat"
+ },
+ "error": {
+ "cannot_connect": "Ha fallat la connexi\u00f3",
+ "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida",
+ "unknown": "Error inesperat"
+ },
+ "step": {
+ "reauth": {
+ "data": {
+ "password": "Contrasenya",
+ "username": "Nom d'usuari"
+ }
+ },
+ "user": {
+ "data": {
+ "password": "Contrasenya",
+ "username": "Nom d'usuari"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sharkiq/translations/en.json b/homeassistant/components/sharkiq/translations/en.json
new file mode 100644
index 00000000000..d305eaf07e8
--- /dev/null
+++ b/homeassistant/components/sharkiq/translations/en.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured_account": "Account is already configured",
+ "cannot_connect": "Failed to connect",
+ "reauth_successful": "Access Token updated successfully",
+ "unknown": "Unexpected error"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "step": {
+ "reauth": {
+ "data": {
+ "password": "Password",
+ "username": "Username"
+ }
+ },
+ "user": {
+ "data": {
+ "password": "Password",
+ "username": "Username"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sharkiq/translations/es.json b/homeassistant/components/sharkiq/translations/es.json
new file mode 100644
index 00000000000..a6f70700806
--- /dev/null
+++ b/homeassistant/components/sharkiq/translations/es.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured_account": "La cuenta ya ha sido configurada",
+ "cannot_connect": "No se pudo conectar",
+ "reauth_successful": "Token de acceso actualizado correctamente",
+ "unknown": "Error inesperado"
+ },
+ "error": {
+ "cannot_connect": "No se pudo conectar",
+ "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida",
+ "unknown": "Error inesperado"
+ },
+ "step": {
+ "reauth": {
+ "data": {
+ "password": "Contrase\u00f1a",
+ "username": "Usuario"
+ }
+ },
+ "user": {
+ "data": {
+ "password": "Contrase\u00f1a",
+ "username": "Usuario"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sharkiq/translations/fr.json b/homeassistant/components/sharkiq/translations/fr.json
new file mode 100644
index 00000000000..b867170a4d2
--- /dev/null
+++ b/homeassistant/components/sharkiq/translations/fr.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured_account": "Le compte a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9",
+ "cannot_connect": "\u00c9chec de connexion",
+ "reauth_successful": "Jeton d'acc\u00e8s mis \u00e0 jour avec succ\u00e8s",
+ "unknown": "Erreur inattendue"
+ },
+ "error": {
+ "cannot_connect": "\u00c9chec de connexion",
+ "invalid_auth": "Authentification invalide",
+ "unknown": "Erreur inattendue"
+ },
+ "step": {
+ "reauth": {
+ "data": {
+ "password": "Mot de passe",
+ "username": "Nom d'utilisateur"
+ }
+ },
+ "user": {
+ "data": {
+ "password": "Mot de passe",
+ "username": "Nom d'utilisateur"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sharkiq/translations/it.json b/homeassistant/components/sharkiq/translations/it.json
new file mode 100644
index 00000000000..8cd3a16bb1c
--- /dev/null
+++ b/homeassistant/components/sharkiq/translations/it.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured_account": "L'account \u00e8 gi\u00e0 configurato",
+ "cannot_connect": "Impossibile connettersi",
+ "reauth_successful": "Token di accesso aggiornato correttamente",
+ "unknown": "Errore imprevisto"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi",
+ "invalid_auth": "Autenticazione non valida",
+ "unknown": "Errore imprevisto"
+ },
+ "step": {
+ "reauth": {
+ "data": {
+ "password": "Password",
+ "username": "Nome utente"
+ }
+ },
+ "user": {
+ "data": {
+ "password": "Password",
+ "username": "Nome utente"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sharkiq/translations/no.json b/homeassistant/components/sharkiq/translations/no.json
new file mode 100644
index 00000000000..80673ea381b
--- /dev/null
+++ b/homeassistant/components/sharkiq/translations/no.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured_account": "Kontoen er allerede konfigurert",
+ "cannot_connect": "Tilkobling mislyktes.",
+ "reauth_successful": "Tilgangstoken oppdatert vellykket",
+ "unknown": "Uventet feil"
+ },
+ "error": {
+ "cannot_connect": "Tilkobling mislyktes.",
+ "invalid_auth": "Ugyldig godkjenning",
+ "unknown": "Uventet feil"
+ },
+ "step": {
+ "reauth": {
+ "data": {
+ "password": "Passord",
+ "username": "Brukernavn"
+ }
+ },
+ "user": {
+ "data": {
+ "password": "Passord",
+ "username": "Brukernavn"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sharkiq/translations/pl.json b/homeassistant/components/sharkiq/translations/pl.json
new file mode 100644
index 00000000000..dcb12e86906
--- /dev/null
+++ b/homeassistant/components/sharkiq/translations/pl.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.",
+ "reauth_successful": "Token dost\u0119pu zosta\u0142 pomy\u015blnie zaktualizowany",
+ "unknown": "Nieoczekiwany b\u0142\u0105d."
+ },
+ "error": {
+ "invalid_auth": "Niepoprawne uwierzytelnienie."
+ },
+ "step": {
+ "reauth": {
+ "data": {
+ "password": "Has\u0142o",
+ "username": "Nazwa u\u017cytkownika"
+ }
+ },
+ "user": {
+ "data": {
+ "password": "Has\u0142o",
+ "username": "Nazwa u\u017cytkownika"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sharkiq/translations/pt.json b/homeassistant/components/sharkiq/translations/pt.json
new file mode 100644
index 00000000000..973b6681d31
--- /dev/null
+++ b/homeassistant/components/sharkiq/translations/pt.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured_account": "Conta j\u00e1 configurada"
+ },
+ "error": {
+ "cannot_connect": "Falha na liga\u00e7\u00e3o",
+ "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida",
+ "unknown": "Erro inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Palavra-passe",
+ "username": "Nome de Utilizador"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sharkiq/translations/ru.json b/homeassistant/components/sharkiq/translations/ru.json
new file mode 100644
index 00000000000..3bbdbc5ac58
--- /dev/null
+++ b/homeassistant/components/sharkiq/translations/ru.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured_account": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.",
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.",
+ "reauth_successful": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d.",
+ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "error": {
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.",
+ "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.",
+ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "step": {
+ "reauth": {
+ "data": {
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
+ "username": "\u041b\u043e\u0433\u0438\u043d"
+ }
+ },
+ "user": {
+ "data": {
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
+ "username": "\u041b\u043e\u0433\u0438\u043d"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sharkiq/translations/zh-Hant.json b/homeassistant/components/sharkiq/translations/zh-Hant.json
new file mode 100644
index 00000000000..1cb63fe1313
--- /dev/null
+++ b/homeassistant/components/sharkiq/translations/zh-Hant.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured_account": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "cannot_connect": "\u9023\u7dda\u5931\u6557",
+ "reauth_successful": "\u5b58\u53d6\u5bc6\u9470\u5df2\u6210\u529f\u66f4\u65b0",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557",
+ "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "step": {
+ "reauth": {
+ "data": {
+ "password": "\u5bc6\u78bc",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31"
+ }
+ },
+ "user": {
+ "data": {
+ "password": "\u5bc6\u78bc",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sharkiq/update_coordinator.py b/homeassistant/components/sharkiq/update_coordinator.py
new file mode 100644
index 00000000000..2b3f6070f3a
--- /dev/null
+++ b/homeassistant/components/sharkiq/update_coordinator.py
@@ -0,0 +1,103 @@
+"""Data update coordinator for shark iq vacuums."""
+
+import asyncio
+from typing import Dict, List, Set
+
+from async_timeout import timeout
+from sharkiqpy import (
+ AylaApi,
+ SharkIqAuthError,
+ SharkIqAuthExpiringError,
+ SharkIqNotAuthedError,
+ SharkIqVacuum,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import API_TIMEOUT, DOMAIN, LOGGER, UPDATE_INTERVAL
+
+
+class SharkIqUpdateCoordinator(DataUpdateCoordinator):
+ """Define a wrapper class to update Shark IQ data."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ config_entry: ConfigEntry,
+ ayla_api: AylaApi,
+ shark_vacs: List[SharkIqVacuum],
+ ) -> None:
+ """Set up the SharkIqUpdateCoordinator class."""
+ self.ayla_api = ayla_api
+ self.shark_vacs: Dict[str, SharkIqVacuum] = {
+ sharkiq.serial_number: sharkiq for sharkiq in shark_vacs
+ }
+ self._config_entry = config_entry
+ self._online_dsns = set()
+
+ super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL)
+
+ @property
+ def online_dsns(self) -> Set[str]:
+ """Get the set of all online DSNs."""
+ return self._online_dsns
+
+ def device_is_online(self, dsn: str) -> bool:
+ """Return the online state of a given vacuum dsn."""
+ return dsn in self._online_dsns
+
+ @staticmethod
+ async def _async_update_vacuum(sharkiq: SharkIqVacuum) -> None:
+ """Asynchronously update the data for a single vacuum."""
+ dsn = sharkiq.serial_number
+ LOGGER.debug("Updating sharkiq data for device DSN %s", dsn)
+ with timeout(API_TIMEOUT):
+ await sharkiq.async_update()
+
+ async def _async_update_data(self) -> bool:
+ """Update data device by device."""
+ try:
+ all_vacuums = await self.ayla_api.async_list_devices()
+ self._online_dsns = {
+ v["dsn"]
+ for v in all_vacuums
+ if v["connection_status"] == "Online" and v["dsn"] in self.shark_vacs
+ }
+
+ LOGGER.debug("Updating sharkiq data")
+ online_vacs = (self.shark_vacs[dsn] for dsn in self.online_dsns)
+ await asyncio.gather(*[self._async_update_vacuum(v) for v in online_vacs])
+ except (
+ SharkIqAuthError,
+ SharkIqNotAuthedError,
+ SharkIqAuthExpiringError,
+ ) as err:
+ LOGGER.exception("Bad auth state")
+ flow_context = {
+ "source": "reauth",
+ "unique_id": self._config_entry.unique_id,
+ }
+
+ matching_flows = [
+ flow
+ for flow in self.hass.config_entries.flow.async_progress()
+ if flow["context"] == flow_context
+ ]
+
+ if not matching_flows:
+ self.hass.async_create_task(
+ self.hass.config_entries.flow.async_init(
+ DOMAIN,
+ context=flow_context,
+ data=self._config_entry.data,
+ )
+ )
+
+ raise UpdateFailed(err) from err
+ except Exception as err: # pylint: disable=broad-except
+ LOGGER.exception("Unexpected error updating SharkIQ")
+ raise UpdateFailed(err) from err
+
+ return True
diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py
new file mode 100644
index 00000000000..96e4d98f318
--- /dev/null
+++ b/homeassistant/components/sharkiq/vacuum.py
@@ -0,0 +1,266 @@
+"""Shark IQ Wrapper."""
+
+
+import logging
+from typing import Dict, Iterable, Optional
+
+from sharkiqpy import OperatingModes, PowerModes, Properties, SharkIqVacuum
+
+from homeassistant.components.vacuum import (
+ STATE_CLEANING,
+ STATE_DOCKED,
+ STATE_IDLE,
+ STATE_PAUSED,
+ STATE_RETURNING,
+ SUPPORT_BATTERY,
+ SUPPORT_FAN_SPEED,
+ SUPPORT_LOCATE,
+ SUPPORT_PAUSE,
+ SUPPORT_RETURN_HOME,
+ SUPPORT_START,
+ SUPPORT_STATE,
+ SUPPORT_STATUS,
+ SUPPORT_STOP,
+ StateVacuumEntity,
+)
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import DOMAIN, SHARK
+from .update_coordinator import SharkIqUpdateCoordinator
+
+LOGGER = logging.getLogger(__name__)
+
+# Supported features
+SUPPORT_SHARKIQ = (
+ SUPPORT_BATTERY
+ | SUPPORT_FAN_SPEED
+ | SUPPORT_PAUSE
+ | SUPPORT_RETURN_HOME
+ | SUPPORT_START
+ | SUPPORT_STATE
+ | SUPPORT_STATUS
+ | SUPPORT_STOP
+ | SUPPORT_LOCATE
+)
+
+OPERATING_STATE_MAP = {
+ OperatingModes.PAUSE: STATE_PAUSED,
+ OperatingModes.START: STATE_CLEANING,
+ OperatingModes.STOP: STATE_IDLE,
+ OperatingModes.RETURN: STATE_RETURNING,
+}
+
+FAN_SPEEDS_MAP = {
+ "Eco": PowerModes.ECO,
+ "Normal": PowerModes.NORMAL,
+ "Max": PowerModes.MAX,
+}
+
+STATE_RECHARGING_TO_RESUME = "recharging_to_resume"
+
+# Attributes to expose
+ATTR_ERROR_CODE = "last_error_code"
+ATTR_ERROR_MSG = "last_error_message"
+ATTR_LOW_LIGHT = "low_light"
+ATTR_RECHARGE_RESUME = "recharge_and_resume"
+ATTR_RSSI = "rssi"
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the Shark IQ vacuum cleaner."""
+ coordinator: SharkIqUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id]
+ devices: Iterable["SharkIqVacuum"] = coordinator.shark_vacs.values()
+ device_names = [d.name for d in devices]
+ LOGGER.debug(
+ "Found %d Shark IQ device(s): %s",
+ len(device_names),
+ ", ".join([d.name for d in devices]),
+ )
+ async_add_entities([SharkVacuumEntity(d, coordinator) for d in devices])
+
+
+class SharkVacuumEntity(CoordinatorEntity, StateVacuumEntity):
+ """Shark IQ vacuum entity."""
+
+ def __init__(self, sharkiq: SharkIqVacuum, coordinator: SharkIqUpdateCoordinator):
+ """Create a new SharkVacuumEntity."""
+ super().__init__(coordinator)
+ self.sharkiq = sharkiq
+
+ def clean_spot(self, **kwargs):
+ """Clean a spot. Not yet implemented."""
+ raise NotImplementedError()
+
+ def send_command(self, command, params=None, **kwargs):
+ """Send a command to the vacuum. Not yet implemented."""
+ raise NotImplementedError()
+
+ @property
+ def is_online(self) -> bool:
+ """Tell us if the device is online."""
+ return self.coordinator.device_is_online(self.sharkiq.serial_number)
+
+ @property
+ def name(self) -> str:
+ """Device name."""
+ return self.sharkiq.name
+
+ @property
+ def serial_number(self) -> str:
+ """Vacuum API serial number (DSN)."""
+ return self.sharkiq.serial_number
+
+ @property
+ def model(self) -> str:
+ """Vacuum model number."""
+ if self.sharkiq.vac_model_number:
+ return self.sharkiq.vac_model_number
+ return self.sharkiq.oem_model_number
+
+ @property
+ def device_info(self) -> Dict:
+ """Device info dictionary."""
+ return {
+ "identifiers": {(DOMAIN, self.serial_number)},
+ "name": self.name,
+ "manufacturer": SHARK,
+ "model": self.model,
+ "sw_version": self.sharkiq.get_property_value(
+ Properties.ROBOT_FIRMWARE_VERSION
+ ),
+ }
+
+ @property
+ def supported_features(self) -> int:
+ """Flag vacuum cleaner robot features that are supported."""
+ return SUPPORT_SHARKIQ
+
+ @property
+ def is_docked(self) -> Optional[bool]:
+ """Is vacuum docked."""
+ return self.sharkiq.get_property_value(Properties.DOCKED_STATUS)
+
+ @property
+ def error_code(self) -> Optional[int]:
+ """Return the last observed error code (or None)."""
+ return self.sharkiq.error_code
+
+ @property
+ def error_message(self) -> Optional[str]:
+ """Return the last observed error message (or None)."""
+ if not self.error_code:
+ return None
+ return self.sharkiq.error_text
+
+ @property
+ def operating_mode(self) -> Optional[str]:
+ """Operating mode.."""
+ op_mode = self.sharkiq.get_property_value(Properties.OPERATING_MODE)
+ return OPERATING_STATE_MAP.get(op_mode)
+
+ @property
+ def recharging_to_resume(self) -> Optional[int]:
+ """Return True if vacuum set to recharge and resume cleaning."""
+ return self.sharkiq.get_property_value(Properties.RECHARGING_TO_RESUME)
+
+ @property
+ def state(self):
+ """
+ Get the current vacuum state.
+
+ NB: Currently, we do not return an error state because they can be very, very stale.
+ In the app, these are (usually) handled by showing the robot as stopped and sending the
+ user a notification.
+ """
+ if self.is_docked:
+ return STATE_DOCKED
+ return self.operating_mode
+
+ @property
+ def unique_id(self) -> str:
+ """Return the unique id of the vacuum cleaner."""
+ return self.serial_number
+
+ @property
+ def available(self) -> bool:
+ """Determine if the sensor is available based on API results."""
+ # If the last update was successful...
+ return self.coordinator.last_update_success and self.is_online
+
+ @property
+ def battery_level(self):
+ """Get the current battery level."""
+ return self.sharkiq.get_property_value(Properties.BATTERY_CAPACITY)
+
+ async def async_return_to_base(self, **kwargs):
+ """Have the device return to base."""
+ await self.sharkiq.async_set_operating_mode(OperatingModes.RETURN)
+ await self.coordinator.async_refresh()
+
+ async def async_pause(self):
+ """Pause the cleaning task."""
+ await self.sharkiq.async_set_operating_mode(OperatingModes.PAUSE)
+ await self.coordinator.async_refresh()
+
+ async def async_start(self):
+ """Start the device."""
+ await self.sharkiq.async_set_operating_mode(OperatingModes.START)
+ await self.coordinator.async_refresh()
+
+ async def async_stop(self, **kwargs):
+ """Stop the device."""
+ await self.sharkiq.async_set_operating_mode(OperatingModes.STOP)
+ await self.coordinator.async_refresh()
+
+ async def async_locate(self, **kwargs):
+ """Cause the device to generate a loud chirp."""
+ await self.sharkiq.async_find_device()
+
+ @property
+ def fan_speed(self) -> str:
+ """Return the current fan speed."""
+ fan_speed = None
+ speed_level = self.sharkiq.get_property_value(Properties.POWER_MODE)
+ for k, val in FAN_SPEEDS_MAP.items():
+ if val == speed_level:
+ fan_speed = k
+ return fan_speed
+
+ async def async_set_fan_speed(self, fan_speed: str, **kwargs):
+ """Set the fan speed."""
+ await self.sharkiq.async_set_property_value(
+ Properties.POWER_MODE, FAN_SPEEDS_MAP.get(fan_speed.capitalize())
+ )
+ await self.coordinator.async_refresh()
+
+ @property
+ def fan_speed_list(self):
+ """Get the list of available fan speed steps of the vacuum cleaner."""
+ return list(FAN_SPEEDS_MAP)
+
+ # Various attributes we want to expose
+ @property
+ def recharge_resume(self) -> Optional[bool]:
+ """Recharge and resume mode active."""
+ return self.sharkiq.get_property_value(Properties.RECHARGE_RESUME)
+
+ @property
+ def rssi(self) -> Optional[int]:
+ """Get the WiFi RSSI."""
+ return self.sharkiq.get_property_value(Properties.RSSI)
+
+ @property
+ def low_light(self):
+ """Let us know if the robot is operating in low-light mode."""
+ return self.sharkiq.get_property_value(Properties.LOW_LIGHT_MISSION)
+
+ @property
+ def device_state_attributes(self) -> Dict:
+ """Return a dictionary of device state attributes specific to sharkiq."""
+ data = {
+ ATTR_ERROR_CODE: self.error_code,
+ ATTR_ERROR_MSG: self.sharkiq.error_text,
+ ATTR_LOW_LIGHT: self.low_light,
+ ATTR_RECHARGE_RESUME: self.recharge_resume,
+ }
+ return data
diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py
new file mode 100644
index 00000000000..83d5d7b9f3a
--- /dev/null
+++ b/homeassistant/components/shelly/__init__.py
@@ -0,0 +1,146 @@
+"""The Shelly integration."""
+import asyncio
+from datetime import timedelta
+import logging
+
+from aiocoap import error as aiocoap_error
+import aioshelly
+import async_timeout
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_USERNAME,
+ EVENT_HOMEASSISTANT_STOP,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import aiohttp_client, device_registry, update_coordinator
+
+from .const import DOMAIN
+
+PLATFORMS = ["binary_sensor", "cover", "light", "sensor", "switch"]
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+ """Set up the Shelly component."""
+ hass.data[DOMAIN] = {}
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up Shelly from a config entry."""
+ temperature_unit = "C" if hass.config.units.is_metric else "F"
+ options = aioshelly.ConnectionOptions(
+ entry.data[CONF_HOST],
+ entry.data.get(CONF_USERNAME),
+ entry.data.get(CONF_PASSWORD),
+ temperature_unit,
+ )
+ try:
+ async with async_timeout.timeout(5):
+ device = await aioshelly.Device.create(
+ aiohttp_client.async_get_clientsession(hass),
+ options,
+ )
+ except (asyncio.TimeoutError, OSError) as err:
+ raise ConfigEntryNotReady from err
+
+ wrapper = hass.data[DOMAIN][entry.entry_id] = ShellyDeviceWrapper(
+ hass, entry, device
+ )
+ await wrapper.async_setup()
+
+ for component in PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component)
+ )
+
+ return True
+
+
+class ShellyDeviceWrapper(update_coordinator.DataUpdateCoordinator):
+ """Wrapper for a Shelly device with Home Assistant specific functions."""
+
+ def __init__(self, hass, entry, device: aioshelly.Device):
+ """Initialize the Shelly device wrapper."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=device.settings["name"] or device.settings["device"]["hostname"],
+ update_interval=timedelta(seconds=5),
+ )
+ self.hass = hass
+ self.entry = entry
+ self.device = device
+ self._unsub_stop = None
+
+ async def _async_update_data(self):
+ """Fetch data."""
+ # Race condition on shutdown. Stop all the fetches.
+ if self._unsub_stop is None:
+ return None
+
+ try:
+ async with async_timeout.timeout(5):
+ return await self.device.update()
+ except aiocoap_error.Error as err:
+ raise update_coordinator.UpdateFailed("Error fetching data") from err
+
+ @property
+ def model(self):
+ """Model of the device."""
+ return self.device.settings["device"]["type"]
+
+ @property
+ def mac(self):
+ """Mac address of the device."""
+ return self.device.settings["device"]["mac"]
+
+ async def async_setup(self):
+ """Set up the wrapper."""
+ self._unsub_stop = self.hass.bus.async_listen(
+ EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop
+ )
+ dev_reg = await device_registry.async_get_registry(self.hass)
+ model_type = self.device.settings["device"]["type"]
+ dev_reg.async_get_or_create(
+ config_entry_id=self.entry.entry_id,
+ name=self.name,
+ connections={(device_registry.CONNECTION_NETWORK_MAC, self.mac)},
+ # This is duplicate but otherwise via_device can't work
+ identifiers={(DOMAIN, self.mac)},
+ manufacturer="Shelly",
+ model=aioshelly.MODEL_NAMES.get(model_type, model_type),
+ sw_version=self.device.settings["fw"],
+ )
+
+ async def shutdown(self):
+ """Shutdown the device wrapper."""
+ if self._unsub_stop:
+ self._unsub_stop()
+ self._unsub_stop = None
+ await self.device.shutdown()
+
+ async def _handle_ha_stop(self, _):
+ """Handle Home Assistant stopping."""
+ self._unsub_stop = None
+ await self.shutdown()
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """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:
+ await hass.data[DOMAIN].pop(entry.entry_id).shutdown()
+
+ return unload_ok
diff --git a/homeassistant/components/shelly/binary_sensor.py b/homeassistant/components/shelly/binary_sensor.py
new file mode 100644
index 00000000000..c9a13249aa8
--- /dev/null
+++ b/homeassistant/components/shelly/binary_sensor.py
@@ -0,0 +1,65 @@
+"""Binary sensor for Shelly."""
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_GAS,
+ DEVICE_CLASS_MOISTURE,
+ DEVICE_CLASS_OPENING,
+ DEVICE_CLASS_PROBLEM,
+ DEVICE_CLASS_SMOKE,
+ DEVICE_CLASS_VIBRATION,
+ BinarySensorEntity,
+)
+
+from .entity import (
+ BlockAttributeDescription,
+ ShellyBlockAttributeEntity,
+ async_setup_entry_attribute_entities,
+)
+
+SENSORS = {
+ ("device", "overtemp"): BlockAttributeDescription(
+ name="Overheating", device_class=DEVICE_CLASS_PROBLEM
+ ),
+ ("device", "overpower"): BlockAttributeDescription(
+ name="Over Power", device_class=DEVICE_CLASS_PROBLEM
+ ),
+ ("light", "overpower"): BlockAttributeDescription(
+ name="Over Power", device_class=DEVICE_CLASS_PROBLEM
+ ),
+ ("relay", "overpower"): BlockAttributeDescription(
+ name="Over Power", device_class=DEVICE_CLASS_PROBLEM
+ ),
+ ("sensor", "dwIsOpened"): BlockAttributeDescription(
+ name="Door", device_class=DEVICE_CLASS_OPENING
+ ),
+ ("sensor", "flood"): BlockAttributeDescription(
+ name="flood", device_class=DEVICE_CLASS_MOISTURE
+ ),
+ ("sensor", "gas"): BlockAttributeDescription(
+ name="gas",
+ device_class=DEVICE_CLASS_GAS,
+ value=lambda value: value in ["mild", "heavy"],
+ device_state_attributes=lambda block: {"detected": block.gas},
+ ),
+ ("sensor", "smoke"): BlockAttributeDescription(
+ name="smoke", device_class=DEVICE_CLASS_SMOKE
+ ),
+ ("sensor", "vibration"): BlockAttributeDescription(
+ name="vibration", device_class=DEVICE_CLASS_VIBRATION
+ ),
+}
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up sensors for device."""
+ await async_setup_entry_attribute_entities(
+ hass, config_entry, async_add_entities, SENSORS, ShellyBinarySensor
+ )
+
+
+class ShellyBinarySensor(ShellyBlockAttributeEntity, BinarySensorEntity):
+ """Shelly binary sensor entity."""
+
+ @property
+ def is_on(self):
+ """Return true if sensor state is on."""
+ return bool(self.attribute_value)
diff --git a/homeassistant/components/shelly/config_flow.py b/homeassistant/components/shelly/config_flow.py
new file mode 100644
index 00000000000..6446d2dd2d2
--- /dev/null
+++ b/homeassistant/components/shelly/config_flow.py
@@ -0,0 +1,175 @@
+"""Config flow for Shelly integration."""
+import asyncio
+import logging
+
+import aiohttp
+import aioshelly
+import async_timeout
+import voluptuous as vol
+
+from homeassistant import config_entries, core
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.helpers import aiohttp_client
+
+from .const import DOMAIN # pylint:disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+HOST_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str})
+
+HTTP_CONNECT_ERRORS = (asyncio.TimeoutError, aiohttp.ClientError)
+
+
+async def validate_input(hass: core.HomeAssistant, host, data):
+ """Validate the user input allows us to connect.
+
+ Data has the keys from DATA_SCHEMA with values provided by the user.
+ """
+ options = aioshelly.ConnectionOptions(
+ host, data.get(CONF_USERNAME), data.get(CONF_PASSWORD)
+ )
+ async with async_timeout.timeout(5):
+ device = await aioshelly.Device.create(
+ aiohttp_client.async_get_clientsession(hass),
+ options,
+ )
+
+ await device.shutdown()
+
+ # Return info that you want to store in the config entry.
+ return {"title": device.settings["name"], "mac": device.settings["device"]["mac"]}
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Shelly."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+ host = None
+ info = None
+
+ async def async_step_user(self, user_input=None):
+ """Handle the initial step."""
+ errors = {}
+ if user_input is not None:
+ host = user_input[CONF_HOST]
+ try:
+ info = await self._async_get_info(host)
+ except HTTP_CONNECT_ERRORS:
+ errors["base"] = "cannot_connect"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ await self.async_set_unique_id(info["mac"])
+ self._abort_if_unique_id_configured({CONF_HOST: host})
+ self.host = host
+ if info["auth"]:
+ return await self.async_step_credentials()
+
+ try:
+ device_info = await validate_input(self.hass, self.host, {})
+ except HTTP_CONNECT_ERRORS:
+ errors["base"] = "cannot_connect"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return self.async_create_entry(
+ title=device_info["title"] or self.host,
+ data=user_input,
+ )
+
+ return self.async_show_form(
+ step_id="user", data_schema=HOST_SCHEMA, errors=errors
+ )
+
+ async def async_step_credentials(self, user_input=None):
+ """Handle the credentials step."""
+ errors = {}
+ if user_input is not None:
+ try:
+ device_info = await validate_input(self.hass, self.host, user_input)
+ except aiohttp.ClientResponseError as error:
+ if error.status == 401:
+ errors["base"] = "invalid_auth"
+ else:
+ errors["base"] = "cannot_connect"
+ except HTTP_CONNECT_ERRORS:
+ errors["base"] = "cannot_connect"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return self.async_create_entry(
+ title=device_info["title"] or self.host,
+ data={**user_input, CONF_HOST: self.host},
+ )
+ else:
+ user_input = {}
+
+ schema = vol.Schema(
+ {
+ vol.Required(CONF_USERNAME, default=user_input.get(CONF_USERNAME)): str,
+ vol.Required(CONF_PASSWORD, default=user_input.get(CONF_PASSWORD)): str,
+ }
+ )
+
+ return self.async_show_form(
+ step_id="credentials", data_schema=schema, errors=errors
+ )
+
+ async def async_step_zeroconf(self, zeroconf_info):
+ """Handle zeroconf discovery."""
+ if not zeroconf_info.get("name", "").startswith("shelly"):
+ return self.async_abort(reason="not_shelly")
+
+ try:
+ self.info = info = await self._async_get_info(zeroconf_info["host"])
+ except HTTP_CONNECT_ERRORS:
+ return self.async_abort(reason="cannot_connect")
+
+ await self.async_set_unique_id(info["mac"])
+ self._abort_if_unique_id_configured({CONF_HOST: zeroconf_info["host"]})
+ self.host = zeroconf_info["host"]
+ # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
+ self.context["title_placeholders"] = {"name": zeroconf_info["host"]}
+ return await self.async_step_confirm_discovery()
+
+ async def async_step_confirm_discovery(self, user_input=None):
+ """Handle discovery confirm."""
+ errors = {}
+ if user_input is not None:
+ if self.info["auth"]:
+ return await self.async_step_credentials()
+
+ try:
+ device_info = await validate_input(self.hass, self.host, {})
+ except HTTP_CONNECT_ERRORS:
+ errors["base"] = "cannot_connect"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ return self.async_create_entry(
+ title=device_info["title"] or self.host, data={"host": self.host}
+ )
+
+ return self.async_show_form(
+ step_id="confirm_discovery",
+ description_placeholders={
+ "model": aioshelly.MODEL_NAMES.get(
+ self.info["type"], self.info["type"]
+ ),
+ "host": self.host,
+ },
+ errors=errors,
+ )
+
+ async def _async_get_info(self, host):
+ """Get info from shelly device."""
+ async with async_timeout.timeout(5):
+ return await aioshelly.get_info(
+ aiohttp_client.async_get_clientsession(self.hass),
+ host,
+ )
diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py
new file mode 100644
index 00000000000..5c9a55915fe
--- /dev/null
+++ b/homeassistant/components/shelly/const.py
@@ -0,0 +1,3 @@
+"""Constants for the Shelly integration."""
+
+DOMAIN = "shelly"
diff --git a/homeassistant/components/shelly/cover.py b/homeassistant/components/shelly/cover.py
new file mode 100644
index 00000000000..ec1933ba420
--- /dev/null
+++ b/homeassistant/components/shelly/cover.py
@@ -0,0 +1,104 @@
+"""Cover for Shelly."""
+from aioshelly import Block
+
+from homeassistant.components.cover import (
+ ATTR_POSITION,
+ SUPPORT_CLOSE,
+ SUPPORT_OPEN,
+ SUPPORT_SET_POSITION,
+ SUPPORT_STOP,
+ CoverEntity,
+)
+from homeassistant.core import callback
+
+from . import ShellyDeviceWrapper
+from .const import DOMAIN
+from .entity import ShellyBlockEntity
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up cover for device."""
+ wrapper = hass.data[DOMAIN][config_entry.entry_id]
+ blocks = [block for block in wrapper.device.blocks if block.type == "roller"]
+
+ if not blocks:
+ return
+
+ async_add_entities(ShellyCover(wrapper, block) for block in blocks)
+
+
+class ShellyCover(ShellyBlockEntity, CoverEntity):
+ """Switch that controls a cover block on Shelly devices."""
+
+ def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None:
+ """Initialize light."""
+ super().__init__(wrapper, block)
+ self.control_result = None
+ self._supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP
+ if self.wrapper.device.settings["rollers"][0]["positioning"]:
+ self._supported_features |= SUPPORT_SET_POSITION
+
+ @property
+ def is_closed(self):
+ """If cover is closed."""
+ if self.control_result:
+ return self.control_result["current_pos"] == 0
+
+ return self.block.rollerPos == 0
+
+ @property
+ def current_cover_position(self):
+ """Position of the cover."""
+ if self.control_result:
+ return self.control_result["current_pos"]
+
+ return self.block.rollerPos
+
+ @property
+ def is_closing(self):
+ """Return if the cover is closing."""
+ if self.control_result:
+ return self.control_result["state"] == "close"
+
+ return self.block.roller == "close"
+
+ @property
+ def is_opening(self):
+ """Return if the cover is opening."""
+ if self.control_result:
+ return self.control_result["state"] == "open"
+
+ return self.block.roller == "open"
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._supported_features
+
+ async def async_close_cover(self, **kwargs):
+ """Close cover."""
+ self.control_result = await self.block.set_state(go="close")
+ self.async_write_ha_state()
+
+ async def async_open_cover(self, **kwargs):
+ """Open cover."""
+ self.control_result = await self.block.set_state(go="open")
+ self.async_write_ha_state()
+
+ async def async_set_cover_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ self.control_result = await self.block.set_state(
+ go="to_pos", roller_pos=kwargs[ATTR_POSITION]
+ )
+ self.async_write_ha_state()
+
+ async def async_stop_cover(self, **_kwargs):
+ """Stop the cover."""
+ self.control_result = await self.block.set_state(go="stop")
+ self.async_write_ha_state()
+
+ @callback
+ def _update_callback(self):
+ """When device updates, clear control result that overrides state."""
+ self.control_result = None
+ super()._update_callback()
diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py
new file mode 100644
index 00000000000..96281e2aee1
--- /dev/null
+++ b/homeassistant/components/shelly/entity.py
@@ -0,0 +1,204 @@
+"""Shelly entity helper."""
+from collections import Counter
+from dataclasses import dataclass
+from typing import Any, Callable, Optional, Union
+
+import aioshelly
+
+from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
+from homeassistant.core import callback
+from homeassistant.helpers import device_registry, entity
+
+from . import ShellyDeviceWrapper
+from .const import DOMAIN
+
+
+def temperature_unit(block_info: dict) -> str:
+ """Detect temperature unit."""
+ if block_info[aioshelly.BLOCK_VALUE_UNIT] == "F":
+ return TEMP_FAHRENHEIT
+ return TEMP_CELSIUS
+
+
+async def async_setup_entry_attribute_entities(
+ hass, config_entry, async_add_entities, sensors, sensor_class
+):
+ """Set up entities for block attributes."""
+ wrapper: ShellyDeviceWrapper = hass.data[DOMAIN][config_entry.entry_id]
+ blocks = []
+
+ for block in wrapper.device.blocks:
+ for sensor_id in block.sensor_ids:
+ description = sensors.get((block.type, sensor_id))
+ if description is None:
+ continue
+
+ # Filter out non-existing sensors and sensors without a value
+ if getattr(block, sensor_id, None) in (-1, None):
+ continue
+
+ blocks.append((block, sensor_id, description))
+
+ if not blocks:
+ return
+
+ counts = Counter([item[1] for item in blocks])
+
+ async_add_entities(
+ [
+ sensor_class(wrapper, block, sensor_id, description, counts[sensor_id])
+ for block, sensor_id, description in blocks
+ ]
+ )
+
+
+@dataclass
+class BlockAttributeDescription:
+ """Class to describe a sensor."""
+
+ name: str
+ # Callable = lambda attr_info: unit
+ unit: Union[None, str, Callable[[dict], str]] = None
+ value: Callable[[Any], Any] = lambda val: val
+ device_class: Optional[str] = None
+ default_enabled: bool = True
+ available: Optional[Callable[[aioshelly.Block], bool]] = None
+ device_state_attributes: Optional[
+ Callable[[aioshelly.Block], Optional[dict]]
+ ] = None
+
+
+class ShellyBlockEntity(entity.Entity):
+ """Helper class to represent a block."""
+
+ def __init__(self, wrapper: ShellyDeviceWrapper, block):
+ """Initialize Shelly entity."""
+ self.wrapper = wrapper
+ self.block = block
+ self._name = f"{self.wrapper.name} {self.block.description.replace('_', ' ')}"
+
+ @property
+ def name(self):
+ """Name of entity."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """If device should be polled."""
+ return False
+
+ @property
+ def device_info(self):
+ """Device info."""
+ return {
+ "connections": {(device_registry.CONNECTION_NETWORK_MAC, self.wrapper.mac)}
+ }
+
+ @property
+ def available(self):
+ """Available."""
+ return self.wrapper.last_update_success
+
+ @property
+ def unique_id(self):
+ """Return unique ID of entity."""
+ return f"{self.wrapper.mac}-{self.block.description}"
+
+ async def async_added_to_hass(self):
+ """When entity is added to HASS."""
+ self.async_on_remove(self.wrapper.async_add_listener(self._update_callback))
+
+ async def async_update(self):
+ """Update entity with latest info."""
+ await self.wrapper.async_request_refresh()
+
+ @callback
+ def _update_callback(self):
+ """Handle device update."""
+ self.async_write_ha_state()
+
+
+class ShellyBlockAttributeEntity(ShellyBlockEntity, entity.Entity):
+ """Switch that controls a relay block on Shelly devices."""
+
+ def __init__(
+ self,
+ wrapper: ShellyDeviceWrapper,
+ block: aioshelly.Block,
+ attribute: str,
+ description: BlockAttributeDescription,
+ same_type_count: int,
+ ) -> None:
+ """Initialize sensor."""
+ super().__init__(wrapper, block)
+ self.attribute = attribute
+ self.description = description
+ self.info = block.info(attribute)
+
+ unit = self.description.unit
+
+ if callable(unit):
+ unit = unit(self.info)
+
+ self._unit = unit
+ self._unique_id = f"{super().unique_id}-{self.attribute}"
+
+ name_parts = [self.wrapper.name]
+ if same_type_count > 1:
+ name_parts.append(str(block.channel))
+ name_parts.append(self.description.name)
+
+ self._name = " ".join(name_parts)
+
+ @property
+ def unique_id(self):
+ """Return unique ID of entity."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Name of sensor."""
+ return self._name
+
+ @property
+ def entity_registry_enabled_default(self) -> bool:
+ """Return if it should be enabled by default."""
+ return self.description.default_enabled
+
+ @property
+ def attribute_value(self):
+ """Value of sensor."""
+ value = getattr(self.block, self.attribute)
+
+ if value is None:
+ return None
+
+ return self.description.value(value)
+
+ @property
+ def unit_of_measurement(self):
+ """Return unit of sensor."""
+ return self._unit
+
+ @property
+ def device_class(self):
+ """Device class of sensor."""
+ return self.description.device_class
+
+ @property
+ def available(self):
+ """Available."""
+ available = super().available
+
+ if not available or not self.description.available:
+ return available
+
+ return self.description.available(self.block)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self.description.device_state_attributes is None:
+ return None
+
+ return self.description.device_state_attributes(self.block)
diff --git a/homeassistant/components/shelly/light.py b/homeassistant/components/shelly/light.py
new file mode 100644
index 00000000000..8025fd11205
--- /dev/null
+++ b/homeassistant/components/shelly/light.py
@@ -0,0 +1,120 @@
+"""Light for Shelly."""
+from typing import Optional
+
+from aioshelly import Block
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS,
+ ATTR_COLOR_TEMP,
+ SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR_TEMP,
+ LightEntity,
+)
+from homeassistant.core import callback
+from homeassistant.util.color import (
+ color_temperature_kelvin_to_mired,
+ color_temperature_mired_to_kelvin,
+)
+
+from . import ShellyDeviceWrapper
+from .const import DOMAIN
+from .entity import ShellyBlockEntity
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up lights for device."""
+ wrapper = hass.data[DOMAIN][config_entry.entry_id]
+ blocks = [block for block in wrapper.device.blocks if block.type == "light"]
+
+ if not blocks:
+ return
+
+ async_add_entities(ShellyLight(wrapper, block) for block in blocks)
+
+
+class ShellyLight(ShellyBlockEntity, LightEntity):
+ """Switch that controls a relay block on Shelly devices."""
+
+ def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None:
+ """Initialize light."""
+ super().__init__(wrapper, block)
+ self.control_result = None
+ self._supported_features = 0
+ if hasattr(block, "brightness"):
+ self._supported_features |= SUPPORT_BRIGHTNESS
+ if hasattr(block, "colorTemp"):
+ self._supported_features |= SUPPORT_COLOR_TEMP
+
+ @property
+ def supported_features(self) -> int:
+ """Supported features."""
+ return self._supported_features
+
+ @property
+ def is_on(self) -> bool:
+ """If light is on."""
+ if self.control_result:
+ return self.control_result["ison"]
+
+ return self.block.output
+
+ @property
+ def brightness(self) -> Optional[int]:
+ """Brightness of light."""
+ if self.control_result:
+ brightness = self.control_result["brightness"]
+ else:
+ brightness = self.block.brightness
+ return int(brightness / 100 * 255)
+
+ @property
+ def color_temp(self) -> Optional[float]:
+ """Return the CT color value in mireds."""
+ if self.control_result:
+ color_temp = self.control_result["temp"]
+ else:
+ color_temp = self.block.colorTemp
+
+ # If you set DUO to max mireds in Shelly app, 2700K,
+ # It reports 0 temp
+ if color_temp == 0:
+ return self.max_mireds
+
+ return int(color_temperature_kelvin_to_mired(color_temp))
+
+ @property
+ def min_mireds(self) -> float:
+ """Return the coldest color_temp that this light supports."""
+ return color_temperature_kelvin_to_mired(6500)
+
+ @property
+ def max_mireds(self) -> float:
+ """Return the warmest color_temp that this light supports."""
+ return color_temperature_kelvin_to_mired(2700)
+
+ async def async_turn_on(self, **kwargs) -> None:
+ """Turn on light."""
+ params = {"turn": "on"}
+ if ATTR_BRIGHTNESS in kwargs:
+ tmp_brightness = kwargs[ATTR_BRIGHTNESS]
+ params["brightness"] = int(tmp_brightness / 255 * 100)
+ if ATTR_COLOR_TEMP in kwargs:
+ color_temp = color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP])
+ if color_temp > 6500:
+ color_temp = 6500
+ elif color_temp < 2700:
+ color_temp = 2700
+ params["temp"] = int(color_temp)
+ self.control_result = await self.block.set_state(**params)
+ self.async_write_ha_state()
+
+ async def async_turn_off(self, **kwargs) -> None:
+ """Turn off light."""
+ self.control_result = await self.block.set_state(turn="off")
+ self.async_write_ha_state()
+
+ @callback
+ def _update_callback(self):
+ """When device updates, clear control result that overrides state."""
+ self.control_result = None
+ super()._update_callback()
diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json
new file mode 100644
index 00000000000..16467fa999c
--- /dev/null
+++ b/homeassistant/components/shelly/manifest.json
@@ -0,0 +1,9 @@
+{
+ "domain": "shelly",
+ "name": "Shelly",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/shelly",
+ "requirements": ["aioshelly==0.3.2"],
+ "zeroconf": [{ "type": "_http._tcp.local.", "name": "shelly*" }],
+ "codeowners": ["@balloob", "@bieniu"]
+}
diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py
new file mode 100644
index 00000000000..8a24a6380ed
--- /dev/null
+++ b/homeassistant/components/shelly/sensor.py
@@ -0,0 +1,127 @@
+"""Sensor for Shelly."""
+from homeassistant.components import sensor
+from homeassistant.const import (
+ CONCENTRATION_PARTS_PER_MILLION,
+ DEGREE,
+ ELECTRICAL_CURRENT_AMPERE,
+ ENERGY_KILO_WATT_HOUR,
+ PERCENTAGE,
+ POWER_WATT,
+)
+
+from .entity import (
+ BlockAttributeDescription,
+ ShellyBlockAttributeEntity,
+ async_setup_entry_attribute_entities,
+ temperature_unit,
+)
+
+SENSORS = {
+ ("device", "battery"): BlockAttributeDescription(
+ name="Battery", unit=PERCENTAGE, device_class=sensor.DEVICE_CLASS_BATTERY
+ ),
+ ("device", "deviceTemp"): BlockAttributeDescription(
+ name="Device Temperature",
+ unit=temperature_unit,
+ value=lambda value: round(value, 1),
+ device_class=sensor.DEVICE_CLASS_TEMPERATURE,
+ default_enabled=False,
+ ),
+ ("emeter", "current"): BlockAttributeDescription(
+ name="Current",
+ unit=ELECTRICAL_CURRENT_AMPERE,
+ value=lambda value: value,
+ device_class=sensor.DEVICE_CLASS_CURRENT,
+ ),
+ ("light", "power"): BlockAttributeDescription(
+ name="Power",
+ unit=POWER_WATT,
+ value=lambda value: round(value, 1),
+ device_class=sensor.DEVICE_CLASS_POWER,
+ default_enabled=False,
+ ),
+ ("device", "power"): BlockAttributeDescription(
+ name="Power",
+ unit=POWER_WATT,
+ value=lambda value: round(value, 1),
+ device_class=sensor.DEVICE_CLASS_POWER,
+ ),
+ ("emeter", "power"): BlockAttributeDescription(
+ name="Power",
+ unit=POWER_WATT,
+ value=lambda value: round(value, 1),
+ device_class=sensor.DEVICE_CLASS_POWER,
+ ),
+ ("relay", "power"): BlockAttributeDescription(
+ name="Power",
+ unit=POWER_WATT,
+ value=lambda value: round(value, 1),
+ device_class=sensor.DEVICE_CLASS_POWER,
+ ),
+ ("device", "energy"): BlockAttributeDescription(
+ name="Energy",
+ unit=ENERGY_KILO_WATT_HOUR,
+ value=lambda value: round(value / 60 / 1000, 2),
+ device_class=sensor.DEVICE_CLASS_ENERGY,
+ ),
+ ("emeter", "energy"): BlockAttributeDescription(
+ name="Energy",
+ unit=ENERGY_KILO_WATT_HOUR,
+ value=lambda value: round(value / 1000, 2),
+ device_class=sensor.DEVICE_CLASS_ENERGY,
+ ),
+ ("light", "energy"): BlockAttributeDescription(
+ name="Energy",
+ unit=ENERGY_KILO_WATT_HOUR,
+ value=lambda value: round(value / 60 / 1000, 2),
+ device_class=sensor.DEVICE_CLASS_ENERGY,
+ default_enabled=False,
+ ),
+ ("relay", "energy"): BlockAttributeDescription(
+ name="Energy",
+ unit=ENERGY_KILO_WATT_HOUR,
+ value=lambda value: round(value / 60 / 1000, 2),
+ device_class=sensor.DEVICE_CLASS_ENERGY,
+ ),
+ ("sensor", "concentration"): BlockAttributeDescription(
+ name="Gas Concentration",
+ unit=CONCENTRATION_PARTS_PER_MILLION,
+ value=lambda value: value,
+ # "sensorOp" is "normal" when the Shelly Gas is working properly and taking measurements.
+ available=lambda block: block.sensorOp == "normal",
+ ),
+ ("sensor", "extTemp"): BlockAttributeDescription(
+ name="Temperature",
+ unit=temperature_unit,
+ value=lambda value: round(value, 1),
+ device_class=sensor.DEVICE_CLASS_TEMPERATURE,
+ ),
+ ("sensor", "humidity"): BlockAttributeDescription(
+ name="Humidity",
+ unit=PERCENTAGE,
+ value=lambda value: round(value, 1),
+ device_class=sensor.DEVICE_CLASS_HUMIDITY,
+ ),
+ ("sensor", "luminosity"): BlockAttributeDescription(
+ name="Luminosity",
+ unit="lx",
+ device_class=sensor.DEVICE_CLASS_ILLUMINANCE,
+ ),
+ ("sensor", "tilt"): BlockAttributeDescription(name="tilt", unit=DEGREE),
+}
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up sensors for device."""
+ await async_setup_entry_attribute_entities(
+ hass, config_entry, async_add_entities, SENSORS, ShellySensor
+ )
+
+
+class ShellySensor(ShellyBlockAttributeEntity):
+ """Represent a shelly sensor."""
+
+ @property
+ def state(self):
+ """Return value of sensor."""
+ return self.attribute_value
diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json
new file mode 100644
index 00000000000..16dc331e452
--- /dev/null
+++ b/homeassistant/components/shelly/strings.json
@@ -0,0 +1,30 @@
+{
+ "title": "Shelly",
+ "config": {
+ "flow_title": "Shelly: {name}",
+ "step": {
+ "user": {
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ }
+ },
+ "credentials": {
+ "data": {
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ }
+ },
+ "confirm_discovery": {
+ "description": "Do you want to set up the {model} at {host}?"
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ }
+}
diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py
new file mode 100644
index 00000000000..0dcefb51cf9
--- /dev/null
+++ b/homeassistant/components/shelly/switch.py
@@ -0,0 +1,61 @@
+"""Switch for Shelly."""
+from aioshelly import Block
+
+from homeassistant.components.switch import SwitchEntity
+from homeassistant.core import callback
+
+from . import ShellyDeviceWrapper
+from .const import DOMAIN
+from .entity import ShellyBlockEntity
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up switches for device."""
+ wrapper = hass.data[DOMAIN][config_entry.entry_id]
+
+ # In roller mode the relay blocks exist but do not contain required info
+ if (
+ wrapper.model in ["SHSW-21", "SHSW-25"]
+ and wrapper.device.settings["mode"] != "relay"
+ ):
+ return
+
+ relay_blocks = [block for block in wrapper.device.blocks if block.type == "relay"]
+
+ if not relay_blocks:
+ return
+
+ async_add_entities(RelaySwitch(wrapper, block) for block in relay_blocks)
+
+
+class RelaySwitch(ShellyBlockEntity, SwitchEntity):
+ """Switch that controls a relay block on Shelly devices."""
+
+ def __init__(self, wrapper: ShellyDeviceWrapper, block: Block) -> None:
+ """Initialize relay switch."""
+ super().__init__(wrapper, block)
+ self.control_result = None
+
+ @property
+ def is_on(self) -> bool:
+ """If switch is on."""
+ if self.control_result:
+ return self.control_result["ison"]
+
+ return self.block.output
+
+ async def async_turn_on(self, **kwargs):
+ """Turn on relay."""
+ self.control_result = await self.block.set_state(turn="on")
+ self.async_write_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn off relay."""
+ self.control_result = await self.block.set_state(turn="off")
+ self.async_write_ha_state()
+
+ @callback
+ def _update_callback(self):
+ """When device updates, clear control result that overrides state."""
+ self.control_result = None
+ super()._update_callback()
diff --git a/homeassistant/components/shelly/translations/ca.json b/homeassistant/components/shelly/translations/ca.json
new file mode 100644
index 00000000000..7e8c3873c11
--- /dev/null
+++ b/homeassistant/components/shelly/translations/ca.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositiu ja est\u00e0 configurat"
+ },
+ "error": {
+ "auth_not_supported": "Actualment els dispositius Shelly amb autenticaci\u00f3 no son compatibles.",
+ "cannot_connect": "Ha fallat la connexi\u00f3",
+ "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida",
+ "unknown": "Error inesperat"
+ },
+ "flow_title": "Shelly: {name}",
+ "step": {
+ "confirm_discovery": {
+ "description": "Voleu configurar {model} a {host}?"
+ },
+ "credentials": {
+ "data": {
+ "password": "Contrasenya",
+ "username": "Nom d'usuari"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "Amfitri\u00f3"
+ }
+ }
+ }
+ },
+ "title": "Shelly"
+}
\ No newline at end of file
diff --git a/homeassistant/components/shelly/translations/cs.json b/homeassistant/components/shelly/translations/cs.json
new file mode 100644
index 00000000000..13ad3d8edbd
--- /dev/null
+++ b/homeassistant/components/shelly/translations/cs.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Za\u0159\u00edzen\u00ed je ji\u017e nakonfigurov\u00e1no"
+ },
+ "error": {
+ "auth_not_supported": "Za\u0159\u00edzen\u00ed Shelly vy\u017eaduj\u00edc\u00ed autentizaci nejsou aktu\u00e1ln\u011b podporov\u00e1na.",
+ "unknown": "Neo\u010dek\u00e1van\u00e1 chyba"
+ },
+ "flow_title": "Shelly: {name}",
+ "step": {
+ "confirm_discovery": {
+ "description": "Chcete nastavit {model} na adrese {host}?"
+ }
+ }
+ },
+ "title": "Shelly"
+}
\ No newline at end of file
diff --git a/homeassistant/components/shelly/translations/en.json b/homeassistant/components/shelly/translations/en.json
new file mode 100644
index 00000000000..546007af0d1
--- /dev/null
+++ b/homeassistant/components/shelly/translations/en.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Device is already configured"
+ },
+ "error": {
+ "auth_not_supported": "Shelly devices requiring authentication are not currently supported.",
+ "cannot_connect": "Failed to connect",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "flow_title": "Shelly: {name}",
+ "step": {
+ "confirm_discovery": {
+ "description": "Do you want to set up the {model} at {host}?"
+ },
+ "credentials": {
+ "data": {
+ "password": "Password",
+ "username": "Username"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ }
+ }
+ }
+ },
+ "title": "Shelly"
+}
\ No newline at end of file
diff --git a/homeassistant/components/shelly/translations/es.json b/homeassistant/components/shelly/translations/es.json
new file mode 100644
index 00000000000..bdc05b734ba
--- /dev/null
+++ b/homeassistant/components/shelly/translations/es.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositivo ya est\u00e1 configurado"
+ },
+ "error": {
+ "auth_not_supported": "Los dispositivos Shelly que requieren autenticaci\u00f3n no son compatibles actualmente.",
+ "cannot_connect": "No se pudo conectar",
+ "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida",
+ "unknown": "Error inesperado"
+ },
+ "flow_title": "Shelly: {name}",
+ "step": {
+ "confirm_discovery": {
+ "description": "\u00bfQuieres configurar el {model} en {host}?"
+ },
+ "credentials": {
+ "data": {
+ "password": "Contrase\u00f1a",
+ "username": "Usuario"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ }
+ }
+ }
+ },
+ "title": "Shelly"
+}
\ No newline at end of file
diff --git a/homeassistant/components/shelly/translations/fr.json b/homeassistant/components/shelly/translations/fr.json
new file mode 100644
index 00000000000..0f62629d21a
--- /dev/null
+++ b/homeassistant/components/shelly/translations/fr.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9"
+ },
+ "error": {
+ "auth_not_supported": "Les appareils Shelly n\u00e9cessitant une authentification ne sont actuellement pas pris en charge.",
+ "cannot_connect": "\u00c9chec de connexion",
+ "invalid_auth": "Authentification invalide",
+ "unknown": "Erreur inattendue"
+ },
+ "flow_title": "Shelly: {name}",
+ "step": {
+ "confirm_discovery": {
+ "description": "Voulez-vous configurer le {model} \u00e0 {host}?"
+ },
+ "credentials": {
+ "data": {
+ "password": "Mot de passe",
+ "username": "Nom d'utilisateur"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "H\u00f4te"
+ }
+ }
+ }
+ },
+ "title": "Shelly"
+}
\ No newline at end of file
diff --git a/homeassistant/components/shelly/translations/it.json b/homeassistant/components/shelly/translations/it.json
new file mode 100644
index 00000000000..595a57b0a00
--- /dev/null
+++ b/homeassistant/components/shelly/translations/it.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato"
+ },
+ "error": {
+ "auth_not_supported": "I dispositivi Shelly che richiedono l'autenticazione non sono attualmente supportati.",
+ "cannot_connect": "Impossibile connettersi",
+ "invalid_auth": "Autenticazione non valida",
+ "unknown": "Errore imprevisto"
+ },
+ "flow_title": "Shelly: {name}",
+ "step": {
+ "confirm_discovery": {
+ "description": "Vuoi impostare {model} su {host}?"
+ },
+ "credentials": {
+ "data": {
+ "password": "Password",
+ "username": "Nome utente"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ }
+ }
+ }
+ },
+ "title": "Shelly"
+}
\ No newline at end of file
diff --git a/homeassistant/components/shelly/translations/lb.json b/homeassistant/components/shelly/translations/lb.json
new file mode 100644
index 00000000000..b50f528c3c0
--- /dev/null
+++ b/homeassistant/components/shelly/translations/lb.json
@@ -0,0 +1,24 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Apparat ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "auth_not_supported": "Shelly Apparaten d\u00e9i eng Authentifikatioun ben\u00e9idegen ginn aktuell net \u00ebnnerst\u00ebtzt.",
+ "cannot_connect": "Feeler beim verbannen",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "flow_title": "Shelly: {name}",
+ "step": {
+ "confirm_discovery": {
+ "description": "Soll de {model} um {host} konfigur\u00e9iert ginn?"
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ }
+ }
+ }
+ },
+ "title": "Shelly"
+}
\ No newline at end of file
diff --git a/homeassistant/components/shelly/translations/no.json b/homeassistant/components/shelly/translations/no.json
new file mode 100644
index 00000000000..898c05e89aa
--- /dev/null
+++ b/homeassistant/components/shelly/translations/no.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Enheten er allerede konfigurert"
+ },
+ "error": {
+ "auth_not_supported": "Shelly-enheter som krever godkjenning st\u00f8ttes for \u00f8yeblikket ikke.",
+ "cannot_connect": "Tilkobling mislyktes.",
+ "invalid_auth": "Ugyldig godkjenning",
+ "unknown": "Uventet feil"
+ },
+ "flow_title": "Shelly: {name}",
+ "step": {
+ "confirm_discovery": {
+ "description": "Vil du konfigurere {model} p\u00e5 {host}?"
+ },
+ "credentials": {
+ "data": {
+ "password": "Passord",
+ "username": "Brukernavn"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "Vert"
+ }
+ }
+ }
+ },
+ "title": "Shelly"
+}
\ No newline at end of file
diff --git a/homeassistant/components/shelly/translations/pl.json b/homeassistant/components/shelly/translations/pl.json
new file mode 100644
index 00000000000..77a7f045671
--- /dev/null
+++ b/homeassistant/components/shelly/translations/pl.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane."
+ },
+ "error": {
+ "auth_not_supported": "Urz\u0105dzenia Shelly wymagaj\u0105ce uwierzytelnienia nie s\u0105 obecnie obs\u0142ugiwane.",
+ "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia.",
+ "invalid_auth": "Niepoprawne uwierzytelnienie.",
+ "unknown": "Nieoczekiwany b\u0142\u0105d."
+ },
+ "flow_title": "Shelly: {name}",
+ "step": {
+ "confirm_discovery": {
+ "description": "Czy chcesz skonfigurowa\u0107 {model} ({host})?"
+ },
+ "credentials": {
+ "data": {
+ "password": "Has\u0142o",
+ "username": "Nazwa u\u017cytkownika"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "Nazwa hosta lub adres IP"
+ }
+ }
+ }
+ },
+ "title": "Shelly"
+}
\ No newline at end of file
diff --git a/homeassistant/components/shelly/translations/pt.json b/homeassistant/components/shelly/translations/pt.json
new file mode 100644
index 00000000000..2252c7c1eb9
--- /dev/null
+++ b/homeassistant/components/shelly/translations/pt.json
@@ -0,0 +1,24 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado"
+ },
+ "error": {
+ "auth_not_supported": "Dispositivos Shelly que requerem autentica\u00e7\u00e3o n\u00e3o s\u00e3o atualmente suportados.",
+ "cannot_connect": "Falha na liga\u00e7\u00e3o",
+ "unknown": "Erro inesperado"
+ },
+ "flow_title": "Shelly: {name}",
+ "step": {
+ "confirm_discovery": {
+ "description": "Deseja configurar o {model} em {host} ?"
+ },
+ "user": {
+ "data": {
+ "host": "Servidor"
+ }
+ }
+ }
+ },
+ "title": "Shelly"
+}
\ No newline at end of file
diff --git a/homeassistant/components/shelly/translations/ru.json b/homeassistant/components/shelly/translations/ru.json
new file mode 100644
index 00000000000..570e6f8d7c7
--- /dev/null
+++ b/homeassistant/components/shelly/translations/ru.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant."
+ },
+ "error": {
+ "auth_not_supported": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Shelly, \u0442\u0440\u0435\u0431\u0443\u044e\u0449\u0438\u0435 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438, \u0432 \u043d\u0430\u0441\u0442\u043e\u044f\u0449\u0435\u0435 \u0432\u0440\u0435\u043c\u044f \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u044e\u0442\u0441\u044f.",
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.",
+ "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.",
+ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "flow_title": "Shelly: {name}",
+ "step": {
+ "confirm_discovery": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c {model} ({host}) ?"
+ },
+ "credentials": {
+ "data": {
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
+ "username": "\u041b\u043e\u0433\u0438\u043d"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "\u0425\u043e\u0441\u0442"
+ }
+ }
+ }
+ },
+ "title": "Shelly"
+}
\ No newline at end of file
diff --git a/homeassistant/components/shelly/translations/zh-Hant.json b/homeassistant/components/shelly/translations/zh-Hant.json
new file mode 100644
index 00000000000..e8fe857c476
--- /dev/null
+++ b/homeassistant/components/shelly/translations/zh-Hant.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "auth_not_supported": "\u76ee\u524d\u4e0d\u652f\u63f4 Shelly \u8a2d\u5099\u6240\u9700\u8a8d\u8b49\u3002",
+ "cannot_connect": "\u9023\u7dda\u5931\u6557",
+ "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "flow_title": "Shelly\uff1a{name}",
+ "step": {
+ "confirm_discovery": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a\u4f4d\u65bc {host} \u7684 {model}\uff1f"
+ },
+ "credentials": {
+ "data": {
+ "password": "\u5bc6\u78bc",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u7aef"
+ }
+ }
+ }
+ },
+ "title": "Shelly"
+}
\ No newline at end of file
diff --git a/homeassistant/components/sht31/sensor.py b/homeassistant/components/sht31/sensor.py
index 3a66b47688c..277039b3ba6 100644
--- a/homeassistant/components/sht31/sensor.py
+++ b/homeassistant/components/sht31/sensor.py
@@ -11,9 +11,9 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import (
CONF_MONITORED_CONDITIONS,
CONF_NAME,
+ PERCENTAGE,
PRECISION_TENTHS,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -141,7 +141,7 @@ class SHTSensorHumidity(SHTSensor):
@property
def unit_of_measurement(self):
"""Return the unit of measurement."""
- return UNIT_PERCENTAGE
+ return PERCENTAGE
def update(self):
"""Fetch humidity from the sensor."""
diff --git a/homeassistant/components/sighthound/manifest.json b/homeassistant/components/sighthound/manifest.json
index 1ad7effdf0e..a5c56b37778 100644
--- a/homeassistant/components/sighthound/manifest.json
+++ b/homeassistant/components/sighthound/manifest.json
@@ -2,6 +2,6 @@
"domain": "sighthound",
"name": "Sighthound",
"documentation": "https://www.home-assistant.io/integrations/sighthound",
- "requirements": ["pillow==7.1.2", "simplehound==0.3"],
+ "requirements": ["pillow==7.2.0", "simplehound==0.3"],
"codeowners": ["@robmarkcole"]
}
diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py
index 07b4942ad34..0a42d42be3a 100644
--- a/homeassistant/components/simplisafe/__init__.py
+++ b/homeassistant/components/simplisafe/__init__.py
@@ -238,7 +238,7 @@ async def async_setup_entry(hass, config_entry):
return False
except SimplipyError as err:
LOGGER.error("Config entry failed: %s", err)
- raise ConfigEntryNotReady
+ raise ConfigEntryNotReady from err
_async_save_refresh_token(hass, config_entry, api.refresh_token)
@@ -566,7 +566,7 @@ class SimpliSafe:
LOGGER.error("SimpliSafe error while updating: %s", result)
return
- if isinstance(result, Exception): # pylint: disable=broad-except
+ if isinstance(result, Exception):
LOGGER.error("Unknown error while updating: %s", result)
return
diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py
index d4a076860a1..ef980c25421 100644
--- a/homeassistant/components/simplisafe/config_flow.py
+++ b/homeassistant/components/simplisafe/config_flow.py
@@ -49,7 +49,10 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
websession = aiohttp_client.async_get_clientsession(self.hass)
return await API.login_via_credentials(
- self._username, self._password, client_id=client_id, session=websession,
+ self._username,
+ self._password,
+ client_id=client_id,
+ session=websession,
)
async def _async_login_during_step(self, *, step_id, form_schema):
@@ -69,7 +72,9 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
if errors:
return self.async_show_form(
- step_id=step_id, data_schema=form_schema, errors=errors,
+ step_id=step_id,
+ data_schema=form_schema,
+ errors=errors,
)
return await self.async_step_finish(
@@ -169,7 +174,8 @@ class SimpliSafeOptionsFlowHandler(config_entries.OptionsFlow):
data_schema=vol.Schema(
{
vol.Optional(
- CONF_CODE, default=self.config_entry.options.get(CONF_CODE),
+ CONF_CODE,
+ default=self.config_entry.options.get(CONF_CODE),
): str
}
),
diff --git a/homeassistant/components/simplisafe/translations/ca.json b/homeassistant/components/simplisafe/translations/ca.json
index 98727fe4c57..bdfd5d76198 100644
--- a/homeassistant/components/simplisafe/translations/ca.json
+++ b/homeassistant/components/simplisafe/translations/ca.json
@@ -12,7 +12,7 @@
},
"step": {
"mfa": {
- "description": "Consulta el correu i busca-hi un missatge amb un enlla\u00e7 de SimpliSafe. Despr\u00e9s de verificar l'enlla\u00e7, torneu aqu\u00ed per completar la instal\u00b7laci\u00f3 de la integraci\u00f3.",
+ "description": "Consulta el correu i busca-hi un missatge amb un enlla\u00e7 de SimpliSafe. Despr\u00e9s de verificar l'enlla\u00e7, torna aqu\u00ed per completar la instal\u00b7laci\u00f3 de la integraci\u00f3.",
"title": "Autenticaci\u00f3 multi-factor SimpliSafe"
},
"reauth_confirm": {
diff --git a/homeassistant/components/simplisafe/translations/es.json b/homeassistant/components/simplisafe/translations/es.json
index 64000e66d62..0c177fd02ff 100644
--- a/homeassistant/components/simplisafe/translations/es.json
+++ b/homeassistant/components/simplisafe/translations/es.json
@@ -2,7 +2,7 @@
"config": {
"abort": {
"already_configured": "Esta cuenta SimpliSafe ya est\u00e1 en uso.",
- "reauth_successful": "SimpliSafe se ha reautenticado correctamente."
+ "reauth_successful": "SimpliSafe se volvi\u00f3 a autenticar con \u00e9xito."
},
"error": {
"identifier_exists": "Cuenta ya registrada",
diff --git a/homeassistant/components/simplisafe/translations/pt.json b/homeassistant/components/simplisafe/translations/pt.json
index ab54288d91a..9c98515448b 100644
--- a/homeassistant/components/simplisafe/translations/pt.json
+++ b/homeassistant/components/simplisafe/translations/pt.json
@@ -2,9 +2,15 @@
"config": {
"error": {
"identifier_exists": "Conta j\u00e1 registada",
- "invalid_credentials": "Credenciais inv\u00e1lidas"
+ "invalid_credentials": "Credenciais inv\u00e1lidas",
+ "unknown": "Erro inesperado"
},
"step": {
+ "reauth_confirm": {
+ "data": {
+ "password": "Palavra-passe"
+ }
+ },
"user": {
"data": {
"password": "Palavra-passe",
diff --git a/homeassistant/components/simplisafe/translations/ru.json b/homeassistant/components/simplisafe/translations/ru.json
index cd539ae184c..03e4cfdcf24 100644
--- a/homeassistant/components/simplisafe/translations/ru.json
+++ b/homeassistant/components/simplisafe/translations/ru.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430.",
+ "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant.",
"reauth_successful": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e."
},
"error": {
diff --git a/homeassistant/components/sisyphus/light.py b/homeassistant/components/sisyphus/light.py
index ce0c37174ef..6d5fa2fc835 100644
--- a/homeassistant/components/sisyphus/light.py
+++ b/homeassistant/components/sisyphus/light.py
@@ -20,8 +20,8 @@ async def async_setup_platform(hass, config, add_entities, discovery_info=None):
try:
table_holder = hass.data[DATA_SISYPHUS][host]
table = await table_holder.get_table()
- except aiohttp.ClientError:
- raise PlatformNotReady()
+ except aiohttp.ClientError as err:
+ raise PlatformNotReady() from err
add_entities([SisyphusLight(table_holder.name, table)], update_before_add=True)
diff --git a/homeassistant/components/sisyphus/media_player.py b/homeassistant/components/sisyphus/media_player.py
index dbc350453b7..27d470ce885 100644
--- a/homeassistant/components/sisyphus/media_player.py
+++ b/homeassistant/components/sisyphus/media_player.py
@@ -50,8 +50,8 @@ async def async_setup_platform(hass, config, add_entities, discovery_info=None):
try:
table_holder = hass.data[DATA_SISYPHUS][host]
table = await table_holder.get_table()
- except aiohttp.ClientError:
- raise PlatformNotReady()
+ except aiohttp.ClientError as err:
+ raise PlatformNotReady() from err
add_entities([SisyphusPlayer(table_holder.name, host, table)], True)
diff --git a/homeassistant/components/sky_hub/device_tracker.py b/homeassistant/components/sky_hub/device_tracker.py
index b97331b6195..75241d78ed3 100644
--- a/homeassistant/components/sky_hub/device_tracker.py
+++ b/homeassistant/components/sky_hub/device_tracker.py
@@ -44,11 +44,25 @@ class SkyHubDeviceScanner(DeviceScanner):
async def async_scan_devices(self):
"""Scan for new devices and return a list with found device IDs."""
await self._async_update_info()
- return list(self.last_results)
+ return [device.mac for device in self.last_results]
async def async_get_device_name(self, device):
"""Return the name of the given device."""
- return self.last_results.get(device)
+ name = next(
+ (result.name for result in self.last_results if result.mac == device),
+ None,
+ )
+ return name
+
+ async def async_get_extra_attributes(self, device):
+ """Get extra attributes of a device."""
+ device = next(
+ (result for result in self.last_results if result.mac == device), None
+ )
+ if device is None:
+ return {}
+
+ return device.asdict()
async def _async_update_info(self):
"""Ensure the information from the Sky Hub is up to date."""
diff --git a/homeassistant/components/sky_hub/manifest.json b/homeassistant/components/sky_hub/manifest.json
index e663820a5ef..965c2af5159 100644
--- a/homeassistant/components/sky_hub/manifest.json
+++ b/homeassistant/components/sky_hub/manifest.json
@@ -2,6 +2,6 @@
"domain": "sky_hub",
"name": "Sky Hub",
"documentation": "https://www.home-assistant.io/integrations/sky_hub",
- "requirements": ["pyskyqhub==0.1.1"],
+ "requirements": ["pyskyqhub==0.1.3"],
"codeowners": ["@rogerselwyn"]
}
diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py
index d976e6e9408..9b759327dca 100644
--- a/homeassistant/components/skybeacon/sensor.py
+++ b/homeassistant/components/skybeacon/sensor.py
@@ -13,9 +13,9 @@ from homeassistant.const import (
CONF_MAC,
CONF_NAME,
EVENT_HOMEASSISTANT_STOP,
+ PERCENTAGE,
STATE_UNKNOWN,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -83,7 +83,7 @@ class SkybeaconHumid(Entity):
@property
def unit_of_measurement(self):
"""Return the unit the value is expressed in."""
- return UNIT_PERCENTAGE
+ return PERCENTAGE
@property
def device_state_attributes(self):
diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py
index d5c784398cf..8477b2fb501 100644
--- a/homeassistant/components/slack/notify.py
+++ b/homeassistant/components/slack/notify.py
@@ -27,10 +27,12 @@ _LOGGER = logging.getLogger(__name__)
ATTR_BLOCKS = "blocks"
ATTR_BLOCKS_TEMPLATE = "blocks_template"
ATTR_FILE = "file"
+ATTR_ICON = "icon"
ATTR_PASSWORD = "password"
ATTR_PATH = "path"
ATTR_URL = "url"
ATTR_USERNAME = "username"
+ATTR_USERNAME = "username"
CONF_DEFAULT_CHANNEL = "default_channel"
@@ -51,7 +53,12 @@ DATA_FILE_SCHEMA = vol.Schema(
)
DATA_TEXT_ONLY_SCHEMA = vol.Schema(
- {vol.Optional(ATTR_BLOCKS): list, vol.Optional(ATTR_BLOCKS_TEMPLATE): list}
+ {
+ vol.Optional(ATTR_USERNAME): cv.string,
+ vol.Optional(ATTR_ICON): cv.string,
+ vol.Optional(ATTR_BLOCKS): list,
+ vol.Optional(ATTR_BLOCKS_TEMPLATE): list,
+ }
)
DATA_SCHEMA = vol.All(
@@ -191,17 +198,27 @@ class SlackNotificationService(BaseNotificationService):
except ClientError as err:
_LOGGER.error("Error while uploading file message: %s", err)
- async def _async_send_text_only_message(self, targets, message, title, blocks):
+ async def _async_send_text_only_message(
+ self, targets, message, title, blocks, username, icon
+ ):
"""Send a text-only message."""
+ message_dict = {
+ "blocks": blocks,
+ "link_names": True,
+ "text": message,
+ "username": username,
+ }
+
+ if self._icon:
+ if self._icon.lower().startswith(("http://", "https://")):
+ icon_type = "url"
+ else:
+ icon_type = "emoji"
+
+ message_dict[f"icon_{icon_type}"] = icon
+
tasks = {
- target: self._client.chat_postMessage(
- channel=target,
- text=message,
- blocks=blocks,
- icon_emoji=self._icon,
- link_names=True,
- username=self._username,
- )
+ target: self._client.chat_postMessage(**message_dict, channel=target)
for target in targets
}
@@ -242,7 +259,12 @@ class SlackNotificationService(BaseNotificationService):
blocks = {}
return await self._async_send_text_only_message(
- targets, message, title, blocks
+ targets,
+ message,
+ title,
+ blocks,
+ username=data.get(ATTR_USERNAME, self._username),
+ icon=data.get(ATTR_ICON, self._icon),
)
# Message Type 2: A message that uploads a remote file
diff --git a/homeassistant/components/smappee/__init__.py b/homeassistant/components/smappee/__init__.py
index 381678f0e86..aed67c5c167 100644
--- a/homeassistant/components/smappee/__init__.py
+++ b/homeassistant/components/smappee/__init__.py
@@ -5,7 +5,12 @@ from pysmappee import Smappee
import voluptuous as vol
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_PLATFORM
+from homeassistant.const import (
+ CONF_CLIENT_ID,
+ CONF_CLIENT_SECRET,
+ CONF_IP_ADDRESS,
+ CONF_PLATFORM,
+)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from homeassistant.util import Throttle
@@ -13,7 +18,7 @@ from homeassistant.util import Throttle
from . import api, config_flow
from .const import (
AUTHORIZE_URL,
- BASE,
+ CONF_SERIALNUMBER,
DOMAIN,
MIN_TIME_BETWEEN_UPDATES,
SMAPPEE_PLATFORMS,
@@ -40,11 +45,14 @@ async def async_setup(hass: HomeAssistant, config: dict):
if DOMAIN not in config:
return True
+ client_id = config[DOMAIN][CONF_CLIENT_ID]
+ hass.data[DOMAIN][client_id] = {}
+
# decide platform
platform = "PRODUCTION"
- if config[DOMAIN][CONF_CLIENT_ID] == "homeassistant_f2":
+ if client_id == "homeassistant_f2":
platform = "ACCEPTANCE"
- elif config[DOMAIN][CONF_CLIENT_ID] == "homeassistant_f3":
+ elif client_id == "homeassistant_f3":
platform = "DEVELOPMENT"
hass.data[DOMAIN][CONF_PLATFORM] = platform
@@ -65,17 +73,24 @@ async def async_setup(hass: HomeAssistant, config: dict):
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
- """Set up Smappee from a config entry."""
- implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation(
- hass, entry
- )
+ """Set up Smappee from a zeroconf or config entry."""
+ if CONF_IP_ADDRESS in entry.data:
+ smappee_api = api.api.SmappeeLocalApi(ip=entry.data[CONF_IP_ADDRESS])
+ smappee = Smappee(api=smappee_api, serialnumber=entry.data[CONF_SERIALNUMBER])
+ await hass.async_add_executor_job(smappee.load_local_service_location)
+ else:
+ implementation = (
+ await config_entry_oauth2_flow.async_get_config_entry_implementation(
+ hass, entry
+ )
+ )
- smappee_api = api.ConfigEntrySmappeeApi(hass, entry, implementation)
+ smappee_api = api.ConfigEntrySmappeeApi(hass, entry, implementation)
- smappee = Smappee(smappee_api)
- await hass.async_add_executor_job(smappee.load_service_locations)
+ smappee = Smappee(api=smappee_api)
+ await hass.async_add_executor_job(smappee.load_service_locations)
- hass.data[DOMAIN][BASE] = SmappeeBase(hass, smappee)
+ hass.data[DOMAIN][entry.entry_id] = SmappeeBase(hass, smappee)
for component in SMAPPEE_PLATFORMS:
hass.async_create_task(
@@ -97,8 +112,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
)
if unload_ok:
- hass.data[DOMAIN].pop(BASE, None)
- hass.data[DOMAIN].pop(CONF_PLATFORM, None)
+ hass.data[DOMAIN].pop(entry.entry_id, None)
return unload_ok
diff --git a/homeassistant/components/smappee/binary_sensor.py b/homeassistant/components/smappee/binary_sensor.py
index 9b55f358ef3..ecc00f12370 100644
--- a/homeassistant/components/smappee/binary_sensor.py
+++ b/homeassistant/components/smappee/binary_sensor.py
@@ -3,7 +3,7 @@ import logging
from homeassistant.components.binary_sensor import BinarySensorEntity
-from .const import BASE, DOMAIN
+from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -13,7 +13,7 @@ PRESENCE_PREFIX = "Presence"
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Smappee binary sensor."""
- smappee_base = hass.data[DOMAIN][BASE]
+ smappee_base = hass.data[DOMAIN][config_entry.entry_id]
entities = []
for service_location in smappee_base.smappee.service_locations.values():
@@ -29,7 +29,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
)
)
- entities.append(SmappeePresence(smappee_base, service_location))
+ if not smappee_base.smappee.local_polling:
+ # presence value only available in cloud env
+ entities.append(SmappeePresence(smappee_base, service_location))
async_add_entities(entities, True)
@@ -59,7 +61,9 @@ class SmappeePresence(BinarySensorEntity):
return "presence"
@property
- def unique_id(self,):
+ def unique_id(
+ self,
+ ):
"""Return the unique ID for this binary sensor."""
return (
f"{self._service_location.device_serial_number}-"
@@ -140,7 +144,9 @@ class SmappeeAppliance(BinarySensorEntity):
return icon_mapping.get(self._appliance_type)
@property
- def unique_id(self,):
+ def unique_id(
+ self,
+ ):
"""Return the unique ID for this binary sensor."""
return (
f"{self._service_location.device_serial_number}-"
diff --git a/homeassistant/components/smappee/config_flow.py b/homeassistant/components/smappee/config_flow.py
index e07c3b65e37..26a47815f34 100644
--- a/homeassistant/components/smappee/config_flow.py
+++ b/homeassistant/components/smappee/config_flow.py
@@ -1,10 +1,21 @@
"""Config flow for Smappee."""
import logging
+import voluptuous as vol
+
from homeassistant import config_entries
+from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS
from homeassistant.helpers import config_entry_oauth2_flow
-from .const import DOMAIN # pylint: disable=unused-import
+from . import api
+from .const import (
+ CONF_HOSTNAME,
+ CONF_SERIALNUMBER,
+ DOMAIN,
+ ENV_CLOUD,
+ ENV_LOCAL,
+ SUPPORTED_LOCAL_DEVICES,
+)
_LOGGER = logging.getLogger(__name__)
@@ -12,12 +23,162 @@ _LOGGER = logging.getLogger(__name__)
class SmappeeFlowHandler(
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN
):
- """Config flow to handle Smappee OAuth2 authentication."""
+ """Config Smappee config flow."""
DOMAIN = DOMAIN
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+ async def async_oauth_create_entry(self, data):
+ """Create an entry for the flow."""
+
+ await self.async_set_unique_id(unique_id=f"{DOMAIN}Cloud")
+ return self.async_create_entry(title=f"{DOMAIN}Cloud", data=data)
+
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
+
+ async def async_step_zeroconf(self, discovery_info):
+ """Handle zeroconf discovery."""
+
+ if not discovery_info[CONF_HOSTNAME].startswith(SUPPORTED_LOCAL_DEVICES):
+ # We currently only support Energy and Solar models (legacy)
+ return self.async_abort(reason="invalid_mdns")
+
+ serial_number = (
+ discovery_info[CONF_HOSTNAME].replace(".local.", "").replace("Smappee", "")
+ )
+
+ # Check if already configured (local)
+ await self.async_set_unique_id(serial_number)
+ self._abort_if_unique_id_configured()
+
+ # Check if already configured (cloud)
+ if self.is_cloud_device_already_added():
+ return self.async_abort(reason="already_configured_device")
+
+ # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
+ self.context.update(
+ {
+ CONF_IP_ADDRESS: discovery_info["host"],
+ CONF_SERIALNUMBER: serial_number,
+ "title_placeholders": {"name": serial_number},
+ }
+ )
+
+ return await self.async_step_zeroconf_confirm()
+
+ async def async_step_zeroconf_confirm(self, user_input=None):
+ """Confirm zeroconf flow."""
+ errors = {}
+
+ # Check if already configured (cloud)
+ if self.is_cloud_device_already_added():
+ return self.async_abort(reason="already_configured_device")
+
+ if user_input is None:
+ # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
+ serialnumber = self.context.get(CONF_SERIALNUMBER)
+ return self.async_show_form(
+ step_id="zeroconf_confirm",
+ description_placeholders={"serialnumber": serialnumber},
+ errors=errors,
+ )
+
+ # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
+ ip_address = self.context.get(CONF_IP_ADDRESS)
+ serial_number = self.context.get(CONF_SERIALNUMBER)
+
+ # Attempt to make a connection to the local device
+ smappee_api = api.api.SmappeeLocalApi(ip=ip_address)
+ logon = await self.hass.async_add_executor_job(smappee_api.logon)
+ if logon is None:
+ return self.async_abort(reason="connection_error")
+
+ return self.async_create_entry(
+ title=f"{DOMAIN}{serial_number}",
+ data={CONF_IP_ADDRESS: ip_address, CONF_SERIALNUMBER: serial_number},
+ )
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow initiated by the user."""
+
+ # If there is a CLOUD entry already, abort a new LOCAL entry
+ if self.is_cloud_device_already_added():
+ return self.async_abort(reason="already_configured_device")
+
+ return await self.async_step_environment()
+
+ async def async_step_environment(self, user_input=None):
+ """Decide environment, cloud or local."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id="environment",
+ data_schema=vol.Schema(
+ {
+ vol.Required("environment", default=ENV_CLOUD): vol.In(
+ [ENV_CLOUD, ENV_LOCAL]
+ )
+ }
+ ),
+ errors={},
+ )
+
+ # Environment chosen, request additional host information for LOCAL or OAuth2 flow for CLOUD
+ # Ask for host detail
+ if user_input["environment"] == ENV_LOCAL:
+ return await self.async_step_local()
+
+ # Abort cloud option if a LOCAL entry has already been added
+ if user_input["environment"] == ENV_CLOUD and self._async_current_entries():
+ return self.async_abort(reason="already_configured_local_device")
+
+ return await self.async_step_pick_implementation()
+
+ async def async_step_local(self, user_input=None):
+ """Handle local flow."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id="local",
+ data_schema=vol.Schema({vol.Required(CONF_HOST): str}),
+ errors={},
+ )
+ # In a LOCAL setup we still need to resolve the host to serial number
+ ip_address = user_input["host"]
+ smappee_api = api.api.SmappeeLocalApi(ip=ip_address)
+ logon = await self.hass.async_add_executor_job(smappee_api.logon)
+ if logon is None:
+ return self.async_abort(reason="connection_error")
+
+ advanced_config = await self.hass.async_add_executor_job(
+ smappee_api.load_advanced_config
+ )
+ serial_number = None
+ for config_item in advanced_config:
+ if config_item["key"] == "mdnsHostName":
+ serial_number = config_item["value"]
+
+ if serial_number is None or not serial_number.startswith(
+ SUPPORTED_LOCAL_DEVICES
+ ):
+ # We currently only support Energy and Solar models (legacy)
+ return self.async_abort(reason="invalid_mdns")
+
+ serial_number = serial_number.replace("Smappee", "")
+
+ # Check if already configured (local)
+ await self.async_set_unique_id(serial_number, raise_on_progress=False)
+ self._abort_if_unique_id_configured()
+
+ return self.async_create_entry(
+ title=f"{DOMAIN}{serial_number}",
+ data={CONF_IP_ADDRESS: ip_address, CONF_SERIALNUMBER: serial_number},
+ )
+
+ def is_cloud_device_already_added(self):
+ """Check if a CLOUD device has already been added."""
+ for entry in self._async_current_entries():
+ if entry.unique_id is not None and entry.unique_id == f"{DOMAIN}Cloud":
+ return True
+ return False
diff --git a/homeassistant/components/smappee/const.py b/homeassistant/components/smappee/const.py
index 4bc370e9c09..2c69f1ccb96 100644
--- a/homeassistant/components/smappee/const.py
+++ b/homeassistant/components/smappee/const.py
@@ -5,11 +5,18 @@ from datetime import timedelta
DOMAIN = "smappee"
DATA_CLIENT = "smappee_data"
-BASE = "BASE"
+CONF_HOSTNAME = "hostname"
+CONF_SERIALNUMBER = "serialnumber"
+CONF_TITLE = "title"
+
+ENV_CLOUD = "cloud"
+ENV_LOCAL = "local"
SMAPPEE_PLATFORMS = ["binary_sensor", "sensor", "switch"]
-MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
+SUPPORTED_LOCAL_DEVICES = ("Smappee1", "Smappee2")
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=20)
AUTHORIZE_URL = {
"PRODUCTION": "https://app1pub.smappee.net/dev/v1/oauth2/authorize",
diff --git a/homeassistant/components/smappee/manifest.json b/homeassistant/components/smappee/manifest.json
index 4f9a33b74da..ba1005b87d4 100644
--- a/homeassistant/components/smappee/manifest.json
+++ b/homeassistant/components/smappee/manifest.json
@@ -5,9 +5,13 @@
"documentation": "https://www.home-assistant.io/integrations/smappee",
"dependencies": ["http"],
"requirements": [
- "pysmappee==0.1.5"
+ "pysmappee==0.2.13"
],
"codeowners": [
"@bsmappee"
+ ],
+ "zeroconf": [
+ {"type":"_ssh._tcp.local.", "name":"smappee1*"},
+ {"type":"_ssh._tcp.local.", "name":"smappee2*"}
]
}
diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py
index 1b0b5af8564..dd145c4cdce 100644
--- a/homeassistant/components/smappee/sensor.py
+++ b/homeassistant/components/smappee/sensor.py
@@ -4,7 +4,7 @@ import logging
from homeassistant.const import DEVICE_CLASS_POWER, ENERGY_WATT_HOUR, POWER_WATT, VOLT
from homeassistant.helpers.entity import Entity
-from .const import BASE, DOMAIN
+from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -15,6 +15,7 @@ TREND_SENSORS = {
POWER_WATT,
"total_power",
DEVICE_CLASS_POWER,
+ True, # both cloud and local
],
"alwayson": [
"Always on - Active power",
@@ -22,6 +23,7 @@ TREND_SENSORS = {
POWER_WATT,
"alwayson",
DEVICE_CLASS_POWER,
+ False, # cloud only
],
"power_today": [
"Total consumption - Today",
@@ -29,6 +31,7 @@ TREND_SENSORS = {
ENERGY_WATT_HOUR,
"power_today",
None,
+ False, # cloud only
],
"power_current_hour": [
"Total consumption - Current hour",
@@ -36,6 +39,7 @@ TREND_SENSORS = {
ENERGY_WATT_HOUR,
"power_current_hour",
None,
+ False, # cloud only
],
"power_last_5_minutes": [
"Total consumption - Last 5 minutes",
@@ -43,6 +47,7 @@ TREND_SENSORS = {
ENERGY_WATT_HOUR,
"power_last_5_minutes",
None,
+ False, # cloud only
],
"alwayson_today": [
"Always on - Today",
@@ -50,6 +55,7 @@ TREND_SENSORS = {
ENERGY_WATT_HOUR,
"alwayson_today",
None,
+ False, # cloud only
],
}
REACTIVE_SENSORS = {
@@ -68,6 +74,7 @@ SOLAR_SENSORS = {
POWER_WATT,
"solar_power",
DEVICE_CLASS_POWER,
+ True, # both cloud and local
],
"solar_today": [
"Total production - Today",
@@ -75,6 +82,7 @@ SOLAR_SENSORS = {
ENERGY_WATT_HOUR,
"solar_today",
None,
+ False, # cloud only
],
"solar_current_hour": [
"Total production - Current hour",
@@ -82,6 +90,7 @@ SOLAR_SENSORS = {
ENERGY_WATT_HOUR,
"solar_current_hour",
None,
+ False, # cloud only
],
}
VOLTAGE_SENSORS = {
@@ -138,20 +147,22 @@ VOLTAGE_SENSORS = {
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Smappee sensor."""
- smappee_base = hass.data[DOMAIN][BASE]
+ smappee_base = hass.data[DOMAIN][config_entry.entry_id]
entities = []
for service_location in smappee_base.smappee.service_locations.values():
# Add all basic sensors (realtime values and aggregators)
+ # Some are available in local only env
for sensor in TREND_SENSORS:
- entities.append(
- SmappeeSensor(
- smappee_base=smappee_base,
- service_location=service_location,
- sensor=sensor,
- attributes=TREND_SENSORS[sensor],
+ if not service_location.local_polling or TREND_SENSORS[sensor][5]:
+ entities.append(
+ SmappeeSensor(
+ smappee_base=smappee_base,
+ service_location=service_location,
+ sensor=sensor,
+ attributes=TREND_SENSORS[sensor],
+ )
)
- )
if service_location.has_reactive_value:
for reactive_sensor in REACTIVE_SENSORS:
@@ -164,17 +175,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
)
)
- # Add solar sensors
+ # Add solar sensors (some are available in local only env)
if service_location.has_solar_production:
for sensor in SOLAR_SENSORS:
- entities.append(
- SmappeeSensor(
- smappee_base=smappee_base,
- service_location=service_location,
- sensor=sensor,
- attributes=SOLAR_SENSORS[sensor],
+ if not service_location.local_polling or SOLAR_SENSORS[sensor][5]:
+ entities.append(
+ SmappeeSensor(
+ smappee_base=smappee_base,
+ service_location=service_location,
+ sensor=sensor,
+ attributes=SOLAR_SENSORS[sensor],
+ )
)
- )
# Add all CT measurements
for measurement_id, measurement in service_location.measurements.items():
@@ -279,7 +291,9 @@ class SmappeeSensor(Entity):
return self._unit_of_measurement
@property
- def unique_id(self,):
+ def unique_id(
+ self,
+ ):
"""Return the unique ID for this sensor."""
if self._sensor in ["load", "sensor"]:
return (
diff --git a/homeassistant/components/smappee/strings.json b/homeassistant/components/smappee/strings.json
index 6b86bd042ac..1bec8fda0cc 100644
--- a/homeassistant/components/smappee/strings.json
+++ b/homeassistant/components/smappee/strings.json
@@ -1,13 +1,35 @@
{
- "config": {
- "step": {
- "pick_implementation": {
- "title": "Pick Authentication Method"
- }
- },
- "abort": {
- "authorize_url_timeout": "Timeout generating authorize url.",
- "missing_configuration": "The component is not configured. Please follow the documentation."
+ "config": {
+ "flow_title": "Smappee: {name}",
+ "step": {
+ "environment": {
+ "description": "Set up your Smappee to integrate with Home Assistant.",
+ "data": {
+ "environment": "Environment"
}
+ },
+ "local": {
+ "description": "Enter the host to initiate the Smappee local integration",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ }
+ },
+ "zeroconf_confirm": {
+ "description": "Do you want to add the Smappee device with serialnumber `{serialnumber}` to Home Assistant?",
+ "title": "Discovered Smappee device"
+ },
+ "pick_implementation": {
+ "title": "Pick Authentication Method"
+ }
+ },
+ "abort": {
+ "already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]",
+ "already_configured_local_device": "Local device(s) is already configured. Please remove those first before configuring a cloud device.",
+ "authorize_url_timeout": "Timeout generating authorize url.",
+ "connection_error": "Failed to connect to Smappee device.",
+ "missing_configuration": "The component is not configured. Please follow the documentation.",
+ "invalid_mdns": "Unsupported device for the Smappee integration.",
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]"
}
+ }
}
diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py
index 7d6a7f2405f..bbcacc7d541 100644
--- a/homeassistant/components/smappee/switch.py
+++ b/homeassistant/components/smappee/switch.py
@@ -1,21 +1,19 @@
"""Support for interacting with Smappee Comport Plugs, Switches and Output Modules."""
-from datetime import timedelta
import logging
from homeassistant.components.switch import SwitchEntity
-from .const import BASE, DOMAIN
+from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
SWITCH_PREFIX = "Switch"
ICON = "mdi:toggle-switch"
-SCAN_INTERVAL = timedelta(seconds=5)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Smappee Comfort Plugs."""
- smappee_base = hass.data[DOMAIN][BASE]
+ smappee_base = hass.data[DOMAIN][config_entry.entry_id]
entities = []
for service_location in smappee_base.smappee.service_locations.values():
@@ -146,7 +144,9 @@ class SmappeeActuator(SwitchEntity):
return None
@property
- def unique_id(self,):
+ def unique_id(
+ self,
+ ):
"""Return the unique ID for this switch."""
if self._actuator_type == "INFINITY_OUTPUT_MODULE":
return (
diff --git a/homeassistant/components/smappee/translations/ca.json b/homeassistant/components/smappee/translations/ca.json
index b34b7b86d6f..54a781c1f4a 100644
--- a/homeassistant/components/smappee/translations/ca.json
+++ b/homeassistant/components/smappee/translations/ca.json
@@ -1,13 +1,33 @@
{
"config": {
"abort": {
+ "already_configured_device": "El dispositiu ja est\u00e0 configurat",
+ "already_configured_local_device": "Dispositiu(s) local ja configurat. Elimina'ls primer abans de configurar un dispositiu al n\u00favol.",
"authorize_url_timeout": "Temps d'espera esgotat generant l'URL d'autoritzaci\u00f3.",
- "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3.",
- "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3."
+ "connection_error": "No s'ha pogut connectar amb el Smappee.",
+ "invalid_mdns": "Dispositiu no compatible amb la integraci\u00f3 de Smappee.",
+ "missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3."
},
+ "flow_title": "Smappee: {name}",
"step": {
+ "environment": {
+ "data": {
+ "environment": "Entorn"
+ },
+ "description": "Configura el teu Smappee per a integrar-lo amb Home Assistant."
+ },
+ "local": {
+ "data": {
+ "host": "Amfitri\u00f3"
+ },
+ "description": "Introdueix l'amfitri\u00f3 per iniciar la integraci\u00f3 local de Smappee"
+ },
"pick_implementation": {
"title": "Selecciona un m\u00e8tode d'autenticaci\u00f3"
+ },
+ "zeroconf_confirm": {
+ "description": "Vols afegir el dispositiu Smappee amb n\u00famero de s\u00e8rie `{serialnumber}` a Home Assistant?",
+ "title": "Dispositiu Smappee descobert"
}
}
}
diff --git a/homeassistant/components/smappee/translations/cs.json b/homeassistant/components/smappee/translations/cs.json
deleted file mode 100644
index 36ec311b7c3..00000000000
--- a/homeassistant/components/smappee/translations/cs.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "config": {
- "abort": {
- "single_instance_allowed": "Ji\u017e nakonfigurov\u00e1no. Je mo\u017en\u00e1 pouze jedin\u00e1 konfigurace."
- }
- }
-}
\ No newline at end of file
diff --git a/homeassistant/components/smappee/translations/en.json b/homeassistant/components/smappee/translations/en.json
index fee105bf825..57f1498d5fa 100644
--- a/homeassistant/components/smappee/translations/en.json
+++ b/homeassistant/components/smappee/translations/en.json
@@ -1,13 +1,33 @@
{
"config": {
"abort": {
+ "already_configured_device": "Device is already configured",
+ "already_configured_local_device": "Local device(s) is already configured. Please remove those first before configuring a cloud device.",
"authorize_url_timeout": "Timeout generating authorize url.",
- "missing_configuration": "The component is not configured. Please follow the documentation.",
- "single_instance_allowed": "Already configured. Only a single configuration possible."
+ "connection_error": "Failed to connect to Smappee device.",
+ "invalid_mdns": "Unsupported device for the Smappee integration.",
+ "missing_configuration": "The component is not configured. Please follow the documentation."
},
+ "flow_title": "Smappee: {name}",
"step": {
+ "environment": {
+ "data": {
+ "environment": "Environment"
+ },
+ "description": "Set up your Smappee to integrate with Home Assistant."
+ },
+ "local": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Enter the host to initiate the Smappee local integration"
+ },
"pick_implementation": {
"title": "Pick Authentication Method"
+ },
+ "zeroconf_confirm": {
+ "description": "Do you want to add the Smappee device with serialnumber `{serialnumber}` to Home Assistant?",
+ "title": "Discovered Smappee device"
}
}
}
diff --git a/homeassistant/components/smappee/translations/es.json b/homeassistant/components/smappee/translations/es.json
index f9b65b5339a..543c988b356 100644
--- a/homeassistant/components/smappee/translations/es.json
+++ b/homeassistant/components/smappee/translations/es.json
@@ -1,13 +1,33 @@
{
"config": {
"abort": {
+ "already_configured_device": "El dispositivo ya est\u00e1 configurado",
+ "already_configured_local_device": "Los dispositivos locales ya est\u00e1n configurados. Elim\u00ednelos primero antes de configurar un dispositivo en la nube.",
"authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n.",
- "missing_configuration": "El componente no est\u00e1 configurado. Por favor, siga la documentaci\u00f3n.",
- "single_instance_allowed": "Ya est\u00e1 configurado. S\u00f3lo es posible una \u00fanica configuraci\u00f3n."
+ "connection_error": "No se pudo conectar al dispositivo Smappee.",
+ "invalid_mdns": "Dispositivo no compatible para la integraci\u00f3n de Smappee.",
+ "missing_configuration": "El componente no est\u00e1 configurado. Por favor, siga la documentaci\u00f3n."
},
+ "flow_title": "Smappee: {name}",
"step": {
+ "environment": {
+ "data": {
+ "environment": "Ambiente"
+ },
+ "description": "Configura tu Smappee para que se integre con Home Assistant."
+ },
+ "local": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Ingrese el host para iniciar la integraci\u00f3n local de Smappee"
+ },
"pick_implementation": {
"title": "Elija el m\u00e9todo de autenticaci\u00f3n"
+ },
+ "zeroconf_confirm": {
+ "description": "\u00bfDesea agregar el dispositivo Smappee con el n\u00famero de serie `{serialnumber}` a Home Assistant?",
+ "title": "Dispositivo Smappee descubierto"
}
}
}
diff --git a/homeassistant/components/smappee/translations/fr.json b/homeassistant/components/smappee/translations/fr.json
index 1f90db7f30d..18985ce9f34 100644
--- a/homeassistant/components/smappee/translations/fr.json
+++ b/homeassistant/components/smappee/translations/fr.json
@@ -2,10 +2,14 @@
"config": {
"abort": {
"authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.",
- "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation.",
- "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible."
+ "missing_configuration": "Le composant n'est pas configur\u00e9. Veuillez suivre la documentation."
},
"step": {
+ "environment": {
+ "data": {
+ "environment": "Environnement"
+ }
+ },
"pick_implementation": {
"title": "Choisissez la m\u00e9thode d'authentification"
}
diff --git a/homeassistant/components/smappee/translations/it.json b/homeassistant/components/smappee/translations/it.json
index 095557aeb5d..66994517c2f 100644
--- a/homeassistant/components/smappee/translations/it.json
+++ b/homeassistant/components/smappee/translations/it.json
@@ -1,13 +1,33 @@
{
"config": {
"abort": {
+ "already_configured_device": "Il dispositivo \u00e8 gi\u00e0 configurato",
+ "already_configured_local_device": "L'apparecchio o gli apparecchi locali sono gi\u00e0 configurati. Si prega di rimuoverli prima di configurare un dispositivo cloud.",
"authorize_url_timeout": "Tempo scaduto nel generare l'URL di autorizzazione.",
- "missing_configuration": "Il componente non \u00e8 configurato. Si prega di seguire la documentazione.",
- "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione."
+ "connection_error": "Impossibile connettersi al dispositivo Smappee.",
+ "invalid_mdns": "Dispositivo non supportato per l'integrazione Smappee.",
+ "missing_configuration": "Il componente non \u00e8 configurato. Si prega di seguire la documentazione."
},
+ "flow_title": "Smappee: {name}",
"step": {
+ "environment": {
+ "data": {
+ "environment": "Ambiente"
+ },
+ "description": "Configura il tuo Smappee per integrarlo con Home Assistant."
+ },
+ "local": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Immettere l'host per avviare l'integrazione locale di Smappee"
+ },
"pick_implementation": {
"title": "Scegliere il metodo di autenticazione"
+ },
+ "zeroconf_confirm": {
+ "description": "Vuoi aggiungere il dispositivo Smappee con numero di serie `{serialnumber}` a Home Assistant?",
+ "title": "Dispositivo Smappee rilevato"
}
}
}
diff --git a/homeassistant/components/smappee/translations/ko.json b/homeassistant/components/smappee/translations/ko.json
index 6e6331f30f1..05557a6046d 100644
--- a/homeassistant/components/smappee/translations/ko.json
+++ b/homeassistant/components/smappee/translations/ko.json
@@ -2,8 +2,7 @@
"config": {
"abort": {
"authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
- "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.",
- "single_instance_allowed": "\uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud558\ub098\uc758 \uad6c\uc131\ub9cc \uac00\ub2a5\ud569\ub2c8\ub2e4."
+ "missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694."
},
"step": {
"pick_implementation": {
diff --git a/homeassistant/components/smappee/translations/lb.json b/homeassistant/components/smappee/translations/lb.json
index a95770a058f..7f514644918 100644
--- a/homeassistant/components/smappee/translations/lb.json
+++ b/homeassistant/components/smappee/translations/lb.json
@@ -1,13 +1,33 @@
{
"config": {
"abort": {
+ "already_configured_device": "Apparat ass scho konfigur\u00e9iert",
+ "already_configured_local_device": "Lokalen Apparat ass scho konfigur\u00e9iert. L\u00e4sch d\u00e9i iers de ee Cloud Apparat dob\u00e4isetz.",
"authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.",
- "missing_configuration": "Komponent ass nach net konfigur\u00e9iert. Follegt w.e.g der Dokumentatioun.",
- "single_instance_allowed": "Scho konfigur\u00e9iert. N\u00ebmmen eng Konfiguratioun ass m\u00e9iglech."
+ "connection_error": "Feeler beim verbannen mam Smappee Apparat.",
+ "invalid_mdns": "Net \u00ebnnerst\u00ebtzten Apparat fir Smappee Integratioun.",
+ "missing_configuration": "Komponent ass nach net konfigur\u00e9iert. Follegt w.e.g der Dokumentatioun."
},
+ "flow_title": "Smappee: {name}",
"step": {
+ "environment": {
+ "data": {
+ "environment": "\u00cbmwelt"
+ },
+ "description": "Smappee ariichten fir d'Integratioun mam Home Assistant."
+ },
+ "local": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "G\u00ebff den Host un fir Smappee lokal Integratioun z'initialis\u00e9ieren"
+ },
"pick_implementation": {
"title": "Wiel Authentifikatiouns Method aus"
+ },
+ "zeroconf_confirm": {
+ "description": "Soll den Smappee Apparat mat der Seriennummer `{serialnumber}` am Home Assistant dob\u00e4igesat ginn?",
+ "title": "Entdeckten Smappee Apparat"
}
}
}
diff --git a/homeassistant/components/smappee/translations/no.json b/homeassistant/components/smappee/translations/no.json
index a6ef71b7448..76e96614aa6 100644
--- a/homeassistant/components/smappee/translations/no.json
+++ b/homeassistant/components/smappee/translations/no.json
@@ -1,13 +1,33 @@
{
"config": {
"abort": {
+ "already_configured_device": "Enheten er allerede konfigurert",
+ "already_configured_local_device": "Lokale enheter er allerede konfigurert. Fjern de f\u00f8rst f\u00f8r du konfigurerer en skyenhet.",
"authorize_url_timeout": "Tidsavbrudd ved generering av autoriseringsadresse.",
- "missing_configuration": "Komponenten er ikke konfigurert. Vennligst f\u00f8lg dokumentasjonen.",
- "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig."
+ "connection_error": "Kunne ikke koble til Smappee-enheten.",
+ "invalid_mdns": "Ikke-st\u00f8ttet enhet for Smappee-integrasjonen.",
+ "missing_configuration": "Komponenten er ikke konfigurert. Vennligst f\u00f8lg dokumentasjonen."
},
+ "flow_title": "Smappee: {navn}",
"step": {
+ "environment": {
+ "data": {
+ "environment": "Milj\u00f8"
+ },
+ "description": "Konfigurer Smappee til \u00e5 integreres med Home Assistant."
+ },
+ "local": {
+ "data": {
+ "host": "Vert"
+ },
+ "description": "Angi verten for \u00e5 starte den lokale Smappee-integreringen"
+ },
"pick_implementation": {
"title": "Velg godkjenningsmetode"
+ },
+ "zeroconf_confirm": {
+ "description": "Vil du legge Smappee-enheten med serienummer ` {serialnumber} ` til Home Assistant?",
+ "title": "Oppdaget Smappee-enhet"
}
}
}
diff --git a/homeassistant/components/smappee/translations/pl.json b/homeassistant/components/smappee/translations/pl.json
index 8f9f0d9803d..da5a481c22b 100644
--- a/homeassistant/components/smappee/translations/pl.json
+++ b/homeassistant/components/smappee/translations/pl.json
@@ -2,8 +2,7 @@
"config": {
"abort": {
"authorize_url_timeout": "Przekroczono limit czasu generowania URL autoryzacji.",
- "missing_configuration": "Komponent nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105.",
- "single_instance_allowed": "Ju\u017c skonfigurowano. Mo\u017cliwa jest tylko jedna konfiguracja."
+ "missing_configuration": "Komponent nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105."
},
"step": {
"pick_implementation": {
diff --git a/homeassistant/components/smappee/translations/pt-BR.json b/homeassistant/components/smappee/translations/pt-BR.json
new file mode 100644
index 00000000000..cbaae7d2d6a
--- /dev/null
+++ b/homeassistant/components/smappee/translations/pt-BR.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured_device": "Dispositivo j\u00e1 configurado"
+ },
+ "step": {
+ "local": {
+ "data": {
+ "host": "Host"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/smappee/translations/pt.json b/homeassistant/components/smappee/translations/pt.json
new file mode 100644
index 00000000000..aba871acf6b
--- /dev/null
+++ b/homeassistant/components/smappee/translations/pt.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured_device": "O dispositivo j\u00e1 est\u00e1 configurado"
+ },
+ "step": {
+ "local": {
+ "data": {
+ "host": "Servidor"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/smappee/translations/ru.json b/homeassistant/components/smappee/translations/ru.json
index abed7656da7..b3650a483f6 100644
--- a/homeassistant/components/smappee/translations/ru.json
+++ b/homeassistant/components/smappee/translations/ru.json
@@ -1,13 +1,33 @@
{
"config": {
"abort": {
+ "already_configured_device": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.",
+ "already_configured_local_device": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430 \u0434\u043b\u044f \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432. \u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0438\u0445 \u043f\u0435\u0440\u0435\u0434 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439 \u043e\u0431\u043b\u0430\u0447\u043d\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430.",
"authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.",
- "missing_configuration": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438.",
- "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e."
+ "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443.",
+ "invalid_mdns": "\u041d\u0435\u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u043c\u043e\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e.",
+ "missing_configuration": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438."
},
+ "flow_title": "Smappee: {name}",
"step": {
+ "environment": {
+ "data": {
+ "environment": "\u041e\u043a\u0440\u0443\u0436\u0435\u043d\u0438\u0435"
+ },
+ "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Smappee."
+ },
+ "local": {
+ "data": {
+ "host": "\u0425\u043e\u0441\u0442"
+ },
+ "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430, \u0447\u0442\u043e\u0431\u044b \u043d\u0430\u0447\u0430\u0442\u044c \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044e \u0441 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Smappee"
+ },
"pick_implementation": {
"title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u043f\u043e\u0441\u043e\u0431 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438"
+ },
+ "zeroconf_confirm": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Smappee \u0441 \u0441\u0435\u0440\u0438\u0439\u043d\u044b\u043c \u043d\u043e\u043c\u0435\u0440\u043e\u043c `{serialnumber}`?",
+ "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Smappee"
}
}
}
diff --git a/homeassistant/components/smappee/translations/zh-Hant.json b/homeassistant/components/smappee/translations/zh-Hant.json
index 3ff9da90cfb..7636ea5b34b 100644
--- a/homeassistant/components/smappee/translations/zh-Hant.json
+++ b/homeassistant/components/smappee/translations/zh-Hant.json
@@ -1,13 +1,33 @@
{
"config": {
"abort": {
+ "already_configured_device": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "already_configured_local_device": "\u672c\u5730\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\uff0c\u8acb\u5148\u9032\u884c\u79fb\u9664\u5f8c\u518d\u8a2d\u5b9a\u96f2\u7aef\u8a2d\u5099\u3002",
"authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642\u3002",
- "missing_configuration": "\u5143\u4ef6\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002",
- "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u8a2d\u5099\u3002"
+ "connection_error": "Smappee \u8a2d\u5099\u9023\u7dda\u5931\u6557\u3002",
+ "invalid_mdns": "Smappee \u6574\u5408\u4e0d\u652f\u63f4\u7684\u8a2d\u5099\u3002",
+ "missing_configuration": "\u5143\u4ef6\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002"
},
+ "flow_title": "Smappee\uff1a{name}",
"step": {
+ "environment": {
+ "data": {
+ "environment": "\u74b0\u5883"
+ },
+ "description": "\u8a2d\u5b9a Smappee \u4ee5\u6574\u5408\u81f3 Home Assistant\u3002"
+ },
+ "local": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u7aef"
+ },
+ "description": "\u8f38\u5165 Smappee \u672c\u5730\u6574\u5408\u4e3b\u6a5f\u7aef\u4ee5\u958b\u59cb"
+ },
"pick_implementation": {
"title": "\u9078\u64c7\u9a57\u8b49\u6a21\u5f0f"
+ },
+ "zeroconf_confirm": {
+ "description": "\u662f\u5426\u8981\u5c07\u5e8f\u865f\u70ba `{serial_number}` \u4e4b Smappee \u8a2d\u5099\u65b0\u589e\u81f3 Home Assistant\uff1f",
+ "title": "\u81ea\u52d5\u63a2\u7d22\u5230 Smappee \u8a2d\u5099"
}
}
}
diff --git a/homeassistant/components/smart_meter_texas/__init__.py b/homeassistant/components/smart_meter_texas/__init__.py
new file mode 100644
index 00000000000..7b1c6cfa9b7
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/__init__.py
@@ -0,0 +1,133 @@
+"""The Smart Meter Texas integration."""
+import asyncio
+import logging
+
+from smart_meter_texas import Account, Client
+from smart_meter_texas.exceptions import (
+ SmartMeterTexasAPIError,
+ SmartMeterTexasAuthError,
+)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.update_coordinator import (
+ DataUpdateCoordinator,
+ Debouncer,
+ UpdateFailed,
+)
+
+from .const import (
+ DATA_COORDINATOR,
+ DATA_SMART_METER,
+ DEBOUNCE_COOLDOWN,
+ DOMAIN,
+ SCAN_INTERVAL,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORMS = ["sensor"]
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+ """Set up the Smart Meter Texas component."""
+ hass.data.setdefault(DOMAIN, {})
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up Smart Meter Texas from a config entry."""
+
+ username = entry.data[CONF_USERNAME]
+ password = entry.data[CONF_PASSWORD]
+
+ account = Account(username, password)
+ smart_meter_texas_data = SmartMeterTexasData(hass, entry, account)
+ try:
+ await smart_meter_texas_data.client.authenticate()
+ except SmartMeterTexasAuthError:
+ _LOGGER.error("Username or password was not accepted")
+ return False
+ except asyncio.TimeoutError as error:
+ raise ConfigEntryNotReady from error
+
+ await smart_meter_texas_data.setup()
+
+ async def async_update_data():
+ _LOGGER.debug("Fetching latest data")
+ await smart_meter_texas_data.read_meters()
+ return smart_meter_texas_data
+
+ # Use a DataUpdateCoordinator to manage the updates. This is due to the
+ # Smart Meter Texas API which takes around 30 seconds to read a meter.
+ # This avoids Home Assistant from complaining about the component taking
+ # too long to update.
+ coordinator = DataUpdateCoordinator(
+ hass,
+ _LOGGER,
+ name="Smart Meter Texas",
+ update_method=async_update_data,
+ update_interval=SCAN_INTERVAL,
+ request_refresh_debouncer=Debouncer(
+ hass, _LOGGER, cooldown=DEBOUNCE_COOLDOWN, immediate=True
+ ),
+ )
+
+ hass.data[DOMAIN][entry.entry_id] = {
+ DATA_COORDINATOR: coordinator,
+ DATA_SMART_METER: smart_meter_texas_data,
+ }
+
+ asyncio.create_task(coordinator.async_refresh())
+
+ for component in PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component)
+ )
+
+ return True
+
+
+class SmartMeterTexasData:
+ """Manages coordinatation of API data updates."""
+
+ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, account: Account):
+ """Initialize the data coordintator."""
+ self._entry = entry
+ self.account = account
+ websession = aiohttp_client.async_get_clientsession(hass)
+ self.client = Client(websession, account)
+ self.meters = []
+
+ async def setup(self):
+ """Fetch all of the user's meters."""
+ self.meters = await self.account.fetch_meters(self.client)
+ _LOGGER.debug("Discovered %s meter(s)", len(self.meters))
+
+ async def read_meters(self):
+ """Read each meter."""
+ for meter in self.meters:
+ try:
+ await meter.read_meter(self.client)
+ except (SmartMeterTexasAPIError, SmartMeterTexasAuthError) as error:
+ raise UpdateFailed(error) from error
+ return self.meters
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """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
diff --git a/homeassistant/components/smart_meter_texas/config_flow.py b/homeassistant/components/smart_meter_texas/config_flow.py
new file mode 100644
index 00000000000..211957dac9d
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/config_flow.py
@@ -0,0 +1,85 @@
+"""Config flow for Smart Meter Texas integration."""
+import asyncio
+import logging
+
+from aiohttp import ClientError
+from smart_meter_texas import Account, Client
+from smart_meter_texas.exceptions import (
+ SmartMeterTexasAPIError,
+ SmartMeterTexasAuthError,
+)
+import voluptuous as vol
+
+from homeassistant import config_entries, core, exceptions
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.helpers import aiohttp_client
+
+from .const import DOMAIN # pylint:disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_SCHEMA = vol.Schema(
+ {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
+)
+
+
+async def validate_input(hass: core.HomeAssistant, data):
+ """Validate the user input allows us to connect.
+
+ Data has the keys from DATA_SCHEMA with values provided by the user.
+ """
+
+ client_session = aiohttp_client.async_get_clientsession(hass)
+ account = Account(data["username"], data["password"])
+ client = Client(client_session, account)
+
+ try:
+ await client.authenticate()
+ except (asyncio.TimeoutError, ClientError, SmartMeterTexasAPIError) as error:
+ raise CannotConnect from error
+ except SmartMeterTexasAuthError as error:
+ raise InvalidAuth(error) from error
+
+ # Return info that you want to store in the config entry.
+ return {"title": account.username}
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Smart Meter Texas."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ async def async_step_user(self, user_input=None):
+ """Handle the initial step."""
+
+ errors = {}
+ if user_input is not None:
+ try:
+ info = await validate_input(self.hass, user_input)
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except InvalidAuth:
+ errors["base"] = "invalid_auth"
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected exception")
+ errors["base"] = "unknown"
+ else:
+ if not errors:
+ # Ensure the same account cannot be setup more than once.
+ await self.async_set_unique_id(user_input[CONF_USERNAME])
+ self._abort_if_unique_id_configured()
+
+ return self.async_create_entry(title=info["title"], data=user_input)
+
+ return self.async_show_form(
+ step_id="user", data_schema=DATA_SCHEMA, errors=errors
+ )
+
+
+class CannotConnect(exceptions.HomeAssistantError):
+ """Error to indicate we cannot connect."""
+
+
+class InvalidAuth(exceptions.HomeAssistantError):
+ """Error to indicate there is invalid auth."""
diff --git a/homeassistant/components/smart_meter_texas/const.py b/homeassistant/components/smart_meter_texas/const.py
new file mode 100644
index 00000000000..3b87454a6a7
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/const.py
@@ -0,0 +1,15 @@
+"""Constants for the Smart Meter Texas integration."""
+from datetime import timedelta
+
+SCAN_INTERVAL = timedelta(hours=1)
+DEBOUNCE_COOLDOWN = 1800 # Seconds
+
+DATA_COORDINATOR = "coordinator"
+DATA_SMART_METER = "smart_meter_data"
+
+DOMAIN = "smart_meter_texas"
+
+METER_NUMBER = "meter_number"
+ESIID = "electric_service_identifier"
+LAST_UPDATE = "last_updated"
+ELECTRIC_METER = "Electric Meter"
diff --git a/homeassistant/components/smart_meter_texas/manifest.json b/homeassistant/components/smart_meter_texas/manifest.json
new file mode 100644
index 00000000000..be1ef6b11a8
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "smart_meter_texas",
+ "name": "Smart Meter Texas",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/smart_meter_texas",
+ "requirements": ["smart-meter-texas==0.4.0"],
+ "codeowners": ["@grahamwetzler"]
+}
diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py
new file mode 100644
index 00000000000..c08301d7021
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/sensor.py
@@ -0,0 +1,102 @@
+"""Support for Smart Meter Texas sensors."""
+import logging
+
+from smart_meter_texas import Meter
+
+from homeassistant.const import CONF_ADDRESS, ENERGY_KILO_WATT_HOUR
+from homeassistant.core import callback
+from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.helpers.update_coordinator import (
+ CoordinatorEntity,
+ DataUpdateCoordinator,
+)
+
+from .const import (
+ DATA_COORDINATOR,
+ DATA_SMART_METER,
+ DOMAIN,
+ ELECTRIC_METER,
+ ESIID,
+ METER_NUMBER,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the Smart Meter Texas sensors."""
+ coordinator = hass.data[DOMAIN][config_entry.entry_id][DATA_COORDINATOR]
+ meters = hass.data[DOMAIN][config_entry.entry_id][DATA_SMART_METER].meters
+
+ async_add_entities(
+ [SmartMeterTexasSensor(meter, coordinator) for meter in meters], False
+ )
+
+
+class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity):
+ """Representation of an Smart Meter Texas sensor."""
+
+ def __init__(self, meter: Meter, coordinator: DataUpdateCoordinator):
+ """Initialize the sensor."""
+ super().__init__(coordinator)
+ self.meter = meter
+ self._state = None
+ self._available = False
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return ENERGY_KILO_WATT_HOUR
+
+ @property
+ def name(self):
+ """Device Name."""
+ return f"{ELECTRIC_METER} {self.meter.meter}"
+
+ @property
+ def unique_id(self):
+ """Device Uniqueid."""
+ return f"{self.meter.esiid}_{self.meter.meter}"
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def state(self):
+ """Get the latest reading."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ attributes = {
+ METER_NUMBER: self.meter.meter,
+ ESIID: self.meter.esiid,
+ CONF_ADDRESS: self.meter.address,
+ }
+ return attributes
+
+ @callback
+ def _state_update(self):
+ """Call when the coordinator has an update."""
+ self._available = self.coordinator.last_update_success
+ if self._available:
+ self._state = self.meter.reading
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self):
+ """Subscribe to updates."""
+ self.async_on_remove(self.coordinator.async_add_listener(self._state_update))
+
+ # If the background update finished before
+ # we added the entity, there is no need to restore
+ # state.
+ if self.coordinator.last_update_success:
+ return
+
+ last_state = await self.async_get_last_state()
+ if last_state:
+ self._state = last_state.state
+ self._available = True
diff --git a/homeassistant/components/smart_meter_texas/strings.json b/homeassistant/components/smart_meter_texas/strings.json
new file mode 100644
index 00000000000..0e7916a269a
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/strings.json
@@ -0,0 +1,22 @@
+{
+ "title": "Smart Meter Texas",
+ "config": {
+ "step": {
+ "user": {
+ "description": "Provide your username and password for Smart Meter Texas.",
+ "data": {
+ "username": "[%key:common::config_flow::data::username%]",
+ "password": "[%key:common::config_flow::data::password%]"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+ "unknown": "[%key:common::config_flow::error::unknown%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+ }
+ }
+}
diff --git a/homeassistant/components/smart_meter_texas/translations/ca.json b/homeassistant/components/smart_meter_texas/translations/ca.json
new file mode 100644
index 00000000000..b2c1f34e1e4
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/translations/ca.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositiu ja est\u00e0 configurat"
+ },
+ "error": {
+ "cannot_connect": "Ha fallat la connexi\u00f3",
+ "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida",
+ "unknown": "Error inesperat"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Contrasenya",
+ "username": "Nom d'usuari"
+ },
+ "description": "Proporciona el nom d'usuari i contrasenya per a Smart Meter Texas."
+ }
+ }
+ },
+ "title": "Smart Meter Texas"
+}
\ No newline at end of file
diff --git a/homeassistant/components/smart_meter_texas/translations/en.json b/homeassistant/components/smart_meter_texas/translations/en.json
new file mode 100644
index 00000000000..dec29957514
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/translations/en.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Device is already configured"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect",
+ "invalid_auth": "Invalid authentication",
+ "unknown": "Unexpected error"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Password",
+ "username": "Username"
+ },
+ "description": "Provide your username and password for Smart Meter Texas."
+ }
+ }
+ },
+ "title": "Smart Meter Texas"
+}
\ No newline at end of file
diff --git a/homeassistant/components/smart_meter_texas/translations/es.json b/homeassistant/components/smart_meter_texas/translations/es.json
new file mode 100644
index 00000000000..0423fee406b
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/translations/es.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositivo ya est\u00e1 configurado"
+ },
+ "error": {
+ "cannot_connect": "No se pudo conectar",
+ "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida",
+ "unknown": "Error inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Contrase\u00f1a",
+ "username": "Usuario"
+ },
+ "description": "Proporciona tu nombre de usuario y contrase\u00f1a para Smart Meter Texas."
+ }
+ }
+ },
+ "title": "Smart Meter Texas"
+}
\ No newline at end of file
diff --git a/homeassistant/components/smart_meter_texas/translations/fr.json b/homeassistant/components/smart_meter_texas/translations/fr.json
new file mode 100644
index 00000000000..08e5bb657ee
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/translations/fr.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9"
+ },
+ "error": {
+ "cannot_connect": "\u00c9chec de connexion",
+ "invalid_auth": "Authentification invalide",
+ "unknown": "Erreur inattendue"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Mot de passe",
+ "username": "Nom d'utilisateur"
+ },
+ "description": "Fournissez votre nom d\u2019utilisateur et votre mot de passe pour Smart Meter Texas."
+ }
+ }
+ },
+ "title": "Smart Meter Texas"
+}
\ No newline at end of file
diff --git a/homeassistant/components/smart_meter_texas/translations/it.json b/homeassistant/components/smart_meter_texas/translations/it.json
new file mode 100644
index 00000000000..787dfd6b8c2
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/translations/it.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi",
+ "invalid_auth": "Autenticazione non valida",
+ "unknown": "Errore imprevisto"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Password",
+ "username": "Nome utente"
+ },
+ "description": "Fornisci il tuo nome utente e la password per Smart Meter Texas."
+ }
+ }
+ },
+ "title": "Smart Meter Texas"
+}
\ No newline at end of file
diff --git a/homeassistant/components/smart_meter_texas/translations/ko.json b/homeassistant/components/smart_meter_texas/translations/ko.json
new file mode 100644
index 00000000000..444e3cf4463
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/translations/ko.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "cannot_connect": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4",
+ "invalid_auth": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "unknown": "\uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "\ube44\ubc00\ubc88\ud638",
+ "username": "\uc0ac\uc6a9\uc790 \uc774\ub984"
+ },
+ "description": "Smart Meter Texas \uc758 \uc0ac\uc6a9\uc790 \uc774\ub984\uacfc \ube44\ubc00\ubc88\ud638\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694."
+ }
+ }
+ },
+ "title": "Smart Meter Texas"
+}
\ No newline at end of file
diff --git a/homeassistant/components/smart_meter_texas/translations/lb.json b/homeassistant/components/smart_meter_texas/translations/lb.json
new file mode 100644
index 00000000000..8b60dd41c8c
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/translations/lb.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Apparat ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "cannot_connect": "Feeler beim verbannen",
+ "invalid_auth": "Ong\u00eblteg Authentifikatioun",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Passwuert",
+ "username": "Benotzernumm"
+ },
+ "description": "G\u00ebff den Benotzernumm an d'Passwuert fir den Smart Meter Texas un."
+ }
+ }
+ },
+ "title": "Smart Meter Texas"
+}
\ No newline at end of file
diff --git a/homeassistant/components/smart_meter_texas/translations/no.json b/homeassistant/components/smart_meter_texas/translations/no.json
new file mode 100644
index 00000000000..693c93376d8
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/translations/no.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Enheten er allerede konfigurert"
+ },
+ "error": {
+ "cannot_connect": "Tilkobling mislyktes.",
+ "invalid_auth": "Ugyldig godkjenning",
+ "unknown": "Uventet feil"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Passord",
+ "username": "Brukernavn"
+ },
+ "description": "Oppgi brukernavn og passord for Smart Meter Texas."
+ }
+ }
+ },
+ "title": "Smart Meter Texas"
+}
\ No newline at end of file
diff --git a/homeassistant/components/smart_meter_texas/translations/pt.json b/homeassistant/components/smart_meter_texas/translations/pt.json
new file mode 100644
index 00000000000..7953cf5625c
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/translations/pt.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado"
+ },
+ "error": {
+ "cannot_connect": "Falha na liga\u00e7\u00e3o",
+ "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida",
+ "unknown": "Erro inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Palavra-passe",
+ "username": "Nome de Utilizador"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/smart_meter_texas/translations/ru.json b/homeassistant/components/smart_meter_texas/translations/ru.json
new file mode 100644
index 00000000000..2015377048d
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/translations/ru.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant."
+ },
+ "error": {
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.",
+ "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.",
+ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
+ "username": "\u041b\u043e\u0433\u0438\u043d"
+ },
+ "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441 Smart Meter Texas."
+ }
+ }
+ },
+ "title": "Smart Meter Texas"
+}
\ No newline at end of file
diff --git a/homeassistant/components/smart_meter_texas/translations/zh-Hant.json b/homeassistant/components/smart_meter_texas/translations/zh-Hant.json
new file mode 100644
index 00000000000..a268bee7cc3
--- /dev/null
+++ b/homeassistant/components/smart_meter_texas/translations/zh-Hant.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557",
+ "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "\u5bc6\u78bc",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31"
+ },
+ "description": "\u8acb\u8f38\u5165 Smart Meter Texas \u5e33\u865f\u4f7f\u7528\u8005\u540d\u7a31\u8207\u5bc6\u78bc\u3002"
+ }
+ }
+ },
+ "title": "Smart Meter Texas"
+}
\ No newline at end of file
diff --git a/homeassistant/components/smarthab/__init__.py b/homeassistant/components/smarthab/__init__.py
index 82c550e0060..2d22841660a 100644
--- a/homeassistant/components/smarthab/__init__.py
+++ b/homeassistant/components/smarthab/__init__.py
@@ -38,7 +38,9 @@ async def async_setup(hass, config) -> bool:
hass.async_create_task(
hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=sh_conf,
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=sh_conf,
)
)
@@ -57,9 +59,9 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
try:
await hub.async_login(username, password)
- except pysmarthab.RequestFailedException:
+ except pysmarthab.RequestFailedException as err:
_LOGGER.exception("Error while trying to reach SmartHab API")
- raise ConfigEntryNotReady
+ raise ConfigEntryNotReady from err
# Pass hub object to child platforms
hass.data[DOMAIN][entry.entry_id] = {DATA_HUB: hub}
diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py
index 479df05fbb4..974cde35faf 100644
--- a/homeassistant/components/smartthings/__init__.py
+++ b/homeassistant/components/smartthings/__init__.py
@@ -166,10 +166,10 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
remove_entry = True
else:
_LOGGER.debug(ex, exc_info=True)
- raise ConfigEntryNotReady
+ raise ConfigEntryNotReady from ex
except (ClientConnectionError, RuntimeWarning) as ex:
_LOGGER.debug(ex, exc_info=True)
- raise ConfigEntryNotReady
+ raise ConfigEntryNotReady from ex
if remove_entry:
hass.async_create_task(hass.config_entries.async_remove(entry.entry_id))
@@ -315,7 +315,8 @@ class DeviceBroker:
async def regenerate_refresh_token(now):
"""Generate a new refresh token and update the config entry."""
await self._token.refresh(
- self._entry.data[CONF_CLIENT_ID], self._entry.data[CONF_CLIENT_SECRET],
+ self._entry.data[CONF_CLIENT_ID],
+ self._entry.data[CONF_CLIENT_SECRET],
)
self._hass.config_entries.async_update_entry(
self._entry,
diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py
index f8746b597de..e320c335a08 100644
--- a/homeassistant/components/smartthings/config_flow.py
+++ b/homeassistant/components/smartthings/config_flow.py
@@ -80,7 +80,8 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
# Show the confirmation
if user_input is None:
return self.async_show_form(
- step_id="user", description_placeholders={"webhook_url": webhook_url},
+ step_id="user",
+ description_placeholders={"webhook_url": webhook_url},
)
# Show the next screen
diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py
index 8a6cf3cc215..7de3b98b1da 100644
--- a/homeassistant/components/smartthings/sensor.py
+++ b/homeassistant/components/smartthings/sensor.py
@@ -13,10 +13,10 @@ from homeassistant.const import (
DEVICE_CLASS_TIMESTAMP,
ENERGY_KILO_WATT_HOUR,
MASS_KILOGRAMS,
+ PERCENTAGE,
POWER_WATT,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
- UNIT_PERCENTAGE,
VOLT,
)
@@ -36,9 +36,9 @@ CAPABILITY_TO_SENSORS = {
Map(Attribute.air_quality, "Air Quality", "CAQI", None)
],
Capability.alarm: [Map(Attribute.alarm, "Alarm", None, None)],
- Capability.audio_volume: [Map(Attribute.volume, "Volume", UNIT_PERCENTAGE, None)],
+ Capability.audio_volume: [Map(Attribute.volume, "Volume", PERCENTAGE, None)],
Capability.battery: [
- Map(Attribute.battery, "Battery", UNIT_PERCENTAGE, DEVICE_CLASS_BATTERY)
+ Map(Attribute.battery, "Battery", PERCENTAGE, DEVICE_CLASS_BATTERY)
],
Capability.body_mass_index_measurement: [
Map(Attribute.bmi_measurement, "Body Mass Index", f"{MASS_KILOGRAMS}/m^2", None)
@@ -113,7 +113,7 @@ CAPABILITY_TO_SENSORS = {
Map(Attribute.illuminance, "Illuminance", "lux", DEVICE_CLASS_ILLUMINANCE)
],
Capability.infrared_level: [
- Map(Attribute.infrared_level, "Infrared Level", UNIT_PERCENTAGE, None)
+ Map(Attribute.infrared_level, "Infrared Level", PERCENTAGE, None)
],
Capability.media_input_source: [
Map(Attribute.input_source, "Media Input Source", None, None)
@@ -151,7 +151,7 @@ CAPABILITY_TO_SENSORS = {
Map(
Attribute.humidity,
"Relative Humidity Measurement",
- UNIT_PERCENTAGE,
+ PERCENTAGE,
DEVICE_CLASS_HUMIDITY,
)
],
diff --git a/homeassistant/components/smartthings/translations/fr.json b/homeassistant/components/smartthings/translations/fr.json
index 9902495e6a1..c355c437689 100644
--- a/homeassistant/components/smartthings/translations/fr.json
+++ b/homeassistant/components/smartthings/translations/fr.json
@@ -24,7 +24,7 @@
"title": "S\u00e9lectionnez l'emplacement"
},
"user": {
- "description": "Veuillez entrer un [jeton d'acc\u00e8s personnel SmartThings] ( {token_url} ) cr\u00e9\u00e9 selon les [instructions] ( {component_url} ).",
+ "description": "SmartThings sera configur\u00e9 pour envoyer des mises \u00e0 jour push \u00e0 Home Assistant \u00e0 l'adresse: \n > {webhook_url} \n\n Si ce n'est pas le cas, mettez \u00e0 jour votre configuration, red\u00e9marrez Home Assistant et r\u00e9essayez.",
"title": "Entrer un jeton d'acc\u00e8s personnel"
}
}
diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py
index 0b8f9d986e3..b1af205fc10 100644
--- a/homeassistant/components/sms/__init__.py
+++ b/homeassistant/components/sms/__init__.py
@@ -31,7 +31,9 @@ async def async_setup(hass, config):
hass.async_create_task(
hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=sms_config,
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=sms_config,
)
)
diff --git a/homeassistant/components/sms/config_flow.py b/homeassistant/components/sms/config_flow.py
index 148360416a2..52f3a403ed1 100644
--- a/homeassistant/components/sms/config_flow.py
+++ b/homeassistant/components/sms/config_flow.py
@@ -27,8 +27,8 @@ async def get_imei_from_config(hass: core.HomeAssistant, data):
raise CannotConnect
try:
imei = await gateway.get_imei_async()
- except gammu.GSMError: # pylint: disable=no-member
- raise CannotConnect
+ except gammu.GSMError as err: # pylint: disable=no-member
+ raise CannotConnect from err
finally:
await gateway.terminate_async()
diff --git a/homeassistant/components/sms/gateway.py b/homeassistant/components/sms/gateway.py
index 000434561bc..2d36444c03d 100644
--- a/homeassistant/components/sms/gateway.py
+++ b/homeassistant/components/sms/gateway.py
@@ -108,7 +108,10 @@ class Gateway:
# delete retrieved sms
_LOGGER.debug("Deleting message")
- state_machine.DeleteSMS(Folder=0, Location=entry[0]["Location"])
+ try:
+ state_machine.DeleteSMS(Folder=0, Location=entry[0]["Location"])
+ except gammu.ERR_MEMORY_NOT_AVAILABLE:
+ _LOGGER.error("Error deleting SMS, memory not available")
else:
_LOGGER.debug("Not all parts have arrived")
break
diff --git a/homeassistant/components/sms/manifest.json b/homeassistant/components/sms/manifest.json
index c3c7db2aa61..1c24777bd4a 100644
--- a/homeassistant/components/sms/manifest.json
+++ b/homeassistant/components/sms/manifest.json
@@ -3,6 +3,6 @@
"name": "SMS notifications via GSM-modem",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sms",
- "requirements": ["python-gammu==3.0"],
+ "requirements": ["python-gammu==3.1"],
"codeowners": ["@ocalvo"]
}
diff --git a/homeassistant/components/sms/sensor.py b/homeassistant/components/sms/sensor.py
index 08168994b07..64d2bf9bd98 100644
--- a/homeassistant/components/sms/sensor.py
+++ b/homeassistant/components/sms/sensor.py
@@ -17,7 +17,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
entities = []
imei = await gateway.get_imei_async()
name = f"gsm_signal_imei_{imei}"
- entities.append(GSMSignalSensor(hass, gateway, name,))
+ entities.append(
+ GSMSignalSensor(
+ hass,
+ gateway,
+ name,
+ )
+ )
async_add_entities(entities, True)
@@ -25,7 +31,10 @@ class GSMSignalSensor(Entity):
"""Implementation of a GSM Signal sensor."""
def __init__(
- self, hass, gateway, name,
+ self,
+ hass,
+ gateway,
+ name,
):
"""Initialize the GSM Signal sensor."""
self._hass = hass
diff --git a/homeassistant/components/sms/translations/ru.json b/homeassistant/components/sms/translations/ru.json
index 85a99a37528..2200b582123 100644
--- a/homeassistant/components/sms/translations/ru.json
+++ b/homeassistant/components/sms/translations/ru.json
@@ -1,11 +1,11 @@
{
"config": {
"abort": {
- "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
+ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.",
"single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e."
},
"error": {
- "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.",
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.",
"unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
},
"step": {
diff --git a/homeassistant/components/smtp/__init__.py b/homeassistant/components/smtp/__init__.py
index 5e7fb41c212..7461dd2b6c3 100644
--- a/homeassistant/components/smtp/__init__.py
+++ b/homeassistant/components/smtp/__init__.py
@@ -1 +1,4 @@
"""The smtp component."""
+
+DOMAIN = "smtp"
+PLATFORMS = ["notify"]
diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py
index 30d7ea2645e..f7d3415525d 100644
--- a/homeassistant/components/smtp/notify.py
+++ b/homeassistant/components/smtp/notify.py
@@ -26,8 +26,11 @@ from homeassistant.const import (
CONF_USERNAME,
)
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.reload import setup_reload_service
import homeassistant.util.dt as dt_util
+from . import DOMAIN, PLATFORMS
+
_LOGGER = logging.getLogger(__name__)
ATTR_IMAGES = "images" # optional embedded image file attachments
@@ -67,6 +70,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
def get_service(hass, config, discovery_info=None):
"""Get the mail notification service."""
+ setup_reload_service(hass, DOMAIN, PLATFORMS)
mail_service = MailNotificationService(
config.get(CONF_SERVER),
config.get(CONF_PORT),
@@ -215,6 +219,29 @@ def _build_text_msg(message):
return MIMEText(message)
+def _attach_file(atch_name, content_id):
+ """Create a message attachment."""
+ try:
+ with open(atch_name, "rb") as attachment_file:
+ file_bytes = attachment_file.read()
+ except FileNotFoundError:
+ _LOGGER.warning("Attachment %s not found. Skipping", atch_name)
+ return None
+
+ try:
+ attachment = MIMEImage(file_bytes)
+ except TypeError:
+ _LOGGER.warning(
+ "Attachment %s has an unknown MIME type. " "Falling back to file",
+ atch_name,
+ )
+ attachment = MIMEApplication(file_bytes, Name=atch_name)
+ attachment["Content-Disposition"] = "attachment; " 'filename="%s"' % atch_name
+
+ attachment.add_header("Content-ID", f"<{content_id}>")
+ return attachment
+
+
def _build_multipart_msg(message, images):
"""Build Multipart message with in-line images."""
_LOGGER.debug("Building multipart email with embedded attachment(s)")
@@ -228,26 +255,9 @@ def _build_multipart_msg(message, images):
for atch_num, atch_name in enumerate(images):
cid = f"image{atch_num}"
body_text.append(f'
')
- try:
- with open(atch_name, "rb") as attachment_file:
- file_bytes = attachment_file.read()
- try:
- attachment = MIMEImage(file_bytes)
- msg.attach(attachment)
- attachment.add_header("Content-ID", f"<{cid}>")
- except TypeError:
- _LOGGER.warning(
- "Attachment %s has an unknown MIME type. "
- "Falling back to file",
- atch_name,
- )
- attachment = MIMEApplication(file_bytes, Name=atch_name)
- attachment["Content-Disposition"] = (
- "attachment; " 'filename="%s"' % atch_name
- )
- msg.attach(attachment)
- except FileNotFoundError:
- _LOGGER.warning("Attachment %s not found. Skipping", atch_name)
+ attachment = _attach_file(atch_name, cid)
+ if attachment:
+ msg.attach(attachment)
body_html = MIMEText("".join(body_text), "html")
msg_alt.attach(body_html)
@@ -263,15 +273,9 @@ def _build_html_msg(text, html, images):
alternative.attach(MIMEText(html, ATTR_HTML, _charset="utf-8"))
msg.attach(alternative)
- for atch_num, atch_name in enumerate(images):
+ for atch_name in images:
name = os.path.basename(atch_name)
- try:
- with open(atch_name, "rb") as attachment_file:
- attachment = MIMEImage(attachment_file.read(), filename=name)
+ attachment = _attach_file(atch_name, name)
+ if attachment:
msg.attach(attachment)
- attachment.add_header("Content-ID", f"<{name}>")
- except FileNotFoundError:
- _LOGGER.warning(
- "Attachment %s [#%s] not found. Skipping", atch_name, atch_num
- )
return msg
diff --git a/homeassistant/components/smtp/services.yaml b/homeassistant/components/smtp/services.yaml
new file mode 100644
index 00000000000..77ff7d22adf
--- /dev/null
+++ b/homeassistant/components/smtp/services.yaml
@@ -0,0 +1,2 @@
+reload:
+ description: Reload smtp notify services.
diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py
index 9acdc43f3c7..6b4c0bc233f 100644
--- a/homeassistant/components/solaredge/const.py
+++ b/homeassistant/components/solaredge/const.py
@@ -1,7 +1,7 @@
"""Constants for the SolarEdge Monitoring API."""
from datetime import timedelta
-from homeassistant.const import ENERGY_WATT_HOUR, POWER_WATT
+from homeassistant.const import ENERGY_WATT_HOUR, PERCENTAGE, POWER_WATT
DOMAIN = "solaredge"
@@ -77,4 +77,5 @@ SENSOR_TYPES = {
False,
],
"feedin_power": ["FeedIn", "Exported Power", None, "mdi:flash", False],
+ "storage_level": ["STORAGE", "Storage Level", PERCENTAGE, None, False],
}
diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py
index 469f8ef64a2..3888b8bf536 100644
--- a/homeassistant/components/solaredge/sensor.py
+++ b/homeassistant/components/solaredge/sensor.py
@@ -6,7 +6,7 @@ from requests.exceptions import ConnectTimeout, HTTPError
import solaredge
from stringcase import snakecase
-from homeassistant.const import CONF_API_KEY
+from homeassistant.const import CONF_API_KEY, DEVICE_CLASS_BATTERY, DEVICE_CLASS_POWER
from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
@@ -83,6 +83,9 @@ class SolarEdgeSensorFactory:
for key in ["power_consumption", "solar_power", "grid_power", "storage_power"]:
self.services[key] = (SolarEdgePowerFlowSensor, flow)
+ for key in ["storage_level"]:
+ self.services[key] = (SolarEdgeStorageLevelSensor, flow)
+
for key in [
"purchased_power",
"production_power",
@@ -233,6 +236,11 @@ class SolarEdgePowerFlowSensor(SolarEdgeSensor):
"""Return the state attributes."""
return self._attributes
+ @property
+ def device_class(self):
+ """Device Class."""
+ return DEVICE_CLASS_POWER
+
def update(self):
"""Get the latest inventory data and update state and attributes."""
self.data_service.update()
@@ -241,6 +249,27 @@ class SolarEdgePowerFlowSensor(SolarEdgeSensor):
self._unit_of_measurement = self.data_service.unit
+class SolarEdgeStorageLevelSensor(SolarEdgeSensor):
+ """Representation of an SolarEdge Monitoring API storage level sensor."""
+
+ def __init__(self, platform_name, sensor_key, data_service):
+ """Initialize the storage level sensor."""
+ super().__init__(platform_name, sensor_key, data_service)
+
+ self._json_key = SENSOR_TYPES[self.sensor_key][0]
+
+ @property
+ def device_class(self):
+ """Return the device_class of the device."""
+ return DEVICE_CLASS_BATTERY
+
+ def update(self):
+ """Get the latest inventory data and update state and attributes."""
+ self.data_service.update()
+ attr = self.data_service.attributes.get(self._json_key)
+ self._state = attr["soc"]
+
+
class SolarEdgeDataService:
"""Get and update the latest data."""
@@ -470,6 +499,7 @@ class SolarEdgePowerFlowDataService(SolarEdgeDataService):
charge = key.lower() in power_to
self.data[key] *= -1 if charge else 1
self.attributes[key]["flow"] = "charge" if charge else "discharge"
+ self.attributes[key]["soc"] = value["chargeLevel"]
_LOGGER.debug(
"Updated SolarEdge power flow: %s, %s", self.data, self.attributes
diff --git a/homeassistant/components/solaredge/translations/fr.json b/homeassistant/components/solaredge/translations/fr.json
index b03a29ad2e1..f8aec1fa230 100644
--- a/homeassistant/components/solaredge/translations/fr.json
+++ b/homeassistant/components/solaredge/translations/fr.json
@@ -9,7 +9,7 @@
"step": {
"user": {
"data": {
- "api_key": "La cl\u00e9 API pour ce site",
+ "api_key": "Cl\u00e9 d'API",
"name": "Le nom de cette installation",
"site_id": "L'identifiant de site SolarEdge"
},
diff --git a/homeassistant/components/tibber/translations/pt.json b/homeassistant/components/solaredge/translations/pt.json
similarity index 50%
rename from homeassistant/components/tibber/translations/pt.json
rename to homeassistant/components/solaredge/translations/pt.json
index 243987422dd..01078bbddfe 100644
--- a/homeassistant/components/tibber/translations/pt.json
+++ b/homeassistant/components/solaredge/translations/pt.json
@@ -3,11 +3,9 @@
"step": {
"user": {
"data": {
- "access_token": ""
- },
- "title": ""
+ "api_key": "Chave de API"
+ }
}
}
- },
- "title": ""
+ }
}
\ No newline at end of file
diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py
index 07dd101b943..dab844f86d6 100644
--- a/homeassistant/components/solarlog/const.py
+++ b/homeassistant/components/solarlog/const.py
@@ -1,7 +1,7 @@
"""Constants for the Solar-Log integration."""
from datetime import timedelta
-from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT, UNIT_PERCENTAGE, VOLT
+from homeassistant.const import ENERGY_KILO_WATT_HOUR, PERCENTAGE, POWER_WATT, VOLT
DOMAIN = "solarlog"
@@ -77,7 +77,7 @@ SENSOR_TYPES = {
POWER_WATT,
"mdi:solar-power",
],
- "capacity": ["CAPACITY", "capacity", UNIT_PERCENTAGE, "mdi:solar-power"],
+ "capacity": ["CAPACITY", "capacity", PERCENTAGE, "mdi:solar-power"],
"efficiency": [
"EFFICIENCY",
"efficiency",
diff --git a/homeassistant/components/solarlog/sensor.py b/homeassistant/components/solarlog/sensor.py
index 85ab9eb913e..ca9f2e3fc13 100644
--- a/homeassistant/components/solarlog/sensor.py
+++ b/homeassistant/components/solarlog/sensor.py
@@ -61,7 +61,7 @@ async def async_setup_entry(hass, entry, async_add_entities):
# Create a new sensor for each sensor type.
entities = []
for sensor_key in SENSOR_TYPES:
- sensor = SolarlogSensor(platform_name, sensor_key, data)
+ sensor = SolarlogSensor(entry.entry_id, platform_name, sensor_key, data)
entities.append(sensor)
async_add_entities(entities, True)
@@ -71,20 +71,28 @@ async def async_setup_entry(hass, entry, async_add_entities):
class SolarlogSensor(Entity):
"""Representation of a Sensor."""
- def __init__(self, platform_name, sensor_key, data):
+ def __init__(self, entry_id, platform_name, sensor_key, data):
"""Initialize the sensor."""
self.platform_name = platform_name
self.sensor_key = sensor_key
self.data = data
+ self.entry_id = entry_id
self._state = None
self._json_key = SENSOR_TYPES[self.sensor_key][0]
+ self._label = SENSOR_TYPES[self.sensor_key][1]
self._unit_of_measurement = SENSOR_TYPES[self.sensor_key][2]
+ self._icon = SENSOR_TYPES[self.sensor_key][3]
+
+ @property
+ def unique_id(self):
+ """Return the unique id."""
+ return f"{self.entry_id}_{self.sensor_key}"
@property
def name(self):
"""Return the name of the sensor."""
- return "{} ({})".format(self.platform_name, SENSOR_TYPES[self.sensor_key][1])
+ return f"{self.platform_name} {self._label}"
@property
def unit_of_measurement(self):
@@ -94,7 +102,7 @@ class SolarlogSensor(Entity):
@property
def icon(self):
"""Return the sensor icon."""
- return SENSOR_TYPES[self.sensor_key][3]
+ return self._icon
@property
def state(self):
diff --git a/homeassistant/components/solarlog/translations/fr.json b/homeassistant/components/solarlog/translations/fr.json
index 2de03d82c31..3e950af8564 100644
--- a/homeassistant/components/solarlog/translations/fr.json
+++ b/homeassistant/components/solarlog/translations/fr.json
@@ -10,7 +10,7 @@
"step": {
"user": {
"data": {
- "host": "Le nom d'h\u00f4te ou l'adresse IP de votre p\u00e9riph\u00e9rique Solar-Log",
+ "host": "H\u00f4te",
"name": "Le pr\u00e9fixe \u00e0 utiliser pour vos capteurs Solar-Log"
},
"title": "D\u00e9finissez votre connexion Solar-Log"
diff --git a/homeassistant/components/solarlog/translations/pt.json b/homeassistant/components/solarlog/translations/pt.json
index ce7cbc3f548..88cfc4a797f 100644
--- a/homeassistant/components/solarlog/translations/pt.json
+++ b/homeassistant/components/solarlog/translations/pt.json
@@ -1,5 +1,8 @@
{
"config": {
+ "error": {
+ "cannot_connect": "Falha na liga\u00e7\u00e3o, por favor verifique o endere\u00e7o do servidor"
+ },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py
index 8eb61560e63..3084aefd97c 100644
--- a/homeassistant/components/solax/sensor.py
+++ b/homeassistant/components/solax/sensor.py
@@ -64,11 +64,11 @@ class RealTimeDataEndpoint:
try:
api_response = await self.api.get_data()
self.ready.set()
- except InverterError:
+ except InverterError as err:
if now is not None:
self.ready.clear()
return
- raise PlatformNotReady
+ raise PlatformNotReady from err
data = api_response.data
for sensor in self.sensors:
if sensor.key in data:
diff --git a/homeassistant/components/soma/translations/no.json b/homeassistant/components/soma/translations/no.json
index 5c2c01ca7a6..5a084433eed 100644
--- a/homeassistant/components/soma/translations/no.json
+++ b/homeassistant/components/soma/translations/no.json
@@ -13,11 +13,9 @@
"step": {
"user": {
"data": {
- "host": "Vert",
- "port": ""
+ "host": "Vert"
},
- "description": "Vennligst fyll inn tilkoblingsinnstillingene for din SOMA Connect.",
- "title": ""
+ "description": "Vennligst fyll inn tilkoblingsinnstillingene for din SOMA Connect."
}
}
}
diff --git a/homeassistant/components/somfy/__init__.py b/homeassistant/components/somfy/__init__.py
index fb20fcd6683..fbc76e7c938 100644
--- a/homeassistant/components/somfy/__init__.py
+++ b/homeassistant/components/somfy/__init__.py
@@ -82,8 +82,10 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
entry, data={**entry.data, "auth_implementation": DOMAIN}
)
- implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation(
- hass, entry
+ implementation = (
+ await config_entry_oauth2_flow.async_get_config_entry_implementation(
+ hass, entry
+ )
)
hass.data[DOMAIN][API] = api.ConfigEntrySomfyApi(hass, entry, implementation)
diff --git a/homeassistant/components/somfy/api.py b/homeassistant/components/somfy/api.py
index 761ee19f8cb..a679af06d73 100644
--- a/homeassistant/components/somfy/api.py
+++ b/homeassistant/components/somfy/api.py
@@ -25,7 +25,9 @@ class ConfigEntrySomfyApi(somfy_api.SomfyApi):
)
super().__init__(None, None, token=self.session.token)
- def refresh_tokens(self,) -> Dict[str, Union[str, int]]:
+ def refresh_tokens(
+ self,
+ ) -> Dict[str, Union[str, int]]:
"""Refresh and return new Somfy tokens using Home Assistant OAuth2 session."""
run_coroutine_threadsafe(
self.session.async_ensure_token_valid(), self.hass.loop
diff --git a/homeassistant/components/somfy/strings.json b/homeassistant/components/somfy/strings.json
index 90ea98f7a87..d1fa921bb8e 100644
--- a/homeassistant/components/somfy/strings.json
+++ b/homeassistant/components/somfy/strings.json
@@ -6,7 +6,8 @@
"abort": {
"already_setup": "You can only configure one Somfy account.",
"authorize_url_timeout": "Timeout generating authorize url.",
- "missing_configuration": "The Somfy component is not configured. Please follow the documentation."
+ "missing_configuration": "The Somfy component is not configured. Please follow the documentation.",
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]"
},
"create_entry": { "default": "Successfully authenticated with Somfy." }
}
diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py
index 4228d6c8400..601509aa575 100644
--- a/homeassistant/components/sonarr/__init__.py
+++ b/homeassistant/components/sonarr/__init__.py
@@ -69,8 +69,8 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool
try:
await sonarr.update()
- except SonarrError:
- raise ConfigEntryNotReady
+ except SonarrError as err:
+ raise ConfigEntryNotReady from err
undo_listener = entry.add_update_listener(_async_update_listener)
diff --git a/homeassistant/components/sonarr/config_flow.py b/homeassistant/components/sonarr/config_flow.py
index e82ecb49fda..ec1a29c660b 100644
--- a/homeassistant/components/sonarr/config_flow.py
+++ b/homeassistant/components/sonarr/config_flow.py
@@ -111,7 +111,9 @@ class SonarrConfigFlow(ConfigFlow, domain=DOMAIN):
] = bool
return self.async_show_form(
- step_id="user", data_schema=vol.Schema(data_schema), errors=errors or {},
+ step_id="user",
+ data_schema=vol.Schema(data_schema),
+ errors=errors or {},
)
diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py
index f1945f26836..a4427223ab7 100644
--- a/homeassistant/components/sonarr/sensor.py
+++ b/homeassistant/components/sonarr/sensor.py
@@ -1,7 +1,7 @@
"""Support for Sonarr sensors."""
from datetime import timedelta
import logging
-from typing import Any, Callable, Dict, List, Optional, Union
+from typing import Any, Callable, Dict, List, Optional
from sonarr import Sonarr, SonarrConnectionError, SonarrError
import voluptuous as vol
@@ -236,7 +236,7 @@ class SonarrCommandsSensor(SonarrSensor):
return attrs
@property
- def state(self) -> Union[None, str, int, float]:
+ def state(self) -> int:
"""Return the state of the sensor."""
return len(self._commands)
@@ -287,7 +287,7 @@ class SonarrDiskspaceSensor(SonarrSensor):
return attrs
@property
- def state(self) -> Union[None, str, int, float]:
+ def state(self) -> str:
"""Return the state of the sensor."""
free = self._to_unit(self._total_free)
return f"{free:.2f}"
@@ -329,7 +329,7 @@ class SonarrQueueSensor(SonarrSensor):
return attrs
@property
- def state(self) -> Union[None, str, int, float]:
+ def state(self) -> int:
"""Return the state of the sensor."""
return len(self._queue)
@@ -367,7 +367,7 @@ class SonarrSeriesSensor(SonarrSensor):
return attrs
@property
- def state(self) -> Union[None, str, int, float]:
+ def state(self) -> int:
"""Return the state of the sensor."""
return len(self._items)
@@ -426,7 +426,7 @@ class SonarrUpcomingSensor(SonarrSensor):
return attrs
@property
- def state(self) -> Union[None, str, int, float]:
+ def state(self) -> int:
"""Return the state of the sensor."""
return len(self._upcoming)
@@ -438,7 +438,7 @@ class SonarrWantedSensor(SonarrSensor):
"""Initialize Sonarr Wanted sensor."""
self._max_items = max_items
self._results = None
- self._total = None
+ self._total: Optional[int] = None
super().__init__(
sonarr=sonarr,
@@ -485,6 +485,6 @@ class SonarrWantedSensor(SonarrSensor):
return attrs
@property
- def state(self) -> Union[None, str, int, float]:
+ def state(self) -> Optional[int]:
"""Return the state of the sensor."""
return self._total
diff --git a/homeassistant/components/sonarr/translations/no.json b/homeassistant/components/sonarr/translations/no.json
index 694565b72d6..2a7ede2964a 100644
--- a/homeassistant/components/sonarr/translations/no.json
+++ b/homeassistant/components/sonarr/translations/no.json
@@ -8,14 +8,12 @@
"cannot_connect": "Tilkobling mislyktes.",
"invalid_auth": "Ugyldig godkjenning"
},
- "flow_title": "",
"step": {
"user": {
"data": {
"api_key": "API N\u00f8kkel",
"base_path": "Bane til API",
"host": "Vert",
- "port": "",
"ssl": "Sonarr bruker et SSL-sertifikat",
"verify_ssl": "Sonarr bruker et riktig sertifikat"
},
@@ -32,6 +30,5 @@
}
}
}
- },
- "title": ""
+ }
}
\ No newline at end of file
diff --git a/homeassistant/components/sonarr/translations/ru.json b/homeassistant/components/sonarr/translations/ru.json
index d7ffd002981..158a3d59391 100644
--- a/homeassistant/components/sonarr/translations/ru.json
+++ b/homeassistant/components/sonarr/translations/ru.json
@@ -5,7 +5,7 @@
"unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
},
"error": {
- "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.",
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.",
"invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f."
},
"flow_title": "Sonarr: {name}",
diff --git a/homeassistant/components/songpal/__init__.py b/homeassistant/components/songpal/__init__.py
index 4a4332cb0a5..493a3ef4913 100644
--- a/homeassistant/components/songpal/__init__.py
+++ b/homeassistant/components/songpal/__init__.py
@@ -31,7 +31,9 @@ async def async_setup(hass: HomeAssistantType, config: OrderedDict) -> bool:
for config_entry in conf:
hass.async_create_task(
hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=config_entry,
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=config_entry,
),
)
return True
diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py
index ae633055b83..2a0bde306b7 100644
--- a/homeassistant/components/songpal/media_player.py
+++ b/homeassistant/components/songpal/media_player.py
@@ -77,7 +77,7 @@ async def async_setup_entry(
except (SongpalException, asyncio.TimeoutError) as ex:
_LOGGER.warning("[%s(%s)] Unable to connect", name, endpoint)
_LOGGER.debug("Unable to get methods from songpal: %s", ex)
- raise PlatformNotReady
+ raise PlatformNotReady from ex
songpal_entity = SongpalEntity(name, device)
async_add_entities([songpal_entity], True)
diff --git a/homeassistant/components/songpal/translations/no.json b/homeassistant/components/songpal/translations/no.json
index eb07eeb2daa..1096e9a0e89 100644
--- a/homeassistant/components/songpal/translations/no.json
+++ b/homeassistant/components/songpal/translations/no.json
@@ -7,7 +7,6 @@
"error": {
"cannot_connect": "Tilkobling mislyktes."
},
- "flow_title": "",
"step": {
"init": {
"description": "Vil du sette opp {name} ({host})?"
diff --git a/homeassistant/components/songpal/translations/ru.json b/homeassistant/components/songpal/translations/ru.json
index 9bd54b442bc..b572236b7f7 100644
--- a/homeassistant/components/songpal/translations/ru.json
+++ b/homeassistant/components/songpal/translations/ru.json
@@ -1,11 +1,11 @@
{
"config": {
"abort": {
- "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
+ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.",
"not_songpal_device": "\u041d\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Songpal."
},
"error": {
- "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f."
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f."
},
"flow_title": "Sony Songpal {name} ({host})",
"step": {
diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py
index 0d88249f740..da397b3e5e7 100644
--- a/homeassistant/components/sonos/const.py
+++ b/homeassistant/components/sonos/const.py
@@ -2,3 +2,11 @@
DOMAIN = "sonos"
DATA_SONOS = "sonos_media_player"
+
+SONOS_ARTIST = "artists"
+SONOS_ALBUM = "albums"
+SONOS_PLAYLISTS = "playlists"
+SONOS_GENRE = "genres"
+SONOS_ALBUM_ARTIST = "album_artists"
+SONOS_TRACKS = "tracks"
+SONOS_COMPOSER = "composers"
diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json
index 3a8ba58cc61..efad23ee1f2 100644
--- a/homeassistant/components/sonos/manifest.json
+++ b/homeassistant/components/sonos/manifest.json
@@ -3,7 +3,7 @@
"name": "Sonos",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/sonos",
- "requirements": ["pysonos==0.0.32"],
+ "requirements": ["pysonos==0.0.33"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py
index 4e4f7338a10..307fee923a3 100644
--- a/homeassistant/components/sonos/media_player.py
+++ b/homeassistant/components/sonos/media_player.py
@@ -4,6 +4,7 @@ import datetime
import functools as ft
import logging
import socket
+import urllib.parse
import async_timeout
import pysonos
@@ -13,11 +14,26 @@ import pysonos.music_library
import pysonos.snapshot
import voluptuous as vol
-from homeassistant.components.media_player import MediaPlayerEntity
+from homeassistant.components.media_player import BrowseMedia, MediaPlayerEntity
from homeassistant.components.media_player.const import (
ATTR_MEDIA_ENQUEUE,
+ MEDIA_CLASS_ALBUM,
+ MEDIA_CLASS_ARTIST,
+ MEDIA_CLASS_COMPOSER,
+ MEDIA_CLASS_CONTRIBUTING_ARTIST,
+ MEDIA_CLASS_DIRECTORY,
+ MEDIA_CLASS_GENRE,
+ MEDIA_CLASS_PLAYLIST,
+ MEDIA_CLASS_TRACK,
+ MEDIA_TYPE_ALBUM,
+ MEDIA_TYPE_ARTIST,
+ MEDIA_TYPE_COMPOSER,
+ MEDIA_TYPE_CONTRIBUTING_ARTIST,
+ MEDIA_TYPE_GENRE,
MEDIA_TYPE_MUSIC,
MEDIA_TYPE_PLAYLIST,
+ MEDIA_TYPE_TRACK,
+ SUPPORT_BROWSE_MEDIA,
SUPPORT_CLEAR_PLAYLIST,
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
@@ -31,6 +47,7 @@ from homeassistant.components.media_player.const import (
SUPPORT_VOLUME_MUTE,
SUPPORT_VOLUME_SET,
)
+from homeassistant.components.media_player.errors import BrowseError
from homeassistant.const import (
ATTR_TIME,
EVENT_HOMEASSISTANT_STOP,
@@ -43,12 +60,17 @@ from homeassistant.helpers import config_validation as cv, entity_platform, serv
import homeassistant.helpers.device_registry as dr
from homeassistant.util.dt import utcnow
-from . import (
- CONF_ADVERTISE_ADDR,
- CONF_HOSTS,
- CONF_INTERFACE_ADDR,
+from . import CONF_ADVERTISE_ADDR, CONF_HOSTS, CONF_INTERFACE_ADDR
+from .const import (
DATA_SONOS,
DOMAIN as SONOS_DOMAIN,
+ SONOS_ALBUM,
+ SONOS_ALBUM_ARTIST,
+ SONOS_ARTIST,
+ SONOS_COMPOSER,
+ SONOS_GENRE,
+ SONOS_PLAYLISTS,
+ SONOS_TRACKS,
)
_LOGGER = logging.getLogger(__name__)
@@ -57,23 +79,119 @@ SCAN_INTERVAL = 10
DISCOVERY_INTERVAL = 60
SUPPORT_SONOS = (
- SUPPORT_VOLUME_SET
- | SUPPORT_VOLUME_MUTE
- | SUPPORT_PLAY
- | SUPPORT_PAUSE
- | SUPPORT_STOP
- | SUPPORT_SELECT_SOURCE
- | SUPPORT_PREVIOUS_TRACK
- | SUPPORT_NEXT_TRACK
- | SUPPORT_SEEK
- | SUPPORT_PLAY_MEDIA
- | SUPPORT_SHUFFLE_SET
+ SUPPORT_BROWSE_MEDIA
| SUPPORT_CLEAR_PLAYLIST
+ | SUPPORT_NEXT_TRACK
+ | SUPPORT_PAUSE
+ | SUPPORT_PLAY
+ | SUPPORT_PLAY_MEDIA
+ | SUPPORT_PREVIOUS_TRACK
+ | SUPPORT_SEEK
+ | SUPPORT_SELECT_SOURCE
+ | SUPPORT_SHUFFLE_SET
+ | SUPPORT_STOP
+ | SUPPORT_VOLUME_MUTE
+ | SUPPORT_VOLUME_SET
)
SOURCE_LINEIN = "Line-in"
SOURCE_TV = "TV"
+EXPANDABLE_MEDIA_TYPES = [
+ MEDIA_TYPE_ALBUM,
+ MEDIA_TYPE_ARTIST,
+ MEDIA_TYPE_COMPOSER,
+ MEDIA_TYPE_GENRE,
+ MEDIA_TYPE_PLAYLIST,
+ SONOS_ALBUM,
+ SONOS_ALBUM_ARTIST,
+ SONOS_ARTIST,
+ SONOS_GENRE,
+ SONOS_COMPOSER,
+ SONOS_PLAYLISTS,
+]
+
+SONOS_TO_MEDIA_CLASSES = {
+ SONOS_ALBUM: MEDIA_CLASS_ALBUM,
+ SONOS_ALBUM_ARTIST: MEDIA_CLASS_ARTIST,
+ SONOS_ARTIST: MEDIA_CLASS_CONTRIBUTING_ARTIST,
+ SONOS_COMPOSER: MEDIA_CLASS_COMPOSER,
+ SONOS_GENRE: MEDIA_CLASS_GENRE,
+ SONOS_PLAYLISTS: MEDIA_CLASS_PLAYLIST,
+ SONOS_TRACKS: MEDIA_CLASS_TRACK,
+ "object.container.album.musicAlbum": MEDIA_CLASS_ALBUM,
+ "object.container.genre.musicGenre": MEDIA_CLASS_PLAYLIST,
+ "object.container.person.composer": MEDIA_CLASS_PLAYLIST,
+ "object.container.person.musicArtist": MEDIA_CLASS_ARTIST,
+ "object.container.playlistContainer.sameArtist": MEDIA_CLASS_ARTIST,
+ "object.container.playlistContainer": MEDIA_CLASS_PLAYLIST,
+ "object.item.audioItem.musicTrack": MEDIA_CLASS_TRACK,
+}
+
+SONOS_TO_MEDIA_TYPES = {
+ SONOS_ALBUM: MEDIA_TYPE_ALBUM,
+ SONOS_ALBUM_ARTIST: MEDIA_TYPE_ARTIST,
+ SONOS_ARTIST: MEDIA_TYPE_CONTRIBUTING_ARTIST,
+ SONOS_COMPOSER: MEDIA_TYPE_COMPOSER,
+ SONOS_GENRE: MEDIA_TYPE_GENRE,
+ SONOS_PLAYLISTS: MEDIA_TYPE_PLAYLIST,
+ SONOS_TRACKS: MEDIA_TYPE_TRACK,
+ "object.container.album.musicAlbum": MEDIA_TYPE_ALBUM,
+ "object.container.genre.musicGenre": MEDIA_TYPE_PLAYLIST,
+ "object.container.person.composer": MEDIA_TYPE_PLAYLIST,
+ "object.container.person.musicArtist": MEDIA_TYPE_ARTIST,
+ "object.container.playlistContainer.sameArtist": MEDIA_TYPE_ARTIST,
+ "object.container.playlistContainer": MEDIA_TYPE_PLAYLIST,
+ "object.item.audioItem.musicTrack": MEDIA_TYPE_TRACK,
+}
+
+MEDIA_TYPES_TO_SONOS = {
+ MEDIA_TYPE_ALBUM: SONOS_ALBUM,
+ MEDIA_TYPE_ARTIST: SONOS_ALBUM_ARTIST,
+ MEDIA_TYPE_CONTRIBUTING_ARTIST: SONOS_ARTIST,
+ MEDIA_TYPE_COMPOSER: SONOS_COMPOSER,
+ MEDIA_TYPE_GENRE: SONOS_GENRE,
+ MEDIA_TYPE_PLAYLIST: SONOS_PLAYLISTS,
+ MEDIA_TYPE_TRACK: SONOS_TRACKS,
+}
+
+SONOS_TYPES_MAPPING = {
+ "A:ALBUM": SONOS_ALBUM,
+ "A:ALBUMARTIST": SONOS_ALBUM_ARTIST,
+ "A:ARTIST": SONOS_ARTIST,
+ "A:COMPOSER": SONOS_COMPOSER,
+ "A:GENRE": SONOS_GENRE,
+ "A:PLAYLISTS": SONOS_PLAYLISTS,
+ "A:TRACKS": SONOS_TRACKS,
+ "object.container.album.musicAlbum": SONOS_ALBUM,
+ "object.container.genre.musicGenre": SONOS_GENRE,
+ "object.container.person.composer": SONOS_COMPOSER,
+ "object.container.person.musicArtist": SONOS_ALBUM_ARTIST,
+ "object.container.playlistContainer.sameArtist": SONOS_ARTIST,
+ "object.container.playlistContainer": SONOS_PLAYLISTS,
+ "object.item.audioItem.musicTrack": SONOS_TRACKS,
+}
+
+LIBRARY_TITLES_MAPPING = {
+ "A:ALBUM": "Albums",
+ "A:ALBUMARTIST": "Artists",
+ "A:ARTIST": "Contributing Artists",
+ "A:COMPOSER": "Composers",
+ "A:GENRE": "Genres",
+ "A:PLAYLISTS": "Playlists",
+ "A:TRACKS": "Tracks",
+}
+
+PLAYABLE_MEDIA_TYPES = [
+ MEDIA_TYPE_ALBUM,
+ MEDIA_TYPE_ARTIST,
+ MEDIA_TYPE_COMPOSER,
+ MEDIA_TYPE_CONTRIBUTING_ARTIST,
+ MEDIA_TYPE_GENRE,
+ MEDIA_TYPE_PLAYLIST,
+ MEDIA_TYPE_TRACK,
+]
+
ATTR_SONOS_GROUP = "sonos_group"
UPNP_ERRORS_TO_IGNORE = ["701", "711", "712"]
@@ -104,6 +222,10 @@ ATTR_STATUS_LIGHT = "status_light"
UNAVAILABLE_VALUES = {"", "NOT_IMPLEMENTED", None}
+class UnknownMediaType(BrowseError):
+ """Unknown media type."""
+
+
class SonosData:
"""Storage class for platform global data."""
@@ -384,10 +506,12 @@ class SonosEntity(MediaPlayerEntity):
self._sonos_group = [self]
self._status = None
self._uri = None
+ self._media_library = pysonos.music_library.MusicLibrary(self.soco)
self._media_duration = None
self._media_position = None
self._media_position_updated_at = None
self._media_image_url = None
+ self._media_channel = None
self._media_artist = None
self._media_album_name = None
self._media_title = None
@@ -450,7 +574,10 @@ class SonosEntity(MediaPlayerEntity):
@soco_coordinator
def state(self):
"""Return the state of the entity."""
- if self._status in ("PAUSED_PLAYBACK", "STOPPED",):
+ if self._status in (
+ "PAUSED_PLAYBACK",
+ "STOPPED",
+ ):
# Sonos can consider itself "paused" but without having media loaded
# (happens if playing Spotify and via Spotify app you pick another device to play on)
if self.media_title is None:
@@ -595,6 +722,7 @@ class SonosEntity(MediaPlayerEntity):
self._uri = None
self._media_duration = None
self._media_image_url = None
+ self._media_channel = None
self._media_artist = None
self._media_album_name = None
self._media_title = None
@@ -645,9 +773,10 @@ class SonosEntity(MediaPlayerEntity):
self._clear_media_position()
try:
- library = pysonos.music_library.MusicLibrary(self.soco)
album_art_uri = variables["current_track_meta_data"].album_art_uri
- self._media_image_url = library.build_album_art_full_uri(album_art_uri)
+ self._media_image_url = self._media_library.build_album_art_full_uri(
+ album_art_uri
+ )
except (TypeError, KeyError, AttributeError):
pass
@@ -667,10 +796,13 @@ class SonosEntity(MediaPlayerEntity):
except (TypeError, KeyError, AttributeError):
pass
+ media_info = self.soco.get_current_media_info()
+
+ self._media_channel = media_info["channel"]
+
# Check if currently playing radio station is in favorites
- media_info = self.soco.avTransport.GetMediaInfo([("InstanceID", 0)])
for fav in self._favorites:
- if fav.reference.get_uri() == media_info["CurrentURI"]:
+ if fav.reference.get_uri() == media_info["uri"]:
self._source_name = fav.title
def update_media_music(self, update_media_position, track_info):
@@ -857,6 +989,12 @@ class SonosEntity(MediaPlayerEntity):
"""Image url of current playing media."""
return self._media_image_url or None
+ @property
+ @soco_coordinator
+ def media_channel(self):
+ """Channel currently playing."""
+ return self._media_channel or None
+
@property
@soco_coordinator
def media_artist(self):
@@ -1011,7 +1149,7 @@ class SonosEntity(MediaPlayerEntity):
If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue.
"""
- if media_type == MEDIA_TYPE_MUSIC:
+ if media_type in (MEDIA_TYPE_MUSIC, MEDIA_TYPE_TRACK):
if kwargs.get(ATTR_MEDIA_ENQUEUE):
try:
self.soco.add_uri_to_queue(media_id)
@@ -1025,6 +1163,10 @@ class SonosEntity(MediaPlayerEntity):
else:
self.soco.play_uri(media_id)
elif media_type == MEDIA_TYPE_PLAYLIST:
+ if media_id.startswith("S:"):
+ item = get_media(self._media_library, media_id, media_type)
+ self.soco.play_uri(item.get_uri())
+ return
try:
playlists = self.soco.get_sonos_playlists()
playlist = next(p for p in playlists if p.title == media_id)
@@ -1033,6 +1175,14 @@ class SonosEntity(MediaPlayerEntity):
self.soco.play_from_queue(0)
except StopIteration:
_LOGGER.error('Could not find a Sonos playlist named "%s"', media_id)
+ elif media_type in PLAYABLE_MEDIA_TYPES:
+ item = get_media(self._media_library, media_id, media_type)
+
+ if not item:
+ _LOGGER.error('Could not find "%s" in the library', media_id)
+ return
+
+ self.soco.play_uri(item.get_uri())
else:
_LOGGER.error('Sonos does not support a media type of "%s"', media_type)
@@ -1281,3 +1431,208 @@ class SonosEntity(MediaPlayerEntity):
attributes[ATTR_QUEUE_POSITION] = self.queue_position
return attributes
+
+ async def async_browse_media(self, media_content_type=None, media_content_id=None):
+ """Implement the websocket media browsing helper."""
+ if media_content_type in [None, "library"]:
+ return await self.hass.async_add_executor_job(
+ library_payload, self._media_library
+ )
+
+ payload = {
+ "search_type": media_content_type,
+ "idstring": media_content_id,
+ }
+ response = await self.hass.async_add_executor_job(
+ build_item_response, self._media_library, payload
+ )
+ if response is None:
+ raise BrowseError(
+ f"Media not found: {media_content_type} / {media_content_id}"
+ )
+ return response
+
+
+def build_item_response(media_library, payload):
+ """Create response payload for the provided media query."""
+ if payload["search_type"] == MEDIA_TYPE_ALBUM and payload["idstring"].startswith(
+ ("A:GENRE", "A:COMPOSER")
+ ):
+ payload["idstring"] = "A:ALBUMARTIST/" + "/".join(
+ payload["idstring"].split("/")[2:]
+ )
+
+ media = media_library.browse_by_idstring(
+ MEDIA_TYPES_TO_SONOS[payload["search_type"]],
+ payload["idstring"],
+ full_album_art_uri=True,
+ max_items=0,
+ )
+
+ if media is None:
+ return
+
+ thumbnail = None
+ title = None
+
+ # Fetch album info for titles and thumbnails
+ # Can't be extracted from track info
+ if (
+ payload["search_type"] == MEDIA_TYPE_ALBUM
+ and media[0].item_class == "object.item.audioItem.musicTrack"
+ ):
+ item = get_media(media_library, payload["idstring"], SONOS_ALBUM_ARTIST)
+ title = getattr(item, "title", None)
+ thumbnail = getattr(item, "album_art_uri", media[0].album_art_uri)
+
+ if not title:
+ try:
+ title = urllib.parse.unquote(payload["idstring"].split("/")[1])
+ except IndexError:
+ title = LIBRARY_TITLES_MAPPING[payload["idstring"]]
+
+ try:
+ media_class = SONOS_TO_MEDIA_CLASSES[
+ MEDIA_TYPES_TO_SONOS[payload["search_type"]]
+ ]
+ except KeyError:
+ _LOGGER.debug("Unknown media type received %s", payload["search_type"])
+ return None
+
+ children = []
+ for item in media:
+ try:
+ children.append(item_payload(item))
+ except UnknownMediaType:
+ pass
+
+ return BrowseMedia(
+ title=title,
+ thumbnail=thumbnail,
+ media_class=media_class,
+ media_content_id=payload["idstring"],
+ media_content_type=payload["search_type"],
+ children=children,
+ can_play=can_play(payload["search_type"]),
+ can_expand=can_expand(payload["search_type"]),
+ )
+
+
+def item_payload(item):
+ """
+ Create response payload for a single media item.
+
+ Used by async_browse_media.
+ """
+ media_type = get_media_type(item)
+ try:
+ media_class = SONOS_TO_MEDIA_CLASSES[media_type]
+ except KeyError as err:
+ _LOGGER.debug("Unknown media type received %s", media_type)
+ raise UnknownMediaType from err
+ return BrowseMedia(
+ title=item.title,
+ thumbnail=getattr(item, "album_art_uri", None),
+ media_class=media_class,
+ media_content_id=get_content_id(item),
+ media_content_type=SONOS_TO_MEDIA_TYPES[media_type],
+ can_play=can_play(item.item_class),
+ can_expand=can_expand(item),
+ )
+
+
+def library_payload(media_library):
+ """
+ Create response payload to describe contents of a specific library.
+
+ Used by async_browse_media.
+ """
+ if not media_library.browse_by_idstring(
+ "tracks",
+ "",
+ max_items=1,
+ ):
+ raise BrowseError("Local library not found")
+
+ children = []
+ for item in media_library.browse():
+ try:
+ children.append(item_payload(item))
+ except UnknownMediaType:
+ pass
+
+ return BrowseMedia(
+ title="Music Library",
+ media_class=MEDIA_CLASS_DIRECTORY,
+ media_content_id="library",
+ media_content_type="library",
+ can_play=False,
+ can_expand=True,
+ children=children,
+ )
+
+
+def get_media_type(item):
+ """Extract media type of item."""
+ if item.item_class == "object.item.audioItem.musicTrack":
+ return SONOS_TRACKS
+
+ if (
+ item.item_class == "object.container.album.musicAlbum"
+ and SONOS_TYPES_MAPPING.get(item.item_id.split("/")[0])
+ in [
+ SONOS_ALBUM_ARTIST,
+ SONOS_GENRE,
+ ]
+ ):
+ return SONOS_TYPES_MAPPING[item.item_class]
+
+ return SONOS_TYPES_MAPPING.get(item.item_id.split("/")[0], item.item_class)
+
+
+def can_play(item):
+ """
+ Test if playable.
+
+ Used by async_browse_media.
+ """
+ return SONOS_TO_MEDIA_TYPES.get(item) in PLAYABLE_MEDIA_TYPES
+
+
+def can_expand(item):
+ """
+ Test if expandable.
+
+ Used by async_browse_media.
+ """
+ if isinstance(item, str):
+ return SONOS_TYPES_MAPPING.get(item) in EXPANDABLE_MEDIA_TYPES
+
+ if SONOS_TO_MEDIA_TYPES.get(item.item_class) in EXPANDABLE_MEDIA_TYPES:
+ return True
+
+ return SONOS_TYPES_MAPPING.get(item.item_id) in EXPANDABLE_MEDIA_TYPES
+
+
+def get_content_id(item):
+ """Extract content id or uri."""
+ if item.item_class == "object.item.audioItem.musicTrack":
+ return item.get_uri()
+ return item.item_id
+
+
+def get_media(media_library, item_id, search_type):
+ """Fetch media/album."""
+ search_type = MEDIA_TYPES_TO_SONOS.get(search_type, search_type)
+
+ if not item_id.startswith("A:ALBUM") and search_type == SONOS_ALBUM:
+ item_id = "A:ALBUMARTIST/" + "/".join(item_id.split("/")[2:])
+
+ for item in media_library.browse_by_idstring(
+ search_type,
+ "/".join(item_id.split("/")[:-1]),
+ full_album_art_uri=True,
+ max_items=0,
+ ):
+ if item.item_id == item_id:
+ return item
diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py
index 6fd2dec5efd..32562251ed4 100644
--- a/homeassistant/components/speedtestdotnet/__init__.py
+++ b/homeassistant/components/speedtestdotnet/__init__.py
@@ -11,7 +11,7 @@ from homeassistant.const import (
CONF_SCAN_INTERVAL,
EVENT_HOMEASSISTANT_STARTED,
)
-from homeassistant.core import CoreState
+from homeassistant.core import CoreState, callback
from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -35,7 +35,7 @@ CONFIG_SCHEMA = vol.Schema(
vol.Optional(CONF_SERVER_ID): cv.positive_int,
vol.Optional(
CONF_SCAN_INTERVAL, default=timedelta(minutes=DEFAULT_SCAN_INTERVAL)
- ): vol.All(cv.time_period, cv.positive_timedelta),
+ ): cv.positive_time_period,
vol.Optional(CONF_MANUAL, default=False): cv.boolean,
vol.Optional(
CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)
@@ -108,6 +108,8 @@ async def async_unload_entry(hass, config_entry):
"""Unload SpeedTest Entry from config_entry."""
hass.services.async_remove(DOMAIN, SPEED_TEST_SERVICE)
+ hass.data[DOMAIN].async_unload()
+
await hass.config_entries.async_forward_entry_unload(config_entry, "sensor")
hass.data.pop(DOMAIN)
@@ -124,8 +126,12 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator):
self.config_entry = config_entry
self.api = None
self.servers = {}
+ self._unsub_update_listener = None
super().__init__(
- self.hass, _LOGGER, name=DOMAIN, update_method=self.async_update,
+ self.hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_method=self.async_update,
)
def update_servers(self):
@@ -137,9 +143,12 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator):
self.servers[DEFAULT_SERVER] = {}
for server in sorted(
- server_list.values(), key=lambda server: server[0]["country"]
+ server_list.values(),
+ key=lambda server: server[0]["country"] + server[0]["sponsor"],
):
- self.servers[f"{server[0]['country']} - {server[0]['sponsor']}"] = server[0]
+ self.servers[
+ f"{server[0]['country']} - {server[0]['sponsor']} - {server[0]['name']}"
+ ] = server[0]
def update_data(self):
"""Get the latest data from speedtest.net."""
@@ -163,8 +172,8 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator):
"""Update Speedtest data."""
try:
return await self.hass.async_add_executor_job(self.update_data)
- except (speedtest.ConfigRetrievalError, speedtest.NoMatchedServers):
- raise UpdateFailed
+ except (speedtest.ConfigRetrievalError, speedtest.NoMatchedServers) as err:
+ raise UpdateFailed from err
async def async_set_options(self):
"""Set options for entry."""
@@ -183,8 +192,8 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator):
"""Set up SpeedTest."""
try:
self.api = await self.hass.async_add_executor_job(speedtest.Speedtest)
- except speedtest.ConfigRetrievalError:
- raise ConfigEntryNotReady
+ except speedtest.ConfigRetrievalError as err:
+ raise ConfigEntryNotReady from err
async def request_update(call):
"""Request update."""
@@ -196,7 +205,17 @@ class SpeedTestDataCoordinator(DataUpdateCoordinator):
self.hass.services.async_register(DOMAIN, SPEED_TEST_SERVICE, request_update)
- self.config_entry.add_update_listener(options_updated_listener)
+ self._unsub_update_listener = self.config_entry.add_update_listener(
+ options_updated_listener
+ )
+
+ @callback
+ def async_unload(self):
+ """Unload the coordinator."""
+ if not self._unsub_update_listener:
+ return
+ self._unsub_update_listener()
+ self._unsub_update_listener = None
async def options_updated_listener(hass, entry):
diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py
index d071a226a05..cf0a91a240e 100644
--- a/homeassistant/components/speedtestdotnet/sensor.py
+++ b/homeassistant/components/speedtestdotnet/sensor.py
@@ -4,6 +4,7 @@ import logging
from homeassistant.const import ATTR_ATTRIBUTION
from homeassistant.core import callback
from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
ATTR_BYTES_RECEIVED,
@@ -33,13 +34,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async_add_entities(entities)
-class SpeedtestSensor(RestoreEntity):
+class SpeedtestSensor(CoordinatorEntity, RestoreEntity):
"""Implementation of a speedtest.net sensor."""
def __init__(self, coordinator, sensor_type):
"""Initialize the sensor."""
+ super().__init__(coordinator)
self._name = SENSOR_TYPES[sensor_type][0]
- self.coordinator = coordinator
self.type = sensor_type
self._unit_of_measurement = SENSOR_TYPES[self.type][1]
self._state = None
@@ -69,11 +70,6 @@ class SpeedtestSensor(RestoreEntity):
"""Return icon."""
return ICON
- @property
- def should_poll(self):
- """Return the polling requirement for this sensor."""
- return False
-
@property
def device_state_attributes(self):
"""Return the state attributes."""
@@ -118,7 +114,3 @@ class SpeedtestSensor(RestoreEntity):
self._state = round(self.coordinator.data["download"] / 10 ** 6, 2)
elif self.type == "upload":
self._state = round(self.coordinator.data["upload"] / 10 ** 6, 2)
-
- async def async_update(self):
- """Request coordinator to update data."""
- await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/speedtestdotnet/translations/zh-Hant.json b/homeassistant/components/speedtestdotnet/translations/zh-Hant.json
index 3f7df7d8ca1..1661030b2f9 100644
--- a/homeassistant/components/speedtestdotnet/translations/zh-Hant.json
+++ b/homeassistant/components/speedtestdotnet/translations/zh-Hant.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002",
+ "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u8a2d\u5099\u5373\u53ef\u3002",
"wrong_server_id": "\u4f3a\u670d\u5668 ID \u7121\u6548"
},
"step": {
diff --git a/homeassistant/components/spider/config_flow.py b/homeassistant/components/spider/config_flow.py
index e1026f344b0..c8c31221a50 100644
--- a/homeassistant/components/spider/config_flow.py
+++ b/homeassistant/components/spider/config_flow.py
@@ -62,7 +62,10 @@ class SpiderConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
result = await self.hass.async_add_executor_job(self._try_connect)
if result == RESULT_SUCCESS:
- return self.async_create_entry(title=DOMAIN, data=self.data,)
+ return self.async_create_entry(
+ title=DOMAIN,
+ data=self.data,
+ )
if result != RESULT_AUTH_FAILED:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
@@ -71,7 +74,9 @@ class SpiderConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "invalid_auth"
return self.async_show_form(
- step_id="user", data_schema=DATA_SCHEMA_USER, errors=errors,
+ step_id="user",
+ data_schema=DATA_SCHEMA_USER,
+ errors=errors,
)
async def async_step_import(self, import_data):
diff --git a/homeassistant/components/spider/translations/ca.json b/homeassistant/components/spider/translations/ca.json
new file mode 100644
index 00000000000..2713c58a85c
--- /dev/null
+++ b/homeassistant/components/spider/translations/ca.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Ja configurat. Nom\u00e9s \u00e9s possible una sola configuraci\u00f3."
+ },
+ "error": {
+ "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida",
+ "unknown": "Error inesperat"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Contrasenya",
+ "username": "Nom d'usuari"
+ },
+ "title": "Inici de sessi\u00f3 amb un compte de mijn.ithodaalderop.nl"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/spider/translations/es.json b/homeassistant/components/spider/translations/es.json
new file mode 100644
index 00000000000..230eb9f53c8
--- /dev/null
+++ b/homeassistant/components/spider/translations/es.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Ya est\u00e1 configurado. S\u00f3lo es posible una \u00fanica configuraci\u00f3n."
+ },
+ "error": {
+ "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida",
+ "unknown": "Error inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Contrase\u00f1a",
+ "username": "Usuario"
+ },
+ "title": "Iniciar sesi\u00f3n con la cuenta de mijn.ithodaalderop.nl"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/spider/translations/fr.json b/homeassistant/components/spider/translations/fr.json
new file mode 100644
index 00000000000..807ba246694
--- /dev/null
+++ b/homeassistant/components/spider/translations/fr.json
@@ -0,0 +1,7 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/spider/translations/it.json b/homeassistant/components/spider/translations/it.json
new file mode 100644
index 00000000000..839d7a62a31
--- /dev/null
+++ b/homeassistant/components/spider/translations/it.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Gi\u00e0 configurato. \u00c8 possibile una sola configurazione."
+ },
+ "error": {
+ "invalid_auth": "Autenticazione non valida",
+ "unknown": "Errore imprevisto"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Password",
+ "username": "Nome utente"
+ },
+ "title": "Accedi con l'account mijn.ithodaalderop.nl"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/spider/translations/lb.json b/homeassistant/components/spider/translations/lb.json
new file mode 100644
index 00000000000..5d83fc8fc16
--- /dev/null
+++ b/homeassistant/components/spider/translations/lb.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Scho konfigur\u00e9iert. N\u00ebmmen eng eenzeg Konfiguratioun m\u00e9iglech."
+ },
+ "error": {
+ "invalid_auth": "Ong\u00eblteg Authentifikatioun",
+ "unknown": "Onerwaarte Feeler"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Passwuert",
+ "username": "Benotzernumm"
+ },
+ "title": "Mam mijn.ithodaalderop.nl Kont verbannen"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/spider/translations/no.json b/homeassistant/components/spider/translations/no.json
new file mode 100644
index 00000000000..934e7b686e9
--- /dev/null
+++ b/homeassistant/components/spider/translations/no.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Allerede konfigurert. Bare \u00e9n enkelt konfigurasjon er mulig."
+ },
+ "error": {
+ "invalid_auth": "Ugyldig godkjenning",
+ "unknown": "Uventet feil"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Passord",
+ "username": "Brukernavn"
+ },
+ "title": "Logg p\u00e5 med min.ithodaalderop.nl-konto"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/spider/translations/pt.json b/homeassistant/components/spider/translations/pt.json
new file mode 100644
index 00000000000..0ef1127e19d
--- /dev/null
+++ b/homeassistant/components/spider/translations/pt.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "J\u00e1 configurado. Apenas uma \u00fanica configura\u00e7\u00e3o \u00e9 poss\u00edvel."
+ },
+ "error": {
+ "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida",
+ "unknown": "Erro inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Palavra-passe",
+ "username": "Nome de Utilizador"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/spider/translations/ru.json b/homeassistant/components/spider/translations/ru.json
new file mode 100644
index 00000000000..983f2b94361
--- /dev/null
+++ b/homeassistant/components/spider/translations/ru.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e."
+ },
+ "error": {
+ "invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.",
+ "unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
+ "username": "\u041b\u043e\u0433\u0438\u043d"
+ },
+ "title": "\u0412\u0445\u043e\u0434 \u0432 \u0441\u0438\u0441\u0442\u0435\u043c\u0443 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 mijn.ithodaalderop.nl"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/spider/translations/zh-Hant.json b/homeassistant/components/spider/translations/zh-Hant.json
new file mode 100644
index 00000000000..96b9ad519d8
--- /dev/null
+++ b/homeassistant/components/spider/translations/zh-Hant.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44\u8a2d\u5099\u3002"
+ },
+ "error": {
+ "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548",
+ "unknown": "\u672a\u9810\u671f\u932f\u8aa4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "\u5bc6\u78bc",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31"
+ },
+ "title": "\u4ee5 mijn.ithodaalderop.nl \u5e33\u865f\u767b\u5165"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py
index 619bcdb471f..9459a4923c8 100644
--- a/homeassistant/components/spotify/__init__.py
+++ b/homeassistant/components/spotify/__init__.py
@@ -1,4 +1,5 @@
"""The spotify integration."""
+import logging
from spotipy import Spotify, SpotifyException
import voluptuous as vol
@@ -16,7 +17,15 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
)
from homeassistant.helpers.typing import ConfigType
-from .const import DATA_SPOTIFY_CLIENT, DATA_SPOTIFY_ME, DATA_SPOTIFY_SESSION, DOMAIN
+from .const import (
+ DATA_SPOTIFY_CLIENT,
+ DATA_SPOTIFY_ME,
+ DATA_SPOTIFY_SESSION,
+ DOMAIN,
+ SPOTIFY_SCOPES,
+)
+
+_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
{
@@ -61,8 +70,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
current_user = await hass.async_add_executor_job(spotify.me)
- except SpotifyException:
- raise ConfigEntryNotReady
+ except SpotifyException as err:
+ raise ConfigEntryNotReady from err
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
@@ -71,6 +80,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
DATA_SPOTIFY_SESSION: session,
}
+ if not set(session.token["scope"].split(" ")).issuperset(SPOTIFY_SCOPES):
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": "reauth"},
+ data=entry.data,
+ )
+ )
+
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, MEDIA_PLAYER_DOMAIN)
)
diff --git a/homeassistant/components/spotify/config_flow.py b/homeassistant/components/spotify/config_flow.py
index d619d3b2b10..14e45d58b39 100644
--- a/homeassistant/components/spotify/config_flow.py
+++ b/homeassistant/components/spotify/config_flow.py
@@ -1,12 +1,15 @@
"""Config flow for Spotify."""
import logging
+from typing import Any, Dict, Optional
from spotipy import Spotify
+import voluptuous as vol
from homeassistant import config_entries
+from homeassistant.components import persistent_notification
from homeassistant.helpers import config_entry_oauth2_flow
-from .const import DOMAIN
+from .const import DOMAIN, SPOTIFY_SCOPES
_LOGGER = logging.getLogger(__name__)
@@ -17,27 +20,25 @@ class SpotifyFlowHandler(
"""Config flow to handle Spotify OAuth2 authentication."""
DOMAIN = DOMAIN
+ VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+ def __init__(self) -> None:
+ """Instantiate config flow."""
+ super().__init__()
+ self.entry: Optional[Dict[str, Any]] = None
+
@property
def logger(self) -> logging.Logger:
"""Return logger."""
return logging.getLogger(__name__)
@property
- def extra_authorize_data(self) -> dict:
+ def extra_authorize_data(self) -> Dict[str, Any]:
"""Extra data that needs to be appended to the authorize url."""
- scopes = [
- # Needed to be able to control playback
- "user-modify-playback-state",
- # Needed in order to read available devices
- "user-read-playback-state",
- # Needed to determine if the user has Spotify Premium
- "user-read-private",
- ]
- return {"scope": ",".join(scopes)}
+ return {"scope": ",".join(SPOTIFY_SCOPES)}
- async def async_oauth_create_entry(self, data: dict) -> dict:
+ async def async_oauth_create_entry(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Create an entry for Spotify."""
spotify = Spotify(auth=data["token"]["access_token"])
@@ -48,6 +49,9 @@ class SpotifyFlowHandler(
name = data["id"] = current_user["id"]
+ if self.entry and self.entry["id"] != current_user["id"]:
+ return self.async_abort(reason="reauth_account_mismatch")
+
if current_user.get("display_name"):
name = current_user["display_name"]
data["name"] = name
@@ -55,3 +59,37 @@ class SpotifyFlowHandler(
await self.async_set_unique_id(current_user["id"])
return self.async_create_entry(title=name, data=data)
+
+ async def async_step_reauth(self, entry: Dict[str, Any]) -> Dict[str, Any]:
+ """Perform reauth upon migration of old entries."""
+ if entry:
+ self.entry = entry
+
+ assert self.hass
+ persistent_notification.async_create(
+ self.hass,
+ f"Spotify integration for account {entry['id']} needs to be re-authenticated. Please go to the integrations page to re-configure it.",
+ "Spotify re-authentication",
+ "spotify_reauth",
+ )
+
+ return await self.async_step_reauth_confirm()
+
+ async def async_step_reauth_confirm(
+ self, user_input: Optional[Dict[str, Any]] = None
+ ) -> Dict[str, Any]:
+ """Confirm reauth dialog."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id="reauth_confirm",
+ description_placeholders={"account": self.entry["id"]},
+ data_schema=vol.Schema({}),
+ errors={},
+ )
+
+ assert self.hass
+ persistent_notification.async_dismiss(self.hass, "spotify_reauth")
+
+ return await self.async_step_pick_implementation(
+ user_input={"implementation": self.entry["auth_implementation"]}
+ )
diff --git a/homeassistant/components/spotify/const.py b/homeassistant/components/spotify/const.py
index f508c9b2938..6b677aca996 100644
--- a/homeassistant/components/spotify/const.py
+++ b/homeassistant/components/spotify/const.py
@@ -5,3 +5,20 @@ DOMAIN = "spotify"
DATA_SPOTIFY_CLIENT = "spotify_client"
DATA_SPOTIFY_ME = "spotify_me"
DATA_SPOTIFY_SESSION = "spotify_session"
+
+SPOTIFY_SCOPES = [
+ # Needed to be able to control playback
+ "user-modify-playback-state",
+ # Needed in order to read available devices
+ "user-read-playback-state",
+ # Needed to determine if the user has Spotify Premium
+ "user-read-private",
+ # Needed for media browsing
+ "playlist-read-private",
+ "playlist-read-collaborative",
+ "user-library-read",
+ "user-top-read",
+ "user-read-playback-position",
+ "user-read-recently-played",
+ "user-follow-read",
+]
diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json
index 88e0c938a28..d17cd43c47f 100644
--- a/homeassistant/components/spotify/manifest.json
+++ b/homeassistant/components/spotify/manifest.json
@@ -2,7 +2,7 @@
"domain": "spotify",
"name": "Spotify",
"documentation": "https://www.home-assistant.io/integrations/spotify",
- "requirements": ["spotipy==2.12.0"],
+ "requirements": ["spotipy==2.14.0"],
"zeroconf": ["_spotify-connect._tcp.local."],
"dependencies": ["http"],
"codeowners": ["@frenck"],
diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py
index 0446500dba2..0782cb2f390 100644
--- a/homeassistant/components/spotify/media_player.py
+++ b/homeassistant/components/spotify/media_player.py
@@ -9,10 +9,23 @@ from aiohttp import ClientError
from spotipy import Spotify, SpotifyException
from yarl import URL
-from homeassistant.components.media_player import MediaPlayerEntity
+from homeassistant.components.media_player import BrowseMedia, MediaPlayerEntity
from homeassistant.components.media_player.const import (
+ MEDIA_CLASS_ALBUM,
+ MEDIA_CLASS_ARTIST,
+ MEDIA_CLASS_DIRECTORY,
+ MEDIA_CLASS_EPISODE,
+ MEDIA_CLASS_GENRE,
+ MEDIA_CLASS_PLAYLIST,
+ MEDIA_CLASS_PODCAST,
+ MEDIA_CLASS_TRACK,
+ MEDIA_TYPE_ALBUM,
+ MEDIA_TYPE_ARTIST,
+ MEDIA_TYPE_EPISODE,
MEDIA_TYPE_MUSIC,
MEDIA_TYPE_PLAYLIST,
+ MEDIA_TYPE_TRACK,
+ SUPPORT_BROWSE_MEDIA,
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
SUPPORT_PLAY,
@@ -23,6 +36,7 @@ from homeassistant.components.media_player.const import (
SUPPORT_SHUFFLE_SET,
SUPPORT_VOLUME_SET,
)
+from homeassistant.components.media_player.errors import BrowseError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_ID,
@@ -36,7 +50,13 @@ from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session
from homeassistant.helpers.entity import Entity
from homeassistant.util.dt import utc_from_timestamp
-from .const import DATA_SPOTIFY_CLIENT, DATA_SPOTIFY_ME, DATA_SPOTIFY_SESSION, DOMAIN
+from .const import (
+ DATA_SPOTIFY_CLIENT,
+ DATA_SPOTIFY_ME,
+ DATA_SPOTIFY_SESSION,
+ DOMAIN,
+ SPOTIFY_SCOPES,
+)
_LOGGER = logging.getLogger(__name__)
@@ -45,7 +65,8 @@ ICON = "mdi:spotify"
SCAN_INTERVAL = timedelta(seconds=30)
SUPPORT_SPOTIFY = (
- SUPPORT_NEXT_TRACK
+ SUPPORT_BROWSE_MEDIA
+ | SUPPORT_NEXT_TRACK
| SUPPORT_PAUSE
| SUPPORT_PLAY
| SUPPORT_PLAY_MEDIA
@@ -56,6 +77,95 @@ SUPPORT_SPOTIFY = (
| SUPPORT_VOLUME_SET
)
+BROWSE_LIMIT = 48
+
+MEDIA_TYPE_SHOW = "show"
+
+PLAYABLE_MEDIA_TYPES = [
+ MEDIA_TYPE_PLAYLIST,
+ MEDIA_TYPE_ALBUM,
+ MEDIA_TYPE_ARTIST,
+ MEDIA_TYPE_EPISODE,
+ MEDIA_TYPE_SHOW,
+ MEDIA_TYPE_TRACK,
+]
+
+LIBRARY_MAP = {
+ "current_user_playlists": "Playlists",
+ "current_user_followed_artists": "Artists",
+ "current_user_saved_albums": "Albums",
+ "current_user_saved_tracks": "Tracks",
+ "current_user_saved_shows": "Podcasts",
+ "current_user_recently_played": "Recently played",
+ "current_user_top_artists": "Top Artists",
+ "current_user_top_tracks": "Top Tracks",
+ "categories": "Categories",
+ "featured_playlists": "Featured Playlists",
+ "new_releases": "New Releases",
+}
+
+CONTENT_TYPE_MEDIA_CLASS = {
+ "current_user_playlists": {
+ "parent": MEDIA_CLASS_DIRECTORY,
+ "children": MEDIA_CLASS_PLAYLIST,
+ },
+ "current_user_followed_artists": {
+ "parent": MEDIA_CLASS_DIRECTORY,
+ "children": MEDIA_CLASS_ARTIST,
+ },
+ "current_user_saved_albums": {
+ "parent": MEDIA_CLASS_DIRECTORY,
+ "children": MEDIA_CLASS_ALBUM,
+ },
+ "current_user_saved_tracks": {
+ "parent": MEDIA_CLASS_DIRECTORY,
+ "children": MEDIA_CLASS_TRACK,
+ },
+ "current_user_saved_shows": {
+ "parent": MEDIA_CLASS_DIRECTORY,
+ "children": MEDIA_CLASS_PODCAST,
+ },
+ "current_user_recently_played": {
+ "parent": MEDIA_CLASS_DIRECTORY,
+ "children": MEDIA_CLASS_TRACK,
+ },
+ "current_user_top_artists": {
+ "parent": MEDIA_CLASS_DIRECTORY,
+ "children": MEDIA_CLASS_ARTIST,
+ },
+ "current_user_top_tracks": {
+ "parent": MEDIA_CLASS_DIRECTORY,
+ "children": MEDIA_CLASS_TRACK,
+ },
+ "featured_playlists": {
+ "parent": MEDIA_CLASS_DIRECTORY,
+ "children": MEDIA_CLASS_PLAYLIST,
+ },
+ "categories": {"parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_GENRE},
+ "category_playlists": {
+ "parent": MEDIA_CLASS_DIRECTORY,
+ "children": MEDIA_CLASS_PLAYLIST,
+ },
+ "new_releases": {"parent": MEDIA_CLASS_DIRECTORY, "children": MEDIA_CLASS_ALBUM},
+ MEDIA_TYPE_PLAYLIST: {
+ "parent": MEDIA_CLASS_PLAYLIST,
+ "children": MEDIA_CLASS_TRACK,
+ },
+ MEDIA_TYPE_ALBUM: {"parent": MEDIA_CLASS_ALBUM, "children": MEDIA_CLASS_TRACK},
+ MEDIA_TYPE_ARTIST: {"parent": MEDIA_CLASS_ARTIST, "children": MEDIA_CLASS_ALBUM},
+ MEDIA_TYPE_EPISODE: {"parent": MEDIA_CLASS_EPISODE, "children": None},
+ MEDIA_TYPE_SHOW: {"parent": MEDIA_CLASS_PODCAST, "children": MEDIA_CLASS_EPISODE},
+ MEDIA_TYPE_TRACK: {"parent": MEDIA_CLASS_TRACK, "children": None},
+}
+
+
+class MissingMediaInformation(BrowseError):
+ """Missing media required information."""
+
+
+class UnknownMediaType(BrowseError):
+ """Unknown media type."""
+
async def async_setup_entry(
hass: HomeAssistant,
@@ -108,6 +218,7 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
self._name = f"Spotify {name}"
self._session = session
self._spotify = spotify
+ self._scope_ok = set(session.token["scope"].split(" ")) == set(SPOTIFY_SCOPES)
self._currently_playing: Optional[dict] = {}
self._devices: Optional[List[dict]] = []
@@ -203,7 +314,7 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
or not self._currently_playing["item"]["album"]["images"]
):
return None
- return self._currently_playing["item"]["album"]["images"][0]["url"]
+ return fetch_image_url(self._currently_playing["item"]["album"])
@property
def media_image_remotely_accessible(self) -> bool:
@@ -308,14 +419,17 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
# Yet, they do generate those types of URI in their official clients.
media_id = str(URL(media_id).with_query(None).with_fragment(None))
- if media_type == MEDIA_TYPE_MUSIC:
+ if media_type in (MEDIA_TYPE_TRACK, MEDIA_TYPE_EPISODE, MEDIA_TYPE_MUSIC):
kwargs["uris"] = [media_id]
- elif media_type == MEDIA_TYPE_PLAYLIST:
+ elif media_type in PLAYABLE_MEDIA_TYPES:
kwargs["context_uri"] = media_id
else:
_LOGGER.error("Media type %s is not supported", media_type)
return
+ if not self._currently_playing.get("device") and self._devices:
+ kwargs["device_id"] = self._devices[0].get("id")
+
self._spotify.start_playback(**kwargs)
@spotify_exception_handler
@@ -355,3 +469,243 @@ class SpotifyMediaPlayer(MediaPlayerEntity):
devices = self._spotify.devices() or {}
self._devices = devices.get("devices", [])
+
+ async def async_browse_media(self, media_content_type=None, media_content_id=None):
+ """Implement the websocket media browsing helper."""
+
+ if not self._scope_ok:
+ raise NotImplementedError
+
+ if media_content_type in [None, "library"]:
+ return await self.hass.async_add_executor_job(library_payload)
+
+ payload = {
+ "media_content_type": media_content_type,
+ "media_content_id": media_content_id,
+ }
+ response = await self.hass.async_add_executor_job(
+ build_item_response, self._spotify, self._me, payload
+ )
+ if response is None:
+ raise BrowseError(
+ f"Media not found: {media_content_type} / {media_content_id}"
+ )
+ return response
+
+
+def build_item_response(spotify, user, payload):
+ """Create response payload for the provided media query."""
+ media_content_type = payload["media_content_type"]
+ media_content_id = payload["media_content_id"]
+ title = None
+ image = None
+ if media_content_type == "current_user_playlists":
+ media = spotify.current_user_playlists(limit=BROWSE_LIMIT)
+ items = media.get("items", [])
+ elif media_content_type == "current_user_followed_artists":
+ media = spotify.current_user_followed_artists(limit=BROWSE_LIMIT)
+ items = media.get("artists", {}).get("items", [])
+ elif media_content_type == "current_user_saved_albums":
+ media = spotify.current_user_saved_albums(limit=BROWSE_LIMIT)
+ items = [item["album"] for item in media.get("items", [])]
+ elif media_content_type == "current_user_saved_tracks":
+ media = spotify.current_user_saved_tracks(limit=BROWSE_LIMIT)
+ items = [item["track"] for item in media.get("items", [])]
+ elif media_content_type == "current_user_saved_shows":
+ media = spotify.current_user_saved_shows(limit=BROWSE_LIMIT)
+ items = [item["show"] for item in media.get("items", [])]
+ elif media_content_type == "current_user_recently_played":
+ media = spotify.current_user_recently_played(limit=BROWSE_LIMIT)
+ items = [item["track"] for item in media.get("items", [])]
+ elif media_content_type == "current_user_top_artists":
+ media = spotify.current_user_top_artists(limit=BROWSE_LIMIT)
+ items = media.get("items", [])
+ elif media_content_type == "current_user_top_tracks":
+ media = spotify.current_user_top_tracks(limit=BROWSE_LIMIT)
+ items = media.get("items", [])
+ elif media_content_type == "featured_playlists":
+ media = spotify.featured_playlists(country=user["country"], limit=BROWSE_LIMIT)
+ items = media.get("playlists", {}).get("items", [])
+ elif media_content_type == "categories":
+ media = spotify.categories(country=user["country"], limit=BROWSE_LIMIT)
+ items = media.get("categories", {}).get("items", [])
+ elif media_content_type == "category_playlists":
+ media = spotify.category_playlists(
+ category_id=media_content_id,
+ country=user["country"],
+ limit=BROWSE_LIMIT,
+ )
+ category = spotify.category(media_content_id, country=user["country"])
+ title = category.get("name")
+ image = fetch_image_url(category, key="icons")
+ items = media.get("playlists", {}).get("items", [])
+ elif media_content_type == "new_releases":
+ media = spotify.new_releases(country=user["country"], limit=BROWSE_LIMIT)
+ items = media.get("albums", {}).get("items", [])
+ elif media_content_type == MEDIA_TYPE_PLAYLIST:
+ media = spotify.playlist(media_content_id)
+ items = [item["track"] for item in media.get("tracks", {}).get("items", [])]
+ elif media_content_type == MEDIA_TYPE_ALBUM:
+ media = spotify.album(media_content_id)
+ items = media.get("tracks", {}).get("items", [])
+ elif media_content_type == MEDIA_TYPE_ARTIST:
+ media = spotify.artist_albums(media_content_id, limit=BROWSE_LIMIT)
+ artist = spotify.artist(media_content_id)
+ title = artist.get("name")
+ image = fetch_image_url(artist)
+ items = media.get("items", [])
+ elif media_content_type == MEDIA_TYPE_SHOW:
+ media = spotify.show_episodes(media_content_id, limit=BROWSE_LIMIT)
+ show = spotify.show(media_content_id)
+ title = show.get("name")
+ image = fetch_image_url(show)
+ items = media.get("items", [])
+ else:
+ media = None
+ items = []
+
+ if media is None:
+ return None
+
+ try:
+ media_class = CONTENT_TYPE_MEDIA_CLASS[media_content_type]
+ except KeyError:
+ _LOGGER.debug("Unknown media type received: %s", media_content_type)
+ return None
+
+ if media_content_type == "categories":
+ media_item = BrowseMedia(
+ title=LIBRARY_MAP.get(media_content_id),
+ media_class=media_class["parent"],
+ children_media_class=media_class["children"],
+ media_content_id=media_content_id,
+ media_content_type=media_content_type,
+ can_play=False,
+ can_expand=True,
+ children=[],
+ )
+ for item in items:
+ try:
+ item_id = item["id"]
+ except KeyError:
+ _LOGGER.debug("Missing id for media item: %s", item)
+ continue
+ media_item.children.append(
+ BrowseMedia(
+ title=item.get("name"),
+ media_class=MEDIA_CLASS_PLAYLIST,
+ children_media_class=MEDIA_CLASS_TRACK,
+ media_content_id=item_id,
+ media_content_type="category_playlists",
+ thumbnail=fetch_image_url(item, key="icons"),
+ can_play=False,
+ can_expand=True,
+ )
+ )
+ return media_item
+
+ if title is None:
+ if "name" in media:
+ title = media.get("name")
+ else:
+ title = LIBRARY_MAP.get(payload["media_content_id"])
+
+ params = {
+ "title": title,
+ "media_class": media_class["parent"],
+ "children_media_class": media_class["children"],
+ "media_content_id": media_content_id,
+ "media_content_type": media_content_type,
+ "can_play": media_content_type in PLAYABLE_MEDIA_TYPES,
+ "children": [],
+ "can_expand": True,
+ }
+ for item in items:
+ try:
+ params["children"].append(item_payload(item))
+ except (MissingMediaInformation, UnknownMediaType):
+ continue
+
+ if "images" in media:
+ params["thumbnail"] = fetch_image_url(media)
+ elif image:
+ params["thumbnail"] = image
+
+ return BrowseMedia(**params)
+
+
+def item_payload(item):
+ """
+ Create response payload for a single media item.
+
+ Used by async_browse_media.
+ """
+ try:
+ media_type = item["type"]
+ media_id = item["uri"]
+ except KeyError as err:
+ _LOGGER.debug("Missing type or uri for media item: %s", item)
+ raise MissingMediaInformation from err
+
+ try:
+ media_class = CONTENT_TYPE_MEDIA_CLASS[media_type]
+ except KeyError as err:
+ _LOGGER.debug("Unknown media type received: %s", media_type)
+ raise UnknownMediaType from err
+
+ can_expand = media_type not in [
+ MEDIA_TYPE_TRACK,
+ MEDIA_TYPE_EPISODE,
+ ]
+
+ payload = {
+ "title": item.get("name"),
+ "media_class": media_class["parent"],
+ "children_media_class": media_class["children"],
+ "media_content_id": media_id,
+ "media_content_type": media_type,
+ "can_play": media_type in PLAYABLE_MEDIA_TYPES,
+ "can_expand": can_expand,
+ }
+
+ if "images" in item:
+ payload["thumbnail"] = fetch_image_url(item)
+ elif MEDIA_TYPE_ALBUM in item:
+ payload["thumbnail"] = fetch_image_url(item[MEDIA_TYPE_ALBUM])
+
+ return BrowseMedia(**payload)
+
+
+def library_payload():
+ """
+ Create response payload to describe contents of a specific library.
+
+ Used by async_browse_media.
+ """
+ library_info = {
+ "title": "Media Library",
+ "media_class": MEDIA_CLASS_DIRECTORY,
+ "media_content_id": "library",
+ "media_content_type": "library",
+ "can_play": False,
+ "can_expand": True,
+ "children": [],
+ }
+
+ for item in [{"name": n, "type": t} for t, n in LIBRARY_MAP.items()]:
+ library_info["children"].append(
+ item_payload(
+ {"name": item["name"], "type": item["type"], "uri": item["type"]}
+ )
+ )
+ response = BrowseMedia(**library_info)
+ response.children_media_class = MEDIA_CLASS_DIRECTORY
+ return response
+
+
+def fetch_image_url(item, key="images"):
+ """Fetch image url."""
+ try:
+ return item.get(key, [])[0].get("url")
+ except IndexError:
+ return None
diff --git a/homeassistant/components/spotify/strings.json b/homeassistant/components/spotify/strings.json
index c7831e31ca4..8e3fa6fc679 100644
--- a/homeassistant/components/spotify/strings.json
+++ b/homeassistant/components/spotify/strings.json
@@ -1,12 +1,18 @@
{
"config": {
"step": {
- "pick_implementation": { "title": "Pick Authentication Method" }
+ "pick_implementation": { "title": "Pick Authentication Method" },
+ "reauth_confirm": {
+ "title": "Re-authenticate with Spotify",
+ "description": "The Spotify integration needs to re-authenticate with Spotify for account: {account}"
+ }
},
"abort": {
"already_setup": "You can only configure one Spotify account.",
"authorize_url_timeout": "Timeout generating authorize url.",
- "missing_configuration": "The Spotify integration is not configured. Please follow the documentation."
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
+ "missing_configuration": "The Spotify integration is not configured. Please follow the documentation.",
+ "reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication."
},
"create_entry": { "default": "Successfully authenticated with Spotify." }
}
diff --git a/homeassistant/components/spotify/translations/ca.json b/homeassistant/components/spotify/translations/ca.json
index 887d3b7236c..005e0c5c331 100644
--- a/homeassistant/components/spotify/translations/ca.json
+++ b/homeassistant/components/spotify/translations/ca.json
@@ -3,7 +3,8 @@
"abort": {
"already_setup": "Nom\u00e9s pots configurar un \u00fanic compte amb Spotify.",
"authorize_url_timeout": "S'ha acabat el temps d'espera durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.",
- "missing_configuration": "La integraci\u00f3 Spotify no est\u00e0 configurada. Mira'n la documentaci\u00f3."
+ "missing_configuration": "La integraci\u00f3 Spotify no est\u00e0 configurada. Mira'n la documentaci\u00f3.",
+ "reauth_account_mismatch": "El compte Spotify autenticat, no coincideix amb el compte necessari per a la re-autenticaci\u00f3."
},
"create_entry": {
"default": "Autenticaci\u00f3 exitosa amb Spotify."
@@ -11,6 +12,10 @@
"step": {
"pick_implementation": {
"title": "Selecci\u00f3 del m\u00e8tode d'autenticaci\u00f3"
+ },
+ "reauth_confirm": {
+ "description": "La integraci\u00f3 de Spotify ha de tornar a autenticar-se amb el compte de Spotify: {account}",
+ "title": "Re-autenticaci\u00f3 amb Spotify"
}
}
}
diff --git a/homeassistant/components/spotify/translations/en.json b/homeassistant/components/spotify/translations/en.json
index c869d06cab3..6cdafbf061c 100644
--- a/homeassistant/components/spotify/translations/en.json
+++ b/homeassistant/components/spotify/translations/en.json
@@ -3,7 +3,8 @@
"abort": {
"already_setup": "You can only configure one Spotify account.",
"authorize_url_timeout": "Timeout generating authorize url.",
- "missing_configuration": "The Spotify integration is not configured. Please follow the documentation."
+ "missing_configuration": "The Spotify integration is not configured. Please follow the documentation.",
+ "reauth_account_mismatch": "The Spotify account authenticated with, does not match the account needed re-authentication."
},
"create_entry": {
"default": "Successfully authenticated with Spotify."
@@ -11,6 +12,10 @@
"step": {
"pick_implementation": {
"title": "Pick Authentication Method"
+ },
+ "reauth_confirm": {
+ "description": "The Spotify integration needs to re-authenticate with Spotify for account: {account}",
+ "title": "Re-authenticate with Spotify"
}
}
}
diff --git a/homeassistant/components/spotify/translations/es.json b/homeassistant/components/spotify/translations/es.json
index 71020022ef1..95c29f7a336 100644
--- a/homeassistant/components/spotify/translations/es.json
+++ b/homeassistant/components/spotify/translations/es.json
@@ -3,7 +3,8 @@
"abort": {
"already_setup": "S\u00f3lo puedes configurar una cuenta de Spotify.",
"authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n.",
- "missing_configuration": "La integraci\u00f3n de Spotify no est\u00e1 configurada. Por favor, siga la documentaci\u00f3n."
+ "missing_configuration": "La integraci\u00f3n de Spotify no est\u00e1 configurada. Por favor, siga la documentaci\u00f3n.",
+ "reauth_account_mismatch": "La cuenta de Spotify con la que est\u00e1s autenticado, no coincide con la cuenta necesaria para re-autenticaci\u00f3n."
},
"create_entry": {
"default": "Autentificado con \u00e9xito con Spotify."
@@ -11,6 +12,10 @@
"step": {
"pick_implementation": {
"title": "Elija el m\u00e9todo de autenticaci\u00f3n"
+ },
+ "reauth_confirm": {
+ "description": "La integraci\u00f3n de Spotify necesita volver a autenticarse con Spotify para la cuenta: {account}",
+ "title": "Volver a autenticar con Spotify"
}
}
}
diff --git a/homeassistant/components/spotify/translations/fr.json b/homeassistant/components/spotify/translations/fr.json
index d4adbe155fe..629aa5c681f 100644
--- a/homeassistant/components/spotify/translations/fr.json
+++ b/homeassistant/components/spotify/translations/fr.json
@@ -3,7 +3,8 @@
"abort": {
"already_setup": "Vous ne pouvez configurer qu'un seul compte Spotify.",
"authorize_url_timeout": "D\u00e9lai d'expiration g\u00e9n\u00e9rant une URL d'autorisation.",
- "missing_configuration": "L'int\u00e9gration Spotify n'est pas configur\u00e9e. Veuillez suivre la documentation."
+ "missing_configuration": "L'int\u00e9gration Spotify n'est pas configur\u00e9e. Veuillez suivre la documentation.",
+ "reauth_account_mismatch": "Le compte Spotify authentifi\u00e9 ne correspond pas au compte requis pour la r\u00e9-authentification."
},
"create_entry": {
"default": "Authentification r\u00e9ussie avec Spotify."
@@ -11,6 +12,10 @@
"step": {
"pick_implementation": {
"title": "Choisissez la m\u00e9thode d'authentification"
+ },
+ "reauth_confirm": {
+ "description": "L'int\u00e9gration de Spotify doit se r\u00e9-authentifier avec Spotify pour le compte: {account}",
+ "title": "R\u00e9-authentifier avec Spotify"
}
}
}
diff --git a/homeassistant/components/spotify/translations/it.json b/homeassistant/components/spotify/translations/it.json
index c3bfaa47f32..bb494f27860 100644
--- a/homeassistant/components/spotify/translations/it.json
+++ b/homeassistant/components/spotify/translations/it.json
@@ -3,7 +3,8 @@
"abort": {
"already_setup": "\u00c8 possibile configurare un solo account di Spotify.",
"authorize_url_timeout": "Tempo scaduto nel generare l'URL di autorizzazione",
- "missing_configuration": "L'integrazione di Spotify non \u00e8 configurata. Si prega di seguire la documentazione."
+ "missing_configuration": "L'integrazione di Spotify non \u00e8 configurata. Si prega di seguire la documentazione.",
+ "reauth_account_mismatch": "L'account Spotify con cui si \u00e8 autenticati non corrisponde all'account necessario per la ri-autenticazione."
},
"create_entry": {
"default": "Autenticato con successo con Spotify."
@@ -11,6 +12,10 @@
"step": {
"pick_implementation": {
"title": "Scegli il metodo di autenticazione"
+ },
+ "reauth_confirm": {
+ "description": "L'integrazione di Spotify deve essere nuovamente autenticata con Spotify per l'account: {account}",
+ "title": "Eseguire nuovamente l'autenticazione con Spotify"
}
}
}
diff --git a/homeassistant/components/spotify/translations/no.json b/homeassistant/components/spotify/translations/no.json
index c6e009c00c5..c2e151e5eb7 100644
--- a/homeassistant/components/spotify/translations/no.json
+++ b/homeassistant/components/spotify/translations/no.json
@@ -3,7 +3,8 @@
"abort": {
"already_setup": "Du kan bare konfigurere en Spotify-konto.",
"authorize_url_timeout": "Tidsavbrudd ved oppretting av godkjenningsadresse.",
- "missing_configuration": "Spotify-integrasjonen er ikke konfigurert. F\u00f8lg dokumentasjonen."
+ "missing_configuration": "Spotify-integrasjonen er ikke konfigurert. F\u00f8lg dokumentasjonen.",
+ "reauth_account_mismatch": "Spotify-kontoen som er autentisert med, samsvarer ikke med den kontoen som trengs re-autentisering."
},
"create_entry": {
"default": "Vellykket godkjenning med Spotify."
@@ -11,6 +12,10 @@
"step": {
"pick_implementation": {
"title": "Velg godkjenningsmetode"
+ },
+ "reauth_confirm": {
+ "description": "Spotify-integreringen m\u00e5 godkjennes p\u00e5 nytt med Spotify for konto: {account}",
+ "title": "Autentiser p\u00e5 nytt med Spotify"
}
}
}
diff --git a/homeassistant/components/spotify/translations/pl.json b/homeassistant/components/spotify/translations/pl.json
index 9dbd73661ee..0ed5a24ce16 100644
--- a/homeassistant/components/spotify/translations/pl.json
+++ b/homeassistant/components/spotify/translations/pl.json
@@ -11,6 +11,9 @@
"step": {
"pick_implementation": {
"title": "Wybierz metod\u0119 uwierzytelniania"
+ },
+ "reauth_confirm": {
+ "title": "Ponownie uwierzytelnij ze Spotify"
}
}
}
diff --git a/homeassistant/components/spotify/translations/pt.json b/homeassistant/components/spotify/translations/pt.json
new file mode 100644
index 00000000000..b459d4e6bfd
--- /dev/null
+++ b/homeassistant/components/spotify/translations/pt.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "abort": {
+ "reauth_account_mismatch": "A conta Spotify com a qual foi autenticada n\u00e3o corresponde \u00e0 conta necess\u00e1ria para a reautentica\u00e7\u00e3o."
+ },
+ "step": {
+ "reauth_confirm": {
+ "description": "A integra\u00e7\u00e3o do Spotify precisa ser reautenticada com o Spotify para a conta: {account}",
+ "title": "Reautenticar com Spotify"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/spotify/translations/ru.json b/homeassistant/components/spotify/translations/ru.json
index 5738707a3a4..c496fa389d9 100644
--- a/homeassistant/components/spotify/translations/ru.json
+++ b/homeassistant/components/spotify/translations/ru.json
@@ -3,7 +3,8 @@
"abort": {
"already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
"authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.",
- "missing_configuration": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f Spotify \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438."
+ "missing_configuration": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f Spotify \u043d\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438.",
+ "reauth_account_mismatch": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u0446\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u0430\u044f \u0443\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u043d\u0435 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438, \u0442\u0440\u0435\u0431\u0443\u044e\u0449\u0435\u0439 \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u043e\u0439 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438."
},
"create_entry": {
"default": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e."
@@ -11,6 +12,10 @@
"step": {
"pick_implementation": {
"title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u043f\u043e\u0441\u043e\u0431 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438"
+ },
+ "reauth_confirm": {
+ "description": "\u0422\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u043f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u0432 Spotify \u0434\u043b\u044f \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438: {account}",
+ "title": "\u041f\u043e\u0432\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0444\u0438\u043b\u044f"
}
}
}
diff --git a/homeassistant/components/spotify/translations/zh-Hant.json b/homeassistant/components/spotify/translations/zh-Hant.json
index 33efd72a6d8..a4fca36205f 100644
--- a/homeassistant/components/spotify/translations/zh-Hant.json
+++ b/homeassistant/components/spotify/translations/zh-Hant.json
@@ -3,7 +3,8 @@
"abort": {
"already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Spotify \u5e33\u865f\u3002",
"authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642\u3002",
- "missing_configuration": "Spotify \u6574\u5408\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002"
+ "missing_configuration": "Spotify \u6574\u5408\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002",
+ "reauth_account_mismatch": "Spotify \u6240\u8a8d\u8b49\u5e33\u865f\u8207\u5e33\u865f\u4e0d\u7b26\u5408\uff0c\u9700\u91cd\u65b0\u9032\u884c\u8a8d\u8b49\u3002"
},
"create_entry": {
"default": "\u5df2\u6210\u529f\u8a8d\u8b49 Spotify\u3002"
@@ -11,6 +12,10 @@
"step": {
"pick_implementation": {
"title": "\u9078\u64c7\u9a57\u8b49\u6a21\u5f0f"
+ },
+ "reauth_confirm": {
+ "description": "Spotify \u6574\u5408\u9700\u8981\u91cd\u65b0\u8a8d\u8b49 Spotify \u5e33\u865f\uff1a{account}",
+ "title": "\u91cd\u65b0\u8a8d\u8b49 Spotify\u3002"
}
}
}
diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json
index bdb1c0582b5..c79f29d6eed 100644
--- a/homeassistant/components/sql/manifest.json
+++ b/homeassistant/components/sql/manifest.json
@@ -2,6 +2,6 @@
"domain": "sql",
"name": "SQL",
"documentation": "https://www.home-assistant.io/integrations/sql",
- "requirements": ["sqlalchemy==1.3.18"],
+ "requirements": ["sqlalchemy==1.3.19"],
"codeowners": ["@dgomes"]
}
diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py
index f19941ed043..27656c260d3 100644
--- a/homeassistant/components/sql/sensor.py
+++ b/homeassistant/components/sql/sensor.py
@@ -122,6 +122,7 @@ class SQLSensor(Entity):
def update(self):
"""Retrieve sensor data from the query."""
+ data = None
try:
sess = self.sessionmaker()
result = sess.execute(self._query)
@@ -147,7 +148,7 @@ class SQLSensor(Entity):
finally:
sess.close()
- if self._template is not None:
+ if data is not None and self._template is not None:
self._state = self._template.async_render_with_possible_json_value(
data, None
)
diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json
index b682887779b..2ebde216130 100644
--- a/homeassistant/components/squeezebox/manifest.json
+++ b/homeassistant/components/squeezebox/manifest.json
@@ -6,7 +6,7 @@
"@rajlaud"
],
"requirements": [
- "pysqueezebox==0.2.4"
+ "pysqueezebox==0.3.1"
],
"config_flow": true
}
diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py
index 88fba2f6ccf..a4b62d33f39 100644
--- a/homeassistant/components/squeezebox/media_player.py
+++ b/homeassistant/components/squeezebox/media_player.py
@@ -1,5 +1,6 @@
"""Support for interfacing to the Logitech SqueezeBox API."""
import asyncio
+import json
import logging
from pysqueezebox import Server, async_discover
@@ -10,6 +11,7 @@ from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerEn
from homeassistant.components.media_player.const import (
ATTR_MEDIA_ENQUEUE,
MEDIA_TYPE_MUSIC,
+ MEDIA_TYPE_PLAYLIST,
SUPPORT_CLEAR_PLAYLIST,
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
@@ -18,6 +20,7 @@ from homeassistant.components.media_player.const import (
SUPPORT_PREVIOUS_TRACK,
SUPPORT_SEEK,
SUPPORT_SHUFFLE_SET,
+ SUPPORT_STOP,
SUPPORT_TURN_OFF,
SUPPORT_TURN_ON,
SUPPORT_VOLUME_MUTE,
@@ -80,6 +83,7 @@ SUPPORT_SQUEEZEBOX = (
| SUPPORT_PLAY
| SUPPORT_SHUFFLE_SET
| SUPPORT_CLEAR_PLAYLIST
+ | SUPPORT_STOP
)
PLATFORM_SCHEMA = vol.All(
@@ -224,7 +228,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
"async_call_query",
)
platform.async_register_entity_service(
- SERVICE_SYNC, {vol.Required(ATTR_OTHER_PLAYER): cv.string}, "async_sync",
+ SERVICE_SYNC,
+ {vol.Required(ATTR_OTHER_PLAYER): cv.string},
+ "async_sync",
)
platform.async_register_entity_service(SERVICE_UNSYNC, None, "async_unsync")
@@ -332,11 +338,20 @@ class SqueezeBoxEntity(MediaPlayerEntity):
@property
def media_content_id(self):
"""Content ID of current playing media."""
+ if not self._player.playlist:
+ return None
+ if len(self._player.playlist) > 1:
+ urls = [{"url": track["url"]} for track in self._player.playlist]
+ return json.dumps({"index": self._player.current_index, "urls": urls})
return self._player.url
@property
def media_content_type(self):
"""Content type of current playing media."""
+ if not self._player.playlist:
+ return None
+ if len(self._player.playlist) > 1:
+ return MEDIA_TYPE_PLAYLIST
return MEDIA_TYPE_MUSIC
@property
@@ -422,6 +437,10 @@ class SqueezeBoxEntity(MediaPlayerEntity):
"""Mute (true) or unmute (false) media player."""
await self._player.async_set_muting(mute)
+ async def async_media_stop(self):
+ """Send stop command to media player."""
+ await self._player.async_stop()
+
async def async_media_play_pause(self):
"""Send pause command to media player."""
await self._player.async_toggle_pause()
@@ -460,7 +479,12 @@ class SqueezeBoxEntity(MediaPlayerEntity):
if kwargs.get(ATTR_MEDIA_ENQUEUE):
cmd = "add"
- await self._player.async_load_url(media_id, cmd)
+ if media_type == MEDIA_TYPE_PLAYLIST:
+ content = json.loads(media_id)
+ await self._player.async_load_playlist(content["urls"], cmd)
+ await self._player.async_index(content["index"])
+ else:
+ await self._player.async_load_url(media_id, cmd)
async def async_set_shuffle(self, shuffle):
"""Enable/disable shuffle mode."""
diff --git a/homeassistant/components/squeezebox/translations/no.json b/homeassistant/components/squeezebox/translations/no.json
index ddda0b61be2..dd9192cfb50 100644
--- a/homeassistant/components/squeezebox/translations/no.json
+++ b/homeassistant/components/squeezebox/translations/no.json
@@ -10,13 +10,11 @@
"no_server_found": "Kan ikke automatisk oppdage serveren.",
"unknown": "Uventet feil"
},
- "flow_title": "",
"step": {
"edit": {
"data": {
"host": "Vert",
"password": "Passord",
- "port": "",
"username": "Brukernavn"
},
"title": "Redigere tilkoblingsinformasjon"
@@ -28,6 +26,5 @@
"title": "Konfigurer Logitech Media Server"
}
}
- },
- "title": ""
+ }
}
\ No newline at end of file
diff --git a/homeassistant/components/squeezebox/translations/pt.json b/homeassistant/components/squeezebox/translations/pt.json
index e3e9813142b..97ced548ed5 100644
--- a/homeassistant/components/squeezebox/translations/pt.json
+++ b/homeassistant/components/squeezebox/translations/pt.json
@@ -1,5 +1,8 @@
{
"config": {
+ "abort": {
+ "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado"
+ },
"error": {
"cannot_connect": "Falha na liga\u00e7\u00e3o",
"invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida",
diff --git a/homeassistant/components/squeezebox/translations/ru.json b/homeassistant/components/squeezebox/translations/ru.json
index f12f6bb5e83..8b6c6a71ff6 100644
--- a/homeassistant/components/squeezebox/translations/ru.json
+++ b/homeassistant/components/squeezebox/translations/ru.json
@@ -1,11 +1,11 @@
{
"config": {
"abort": {
- "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
+ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.",
"no_server_found": "\u0421\u0435\u0440\u0432\u0435\u0440 LMS \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d."
},
"error": {
- "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.",
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.",
"invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.",
"no_server_found": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0442\u044c \u0441\u0435\u0440\u0432\u0435\u0440.",
"unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
diff --git a/homeassistant/components/starline/config_flow.py b/homeassistant/components/starline/config_flow.py
index 34415e9dca4..8650f158cfc 100644
--- a/homeassistant/components/starline/config_flow.py
+++ b/homeassistant/components/starline/config_flow.py
@@ -174,9 +174,11 @@ class StarlineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
async def _async_authenticate_app(self, error=None):
"""Authenticate application."""
try:
- self._app_code = self._auth.get_app_code(self._app_id, self._app_secret)
- self._app_token = self._auth.get_app_token(
- self._app_id, self._app_secret, self._app_code
+ self._app_code = await self.hass.async_add_executor_job(
+ self._auth.get_app_code, self._app_id, self._app_secret
+ )
+ self._app_token = await self.hass.async_add_executor_job(
+ self._auth.get_app_token, self._app_id, self._app_secret, self._app_code
)
return self._async_form_auth_user(error)
except Exception as err: # pylint: disable=broad-except
@@ -186,7 +188,8 @@ class StarlineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
async def _async_authenticate_user(self, error=None):
"""Authenticate user."""
try:
- state, data = self._auth.get_slid_user_token(
+ state, data = await self.hass.async_add_executor_job(
+ self._auth.get_slid_user_token,
self._app_token,
self._username,
self._password,
@@ -221,7 +224,9 @@ class StarlineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
self._slnet_token,
self._slnet_token_expires,
self._user_id,
- ) = self._auth.get_user_id(self._user_slid)
+ ) = await self.hass.async_add_executor_job(
+ self._auth.get_user_id, self._user_slid
+ )
return self.async_create_entry(
title=f"Application {self._app_id}",
diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py
index 83392b31380..2de4647aa94 100644
--- a/homeassistant/components/starline/sensor.py
+++ b/homeassistant/components/starline/sensor.py
@@ -1,6 +1,6 @@
"""Reads vehicle status from StarLine API."""
from homeassistant.components.sensor import DEVICE_CLASS_TEMPERATURE
-from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE, VOLT
+from homeassistant.const import PERCENTAGE, TEMP_CELSIUS, VOLT
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.icon import icon_for_battery_level, icon_for_signal_level
@@ -13,7 +13,7 @@ SENSOR_TYPES = {
"balance": ["Balance", None, None, "mdi:cash-multiple"],
"ctemp": ["Interior Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, None],
"etemp": ["Engine Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, None],
- "gsm_lvl": ["GSM Signal", None, UNIT_PERCENTAGE, None],
+ "gsm_lvl": ["GSM Signal", None, PERCENTAGE, None],
}
diff --git a/homeassistant/components/starline/translations/fr.json b/homeassistant/components/starline/translations/fr.json
index 3720cf10211..01385b7feee 100644
--- a/homeassistant/components/starline/translations/fr.json
+++ b/homeassistant/components/starline/translations/fr.json
@@ -11,7 +11,7 @@
"app_id": "ID de l'application",
"app_secret": "Secret"
},
- "description": "ID applicatif et code secret du compte d\u00e9veloppeur StarLine",
+ "description": "ID applicatif et code secret du [compte d\u00e9veloppeur StarLine](https://my.starline.ru/developer)",
"title": "Informations d'identification de l'application"
},
"auth_captcha": {
diff --git a/homeassistant/components/starline/translations/no.json b/homeassistant/components/starline/translations/no.json
index 36545f3efd7..89dc882cf82 100644
--- a/homeassistant/components/starline/translations/no.json
+++ b/homeassistant/components/starline/translations/no.json
@@ -17,9 +17,7 @@
"auth_captcha": {
"data": {
"captcha_code": "Kode fra bilde"
- },
- "description": "",
- "title": ""
+ }
},
"auth_mfa": {
"data": {
diff --git a/homeassistant/components/starline/translations/pt-BR.json b/homeassistant/components/starline/translations/pt-BR.json
index 158c2b01cf9..3b73793804a 100644
--- a/homeassistant/components/starline/translations/pt-BR.json
+++ b/homeassistant/components/starline/translations/pt-BR.json
@@ -23,7 +23,8 @@
},
"auth_user": {
"data": {
- "password": "Senha"
+ "password": "Senha",
+ "username": "Usu\u00e1rio"
}
}
}
diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py
index 19c80a82e9f..f6867d83212 100644
--- a/homeassistant/components/startca/sensor.py
+++ b/homeassistant/components/startca/sensor.py
@@ -14,7 +14,7 @@ from homeassistant.const import (
CONF_NAME,
DATA_GIGABYTES,
HTTP_OK,
- UNIT_PERCENTAGE,
+ PERCENTAGE,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
@@ -30,7 +30,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1)
REQUEST_TIMEOUT = 5 # seconds
SENSOR_TYPES = {
- "usage": ["Usage Ratio", UNIT_PERCENTAGE, "mdi:percent"],
+ "usage": ["Usage Ratio", PERCENTAGE, "mdi:percent"],
"usage_gb": ["Usage", DATA_GIGABYTES, "mdi:download"],
"limit": ["Data limit", DATA_GIGABYTES, "mdi:download"],
"used_download": ["Used Download", DATA_GIGABYTES, "mdi:download"],
diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py
index 3f0f03b4909..ce5f959d731 100644
--- a/homeassistant/components/statistics/__init__.py
+++ b/homeassistant/components/statistics/__init__.py
@@ -1 +1,4 @@
"""The statistics component."""
+
+DOMAIN = "statistics"
+PLATFORMS = ["sensor"]
diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py
index 945e5ff89d1..11cddc88c87 100644
--- a/homeassistant/components/statistics/sensor.py
+++ b/homeassistant/components/statistics/sensor.py
@@ -23,8 +23,11 @@ from homeassistant.helpers.event import (
async_track_point_in_utc_time,
async_track_state_change_event,
)
+from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.util import dt as dt_util
+from . import DOMAIN, PLATFORMS
+
_LOGGER = logging.getLogger(__name__)
ATTR_AVERAGE_CHANGE = "average_change"
@@ -66,6 +69,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Statistics sensor."""
+
+ await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
+
entity_id = config.get(CONF_ENTITY_ID)
name = config.get(CONF_NAME)
sampling_size = config.get(CONF_SAMPLING_SIZE)
@@ -124,8 +130,10 @@ class StatisticsSensor(Entity):
"""Add listener and get recorded state."""
_LOGGER.debug("Startup for %s", self.entity_id)
- async_track_state_change_event(
- self.hass, [self._entity_id], async_stats_sensor_state_listener
+ self.async_on_remove(
+ async_track_state_change_event(
+ self.hass, [self._entity_id], async_stats_sensor_state_listener
+ )
)
if "recorder" in self.hass.config.components:
diff --git a/homeassistant/components/statistics/services.yaml b/homeassistant/components/statistics/services.yaml
new file mode 100644
index 00000000000..608e2991334
--- /dev/null
+++ b/homeassistant/components/statistics/services.yaml
@@ -0,0 +1,2 @@
+reload:
+ description: Reload all statistics entities.
diff --git a/homeassistant/components/stookalert/binary_sensor.py b/homeassistant/components/stookalert/binary_sensor.py
index df0c1db369c..aa792d10653 100644
--- a/homeassistant/components/stookalert/binary_sensor.py
+++ b/homeassistant/components/stookalert/binary_sensor.py
@@ -5,7 +5,11 @@ import logging
import stookalert
import voluptuous as vol
-from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_SAFETY,
+ PLATFORM_SCHEMA,
+ BinarySensorEntity,
+)
from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME
from homeassistant.helpers import config_validation as cv
@@ -13,7 +17,7 @@ _LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(minutes=60)
CONF_PROVINCE = "province"
-DEFAULT_DEVICE_CLASS = "safety"
+DEFAULT_DEVICE_CLASS = DEVICE_CLASS_SAFETY
DEFAULT_NAME = "Stookalert"
ATTRIBUTION = "Data provided by rivm.nl"
PROVINCES = [
diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py
index a84f37ee126..d754f0beb01 100644
--- a/homeassistant/components/stream/__init__.py
+++ b/homeassistant/components/stream/__init__.py
@@ -2,6 +2,7 @@
import logging
import secrets
import threading
+from types import MappingProxyType
import voluptuous as vol
@@ -18,6 +19,7 @@ from .const import (
CONF_LOOKBACK,
CONF_STREAM_SOURCE,
DOMAIN,
+ MAX_SEGMENTS,
SERVICE_RECORD,
)
from .core import PROVIDERS
@@ -72,14 +74,15 @@ def request_stream(hass, stream_source, *, fmt="hls", keepalive=False, options=N
stream.access_token = secrets.token_hex()
stream.start()
return hass.data[DOMAIN][ATTR_ENDPOINTS][fmt].format(stream.access_token)
- except Exception:
- raise HomeAssistantError("Unable to get stream")
+ except Exception as err:
+ raise HomeAssistantError("Unable to get stream") from err
async def async_setup(hass, config):
"""Set up stream."""
# Set log level to error for libav
logging.getLogger("libav").setLevel(logging.ERROR)
+ logging.getLogger("libav.mp4").setLevel(logging.ERROR)
# Keep import here so that we can import stream integration without installing reqs
# pylint: disable=import-outside-toplevel
@@ -136,8 +139,10 @@ class Stream:
@property
def outputs(self):
- """Return stream outputs."""
- return self._outputs
+ """Return a copy of the stream outputs."""
+ # A copy is returned so the caller can iterate through the outputs
+ # without concern about self._outputs being modified from another thread.
+ return MappingProxyType(self._outputs.copy())
def add_provider(self, fmt):
"""Add provider output stream."""
@@ -167,6 +172,10 @@ class Stream:
from .worker import stream_worker
if self._thread is None or not self._thread.isAlive():
+ if self._thread is not None:
+ # The thread must have crashed/exited. Join to clean up the
+ # previous thread.
+ self._thread.join(timeout=0)
self._thread_quit = threading.Event()
self._thread = threading.Thread(
name="stream_worker",
@@ -225,7 +234,7 @@ async def async_handle_record_service(hass, call):
# Take advantage of lookback
hls = stream.outputs.get("hls")
if lookback > 0 and hls:
- num_segments = min(int(lookback // hls.target_duration), hls.num_segments)
+ num_segments = min(int(lookback // hls.target_duration), MAX_SEGMENTS)
# Wait for latest segment, then add the lookback
await hls.recv()
recorder.prepend(list(hls.get_segment())[-num_segments:])
diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py
index 2f50ff26226..91c4018d899 100644
--- a/homeassistant/components/stream/const.py
+++ b/homeassistant/components/stream/const.py
@@ -15,4 +15,7 @@ OUTPUT_FORMATS = ["hls"]
FORMAT_CONTENT_TYPE = {"hls": "application/vnd.apple.mpegurl"}
-AUDIO_SAMPLE_RATE = 44100
+MAX_SEGMENTS = 3 # Max number of segments to keep around
+MIN_SEGMENT_DURATION = 1.5 # Each segment is at least this many seconds
+
+PACKETS_TO_WAIT_FOR_AUDIO = 20 # Some streams have an audio stream with no audio
diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py
index 715ae47e133..5e4e85ceea6 100644
--- a/homeassistant/components/stream/core.py
+++ b/homeassistant/components/stream/core.py
@@ -2,7 +2,7 @@
import asyncio
from collections import deque
import io
-from typing import Any, List
+from typing import Any, Callable, List
from aiohttp import web
import attr
@@ -12,7 +12,7 @@ from homeassistant.core import callback
from homeassistant.helpers.event import async_call_later
from homeassistant.util.decorator import Registry
-from .const import ATTR_STREAMS, DOMAIN
+from .const import ATTR_STREAMS, DOMAIN, MAX_SEGMENTS
PROVIDERS = Registry()
@@ -39,8 +39,6 @@ class Segment:
class StreamOutput:
"""Represents a stream output."""
- num_segments = 3
-
def __init__(self, stream, timeout: int = 300) -> None:
"""Initialize a stream output."""
self.idle = False
@@ -48,7 +46,7 @@ class StreamOutput:
self._stream = stream
self._cursor = None
self._event = asyncio.Event()
- self._segments = deque(maxlen=self.num_segments)
+ self._segments = deque(maxlen=MAX_SEGMENTS)
self._unsub = None
@property
@@ -62,13 +60,18 @@ class StreamOutput:
return None
@property
- def audio_codec(self) -> str:
- """Return desired audio codec."""
+ def audio_codecs(self) -> str:
+ """Return desired audio codecs."""
return None
@property
- def video_codec(self) -> str:
- """Return desired video codec."""
+ def video_codecs(self) -> tuple:
+ """Return desired video codecs."""
+ return None
+
+ @property
+ def container_options(self) -> Callable[[int], dict]:
+ """Return Callable which takes a sequence number and returns container options."""
return None
@property
@@ -78,12 +81,12 @@ class StreamOutput:
@property
def target_duration(self) -> int:
- """Return the average duration of the segments in seconds."""
+ """Return the max duration of any given segment in seconds."""
segment_length = len(self._segments)
if not segment_length:
return 0
durations = [s.duration for s in self._segments]
- return round(sum(durations) // segment_length) or 1
+ return round(max(durations)) or 1
def get_segment(self, sequence: int = None) -> Any:
"""Retrieve a specific segment, or the whole list."""
@@ -147,7 +150,7 @@ class StreamOutput:
def cleanup(self):
"""Handle cleanup."""
- self._segments = deque(maxlen=self.num_segments)
+ self._segments = deque(maxlen=MAX_SEGMENTS)
self._stream.remove_provider(self)
diff --git a/homeassistant/components/stream/fmp4utils.py b/homeassistant/components/stream/fmp4utils.py
new file mode 100644
index 00000000000..00603807215
--- /dev/null
+++ b/homeassistant/components/stream/fmp4utils.py
@@ -0,0 +1,38 @@
+"""Utilities to help convert mp4s to fmp4s."""
+import io
+
+
+def find_box(segment: io.BytesIO, target_type: bytes, box_start: int = 0) -> int:
+ """Find location of first box (or sub_box if box_start provided) of given type."""
+ if box_start == 0:
+ box_end = segment.seek(0, io.SEEK_END)
+ segment.seek(0)
+ index = 0
+ else:
+ segment.seek(box_start)
+ box_end = box_start + int.from_bytes(segment.read(4), byteorder="big")
+ index = box_start + 8
+ while 1:
+ if index > box_end - 8: # End of box, not found
+ break
+ segment.seek(index)
+ box_header = segment.read(8)
+ if box_header[4:8] == target_type:
+ yield index
+ segment.seek(index)
+ index += int.from_bytes(box_header[0:4], byteorder="big")
+
+
+def get_init(segment: io.BytesIO) -> bytes:
+ """Get init section from fragmented mp4."""
+ moof_location = next(find_box(segment, b"moof"))
+ segment.seek(0)
+ return segment.read(moof_location)
+
+
+def get_m4s(segment: io.BytesIO, sequence: int) -> bytes:
+ """Get m4s section from fragmented mp4."""
+ moof_location = next(find_box(segment, b"moof"))
+ mfra_location = next(find_box(segment, b"mfra"))
+ segment.seek(moof_location)
+ return segment.read(mfra_location - moof_location)
diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py
index 2cd98c0a00f..816d1231c4c 100644
--- a/homeassistant/components/stream/hls.py
+++ b/homeassistant/components/stream/hls.py
@@ -1,11 +1,13 @@
"""Provide functionality to stream HLS."""
+from typing import Callable
+
from aiohttp import web
from homeassistant.core import callback
-from homeassistant.util.dt import utcnow
from .const import FORMAT_CONTENT_TYPE
from .core import PROVIDERS, StreamOutput, StreamView
+from .fmp4utils import get_init, get_m4s
@callback
@@ -13,6 +15,7 @@ def async_setup_hls(hass):
"""Set up api endpoints."""
hass.http.register_view(HlsPlaylistView())
hass.http.register_view(HlsSegmentView())
+ hass.http.register_view(HlsInitView())
return "/api/hls/{}/playlist.m3u8"
@@ -33,25 +36,45 @@ class HlsPlaylistView(StreamView):
await track.recv()
headers = {"Content-Type": FORMAT_CONTENT_TYPE["hls"]}
return web.Response(
- body=renderer.render(track, utcnow()).encode("utf-8"), headers=headers
+ body=renderer.render(track).encode("utf-8"), headers=headers
)
-class HlsSegmentView(StreamView):
- """Stream view to serve a MPEG2TS segment."""
+class HlsInitView(StreamView):
+ """Stream view to serve HLS init.mp4."""
- url = r"/api/hls/{token:[a-f0-9]+}/segment/{sequence:\d+}.ts"
+ url = r"/api/hls/{token:[a-f0-9]+}/init.mp4"
+ name = "api:stream:hls:init"
+ cors_allowed = True
+
+ async def handle(self, request, stream, sequence):
+ """Return init.mp4."""
+ track = stream.add_provider("hls")
+ segments = track.get_segment()
+ if not segments:
+ return web.HTTPNotFound()
+ headers = {"Content-Type": "video/mp4"}
+ return web.Response(body=get_init(segments[0].segment), headers=headers)
+
+
+class HlsSegmentView(StreamView):
+ """Stream view to serve a HLS fmp4 segment."""
+
+ url = r"/api/hls/{token:[a-f0-9]+}/segment/{sequence:\d+}.m4s"
name = "api:stream:hls:segment"
cors_allowed = True
async def handle(self, request, stream, sequence):
- """Return mpegts segment."""
+ """Return fmp4 segment."""
track = stream.add_provider("hls")
segment = track.get_segment(int(sequence))
if not segment:
return web.HTTPNotFound()
- headers = {"Content-Type": "video/mp2t"}
- return web.Response(body=segment.segment.getvalue(), headers=headers)
+ headers = {"Content-Type": "video/iso.segment"}
+ return web.Response(
+ body=get_m4s(segment.segment, int(sequence)),
+ headers=headers,
+ )
class M3U8Renderer:
@@ -64,10 +87,14 @@ class M3U8Renderer:
@staticmethod
def render_preamble(track):
"""Render preamble."""
- return ["#EXT-X-VERSION:3", f"#EXT-X-TARGETDURATION:{track.target_duration}"]
+ return [
+ "#EXT-X-VERSION:7",
+ f"#EXT-X-TARGETDURATION:{track.target_duration}",
+ '#EXT-X-MAP:URI="init.mp4"',
+ ]
@staticmethod
- def render_playlist(track, start_time):
+ def render_playlist(track):
"""Render playlist."""
segments = track.segments
@@ -81,19 +108,15 @@ class M3U8Renderer:
playlist.extend(
[
"#EXTINF:{:.04f},".format(float(segment.duration)),
- f"./segment/{segment.sequence}.ts",
+ f"./segment/{segment.sequence}.m4s",
]
)
return playlist
- def render(self, track, start_time):
+ def render(self, track):
"""Render M3U8 file."""
- lines = (
- ["#EXTM3U"]
- + self.render_preamble(track)
- + self.render_playlist(track, start_time)
- )
+ lines = ["#EXTM3U"] + self.render_preamble(track) + self.render_playlist(track)
return "\n".join(lines) + "\n"
@@ -109,14 +132,24 @@ class HlsStreamOutput(StreamOutput):
@property
def format(self) -> str:
"""Return container format."""
- return "mpegts"
+ return "mp4"
@property
- def audio_codec(self) -> str:
- """Return desired audio codec."""
- return "aac"
+ def audio_codecs(self) -> str:
+ """Return desired audio codecs."""
+ return {"aac", "ac3", "mp3"}
@property
- def video_codec(self) -> str:
- """Return desired video codec."""
- return "h264"
+ def video_codecs(self) -> tuple:
+ """Return desired video codecs."""
+ return {"hevc", "h264"}
+
+ @property
+ def container_options(self) -> Callable[[int], dict]:
+ """Return Callable which takes a sequence number and returns container options."""
+ return lambda sequence: {
+ # Removed skip_sidx - see https://github.com/home-assistant/core/pull/39970
+ "movflags": "frag_custom+empty_moov+default_base_moof+frag_discont",
+ "avoid_negative_ts": "make_non_negative",
+ "fragment_index": str(sequence),
+ }
diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json
index d434d1ce1ed..3d194bdf0d4 100644
--- a/homeassistant/components/stream/manifest.json
+++ b/homeassistant/components/stream/manifest.json
@@ -4,6 +4,6 @@
"documentation": "https://www.home-assistant.io/integrations/stream",
"requirements": ["av==8.0.2"],
"dependencies": ["http"],
- "codeowners": ["@hunterjm"],
+ "codeowners": ["@hunterjm", "@uvjustin"],
"quality_scale": "internal"
}
diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py
index c28c73c64ac..82b146cc51f 100644
--- a/homeassistant/components/stream/recorder.py
+++ b/homeassistant/components/stream/recorder.py
@@ -1,5 +1,5 @@
"""Provide functionality to record stream."""
-
+import os
import threading
from typing import List
@@ -15,16 +15,20 @@ def async_setup_recorder(hass):
"""Only here so Provider Registry works."""
-def recorder_save_worker(file_out: str, segments: List[Segment]):
+def recorder_save_worker(file_out: str, segments: List[Segment], container_format: str):
"""Handle saving stream."""
- first_pts = None
- output = av.open(file_out, "w")
+ if not os.path.exists(os.path.dirname(file_out)):
+ os.makedirs(os.path.dirname(file_out), exist_ok=True)
+
+ first_pts = {"video": None, "audio": None}
+ output = av.open(file_out, "w", format=container_format)
output_v = None
+ output_a = None
for segment in segments:
# Seek to beginning and open segment
segment.segment.seek(0)
- source = av.open(segment.segment, "r", format="mpegts")
+ source = av.open(segment.segment, "r", format=container_format)
source_v = source.streams.video[0]
# Add output streams
@@ -32,16 +36,18 @@ def recorder_save_worker(file_out: str, segments: List[Segment]):
output_v = output.add_stream(template=source_v)
context = output_v.codec_context
context.flags |= "GLOBAL_HEADER"
+ if not output_a and len(source.streams.audio) > 0:
+ source_a = source.streams.audio[0]
+ output_a = output.add_stream(template=source_a)
# Remux video
- for packet in source.demux(source_v):
+ for packet in source.demux():
if packet is not None and packet.dts is not None:
- if first_pts is None:
- first_pts = packet.pts
-
- packet.pts -= first_pts
- packet.dts -= first_pts
- packet.stream = output_v
+ if first_pts[packet.stream.type] is None:
+ first_pts[packet.stream.type] = packet.pts
+ packet.pts -= first_pts[packet.stream.type]
+ packet.dts -= first_pts[packet.stream.type]
+ packet.stream = output_v if packet.stream.type == "video" else output_a
output.mux(packet)
source.close()
@@ -67,17 +73,17 @@ class RecorderOutput(StreamOutput):
@property
def format(self) -> str:
"""Return container format."""
- return "mpegts"
+ return "mp4"
@property
- def audio_codec(self) -> str:
+ def audio_codecs(self) -> str:
"""Return desired audio codec."""
- return "aac"
+ return {"aac", "ac3", "mp3"}
@property
- def video_codec(self) -> str:
- """Return desired video codec."""
- return "h264"
+ def video_codecs(self) -> tuple:
+ """Return desired video codecs."""
+ return {"hevc", "h264"}
def prepend(self, segments: List[Segment]) -> None:
"""Prepend segments to existing list."""
@@ -96,7 +102,7 @@ class RecorderOutput(StreamOutput):
thread = threading.Thread(
name="recorder_save_worker",
target=recorder_save_worker,
- args=(self.video_path, self._segments),
+ args=(self.video_path, self._segments, self.format),
)
thread.start()
diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py
index 965f2611ed4..b76896b815a 100644
--- a/homeassistant/components/stream/worker.py
+++ b/homeassistant/components/stream/worker.py
@@ -1,49 +1,65 @@
"""Provides the worker thread needed for processing streams."""
-from fractions import Fraction
+from collections import deque
import io
import logging
+import time
import av
-from .const import AUDIO_SAMPLE_RATE
+from .const import MIN_SEGMENT_DURATION, PACKETS_TO_WAIT_FOR_AUDIO
from .core import Segment, StreamBuffer
_LOGGER = logging.getLogger(__name__)
-def generate_audio_frame():
- """Generate a blank audio frame."""
-
- audio_frame = av.AudioFrame(format="dbl", layout="mono", samples=1024)
- # audio_bytes = b''.join(b'\x00\x00\x00\x00\x00\x00\x00\x00'
- # for i in range(0, 1024))
- audio_bytes = b"\x00\x00\x00\x00\x00\x00\x00\x00" * 1024
- audio_frame.planes[0].update(audio_bytes)
- audio_frame.sample_rate = AUDIO_SAMPLE_RATE
- audio_frame.time_base = Fraction(1, AUDIO_SAMPLE_RATE)
- return audio_frame
-
-
-def create_stream_buffer(stream_output, video_stream, audio_frame):
+def create_stream_buffer(stream_output, video_stream, audio_stream, sequence):
"""Create a new StreamBuffer."""
- a_packet = None
segment = io.BytesIO()
- output = av.open(segment, mode="w", format=stream_output.format)
+ container_options = (
+ stream_output.container_options(sequence)
+ if stream_output.container_options
+ else {}
+ )
+ output = av.open(
+ segment,
+ mode="w",
+ format=stream_output.format,
+ container_options={
+ "video_track_timescale": str(int(1 / video_stream.time_base)),
+ **container_options,
+ },
+ )
vstream = output.add_stream(template=video_stream)
# Check if audio is requested
astream = None
- if stream_output.audio_codec:
- astream = output.add_stream(stream_output.audio_codec, AUDIO_SAMPLE_RATE)
- # Need to do it multiple times for some reason
- while not a_packet:
- a_packets = astream.encode(audio_frame)
- if a_packets:
- a_packet = a_packets[0]
- return (a_packet, StreamBuffer(segment, output, vstream, astream))
+ if audio_stream and audio_stream.name in stream_output.audio_codecs:
+ astream = output.add_stream(template=audio_stream)
+ return StreamBuffer(segment, output, vstream, astream)
def stream_worker(hass, stream, quit_event):
+ """Handle consuming streams and restart keepalive streams."""
+
+ wait_timeout = 0
+ while not quit_event.wait(timeout=wait_timeout):
+ start_time = time.time()
+ try:
+ _stream_worker_internal(hass, stream, quit_event)
+ except av.error.FFmpegError: # pylint: disable=c-extension-no-member
+ _LOGGER.exception("Stream connection failed: %s", stream.source)
+ if not stream.keepalive or quit_event.is_set():
+ break
+ # To avoid excessive restarts, don't restart faster than once every 40 seconds.
+ wait_timeout = max(40 - (time.time() - start_time), 0)
+ _LOGGER.debug(
+ "Restarting stream worker in %d seconds: %s",
+ wait_timeout,
+ stream.source,
+ )
+
+
+def _stream_worker_internal(hass, stream, quit_event):
"""Handle consuming streams."""
container = av.open(stream.source, options=stream.options)
@@ -51,30 +67,143 @@ def stream_worker(hass, stream, quit_event):
video_stream = container.streams.video[0]
except (KeyError, IndexError):
_LOGGER.error("Stream has no video")
+ container.close()
return
+ try:
+ audio_stream = container.streams.audio[0]
+ except (KeyError, IndexError):
+ audio_stream = None
+ # These formats need aac_adtstoasc bitstream filter, but auto_bsf not
+ # compatible with empty_moov and manual bitstream filters not in PyAV
+ if container.format.name in {"hls", "mpegts"}:
+ audio_stream = None
- audio_frame = generate_audio_frame()
-
- first_packet = True
- # Holds the buffers for each stream provider
- outputs = {}
- # Keep track of the number of segments we've processed
- sequence = 1
- # Holds the generated silence that needs to be muxed into the output
- audio_packets = {}
- # The presentation timestamp of the first video packet we receive
- first_pts = 0
- # The decoder timestamp of the latest packet we processed
+ # The presentation timestamps of the first packet in each stream we receive
+ # Use to adjust before muxing or outputting, but we don't adjust internally
+ first_pts = {}
+ # The decoder timestamps of the latest packet in each stream we processed
last_dts = None
# Keep track of consecutive packets without a dts to detect end of stream.
last_packet_was_without_dts = False
+ # Holds the buffers for each stream provider
+ outputs = None
+ # Keep track of the number of segments we've processed
+ sequence = 0
+ # The video pts at the beginning of the segment
+ segment_start_pts = None
+ # Because of problems 1 and 2 below, we need to store the first few packets and replay them
+ initial_packets = deque()
+
+ # Have to work around two problems with RTSP feeds in ffmpeg
+ # 1 - first frame has bad pts/dts https://trac.ffmpeg.org/ticket/5018
+ # 2 - seeking can be problematic https://trac.ffmpeg.org/ticket/7815
+
+ def peek_first_pts():
+ nonlocal first_pts, audio_stream
+
+ def empty_stream_dict():
+ return {
+ video_stream: None,
+ **({audio_stream: None} if audio_stream else {}),
+ }
+
+ try:
+ first_packet = empty_stream_dict()
+ first_pts = empty_stream_dict()
+ # Get to first video keyframe
+ while first_packet[video_stream] is None:
+ packet = next(container.demux())
+ if packet.stream == video_stream and packet.is_keyframe:
+ first_packet[video_stream] = packet
+ initial_packets.append(packet)
+ # Get first_pts from subsequent frame to first keyframe
+ while any(
+ [pts is None for pts in {**first_packet, **first_pts}.values()]
+ ) and (len(initial_packets) < PACKETS_TO_WAIT_FOR_AUDIO):
+ packet = next(container.demux((video_stream, audio_stream)))
+ if (
+ first_packet[packet.stream] is None
+ ): # actually video already found above so only for audio
+ if packet.is_keyframe:
+ first_packet[packet.stream] = packet
+ else: # Discard leading non-keyframes
+ continue
+ else: # This is the second frame to calculate first_pts from
+ if first_pts[packet.stream] is None:
+ first_pts[packet.stream] = packet.dts - packet.duration
+ first_packet[packet.stream].pts = first_pts[packet.stream]
+ first_packet[packet.stream].dts = first_pts[packet.stream]
+ initial_packets.append(packet)
+ if audio_stream and first_packet[audio_stream] is None:
+ _LOGGER.warning(
+ "Audio stream not found"
+ ) # Some streams declare an audio stream and never send any packets
+ del first_pts[audio_stream]
+ audio_stream = None
+
+ except (av.AVError, StopIteration) as ex:
+ if not stream.keepalive:
+ # End of stream, clear listeners and stop thread
+ for fmt, _ in outputs.items():
+ hass.loop.call_soon_threadsafe(stream.outputs[fmt].put, None)
+ _LOGGER.error(
+ "Error demuxing stream while finding first packet: %s", str(ex)
+ )
+ return False
+ return True
+
+ def initialize_segment(video_pts):
+ """Reset some variables and initialize outputs for each segment."""
+ nonlocal outputs, sequence, segment_start_pts
+ # Clear outputs and increment sequence
+ outputs = {}
+ sequence += 1
+ segment_start_pts = video_pts
+ for stream_output in stream.outputs.values():
+ if video_stream.name not in stream_output.video_codecs:
+ continue
+ buffer = create_stream_buffer(
+ stream_output, video_stream, audio_stream, sequence
+ )
+ outputs[stream_output.name] = (
+ buffer,
+ {video_stream: buffer.vstream, audio_stream: buffer.astream},
+ )
+
+ def mux_video_packet(packet):
+ # adjust pts and dts before muxing
+ packet.pts -= first_pts[video_stream]
+ packet.dts -= first_pts[video_stream]
+ # mux packets to each buffer
+ for buffer, output_streams in outputs.values():
+ # Assign the packet to the new stream & mux
+ packet.stream = output_streams[video_stream]
+ buffer.output.mux(packet)
+
+ def mux_audio_packet(packet):
+ # almost the same as muxing video but add extra check
+ # adjust pts and dts before muxing
+ packet.pts -= first_pts[audio_stream]
+ packet.dts -= first_pts[audio_stream]
+ for buffer, output_streams in outputs.values():
+ # Assign the packet to the new stream & mux
+ if output_streams.get(audio_stream):
+ packet.stream = output_streams[audio_stream]
+ buffer.output.mux(packet)
+
+ if not peek_first_pts():
+ container.close()
+ return
+ last_dts = {k: v - 1 for k, v in first_pts.items()}
+ initialize_segment(first_pts[video_stream])
while not quit_event.is_set():
try:
- packet = next(container.demux(video_stream))
+ if len(initial_packets) > 0:
+ packet = initial_packets.popleft()
+ else:
+ packet = next(container.demux((video_stream, audio_stream)))
if packet.dts is None:
- if first_packet:
- continue
_LOGGER.error("Stream packet without dts detected, skipping...")
# Allow a single packet without dts before terminating the stream.
if last_packet_was_without_dts:
@@ -84,96 +213,46 @@ def stream_worker(hass, stream, quit_event):
continue
last_packet_was_without_dts = False
except (av.AVError, StopIteration) as ex:
- # End of stream, clear listeners and stop thread
- for fmt, _ in outputs.items():
- hass.loop.call_soon_threadsafe(stream.outputs[fmt].put, None)
+ if not stream.keepalive:
+ # End of stream, clear listeners and stop thread
+ for fmt, _ in outputs.items():
+ hass.loop.call_soon_threadsafe(stream.outputs[fmt].put, None)
_LOGGER.error("Error demuxing stream: %s", str(ex))
break
- # Skip non monotonically increasing dts in feed
- if not first_packet and last_dts >= packet.dts:
+ # Discard packet if dts is not monotonic
+ if packet.dts <= last_dts[packet.stream]:
continue
- last_dts = packet.dts
- # Reset timestamps from a 0 time base for this stream
- packet.dts -= first_pts
- packet.pts -= first_pts
+ # Check for end of segment
+ if packet.stream == video_stream and packet.is_keyframe:
+ segment_duration = (packet.pts - segment_start_pts) * packet.time_base
+ if segment_duration >= MIN_SEGMENT_DURATION:
+ # Save segment to outputs
+ for fmt, (buffer, _) in outputs.items():
+ buffer.output.close()
+ if stream.outputs.get(fmt):
+ hass.loop.call_soon_threadsafe(
+ stream.outputs[fmt].put,
+ Segment(
+ sequence,
+ buffer.segment,
+ segment_duration,
+ ),
+ )
- # Reset segment on every keyframe
- if packet.is_keyframe:
- # Calculate the segment duration by multiplying the presentation
- # timestamp by the time base, which gets us total seconds.
- # By then dividing by the sequence, we can calculate how long
- # each segment is, assuming the stream starts from 0.
- segment_duration = (packet.pts * packet.time_base) / sequence
- # Save segment to outputs
- for fmt, buffer in outputs.items():
- buffer.output.close()
- del audio_packets[buffer.astream]
- if stream.outputs.get(fmt):
- hass.loop.call_soon_threadsafe(
- stream.outputs[fmt].put,
- Segment(sequence, buffer.segment, segment_duration),
- )
+ # Reinitialize
+ initialize_segment(packet.pts)
- # Clear outputs and increment sequence
- outputs = {}
- if not first_packet:
- sequence += 1
-
- # Initialize outputs
- for stream_output in stream.outputs.values():
- if video_stream.name != stream_output.video_codec:
- continue
-
- a_packet, buffer = create_stream_buffer(
- stream_output, video_stream, audio_frame
- )
- audio_packets[buffer.astream] = a_packet
- outputs[stream_output.name] = buffer
-
- # First video packet tends to have a weird dts/pts
- if first_packet:
- # If we are attaching to a live stream that does not reset
- # timestamps for us, we need to do it ourselves by recording
- # the first presentation timestamp and subtracting it from
- # subsequent packets we receive.
- if (packet.pts * packet.time_base) > 1:
- first_pts = packet.pts
- packet.dts = 0
- packet.pts = 0
- first_packet = False
-
- # Store packets on each output
- for buffer in outputs.values():
- # Check if the format requires audio
- if audio_packets.get(buffer.astream):
- a_packet = audio_packets[buffer.astream]
- a_time_base = a_packet.time_base
-
- # Determine video start timestamp and duration
- video_start = packet.pts * packet.time_base
- video_duration = packet.duration * packet.time_base
-
- if packet.is_keyframe:
- # Set first audio packet in sequence to equal video pts
- a_packet.pts = int(video_start / a_time_base)
- a_packet.dts = int(video_start / a_time_base)
-
- # Determine target end timestamp for audio
- target_pts = int((video_start + video_duration) / a_time_base)
- while a_packet.pts < target_pts:
- # Mux audio packet and adjust points until target hit
- buffer.output.mux(a_packet)
- a_packet.pts += a_packet.duration
- a_packet.dts += a_packet.duration
- audio_packets[buffer.astream] = a_packet
-
- # Assign the video packet to the new stream & mux
- packet.stream = buffer.vstream
- buffer.output.mux(packet)
+ # Update last_dts processed
+ last_dts[packet.stream] = packet.dts
+ # mux packets
+ if packet.stream == video_stream:
+ mux_video_packet(packet) # mutates packet timestamps
+ else:
+ mux_audio_packet(packet) # mutates packet timestamps
# Close stream
- for buffer in outputs.values():
+ for buffer, _ in outputs.values():
buffer.output.close()
container.close()
diff --git a/homeassistant/components/suez_water/manifest.json b/homeassistant/components/suez_water/manifest.json
index fface9b495a..632915d7e5f 100644
--- a/homeassistant/components/suez_water/manifest.json
+++ b/homeassistant/components/suez_water/manifest.json
@@ -1,7 +1,7 @@
{
- "domain": "suez_water",
- "name": "Suez Water",
- "documentation": "https://www.home-assistant.io/integrations/suez_water",
- "codeowners": ["@ooii"],
- "requirements": ["pysuez==0.1.17"]
+ "domain": "suez_water",
+ "name": "Suez Water",
+ "documentation": "https://www.home-assistant.io/integrations/suez_water",
+ "codeowners": ["@ooii"],
+ "requirements": ["pysuez==0.1.19"]
}
diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py
index fe89413f4d5..5fb777bd325 100644
--- a/homeassistant/components/sun/__init__.py
+++ b/homeassistant/components/sun/__init__.py
@@ -104,7 +104,7 @@ class Sun(Entity):
if location == self.location:
return
self.location = location
- self.update_events(dt_util.utcnow())
+ self.update_events()
update_location(None)
self.hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, update_location)
@@ -148,8 +148,10 @@ class Sun(Entity):
return next_utc
@callback
- def update_events(self, utc_point_in_time):
+ def update_events(self, now=None):
"""Update the attributes containing solar events."""
+ # Grab current time in case system clock changed since last time we ran.
+ utc_point_in_time = dt_util.utcnow()
self._next_change = utc_point_in_time + timedelta(days=400)
# Work our way around the solar cycle, figure out the next
@@ -207,7 +209,7 @@ class Sun(Entity):
_LOGGER.debug(
"sun phase_update@%s: phase=%s", utc_point_in_time.isoformat(), self.phase
)
- self.update_sun_position(utc_point_in_time)
+ self.update_sun_position()
# Set timer for the next solar event
event.async_track_point_in_utc_time(
@@ -216,8 +218,10 @@ class Sun(Entity):
_LOGGER.debug("next time: %s", self._next_change.isoformat())
@callback
- def update_sun_position(self, utc_point_in_time):
+ def update_sun_position(self, now=None):
"""Calculate the position of the sun."""
+ # Grab current time in case system clock changed since last time we ran.
+ utc_point_in_time = dt_util.utcnow()
self.solar_azimuth = round(self.location.solar_azimuth(utc_point_in_time), 2)
self.solar_elevation = round(
self.location.solar_elevation(utc_point_in_time), 2
diff --git a/homeassistant/components/automation/sun.py b/homeassistant/components/sun/trigger.py
similarity index 77%
rename from homeassistant/components/automation/sun.py
rename to homeassistant/components/sun/trigger.py
index c416742f397..c21726cf0ae 100644
--- a/homeassistant/components/automation/sun.py
+++ b/homeassistant/components/sun/trigger.py
@@ -31,12 +31,23 @@ async def async_attach_trigger(hass, config, action, automation_info):
"""Listen for events based on configuration."""
event = config.get(CONF_EVENT)
offset = config.get(CONF_OFFSET)
+ description = event
+ if offset:
+ description = f"{description} with offset"
@callback
def call_action():
"""Call action with right context."""
hass.async_run_job(
- action, {"trigger": {"platform": "sun", "event": event, "offset": offset}}
+ action,
+ {
+ "trigger": {
+ "platform": "sun",
+ "event": event,
+ "offset": offset,
+ "description": description,
+ }
+ },
)
if event == SUN_EVENT_SUNRISE:
diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py
index 6c9bfb8d16e..40313a41553 100644
--- a/homeassistant/components/supla/__init__.py
+++ b/homeassistant/components/supla/__init__.py
@@ -1,32 +1,43 @@
"""Support for Supla devices."""
+from datetime import timedelta
import logging
from typing import Optional
-from pysupla import SuplaAPI
+import async_timeout
+from asyncpysupla import SuplaAPI
import voluptuous as vol
from homeassistant.const import CONF_ACCESS_TOKEN
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.discovery import load_platform
-from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.update_coordinator import (
+ CoordinatorEntity,
+ DataUpdateCoordinator,
+)
_LOGGER = logging.getLogger(__name__)
-DOMAIN = "supla"
+DOMAIN = "supla"
CONF_SERVER = "server"
CONF_SERVERS = "servers"
+SCAN_INTERVAL = timedelta(seconds=10)
+
SUPLA_FUNCTION_HA_CMP_MAP = {
"CONTROLLINGTHEROLLERSHUTTER": "cover",
"CONTROLLINGTHEGATE": "cover",
"LIGHTSWITCH": "switch",
}
SUPLA_FUNCTION_NONE = "NONE"
-SUPLA_CHANNELS = "supla_channels"
SUPLA_SERVERS = "supla_servers"
+SUPLA_COORDINATORS = "supla_coordinators"
SERVER_CONFIG = vol.Schema(
- {vol.Required(CONF_SERVER): cv.string, vol.Required(CONF_ACCESS_TOKEN): cv.string}
+ {
+ vol.Required(CONF_SERVER): cv.string,
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ }
)
CONFIG_SCHEMA = vol.Schema(
@@ -39,25 +50,27 @@ CONFIG_SCHEMA = vol.Schema(
)
-def setup(hass, base_config):
+async def async_setup(hass, base_config):
"""Set up the Supla component."""
server_confs = base_config[DOMAIN][CONF_SERVERS]
- hass.data[SUPLA_SERVERS] = {}
- hass.data[SUPLA_CHANNELS] = {}
+ hass.data[DOMAIN] = {SUPLA_SERVERS: {}, SUPLA_COORDINATORS: {}}
+
+ session = async_get_clientsession(hass)
for server_conf in server_confs:
server_address = server_conf[CONF_SERVER]
- server = SuplaAPI(server_address, server_conf[CONF_ACCESS_TOKEN])
+ server = SuplaAPI(server_address, server_conf[CONF_ACCESS_TOKEN], session)
# Test connection
try:
- srv_info = server.get_server_info()
+ srv_info = await server.get_server_info()
if srv_info.get("authenticated"):
- hass.data[SUPLA_SERVERS][server_conf[CONF_SERVER]] = server
+ hass.data[DOMAIN][SUPLA_SERVERS][server_conf[CONF_SERVER]] = server
+
else:
_LOGGER.error(
"Server: %s not configured. API call returned: %s",
@@ -71,23 +84,46 @@ def setup(hass, base_config):
)
return False
- discover_devices(hass, base_config)
+ await discover_devices(hass, base_config)
return True
-def discover_devices(hass, hass_config):
+async def discover_devices(hass, hass_config):
"""
Run periodically to discover new devices.
- Currently it's only run at startup.
+ Currently it is only run at startup.
"""
component_configs = {}
- for server_name, server in hass.data[SUPLA_SERVERS].items():
+ for server_name, server in hass.data[DOMAIN][SUPLA_SERVERS].items():
- for channel in server.get_channels(include=["iodevice"]):
+ async def _fetch_channels():
+ async with async_timeout.timeout(SCAN_INTERVAL.total_seconds()):
+ channels = {
+ channel["id"]: channel
+ for channel in await server.get_channels(
+ include=["iodevice", "state", "connected"]
+ )
+ }
+ return channels
+
+ coordinator = DataUpdateCoordinator(
+ hass,
+ _LOGGER,
+ name=f"{DOMAIN}-{server_name}",
+ update_method=_fetch_channels,
+ update_interval=SCAN_INTERVAL,
+ )
+
+ await coordinator.async_refresh()
+
+ hass.data[DOMAIN][SUPLA_COORDINATORS][server_name] = coordinator
+
+ for channel_id, channel in coordinator.data.items():
channel_function = channel["function"]["name"]
+
if channel_function == SUPLA_FUNCTION_NONE:
_LOGGER.debug(
"Ignored function: %s, channel id: %s",
@@ -107,25 +143,33 @@ def discover_devices(hass, hass_config):
continue
channel["server_name"] = server_name
- component_configs.setdefault(component_name, []).append(channel)
+ component_configs.setdefault(component_name, []).append(
+ {
+ "channel_id": channel_id,
+ "server_name": server_name,
+ "function_name": channel["function"]["name"],
+ }
+ )
# Load discovered devices
- for component_name, channel in component_configs.items():
- load_platform(hass, component_name, "supla", channel, hass_config)
+ for component_name, config in component_configs.items():
+ await async_load_platform(hass, component_name, DOMAIN, config, hass_config)
-class SuplaChannel(Entity):
+class SuplaChannel(CoordinatorEntity):
"""Base class of a Supla Channel (an equivalent of HA's Entity)."""
- def __init__(self, channel_data):
- """Channel data -- raw channel information from PySupla."""
- self.server_name = channel_data["server_name"]
- self.channel_data = channel_data
+ def __init__(self, config, server, coordinator):
+ """Init from config, hookup[ server and coordinator."""
+ super().__init__(coordinator)
+ self.server_name = config["server_name"]
+ self.channel_id = config["channel_id"]
+ self.server = server
@property
- def server(self):
- """Return PySupla's server component associated with entity."""
- return self.hass.data[SUPLA_SERVERS][self.server_name]
+ def channel_data(self):
+ """Return channel data taken from coordinator."""
+ return self.coordinator.data.get(self.channel_id)
@property
def unique_id(self) -> str:
@@ -150,7 +194,7 @@ class SuplaChannel(Entity):
return False
return state.get("connected")
- def action(self, action, **add_pars):
+ async def async_action(self, action, **add_pars):
"""
Run server action.
@@ -163,10 +207,7 @@ class SuplaChannel(Entity):
self.channel_data["id"],
add_pars,
)
- self.server.execute_action(self.channel_data["id"], action, **add_pars)
+ await self.server.execute_action(self.channel_data["id"], action, **add_pars)
- def update(self):
- """Call to update state."""
- self.channel_data = self.server.get_channel(
- self.channel_data["id"], include=["connected", "state"]
- )
+ # Update state
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py
index 1c0f2f60431..ac71bc4ea8f 100644
--- a/homeassistant/components/supla/cover.py
+++ b/homeassistant/components/supla/cover.py
@@ -7,7 +7,12 @@ from homeassistant.components.cover import (
DEVICE_CLASS_GARAGE,
CoverEntity,
)
-from homeassistant.components.supla import SuplaChannel
+from homeassistant.components.supla import (
+ DOMAIN,
+ SUPLA_COORDINATORS,
+ SUPLA_SERVERS,
+ SuplaChannel,
+)
_LOGGER = logging.getLogger(__name__)
@@ -15,7 +20,7 @@ SUPLA_SHUTTER = "CONTROLLINGTHEROLLERSHUTTER"
SUPLA_GATE = "CONTROLLINGTHEGATE"
-def setup_platform(hass, config, add_entities, discovery_info=None):
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Supla covers."""
if discovery_info is None:
return
@@ -24,12 +29,28 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
entities = []
for device in discovery_info:
- device_name = device["function"]["name"]
+ device_name = device["function_name"]
+ server_name = device["server_name"]
+
if device_name == SUPLA_SHUTTER:
- entities.append(SuplaCover(device))
+ entities.append(
+ SuplaCover(
+ device,
+ hass.data[DOMAIN][SUPLA_SERVERS][server_name],
+ hass.data[DOMAIN][SUPLA_COORDINATORS][server_name],
+ )
+ )
+
elif device_name == SUPLA_GATE:
- entities.append(SuplaGateDoor(device))
- add_entities(entities)
+ entities.append(
+ SuplaGateDoor(
+ device,
+ hass.data[DOMAIN][SUPLA_SERVERS][server_name],
+ hass.data[DOMAIN][SUPLA_COORDINATORS][server_name],
+ )
+ )
+
+ async_add_entities(entities)
class SuplaCover(SuplaChannel, CoverEntity):
@@ -43,9 +64,9 @@ class SuplaCover(SuplaChannel, CoverEntity):
return 100 - state["shut"]
return None
- def set_cover_position(self, **kwargs):
+ async def async_set_cover_position(self, **kwargs):
"""Move the cover to a specific position."""
- self.action("REVEAL", percentage=kwargs.get(ATTR_POSITION))
+ await self.async_action("REVEAL", percentage=kwargs.get(ATTR_POSITION))
@property
def is_closed(self):
@@ -54,17 +75,17 @@ class SuplaCover(SuplaChannel, CoverEntity):
return None
return self.current_cover_position == 0
- def open_cover(self, **kwargs):
+ async def async_open_cover(self, **kwargs):
"""Open the cover."""
- self.action("REVEAL")
+ await self.async_action("REVEAL")
- def close_cover(self, **kwargs):
+ async def async_close_cover(self, **kwargs):
"""Close the cover."""
- self.action("SHUT")
+ await self.async_action("SHUT")
- def stop_cover(self, **kwargs):
+ async def async_stop_cover(self, **kwargs):
"""Stop the cover."""
- self.action("STOP")
+ await self.async_action("STOP")
class SuplaGateDoor(SuplaChannel, CoverEntity):
@@ -78,23 +99,23 @@ class SuplaGateDoor(SuplaChannel, CoverEntity):
return state.get("hi")
return None
- def open_cover(self, **kwargs) -> None:
+ async def async_open_cover(self, **kwargs) -> None:
"""Open the gate."""
if self.is_closed:
- self.action("OPEN_CLOSE")
+ await self.async_action("OPEN_CLOSE")
- def close_cover(self, **kwargs) -> None:
+ async def async_close_cover(self, **kwargs) -> None:
"""Close the gate."""
if not self.is_closed:
- self.action("OPEN_CLOSE")
+ await self.async_action("OPEN_CLOSE")
- def stop_cover(self, **kwargs) -> None:
+ async def async_stop_cover(self, **kwargs) -> None:
"""Stop the gate."""
- self.action("OPEN_CLOSE")
+ await self.async_action("OPEN_CLOSE")
- def toggle(self, **kwargs) -> None:
+ async def async_toggle(self, **kwargs) -> None:
"""Toggle the gate."""
- self.action("OPEN_CLOSE")
+ await self.async_action("OPEN_CLOSE")
@property
def device_class(self):
diff --git a/homeassistant/components/supla/manifest.json b/homeassistant/components/supla/manifest.json
index a4ab0e72719..1a2dcf3cbc5 100644
--- a/homeassistant/components/supla/manifest.json
+++ b/homeassistant/components/supla/manifest.json
@@ -2,6 +2,6 @@
"domain": "supla",
"name": "Supla",
"documentation": "https://www.home-assistant.io/integrations/supla",
- "requirements": ["pysupla==0.0.3"],
+ "requirements": ["asyncpysupla==0.0.5"],
"codeowners": ["@mwegrzynek"]
}
diff --git a/homeassistant/components/supla/switch.py b/homeassistant/components/supla/switch.py
index 61f218b75d9..9122d9d1970 100644
--- a/homeassistant/components/supla/switch.py
+++ b/homeassistant/components/supla/switch.py
@@ -2,32 +2,49 @@
import logging
from pprint import pformat
-from homeassistant.components.supla import SuplaChannel
+from homeassistant.components.supla import (
+ DOMAIN,
+ SUPLA_COORDINATORS,
+ SUPLA_SERVERS,
+ SuplaChannel,
+)
from homeassistant.components.switch import SwitchEntity
_LOGGER = logging.getLogger(__name__)
-def setup_platform(hass, config, add_entities, discovery_info=None):
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the Supla switches."""
if discovery_info is None:
return
_LOGGER.debug("Discovery: %s", pformat(discovery_info))
- add_entities([SuplaSwitch(device) for device in discovery_info])
+ entities = []
+ for device in discovery_info:
+ server_name = device["server_name"]
+
+ entities.append(
+ SuplaSwitch(
+ device,
+ hass.data[DOMAIN][SUPLA_SERVERS][server_name],
+ hass.data[DOMAIN][SUPLA_COORDINATORS][server_name],
+ )
+ )
+
+ async_add_entities(entities)
class SuplaSwitch(SuplaChannel, SwitchEntity):
"""Representation of a Supla Switch."""
- def turn_on(self, **kwargs):
+ async def async_turn_on(self, **kwargs):
"""Turn on the switch."""
- self.action("TURN_ON")
+ await self.async_action("TURN_ON")
- def turn_off(self, **kwargs):
+ async def async_turn_off(self, **kwargs):
"""Turn off the switch."""
- self.action("TURN_OFF")
+ await self.async_action("TURN_OFF")
@property
def is_on(self):
diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py
index 90e754118ab..9aac8f11941 100644
--- a/homeassistant/components/surepetcare/__init__.py
+++ b/homeassistant/components/surepetcare/__init__.py
@@ -104,12 +104,13 @@ async def async_setup(hass, config) -> bool:
)
# discover hubs the flaps/feeders are connected to
+ hub_ids = set()
for device in things.copy():
device_data = await surepy.device(device[CONF_ID])
if (
CONF_PARENT in device_data
and device_data[CONF_PARENT][CONF_PRODUCT_ID] == SureProductID.HUB
- and device_data[CONF_PARENT][CONF_ID] not in things
+ and device_data[CONF_PARENT][CONF_ID] not in hub_ids
):
things.append(
{
@@ -117,6 +118,7 @@ async def async_setup(hass, config) -> bool:
CONF_TYPE: SureProductID.HUB,
}
)
+ hub_ids.add(device_data[CONF_PARENT][CONF_ID])
# add pets
things.extend(
diff --git a/homeassistant/components/surepetcare/binary_sensor.py b/homeassistant/components/surepetcare/binary_sensor.py
index efd5048053f..0cb96731058 100644
--- a/homeassistant/components/surepetcare/binary_sensor.py
+++ b/homeassistant/components/surepetcare/binary_sensor.py
@@ -203,7 +203,10 @@ class DeviceConnectivity(SurePetcareBinarySensor):
"""Sure Petcare Pet."""
def __init__(
- self, _id: int, sure_type: SureProductID, spc: SurePetcareAPI,
+ self,
+ _id: int,
+ sure_type: SureProductID,
+ spc: SurePetcareAPI,
) -> None:
"""Initialize a Sure Petcare Device."""
super().__init__(_id, spc, DEVICE_CLASS_CONNECTIVITY, sure_type)
diff --git a/homeassistant/components/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py
index 9c1416479cb..d3fcb41dbf4 100644
--- a/homeassistant/components/surepetcare/sensor.py
+++ b/homeassistant/components/surepetcare/sensor.py
@@ -9,7 +9,7 @@ from homeassistant.const import (
CONF_ID,
CONF_TYPE,
DEVICE_CLASS_BATTERY,
- UNIT_PERCENTAGE,
+ PERCENTAGE,
)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -181,4 +181,4 @@ class SureBattery(SurePetcareSensor):
@property
def unit_of_measurement(self) -> str:
"""Return the unit of measurement."""
- return UNIT_PERCENTAGE
+ return PERCENTAGE
diff --git a/homeassistant/components/switch/translations/es-419.json b/homeassistant/components/switch/translations/es-419.json
index 7fb04127b15..a7087a1bbf1 100644
--- a/homeassistant/components/switch/translations/es-419.json
+++ b/homeassistant/components/switch/translations/es-419.json
@@ -14,11 +14,5 @@
"turned_on": "{entity_name} encendido"
}
},
- "state": {
- "_": {
- "off": "",
- "on": ""
- }
- },
"title": "Interruptor"
}
\ No newline at end of file
diff --git a/homeassistant/components/switch/translations/fr.json b/homeassistant/components/switch/translations/fr.json
index d0349369515..997986924a3 100644
--- a/homeassistant/components/switch/translations/fr.json
+++ b/homeassistant/components/switch/translations/fr.json
@@ -17,7 +17,7 @@
"state": {
"_": {
"off": "Inactif",
- "on": "On"
+ "on": "Actif"
}
},
"title": "Interrupteur"
diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json
index a4e312908f2..c0cf7f18de6 100644
--- a/homeassistant/components/switcher_kis/manifest.json
+++ b/homeassistant/components/switcher_kis/manifest.json
@@ -3,5 +3,5 @@
"name": "Switcher",
"documentation": "https://www.home-assistant.io/integrations/switcher_kis/",
"codeowners": ["@tomerfi"],
- "requirements": ["aioswitcher==1.2.0"]
+ "requirements": ["aioswitcher==1.2.1"]
}
diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py
index c2254968901..badab64391e 100644
--- a/homeassistant/components/switcher_kis/switch.py
+++ b/homeassistant/components/switcher_kis/switch.py
@@ -27,8 +27,8 @@ from . import (
# pylint: disable=ungrouped-imports
if TYPE_CHECKING:
- from aioswitcher.devices import SwitcherV2Device
from aioswitcher.api.messages import SwitcherV2ControlResponseMSG
+ from aioswitcher.devices import SwitcherV2Device
_LOGGER = getLogger(__name__)
diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py
index 512db1b7527..83d32eb9b47 100644
--- a/homeassistant/components/syncthru/__init__.py
+++ b/homeassistant/components/syncthru/__init__.py
@@ -1,23 +1,82 @@
"""The syncthru component."""
+import logging
+from typing import Set, Tuple
+
+from pysyncthru import SyncThru
+
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_URL
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import aiohttp_client, device_registry as dr
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
"""Set up."""
+ hass.data.setdefault(DOMAIN, {})
return True
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
"""Set up config entry."""
+
+ session = aiohttp_client.async_get_clientsession(hass)
+ printer = hass.data[DOMAIN][entry.entry_id] = SyncThru(
+ entry.data[CONF_URL], session
+ )
+
+ try:
+ await printer.update()
+ except ValueError:
+ _LOGGER.error(
+ "Device at %s not appear to be a SyncThru printer, aborting setup",
+ printer.url,
+ )
+ return False
+ else:
+ if printer.is_unknown_state():
+ raise ConfigEntryNotReady
+
+ device_registry = await dr.async_get_registry(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ connections=device_connections(printer),
+ identifiers=device_identifiers(printer),
+ model=printer.model(),
+ name=printer.hostname(),
+ )
+
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, SENSOR_DOMAIN)
)
return True
-async def async_unload_entry(hass, entry):
+async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool:
"""Unload the config entry."""
- return await hass.config_entries.async_forward_entry_unload(entry, SENSOR_DOMAIN)
+ await hass.config_entries.async_forward_entry_unload(entry, SENSOR_DOMAIN)
+ hass.data[DOMAIN].pop(entry.entry_id, None)
+ return True
+
+
+def device_identifiers(printer: SyncThru) -> Set[Tuple[str, str]]:
+ """Get device identifiers for device registry."""
+ return {(DOMAIN, printer.serial_number())}
+
+
+def device_connections(printer: SyncThru) -> Set[Tuple[str, str]]:
+ """Get device connections for device registry."""
+ connections = set()
+ try:
+ mac = printer.raw()["identity"]["mac_addr"]
+ if mac:
+ connections.add((dr.CONNECTION_NETWORK_MAC, mac))
+ except AttributeError:
+ pass
+ return connections
diff --git a/homeassistant/components/syncthru/config_flow.py b/homeassistant/components/syncthru/config_flow.py
index ef3b6903358..cbdd46b4a6a 100644
--- a/homeassistant/components/syncthru/config_flow.py
+++ b/homeassistant/components/syncthru/config_flow.py
@@ -74,7 +74,8 @@ class SyncThruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
return await self._async_check_and_create("confirm", user_input)
return await self._async_show_form(
- step_id="confirm", user_input={CONF_URL: self.url, CONF_NAME: self.name},
+ step_id="confirm",
+ user_input={CONF_URL: self.url, CONF_NAME: self.name},
)
async def _async_show_form(self, step_id, user_input=None, errors=None):
@@ -133,5 +134,6 @@ class SyncThruConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
)
return self.async_create_entry(
- title=user_input.get(CONF_NAME), data=user_input,
+ title=user_input.get(CONF_NAME),
+ data=user_input,
)
diff --git a/homeassistant/components/syncthru/exceptions.py b/homeassistant/components/syncthru/exceptions.py
deleted file mode 100644
index 0bb4b8229ce..00000000000
--- a/homeassistant/components/syncthru/exceptions.py
+++ /dev/null
@@ -1,7 +0,0 @@
-"""Samsung SyncThru exceptions."""
-
-from homeassistant.exceptions import HomeAssistantError
-
-
-class SyncThruNotSupported(HomeAssistantError):
- """Error to indicate SyncThru is not supported."""
diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json
index bf62738a02e..f70afa5a695 100644
--- a/homeassistant/components/syncthru/manifest.json
+++ b/homeassistant/components/syncthru/manifest.json
@@ -3,7 +3,7 @@
"name": "Samsung SyncThru Printer",
"documentation": "https://www.home-assistant.io/integrations/syncthru",
"config_flow": true,
- "requirements": ["pysyncthru==0.5.0", "url-normalize==1.4.1"],
+ "requirements": ["pysyncthru==0.7.0", "url-normalize==1.4.1"],
"ssdp": [
{
"deviceType": "urn:schemas-upnp-org:device:Printer:1",
diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py
index 6f7f248a924..639ec3ac6cb 100644
--- a/homeassistant/components/syncthru/sensor.py
+++ b/homeassistant/components/syncthru/sensor.py
@@ -2,19 +2,17 @@
import logging
-from pysyncthru import SyncThru
+from pysyncthru import SYNCTHRU_STATE_HUMAN, SyncThru
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.config_entries import SOURCE_IMPORT
-from homeassistant.const import CONF_NAME, CONF_RESOURCE, CONF_URL, UNIT_PERCENTAGE
-from homeassistant.exceptions import PlatformNotReady
-from homeassistant.helpers import aiohttp_client
+from homeassistant.const import CONF_NAME, CONF_RESOURCE, CONF_URL, PERCENTAGE
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
+from . import device_identifiers
from .const import DEFAULT_MODEL, DEFAULT_NAME_TEMPLATE, DOMAIN
-from .exceptions import SyncThruNotSupported
_LOGGER = logging.getLogger(__name__)
@@ -61,25 +59,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up from config entry."""
- session = aiohttp_client.async_get_clientsession(hass)
+ printer = hass.data[DOMAIN][config_entry.entry_id]
- printer = SyncThru(config_entry.data[CONF_URL], session)
- # Test if the discovered device actually is a syncthru printer
- # and fetch the available toner/drum/etc
- try:
- # No error is thrown when the device is off
- # (only after user added it manually)
- # therefore additional catches are inside the Sensor below
- await printer.update()
- supp_toner = printer.toner_status(filter_supported=True)
- supp_drum = printer.drum_status(filter_supported=True)
- supp_tray = printer.input_tray_status(filter_supported=True)
- supp_output_tray = printer.output_tray_status()
- except ValueError as ex:
- raise SyncThruNotSupported from ex
- else:
- if printer.is_unknown_state():
- raise PlatformNotReady
+ supp_toner = printer.toner_status(filter_supported=True)
+ supp_drum = printer.drum_status(filter_supported=True)
+ supp_tray = printer.input_tray_status(filter_supported=True)
+ supp_output_tray = printer.output_tray_status()
name = config_entry.data[CONF_NAME]
devices = [SyncThruMainSensor(printer, name)]
@@ -101,7 +86,7 @@ class SyncThruSensor(Entity):
def __init__(self, syncthru, name):
"""Initialize the sensor."""
- self.syncthru = syncthru
+ self.syncthru: SyncThru = syncthru
self._attributes = {}
self._state = None
self._name = name
@@ -140,6 +125,11 @@ class SyncThruSensor(Entity):
"""Return the state attributes of the device."""
return self._attributes
+ @property
+ def device_info(self):
+ """Return device information."""
+ return {"identifiers": device_identifiers(self.syncthru)}
+
class SyncThruMainSensor(SyncThruSensor):
"""Implementation of the main sensor, conducting the actual polling."""
@@ -164,7 +154,8 @@ class SyncThruMainSensor(SyncThruSensor):
self.syncthru.url,
)
self._active = False
- self._state = self.syncthru.device_status()
+ self._state = SYNCTHRU_STATE_HUMAN[self.syncthru.device_status()]
+ self._attributes = {"display_text": self.syncthru.device_status_details()}
class SyncThruTonerSensor(SyncThruSensor):
@@ -175,7 +166,7 @@ class SyncThruTonerSensor(SyncThruSensor):
super().__init__(syncthru, name)
self._name = f"{name} Toner {color}"
self._color = color
- self._unit_of_measurement = UNIT_PERCENTAGE
+ self._unit_of_measurement = PERCENTAGE
self._id_suffix = f"_toner_{color}"
def update(self):
@@ -195,7 +186,7 @@ class SyncThruDrumSensor(SyncThruSensor):
super().__init__(syncthru, name)
self._name = f"{name} Drum {color}"
self._color = color
- self._unit_of_measurement = UNIT_PERCENTAGE
+ self._unit_of_measurement = PERCENTAGE
self._id_suffix = f"_drum_{color}"
def update(self):
diff --git a/homeassistant/components/syncthru/translations/pt.json b/homeassistant/components/syncthru/translations/pt.json
index 03ddb9523ec..6463b4241c6 100644
--- a/homeassistant/components/syncthru/translations/pt.json
+++ b/homeassistant/components/syncthru/translations/pt.json
@@ -1,17 +1,22 @@
{
"config": {
+ "abort": {
+ "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado"
+ },
"error": {
"unknown_state": "Estado da impressora desconhecido, verifique a conectividade de URL e de rede"
},
"step": {
"confirm": {
"data": {
- "name": "Nome"
+ "name": "Nome",
+ "url": "URL da interface Web"
}
},
"user": {
"data": {
- "name": "Nome"
+ "name": "Nome",
+ "url": "URL da interface Web"
}
}
}
diff --git a/homeassistant/components/syncthru/translations/ru.json b/homeassistant/components/syncthru/translations/ru.json
index c3b88690a91..3e904f3d32a 100644
--- a/homeassistant/components/syncthru/translations/ru.json
+++ b/homeassistant/components/syncthru/translations/ru.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant."
},
"error": {
"invalid_url": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 URL-\u0430\u0434\u0440\u0435\u0441.",
diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py
index 89dc39e427c..2e4b550337b 100644
--- a/homeassistant/components/synology_dsm/__init__.py
+++ b/homeassistant/components/synology_dsm/__init__.py
@@ -88,7 +88,9 @@ async def async_setup(hass, config):
for dsm_conf in conf:
hass.async_create_task(
hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=dsm_conf,
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=dsm_conf,
)
)
@@ -335,7 +337,10 @@ class SynologyDSMEntity(Entity):
"""Representation of a Synology NAS entry."""
def __init__(
- self, api: SynoApi, entity_type: str, entity_info: Dict[str, str],
+ self,
+ api: SynoApi,
+ entity_type: str,
+ entity_info: Dict[str, str],
):
"""Initialize the Synology DSM entity."""
self._api = api
diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py
index a75f57db678..c95b4298f5d 100644
--- a/homeassistant/components/synology_dsm/binary_sensor.py
+++ b/homeassistant/components/synology_dsm/binary_sensor.py
@@ -1,7 +1,10 @@
"""Support for Synology DSM binary sensors."""
from typing import Dict
-from homeassistant.components.binary_sensor import BinarySensorEntity
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_SAFETY,
+ BinarySensorEntity,
+)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_DISKS
from homeassistant.helpers.typing import HomeAssistantType
@@ -14,6 +17,8 @@ from .const import (
SYNO_API,
)
+DEFAULT_DEVICE_CLASS = DEVICE_CLASS_SAFETY
+
async def async_setup_entry(
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
@@ -71,3 +76,8 @@ class SynoDSMStorageBinarySensor(SynologyDSMDeviceEntity, BinarySensorEntity):
if attr is None:
return None
return attr
+
+ @property
+ def device_class(self):
+ """Return the device class of this binary sensor."""
+ return DEFAULT_DEVICE_CLASS
diff --git a/homeassistant/components/synology_dsm/const.py b/homeassistant/components/synology_dsm/const.py
index d19e919e41b..163561d13a0 100644
--- a/homeassistant/components/synology_dsm/const.py
+++ b/homeassistant/components/synology_dsm/const.py
@@ -8,7 +8,7 @@ from homeassistant.const import (
DATA_MEGABYTES,
DATA_RATE_KILOBYTES_PER_SECOND,
DATA_TERABYTES,
- UNIT_PERCENTAGE,
+ PERCENTAGE,
)
DOMAIN = "synology_dsm"
@@ -68,56 +68,56 @@ SECURITY_BINARY_SENSORS = {
UTILISATION_SENSORS = {
f"{SynoCoreUtilization.API_KEY}:cpu_other_load": {
ENTITY_NAME: "CPU Load (Other)",
- ENTITY_UNIT: UNIT_PERCENTAGE,
+ ENTITY_UNIT: PERCENTAGE,
ENTITY_ICON: "mdi:chip",
ENTITY_CLASS: None,
ENTITY_ENABLE: False,
},
f"{SynoCoreUtilization.API_KEY}:cpu_user_load": {
ENTITY_NAME: "CPU Load (User)",
- ENTITY_UNIT: UNIT_PERCENTAGE,
+ ENTITY_UNIT: PERCENTAGE,
ENTITY_ICON: "mdi:chip",
ENTITY_CLASS: None,
ENTITY_ENABLE: True,
},
f"{SynoCoreUtilization.API_KEY}:cpu_system_load": {
ENTITY_NAME: "CPU Load (System)",
- ENTITY_UNIT: UNIT_PERCENTAGE,
+ ENTITY_UNIT: PERCENTAGE,
ENTITY_ICON: "mdi:chip",
ENTITY_CLASS: None,
ENTITY_ENABLE: False,
},
f"{SynoCoreUtilization.API_KEY}:cpu_total_load": {
ENTITY_NAME: "CPU Load (Total)",
- ENTITY_UNIT: UNIT_PERCENTAGE,
+ ENTITY_UNIT: PERCENTAGE,
ENTITY_ICON: "mdi:chip",
ENTITY_CLASS: None,
ENTITY_ENABLE: True,
},
f"{SynoCoreUtilization.API_KEY}:cpu_1min_load": {
ENTITY_NAME: "CPU Load (1 min)",
- ENTITY_UNIT: UNIT_PERCENTAGE,
+ ENTITY_UNIT: PERCENTAGE,
ENTITY_ICON: "mdi:chip",
ENTITY_CLASS: None,
ENTITY_ENABLE: False,
},
f"{SynoCoreUtilization.API_KEY}:cpu_5min_load": {
ENTITY_NAME: "CPU Load (5 min)",
- ENTITY_UNIT: UNIT_PERCENTAGE,
+ ENTITY_UNIT: PERCENTAGE,
ENTITY_ICON: "mdi:chip",
ENTITY_CLASS: None,
ENTITY_ENABLE: True,
},
f"{SynoCoreUtilization.API_KEY}:cpu_15min_load": {
ENTITY_NAME: "CPU Load (15 min)",
- ENTITY_UNIT: UNIT_PERCENTAGE,
+ ENTITY_UNIT: PERCENTAGE,
ENTITY_ICON: "mdi:chip",
ENTITY_CLASS: None,
ENTITY_ENABLE: True,
},
f"{SynoCoreUtilization.API_KEY}:memory_real_usage": {
ENTITY_NAME: "Memory Usage (Real)",
- ENTITY_UNIT: UNIT_PERCENTAGE,
+ ENTITY_UNIT: PERCENTAGE,
ENTITY_ICON: "mdi:memory",
ENTITY_CLASS: None,
ENTITY_ENABLE: True,
@@ -203,7 +203,7 @@ STORAGE_VOL_SENSORS = {
},
f"{SynoStorage.API_KEY}:volume_percentage_used": {
ENTITY_NAME: "Volume Used",
- ENTITY_UNIT: UNIT_PERCENTAGE,
+ ENTITY_UNIT: PERCENTAGE,
ENTITY_ICON: "mdi:chart-pie",
ENTITY_CLASS: None,
ENTITY_ENABLE: True,
diff --git a/homeassistant/components/synology_dsm/translations/fr.json b/homeassistant/components/synology_dsm/translations/fr.json
index 74604a96c11..6c8b627a76b 100644
--- a/homeassistant/components/synology_dsm/translations/fr.json
+++ b/homeassistant/components/synology_dsm/translations/fr.json
@@ -21,7 +21,7 @@
"link": {
"data": {
"password": "Mot de passe",
- "port": "Port (facultatif)",
+ "port": "Port",
"ssl": "Utilisez SSL/TLS pour vous connecter \u00e0 votre NAS",
"username": "Nom d'utilisateur"
},
@@ -32,7 +32,7 @@
"data": {
"host": "Nom d'h\u00f4te ou adresse IP",
"password": "Mot de passe",
- "port": "Port (facultatif)",
+ "port": "Port",
"ssl": "Utilisez SSL/TLS pour vous connecter \u00e0 votre NAS",
"username": "Nom d'utilisateur"
},
diff --git a/homeassistant/components/synology_dsm/translations/no.json b/homeassistant/components/synology_dsm/translations/no.json
index f8d7add4dc2..2bfc824cb76 100644
--- a/homeassistant/components/synology_dsm/translations/no.json
+++ b/homeassistant/components/synology_dsm/translations/no.json
@@ -21,22 +21,18 @@
"link": {
"data": {
"password": "Passord",
- "port": "",
"ssl": "Bruk SSL/TLS til \u00e5 koble til NAS-en",
"username": "Brukernavn"
},
- "description": "Vil du konfigurere {name} ({host})?",
- "title": ""
+ "description": "Vil du konfigurere {name} ({host})?"
},
"user": {
"data": {
"host": "Vert",
"password": "Passord",
- "port": "",
"ssl": "Bruk SSL/TLS til \u00e5 koble til NAS-en",
"username": "Brukernavn"
- },
- "title": ""
+ }
}
}
},
diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py
index 6f658962fe0..b8d6b1664ac 100644
--- a/homeassistant/components/system_log/__init__.py
+++ b/homeassistant/components/system_log/__init__.py
@@ -165,7 +165,7 @@ class LogErrorQueueHandler(logging.handlers.QueueHandler):
"""Emit a log record."""
try:
self.enqueue(record)
- except asyncio.CancelledError: # pylint: disable=try-except-raise
+ except asyncio.CancelledError:
raise
except Exception: # pylint: disable=broad-except
self.handleError(record)
diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json
index 5753dbbf682..3b08a7afe14 100644
--- a/homeassistant/components/systemmonitor/manifest.json
+++ b/homeassistant/components/systemmonitor/manifest.json
@@ -2,6 +2,6 @@
"domain": "systemmonitor",
"name": "System Monitor",
"documentation": "https://www.home-assistant.io/integrations/systemmonitor",
- "requirements": ["psutil==5.7.0"],
+ "requirements": ["psutil==5.7.2"],
"codeowners": []
}
diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py
index bae42c2f50b..e8ff20b2b5a 100644
--- a/homeassistant/components/systemmonitor/sensor.py
+++ b/homeassistant/components/systemmonitor/sensor.py
@@ -2,6 +2,7 @@
import logging
import os
import socket
+import sys
import psutil
import voluptuous as vol
@@ -13,12 +14,14 @@ from homeassistant.const import (
DATA_GIBIBYTES,
DATA_MEBIBYTES,
DATA_RATE_MEGABYTES_PER_SECOND,
+ PERCENTAGE,
STATE_OFF,
STATE_ON,
- UNIT_PERCENTAGE,
+ TEMP_CELSIUS,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
+from homeassistant.util import slugify
import homeassistant.util.dt as dt_util
# mypy: allow-untyped-defs, no-check-untyped-defs
@@ -27,10 +30,15 @@ _LOGGER = logging.getLogger(__name__)
CONF_ARG = "arg"
+if sys.maxsize > 2 ** 32:
+ CPU_ICON = "mdi:cpu-64-bit"
+else:
+ CPU_ICON = "mdi:cpu-32-bit"
+
SENSOR_TYPES = {
"disk_free": ["Disk free", DATA_GIBIBYTES, "mdi:harddisk", None],
"disk_use": ["Disk use", DATA_GIBIBYTES, "mdi:harddisk", None],
- "disk_use_percent": ["Disk use (percent)", UNIT_PERCENTAGE, "mdi:harddisk", None],
+ "disk_use_percent": ["Disk use (percent)", PERCENTAGE, "mdi:harddisk", None],
"ipv4_address": ["IPv4 address", "", "mdi:server-network", None],
"ipv6_address": ["IPv6 address", "", "mdi:server-network", None],
"last_boot": ["Last boot", "", "mdi:clock", "timestamp"],
@@ -39,7 +47,7 @@ SENSOR_TYPES = {
"load_5m": ["Load (5m)", " ", "mdi:memory", None],
"memory_free": ["Memory free", DATA_MEBIBYTES, "mdi:memory", None],
"memory_use": ["Memory use", DATA_MEBIBYTES, "mdi:memory", None],
- "memory_use_percent": ["Memory use (percent)", UNIT_PERCENTAGE, "mdi:memory", None],
+ "memory_use_percent": ["Memory use (percent)", PERCENTAGE, "mdi:memory", None],
"network_in": ["Network in", DATA_MEBIBYTES, "mdi:server-network", None],
"network_out": ["Network out", DATA_MEBIBYTES, "mdi:server-network", None],
"packets_in": ["Packets in", " ", "mdi:server-network", None],
@@ -56,11 +64,12 @@ SENSOR_TYPES = {
"mdi:server-network",
None,
],
- "process": ["Process", " ", "mdi:memory", None],
- "processor_use": ["Processor use", UNIT_PERCENTAGE, "mdi:memory", None],
+ "process": ["Process", " ", CPU_ICON, None],
+ "processor_use": ["Processor use", PERCENTAGE, CPU_ICON, None],
+ "processor_temperature": ["Processor temperature", TEMP_CELSIUS, CPU_ICON, None],
"swap_free": ["Swap free", DATA_MEBIBYTES, "mdi:harddisk", None],
"swap_use": ["Swap use", DATA_MEBIBYTES, "mdi:harddisk", None],
- "swap_use_percent": ["Swap use (percent)", UNIT_PERCENTAGE, "mdi:harddisk", None],
+ "swap_use_percent": ["Swap use (percent)", PERCENTAGE, "mdi:harddisk", None],
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@@ -90,13 +99,48 @@ IO_COUNTER = {
IF_ADDRS_FAMILY = {"ipv4_address": socket.AF_INET, "ipv6_address": socket.AF_INET6}
+# There might be additional keys to be added for different
+# platforms / hardware combinations.
+# Taken from last version of "glances" integration before they moved to
+# a generic temperature sensor logic.
+# https://github.com/home-assistant/core/blob/5e15675593ba94a2c11f9f929cdad317e27ce190/homeassistant/components/glances/sensor.py#L199
+CPU_SENSOR_PREFIXES = [
+ "amdgpu 1",
+ "aml_thermal",
+ "Core 0",
+ "Core 1",
+ "CPU Temperature",
+ "CPU",
+ "cpu-thermal 1",
+ "cpu_thermal 1",
+ "exynos-therm 1",
+ "Package id 0",
+ "Physical id 0",
+ "radeon 1",
+ "soc-thermal 1",
+ "soc_thermal 1",
+]
+
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the system monitor sensors."""
dev = []
for resource in config[CONF_RESOURCES]:
+ # Initialize the sensor argument if none was provided.
+ # For disk monitoring default to "/" (root) to prevent runtime errors, if argument was not specified.
if CONF_ARG not in resource:
- resource[CONF_ARG] = ""
+ if resource[CONF_TYPE].startswith("disk_"):
+ resource[CONF_ARG] = "/"
+ else:
+ resource[CONF_ARG] = ""
+
+ # Verify if we can retrieve CPU / processor temperatures.
+ # If not, do not create the entity and add a warning to the log
+ if resource[CONF_TYPE] == "processor_temperature":
+ if SystemMonitorSensor.read_cpu_temperature() is None:
+ _LOGGER.warning("Cannot read CPU / processor temperature information.")
+ continue
+
dev.append(SystemMonitorSensor(resource[CONF_TYPE], resource[CONF_ARG]))
add_entities(dev, True)
@@ -108,10 +152,12 @@ class SystemMonitorSensor(Entity):
def __init__(self, sensor_type, argument=""):
"""Initialize the sensor."""
self._name = "{} {}".format(SENSOR_TYPES[sensor_type][0], argument)
+ self._unique_id = slugify(f"{sensor_type}_{argument}")
self.argument = argument
self.type = sensor_type
self._state = None
self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ self._available = True
if sensor_type in ["throughput_network_out", "throughput_network_in"]:
self._last_value = None
self._last_update_time = None
@@ -121,6 +167,11 @@ class SystemMonitorSensor(Entity):
"""Return the name of the sensor."""
return self._name.rstrip()
+ @property
+ def unique_id(self):
+ """Return the unique ID."""
+ return self._unique_id
+
@property
def device_class(self):
"""Return the class of this sensor."""
@@ -141,6 +192,11 @@ class SystemMonitorSensor(Entity):
"""Return the unit of measurement of this entity, if any."""
return self._unit_of_measurement
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
+
def update(self):
"""Get the latest system information."""
if self.type == "disk_use_percent":
@@ -166,6 +222,8 @@ class SystemMonitorSensor(Entity):
self._state = round(psutil.swap_memory().free / 1024 ** 2, 1)
elif self.type == "processor_use":
self._state = round(psutil.cpu_percent(interval=None))
+ elif self.type == "processor_temperature":
+ self._state = self.read_cpu_temperature()
elif self.type == "process":
for proc in psutil.process_iter():
try:
@@ -231,3 +289,23 @@ class SystemMonitorSensor(Entity):
self._state = round(os.getloadavg()[1], 2)
elif self.type == "load_15m":
self._state = round(os.getloadavg()[2], 2)
+
+ @staticmethod
+ def read_cpu_temperature():
+ """Attempt to read CPU / processor temperature."""
+ temps = psutil.sensors_temperatures()
+
+ for name, entries in temps.items():
+ i = 1
+ for entry in entries:
+ # In case the label is empty (e.g. on Raspberry PI 4),
+ # construct it ourself here based on the sensor key name.
+ if not entry.label:
+ _label = f"{name} {i}"
+ else:
+ _label = entry.label
+
+ if _label in CPU_SENSOR_PREFIXES:
+ return round(entry.current, 1)
+
+ i += 1
diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py
index f2273bcae10..44a0f551ae0 100644
--- a/homeassistant/components/tado/__init__.py
+++ b/homeassistant/components/tado/__init__.py
@@ -63,7 +63,9 @@ async def async_setup(hass: HomeAssistant, config: dict):
for conf in config[DOMAIN]:
hass.async_create_task(
hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=conf,
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=conf,
)
)
@@ -89,18 +91,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
except RuntimeError as exc:
_LOGGER.error("Failed to setup tado: %s", exc)
return ConfigEntryNotReady
+ except requests.exceptions.Timeout as ex:
+ raise ConfigEntryNotReady from ex
except requests.exceptions.HTTPError as ex:
if ex.response.status_code > 400 and ex.response.status_code < 500:
_LOGGER.error("Failed to login to tado: %s", ex)
return False
- raise ConfigEntryNotReady
+ raise ConfigEntryNotReady from ex
# Do first update
await hass.async_add_executor_job(tadoconnector.update)
# Poll for updates in the background
update_track = async_track_time_interval(
- hass, lambda now: tadoconnector.update(), SCAN_INTERVAL,
+ hass,
+ lambda now: tadoconnector.update(),
+ SCAN_INTERVAL,
)
update_listener = entry.add_update_listener(_async_update_listener)
@@ -211,7 +217,9 @@ class TadoConnector:
return
except RuntimeError:
_LOGGER.error(
- "Unable to connect to Tado while updating %s %s", sensor_type, sensor,
+ "Unable to connect to Tado while updating %s %s",
+ sensor_type,
+ sensor,
)
return
@@ -239,7 +247,8 @@ class TadoConnector:
self.update_sensor("zone", zone_id)
def set_presence(
- self, presence=PRESET_HOME,
+ self,
+ presence=PRESET_HOME,
):
"""Set the presence to home or away."""
if presence == PRESET_AWAY:
diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py
index 6d45552abd2..3e0c79ad65e 100644
--- a/homeassistant/components/tado/climate.py
+++ b/homeassistant/components/tado/climate.py
@@ -75,7 +75,9 @@ async def async_setup_entry(
platform = entity_platform.current_platform.get()
platform.async_register_entity_service(
- SERVICE_CLIMATE_TIMER, CLIMATE_TIMER_SCHEMA, "set_timer",
+ SERVICE_CLIMATE_TIMER,
+ CLIMATE_TIMER_SCHEMA,
+ "set_timer",
)
if entities:
diff --git a/homeassistant/components/tado/config_flow.py b/homeassistant/components/tado/config_flow.py
index fb60b820ab9..0c45cc809af 100644
--- a/homeassistant/components/tado/config_flow.py
+++ b/homeassistant/components/tado/config_flow.py
@@ -30,14 +30,14 @@ async def validate_input(hass: core.HomeAssistant, data):
Tado, data[CONF_USERNAME], data[CONF_PASSWORD]
)
tado_me = await hass.async_add_executor_job(tado.getMe)
- except KeyError:
- raise InvalidAuth
- except RuntimeError:
- raise CannotConnect
+ except KeyError as ex:
+ raise InvalidAuth from ex
+ except RuntimeError as ex:
+ raise CannotConnect from ex
except requests.exceptions.HTTPError as ex:
if ex.response.status_code > 400 and ex.response.status_code < 500:
- raise InvalidAuth
- raise CannotConnect
+ raise InvalidAuth from ex
+ raise CannotConnect from ex
if "homes" not in tado_me or len(tado_me["homes"]) == 0:
raise NoHomes
diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py
index 231dc419b6b..56be5eb0123 100644
--- a/homeassistant/components/tado/sensor.py
+++ b/homeassistant/components/tado/sensor.py
@@ -2,7 +2,7 @@
import logging
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE, TEMP_CELSIUS
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
@@ -145,9 +145,9 @@ class TadoZoneSensor(TadoZoneEntity, Entity):
if self.zone_variable == "temperature":
return self.hass.config.units.temperature_unit
if self.zone_variable == "humidity":
- return UNIT_PERCENTAGE
+ return PERCENTAGE
if self.zone_variable == "heating":
- return UNIT_PERCENTAGE
+ return PERCENTAGE
if self.zone_variable == "ac":
return None
diff --git a/homeassistant/components/tado/translations/pt.json b/homeassistant/components/tado/translations/pt.json
index b4642359973..4a071063d47 100644
--- a/homeassistant/components/tado/translations/pt.json
+++ b/homeassistant/components/tado/translations/pt.json
@@ -1,5 +1,8 @@
{
"config": {
+ "error": {
+ "unknown": "Erro inesperado"
+ },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py
index be3be0f545a..1a99db5c24c 100644
--- a/homeassistant/components/tado/water_heater.py
+++ b/homeassistant/components/tado/water_heater.py
@@ -71,7 +71,9 @@ async def async_setup_entry(
platform = entity_platform.current_platform.get()
platform.async_register_entity_service(
- SERVICE_WATER_HEATER_TIMER, WATER_HEATER_TIMER_SCHEMA, "set_timer",
+ SERVICE_WATER_HEATER_TIMER,
+ WATER_HEATER_TIMER_SCHEMA,
+ "set_timer",
)
if entities:
diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py
new file mode 100644
index 00000000000..321dce9a296
--- /dev/null
+++ b/homeassistant/components/tag/__init__.py
@@ -0,0 +1,121 @@
+"""The Tag integration."""
+import logging
+import typing
+import uuid
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_NAME
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import collection
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.storage import Store
+from homeassistant.loader import bind_hass
+import homeassistant.util.dt as dt_util
+
+from .const import DEVICE_ID, DOMAIN, EVENT_TAG_SCANNED, TAG_ID
+
+_LOGGER = logging.getLogger(__name__)
+
+LAST_SCANNED = "last_scanned"
+STORAGE_KEY = DOMAIN
+STORAGE_VERSION = 1
+TAGS = "tags"
+
+CREATE_FIELDS = {
+ vol.Optional(TAG_ID): cv.string,
+ vol.Optional(CONF_NAME): vol.All(str, vol.Length(min=1)),
+ vol.Optional("description"): cv.string,
+ vol.Optional(LAST_SCANNED): cv.datetime,
+}
+
+UPDATE_FIELDS = {
+ vol.Optional(CONF_NAME): vol.All(str, vol.Length(min=1)),
+ vol.Optional("description"): cv.string,
+ vol.Optional(LAST_SCANNED): cv.datetime,
+}
+
+
+class TagIDExistsError(HomeAssistantError):
+ """Raised when an item is not found."""
+
+ def __init__(self, item_id: str):
+ """Initialize tag id exists error."""
+ super().__init__(f"Tag with id: {item_id} already exists.")
+ self.item_id = item_id
+
+
+class TagIDManager(collection.IDManager):
+ """ID manager for tags."""
+
+ def generate_id(self, suggestion: str) -> str:
+ """Generate an ID."""
+ if self.has_id(suggestion):
+ raise TagIDExistsError(suggestion)
+
+ return suggestion
+
+
+class TagStorageCollection(collection.StorageCollection):
+ """Tag collection stored in storage."""
+
+ CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
+ UPDATE_SCHEMA = vol.Schema(UPDATE_FIELDS)
+
+ async def _process_create_data(self, data: typing.Dict) -> typing.Dict:
+ """Validate the config is valid."""
+ data = self.CREATE_SCHEMA(data)
+ if not data[TAG_ID]:
+ data[TAG_ID] = str(uuid.uuid4())
+ # make last_scanned JSON serializeable
+ if LAST_SCANNED in data:
+ data[LAST_SCANNED] = data[LAST_SCANNED].isoformat()
+ return data
+
+ @callback
+ def _get_suggested_id(self, info: typing.Dict) -> str:
+ """Suggest an ID based on the config."""
+ return info[TAG_ID]
+
+ async def _update_data(self, data: dict, update_data: typing.Dict) -> typing.Dict:
+ """Return a new updated data object."""
+ data = {**data, **self.UPDATE_SCHEMA(update_data)}
+ # make last_scanned JSON serializeable
+ if LAST_SCANNED in update_data:
+ data[LAST_SCANNED] = data[LAST_SCANNED].isoformat()
+ return data
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+ """Set up the Tag component."""
+ hass.data[DOMAIN] = {}
+ id_manager = TagIDManager()
+ hass.data[DOMAIN][TAGS] = storage_collection = TagStorageCollection(
+ Store(hass, STORAGE_VERSION, STORAGE_KEY),
+ logging.getLogger(f"{__name__}.storage_collection"),
+ id_manager,
+ )
+ await storage_collection.async_load()
+ collection.StorageCollectionWebsocket(
+ storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS
+ ).async_setup(hass)
+
+ return True
+
+
+@bind_hass
+async def async_scan_tag(hass, tag_id, device_id, context=None):
+ """Handle when a tag is scanned."""
+ if DOMAIN not in hass.config.components:
+ raise HomeAssistantError("tag component has not been set up.")
+
+ hass.bus.async_fire(
+ EVENT_TAG_SCANNED, {TAG_ID: tag_id, DEVICE_ID: device_id}, context=context
+ )
+ helper = hass.data[DOMAIN][TAGS]
+ if tag_id in helper.data:
+ await helper.async_update_item(tag_id, {LAST_SCANNED: dt_util.utcnow()})
+ else:
+ await helper.async_create_item({TAG_ID: tag_id, LAST_SCANNED: dt_util.utcnow()})
+ _LOGGER.debug("Tag: %s scanned by device: %s", tag_id, device_id)
diff --git a/homeassistant/components/tag/const.py b/homeassistant/components/tag/const.py
new file mode 100644
index 00000000000..ed74a1f0549
--- /dev/null
+++ b/homeassistant/components/tag/const.py
@@ -0,0 +1,6 @@
+"""Constants for the Tag integration."""
+
+DEVICE_ID = "device_id"
+DOMAIN = "tag"
+EVENT_TAG_SCANNED = "tag_scanned"
+TAG_ID = "tag_id"
diff --git a/homeassistant/components/tag/manifest.json b/homeassistant/components/tag/manifest.json
new file mode 100644
index 00000000000..d330fdaf3f8
--- /dev/null
+++ b/homeassistant/components/tag/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "tag",
+ "name": "Tag",
+ "config_flow": false,
+ "documentation": "https://www.home-assistant.io/integrations/tag",
+ "requirements": [],
+ "ssdp": [],
+ "zeroconf": [],
+ "homekit": {},
+ "dependencies": [],
+ "codeowners": ["@balloob", "@dmulcahey"]
+}
diff --git a/homeassistant/components/tag/strings.json b/homeassistant/components/tag/strings.json
new file mode 100644
index 00000000000..ba680ba0d81
--- /dev/null
+++ b/homeassistant/components/tag/strings.json
@@ -0,0 +1,3 @@
+{
+ "title": "Tag"
+}
diff --git a/homeassistant/components/tag/translations/ca.json b/homeassistant/components/tag/translations/ca.json
new file mode 100644
index 00000000000..fdac700612d
--- /dev/null
+++ b/homeassistant/components/tag/translations/ca.json
@@ -0,0 +1,3 @@
+{
+ "title": "Tag"
+}
\ No newline at end of file
diff --git a/homeassistant/components/tag/translations/cs.json b/homeassistant/components/tag/translations/cs.json
new file mode 100644
index 00000000000..55ef9373f14
--- /dev/null
+++ b/homeassistant/components/tag/translations/cs.json
@@ -0,0 +1,3 @@
+{
+ "title": "Zna\u010dka"
+}
\ No newline at end of file
diff --git a/homeassistant/components/tag/translations/en.json b/homeassistant/components/tag/translations/en.json
new file mode 100644
index 00000000000..fdac700612d
--- /dev/null
+++ b/homeassistant/components/tag/translations/en.json
@@ -0,0 +1,3 @@
+{
+ "title": "Tag"
+}
\ No newline at end of file
diff --git a/homeassistant/components/tag/translations/es.json b/homeassistant/components/tag/translations/es.json
new file mode 100644
index 00000000000..6f21d3e8d6d
--- /dev/null
+++ b/homeassistant/components/tag/translations/es.json
@@ -0,0 +1,3 @@
+{
+ "title": "Etiqueta"
+}
\ No newline at end of file
diff --git a/homeassistant/components/tag/translations/fr.json b/homeassistant/components/tag/translations/fr.json
new file mode 100644
index 00000000000..93ee415d38b
--- /dev/null
+++ b/homeassistant/components/tag/translations/fr.json
@@ -0,0 +1,3 @@
+{
+ "title": "Balise"
+}
\ No newline at end of file
diff --git a/homeassistant/components/tag/translations/it.json b/homeassistant/components/tag/translations/it.json
new file mode 100644
index 00000000000..a7622fae02b
--- /dev/null
+++ b/homeassistant/components/tag/translations/it.json
@@ -0,0 +1,3 @@
+{
+ "title": "Etichetta"
+}
\ No newline at end of file
diff --git a/homeassistant/components/tag/translations/lb.json b/homeassistant/components/tag/translations/lb.json
new file mode 100644
index 00000000000..fdac700612d
--- /dev/null
+++ b/homeassistant/components/tag/translations/lb.json
@@ -0,0 +1,3 @@
+{
+ "title": "Tag"
+}
\ No newline at end of file
diff --git a/homeassistant/components/tag/translations/no.json b/homeassistant/components/tag/translations/no.json
new file mode 100644
index 00000000000..fdac700612d
--- /dev/null
+++ b/homeassistant/components/tag/translations/no.json
@@ -0,0 +1,3 @@
+{
+ "title": "Tag"
+}
\ No newline at end of file
diff --git a/homeassistant/components/tag/translations/pt-BR.json b/homeassistant/components/tag/translations/pt-BR.json
new file mode 100644
index 00000000000..6f21d3e8d6d
--- /dev/null
+++ b/homeassistant/components/tag/translations/pt-BR.json
@@ -0,0 +1,3 @@
+{
+ "title": "Etiqueta"
+}
\ No newline at end of file
diff --git a/homeassistant/components/tag/translations/ru.json b/homeassistant/components/tag/translations/ru.json
new file mode 100644
index 00000000000..fdac700612d
--- /dev/null
+++ b/homeassistant/components/tag/translations/ru.json
@@ -0,0 +1,3 @@
+{
+ "title": "Tag"
+}
\ No newline at end of file
diff --git a/homeassistant/components/tag/translations/zh-Hant.json b/homeassistant/components/tag/translations/zh-Hant.json
new file mode 100644
index 00000000000..9b83ec73efc
--- /dev/null
+++ b/homeassistant/components/tag/translations/zh-Hant.json
@@ -0,0 +1,3 @@
+{
+ "title": "\u6a19\u7c64"
+}
\ No newline at end of file
diff --git a/homeassistant/components/tag/trigger.py b/homeassistant/components/tag/trigger.py
new file mode 100644
index 00000000000..8da9baa5aaa
--- /dev/null
+++ b/homeassistant/components/tag/trigger.py
@@ -0,0 +1,37 @@
+"""Support for tag triggers."""
+import voluptuous as vol
+
+from homeassistant.components.homeassistant.triggers import event as event_trigger
+from homeassistant.const import CONF_PLATFORM
+from homeassistant.helpers import config_validation as cv
+
+from .const import DEVICE_ID, DOMAIN, EVENT_TAG_SCANNED, TAG_ID
+
+TRIGGER_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_PLATFORM): DOMAIN,
+ vol.Required(TAG_ID): cv.string,
+ vol.Optional(DEVICE_ID): cv.string,
+ }
+)
+
+
+async def async_attach_trigger(hass, config, action, automation_info):
+ """Listen for tag_scanned events based on configuration."""
+ tag_id = config.get(TAG_ID)
+ device_id = config.get(DEVICE_ID)
+ event_data = {TAG_ID: tag_id}
+
+ if device_id:
+ event_data[DEVICE_ID] = device_id
+
+ event_config = {
+ event_trigger.CONF_PLATFORM: "event",
+ event_trigger.CONF_EVENT_TYPE: EVENT_TAG_SCANNED,
+ event_trigger.CONF_EVENT_DATA: event_data,
+ }
+ event_config = event_trigger.TRIGGER_SCHEMA(event_config)
+
+ return await event_trigger.async_attach_trigger(
+ hass, event_config, action, automation_info, platform_type=DOMAIN
+ )
diff --git a/homeassistant/components/tahoma/sensor.py b/homeassistant/components/tahoma/sensor.py
index 8ceb07e1a9f..7b28989ad8e 100644
--- a/homeassistant/components/tahoma/sensor.py
+++ b/homeassistant/components/tahoma/sensor.py
@@ -2,7 +2,7 @@
from datetime import timedelta
import logging
-from homeassistant.const import ATTR_BATTERY_LEVEL, TEMP_CELSIUS, UNIT_PERCENTAGE
+from homeassistant.const import ATTR_BATTERY_LEVEL, PERCENTAGE, TEMP_CELSIUS
from homeassistant.helpers.entity import Entity
from . import DOMAIN as TAHOMA_DOMAIN, TahomaDevice
@@ -51,7 +51,7 @@ class TahomaSensor(TahomaDevice, Entity):
if self.tahoma_device.type == "io:LightIOSystemSensor":
return "lx"
if self.tahoma_device.type == "Humidity Sensor":
- return UNIT_PERCENTAGE
+ return PERCENTAGE
if self.tahoma_device.type == "rtds:RTDSContactSensor":
return None
if self.tahoma_device.type == "rtds:RTDSMotionSensor":
@@ -62,7 +62,7 @@ class TahomaSensor(TahomaDevice, Entity):
):
return TEMP_CELSIUS
if self.tahoma_device.type == "somfythermostat:SomfyThermostatHumiditySensor":
- return UNIT_PERCENTAGE
+ return PERCENTAGE
def update(self):
"""Update the state."""
diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py
index 5847eecc8a8..ab1cc8b23da 100644
--- a/homeassistant/components/tank_utility/sensor.py
+++ b/homeassistant/components/tank_utility/sensor.py
@@ -8,7 +8,7 @@ from tank_utility import auth, device as tank_monitor
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD, UNIT_PERCENTAGE
+from homeassistant.const import CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD, PERCENTAGE
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -73,7 +73,7 @@ class TankUtilitySensor(Entity):
self._device = device
self._state = None
self._name = f"Tank Utility {self.device}"
- self._unit_of_measurement = UNIT_PERCENTAGE
+ self._unit_of_measurement = PERCENTAGE
self._attributes = {}
@property
diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py
index d78b5eb2641..b0dd3368ad3 100644
--- a/homeassistant/components/tankerkoenig/sensor.py
+++ b/homeassistant/components/tankerkoenig/sensor.py
@@ -3,8 +3,11 @@
import logging
from homeassistant.const import ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE
-from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.helpers.update_coordinator import (
+ CoordinatorEntity,
+ DataUpdateCoordinator,
+ UpdateFailed,
+)
from .const import DOMAIN, NAME
@@ -35,8 +38,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
"""Fetch data from API endpoint."""
try:
return await tankerkoenig.fetch_data()
- except LookupError:
- raise UpdateFailed("Failed to fetch data")
+ except LookupError as err:
+ raise UpdateFailed("Failed to fetch data") from err
coordinator = DataUpdateCoordinator(
hass,
@@ -71,15 +74,15 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
async_add_entities(entities)
-class FuelPriceSensor(Entity):
+class FuelPriceSensor(CoordinatorEntity):
"""Contains prices for fuel in a given station."""
def __init__(self, fuel_type, station, coordinator, name, show_on_map):
"""Initialize the sensor."""
+ super().__init__(coordinator)
self._station = station
self._station_id = station["id"]
self._fuel_type = fuel_type
- self._coordinator = coordinator
self._name = name
self._latitude = station["lat"]
self._longitude = station["lng"]
@@ -105,16 +108,11 @@ class FuelPriceSensor(Entity):
"""Return unit of measurement."""
return "€"
- @property
- def should_poll(self):
- """No need to poll. Coordinator notifies of updates."""
- return False
-
@property
def state(self):
"""Return the state of the device."""
# key Fuel_type is not available when the fuel station is closed, use "get" instead of "[]" to avoid exceptions
- return self._coordinator.data[self._station_id].get(self._fuel_type)
+ return self.coordinator.data[self._station_id].get(self._fuel_type)
@property
def unique_id(self) -> str:
@@ -124,7 +122,7 @@ class FuelPriceSensor(Entity):
@property
def device_state_attributes(self):
"""Return the attributes of the device."""
- data = self._coordinator.data[self._station_id]
+ data = self.coordinator.data[self._station_id]
attrs = {
ATTR_ATTRIBUTION: ATTRIBUTION,
@@ -144,20 +142,3 @@ class FuelPriceSensor(Entity):
if data is not None and "status" in data:
attrs[ATTR_IS_OPEN] = data["status"] == "open"
return attrs
-
- @property
- def available(self):
- """Return if entity is available."""
- return self._coordinator.last_update_success
-
- async def async_added_to_hass(self):
- """When entity is added to hass."""
- self._coordinator.async_add_listener(self.async_write_ha_state)
-
- async def async_will_remove_from_hass(self):
- """When entity will be removed from hass."""
- self._coordinator.async_remove_listener(self.async_write_ha_state)
-
- async def async_update(self):
- """Update the entity."""
- await self._coordinator.async_request_refresh()
diff --git a/homeassistant/components/teksavvy/sensor.py b/homeassistant/components/teksavvy/sensor.py
index 96331cb5347..4ff2bc84dbe 100644
--- a/homeassistant/components/teksavvy/sensor.py
+++ b/homeassistant/components/teksavvy/sensor.py
@@ -12,7 +12,7 @@ from homeassistant.const import (
CONF_NAME,
DATA_GIGABYTES,
HTTP_OK,
- UNIT_PERCENTAGE,
+ PERCENTAGE,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
@@ -28,7 +28,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1)
REQUEST_TIMEOUT = 5 # seconds
SENSOR_TYPES = {
- "usage": ["Usage Ratio", UNIT_PERCENTAGE, "mdi:percent"],
+ "usage": ["Usage Ratio", PERCENTAGE, "mdi:percent"],
"usage_gb": ["Usage", DATA_GIGABYTES, "mdi:download"],
"limit": ["Data limit", DATA_GIGABYTES, "mdi:download"],
"onpeak_download": ["On Peak Download", DATA_GIGABYTES, "mdi:download"],
diff --git a/homeassistant/components/telegram/__init__.py b/homeassistant/components/telegram/__init__.py
index 1aca4e510c6..43a639cb2c3 100644
--- a/homeassistant/components/telegram/__init__.py
+++ b/homeassistant/components/telegram/__init__.py
@@ -1 +1,4 @@
"""The telegram component."""
+
+DOMAIN = "telegram"
+PLATFORMS = ["notify"]
diff --git a/homeassistant/components/telegram/notify.py b/homeassistant/components/telegram/notify.py
index 673935d8283..c0f3f624af9 100644
--- a/homeassistant/components/telegram/notify.py
+++ b/homeassistant/components/telegram/notify.py
@@ -12,6 +12,9 @@ from homeassistant.components.notify import (
BaseNotificationService,
)
from homeassistant.const import ATTR_LOCATION
+from homeassistant.helpers.reload import setup_reload_service
+
+from . import DOMAIN as TELEGRAM_DOMAIN, PLATFORMS
_LOGGER = logging.getLogger(__name__)
@@ -29,6 +32,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_CHAT_ID): vol.Coerce
def get_service(hass, config, discovery_info=None):
"""Get the Telegram notification service."""
+
+ setup_reload_service(hass, TELEGRAM_DOMAIN, PLATFORMS)
chat_id = config.get(CONF_CHAT_ID)
return TelegramNotificationService(hass, chat_id)
diff --git a/homeassistant/components/telegram/services.yaml b/homeassistant/components/telegram/services.yaml
index e69de29bb2d..c467de06fe5 100644
--- a/homeassistant/components/telegram/services.yaml
+++ b/homeassistant/components/telegram/services.yaml
@@ -0,0 +1,2 @@
+reload:
+ description: Reload telegram notify services.
diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py
index 7c8f976a049..4d5f41066e2 100644
--- a/homeassistant/components/telegram_bot/webhooks.py
+++ b/homeassistant/components/telegram_bot/webhooks.py
@@ -1,11 +1,11 @@
"""Support for Telegram bots using webhooks."""
import datetime as dt
+from ipaddress import ip_address
import logging
from telegram.error import TimedOut
from homeassistant.components.http import HomeAssistantView
-from homeassistant.components.http.const import KEY_REAL_IP
from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
HTTP_BAD_REQUEST,
@@ -96,7 +96,7 @@ class BotPushReceiver(HomeAssistantView, BaseTelegramBotEntity):
async def post(self, request):
"""Accept the POST from telegram."""
- real_ip = request[KEY_REAL_IP]
+ real_ip = ip_address(request.remote)
if not any(real_ip in net for net in self.trusted_networks):
_LOGGER.warning("Access denied from %s", real_ip)
return self.json_message("Access denied", HTTP_UNAUTHORIZED)
diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py
index 2e36f8f3ac2..c8f27a9412a 100644
--- a/homeassistant/components/tellduslive/sensor.py
+++ b/homeassistant/components/tellduslive/sensor.py
@@ -6,11 +6,11 @@ from homeassistant.const import (
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_TEMPERATURE,
+ PERCENTAGE,
POWER_WATT,
SPEED_METERS_PER_SECOND,
TEMP_CELSIUS,
TIME_HOURS,
- UNIT_PERCENTAGE,
UV_INDEX,
)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -39,7 +39,7 @@ SENSOR_TYPES = {
None,
DEVICE_CLASS_TEMPERATURE,
],
- SENSOR_TYPE_HUMIDITY: ["Humidity", UNIT_PERCENTAGE, None, DEVICE_CLASS_HUMIDITY],
+ SENSOR_TYPE_HUMIDITY: ["Humidity", PERCENTAGE, None, DEVICE_CLASS_HUMIDITY],
SENSOR_TYPE_RAINRATE: ["Rain rate", f"mm/{TIME_HOURS}", "mdi:water", None],
SENSOR_TYPE_RAINTOTAL: ["Rain total", "mm", "mdi:water", None],
SENSOR_TYPE_WINDDIRECTION: ["Wind direction", "", "", None],
diff --git a/homeassistant/components/tellduslive/translations/bg.json b/homeassistant/components/tellduslive/translations/bg.json
index eef994a608e..903819c1f3e 100644
--- a/homeassistant/components/tellduslive/translations/bg.json
+++ b/homeassistant/components/tellduslive/translations/bg.json
@@ -1,7 +1,6 @@
{
"config": {
"abort": {
- "already_setup": "TelldusLive \u0435 \u0432\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d",
"authorize_url_fail": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0430\u0434\u0440\u0435\u0441 \u0437\u0430 \u043e\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f.",
"authorize_url_timeout": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0430\u0434\u0440\u0435\u0441 \u0437\u0430 \u043e\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f \u0432 \u0441\u0440\u043e\u043a.",
"unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430"
diff --git a/homeassistant/components/tellduslive/translations/ca.json b/homeassistant/components/tellduslive/translations/ca.json
index ae2bd468964..4326269131e 100644
--- a/homeassistant/components/tellduslive/translations/ca.json
+++ b/homeassistant/components/tellduslive/translations/ca.json
@@ -2,7 +2,6 @@
"config": {
"abort": {
"already_configured": "TelldusLive ja est\u00e0 configurat",
- "already_setup": "TelldusLive ja est\u00e0 configurat",
"authorize_url_fail": "S'ha produ\u00eft un error desconegut al generar l'URL d'autoritzaci\u00f3.",
"authorize_url_timeout": "S'ha acabat el temps d'espera durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3.",
"unknown": "S'ha produ\u00eft un error desconegut"
diff --git a/homeassistant/components/tellduslive/translations/da.json b/homeassistant/components/tellduslive/translations/da.json
index 2ceb78b6bd0..c64e51dd06a 100644
--- a/homeassistant/components/tellduslive/translations/da.json
+++ b/homeassistant/components/tellduslive/translations/da.json
@@ -1,7 +1,6 @@
{
"config": {
"abort": {
- "already_setup": "TelldusLive er allerede konfigureret",
"authorize_url_fail": "Ukendt fejl ved generering af en autoriseret url.",
"authorize_url_timeout": "Timeout ved generering af autoriseret url.",
"unknown": "Ukendt fejl opstod"
diff --git a/homeassistant/components/tellduslive/translations/de.json b/homeassistant/components/tellduslive/translations/de.json
index 7d2ab21d81f..429ca47fbdf 100644
--- a/homeassistant/components/tellduslive/translations/de.json
+++ b/homeassistant/components/tellduslive/translations/de.json
@@ -1,7 +1,6 @@
{
"config": {
"abort": {
- "already_setup": "TelldusLive ist bereits konfiguriert",
"authorize_url_fail": "Unbekannter Fehler beim Erstellen der Authorisierungs-URL",
"authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.",
"unknown": "Unbekannter Fehler ist aufgetreten"
diff --git a/homeassistant/components/tellduslive/translations/en.json b/homeassistant/components/tellduslive/translations/en.json
index 04bd2a192c1..ef509f0ac43 100644
--- a/homeassistant/components/tellduslive/translations/en.json
+++ b/homeassistant/components/tellduslive/translations/en.json
@@ -2,7 +2,6 @@
"config": {
"abort": {
"already_configured": "TelldusLive is already configured",
- "already_setup": "TelldusLive is already configured",
"authorize_url_fail": "Unknown error generating an authorize url.",
"authorize_url_timeout": "Timeout generating authorize url.",
"unknown": "Unknown error occurred"
diff --git a/homeassistant/components/tellduslive/translations/es-419.json b/homeassistant/components/tellduslive/translations/es-419.json
index 71529c1f41d..0deeb26eea6 100644
--- a/homeassistant/components/tellduslive/translations/es-419.json
+++ b/homeassistant/components/tellduslive/translations/es-419.json
@@ -1,7 +1,6 @@
{
"config": {
"abort": {
- "already_setup": "TelldusLive ya est\u00e1 configurado",
"authorize_url_fail": "Error desconocido al generar una URL de autorizaci\u00f3n.",
"authorize_url_timeout": "Tiempo de espera agotado para generar la URL de autorizaci\u00f3n.",
"unknown": "Se produjo un error desconocido"
diff --git a/homeassistant/components/tellduslive/translations/es.json b/homeassistant/components/tellduslive/translations/es.json
index 378274f63af..3fcc57050fe 100644
--- a/homeassistant/components/tellduslive/translations/es.json
+++ b/homeassistant/components/tellduslive/translations/es.json
@@ -2,7 +2,6 @@
"config": {
"abort": {
"already_configured": "TelldusLive ya est\u00e1 configurado",
- "already_setup": "TelldusLive ya est\u00e1 configurado",
"authorize_url_fail": "Error desconocido generando la url de autorizaci\u00f3n",
"authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n",
"unknown": "Se produjo un error desconocido"
diff --git a/homeassistant/components/tellduslive/translations/fr.json b/homeassistant/components/tellduslive/translations/fr.json
index f3e90a5c7bf..a6c125fb04e 100644
--- a/homeassistant/components/tellduslive/translations/fr.json
+++ b/homeassistant/components/tellduslive/translations/fr.json
@@ -2,7 +2,6 @@
"config": {
"abort": {
"already_configured": "TelldusLive est d\u00e9j\u00e0 configur\u00e9",
- "already_setup": "TelldusLive est d\u00e9j\u00e0 configur\u00e9",
"authorize_url_fail": "Erreur inconnue lors de la g\u00e9n\u00e9ration d'une URL d'autorisation.",
"authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.",
"unknown": "Une erreur inconnue s'est produite"
diff --git a/homeassistant/components/tellduslive/translations/hu.json b/homeassistant/components/tellduslive/translations/hu.json
index ace432f6a3f..c285fd8213d 100644
--- a/homeassistant/components/tellduslive/translations/hu.json
+++ b/homeassistant/components/tellduslive/translations/hu.json
@@ -1,7 +1,6 @@
{
"config": {
"abort": {
- "already_setup": "A TelldusLive m\u00e1r be van \u00e1ll\u00edtva",
"authorize_url_fail": "Ismeretlen hiba t\u00f6rt\u00e9nt a hiteles\u00edt\u00e9si link gener\u00e1l\u00e1sa sor\u00e1n.",
"authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n.",
"unknown": "Ismeretlen hiba t\u00f6rt\u00e9nt"
diff --git a/homeassistant/components/tellduslive/translations/it.json b/homeassistant/components/tellduslive/translations/it.json
index e8f74f5ce29..c4afb96170e 100644
--- a/homeassistant/components/tellduslive/translations/it.json
+++ b/homeassistant/components/tellduslive/translations/it.json
@@ -2,7 +2,6 @@
"config": {
"abort": {
"already_configured": "TelldusLive \u00e8 gi\u00e0 configurato",
- "already_setup": "TelldusLive \u00e8 gi\u00e0 configurato",
"authorize_url_fail": "Errore sconosciuto nel generare l'url di autorizzazione",
"authorize_url_timeout": "Tempo scaduto nel generare l'url di autorizzazione",
"unknown": "Si \u00e8 verificato un errore sconosciuto."
diff --git a/homeassistant/components/tellduslive/translations/ko.json b/homeassistant/components/tellduslive/translations/ko.json
index ffbded23f7f..ef1b20086c5 100644
--- a/homeassistant/components/tellduslive/translations/ko.json
+++ b/homeassistant/components/tellduslive/translations/ko.json
@@ -2,7 +2,6 @@
"config": {
"abort": {
"already_configured": "TelldusLive \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
- "already_setup": "TelldusLive \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
"authorize_url_fail": "\uc778\uc99d url \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.",
"authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
"unknown": "\uc54c \uc218\uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
diff --git a/homeassistant/components/tellduslive/translations/lb.json b/homeassistant/components/tellduslive/translations/lb.json
index bf91c5d26ea..ba200074ac5 100644
--- a/homeassistant/components/tellduslive/translations/lb.json
+++ b/homeassistant/components/tellduslive/translations/lb.json
@@ -2,7 +2,6 @@
"config": {
"abort": {
"already_configured": "TelldusLive ass scho konfigur\u00e9iert",
- "already_setup": "TelldusLive ass scho konfigur\u00e9iert",
"authorize_url_fail": "Onbekannte Feeler beim gener\u00e9ieren vun der Autorisatiouns URL.",
"authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.",
"unknown": "Onbekannten Feeler opgetrueden"
diff --git a/homeassistant/components/tellduslive/translations/nl.json b/homeassistant/components/tellduslive/translations/nl.json
index d17637e7c2c..1ef78ae1fd3 100644
--- a/homeassistant/components/tellduslive/translations/nl.json
+++ b/homeassistant/components/tellduslive/translations/nl.json
@@ -1,7 +1,6 @@
{
"config": {
"abort": {
- "already_setup": "TelldusLive is al geconfigureerd",
"authorize_url_fail": "Onbekende fout bij het genereren van een autorisatie url.",
"authorize_url_timeout": "Time-out tijdens genereren autorisatie url.",
"unknown": "Onbekende fout opgetreden"
diff --git a/homeassistant/components/tellduslive/translations/no.json b/homeassistant/components/tellduslive/translations/no.json
index 8c2f3d3489d..232db7a0fad 100644
--- a/homeassistant/components/tellduslive/translations/no.json
+++ b/homeassistant/components/tellduslive/translations/no.json
@@ -2,7 +2,6 @@
"config": {
"abort": {
"already_configured": "TelldusLive er allerede konfigurert",
- "already_setup": "TelldusLive er allerede konfigurert",
"authorize_url_fail": "Ukjent feil ved oppretting av godkjenningsadresse.",
"authorize_url_timeout": "Tidsavbrudd ved oppretting av godkjenningsadresse.",
"unknown": "Ukjent feil oppstod"
diff --git a/homeassistant/components/tellduslive/translations/pl.json b/homeassistant/components/tellduslive/translations/pl.json
index deee22d9ed8..8145717b40e 100644
--- a/homeassistant/components/tellduslive/translations/pl.json
+++ b/homeassistant/components/tellduslive/translations/pl.json
@@ -2,7 +2,6 @@
"config": {
"abort": {
"already_configured": "TelldusLive jest ju\u017c skonfigurowany",
- "already_setup": "TelldusLive jest ju\u017c skonfigurowany.",
"authorize_url_fail": "Nieznany b\u0142\u0105d podczas generowania url autoryzacji.",
"authorize_url_timeout": "Przekroczono limit czasu generowania URL autoryzacji.",
"unknown": "Nieoczekiwany b\u0142\u0105d."
diff --git a/homeassistant/components/tellduslive/translations/pt-BR.json b/homeassistant/components/tellduslive/translations/pt-BR.json
index a1114965a4b..a63d94eb25b 100644
--- a/homeassistant/components/tellduslive/translations/pt-BR.json
+++ b/homeassistant/components/tellduslive/translations/pt-BR.json
@@ -1,7 +1,6 @@
{
"config": {
"abort": {
- "already_setup": "TelldusLive j\u00e1 est\u00e1 configurado",
"authorize_url_fail": "Erro desconhecido ao gerar um URL de autoriza\u00e7\u00e3o.",
"authorize_url_timeout": "Tempo limite de gera\u00e7\u00e3o de url de autoriza\u00e7\u00e3o.",
"unknown": "Ocorreu um erro desconhecido"
diff --git a/homeassistant/components/tellduslive/translations/pt.json b/homeassistant/components/tellduslive/translations/pt.json
index 549fa406253..ac374ba7b2c 100644
--- a/homeassistant/components/tellduslive/translations/pt.json
+++ b/homeassistant/components/tellduslive/translations/pt.json
@@ -1,7 +1,6 @@
{
"config": {
"abort": {
- "already_setup": "TelldusLive j\u00e1 est\u00e1 configurado",
"authorize_url_fail": "Erro desconhecido ao gerar um URL de autoriza\u00e7\u00e3o.",
"authorize_url_timeout": "Limite temporal ultrapassado ao gerar um URL de autoriza\u00e7\u00e3o.",
"unknown": "Ocorreu um erro desconhecido"
diff --git a/homeassistant/components/tellduslive/translations/ru.json b/homeassistant/components/tellduslive/translations/ru.json
index 4aac3b2a5d5..c1d052930a1 100644
--- a/homeassistant/components/tellduslive/translations/ru.json
+++ b/homeassistant/components/tellduslive/translations/ru.json
@@ -2,7 +2,6 @@
"config": {
"abort": {
"already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
- "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
"authorize_url_fail": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.",
"authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.",
"unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
diff --git a/homeassistant/components/tellduslive/translations/sl.json b/homeassistant/components/tellduslive/translations/sl.json
index 0775e47f694..a0634b3c6e5 100644
--- a/homeassistant/components/tellduslive/translations/sl.json
+++ b/homeassistant/components/tellduslive/translations/sl.json
@@ -1,7 +1,6 @@
{
"config": {
"abort": {
- "already_setup": "TelldusLive je \u017ee konfiguriran",
"authorize_url_fail": "Neznana napaka pri generiranju potrditvenega URL-ja.",
"authorize_url_timeout": "\u010casovna omejitev za generiranje URL-ja je potekla.",
"unknown": "Pri\u0161lo je do neznane napake"
diff --git a/homeassistant/components/tellduslive/translations/sv.json b/homeassistant/components/tellduslive/translations/sv.json
index 1a8affa7c31..96e1258faa5 100644
--- a/homeassistant/components/tellduslive/translations/sv.json
+++ b/homeassistant/components/tellduslive/translations/sv.json
@@ -1,7 +1,6 @@
{
"config": {
"abort": {
- "already_setup": "Telldus Live! \u00e4r redan konfigurerad",
"authorize_url_fail": "Ok\u00e4nt fel n\u00e4r genererar en url f\u00f6r att auktorisera.",
"authorize_url_timeout": "Timeout n\u00e4r genererar auktorisera url.",
"unknown": "Ok\u00e4nt fel intr\u00e4ffade"
diff --git a/homeassistant/components/tellduslive/translations/zh-Hans.json b/homeassistant/components/tellduslive/translations/zh-Hans.json
index f28bb62b10a..c1d4a0c54f5 100644
--- a/homeassistant/components/tellduslive/translations/zh-Hans.json
+++ b/homeassistant/components/tellduslive/translations/zh-Hans.json
@@ -1,7 +1,6 @@
{
"config": {
"abort": {
- "already_setup": "TelldusLive \u5df2\u914d\u7f6e\u5b8c\u6210",
"authorize_url_fail": "\u751f\u6210\u6388\u6743\u7f51\u5740\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002",
"authorize_url_timeout": "\u751f\u6210\u6388\u6743\u7f51\u5740\u8d85\u65f6\u3002",
"unknown": "\u53d1\u751f\u672a\u77e5\u7684\u9519\u8bef"
diff --git a/homeassistant/components/tellduslive/translations/zh-Hant.json b/homeassistant/components/tellduslive/translations/zh-Hant.json
index 0683c783677..de64974b2de 100644
--- a/homeassistant/components/tellduslive/translations/zh-Hant.json
+++ b/homeassistant/components/tellduslive/translations/zh-Hant.json
@@ -2,7 +2,6 @@
"config": {
"abort": {
"already_configured": "TelldusLive \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
- "already_setup": "TelldusLive \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
"authorize_url_fail": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4",
"authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642",
"unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002"
diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py
index 93c510e2fa1..444cabd0180 100644
--- a/homeassistant/components/tellstick/sensor.py
+++ b/homeassistant/components/tellstick/sensor.py
@@ -11,8 +11,8 @@ from homeassistant.const import (
CONF_ID,
CONF_NAME,
CONF_PROTOCOL,
+ PERCENTAGE,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -62,7 +62,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
"temperature", config.get(CONF_TEMPERATURE_SCALE)
),
tellcore_constants.TELLSTICK_HUMIDITY: DatatypeDescription(
- "humidity", UNIT_PERCENTAGE
+ "humidity", PERCENTAGE
),
tellcore_constants.TELLSTICK_RAINRATE: DatatypeDescription("rain rate", ""),
tellcore_constants.TELLSTICK_RAINTOTAL: DatatypeDescription("rain total", ""),
diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py
index de9d60feceb..a0f3a6a4a65 100644
--- a/homeassistant/components/template/__init__.py
+++ b/homeassistant/components/template/__init__.py
@@ -1,60 +1,27 @@
"""The template component."""
-from itertools import chain
import logging
-from homeassistant.const import MATCH_ALL
+from homeassistant.const import SERVICE_RELOAD
+from homeassistant.helpers.reload import async_reload_integration_platforms
+
+from .const import DOMAIN, EVENT_TEMPLATE_RELOADED, PLATFORMS
_LOGGER = logging.getLogger(__name__)
-def initialise_templates(hass, templates, attribute_templates=None):
- """Initialise templates and attribute templates."""
- if attribute_templates is None:
- attribute_templates = {}
- for template in chain(templates.values(), attribute_templates.values()):
- if template is None:
- continue
- template.hass = hass
+async def async_setup_reload_service(hass):
+ """Create the reload service for the template domain."""
+ if hass.services.has_service(DOMAIN, SERVICE_RELOAD):
+ return
-def extract_entities(
- device_name, device_type, manual_entity_ids, templates, attribute_templates=None
-):
- """Extract entity ids from templates and attribute templates."""
- if attribute_templates is None:
- attribute_templates = {}
- entity_ids = set()
- if manual_entity_ids is None:
- invalid_templates = []
- for template_name, template in chain(
- templates.items(), attribute_templates.items()
- ):
- if template is None:
- continue
+ async def _reload_config(call):
+ """Reload the template platform config."""
- template_entity_ids = template.extract_entities()
+ await async_reload_integration_platforms(hass, DOMAIN, PLATFORMS)
+ hass.bus.async_fire(EVENT_TEMPLATE_RELOADED, context=call.context)
- if template_entity_ids != MATCH_ALL:
- entity_ids |= set(template_entity_ids)
- else:
- invalid_templates.append(template_name.replace("_template", ""))
-
- entity_ids = list(entity_ids)
-
- if invalid_templates:
- if not entity_ids:
- entity_ids = MATCH_ALL
- _LOGGER.warning(
- "Template %s '%s' has no entity ids configured to track nor"
- " were we able to extract the entities to track from the %s "
- "template(s). This entity will only be able to be updated "
- "manually",
- device_type,
- device_name,
- ", ".join(invalid_templates),
- )
- else:
- entity_ids = manual_entity_ids
-
- return entity_ids
+ hass.helpers.service.async_register_admin_service(
+ DOMAIN, SERVICE_RELOAD, _reload_config
+ )
diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py
index 4209388ae8a..e37c7e2982e 100644
--- a/homeassistant/components/template/alarm_control_panel.py
+++ b/homeassistant/components/template/alarm_control_panel.py
@@ -19,8 +19,6 @@ from homeassistant.const import (
CONF_NAME,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
- EVENT_HOMEASSISTANT_START,
- MATCH_ALL,
STATE_ALARM_ARMED_AWAY,
STATE_ALARM_ARMED_HOME,
STATE_ALARM_ARMED_NIGHT,
@@ -33,9 +31,12 @@ from homeassistant.core import callback
from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
-from homeassistant.helpers.event import async_track_state_change_event
+from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.script import Script
+from .const import DOMAIN, PLATFORMS
+from .template_entity import TemplateEntity
+
_LOGGER = logging.getLogger(__name__)
_VALID_STATES = [
STATE_ALARM_ARMED_AWAY,
@@ -76,8 +77,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
- """Set up the Template Alarm Control Panels."""
+async def _async_create_entities(hass, config):
+ """Create Template Alarm Control Panels."""
+
alarm_control_panels = []
for device, device_config in config[CONF_ALARM_CONTROL_PANELS].items():
@@ -90,18 +92,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
code_arm_required = device_config[CONF_CODE_ARM_REQUIRED]
unique_id = device_config.get(CONF_UNIQUE_ID)
- template_entity_ids = set()
-
- if state_template is not None:
- temp_ids = state_template.extract_entities()
- if str(temp_ids) != MATCH_ALL:
- template_entity_ids |= set(temp_ids)
- else:
- _LOGGER.warning("No value template - will use optimistic state")
-
- if not template_entity_ids:
- template_entity_ids = MATCH_ALL
-
alarm_control_panels.append(
AlarmControlPanelTemplate(
hass,
@@ -113,15 +103,21 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
arm_home_action,
arm_night_action,
code_arm_required,
- template_entity_ids,
unique_id,
)
)
- async_add_entities(alarm_control_panels)
+ return alarm_control_panels
-class AlarmControlPanelTemplate(AlarmControlPanelEntity):
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+ """Set up the Template Alarm Control Panels."""
+
+ await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
+ async_add_entities(await _async_create_entities(hass, config))
+
+
+class AlarmControlPanelTemplate(TemplateEntity, AlarmControlPanelEntity):
"""Representation of a templated Alarm Control Panel."""
def __init__(
@@ -135,11 +131,10 @@ class AlarmControlPanelTemplate(AlarmControlPanelEntity):
arm_home_action,
arm_night_action,
code_arm_required,
- template_entity_ids,
unique_id,
):
"""Initialize the panel."""
- self.hass = hass
+ super().__init__()
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, device_id, hass=hass
)
@@ -147,25 +142,22 @@ class AlarmControlPanelTemplate(AlarmControlPanelEntity):
self._template = state_template
self._disarm_script = None
self._code_arm_required = code_arm_required
+ domain = __name__.split(".")[-2]
if disarm_action is not None:
- self._disarm_script = Script(hass, disarm_action)
+ self._disarm_script = Script(hass, disarm_action, name, domain)
self._arm_away_script = None
if arm_away_action is not None:
- self._arm_away_script = Script(hass, arm_away_action)
+ self._arm_away_script = Script(hass, arm_away_action, name, domain)
self._arm_home_script = None
if arm_home_action is not None:
- self._arm_home_script = Script(hass, arm_home_action)
+ self._arm_home_script = Script(hass, arm_home_action, name, domain)
self._arm_night_script = None
if arm_night_action is not None:
- self._arm_night_script = Script(hass, arm_night_action)
+ self._arm_night_script = Script(hass, arm_night_action, name, domain)
self._state = None
- self._entities = template_entity_ids
self._unique_id = unique_id
- if self._template is not None:
- self._template.hass = self.hass
-
@property
def name(self):
"""Return the display name of this alarm control panel."""
@@ -176,11 +168,6 @@ class AlarmControlPanelTemplate(AlarmControlPanelEntity):
"""Return the unique id of this alarm control panel."""
return self._unique_id
- @property
- def should_poll(self):
- """Return the polling state."""
- return False
-
@property
def state(self):
"""Return the state of the device."""
@@ -211,28 +198,32 @@ class AlarmControlPanelTemplate(AlarmControlPanelEntity):
"""Whether the code is required for arm actions."""
return self._code_arm_required
+ @callback
+ def _update_state(self, result):
+ if isinstance(result, TemplateError):
+ self._state = None
+ return
+
+ # Validate state
+ if result in _VALID_STATES:
+ self._state = result
+ _LOGGER.debug("Valid state - %s", result)
+ return
+
+ _LOGGER.error(
+ "Received invalid alarm panel state: %s. Expected: %s",
+ result,
+ ", ".join(_VALID_STATES),
+ )
+ self._state = None
+
async def async_added_to_hass(self):
"""Register callbacks."""
-
- @callback
- def template_alarm_state_listener(event):
- """Handle target device state changes."""
- self.async_schedule_update_ha_state(True)
-
- @callback
- def template_alarm_control_panel_startup(event):
- """Update template on startup."""
- if self._template is not None and self._entities != MATCH_ALL:
- # Track state change only for valid templates
- async_track_state_change_event(
- self.hass, self._entities, template_alarm_state_listener
- )
-
- self.async_schedule_update_ha_state(True)
-
- self.hass.bus.async_listen_once(
- EVENT_HOMEASSISTANT_START, template_alarm_control_panel_startup
- )
+ if self._template:
+ self.add_template_attribute(
+ "_state", self._template, None, self._update_state
+ )
+ await super().async_added_to_hass()
async def _async_alarm_arm(self, state, script=None, code=None):
"""Arm the panel to specified state with supplied script."""
@@ -273,25 +264,3 @@ class AlarmControlPanelTemplate(AlarmControlPanelEntity):
await self._async_alarm_arm(
STATE_ALARM_DISARMED, script=self._disarm_script, code=code
)
-
- async def async_update(self):
- """Update the state from the template."""
- if self._template is None:
- return
-
- try:
- state = self._template.async_render().lower()
- except TemplateError as ex:
- _LOGGER.error(ex)
- self._state = None
-
- if state in _VALID_STATES:
- self._state = state
- _LOGGER.debug("Valid state - %s", state)
- else:
- _LOGGER.error(
- "Received invalid alarm panel state: %s. Expected: %s",
- state,
- ", ".join(_VALID_STATES),
- )
- self._state = None
diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py
index 22eb8b9d242..2e50448c037 100644
--- a/homeassistant/components/template/binary_sensor.py
+++ b/homeassistant/components/template/binary_sensor.py
@@ -18,20 +18,17 @@ from homeassistant.const import (
CONF_SENSORS,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
- EVENT_HOMEASSISTANT_START,
- MATCH_ALL,
)
from homeassistant.core import callback
from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
-from homeassistant.helpers.event import (
- async_track_same_state,
- async_track_state_change_event,
-)
+from homeassistant.helpers.event import async_call_later
+from homeassistant.helpers.reload import async_setup_reload_service
+from homeassistant.helpers.template import result_as_boolean
-from . import extract_entities, initialise_templates
-from .const import CONF_AVAILABILITY_TEMPLATE
+from .const import CONF_AVAILABILITY_TEMPLATE, DOMAIN, PLATFORMS
+from .template_entity import TemplateEntity
_LOGGER = logging.getLogger(__name__)
@@ -39,20 +36,25 @@ CONF_DELAY_ON = "delay_on"
CONF_DELAY_OFF = "delay_off"
CONF_ATTRIBUTE_TEMPLATES = "attribute_templates"
-SENSOR_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_VALUE_TEMPLATE): cv.template,
- vol.Optional(CONF_ICON_TEMPLATE): cv.template,
- vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
- vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
- vol.Optional(CONF_ATTRIBUTE_TEMPLATES): vol.Schema({cv.string: cv.template}),
- vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
- vol.Optional(CONF_DELAY_ON): vol.All(cv.time_period, cv.positive_timedelta),
- vol.Optional(CONF_DELAY_OFF): vol.All(cv.time_period, cv.positive_timedelta),
- vol.Optional(CONF_UNIQUE_ID): cv.string,
- }
+SENSOR_SCHEMA = vol.All(
+ cv.deprecated(ATTR_ENTITY_ID),
+ vol.Schema(
+ {
+ vol.Required(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_ICON_TEMPLATE): cv.template,
+ vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
+ vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
+ vol.Optional(CONF_ATTRIBUTE_TEMPLATES): vol.Schema(
+ {cv.string: cv.template}
+ ),
+ vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_DELAY_ON): cv.positive_time_period,
+ vol.Optional(CONF_DELAY_OFF): cv.positive_time_period,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ }
+ ),
)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@@ -60,8 +62,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
- """Set up template binary sensors."""
+async def _async_create_entities(hass, config):
+ """Create the template binary sensors."""
sensors = []
for device, device_config in config[CONF_SENSORS].items():
@@ -77,22 +79,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
delay_off = device_config.get(CONF_DELAY_OFF)
unique_id = device_config.get(CONF_UNIQUE_ID)
- templates = {
- CONF_VALUE_TEMPLATE: value_template,
- CONF_ICON_TEMPLATE: icon_template,
- CONF_ENTITY_PICTURE_TEMPLATE: entity_picture_template,
- CONF_AVAILABILITY_TEMPLATE: availability_template,
- }
-
- initialise_templates(hass, templates, attribute_templates)
- entity_ids = extract_entities(
- device,
- "binary sensor",
- device_config.get(ATTR_ENTITY_ID),
- templates,
- attribute_templates,
- )
-
sensors.append(
BinarySensorTemplate(
hass,
@@ -103,7 +89,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
icon_template,
entity_picture_template,
availability_template,
- entity_ids,
delay_on,
delay_off,
attribute_templates,
@@ -111,10 +96,17 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
)
)
- async_add_entities(sensors)
+ return sensors
-class BinarySensorTemplate(BinarySensorEntity):
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+ """Set up the template binary sensors."""
+
+ await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
+ async_add_entities(await _async_create_entities(hass, config))
+
+
+class BinarySensorTemplate(TemplateEntity, BinarySensorEntity):
"""A virtual binary sensor that triggers from another sensor."""
def __init__(
@@ -127,54 +119,66 @@ class BinarySensorTemplate(BinarySensorEntity):
icon_template,
entity_picture_template,
availability_template,
- entity_ids,
delay_on,
delay_off,
attribute_templates,
unique_id,
):
"""Initialize the Template binary sensor."""
- self.hass = hass
+ super().__init__(
+ attribute_templates=attribute_templates,
+ availability_template=availability_template,
+ icon_template=icon_template,
+ entity_picture_template=entity_picture_template,
+ )
self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device, hass=hass)
self._name = friendly_name
self._device_class = device_class
self._template = value_template
self._state = None
- self._icon_template = icon_template
- self._availability_template = availability_template
- self._entity_picture_template = entity_picture_template
- self._icon = None
- self._entity_picture = None
- self._entities = entity_ids
+ self._delay_cancel = None
self._delay_on = delay_on
self._delay_off = delay_off
- self._available = True
- self._attribute_templates = attribute_templates
- self._attributes = {}
self._unique_id = unique_id
async def async_added_to_hass(self):
"""Register callbacks."""
- @callback
- def template_bsensor_state_listener(event):
- """Handle the target device state changes."""
- self.async_check_state()
+ self.add_template_attribute("_state", self._template, None, self._update_state)
+
+ await super().async_added_to_hass()
+
+ @callback
+ def _update_state(self, result):
+ super()._update_state(result)
+
+ if self._delay_cancel:
+ self._delay_cancel()
+ self._delay_cancel = None
+
+ state = None if isinstance(result, TemplateError) else result_as_boolean(result)
+
+ if state == self._state:
+ return
+
+ # state without delay
+ if (
+ state is None
+ or (state and not self._delay_on)
+ or (not state and not self._delay_off)
+ ):
+ self._state = state
+ return
@callback
- def template_bsensor_startup(event):
- """Update template on startup."""
- if self._entities != MATCH_ALL:
- # Track state change only for valid templates
- async_track_state_change_event(
- self.hass, self._entities, template_bsensor_state_listener
- )
+ def _set_state(_):
+ """Set state of template binary sensor."""
+ self._state = state
+ self.async_write_ha_state()
- self.async_check_state()
-
- self.hass.bus.async_listen_once(
- EVENT_HOMEASSISTANT_START, template_bsensor_startup
- )
+ delay = (self._delay_on if state else self._delay_off).seconds
+ # state with delay. Cancelled if template result changes.
+ self._delay_cancel = async_call_later(self.hass, delay, _set_state)
@property
def name(self):
@@ -186,16 +190,6 @@ class BinarySensorTemplate(BinarySensorEntity):
"""Return the unique id of this binary sensor."""
return self._unique_id
- @property
- def icon(self):
- """Return the icon to use in the frontend, if any."""
- return self._icon
-
- @property
- def entity_picture(self):
- """Return the entity_picture to use in the frontend, if any."""
- return self._entity_picture
-
@property
def is_on(self):
"""Return true if sensor is on."""
@@ -203,116 +197,5 @@ class BinarySensorTemplate(BinarySensorEntity):
@property
def device_class(self):
- """Return the sensor class of the sensor."""
+ """Return the sensor class of the binary sensor."""
return self._device_class
-
- @property
- def device_state_attributes(self):
- """Return the state attributes."""
- return self._attributes
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def available(self):
- """Availability indicator."""
- return self._available
-
- @callback
- def _async_render(self):
- """Get the state of template."""
- state = None
- try:
- state = self._template.async_render().lower() == "true"
- except TemplateError as ex:
- if ex.args and ex.args[0].startswith(
- "UndefinedError: 'None' has no attribute"
- ):
- # Common during HA startup - so just a warning
- _LOGGER.warning(
- "Could not render template %s, the state is unknown", self._name
- )
- return
- _LOGGER.error("Could not render template %s: %s", self._name, ex)
-
- attrs = {}
- if self._attribute_templates is not None:
- for key, value in self._attribute_templates.items():
- try:
- attrs[key] = value.async_render()
- except TemplateError as err:
- _LOGGER.error("Error rendering attribute %s: %s", key, err)
- self._attributes = attrs
-
- templates = {
- "_icon": self._icon_template,
- "_entity_picture": self._entity_picture_template,
- "_available": self._availability_template,
- }
-
- for property_name, template in templates.items():
- if template is None:
- continue
-
- try:
- value = template.async_render()
- if property_name == "_available":
- value = value.lower() == "true"
- setattr(self, property_name, value)
- except TemplateError as ex:
- friendly_property_name = property_name[1:].replace("_", " ")
- if ex.args and ex.args[0].startswith(
- "UndefinedError: 'None' has no attribute"
- ):
- # Common during HA startup - so just a warning
- _LOGGER.warning(
- "Could not render %s template %s, the state is unknown",
- friendly_property_name,
- self._name,
- )
- else:
- _LOGGER.error(
- "Could not render %s template %s: %s",
- friendly_property_name,
- self._name,
- ex,
- )
- return state
-
- return state
-
- @callback
- def async_check_state(self):
- """Update the state from the template."""
- state = self._async_render()
-
- # return if the state don't change or is invalid
- if state is None or state == self.state:
- return
-
- @callback
- def set_state():
- """Set state of template binary sensor."""
- self._state = state
- self.async_write_ha_state()
-
- # state without delay
- if (state and not self._delay_on) or (not state and not self._delay_off):
- set_state()
- return
-
- period = self._delay_on if state else self._delay_off
- async_track_same_state(
- self.hass,
- period,
- set_state,
- entity_ids=self._entities,
- async_check_same_func=lambda *args: self._async_render() == state,
- )
-
- async def async_update(self):
- """Force update of the state from the template."""
- self.async_check_state()
diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py
index e6cf69341f9..cf1ec8bc1c3 100644
--- a/homeassistant/components/template/const.py
+++ b/homeassistant/components/template/const.py
@@ -1,3 +1,21 @@
"""Constants for the Template Platform Components."""
CONF_AVAILABILITY_TEMPLATE = "availability_template"
+
+DOMAIN = "template"
+
+PLATFORM_STORAGE_KEY = "template_platforms"
+
+EVENT_TEMPLATE_RELOADED = "event_template_reloaded"
+
+PLATFORMS = [
+ "alarm_control_panel",
+ "binary_sensor",
+ "cover",
+ "fan",
+ "light",
+ "lock",
+ "sensor",
+ "switch",
+ "vacuum",
+]
diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py
index 08dd18ae3a4..dc9e5ead1d0 100644
--- a/homeassistant/components/template/cover.py
+++ b/homeassistant/components/template/cover.py
@@ -28,8 +28,6 @@ from homeassistant.const import (
CONF_OPTIMISTIC,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
- EVENT_HOMEASSISTANT_START,
- MATCH_ALL,
STATE_CLOSED,
STATE_OPEN,
)
@@ -37,11 +35,11 @@ from homeassistant.core import callback
from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
-from homeassistant.helpers.event import async_track_state_change_event
+from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.script import Script
-from . import extract_entities, initialise_templates
-from .const import CONF_AVAILABILITY_TEMPLATE
+from .const import CONF_AVAILABILITY_TEMPLATE, DOMAIN, PLATFORMS
+from .template_entity import TemplateEntity
_LOGGER = logging.getLogger(__name__)
_VALID_STATES = [STATE_OPEN, STATE_CLOSED, "true", "false"]
@@ -68,6 +66,7 @@ TILT_FEATURES = (
)
COVER_SCHEMA = vol.All(
+ cv.deprecated(CONF_ENTITY_ID),
vol.Schema(
{
vol.Inclusive(OPEN_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA,
@@ -102,8 +101,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
- """Set up the Template cover."""
+async def _async_create_entities(hass, config):
+ """Create the Template cover."""
covers = []
for device, device_config in config[CONF_COVERS].items():
@@ -125,20 +124,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
tilt_optimistic = device_config.get(CONF_TILT_OPTIMISTIC)
unique_id = device_config.get(CONF_UNIQUE_ID)
- templates = {
- CONF_VALUE_TEMPLATE: state_template,
- CONF_POSITION_TEMPLATE: position_template,
- CONF_TILT_TEMPLATE: tilt_template,
- CONF_ICON_TEMPLATE: icon_template,
- CONF_AVAILABILITY_TEMPLATE: availability_template,
- CONF_ENTITY_PICTURE_TEMPLATE: entity_picture_template,
- }
-
- initialise_templates(hass, templates)
- entity_ids = extract_entities(
- device, "cover", device_config.get(CONF_ENTITY_ID), templates
- )
-
covers.append(
CoverTemplate(
hass,
@@ -158,15 +143,21 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
tilt_action,
optimistic,
tilt_optimistic,
- entity_ids,
unique_id,
)
)
- async_add_entities(covers)
+ return covers
-class CoverTemplate(CoverEntity):
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+ """Set up the Template cover."""
+
+ await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
+ async_add_entities(await _async_create_entities(hass, config))
+
+
+class CoverTemplate(TemplateEntity, CoverEntity):
"""Representation of a Template cover."""
def __init__(
@@ -188,11 +179,14 @@ class CoverTemplate(CoverEntity):
tilt_action,
optimistic,
tilt_optimistic,
- entity_ids,
unique_id,
):
"""Initialize the Template cover."""
- self.hass = hass
+ super().__init__(
+ availability_template=availability_template,
+ icon_template=icon_template,
+ entity_picture_template=entity_picture_template,
+ )
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, device_id, hass=hass
)
@@ -200,57 +194,110 @@ class CoverTemplate(CoverEntity):
self._template = state_template
self._position_template = position_template
self._tilt_template = tilt_template
- self._icon_template = icon_template
self._device_class = device_class
- self._entity_picture_template = entity_picture_template
- self._availability_template = availability_template
self._open_script = None
+ domain = __name__.split(".")[-2]
if open_action is not None:
- self._open_script = Script(hass, open_action)
+ self._open_script = Script(hass, open_action, friendly_name, domain)
self._close_script = None
if close_action is not None:
- self._close_script = Script(hass, close_action)
+ self._close_script = Script(hass, close_action, friendly_name, domain)
self._stop_script = None
if stop_action is not None:
- self._stop_script = Script(hass, stop_action)
+ self._stop_script = Script(hass, stop_action, friendly_name, domain)
self._position_script = None
if position_action is not None:
- self._position_script = Script(hass, position_action)
+ self._position_script = Script(hass, position_action, friendly_name, domain)
self._tilt_script = None
if tilt_action is not None:
- self._tilt_script = Script(hass, tilt_action)
+ self._tilt_script = Script(hass, tilt_action, friendly_name, domain)
self._optimistic = optimistic or (not state_template and not position_template)
self._tilt_optimistic = tilt_optimistic or not tilt_template
- self._icon = None
- self._entity_picture = None
self._position = None
self._tilt_value = None
- self._entities = entity_ids
- self._available = True
self._unique_id = unique_id
async def async_added_to_hass(self):
"""Register callbacks."""
- @callback
- def template_cover_state_listener(event):
- """Handle target device state changes."""
- self.async_schedule_update_ha_state(True)
+ if self._template:
+ self.add_template_attribute(
+ "_position", self._template, None, self._update_state
+ )
+ if self._position_template:
+ self.add_template_attribute(
+ "_position",
+ self._position_template,
+ None,
+ self._update_position,
+ none_on_template_error=True,
+ )
+ if self._tilt_template:
+ self.add_template_attribute(
+ "_tilt_value",
+ self._tilt_template,
+ None,
+ self._update_tilt,
+ none_on_template_error=True,
+ )
+ await super().async_added_to_hass()
- @callback
- def template_cover_startup(event):
- """Update template on startup."""
- if self._entities != MATCH_ALL:
- # Track state change only for valid templates
- async_track_state_change_event(
- self.hass, self._entities, template_cover_state_listener
- )
+ @callback
+ def _update_state(self, result):
+ super()._update_state(result)
+ if isinstance(result, TemplateError):
+ self._position = None
+ return
- self.async_schedule_update_ha_state(True)
+ state = result.lower()
+ if state in _VALID_STATES:
+ if state in ("true", STATE_OPEN):
+ self._position = 100
+ else:
+ self._position = 0
+ else:
+ _LOGGER.error(
+ "Received invalid cover is_on state: %s. Expected: %s",
+ state,
+ ", ".join(_VALID_STATES),
+ )
+ self._position = None
- self.hass.bus.async_listen_once(
- EVENT_HOMEASSISTANT_START, template_cover_startup
- )
+ @callback
+ def _update_position(self, result):
+ try:
+ state = float(result)
+ except ValueError as err:
+ _LOGGER.error(err)
+ self._position = None
+ return
+
+ if state < 0 or state > 100:
+ self._position = None
+ _LOGGER.error(
+ "Cover position value must be" " between 0 and 100." " Value was: %.2f",
+ state,
+ )
+ else:
+ self._position = state
+
+ @callback
+ def _update_tilt(self, result):
+ try:
+ state = float(result)
+ except ValueError as err:
+ _LOGGER.error(err)
+ self._tilt_value = None
+ return
+
+ if state < 0 or state > 100:
+ self._tilt_value = None
+ _LOGGER.error(
+ "Tilt value must be between 0 and 100. Value was: %.2f",
+ state,
+ )
+ else:
+ self._tilt_value = state
@property
def name(self):
@@ -285,16 +332,6 @@ class CoverTemplate(CoverEntity):
"""
return self._tilt_value
- @property
- def icon(self):
- """Return the icon to use in the frontend, if any."""
- return self._icon
-
- @property
- def entity_picture(self):
- """Return the entity picture to use in the frontend, if any."""
- return self._entity_picture
-
@property
def device_class(self):
"""Return the device class of the cover."""
@@ -316,16 +353,6 @@ class CoverTemplate(CoverEntity):
return supported_features
- @property
- def should_poll(self):
- """Return the polling state."""
- return False
-
- @property
- def available(self) -> bool:
- """Return if the device is available."""
- return self._available
-
async def async_open_cover(self, **kwargs):
"""Move the cover up."""
if self._open_script:
@@ -390,89 +417,3 @@ class CoverTemplate(CoverEntity):
)
if self._tilt_optimistic:
self.async_write_ha_state()
-
- async def async_update(self):
- """Update the state from the template."""
- if self._template is not None:
- try:
- state = self._template.async_render().lower()
- if state in _VALID_STATES:
- if state in ("true", STATE_OPEN):
- self._position = 100
- else:
- self._position = 0
- else:
- _LOGGER.error(
- "Received invalid cover is_on state: %s. Expected: %s",
- state,
- ", ".join(_VALID_STATES),
- )
- self._position = None
- except TemplateError as ex:
- _LOGGER.error(ex)
- self._position = None
- if self._position_template is not None:
- try:
- state = float(self._position_template.async_render())
- if state < 0 or state > 100:
- self._position = None
- _LOGGER.error(
- "Cover position value must be"
- " between 0 and 100."
- " Value was: %.2f",
- state,
- )
- else:
- self._position = state
- except (TemplateError, ValueError) as err:
- _LOGGER.error(err)
- self._position = None
- if self._tilt_template is not None:
- try:
- state = float(self._tilt_template.async_render())
- if state < 0 or state > 100:
- self._tilt_value = None
- _LOGGER.error(
- "Tilt value must be between 0 and 100. Value was: %.2f", state,
- )
- else:
- self._tilt_value = state
- except (TemplateError, ValueError) as err:
- _LOGGER.error(err)
- self._tilt_value = None
-
- for property_name, template in (
- ("_icon", self._icon_template),
- ("_entity_picture", self._entity_picture_template),
- ("_available", self._availability_template),
- ):
- if template is None:
- continue
-
- try:
- value = template.async_render()
- if property_name == "_available":
- value = value.lower() == "true"
- setattr(self, property_name, value)
- except TemplateError as ex:
- friendly_property_name = property_name[1:].replace("_", " ")
- if ex.args and ex.args[0].startswith(
- "UndefinedError: 'None' has no attribute"
- ):
- # Common during HA startup - so just a warning
- _LOGGER.warning(
- "Could not render %s template %s, the state is unknown",
- friendly_property_name,
- self._name,
- )
- return
-
- try:
- setattr(self, property_name, getattr(super(), property_name))
- except AttributeError:
- _LOGGER.error(
- "Could not render %s template %s: %s",
- friendly_property_name,
- self._name,
- ex,
- )
diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py
index a6a0f6b8135..4f75faa36ff 100644
--- a/homeassistant/components/template/fan.py
+++ b/homeassistant/components/template/fan.py
@@ -23,8 +23,6 @@ from homeassistant.const import (
CONF_FRIENDLY_NAME,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
- EVENT_HOMEASSISTANT_START,
- MATCH_ALL,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
@@ -34,10 +32,11 @@ from homeassistant.core import callback
from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
+from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.script import Script
-from . import extract_entities, initialise_templates
-from .const import CONF_AVAILABILITY_TEMPLATE
+from .const import CONF_AVAILABILITY_TEMPLATE, DOMAIN, PLATFORMS
+from .template_entity import TemplateEntity
_LOGGER = logging.getLogger(__name__)
@@ -56,25 +55,28 @@ _VALID_STATES = [STATE_ON, STATE_OFF]
_VALID_OSC = [True, False]
_VALID_DIRECTIONS = [DIRECTION_FORWARD, DIRECTION_REVERSE]
-FAN_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_FRIENDLY_NAME): cv.string,
- vol.Required(CONF_VALUE_TEMPLATE): cv.template,
- vol.Optional(CONF_SPEED_TEMPLATE): cv.template,
- vol.Optional(CONF_OSCILLATING_TEMPLATE): cv.template,
- vol.Optional(CONF_DIRECTION_TEMPLATE): cv.template,
- vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
- vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
- vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(CONF_SET_SPEED_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(
- CONF_SPEED_LIST, default=[SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
- ): cv.ensure_list,
- vol.Optional(CONF_ENTITY_ID): cv.entity_ids,
- vol.Optional(CONF_UNIQUE_ID): cv.string,
- }
+FAN_SCHEMA = vol.All(
+ cv.deprecated(CONF_ENTITY_ID),
+ vol.Schema(
+ {
+ vol.Optional(CONF_FRIENDLY_NAME): cv.string,
+ vol.Required(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_SPEED_TEMPLATE): cv.template,
+ vol.Optional(CONF_OSCILLATING_TEMPLATE): cv.template,
+ vol.Optional(CONF_DIRECTION_TEMPLATE): cv.template,
+ vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
+ vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_SET_SPEED_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(
+ CONF_SPEED_LIST, default=[SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
+ ): cv.ensure_list,
+ vol.Optional(CONF_ENTITY_ID): cv.entity_ids,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ }
+ ),
)
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
@@ -82,8 +84,8 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
)
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
- """Set up the Template Fans."""
+async def _async_create_entities(hass, config):
+ """Create the Template Fans."""
fans = []
for device, device_config in config[CONF_FANS].items():
@@ -104,17 +106,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
speed_list = device_config[CONF_SPEED_LIST]
unique_id = device_config.get(CONF_UNIQUE_ID)
- templates = {
- CONF_VALUE_TEMPLATE: state_template,
- CONF_SPEED_TEMPLATE: speed_template,
- CONF_OSCILLATING_TEMPLATE: oscillating_template,
- CONF_DIRECTION_TEMPLATE: direction_template,
- CONF_AVAILABILITY_TEMPLATE: availability_template,
- }
-
- initialise_templates(hass, templates)
- entity_ids = extract_entities(device, "fan", None, templates)
-
fans.append(
TemplateFan(
hass,
@@ -131,15 +122,21 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
set_oscillating_action,
set_direction_action,
speed_list,
- entity_ids,
unique_id,
)
)
- async_add_entities(fans)
+ return fans
-class TemplateFan(FanEntity):
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+ """Set up the template fans."""
+
+ await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
+ async_add_entities(await _async_create_entities(hass, config))
+
+
+class TemplateFan(TemplateEntity, FanEntity):
"""A template fan component."""
def __init__(
@@ -158,10 +155,10 @@ class TemplateFan(FanEntity):
set_oscillating_action,
set_direction_action,
speed_list,
- entity_ids,
unique_id,
):
"""Initialize the fan."""
+ super().__init__(availability_template=availability_template)
self.hass = hass
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, device_id, hass=hass
@@ -172,24 +169,30 @@ class TemplateFan(FanEntity):
self._speed_template = speed_template
self._oscillating_template = oscillating_template
self._direction_template = direction_template
- self._availability_template = availability_template
- self._available = True
self._supported_features = 0
- self._on_script = Script(hass, on_action)
- self._off_script = Script(hass, off_action)
+ domain = __name__.split(".")[-2]
+
+ self._on_script = Script(hass, on_action, friendly_name, domain)
+ self._off_script = Script(hass, off_action, friendly_name, domain)
self._set_speed_script = None
if set_speed_action:
- self._set_speed_script = Script(hass, set_speed_action)
+ self._set_speed_script = Script(
+ hass, set_speed_action, friendly_name, domain
+ )
self._set_oscillating_script = None
if set_oscillating_action:
- self._set_oscillating_script = Script(hass, set_oscillating_action)
+ self._set_oscillating_script = Script(
+ hass, set_oscillating_action, friendly_name, domain
+ )
self._set_direction_script = None
if set_direction_action:
- self._set_direction_script = Script(hass, set_direction_action)
+ self._set_direction_script = Script(
+ hass, set_direction_action, friendly_name, domain
+ )
self._state = STATE_OFF
self._speed = None
@@ -203,7 +206,6 @@ class TemplateFan(FanEntity):
if self._direction_template:
self._supported_features |= SUPPORT_DIRECTION
- self._entities = entity_ids
self._unique_id = unique_id
# List of valid speeds
@@ -249,16 +251,6 @@ class TemplateFan(FanEntity):
"""Return the oscillation state."""
return self._direction
- @property
- def should_poll(self):
- """Return the polling state."""
- return False
-
- @property
- def available(self):
- """Return availability of Device."""
- return self._available
-
# pylint: disable=arguments-differ
async def async_turn_on(self, speed: str = None) -> None:
"""Turn on the fan."""
@@ -323,125 +315,95 @@ class TemplateFan(FanEntity):
", ".join(_VALID_DIRECTIONS),
)
- async def async_added_to_hass(self):
- """Register callbacks."""
-
- @callback
- def template_fan_state_listener(event):
- """Handle target device state changes."""
- self.async_schedule_update_ha_state(True)
-
- @callback
- def template_fan_startup(event):
- """Update template on startup."""
- if self._entities != MATCH_ALL:
- # Track state change only for valid templates
- self.hass.helpers.event.async_track_state_change_event(
- self._entities, template_fan_state_listener
- )
-
- self.async_schedule_update_ha_state(True)
-
- self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, template_fan_startup)
-
- async def async_update(self):
- """Update the state from the template."""
- # Update state
- try:
- state = self._template.async_render()
- except TemplateError as ex:
- _LOGGER.error(ex)
- state = None
+ @callback
+ def _update_state(self, result):
+ super()._update_state(result)
+ if isinstance(result, TemplateError):
self._state = None
+ return
# Validate state
- if state in _VALID_STATES:
- self._state = state
- elif state in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
+ if result in _VALID_STATES:
+ self._state = result
+ elif result in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
self._state = None
else:
_LOGGER.error(
"Received invalid fan is_on state: %s. Expected: %s",
- state,
+ result,
", ".join(_VALID_STATES),
)
self._state = None
- # Update speed if 'speed_template' is configured
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self.add_template_attribute("_state", self._template, None, self._update_state)
if self._speed_template is not None:
- try:
- speed = self._speed_template.async_render()
- except TemplateError as ex:
- _LOGGER.error(ex)
- speed = None
- self._state = None
-
- # Validate speed
- if speed in self._speed_list:
- self._speed = speed
- elif speed in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
- self._speed = None
- else:
- _LOGGER.error(
- "Received invalid speed: %s. Expected: %s", speed, self._speed_list
- )
- self._speed = None
-
- # Update oscillating if 'oscillating_template' is configured
+ self.add_template_attribute(
+ "_speed",
+ self._speed_template,
+ None,
+ self._update_speed,
+ none_on_template_error=True,
+ )
if self._oscillating_template is not None:
- try:
- oscillating = self._oscillating_template.async_render()
- except TemplateError as ex:
- _LOGGER.error(ex)
- oscillating = None
- self._state = None
-
- # Validate osc
- if oscillating == "True" or oscillating is True:
- self._oscillating = True
- elif oscillating == "False" or oscillating is False:
- self._oscillating = False
- elif oscillating in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
- self._oscillating = None
- else:
- _LOGGER.error(
- "Received invalid oscillating: %s. Expected: True/False",
- oscillating,
- )
- self._oscillating = None
-
- # Update direction if 'direction_template' is configured
+ self.add_template_attribute(
+ "_oscillating",
+ self._oscillating_template,
+ None,
+ self._update_oscillating,
+ none_on_template_error=True,
+ )
if self._direction_template is not None:
- try:
- direction = self._direction_template.async_render()
- except TemplateError as ex:
- _LOGGER.error(ex)
- direction = None
- self._state = None
+ self.add_template_attribute(
+ "_direction",
+ self._direction_template,
+ None,
+ self._update_direction,
+ none_on_template_error=True,
+ )
+ await super().async_added_to_hass()
- # Validate speed
- if direction in _VALID_DIRECTIONS:
- self._direction = direction
- elif direction in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
- self._direction = None
- else:
- _LOGGER.error(
- "Received invalid direction: %s. Expected: %s",
- direction,
- ", ".join(_VALID_DIRECTIONS),
- )
- self._direction = None
+ @callback
+ def _update_speed(self, speed):
+ # Validate speed
+ if speed in self._speed_list:
+ self._speed = speed
+ elif speed in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
+ self._speed = None
+ else:
+ _LOGGER.error(
+ "Received invalid speed: %s. Expected: %s", speed, self._speed_list
+ )
+ self._speed = None
- # Update Availability if 'availability_template' is defined
- if self._availability_template is not None:
- try:
- self._available = (
- self._availability_template.async_render().lower() == "true"
- )
- except (TemplateError, ValueError) as ex:
- _LOGGER.error(
- "Could not render %s template %s: %s",
- CONF_AVAILABILITY_TEMPLATE,
- self._name,
- ex,
- )
+ @callback
+ def _update_oscillating(self, oscillating):
+ # Validate osc
+ if oscillating == "True" or oscillating is True:
+ self._oscillating = True
+ elif oscillating == "False" or oscillating is False:
+ self._oscillating = False
+ elif oscillating in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
+ self._oscillating = None
+ else:
+ _LOGGER.error(
+ "Received invalid oscillating: %s. Expected: True/False",
+ oscillating,
+ )
+ self._oscillating = None
+
+ @callback
+ def _update_direction(self, direction):
+ # Validate direction
+ if direction in _VALID_DIRECTIONS:
+ self._direction = direction
+ elif direction in [STATE_UNAVAILABLE, STATE_UNKNOWN]:
+ self._direction = None
+ else:
+ _LOGGER.error(
+ "Received invalid direction: %s. Expected: %s",
+ direction,
+ ", ".join(_VALID_DIRECTIONS),
+ )
+ self._direction = None
diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py
index b85aa6f3a95..2b79846986c 100644
--- a/homeassistant/components/template/light.py
+++ b/homeassistant/components/template/light.py
@@ -23,8 +23,6 @@ from homeassistant.const import (
CONF_LIGHTS,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
- EVENT_HOMEASSISTANT_START,
- MATCH_ALL,
STATE_OFF,
STATE_ON,
)
@@ -33,11 +31,11 @@ from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.config_validation import PLATFORM_SCHEMA
from homeassistant.helpers.entity import async_generate_entity_id
-from homeassistant.helpers.event import async_track_state_change_event
+from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.script import Script
-from . import extract_entities, initialise_templates
-from .const import CONF_AVAILABILITY_TEMPLATE
+from .const import CONF_AVAILABILITY_TEMPLATE, DOMAIN, PLATFORMS
+from .template_entity import TemplateEntity
_LOGGER = logging.getLogger(__name__)
_VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"]
@@ -53,26 +51,29 @@ CONF_COLOR_ACTION = "set_color"
CONF_WHITE_VALUE_TEMPLATE = "white_value_template"
CONF_WHITE_VALUE_ACTION = "set_white_value"
-LIGHT_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
- vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
- vol.Optional(CONF_ICON_TEMPLATE): cv.template,
- vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
- vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
- vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(CONF_LEVEL_TEMPLATE): cv.template,
- vol.Optional(CONF_FRIENDLY_NAME): cv.string,
- vol.Optional(CONF_ENTITY_ID): cv.entity_ids,
- vol.Optional(CONF_TEMPERATURE_TEMPLATE): cv.template,
- vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(CONF_COLOR_TEMPLATE): cv.template,
- vol.Optional(CONF_COLOR_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(CONF_WHITE_VALUE_TEMPLATE): cv.template,
- vol.Optional(CONF_WHITE_VALUE_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(CONF_UNIQUE_ID): cv.string,
- }
+LIGHT_SCHEMA = vol.All(
+ cv.deprecated(CONF_ENTITY_ID),
+ vol.Schema(
+ {
+ vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_ICON_TEMPLATE): cv.template,
+ vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
+ vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
+ vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_LEVEL_TEMPLATE): cv.template,
+ vol.Optional(CONF_FRIENDLY_NAME): cv.string,
+ vol.Optional(CONF_ENTITY_ID): cv.entity_ids,
+ vol.Optional(CONF_TEMPERATURE_TEMPLATE): cv.template,
+ vol.Optional(CONF_TEMPERATURE_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_COLOR_TEMPLATE): cv.template,
+ vol.Optional(CONF_COLOR_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_WHITE_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_WHITE_VALUE_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ }
+ ),
)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@@ -80,8 +81,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
- """Set up the Template Lights."""
+async def _async_create_entities(hass, config):
+ """Create the Template Lights."""
lights = []
for device, device_config in config[CONF_LIGHTS].items():
@@ -108,22 +109,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
white_value_action = device_config.get(CONF_WHITE_VALUE_ACTION)
white_value_template = device_config.get(CONF_WHITE_VALUE_TEMPLATE)
- templates = {
- CONF_VALUE_TEMPLATE: state_template,
- CONF_ICON_TEMPLATE: icon_template,
- CONF_ENTITY_PICTURE_TEMPLATE: entity_picture_template,
- CONF_AVAILABILITY_TEMPLATE: availability_template,
- CONF_LEVEL_TEMPLATE: level_template,
- CONF_TEMPERATURE_TEMPLATE: temperature_template,
- CONF_COLOR_TEMPLATE: color_template,
- CONF_WHITE_VALUE_TEMPLATE: white_value_template,
- }
-
- initialise_templates(hass, templates)
- entity_ids = extract_entities(
- device, "light", device_config.get(CONF_ENTITY_ID), templates
- )
-
lights.append(
LightTemplate(
hass,
@@ -137,7 +122,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
off_action,
level_action,
level_template,
- entity_ids,
temperature_action,
temperature_template,
color_action,
@@ -148,10 +132,17 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
)
)
- async_add_entities(lights)
+ return lights
-class LightTemplate(LightEntity):
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+ """Set up the template lights."""
+
+ await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
+ async_add_entities(await _async_create_entities(hass, config))
+
+
+class LightTemplate(TemplateEntity, LightEntity):
"""Representation of a templated Light, including dimmable."""
def __init__(
@@ -167,7 +158,6 @@ class LightTemplate(LightEntity):
off_action,
level_action,
level_template,
- entity_ids,
temperature_action,
temperature_template,
color_action,
@@ -177,43 +167,45 @@ class LightTemplate(LightEntity):
unique_id,
):
"""Initialize the light."""
- self.hass = hass
+ super().__init__(
+ availability_template=availability_template,
+ icon_template=icon_template,
+ entity_picture_template=entity_picture_template,
+ )
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, device_id, hass=hass
)
self._name = friendly_name
self._template = state_template
- self._icon_template = icon_template
- self._entity_picture_template = entity_picture_template
- self._availability_template = availability_template
- self._on_script = Script(hass, on_action)
- self._off_script = Script(hass, off_action)
+ domain = __name__.split(".")[-2]
+ self._on_script = Script(hass, on_action, friendly_name, domain)
+ self._off_script = Script(hass, off_action, friendly_name, domain)
self._level_script = None
if level_action is not None:
- self._level_script = Script(hass, level_action)
+ self._level_script = Script(hass, level_action, friendly_name, domain)
self._level_template = level_template
self._temperature_script = None
if temperature_action is not None:
- self._temperature_script = Script(hass, temperature_action)
+ self._temperature_script = Script(
+ hass, temperature_action, friendly_name, domain
+ )
self._temperature_template = temperature_template
self._color_script = None
if color_action is not None:
- self._color_script = Script(hass, color_action)
+ self._color_script = Script(hass, color_action, friendly_name, domain)
self._color_template = color_template
self._white_value_script = None
if white_value_action is not None:
- self._white_value_script = Script(hass, white_value_action)
+ self._white_value_script = Script(
+ hass, white_value_action, friendly_name, domain
+ )
self._white_value_template = white_value_template
self._state = False
- self._icon = None
- self._entity_picture = None
self._brightness = None
self._temperature = None
self._color = None
self._white_value = None
- self._entities = entity_ids
- self._available = True
self._unique_id = unique_id
@property
@@ -265,56 +257,46 @@ class LightTemplate(LightEntity):
"""Return true if device is on."""
return self._state
- @property
- def should_poll(self):
- """Return the polling state."""
- return False
-
- @property
- def icon(self):
- """Return the icon to use in the frontend, if any."""
- return self._icon
-
- @property
- def entity_picture(self):
- """Return the entity picture to use in the frontend, if any."""
- return self._entity_picture
-
- @property
- def available(self) -> bool:
- """Return if the device is available."""
- return self._available
-
async def async_added_to_hass(self):
"""Register callbacks."""
- @callback
- def template_light_state_listener(event):
- """Handle target device state changes."""
- self.async_schedule_update_ha_state(True)
-
- @callback
- def template_light_startup(event):
- """Update template on startup."""
- if (
- self._template is not None
- or self._level_template is not None
- or self._temperature_template is not None
- or self._color_template is not None
- or self._white_value_template is not None
- or self._availability_template is not None
- ):
- if self._entities != MATCH_ALL:
- # Track state change only for valid templates
- async_track_state_change_event(
- self.hass, self._entities, template_light_state_listener
- )
-
- self.async_schedule_update_ha_state(True)
-
- self.hass.bus.async_listen_once(
- EVENT_HOMEASSISTANT_START, template_light_startup
- )
+ if self._template:
+ self.add_template_attribute(
+ "_state", self._template, None, self._update_state
+ )
+ if self._level_template:
+ self.add_template_attribute(
+ "_brightness",
+ self._level_template,
+ None,
+ self._update_brightness,
+ none_on_template_error=True,
+ )
+ if self._temperature_template:
+ self.add_template_attribute(
+ "_temperature",
+ self._temperature_template,
+ None,
+ self._update_temperature,
+ none_on_template_error=True,
+ )
+ if self._color_template:
+ self.add_template_attribute(
+ "_color",
+ self._color_template,
+ None,
+ self._update_color,
+ none_on_template_error=True,
+ )
+ if self._white_value_template:
+ self.add_template_attribute(
+ "_white_value",
+ self._white_value_template,
+ None,
+ self._update_white_value,
+ none_on_template_error=True,
+ )
+ await super().async_added_to_hass()
async def async_turn_on(self, **kwargs):
"""Turn the light on."""
@@ -365,7 +347,7 @@ class LightTemplate(LightEntity):
context=self._context,
)
else:
- await self._on_script.async_run()
+ await self._on_script.async_run(context=self._context)
if optimistic_set:
self.async_write_ha_state()
@@ -377,61 +359,10 @@ class LightTemplate(LightEntity):
self._state = False
self.async_write_ha_state()
- async def async_update(self):
- """Update from templates."""
- self.update_state()
-
- self.update_brightness()
-
- self.update_temperature()
-
- self.update_color()
-
- self.update_white_value()
-
- for property_name, template in (
- ("_icon", self._icon_template),
- ("_entity_picture", self._entity_picture_template),
- ("_available", self._availability_template),
- ):
- if template is None:
- continue
-
- try:
- value = template.async_render()
- if property_name == "_available":
- value = value.lower() == "true"
- setattr(self, property_name, value)
- except TemplateError as ex:
- friendly_property_name = property_name[1:].replace("_", " ")
- if ex.args and ex.args[0].startswith(
- "UndefinedError: 'None' has no attribute"
- ):
- # Common during HA startup - so just a warning
- _LOGGER.warning(
- "Could not render %s template %s, the state is unknown",
- friendly_property_name,
- self._name,
- )
- return
-
- try:
- setattr(self, property_name, getattr(super(), property_name))
- except AttributeError:
- _LOGGER.error(
- "Could not render %s template %s: %s",
- friendly_property_name,
- self._name,
- ex,
- )
-
@callback
- def update_brightness(self):
+ def _update_brightness(self, brightness):
"""Update the brightness from the template."""
- if self._level_template is None:
- return
try:
- brightness = self._level_template.async_render()
if brightness in ("None", ""):
self._brightness = None
return
@@ -448,17 +379,11 @@ class LightTemplate(LightEntity):
exc_info=True,
)
self._brightness = None
- except TemplateError:
- _LOGGER.error("Invalid template", exc_info=True)
- self._brightness = None
@callback
- def update_white_value(self):
+ def _update_white_value(self, white_value):
"""Update the white value from the template."""
- if self._white_value_template is None:
- return
try:
- white_value = self._white_value_template.async_render()
if white_value in ("None", ""):
self._white_value = None
return
@@ -475,37 +400,34 @@ class LightTemplate(LightEntity):
exc_info=True,
)
self._white_value = None
- except TemplateError as ex:
- _LOGGER.error(ex)
- self._state = None
@callback
- def update_state(self):
+ def _update_state(self, result):
"""Update the state from the template."""
- if self._template is None:
+
+ if isinstance(result, TemplateError):
+ # This behavior is legacy
+ self._state = False
+ if not self._availability_template:
+ self._available = True
return
- try:
- state = self._template.async_render().lower()
- if state in _VALID_STATES:
- self._state = state in ("true", STATE_ON)
- else:
- _LOGGER.error(
- "Received invalid light is_on state: %s. Expected: %s",
- state,
- ", ".join(_VALID_STATES),
- )
- self._state = None
- except TemplateError as ex:
- _LOGGER.error(ex)
+
+ state = result.lower()
+ if state in _VALID_STATES:
+ self._state = state in ("true", STATE_ON)
+ else:
+ _LOGGER.error(
+ "Received invalid light is_on state: %s. Expected: %s",
+ state,
+ ", ".join(_VALID_STATES),
+ )
self._state = None
@callback
- def update_temperature(self):
+ def _update_temperature(self, render):
"""Update the temperature from the template."""
- if self._temperature_template is None:
- return
+
try:
- render = self._temperature_template.async_render()
if render in ("None", ""):
self._temperature = None
return
@@ -525,41 +447,30 @@ class LightTemplate(LightEntity):
exc_info=True,
)
self._temperature = None
- except TemplateError:
- _LOGGER.error("Cannot evaluate temperature template", exc_info=True)
- self._temperature = None
@callback
- def update_color(self):
+ def _update_color(self, render):
"""Update the hs_color from the template."""
- if self._color_template is None:
- return
-
- try:
- render = self._color_template.async_render()
- if render in ("None", ""):
- self._color = None
- return
- h_str, s_str = map(
- float, render.replace("(", "").replace(")", "").split(",", 1)
- )
- if (
- h_str is not None
- and s_str is not None
- and 0 <= h_str <= 360
- and 0 <= s_str <= 100
- ):
- self._color = (h_str, s_str)
- elif h_str is not None and s_str is not None:
- _LOGGER.error(
- "Received invalid hs_color : (%s, %s). Expected: (0-360, 0-100)",
- h_str,
- s_str,
- )
- self._color = None
- else:
- _LOGGER.error("Received invalid hs_color : (%s)", render)
- self._color = None
- except TemplateError:
- _LOGGER.error("Cannot evaluate hs_color template", exc_info=True)
+ if render in ("None", ""):
+ self._color = None
+ return
+ h_str, s_str = map(
+ float, render.replace("(", "").replace(")", "").split(",", 1)
+ )
+ if (
+ h_str is not None
+ and s_str is not None
+ and 0 <= h_str <= 360
+ and 0 <= s_str <= 100
+ ):
+ self._color = (h_str, s_str)
+ elif h_str is not None and s_str is not None:
+ _LOGGER.error(
+ "Received invalid hs_color : (%s, %s). Expected: (0-360, 0-100)",
+ h_str,
+ s_str,
+ )
+ self._color = None
+ else:
+ _LOGGER.error("Received invalid hs_color : (%s)", render)
self._color = None
diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py
index 07aeda70be1..b917430a6ff 100644
--- a/homeassistant/components/template/lock.py
+++ b/homeassistant/components/template/lock.py
@@ -9,19 +9,17 @@ from homeassistant.const import (
CONF_OPTIMISTIC,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
- EVENT_HOMEASSISTANT_START,
- MATCH_ALL,
STATE_LOCKED,
STATE_ON,
)
from homeassistant.core import callback
from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.event import async_track_state_change_event
+from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.script import Script
-from . import extract_entities, initialise_templates
-from .const import CONF_AVAILABILITY_TEMPLATE
+from .const import CONF_AVAILABILITY_TEMPLATE, DOMAIN, PLATFORMS
+from .template_entity import TemplateEntity
_LOGGER = logging.getLogger(__name__)
@@ -44,38 +42,34 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
-async def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
- """Set up the Template lock."""
+async def _async_create_entities(hass, config):
+ """Create the Template lock."""
device = config.get(CONF_NAME)
value_template = config.get(CONF_VALUE_TEMPLATE)
availability_template = config.get(CONF_AVAILABILITY_TEMPLATE)
- templates = {
- CONF_VALUE_TEMPLATE: value_template,
- CONF_AVAILABILITY_TEMPLATE: availability_template,
- }
-
- initialise_templates(hass, templates)
- entity_ids = extract_entities(device, "lock", None, templates)
-
- async_add_devices(
- [
- TemplateLock(
- hass,
- device,
- value_template,
- availability_template,
- entity_ids,
- config.get(CONF_LOCK),
- config.get(CONF_UNLOCK),
- config.get(CONF_OPTIMISTIC),
- config.get(CONF_UNIQUE_ID),
- )
- ]
- )
+ return [
+ TemplateLock(
+ hass,
+ device,
+ value_template,
+ availability_template,
+ config.get(CONF_LOCK),
+ config.get(CONF_UNLOCK),
+ config.get(CONF_OPTIMISTIC),
+ config.get(CONF_UNIQUE_ID),
+ )
+ ]
-class TemplateLock(LockEntity):
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+ """Set up the template lock."""
+
+ await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
+ async_add_entities(await _async_create_entities(hass, config))
+
+
+class TemplateLock(TemplateEntity, LockEntity):
"""Representation of a template lock."""
def __init__(
@@ -84,57 +78,27 @@ class TemplateLock(LockEntity):
name,
value_template,
availability_template,
- entity_ids,
command_lock,
command_unlock,
optimistic,
unique_id,
):
"""Initialize the lock."""
+ super().__init__(availability_template=availability_template)
self._state = None
- self._hass = hass
self._name = name
self._state_template = value_template
- self._availability_template = availability_template
- self._state_entities = entity_ids
- self._command_lock = Script(hass, command_lock)
- self._command_unlock = Script(hass, command_unlock)
+ domain = __name__.split(".")[-2]
+ self._command_lock = Script(hass, command_lock, name, domain)
+ self._command_unlock = Script(hass, command_unlock, name, domain)
self._optimistic = optimistic
- self._available = True
self._unique_id = unique_id
- async def async_added_to_hass(self):
- """Register callbacks."""
-
- @callback
- def template_lock_state_listener(event):
- """Handle target device state changes."""
- self.async_schedule_update_ha_state(True)
-
- @callback
- def template_lock_startup(event):
- """Update template on startup."""
- if self._state_entities != MATCH_ALL:
- # Track state change only for valid templates
- async_track_state_change_event(
- self._hass, self._state_entities, template_lock_state_listener
- )
- self.async_schedule_update_ha_state(True)
-
- self._hass.bus.async_listen_once(
- EVENT_HOMEASSISTANT_START, template_lock_startup
- )
-
@property
def assumed_state(self):
"""Return true if we do optimistic updates."""
return self._optimistic
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
@property
def name(self):
"""Return the name of the lock."""
@@ -150,35 +114,21 @@ class TemplateLock(LockEntity):
"""Return true if lock is locked."""
return self._state
- @property
- def available(self) -> bool:
- """Return if the device is available."""
- return self._available
-
- async def async_update(self):
- """Update the state from the template."""
- try:
- self._state = self._state_template.async_render().lower() in (
- "true",
- STATE_ON,
- STATE_LOCKED,
- )
- except TemplateError as ex:
+ @callback
+ def _update_state(self, result):
+ super()._update_state(result)
+ if isinstance(result, TemplateError):
self._state = None
- _LOGGER.error("Could not render template %s: %s", self._name, ex)
+ return
+ self._state = result.lower() in ("true", STATE_ON, STATE_LOCKED)
- if self._availability_template is not None:
- try:
- self._available = (
- self._availability_template.async_render().lower() == "true"
- )
- except (TemplateError, ValueError) as ex:
- _LOGGER.error(
- "Could not render %s template %s: %s",
- CONF_AVAILABILITY_TEMPLATE,
- self._name,
- ex,
- )
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+
+ self.add_template_attribute(
+ "_state", self._state_template, None, self._update_state
+ )
+ await super().async_added_to_hass()
async def async_lock(self, **kwargs):
"""Lock the device."""
diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py
index 53736050ed3..754b3da27ac 100644
--- a/homeassistant/components/template/sensor.py
+++ b/homeassistant/components/template/sensor.py
@@ -20,38 +20,39 @@ from homeassistant.const import (
CONF_SENSORS,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
- EVENT_HOMEASSISTANT_START,
- MATCH_ALL,
)
from homeassistant.core import callback
from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity, async_generate_entity_id
-from homeassistant.helpers.event import async_track_state_change_event
+from homeassistant.helpers.reload import async_setup_reload_service
-from . import extract_entities, initialise_templates
-from .const import CONF_AVAILABILITY_TEMPLATE
+from .const import CONF_AVAILABILITY_TEMPLATE, DOMAIN, PLATFORMS
+from .template_entity import TemplateEntity
CONF_ATTRIBUTE_TEMPLATES = "attribute_templates"
_LOGGER = logging.getLogger(__name__)
-SENSOR_SCHEMA = vol.Schema(
- {
- vol.Required(CONF_VALUE_TEMPLATE): cv.template,
- vol.Optional(CONF_ICON_TEMPLATE): cv.template,
- vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
- vol.Optional(CONF_FRIENDLY_NAME_TEMPLATE): cv.template,
- vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
- vol.Optional(CONF_ATTRIBUTE_TEMPLATES, default={}): vol.Schema(
- {cv.string: cv.template}
- ),
- vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
- vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string,
- vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Optional(CONF_UNIQUE_ID): cv.string,
- }
+SENSOR_SCHEMA = vol.All(
+ cv.deprecated(ATTR_ENTITY_ID),
+ vol.Schema(
+ {
+ vol.Required(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_ICON_TEMPLATE): cv.template,
+ vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
+ vol.Optional(CONF_FRIENDLY_NAME_TEMPLATE): cv.template,
+ vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
+ vol.Optional(CONF_ATTRIBUTE_TEMPLATES, default={}): vol.Schema(
+ {cv.string: cv.template}
+ ),
+ vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
+ vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ }
+ ),
)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@@ -59,8 +60,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
- """Set up the template sensors."""
+async def _async_create_entities(hass, config):
+ """Create the template sensors."""
+
sensors = []
for device, device_config in config[CONF_SENSORS].items():
@@ -75,23 +77,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
attribute_templates = device_config[CONF_ATTRIBUTE_TEMPLATES]
unique_id = device_config.get(CONF_UNIQUE_ID)
- templates = {
- CONF_VALUE_TEMPLATE: state_template,
- CONF_ICON_TEMPLATE: icon_template,
- CONF_ENTITY_PICTURE_TEMPLATE: entity_picture_template,
- CONF_FRIENDLY_NAME_TEMPLATE: friendly_name_template,
- CONF_AVAILABILITY_TEMPLATE: availability_template,
- }
-
- initialise_templates(hass, templates, attribute_templates)
- entity_ids = extract_entities(
- device,
- "sensor",
- device_config.get(ATTR_ENTITY_ID),
- templates,
- attribute_templates,
- )
-
sensors.append(
SensorTemplate(
hass,
@@ -103,19 +88,23 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
icon_template,
entity_picture_template,
availability_template,
- entity_ids,
device_class,
attribute_templates,
unique_id,
)
)
- async_add_entities(sensors)
-
- return True
+ return sensors
-class SensorTemplate(Entity):
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+ """Set up the template sensors."""
+
+ await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
+ async_add_entities(await _async_create_entities(hass, config))
+
+
+class SensorTemplate(TemplateEntity, Entity):
"""Representation of a Template Sensor."""
def __init__(
@@ -129,13 +118,17 @@ class SensorTemplate(Entity):
icon_template,
entity_picture_template,
availability_template,
- entity_ids,
device_class,
attribute_templates,
unique_id,
):
"""Initialize the sensor."""
- self.hass = hass
+ super().__init__(
+ attribute_templates=attribute_templates,
+ availability_template=availability_template,
+ icon_template=icon_template,
+ entity_picture_template=entity_picture_template,
+ )
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, device_id, hass=hass
)
@@ -144,40 +137,23 @@ class SensorTemplate(Entity):
self._unit_of_measurement = unit_of_measurement
self._template = state_template
self._state = None
- self._icon_template = icon_template
- self._entity_picture_template = entity_picture_template
- self._availability_template = availability_template
- self._icon = None
- self._entity_picture = None
- self._entities = entity_ids
self._device_class = device_class
- self._available = True
- self._attribute_templates = attribute_templates
- self._attributes = {}
+
self._unique_id = unique_id
async def async_added_to_hass(self):
"""Register callbacks."""
- @callback
- def template_sensor_state_listener(event):
- """Handle device state changes."""
- self.async_schedule_update_ha_state(True)
+ self.add_template_attribute("_state", self._template, None, self._update_state)
+ if self._friendly_name_template is not None:
+ self.add_template_attribute("_name", self._friendly_name_template)
- @callback
- def template_sensor_startup(event):
- """Update template on startup."""
- if self._entities != MATCH_ALL:
- # Track state change only for valid templates
- async_track_state_change_event(
- self.hass, self._entities, template_sensor_state_listener
- )
+ await super().async_added_to_hass()
- self.async_schedule_update_ha_state(True)
-
- self.hass.bus.async_listen_once(
- EVENT_HOMEASSISTANT_START, template_sensor_startup
- )
+ @callback
+ def _update_state(self, result):
+ super()._update_state(result)
+ self._state = None if isinstance(result, TemplateError) else result
@property
def name(self):
@@ -194,103 +170,12 @@ class SensorTemplate(Entity):
"""Return the state of the sensor."""
return self._state
- @property
- def icon(self):
- """Return the icon to use in the frontend, if any."""
- return self._icon
-
@property
def device_class(self) -> Optional[str]:
"""Return the device class of the sensor."""
return self._device_class
- @property
- def entity_picture(self):
- """Return the entity_picture to use in the frontend, if any."""
- return self._entity_picture
-
@property
def unit_of_measurement(self):
"""Return the unit_of_measurement of the device."""
return self._unit_of_measurement
-
- @property
- def available(self) -> bool:
- """Return if the device is available."""
- return self._available
-
- @property
- def device_state_attributes(self):
- """Return the state attributes."""
- return self._attributes
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- async def async_update(self):
- """Update the state from the template."""
- try:
- self._state = self._template.async_render()
- self._available = True
- except TemplateError as ex:
- self._available = False
- if ex.args and ex.args[0].startswith(
- "UndefinedError: 'None' has no attribute"
- ):
- # Common during HA startup - so just a warning
- _LOGGER.warning(
- "Could not render template %s, the state is unknown", self._name
- )
- else:
- self._state = None
- _LOGGER.error("Could not render template %s: %s", self._name, ex)
-
- attrs = {}
- for key, value in self._attribute_templates.items():
- try:
- attrs[key] = value.async_render()
- except TemplateError as err:
- _LOGGER.error("Error rendering attribute %s: %s", key, err)
-
- self._attributes = attrs
-
- templates = {
- "_icon": self._icon_template,
- "_entity_picture": self._entity_picture_template,
- "_name": self._friendly_name_template,
- "_available": self._availability_template,
- }
-
- for property_name, template in templates.items():
- if template is None:
- continue
-
- try:
- value = template.async_render()
- if property_name == "_available":
- value = value.lower() == "true"
- setattr(self, property_name, value)
- except TemplateError as ex:
- friendly_property_name = property_name[1:].replace("_", " ")
- if ex.args and ex.args[0].startswith(
- "UndefinedError: 'None' has no attribute"
- ):
- # Common during HA startup - so just a warning
- _LOGGER.warning(
- "Could not render %s template %s, the state is unknown",
- friendly_property_name,
- self._name,
- )
- continue
-
- try:
- setattr(self, property_name, getattr(super(), property_name))
- except AttributeError:
- _LOGGER.error(
- "Could not render %s template %s: %s",
- friendly_property_name,
- self._name,
- ex,
- )
diff --git a/homeassistant/components/template/services.yaml b/homeassistant/components/template/services.yaml
new file mode 100644
index 00000000000..d36111d608e
--- /dev/null
+++ b/homeassistant/components/template/services.yaml
@@ -0,0 +1,3 @@
+reload:
+ description: Reload all template entities.
+
diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py
index f9b07fa1dec..fc7b2408f21 100644
--- a/homeassistant/components/template/switch.py
+++ b/homeassistant/components/template/switch.py
@@ -16,8 +16,6 @@ from homeassistant.const import (
CONF_SWITCHES,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
- EVENT_HOMEASSISTANT_START,
- MATCH_ALL,
STATE_OFF,
STATE_ON,
)
@@ -25,12 +23,12 @@ from homeassistant.core import callback
from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
-from homeassistant.helpers.event import async_track_state_change_event
+from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.script import Script
-from . import extract_entities, initialise_templates
-from .const import CONF_AVAILABILITY_TEMPLATE
+from .const import CONF_AVAILABILITY_TEMPLATE, DOMAIN, PLATFORMS
+from .template_entity import TemplateEntity
_LOGGER = logging.getLogger(__name__)
_VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"]
@@ -38,18 +36,21 @@ _VALID_STATES = [STATE_ON, STATE_OFF, "true", "false"]
ON_ACTION = "turn_on"
OFF_ACTION = "turn_off"
-SWITCH_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
- vol.Optional(CONF_ICON_TEMPLATE): cv.template,
- vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
- vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
- vol.Required(ON_ACTION): cv.SCRIPT_SCHEMA,
- vol.Required(OFF_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Optional(CONF_UNIQUE_ID): cv.string,
- }
+SWITCH_SCHEMA = vol.All(
+ cv.deprecated(ATTR_ENTITY_ID),
+ vol.Schema(
+ {
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_ICON_TEMPLATE): cv.template,
+ vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
+ vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
+ vol.Required(ON_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Required(OFF_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ }
+ ),
)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@@ -57,8 +58,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
)
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
- """Set up the Template switch."""
+async def _async_create_entities(hass, config):
+ """Create the Template switches."""
switches = []
for device, device_config in config[CONF_SWITCHES].items():
@@ -71,18 +72,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
off_action = device_config[OFF_ACTION]
unique_id = device_config.get(CONF_UNIQUE_ID)
- templates = {
- CONF_VALUE_TEMPLATE: state_template,
- CONF_ICON_TEMPLATE: icon_template,
- CONF_ENTITY_PICTURE_TEMPLATE: entity_picture_template,
- CONF_AVAILABILITY_TEMPLATE: availability_template,
- }
-
- initialise_templates(hass, templates)
- entity_ids = extract_entities(
- device, "switch", device_config.get(ATTR_ENTITY_ID), templates
- )
-
switches.append(
SwitchTemplate(
hass,
@@ -94,15 +83,21 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
availability_template,
on_action,
off_action,
- entity_ids,
unique_id,
)
)
- async_add_entities(switches)
+ return switches
-class SwitchTemplate(SwitchEntity, RestoreEntity):
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+ """Set up the template switches."""
+
+ await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
+ async_add_entities(await _async_create_entities(hass, config))
+
+
+class SwitchTemplate(TemplateEntity, SwitchEntity, RestoreEntity):
"""Representation of a Template switch."""
def __init__(
@@ -116,28 +111,33 @@ class SwitchTemplate(SwitchEntity, RestoreEntity):
availability_template,
on_action,
off_action,
- entity_ids,
unique_id,
):
"""Initialize the Template switch."""
- self.hass = hass
+ super().__init__(
+ availability_template=availability_template,
+ icon_template=icon_template,
+ entity_picture_template=entity_picture_template,
+ )
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, device_id, hass=hass
)
self._name = friendly_name
self._template = state_template
- self._on_script = Script(hass, on_action)
- self._off_script = Script(hass, off_action)
+ domain = __name__.split(".")[-2]
+ self._on_script = Script(hass, on_action, friendly_name, domain)
+ self._off_script = Script(hass, off_action, friendly_name, domain)
self._state = False
- self._icon_template = icon_template
- self._entity_picture_template = entity_picture_template
- self._availability_template = availability_template
- self._icon = None
- self._entity_picture = None
- self._entities = entity_ids
- self._available = True
self._unique_id = unique_id
+ @callback
+ def _update_state(self, result):
+ super()._update_state(result)
+ if isinstance(result, TemplateError):
+ self._state = None
+ return
+ self._state = result.lower() in ("true", STATE_ON)
+
async def async_added_to_hass(self):
"""Register callbacks."""
@@ -150,28 +150,12 @@ class SwitchTemplate(SwitchEntity, RestoreEntity):
self._state = state.state == STATE_ON
# no need to listen for events
- return
+ else:
+ self.add_template_attribute(
+ "_state", self._template, None, self._update_state
+ )
- # set up event listening
- @callback
- def template_switch_state_listener(event):
- """Handle target device state changes."""
- self.async_schedule_update_ha_state(True)
-
- @callback
- def template_switch_startup(event):
- """Update template on startup."""
- if self._entities != MATCH_ALL:
- # Track state change only for valid templates
- async_track_state_change_event(
- self.hass, self._entities, template_switch_state_listener
- )
-
- self.async_schedule_update_ha_state(True)
-
- self.hass.bus.async_listen_once(
- EVENT_HOMEASSISTANT_START, template_switch_startup
- )
+ await super().async_added_to_hass()
@property
def name(self):
@@ -193,21 +177,6 @@ class SwitchTemplate(SwitchEntity, RestoreEntity):
"""Return the polling state."""
return False
- @property
- def icon(self):
- """Return the icon to use in the frontend, if any."""
- return self._icon
-
- @property
- def entity_picture(self):
- """Return the entity_picture to use in the frontend, if any."""
- return self._entity_picture
-
- @property
- def available(self) -> bool:
- """Return if the device is available."""
- return self._available
-
async def async_turn_on(self, **kwargs):
"""Fire the on action."""
await self._on_script.async_run(context=self._context)
@@ -222,63 +191,6 @@ class SwitchTemplate(SwitchEntity, RestoreEntity):
self._state = False
self.async_write_ha_state()
- async def async_update(self):
- """Update the state from the template."""
- if self._template is None:
- return
- try:
- state = self._template.async_render().lower()
-
- if state in _VALID_STATES:
- self._state = state in ("true", STATE_ON)
- else:
- _LOGGER.error(
- "Received invalid switch is_on state: %s. Expected: %s",
- state,
- ", ".join(_VALID_STATES),
- )
- self._state = None
-
- except TemplateError as ex:
- _LOGGER.error(ex)
- self._state = None
-
- for property_name, template in (
- ("_icon", self._icon_template),
- ("_entity_picture", self._entity_picture_template),
- ("_available", self._availability_template),
- ):
- if template is None:
- continue
-
- try:
- value = template.async_render()
- if property_name == "_available":
- value = value.lower() == "true"
- setattr(self, property_name, value)
- except TemplateError as ex:
- friendly_property_name = property_name[1:].replace("_", " ")
- if ex.args and ex.args[0].startswith(
- "UndefinedError: 'None' has no attribute"
- ):
- # Common during HA startup - so just a warning
- _LOGGER.warning(
- "Could not render %s template %s, the state is unknown",
- friendly_property_name,
- self._name,
- )
- return
-
- try:
- setattr(self, property_name, getattr(super(), property_name))
- except AttributeError:
- _LOGGER.error(
- "Could not render %s template %s: %s",
- friendly_property_name,
- self._name,
- ex,
- )
-
@property
def assumed_state(self):
"""State is assumed, if no template given."""
diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py
new file mode 100644
index 00000000000..632eeea8926
--- /dev/null
+++ b/homeassistant/components/template/template_entity.py
@@ -0,0 +1,299 @@
+"""TemplateEntity utility class."""
+
+import logging
+from typing import Any, Callable, List, Optional, Union
+
+import voluptuous as vol
+
+from homeassistant.const import ATTR_ENTITY_ID
+from homeassistant.core import EVENT_HOMEASSISTANT_START, CoreState, callback
+from homeassistant.exceptions import TemplateError
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import (
+ Event,
+ TrackTemplate,
+ TrackTemplateResult,
+ async_track_template_result,
+)
+from homeassistant.helpers.template import Template, result_as_boolean
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class _TemplateAttribute:
+ """Attribute value linked to template result."""
+
+ def __init__(
+ self,
+ entity: Entity,
+ attribute: str,
+ template: Template,
+ validator: Callable[[Any], Any] = None,
+ on_update: Optional[Callable[[Any], None]] = None,
+ none_on_template_error: Optional[bool] = False,
+ ):
+ """Template attribute."""
+ self._entity = entity
+ self._attribute = attribute
+ self.template = template
+ self.validator = validator
+ self.on_update = on_update
+ self.async_update = None
+ self.none_on_template_error = none_on_template_error
+
+ @callback
+ def async_setup(self):
+ """Config update path for the attribute."""
+ if self.on_update:
+ return
+
+ if not hasattr(self._entity, self._attribute):
+ raise AttributeError(f"Attribute '{self._attribute}' does not exist.")
+
+ self.on_update = self._default_update
+
+ @callback
+ def _default_update(self, result):
+ attr_result = None if isinstance(result, TemplateError) else result
+ setattr(self._entity, self._attribute, attr_result)
+
+ @callback
+ def handle_result(
+ self,
+ event: Optional[Event],
+ template: Template,
+ last_result: Union[str, None, TemplateError],
+ result: Union[str, TemplateError],
+ ) -> None:
+ """Handle a template result event callback."""
+ if isinstance(result, TemplateError):
+ _LOGGER.error(
+ "TemplateError('%s') "
+ "while processing template '%s' "
+ "for attribute '%s' in entity '%s'",
+ result,
+ self.template,
+ self._attribute,
+ self._entity.entity_id,
+ )
+ if self.none_on_template_error:
+ self._default_update(result)
+ else:
+ self.on_update(result)
+ return
+
+ if not self.validator:
+ self.on_update(result)
+ return
+
+ try:
+ validated = self.validator(result)
+ except vol.Invalid as ex:
+ _LOGGER.error(
+ "Error validating template result '%s' "
+ "from template '%s' "
+ "for attribute '%s' in entity %s "
+ "validation message '%s'",
+ result,
+ self.template,
+ self._attribute,
+ self._entity.entity_id,
+ ex.msg,
+ )
+ self.on_update(None)
+ return
+
+ self.on_update(validated)
+ return
+
+
+class TemplateEntity(Entity):
+ """Entity that uses templates to calculate attributes."""
+
+ def __init__(
+ self,
+ *,
+ availability_template=None,
+ icon_template=None,
+ entity_picture_template=None,
+ attribute_templates=None,
+ ):
+ """Template Entity."""
+ self._template_attrs = {}
+ self._async_update = None
+ self._attribute_templates = attribute_templates
+ self._attributes = {}
+ self._availability_template = availability_template
+ self._available = True
+ self._icon_template = icon_template
+ self._entity_picture_template = entity_picture_template
+ self._icon = None
+ self._entity_picture = None
+ self._self_ref_update_count = 0
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @callback
+ def _update_available(self, result):
+ if isinstance(result, TemplateError):
+ self._available = True
+ return
+
+ self._available = result_as_boolean(result)
+
+ @callback
+ def _update_state(self, result):
+ if self._availability_template:
+ return
+
+ self._available = not isinstance(result, TemplateError)
+
+ @property
+ def available(self) -> bool:
+ """Return if the device is available."""
+ return self._available
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def entity_picture(self):
+ """Return the entity_picture to use in the frontend, if any."""
+ return self._entity_picture
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ @callback
+ def _add_attribute_template(self, attribute_key, attribute_template):
+ """Create a template tracker for the attribute."""
+
+ def _update_attribute(result):
+ attr_result = None if isinstance(result, TemplateError) else result
+ self._attributes[attribute_key] = attr_result
+
+ self.add_template_attribute(
+ attribute_key, attribute_template, None, _update_attribute
+ )
+
+ def add_template_attribute(
+ self,
+ attribute: str,
+ template: Template,
+ validator: Callable[[Any], Any] = None,
+ on_update: Optional[Callable[[Any], None]] = None,
+ none_on_template_error: bool = False,
+ ) -> None:
+ """
+ Call in the constructor to add a template linked to a attribute.
+
+ Parameters
+ ----------
+ attribute
+ The name of the attribute to link to. This attribute must exist
+ unless a custom on_update method is supplied.
+ template
+ The template to calculate.
+ validator
+ Validator function to parse the result and ensure it's valid.
+ on_update
+ Called to store the template result rather than storing it
+ the supplied attribute. Passed the result of the validator, or None
+ if the template or validator resulted in an error.
+
+ """
+ attribute = _TemplateAttribute(
+ self, attribute, template, validator, on_update, none_on_template_error
+ )
+ attribute.async_setup()
+ self._template_attrs.setdefault(template, [])
+ self._template_attrs[template].append(attribute)
+
+ @callback
+ def _handle_results(
+ self,
+ event: Optional[Event],
+ updates: List[TrackTemplateResult],
+ ) -> None:
+ """Call back the results to the attributes."""
+
+ if event:
+ self.async_set_context(event.context)
+
+ entity_id = event and event.data.get(ATTR_ENTITY_ID)
+
+ if entity_id and entity_id == self.entity_id:
+ self._self_ref_update_count += 1
+ else:
+ self._self_ref_update_count = 0
+
+ # If we need to make this less sensitive in the future,
+ # change the '>=' to a '>' here.
+ if self._self_ref_update_count >= len(self._template_attrs):
+ for update in updates:
+ _LOGGER.warning(
+ "Template loop detected while processing event: %s, skipping template render for Template[%s]",
+ event,
+ update.template.template,
+ )
+ return
+
+ for update in updates:
+ for attr in self._template_attrs[update.template]:
+ attr.handle_result(
+ event, update.template, update.last_result, update.result
+ )
+
+ if self._async_update:
+ self.async_write_ha_state()
+
+ async def _async_template_startup(self, *_) -> None:
+ # _handle_results will not write state until "_async_update" is set
+ template_var_tups = [
+ TrackTemplate(template, None) for template in self._template_attrs
+ ]
+
+ result_info = async_track_template_result(
+ self.hass, template_var_tups, self._handle_results
+ )
+ self.async_on_remove(result_info.async_remove)
+ result_info.async_refresh()
+ self.async_write_ha_state()
+ self._async_update = result_info.async_refresh
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+ if self._availability_template is not None:
+ self.add_template_attribute(
+ "_available", self._availability_template, None, self._update_available
+ )
+ if self._attribute_templates is not None:
+ for key, value in self._attribute_templates.items():
+ self._add_attribute_template(key, value)
+ if self._icon_template is not None:
+ self.add_template_attribute(
+ "_icon", self._icon_template, vol.Or(cv.whitespace, cv.icon)
+ )
+ if self._entity_picture_template is not None:
+ self.add_template_attribute(
+ "_entity_picture", self._entity_picture_template
+ )
+ if self.hass.state == CoreState.running:
+ await self._async_template_startup()
+ return
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, self._async_template_startup
+ )
+
+ async def async_update(self) -> None:
+ """Call for forced update."""
+ self._async_update()
diff --git a/homeassistant/components/template/trigger.py b/homeassistant/components/template/trigger.py
new file mode 100644
index 00000000000..5dcee0a7347
--- /dev/null
+++ b/homeassistant/components/template/trigger.py
@@ -0,0 +1,116 @@
+"""Offer template automation rules."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant import exceptions
+from homeassistant.const import CONF_FOR, CONF_PLATFORM, CONF_VALUE_TEMPLATE
+from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv, template
+from homeassistant.helpers.event import (
+ TrackTemplate,
+ async_call_later,
+ async_track_template_result,
+)
+from homeassistant.helpers.template import result_as_boolean
+
+# mypy: allow-untyped-defs, no-check-untyped-defs
+
+_LOGGER = logging.getLogger(__name__)
+
+TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_PLATFORM): "template",
+ vol.Required(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_FOR): cv.positive_time_period_template,
+ }
+)
+
+
+async def async_attach_trigger(
+ hass, config, action, automation_info, *, platform_type="template"
+):
+ """Listen for state changes based on configuration."""
+ value_template = config.get(CONF_VALUE_TEMPLATE)
+ value_template.hass = hass
+ time_delta = config.get(CONF_FOR)
+ template.attach(hass, time_delta)
+ delay_cancel = None
+
+ @callback
+ def template_listener(event, updates):
+ """Listen for state changes and calls action."""
+ nonlocal delay_cancel
+ result = updates.pop().result
+
+ if delay_cancel:
+ # pylint: disable=not-callable
+ delay_cancel()
+ delay_cancel = None
+
+ if not result_as_boolean(result):
+ return
+
+ entity_id = event.data.get("entity_id")
+ from_s = event.data.get("old_state")
+ to_s = event.data.get("new_state")
+
+ @callback
+ def call_action(*_):
+ """Call action with right context."""
+ hass.async_run_job(
+ action,
+ {
+ "trigger": {
+ "platform": "template",
+ "entity_id": entity_id,
+ "from_state": from_s,
+ "to_state": to_s,
+ "for": time_delta if not time_delta else period,
+ "description": f"{entity_id} via template",
+ }
+ },
+ (to_s.context if to_s else None),
+ )
+
+ if not time_delta:
+ call_action()
+ return
+
+ variables = {
+ "trigger": {
+ "platform": platform_type,
+ "entity_id": entity_id,
+ "from_state": from_s,
+ "to_state": to_s,
+ }
+ }
+
+ try:
+ period = cv.positive_time_period(
+ template.render_complex(time_delta, variables)
+ )
+ except (exceptions.TemplateError, vol.Invalid) as ex:
+ _LOGGER.error(
+ "Error rendering '%s' for template: %s", automation_info["name"], ex
+ )
+ return
+
+ delay_cancel = async_call_later(hass, period.seconds, call_action)
+
+ info = async_track_template_result(
+ hass,
+ [TrackTemplate(value_template, automation_info["variables"])],
+ template_listener,
+ )
+ unsub = info.async_remove
+
+ @callback
+ def async_remove():
+ """Remove state listeners async."""
+ unsub()
+ if delay_cancel:
+ # pylint: disable=not-callable
+ delay_cancel()
+
+ return async_remove
diff --git a/homeassistant/components/template/vacuum.py b/homeassistant/components/template/vacuum.py
index a61a1690e5a..5bf8148b96e 100644
--- a/homeassistant/components/template/vacuum.py
+++ b/homeassistant/components/template/vacuum.py
@@ -5,7 +5,7 @@ import voluptuous as vol
from homeassistant.components.vacuum import (
ATTR_FAN_SPEED,
- DOMAIN,
+ DOMAIN as VACUUM_DOMAIN,
SERVICE_CLEAN_SPOT,
SERVICE_LOCATE,
SERVICE_PAUSE,
@@ -35,18 +35,17 @@ from homeassistant.const import (
CONF_FRIENDLY_NAME,
CONF_UNIQUE_ID,
CONF_VALUE_TEMPLATE,
- EVENT_HOMEASSISTANT_START,
- MATCH_ALL,
STATE_UNKNOWN,
)
from homeassistant.core import callback
from homeassistant.exceptions import TemplateError
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import async_generate_entity_id
+from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.script import Script
-from . import extract_entities, initialise_templates
-from .const import CONF_AVAILABILITY_TEMPLATE
+from .const import CONF_AVAILABILITY_TEMPLATE, DOMAIN, PLATFORMS
+from .template_entity import TemplateEntity
_LOGGER = logging.getLogger(__name__)
@@ -56,7 +55,7 @@ CONF_FAN_SPEED_LIST = "fan_speeds"
CONF_FAN_SPEED_TEMPLATE = "fan_speed_template"
CONF_ATTRIBUTE_TEMPLATES = "attribute_templates"
-ENTITY_ID_FORMAT = DOMAIN + ".{}"
+ENTITY_ID_FORMAT = VACUUM_DOMAIN + ".{}"
_VALID_STATES = [
STATE_CLEANING,
STATE_DOCKED,
@@ -66,27 +65,30 @@ _VALID_STATES = [
STATE_ERROR,
]
-VACUUM_SCHEMA = vol.Schema(
- {
- vol.Optional(CONF_FRIENDLY_NAME): cv.string,
- vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
- vol.Optional(CONF_BATTERY_LEVEL_TEMPLATE): cv.template,
- vol.Optional(CONF_FAN_SPEED_TEMPLATE): cv.template,
- vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
- vol.Optional(CONF_ATTRIBUTE_TEMPLATES, default={}): vol.Schema(
- {cv.string: cv.template}
- ),
- vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA,
- vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA,
- vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA,
- vol.Optional(SERVICE_RETURN_TO_BASE): cv.SCRIPT_SCHEMA,
- vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA,
- vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA,
- vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA,
- vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list,
- vol.Optional(CONF_ENTITY_ID): cv.entity_ids,
- vol.Optional(CONF_UNIQUE_ID): cv.string,
- }
+VACUUM_SCHEMA = vol.All(
+ cv.deprecated(CONF_ENTITY_ID),
+ vol.Schema(
+ {
+ vol.Optional(CONF_FRIENDLY_NAME): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_BATTERY_LEVEL_TEMPLATE): cv.template,
+ vol.Optional(CONF_FAN_SPEED_TEMPLATE): cv.template,
+ vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template,
+ vol.Optional(CONF_ATTRIBUTE_TEMPLATES, default={}): vol.Schema(
+ {cv.string: cv.template}
+ ),
+ vol.Required(SERVICE_START): cv.SCRIPT_SCHEMA,
+ vol.Optional(SERVICE_PAUSE): cv.SCRIPT_SCHEMA,
+ vol.Optional(SERVICE_STOP): cv.SCRIPT_SCHEMA,
+ vol.Optional(SERVICE_RETURN_TO_BASE): cv.SCRIPT_SCHEMA,
+ vol.Optional(SERVICE_CLEAN_SPOT): cv.SCRIPT_SCHEMA,
+ vol.Optional(SERVICE_LOCATE): cv.SCRIPT_SCHEMA,
+ vol.Optional(SERVICE_SET_FAN_SPEED): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_FAN_SPEED_LIST, default=[]): cv.ensure_list,
+ vol.Optional(CONF_ENTITY_ID): cv.entity_ids,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ }
+ ),
)
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
@@ -94,8 +96,8 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
)
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
- """Set up the Template Vacuums."""
+async def _async_create_entities(hass, config):
+ """Create the Template Vacuums."""
vacuums = []
for device, device_config in config[CONF_VACUUMS].items():
@@ -118,18 +120,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
fan_speed_list = device_config[CONF_FAN_SPEED_LIST]
unique_id = device_config.get(CONF_UNIQUE_ID)
- templates = {
- CONF_VALUE_TEMPLATE: state_template,
- CONF_BATTERY_LEVEL_TEMPLATE: battery_level_template,
- CONF_FAN_SPEED_TEMPLATE: fan_speed_template,
- CONF_AVAILABILITY_TEMPLATE: availability_template,
- }
-
- initialise_templates(hass, templates, attribute_templates)
- entity_ids = extract_entities(
- device, "vacuum", None, templates, attribute_templates
- )
-
vacuums.append(
TemplateVacuum(
hass,
@@ -147,16 +137,22 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
locate_action,
set_fan_speed_action,
fan_speed_list,
- entity_ids,
attribute_templates,
unique_id,
)
)
- async_add_entities(vacuums)
+ return vacuums
-class TemplateVacuum(StateVacuumEntity):
+async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
+ """Set up the template vacuums."""
+
+ await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
+ async_add_entities(await _async_create_entities(hass, config))
+
+
+class TemplateVacuum(TemplateEntity, StateVacuumEntity):
"""A template vacuum component."""
def __init__(
@@ -176,12 +172,14 @@ class TemplateVacuum(StateVacuumEntity):
locate_action,
set_fan_speed_action,
fan_speed_list,
- entity_ids,
attribute_templates,
unique_id,
):
"""Initialize the vacuum."""
- self.hass = hass
+ super().__init__(
+ attribute_templates=attribute_templates,
+ availability_template=availability_template,
+ )
self.entity_id = async_generate_entity_id(
ENTITY_ID_FORMAT, device_id, hass=hass
)
@@ -190,54 +188,57 @@ class TemplateVacuum(StateVacuumEntity):
self._template = state_template
self._battery_level_template = battery_level_template
self._fan_speed_template = fan_speed_template
- self._availability_template = availability_template
self._supported_features = SUPPORT_START
- self._attribute_templates = attribute_templates
- self._attributes = {}
- self._start_script = Script(hass, start_action)
+ domain = __name__.split(".")[-2]
+
+ self._start_script = Script(hass, start_action, friendly_name, domain)
self._pause_script = None
if pause_action:
- self._pause_script = Script(hass, pause_action)
+ self._pause_script = Script(hass, pause_action, friendly_name, domain)
self._supported_features |= SUPPORT_PAUSE
self._stop_script = None
if stop_action:
- self._stop_script = Script(hass, stop_action)
+ self._stop_script = Script(hass, stop_action, friendly_name, domain)
self._supported_features |= SUPPORT_STOP
self._return_to_base_script = None
if return_to_base_action:
- self._return_to_base_script = Script(hass, return_to_base_action)
+ self._return_to_base_script = Script(
+ hass, return_to_base_action, friendly_name, domain
+ )
self._supported_features |= SUPPORT_RETURN_HOME
self._clean_spot_script = None
if clean_spot_action:
- self._clean_spot_script = Script(hass, clean_spot_action)
+ self._clean_spot_script = Script(
+ hass, clean_spot_action, friendly_name, domain
+ )
self._supported_features |= SUPPORT_CLEAN_SPOT
self._locate_script = None
if locate_action:
- self._locate_script = Script(hass, locate_action)
+ self._locate_script = Script(hass, locate_action, friendly_name, domain)
self._supported_features |= SUPPORT_LOCATE
self._set_fan_speed_script = None
if set_fan_speed_action:
- self._set_fan_speed_script = Script(hass, set_fan_speed_action)
+ self._set_fan_speed_script = Script(
+ hass, set_fan_speed_action, friendly_name, domain
+ )
self._supported_features |= SUPPORT_FAN_SPEED
self._state = None
self._battery_level = None
self._fan_speed = None
- self._available = True
if self._template:
self._supported_features |= SUPPORT_STATE
if self._battery_level_template:
self._supported_features |= SUPPORT_BATTERY
- self._entities = entity_ids
self._unique_id = unique_id
# List of valid fan speeds
@@ -278,21 +279,6 @@ class TemplateVacuum(StateVacuumEntity):
"""Get the list of available fan speeds."""
return self._fan_speed_list
- @property
- def should_poll(self):
- """Return the polling state."""
- return False
-
- @property
- def available(self) -> bool:
- """Return if the device is available."""
- return self._available
-
- @property
- def device_state_attributes(self):
- """Return the state attributes."""
- return self._attributes
-
async def async_start(self):
"""Start or resume the cleaning task."""
await self._start_script.async_run(context=self._context)
@@ -352,108 +338,81 @@ class TemplateVacuum(StateVacuumEntity):
async def async_added_to_hass(self):
"""Register callbacks."""
- @callback
- def template_vacuum_state_listener(event):
- """Handle target device state changes."""
- self.async_schedule_update_ha_state(True)
-
- @callback
- def template_vacuum_startup(event):
- """Update template on startup."""
- if self._entities != MATCH_ALL:
- # Track state changes only for valid templates
- self.hass.helpers.event.async_track_state_change_event(
- self._entities, template_vacuum_state_listener
- )
-
- self.async_schedule_update_ha_state(True)
-
- self.hass.bus.async_listen_once(
- EVENT_HOMEASSISTANT_START, template_vacuum_startup
- )
-
- async def async_update(self):
- """Update the state from the template."""
- # Update state
if self._template is not None:
- try:
- state = self._template.async_render()
- except TemplateError as ex:
- _LOGGER.error(ex)
- state = None
- self._state = None
-
- # Validate state
- if state in _VALID_STATES:
- self._state = state
- elif state == STATE_UNKNOWN:
- self._state = None
- else:
- _LOGGER.error(
- "Received invalid vacuum state: %s. Expected: %s",
- state,
- ", ".join(_VALID_STATES),
- )
- self._state = None
-
- # Update battery level if 'battery_level_template' is configured
- if self._battery_level_template is not None:
- try:
- battery_level = self._battery_level_template.async_render()
- except TemplateError as ex:
- _LOGGER.error(ex)
- battery_level = None
-
- # Validate battery level
- if battery_level and 0 <= int(battery_level) <= 100:
- self._battery_level = int(battery_level)
- else:
- _LOGGER.error(
- "Received invalid battery level: %s. Expected: 0-100", battery_level
- )
- self._battery_level = None
-
- # Update fan speed if 'fan_speed_template' is configured
+ self.add_template_attribute(
+ "_state", self._template, None, self._update_state
+ )
if self._fan_speed_template is not None:
- try:
- fan_speed = self._fan_speed_template.async_render()
- except TemplateError as ex:
- _LOGGER.error(ex)
- fan_speed = None
- self._state = None
+ self.add_template_attribute(
+ "_fan_speed",
+ self._fan_speed_template,
+ None,
+ self._update_fan_speed,
+ )
+ if self._battery_level_template is not None:
+ self.add_template_attribute(
+ "_battery_level",
+ self._battery_level_template,
+ None,
+ self._update_battery_level,
+ none_on_template_error=True,
+ )
+ await super().async_added_to_hass()
- # Validate fan speed
- if fan_speed in self._fan_speed_list:
- self._fan_speed = fan_speed
- elif fan_speed == STATE_UNKNOWN:
- self._fan_speed = None
- else:
- _LOGGER.error(
- "Received invalid fan speed: %s. Expected: %s",
- fan_speed,
- self._fan_speed_list,
- )
- self._fan_speed = None
- # Update availability if availability template is defined
- if self._availability_template is not None:
- try:
- self._available = (
- self._availability_template.async_render().lower() == "true"
- )
- except (TemplateError, ValueError) as ex:
- _LOGGER.error(
- "Could not render %s template %s: %s",
- CONF_AVAILABILITY_TEMPLATE,
- self._name,
- ex,
- )
- # Update attribute if attribute template is defined
- if self._attribute_templates is not None:
- attrs = {}
- for key, value in self._attribute_templates.items():
- try:
- attrs[key] = value.async_render()
- except TemplateError as err:
- _LOGGER.error("Error rendering attribute %s: %s", key, err)
+ @callback
+ def _update_state(self, result):
+ super()._update_state(result)
+ if isinstance(result, TemplateError):
+ # This is legacy behavior
+ self._state = STATE_UNKNOWN
+ if not self._availability_template:
+ self._available = True
+ return
- self._attributes = attrs
+ # Validate state
+ if result in _VALID_STATES:
+ self._state = result
+ elif result == STATE_UNKNOWN:
+ self._state = None
+ else:
+ _LOGGER.error(
+ "Received invalid vacuum state: %s. Expected: %s",
+ result,
+ ", ".join(_VALID_STATES),
+ )
+ self._state = None
+
+ @callback
+ def _update_battery_level(self, battery_level):
+ try:
+ battery_level_int = int(battery_level)
+ if not 0 <= battery_level_int <= 100:
+ raise ValueError
+ except ValueError:
+ _LOGGER.error(
+ "Received invalid battery level: %s. Expected: 0-100", battery_level
+ )
+ self._battery_level = None
+ return
+
+ self._battery_level = battery_level_int
+
+ @callback
+ def _update_fan_speed(self, fan_speed):
+ if isinstance(fan_speed, TemplateError):
+ # This is legacy behavior
+ self._fan_speed = None
+ self._state = None
+ return
+
+ if fan_speed in self._fan_speed_list:
+ self._fan_speed = fan_speed
+ elif fan_speed == STATE_UNKNOWN:
+ self._fan_speed = None
+ else:
+ _LOGGER.error(
+ "Received invalid fan speed: %s. Expected: %s",
+ fan_speed,
+ self._fan_speed_list,
+ )
+ self._fan_speed = None
diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py
index 420c3403a11..0e73d9e871b 100644
--- a/homeassistant/components/tensorflow/image_processing.py
+++ b/homeassistant/components/tensorflow/image_processing.py
@@ -125,8 +125,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
# (The model_dir is created during the manual setup process. See integration docs.)
# pylint: disable=import-outside-toplevel
- from object_detection.utils import config_util, label_map_util
from object_detection.builders import model_builder
+ from object_detection.utils import config_util, label_map_util
except ImportError:
_LOGGER.error(
"No TensorFlow Object Detection library found! Install or compile "
@@ -204,7 +204,12 @@ class TensorFlowImageProcessor(ImageProcessingEntity):
"""Representation of an TensorFlow image processor."""
def __init__(
- self, hass, camera_entity, name, category_index, config,
+ self,
+ hass,
+ camera_entity,
+ name,
+ category_index,
+ config,
):
"""Initialize the TensorFlow entity."""
model_config = config.get(CONF_MODEL)
@@ -323,6 +328,8 @@ class TensorFlowImageProcessor(ImageProcessingEntity):
for path in paths:
_LOGGER.info("Saving results image to %s", path)
+ if not os.path.exists(os.path.dirname(path)):
+ os.makedirs(os.path.dirname(path), exist_ok=True)
img.save(path)
def process_image(self, image):
diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json
index fc87b5cdbff..2f1c391094c 100644
--- a/homeassistant/components/tensorflow/manifest.json
+++ b/homeassistant/components/tensorflow/manifest.json
@@ -3,13 +3,11 @@
"name": "TensorFlow",
"documentation": "https://www.home-assistant.io/integrations/tensorflow",
"requirements": [
- "tensorflow==2.2.0",
- "tf-slim==1.1.0",
- "tf-models-official==2.2.1",
+ "tensorflow==2.3.0",
+ "tf-models-official==2.3.0",
"pycocotools==2.0.1",
"numpy==1.19.1",
- "protobuf==3.12.2",
- "pillow==7.1.2"
+ "pillow==7.2.0"
],
"codeowners": []
}
diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py
index 67ebe90669d..a04c5975881 100644
--- a/homeassistant/components/tesla/__init__.py
+++ b/homeassistant/components/tesla/__init__.py
@@ -1,8 +1,10 @@
"""Support for Tesla cars."""
import asyncio
from collections import defaultdict
+from datetime import timedelta
import logging
+import async_timeout
from teslajsonpy import Controller as TeslaAPI, TeslaException
import voluptuous as vol
@@ -17,8 +19,13 @@ from homeassistant.const import (
CONF_USERNAME,
)
from homeassistant.core import callback
+from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_validation as cv
-from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.update_coordinator import (
+ CoordinatorEntity,
+ DataUpdateCoordinator,
+ UpdateFailed,
+)
from homeassistant.util import slugify
from .config_flow import (
@@ -116,7 +123,6 @@ async def async_setup(hass, base_config):
async def async_setup_entry(hass, config_entry):
"""Set up Tesla as config entry."""
-
hass.data.setdefault(DOMAIN, {})
config = config_entry.data
websession = aiohttp_client.async_get_clientsession(hass)
@@ -145,13 +151,22 @@ async def async_setup_entry(hass, config_entry):
_LOGGER.error("Unable to communicate with Tesla API: %s", ex.message)
return False
_async_save_tokens(hass, config_entry, access_token, refresh_token)
+ coordinator = TeslaDataUpdateCoordinator(
+ hass, config_entry=config_entry, controller=controller
+ )
+ # Fetch initial data so we have data when entities subscribe
entry_data = hass.data[DOMAIN][config_entry.entry_id] = {
- "controller": controller,
+ "coordinator": coordinator,
"devices": defaultdict(list),
DATA_LISTENER: [config_entry.add_update_listener(update_listener)],
}
_LOGGER.debug("Connected to the Tesla API")
- all_devices = entry_data["controller"].get_homeassistant_components()
+
+ await coordinator.async_refresh()
+ if not coordinator.last_update_success:
+ raise ConfigEntryNotReady
+
+ all_devices = controller.get_homeassistant_components()
if not all_devices:
return False
@@ -169,44 +184,82 @@ async def async_setup_entry(hass, config_entry):
async def async_unload_entry(hass, config_entry) -> bool:
"""Unload a config entry."""
- await asyncio.gather(
- *[
- hass.config_entries.async_forward_entry_unload(config_entry, component)
- for component in TESLA_COMPONENTS
- ]
+ unload_ok = all(
+ await asyncio.gather(
+ *[
+ hass.config_entries.async_forward_entry_unload(config_entry, component)
+ for component in TESLA_COMPONENTS
+ ]
+ )
)
for listener in hass.data[DOMAIN][config_entry.entry_id][DATA_LISTENER]:
listener()
username = config_entry.title
- hass.data[DOMAIN].pop(config_entry.entry_id)
- _LOGGER.debug("Unloaded entry for %s", username)
- return True
+ if unload_ok:
+ hass.data[DOMAIN].pop(config_entry.entry_id)
+ _LOGGER.debug("Unloaded entry for %s", username)
+ return True
+ return False
async def update_listener(hass, config_entry):
"""Update when config_entry options update."""
- controller = hass.data[DOMAIN][config_entry.entry_id]["controller"]
+ controller = hass.data[DOMAIN][config_entry.entry_id]["coordinator"].controller
old_update_interval = controller.update_interval
controller.update_interval = config_entry.options.get(CONF_SCAN_INTERVAL)
- _LOGGER.debug(
- "Changing scan_interval from %s to %s",
- old_update_interval,
- controller.update_interval,
- )
+ if old_update_interval != controller.update_interval:
+ _LOGGER.debug(
+ "Changing scan_interval from %s to %s",
+ old_update_interval,
+ controller.update_interval,
+ )
-class TeslaDevice(Entity):
- """Representation of a Tesla device."""
+class TeslaDataUpdateCoordinator(DataUpdateCoordinator):
+ """Class to manage fetching Tesla data."""
- def __init__(self, tesla_device, controller, config_entry):
- """Initialise the Tesla device."""
- self.tesla_device = tesla_device
+ def __init__(self, hass, *, config_entry, controller):
+ """Initialize global Tesla data updater."""
self.controller = controller
self.config_entry = config_entry
+
+ update_interval = timedelta(seconds=MIN_SCAN_INTERVAL)
+
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=update_interval,
+ )
+
+ async def _async_update_data(self):
+ """Fetch data from API endpoint."""
+ if self.controller.is_token_refreshed():
+ (refresh_token, access_token) = self.controller.get_tokens()
+ _async_save_tokens(
+ self.hass, self.config_entry, access_token, refresh_token
+ )
+ _LOGGER.debug("Saving new tokens in config_entry")
+
+ try:
+ # Note: asyncio.TimeoutError and aiohttp.ClientError are already
+ # handled by the data update coordinator.
+ async with async_timeout.timeout(30):
+ return await self.controller.update()
+ except TeslaException as err:
+ raise UpdateFailed(f"Error communicating with API: {err}") from err
+
+
+class TeslaDevice(CoordinatorEntity):
+ """Representation of a Tesla device."""
+
+ def __init__(self, tesla_device, coordinator):
+ """Initialise the Tesla device."""
+ super().__init__(coordinator)
+ self.tesla_device = tesla_device
self._name = self.tesla_device.name
- self.tesla_id = slugify(self.tesla_device.uniq_name)
- self._attributes = {}
- self._icon = ICONS.get(self.tesla_device.type)
+ self._unique_id = slugify(self.tesla_device.uniq_name)
+ self._attributes = self.tesla_device.attrs.copy()
@property
def name(self):
@@ -216,7 +269,7 @@ class TeslaDevice(Entity):
@property
def unique_id(self) -> str:
"""Return a unique ID."""
- return self.tesla_id
+ return self._unique_id
@property
def icon(self):
@@ -224,17 +277,12 @@ class TeslaDevice(Entity):
if self.device_class:
return None
- return self._icon
-
- @property
- def should_poll(self):
- """Return the polling state."""
- return self.tesla_device.should_poll
+ return ICONS.get(self.tesla_device.type)
@property
def device_state_attributes(self):
"""Return the state attributes of the device."""
- attr = self._attributes
+ attr = self._attributes.copy()
if self.tesla_device.has_battery():
attr[ATTR_BATTERY_LEVEL] = self.tesla_device.battery_level()
attr[ATTR_BATTERY_CHARGING] = self.tesla_device.battery_charging()
@@ -253,16 +301,13 @@ class TeslaDevice(Entity):
async def async_added_to_hass(self):
"""Register state update callback."""
+ self.async_on_remove(self.coordinator.async_add_listener(self.refresh))
- async def async_will_remove_from_hass(self):
- """Prepare for unload."""
+ @callback
+ def refresh(self) -> None:
+ """Refresh the state of the device.
- async def async_update(self):
- """Update the state of the device."""
- if self.controller.is_token_refreshed():
- (refresh_token, access_token) = self.controller.get_tokens()
- _async_save_tokens(
- self.hass, self.config_entry, access_token, refresh_token
- )
- _LOGGER.debug("Saving new tokens in config_entry")
- await self.tesla_device.async_update()
+ This assumes the coordinator has updated the controller.
+ """
+ self.tesla_device.refresh()
+ self.async_write_ha_state()
diff --git a/homeassistant/components/tesla/binary_sensor.py b/homeassistant/components/tesla/binary_sensor.py
index c6b63d92bd2..3c3777afc1f 100644
--- a/homeassistant/components/tesla/binary_sensor.py
+++ b/homeassistant/components/tesla/binary_sensor.py
@@ -14,8 +14,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
[
TeslaBinarySensor(
device,
- hass.data[TESLA_DOMAIN][config_entry.entry_id]["controller"],
- config_entry,
+ hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"],
)
for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"][
"binary_sensor"
@@ -28,27 +27,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
class TeslaBinarySensor(TeslaDevice, BinarySensorEntity):
"""Implement an Tesla binary sensor for parking and charger."""
- def __init__(self, tesla_device, controller, config_entry):
- """Initialise of a Tesla binary sensor."""
- super().__init__(tesla_device, controller, config_entry)
- self._state = None
- self._sensor_type = None
- if tesla_device.sensor_type in DEVICE_CLASSES:
- self._sensor_type = tesla_device.sensor_type
-
@property
def device_class(self):
"""Return the class of this binary sensor."""
- return self._sensor_type
+ return (
+ self.tesla_device.sensor_type
+ if self.tesla_device.sensor_type in DEVICE_CLASSES
+ else None
+ )
@property
def is_on(self):
"""Return the state of the binary sensor."""
- return self._state
-
- async def async_update(self):
- """Update the state of the device."""
- _LOGGER.debug("Updating sensor: %s", self._name)
- await super().async_update()
- self._state = self.tesla_device.get_value()
- self._attributes = self.tesla_device.attrs
+ return self.tesla_device.get_value()
diff --git a/homeassistant/components/tesla/climate.py b/homeassistant/components/tesla/climate.py
index 31269155d91..4c7ed850749 100644
--- a/homeassistant/components/tesla/climate.py
+++ b/homeassistant/components/tesla/climate.py
@@ -26,8 +26,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
[
TeslaThermostat(
device,
- hass.data[TESLA_DOMAIN][config_entry.entry_id]["controller"],
- config_entry,
+ hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"],
)
for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"][
"climate"
@@ -40,12 +39,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
class TeslaThermostat(TeslaDevice, ClimateEntity):
"""Representation of a Tesla climate."""
- def __init__(self, tesla_device, controller, config_entry):
- """Initialize the Tesla device."""
- super().__init__(tesla_device, controller, config_entry)
- self._target_temperature = None
- self._temperature = None
-
@property
def supported_features(self):
"""Return the list of supported features."""
@@ -69,42 +62,33 @@ class TeslaThermostat(TeslaDevice, ClimateEntity):
"""
return SUPPORT_HVAC
- async def async_update(self):
- """Call by the Tesla device callback to update state."""
- _LOGGER.debug("Updating: %s", self._name)
- await super().async_update()
- self._target_temperature = self.tesla_device.get_goal_temp()
- self._temperature = self.tesla_device.get_current_temp()
-
@property
def temperature_unit(self):
"""Return the unit of measurement."""
- tesla_temp_units = self.tesla_device.measurement
-
- if tesla_temp_units == "F":
+ if self.tesla_device.measurement == "F":
return TEMP_FAHRENHEIT
return TEMP_CELSIUS
@property
def current_temperature(self):
"""Return the current temperature."""
- return self._temperature
+ return self.tesla_device.get_current_temp()
@property
def target_temperature(self):
"""Return the temperature we try to reach."""
- return self._target_temperature
+ return self.tesla_device.get_goal_temp()
async def async_set_temperature(self, **kwargs):
"""Set new target temperatures."""
temperature = kwargs.get(ATTR_TEMPERATURE)
if temperature:
- _LOGGER.debug("%s: Setting temperature to %s", self._name, temperature)
+ _LOGGER.debug("%s: Setting temperature to %s", self.name, temperature)
await self.tesla_device.set_temperature(temperature)
async def async_set_hvac_mode(self, hvac_mode):
"""Set new target hvac mode."""
- _LOGGER.debug("%s: Setting hvac mode to %s", self._name, hvac_mode)
+ _LOGGER.debug("%s: Setting hvac mode to %s", self.name, hvac_mode)
if hvac_mode == HVAC_MODE_OFF:
await self.tesla_device.set_status(False)
elif hvac_mode == HVAC_MODE_HEAT_COOL:
diff --git a/homeassistant/components/tesla/config_flow.py b/homeassistant/components/tesla/config_flow.py
index f9a218d2f5f..9e46a30972f 100644
--- a/homeassistant/components/tesla/config_flow.py
+++ b/homeassistant/components/tesla/config_flow.py
@@ -142,9 +142,9 @@ async def validate_input(hass: core.HomeAssistant, data):
except TeslaException as ex:
if ex.code == 401:
_LOGGER.error("Invalid credentials: %s", ex)
- raise InvalidAuth()
+ raise InvalidAuth() from ex
_LOGGER.error("Unable to communicate with Tesla API: %s", ex)
- raise CannotConnect()
+ raise CannotConnect() from ex
_LOGGER.debug("Credentials successfully connected to the Tesla API")
return config
diff --git a/homeassistant/components/tesla/device_tracker.py b/homeassistant/components/tesla/device_tracker.py
index 08e5d58ba6e..46265a96ae4 100644
--- a/homeassistant/components/tesla/device_tracker.py
+++ b/homeassistant/components/tesla/device_tracker.py
@@ -1,5 +1,6 @@
"""Support for tracking Tesla cars."""
import logging
+from typing import Optional
from homeassistant.components.device_tracker import SOURCE_TYPE_GPS
from homeassistant.components.device_tracker.config_entry import TrackerEntity
@@ -14,8 +15,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
entities = [
TeslaDeviceEntity(
device,
- hass.data[TESLA_DOMAIN][config_entry.entry_id]["controller"],
- config_entry,
+ hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"],
)
for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"][
"devices_tracker"
@@ -27,44 +27,34 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
class TeslaDeviceEntity(TeslaDevice, TrackerEntity):
"""A class representing a Tesla device."""
- def __init__(self, tesla_device, controller, config_entry):
- """Initialize the Tesla device scanner."""
- super().__init__(tesla_device, controller, config_entry)
- self._latitude = None
- self._longitude = None
- self._attributes = {"trackr_id": self.unique_id}
- self._listener = None
-
- async def async_update(self):
- """Update the device info."""
- _LOGGER.debug("Updating device position: %s", self.name)
- await super().async_update()
- location = self.tesla_device.get_location()
- if location:
- self._latitude = location["latitude"]
- self._longitude = location["longitude"]
- self._attributes = {
- "trackr_id": self.unique_id,
- "heading": location["heading"],
- "speed": location["speed"],
- }
-
@property
- def latitude(self) -> float:
+ def latitude(self) -> Optional[float]:
"""Return latitude value of the device."""
- return self._latitude
+ location = self.tesla_device.get_location()
+ return self.tesla_device.get_location().get("latitude") if location else None
@property
- def longitude(self) -> float:
+ def longitude(self) -> Optional[float]:
"""Return longitude value of the device."""
- return self._longitude
-
- @property
- def should_poll(self):
- """Return whether polling is needed."""
- return True
+ location = self.tesla_device.get_location()
+ return self.tesla_device.get_location().get("longitude") if location else None
@property
def source_type(self):
"""Return the source type, eg gps or router, of the device."""
return SOURCE_TYPE_GPS
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ attr = super().device_state_attributes.copy()
+ location = self.tesla_device.get_location()
+ if location:
+ attr.update(
+ {
+ "trackr_id": self.unique_id,
+ "heading": location["heading"],
+ "speed": location["speed"],
+ }
+ )
+ return attr
diff --git a/homeassistant/components/tesla/lock.py b/homeassistant/components/tesla/lock.py
index 91833d777fd..7a74d2ececb 100644
--- a/homeassistant/components/tesla/lock.py
+++ b/homeassistant/components/tesla/lock.py
@@ -2,7 +2,6 @@
import logging
from homeassistant.components.lock import LockEntity
-from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED
from . import DOMAIN as TESLA_DOMAIN, TeslaDevice
@@ -14,8 +13,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
entities = [
TeslaLock(
device,
- hass.data[TESLA_DOMAIN][config_entry.entry_id]["controller"],
- config_entry,
+ hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"],
)
for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"]["lock"]
]
@@ -25,28 +23,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
class TeslaLock(TeslaDevice, LockEntity):
"""Representation of a Tesla door lock."""
- def __init__(self, tesla_device, controller, config_entry):
- """Initialise of the lock."""
- self._state = None
- super().__init__(tesla_device, controller, config_entry)
-
async def async_lock(self, **kwargs):
"""Send the lock command."""
- _LOGGER.debug("Locking doors for: %s", self._name)
+ _LOGGER.debug("Locking doors for: %s", self.name)
await self.tesla_device.lock()
async def async_unlock(self, **kwargs):
"""Send the unlock command."""
- _LOGGER.debug("Unlocking doors for: %s", self._name)
+ _LOGGER.debug("Unlocking doors for: %s", self.name)
await self.tesla_device.unlock()
@property
def is_locked(self):
"""Get whether the lock is in locked state."""
- return self._state == STATE_LOCKED
-
- async def async_update(self):
- """Update state of the lock."""
- _LOGGER.debug("Updating state for: %s", self._name)
- await super().async_update()
- self._state = STATE_LOCKED if self.tesla_device.is_locked() else STATE_UNLOCKED
+ if self.tesla_device.is_locked() is None:
+ return None
+ return self.tesla_device.is_locked()
diff --git a/homeassistant/components/tesla/manifest.json b/homeassistant/components/tesla/manifest.json
index fab844eb8eb..f1f4df6edd6 100644
--- a/homeassistant/components/tesla/manifest.json
+++ b/homeassistant/components/tesla/manifest.json
@@ -3,6 +3,6 @@
"name": "Tesla",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tesla",
- "requirements": ["teslajsonpy==0.10.1"],
+ "requirements": ["teslajsonpy==0.10.4"],
"codeowners": ["@zabuldon", "@alandtse"]
}
diff --git a/homeassistant/components/tesla/sensor.py b/homeassistant/components/tesla/sensor.py
index 62bdebbb1f3..50be1edc87d 100644
--- a/homeassistant/components/tesla/sensor.py
+++ b/homeassistant/components/tesla/sensor.py
@@ -1,6 +1,8 @@
"""Support for the Tesla sensors."""
import logging
+from typing import Optional
+from homeassistant.components.sensor import DEVICE_CLASSES
from homeassistant.const import (
LENGTH_KILOMETERS,
LENGTH_MILES,
@@ -17,89 +19,83 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Tesla binary_sensors by config_entry."""
- controller = hass.data[TESLA_DOMAIN][config_entry.entry_id]["controller"]
+ coordinator = hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"]
entities = []
for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"]["sensor"]:
if device.type == "temperature sensor":
- entities.append(TeslaSensor(device, controller, config_entry, "inside"))
- entities.append(TeslaSensor(device, controller, config_entry, "outside"))
+ entities.append(TeslaSensor(device, coordinator, "inside"))
+ entities.append(TeslaSensor(device, coordinator, "outside"))
else:
- entities.append(TeslaSensor(device, controller, config_entry))
+ entities.append(TeslaSensor(device, coordinator))
async_add_entities(entities, True)
class TeslaSensor(TeslaDevice, Entity):
"""Representation of Tesla sensors."""
- def __init__(self, tesla_device, controller, config_entry, sensor_type=None):
+ def __init__(self, tesla_device, coordinator, sensor_type=None):
"""Initialize of the sensor."""
- self.current_value = None
- self.units = None
- self.last_changed_time = None
+ super().__init__(tesla_device, coordinator)
self.type = sensor_type
- self._device_class = tesla_device.device_class
- super().__init__(tesla_device, controller, config_entry)
-
if self.type:
- self._name = f"{self.tesla_device.name} ({self.type})"
+ self._name = f"{super().name} ({self.type})"
+ self._unique_id = f"{super().unique_id}_{self.type}"
@property
- def unique_id(self) -> str:
- """Return a unique ID."""
- if self.type:
- return f"{self.tesla_id}_{self.type}"
- return self.tesla_id
-
- @property
- def state(self):
+ def state(self) -> Optional[float]:
"""Return the state of the sensor."""
- return self.current_value
-
- @property
- def unit_of_measurement(self):
- """Return the unit_of_measurement of the device."""
- return self.units
-
- @property
- def device_class(self):
- """Return the device_class of the device."""
- return self._device_class
-
- async def async_update(self):
- """Update the state from the sensor."""
- _LOGGER.debug("Updating sensor: %s", self._name)
- await super().async_update()
- units = self.tesla_device.measurement
-
if self.tesla_device.type == "temperature sensor":
if self.type == "outside":
- self.current_value = self.tesla_device.get_outside_temp()
- else:
- self.current_value = self.tesla_device.get_inside_temp()
- if units == "F":
- self.units = TEMP_FAHRENHEIT
- else:
- self.units = TEMP_CELSIUS
- elif self.tesla_device.type in ["range sensor", "mileage sensor"]:
- self.current_value = self.tesla_device.get_value()
+ return self.tesla_device.get_outside_temp()
+ return self.tesla_device.get_inside_temp()
+ if self.tesla_device.type in ["range sensor", "mileage sensor"]:
+ units = self.tesla_device.measurement
if units == "LENGTH_MILES":
- self.units = LENGTH_MILES
- else:
- self.units = LENGTH_KILOMETERS
- self.current_value = round(
- convert(self.current_value, LENGTH_MILES, LENGTH_KILOMETERS), 2
- )
- elif self.tesla_device.type == "charging rate sensor":
- self.current_value = self.tesla_device.charging_rate
- self.units = units
- self._attributes = {
- "time_left": self.tesla_device.time_left,
- "added_range": self.tesla_device.added_range,
- "charge_energy_added": self.tesla_device.charge_energy_added,
- "charge_current_request": self.tesla_device.charge_current_request,
- "charger_actual_current": self.tesla_device.charger_actual_current,
- "charger_voltage": self.tesla_device.charger_voltage,
- }
- else:
- self.current_value = self.tesla_device.get_value()
- self.units = units
+ return self.tesla_device.get_value()
+ return round(
+ convert(self.tesla_device.get_value(), LENGTH_MILES, LENGTH_KILOMETERS),
+ 2,
+ )
+ if self.tesla_device.type == "charging rate sensor":
+ return self.tesla_device.charging_rate
+ return self.tesla_device.get_value()
+
+ @property
+ def unit_of_measurement(self) -> Optional[str]:
+ """Return the unit_of_measurement of the device."""
+ units = self.tesla_device.measurement
+ if units == "F":
+ return TEMP_FAHRENHEIT
+ if units == "C":
+ return TEMP_CELSIUS
+ if units == "LENGTH_MILES":
+ return LENGTH_MILES
+ if units == "LENGTH_KILOMETERS":
+ return LENGTH_KILOMETERS
+ return units
+
+ @property
+ def device_class(self) -> Optional[str]:
+ """Return the device_class of the device."""
+ return (
+ self.tesla_device.device_class
+ if self.tesla_device.device_class in DEVICE_CLASSES
+ else None
+ )
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ attr = self._attributes.copy()
+ if self.tesla_device.type == "charging rate sensor":
+ attr.update(
+ {
+ "time_left": self.tesla_device.time_left,
+ "added_range": self.tesla_device.added_range,
+ "charge_energy_added": self.tesla_device.charge_energy_added,
+ "charge_current_request": self.tesla_device.charge_current_request,
+ "charger_actual_current": self.tesla_device.charger_actual_current,
+ "charger_voltage": self.tesla_device.charger_voltage,
+ }
+ )
+ return attr
diff --git a/homeassistant/components/tesla/switch.py b/homeassistant/components/tesla/switch.py
index 5e9c2aa9031..cb57c1e3d5c 100644
--- a/homeassistant/components/tesla/switch.py
+++ b/homeassistant/components/tesla/switch.py
@@ -2,7 +2,7 @@
import logging
from homeassistant.components.switch import SwitchEntity
-from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.const import STATE_ON
from . import DOMAIN as TESLA_DOMAIN, TeslaDevice
@@ -11,111 +11,95 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Tesla binary_sensors by config_entry."""
- controller = hass.data[TESLA_DOMAIN][config_entry.entry_id]["controller"]
+ coordinator = hass.data[TESLA_DOMAIN][config_entry.entry_id]["coordinator"]
entities = []
for device in hass.data[TESLA_DOMAIN][config_entry.entry_id]["devices"]["switch"]:
if device.type == "charger switch":
- entities.append(ChargerSwitch(device, controller, config_entry))
- entities.append(UpdateSwitch(device, controller, config_entry))
+ entities.append(ChargerSwitch(device, coordinator))
+ entities.append(UpdateSwitch(device, coordinator))
elif device.type == "maxrange switch":
- entities.append(RangeSwitch(device, controller, config_entry))
+ entities.append(RangeSwitch(device, coordinator))
elif device.type == "sentry mode switch":
- entities.append(SentryModeSwitch(device, controller, config_entry))
+ entities.append(SentryModeSwitch(device, coordinator))
async_add_entities(entities, True)
class ChargerSwitch(TeslaDevice, SwitchEntity):
"""Representation of a Tesla charger switch."""
- def __init__(self, tesla_device, controller, config_entry):
- """Initialise of the switch."""
- self._state = None
- super().__init__(tesla_device, controller, config_entry)
-
async def async_turn_on(self, **kwargs):
"""Send the on command."""
- _LOGGER.debug("Enable charging: %s", self._name)
+ _LOGGER.debug("Enable charging: %s", self.name)
await self.tesla_device.start_charge()
async def async_turn_off(self, **kwargs):
"""Send the off command."""
- _LOGGER.debug("Disable charging for: %s", self._name)
+ _LOGGER.debug("Disable charging for: %s", self.name)
await self.tesla_device.stop_charge()
@property
def is_on(self):
"""Get whether the switch is in on state."""
- return self._state == STATE_ON
-
- async def async_update(self):
- """Update the state of the switch."""
- _LOGGER.debug("Updating state for: %s", self._name)
- await super().async_update()
- self._state = STATE_ON if self.tesla_device.is_charging() else STATE_OFF
+ if self.tesla_device.is_charging() is None:
+ return None
+ return self.tesla_device.is_charging() == STATE_ON
class RangeSwitch(TeslaDevice, SwitchEntity):
"""Representation of a Tesla max range charging switch."""
- def __init__(self, tesla_device, controller, config_entry):
- """Initialise the switch."""
- self._state = None
- super().__init__(tesla_device, controller, config_entry)
-
async def async_turn_on(self, **kwargs):
"""Send the on command."""
- _LOGGER.debug("Enable max range charging: %s", self._name)
+ _LOGGER.debug("Enable max range charging: %s", self.name)
await self.tesla_device.set_max()
async def async_turn_off(self, **kwargs):
"""Send the off command."""
- _LOGGER.debug("Disable max range charging: %s", self._name)
+ _LOGGER.debug("Disable max range charging: %s", self.name)
await self.tesla_device.set_standard()
@property
def is_on(self):
"""Get whether the switch is in on state."""
- return self._state
-
- async def async_update(self):
- """Update the state of the switch."""
- _LOGGER.debug("Updating state for: %s", self._name)
- await super().async_update()
- self._state = bool(self.tesla_device.is_maxrange())
+ if self.tesla_device.is_maxrange() is None:
+ return None
+ return bool(self.tesla_device.is_maxrange())
class UpdateSwitch(TeslaDevice, SwitchEntity):
"""Representation of a Tesla update switch."""
- def __init__(self, tesla_device, controller, config_entry):
+ def __init__(self, tesla_device, coordinator):
"""Initialise the switch."""
- self._state = None
- tesla_device.type = "update switch"
- super().__init__(tesla_device, controller, config_entry)
- self._name = self._name.replace("charger", "update")
- self.tesla_id = self.tesla_id.replace("charger", "update")
+ super().__init__(tesla_device, coordinator)
+ self.controller = coordinator.controller
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return super().name.replace("charger", "update")
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return super().unique_id.replace("charger", "update")
async def async_turn_on(self, **kwargs):
"""Send the on command."""
- _LOGGER.debug("Enable updates: %s %s", self._name, self.tesla_device.id())
+ _LOGGER.debug("Enable updates: %s %s", self.name, self.tesla_device.id())
self.controller.set_updates(self.tesla_device.id(), True)
async def async_turn_off(self, **kwargs):
"""Send the off command."""
- _LOGGER.debug("Disable updates: %s %s", self._name, self.tesla_device.id())
+ _LOGGER.debug("Disable updates: %s %s", self.name, self.tesla_device.id())
self.controller.set_updates(self.tesla_device.id(), False)
@property
def is_on(self):
"""Get whether the switch is in on state."""
- return self._state
-
- async def async_update(self):
- """Update the state of the switch."""
- car_id = self.tesla_device.id()
- _LOGGER.debug("Updating state for: %s %s", self._name, car_id)
- await super().async_update()
- self._state = bool(self.controller.get_updates(car_id))
+ if self.controller.get_updates(self.tesla_device.id()) is None:
+ return None
+ return bool(self.controller.get_updates(self.tesla_device.id()))
class SentryModeSwitch(TeslaDevice, SwitchEntity):
@@ -123,25 +107,17 @@ class SentryModeSwitch(TeslaDevice, SwitchEntity):
async def async_turn_on(self, **kwargs):
"""Send the on command."""
- _LOGGER.debug("Enable sentry mode: %s", self._name)
+ _LOGGER.debug("Enable sentry mode: %s", self.name)
await self.tesla_device.enable_sentry_mode()
async def async_turn_off(self, **kwargs):
"""Send the off command."""
- _LOGGER.debug("Disable sentry mode: %s", self._name)
+ _LOGGER.debug("Disable sentry mode: %s", self.name)
await self.tesla_device.disable_sentry_mode()
@property
def is_on(self):
"""Get whether the switch is in on state."""
+ if self.tesla_device.is_on() is None:
+ return None
return self.tesla_device.is_on()
-
- @property
- def available(self):
- """Indicate if Home Assistant is able to read the state and control the underlying device."""
- return self.tesla_device.available()
-
- async def async_update(self):
- """Update the state of the switch."""
- _LOGGER.debug("Updating state for: %s", self._name)
- await super().async_update()
diff --git a/homeassistant/components/tesla/translations/fr.json b/homeassistant/components/tesla/translations/fr.json
index 638557f1035..96fe2e9d082 100644
--- a/homeassistant/components/tesla/translations/fr.json
+++ b/homeassistant/components/tesla/translations/fr.json
@@ -10,7 +10,7 @@
"user": {
"data": {
"password": "Mot de passe",
- "username": "Adresse e-mail"
+ "username": "Email"
},
"description": "Veuillez saisir vos informations.",
"title": "Tesla - Configuration"
diff --git a/homeassistant/components/thinkingcleaner/sensor.py b/homeassistant/components/thinkingcleaner/sensor.py
index 724ed54e4da..ec8d6a5ef2b 100644
--- a/homeassistant/components/thinkingcleaner/sensor.py
+++ b/homeassistant/components/thinkingcleaner/sensor.py
@@ -7,7 +7,7 @@ import voluptuous as vol
from homeassistant import util
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_HOST, UNIT_PERCENTAGE
+from homeassistant.const import CONF_HOST, PERCENTAGE
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -17,7 +17,7 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
SENSOR_TYPES = {
- "battery": ["Battery", UNIT_PERCENTAGE, "mdi:battery"],
+ "battery": ["Battery", PERCENTAGE, "mdi:battery"],
"state": ["State", None, None],
"capacity": ["Capacity", None, None],
}
diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py
index 657be67c7fc..b4b29a84297 100644
--- a/homeassistant/components/tibber/__init__.py
+++ b/homeassistant/components/tibber/__init__.py
@@ -64,8 +64,8 @@ async def async_setup_entry(hass, entry):
try:
await tibber_connection.update_info()
- except asyncio.TimeoutError:
- raise ConfigEntryNotReady
+ except asyncio.TimeoutError as err:
+ raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
_LOGGER.error("Error connecting to Tibber: %s ", err)
return False
diff --git a/homeassistant/components/tibber/config_flow.py b/homeassistant/components/tibber/config_flow.py
index b0115d84e2c..2e09ea22777 100644
--- a/homeassistant/components/tibber/config_flow.py
+++ b/homeassistant/components/tibber/config_flow.py
@@ -54,7 +54,9 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if errors:
return self.async_show_form(
- step_id="user", data_schema=DATA_SCHEMA, errors=errors,
+ step_id="user",
+ data_schema=DATA_SCHEMA,
+ errors=errors,
)
unique_id = tibber_connection.user_id
@@ -62,7 +64,12 @@ class TibberConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self.async_create_entry(
- title=tibber_connection.name, data={CONF_ACCESS_TOKEN: access_token},
+ title=tibber_connection.name,
+ data={CONF_ACCESS_TOKEN: access_token},
)
- return self.async_show_form(step_id="user", data_schema=DATA_SCHEMA, errors={},)
+ return self.async_show_form(
+ step_id="user",
+ data_schema=DATA_SCHEMA,
+ errors={},
+ )
diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py
index 323267cae6f..939c6d1597d 100644
--- a/homeassistant/components/tibber/sensor.py
+++ b/homeassistant/components/tibber/sensor.py
@@ -32,10 +32,10 @@ async def async_setup_entry(hass, entry, async_add_entities):
await home.update_info()
except asyncio.TimeoutError as err:
_LOGGER.error("Timeout connecting to Tibber home: %s ", err)
- raise PlatformNotReady()
+ raise PlatformNotReady() from err
except aiohttp.ClientError as err:
_LOGGER.error("Error connecting to Tibber home: %s ", err)
- raise PlatformNotReady()
+ raise PlatformNotReady() from err
if home.has_active_subscription:
dev.append(TibberSensorElPrice(home))
if home.has_real_time_consumption:
diff --git a/homeassistant/components/tibber/translations/no.json b/homeassistant/components/tibber/translations/no.json
index 34e078f5467..4480fb106de 100644
--- a/homeassistant/components/tibber/translations/no.json
+++ b/homeassistant/components/tibber/translations/no.json
@@ -13,10 +13,8 @@
"data": {
"access_token": "Tilgangstoken"
},
- "description": "Fyll inn din tilgangstoken fra [https://developer.tibber.com/settings/accesstoken](https://developer.tibber.com/settings/accesstoken)",
- "title": ""
+ "description": "Fyll inn din tilgangstoken fra [https://developer.tibber.com/settings/accesstoken](https://developer.tibber.com/settings/accesstoken)"
}
}
- },
- "title": ""
+ }
}
\ No newline at end of file
diff --git a/homeassistant/components/tibber/translations/ru.json b/homeassistant/components/tibber/translations/ru.json
index 06a5bf1331f..715fcf1179a 100644
--- a/homeassistant/components/tibber/translations/ru.json
+++ b/homeassistant/components/tibber/translations/ru.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430."
+ "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant."
},
"error": {
"connection_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.",
diff --git a/homeassistant/components/tile/__init__.py b/homeassistant/components/tile/__init__.py
index 4f6411ed368..ad4a8886873 100644
--- a/homeassistant/components/tile/__init__.py
+++ b/homeassistant/components/tile/__init__.py
@@ -8,8 +8,11 @@ from pytile.errors import SessionExpiredError, TileError
from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client
-from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.helpers.update_coordinator import (
+ CoordinatorEntity,
+ DataUpdateCoordinator,
+ UpdateFailed,
+)
from .const import DATA_COORDINATOR, DOMAIN, LOGGER
@@ -48,7 +51,7 @@ async def async_setup_entry(hass, config_entry):
LOGGER.info("Tile session expired; creating a new one")
await client.async_init()
except TileError as err:
- raise UpdateFailed(f"Error while retrieving data: {err}")
+ raise UpdateFailed(f"Error while retrieving data: {err}") from err
coordinator = DataUpdateCoordinator(
hass,
@@ -85,15 +88,15 @@ async def async_unload_entry(hass, config_entry):
return unload_ok
-class TileEntity(Entity):
+class TileEntity(CoordinatorEntity):
"""Define a generic Tile entity."""
def __init__(self, coordinator):
"""Initialize."""
+ super().__init__(coordinator)
self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
self._name = None
self._unique_id = None
- self.coordinator = coordinator
@property
def device_state_attributes(self):
@@ -110,11 +113,6 @@ class TileEntity(Entity):
"""Return the name."""
return self._name
- @property
- def should_poll(self):
- """Disable polling."""
- return False
-
@property
def unique_id(self):
"""Return the unique ID of the entity."""
@@ -137,10 +135,3 @@ class TileEntity(Entity):
self.async_on_remove(self.coordinator.async_add_listener(update))
self._update_from_latest_data()
-
- async def async_update(self):
- """Update the entity.
-
- Only used by the generic entity update service.
- """
- await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py
index e47ac69be5b..64d651b4cd8 100644
--- a/homeassistant/components/timer/__init__.py
+++ b/homeassistant/components/timer/__init__.py
@@ -1,7 +1,7 @@
"""Support for Timers."""
-from datetime import timedelta
+from datetime import datetime, timedelta
import logging
-import typing
+from typing import Dict, Optional
import voluptuous as vol
@@ -31,6 +31,7 @@ ENTITY_ID_FORMAT = DOMAIN + ".{}"
DEFAULT_DURATION = 0
ATTR_DURATION = "duration"
ATTR_REMAINING = "remaining"
+ATTR_FINISHES_AT = "finishes_at"
CONF_DURATION = "duration"
STATUS_IDLE = "idle"
@@ -64,6 +65,13 @@ UPDATE_FIELDS = {
}
+def _format_timedelta(delta: timedelta):
+ total_seconds = delta.total_seconds()
+ hours, remainder = divmod(total_seconds, 3600)
+ minutes, seconds = divmod(remainder, 60)
+ return f"{int(hours)}:{int(minutes):02}:{int(seconds):02}"
+
+
def _none_to_empty_dict(value):
if value is None:
return {}
@@ -78,9 +86,9 @@ CONFIG_SCHEMA = vol.Schema(
{
vol.Optional(CONF_NAME): cv.string,
vol.Optional(CONF_ICON): cv.icon,
- vol.Optional(
- CONF_DURATION, default=DEFAULT_DURATION
- ): cv.time_period,
+ vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): vol.All(
+ cv.time_period, _format_timedelta
+ ),
},
)
)
@@ -156,40 +164,42 @@ class TimerStorageCollection(collection.StorageCollection):
CREATE_SCHEMA = vol.Schema(CREATE_FIELDS)
UPDATE_SCHEMA = vol.Schema(UPDATE_FIELDS)
- async def _process_create_data(self, data: typing.Dict) -> typing.Dict:
+ async def _process_create_data(self, data: Dict) -> Dict:
"""Validate the config is valid."""
data = self.CREATE_SCHEMA(data)
# make duration JSON serializeable
- data[CONF_DURATION] = str(data[CONF_DURATION])
+ data[CONF_DURATION] = _format_timedelta(data[CONF_DURATION])
return data
@callback
- def _get_suggested_id(self, info: typing.Dict) -> str:
+ def _get_suggested_id(self, info: Dict) -> str:
"""Suggest an ID based on the config."""
return info[CONF_NAME]
- async def _update_data(self, data: dict, update_data: typing.Dict) -> typing.Dict:
+ async def _update_data(self, data: dict, update_data: Dict) -> Dict:
"""Return a new updated data object."""
data = {**data, **self.UPDATE_SCHEMA(update_data)}
# make duration JSON serializeable
- data[CONF_DURATION] = str(data[CONF_DURATION])
+ if CONF_DURATION in update_data:
+ data[CONF_DURATION] = _format_timedelta(data[CONF_DURATION])
return data
class Timer(RestoreEntity):
"""Representation of a timer."""
- def __init__(self, config: typing.Dict):
+ def __init__(self, config: Dict):
"""Initialize a timer."""
- self._config = config
- self.editable = True
- self._state = STATUS_IDLE
- self._remaining = config[CONF_DURATION]
- self._end = None
+ self._config: dict = config
+ self.editable: bool = True
+ self._state: str = STATUS_IDLE
+ self._duration = cv.time_period_str(config[CONF_DURATION])
+ self._remaining: Optional[timedelta] = None
+ self._end: Optional[datetime] = None
self._listener = None
@classmethod
- def from_yaml(cls, config: typing.Dict) -> "Timer":
+ def from_yaml(cls, config: Dict) -> "Timer":
"""Return entity instance initialized from yaml storage."""
timer = cls(config)
timer.entity_id = ENTITY_ID_FORMAT.format(config[CONF_ID])
@@ -224,14 +234,19 @@ class Timer(RestoreEntity):
@property
def state_attributes(self):
"""Return the state attributes."""
- return {
- ATTR_DURATION: str(self._config[CONF_DURATION]),
+ attrs = {
+ ATTR_DURATION: _format_timedelta(self._duration),
ATTR_EDITABLE: self.editable,
- ATTR_REMAINING: str(self._remaining),
}
+ if self._end is not None:
+ attrs[ATTR_FINISHES_AT] = self._end.isoformat()
+ if self._remaining is not None:
+ attrs[ATTR_REMAINING] = _format_timedelta(self._remaining)
+
+ return attrs
@property
- def unique_id(self) -> typing.Optional[str]:
+ def unique_id(self) -> Optional[str]:
"""Return unique id for the entity."""
return self._config[CONF_ID]
@@ -245,7 +260,7 @@ class Timer(RestoreEntity):
self._state = state and state.state == state
@callback
- def async_start(self, duration):
+ def async_start(self, duration: timedelta):
"""Start a timer."""
if self._listener:
self._listener()
@@ -260,15 +275,18 @@ class Timer(RestoreEntity):
self._state = STATUS_ACTIVE
start = dt_util.utcnow().replace(microsecond=0)
+
if self._remaining and newduration is None:
self._end = start + self._remaining
+
+ elif newduration:
+ self._duration = newduration
+ self._remaining = newduration
+ self._end = start + self._duration
+
else:
- if newduration:
- self._config[CONF_DURATION] = newduration
- self._remaining = newduration
- else:
- self._remaining = self._config[CONF_DURATION]
- self._end = start + self._config[CONF_DURATION]
+ self._remaining = self._duration
+ self._end = start + self._duration
self.hass.bus.async_fire(event, {"entity_id": self.entity_id})
@@ -299,7 +317,7 @@ class Timer(RestoreEntity):
self._listener = None
self._state = STATUS_IDLE
self._end = None
- self._remaining = timedelta()
+ self._remaining = None
self.hass.bus.async_fire(EVENT_TIMER_CANCELLED, {"entity_id": self.entity_id})
self.async_write_ha_state()
@@ -311,7 +329,8 @@ class Timer(RestoreEntity):
self._listener = None
self._state = STATUS_IDLE
- self._remaining = timedelta()
+ self._end = None
+ self._remaining = None
self.hass.bus.async_fire(EVENT_TIMER_FINISHED, {"entity_id": self.entity_id})
self.async_write_ha_state()
@@ -323,11 +342,13 @@ class Timer(RestoreEntity):
self._listener = None
self._state = STATUS_IDLE
- self._remaining = timedelta()
+ self._end = None
+ self._remaining = None
self.hass.bus.async_fire(EVENT_TIMER_FINISHED, {"entity_id": self.entity_id})
self.async_write_ha_state()
- async def async_update_config(self, config: typing.Dict) -> None:
+ async def async_update_config(self, config: Dict) -> None:
"""Handle when the config is updated."""
self._config = config
+ self._duration = cv.time_period_str(config[CONF_DURATION])
self.async_write_ha_state()
diff --git a/homeassistant/components/timer/translations/fr.json b/homeassistant/components/timer/translations/fr.json
index 7c15fdc8dd6..6f9194f9bbf 100644
--- a/homeassistant/components/timer/translations/fr.json
+++ b/homeassistant/components/timer/translations/fr.json
@@ -1,9 +1,9 @@
{
"state": {
"_": {
- "active": "actif",
- "idle": "en veille",
- "paused": "en pause"
+ "active": "Actif",
+ "idle": "En veille",
+ "paused": "En pause"
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/timer/translations/pt-BR.json b/homeassistant/components/timer/translations/pt-BR.json
index 0e37123d3ef..ca201a79760 100644
--- a/homeassistant/components/timer/translations/pt-BR.json
+++ b/homeassistant/components/timer/translations/pt-BR.json
@@ -1,8 +1,8 @@
{
"state": {
"_": {
- "active": "ativo",
- "idle": "ocioso",
+ "active": "Ativo",
+ "idle": "Ocioso",
"paused": "Pausado"
}
}
diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py
index 014dbd37a4e..548cac6da92 100644
--- a/homeassistant/components/todoist/calendar.py
+++ b/homeassistant/components/todoist/calendar.py
@@ -503,12 +503,19 @@ class TodoistProjectData:
continue
due_date = _parse_due_date(task["due"])
if start_date < due_date < end_date:
+ if due_date.hour == 0 and due_date.minute == 0:
+ # If the due date has no time data, return just the date so that it
+ # will render correctly as an all day event on a calendar.
+ due_date_value = due_date.strftime("%Y-%m-%d")
+ else:
+ due_date_value = due_date.isoformat()
event = {
"uid": task["id"],
"title": task["content"],
- "start": due_date.isoformat(),
- "end": due_date.isoformat(),
+ "start": due_date_value,
+ "end": due_date_value,
"allDay": True,
+ "summary": task["content"],
}
events.append(event)
return events
diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py
index bdfe8e35c74..e2171f02290 100644
--- a/homeassistant/components/toon/__init__.py
+++ b/homeassistant/components/toon/__init__.py
@@ -48,7 +48,7 @@ CONFIG_SCHEMA = vol.Schema(
vol.Required(CONF_CLIENT_SECRET): cv.string,
vol.Optional(
CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL
- ): vol.All(cv.time_period, cv.positive_timedelta),
+ ): cv.positive_time_period,
}
),
)
diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py
index c814134f767..a1afaf9f9b0 100644
--- a/homeassistant/components/toon/const.py
+++ b/homeassistant/components/toon/const.py
@@ -12,9 +12,9 @@ from homeassistant.const import (
ATTR_NAME,
ATTR_UNIT_OF_MEASUREMENT,
ENERGY_KILO_WATT_HOUR,
+ PERCENTAGE,
POWER_WATT,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
DOMAIN = "toon"
@@ -326,7 +326,7 @@ SENSOR_ENTITIES = {
ATTR_NAME: "Boiler Modulation Level",
ATTR_SECTION: "thermostat",
ATTR_MEASUREMENT: "current_modulation_level",
- ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE,
+ ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE,
ATTR_DEVICE_CLASS: None,
ATTR_ICON: "mdi:percent",
ATTR_DEFAULT_ENABLED: False,
@@ -335,7 +335,7 @@ SENSOR_ENTITIES = {
ATTR_NAME: "Current Power Usage Covered By Solar",
ATTR_SECTION: "power_usage",
ATTR_MEASUREMENT: "current_covered_by_solar",
- ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE,
+ ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE,
ATTR_DEVICE_CLASS: None,
ATTR_ICON: "mdi:solar-power",
ATTR_DEFAULT_ENABLED: True,
diff --git a/homeassistant/components/toon/coordinator.py b/homeassistant/components/toon/coordinator.py
index fa4cf52a630..359cb5b0ffb 100644
--- a/homeassistant/components/toon/coordinator.py
+++ b/homeassistant/components/toon/coordinator.py
@@ -141,4 +141,4 @@ class ToonDataUpdateCoordinator(DataUpdateCoordinator[Status]):
try:
return await self.toon.update()
except ToonError as error:
- raise UpdateFailed(f"Invalid response from API: {error}")
+ raise UpdateFailed(f"Invalid response from API: {error}") from error
diff --git a/homeassistant/components/toon/models.py b/homeassistant/components/toon/models.py
index 441b718c40a..4047ac0d744 100644
--- a/homeassistant/components/toon/models.py
+++ b/homeassistant/components/toon/models.py
@@ -2,7 +2,7 @@
import logging
from typing import Any, Dict, Optional
-from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import ToonDataUpdateCoordinator
@@ -10,7 +10,7 @@ from .coordinator import ToonDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
-class ToonEntity(Entity):
+class ToonEntity(CoordinatorEntity):
"""Defines a base Toon entity."""
def __init__(
@@ -22,11 +22,11 @@ class ToonEntity(Entity):
enabled_default: bool = True,
) -> None:
"""Initialize the Toon entity."""
+ super().__init__(coordinator)
self._enabled_default = enabled_default
self._icon = icon
self._name = name
self._state = None
- self.coordinator = coordinator
@property
def name(self) -> str:
@@ -38,31 +38,11 @@ class ToonEntity(Entity):
"""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.async_on_remove(
- self.coordinator.async_add_listener(self.async_write_ha_state)
- )
-
- async def async_update(self) -> None:
- """Update Toon entity."""
- await self.coordinator.async_request_refresh()
-
class ToonDisplayDeviceEntity(ToonEntity):
"""Defines a Toon display device entity."""
diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json
index 05eef817d28..c5ac07516b6 100644
--- a/homeassistant/components/toon/strings.json
+++ b/homeassistant/components/toon/strings.json
@@ -17,7 +17,8 @@
"authorize_url_fail": "Unknown error generating an authorize url.",
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
- "no_agreements": "This account has no Toon displays."
+ "no_agreements": "This account has no Toon displays.",
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]"
}
}
}
diff --git a/homeassistant/components/toon/translations/bg.json b/homeassistant/components/toon/translations/bg.json
index 0108f532d6a..11793c5bb5d 100644
--- a/homeassistant/components/toon/translations/bg.json
+++ b/homeassistant/components/toon/translations/bg.json
@@ -1,33 +1,7 @@
{
"config": {
"abort": {
- "client_id": "\u041a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u0438\u044f\u0442 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u043e\u0442 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u0435 \u043d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d.",
- "client_secret": "\u041a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u0430\u0442\u0430 \u043f\u0430\u0440\u043e\u043b\u0430 \u043e\u0442 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u0435 \u043d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0430.",
- "no_agreements": "\u0422\u043e\u0437\u0438 \u043f\u0440\u043e\u0444\u0438\u043b \u043d\u044f\u043c\u0430 Toon \u0434\u0438\u0441\u043f\u043b\u0435\u0438.",
- "no_app": "\u0422\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 Toon, \u043f\u0440\u0435\u0434\u0438 \u0434\u0430 \u043c\u043e\u0436\u0435\u0442\u0435 \u0434\u0430 \u0441\u0435 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u0446\u0438\u0440\u0430\u0442\u0435. [\u041c\u043e\u043b\u044f, \u043f\u0440\u043e\u0447\u0435\u0442\u0435\u0442\u0435 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u0438\u0442\u0435] (https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "\u0412\u044a\u0437\u043d\u0438\u043a\u043d\u0430 \u043d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f."
- },
- "error": {
- "credentials": "\u041f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u0435\u043d\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u0438 \u0441\u0430 \u043d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0438.",
- "display_exists": "\u0418\u0437\u0431\u0440\u0430\u043d\u0438\u044f\u0442 \u0434\u0438\u0441\u043f\u043b\u0435\u0439 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d."
- },
- "step": {
- "authenticate": {
- "data": {
- "password": "\u041f\u0430\u0440\u043e\u043b\u0430",
- "tenant": "\u041d\u0430\u0435\u043c\u0430\u0442\u0435\u043b",
- "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435"
- },
- "description": "\u0423\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0441 \u0412\u0430\u0448\u0438\u044f Eneco Toon \u043f\u0440\u043e\u0444\u0438\u043b (\u043d\u0435 \u043f\u0440\u043e\u0444\u0438\u043b\u0430 \u0437\u0430 \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u0446\u0438).",
- "title": "\u0421\u0432\u044a\u0440\u0436\u0435\u0442\u0435 \u0412\u0430\u0448\u0438\u044f \u0430\u043a\u0430\u0443\u043d\u0442 \u0432 \u0422\u043e\u043e\u043d"
- },
- "display": {
- "data": {
- "display": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0434\u0438\u0441\u043f\u043b\u0435\u0439"
- },
- "description": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0434\u0438\u0441\u043f\u043b\u0435\u044f \u043d\u0430 Toon, \u0441 \u043a\u043e\u0439\u0442\u043e \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0436\u0435\u0442\u0435.",
- "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0434\u0438\u0441\u043f\u043b\u0435\u0439"
- }
+ "no_agreements": "\u0422\u043e\u0437\u0438 \u043f\u0440\u043e\u0444\u0438\u043b \u043d\u044f\u043c\u0430 Toon \u0434\u0438\u0441\u043f\u043b\u0435\u0438."
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/toon/translations/ca.json b/homeassistant/components/toon/translations/ca.json
index 8b424b3d647..cec5d4ef851 100644
--- a/homeassistant/components/toon/translations/ca.json
+++ b/homeassistant/components/toon/translations/ca.json
@@ -1,44 +1,20 @@
{
"config": {
"abort": {
- "already_configured": "L\u2019acord seleccionat ja est\u00e0 configurat.",
+ "already_configured": "L'acord seleccionat ja est\u00e0 configurat.",
"authorize_url_fail": "S'ha produ\u00eft un error desconegut al generar l'URL d'autoritzaci\u00f3.",
"authorize_url_timeout": "Temps d'espera durant la generaci\u00f3 de l'URL d'autoritzaci\u00f3 esgotat.",
- "client_id": "L'identificador de client de la configuraci\u00f3 no \u00e9s v\u00e0lid.",
- "client_secret": "El codi secret de client de la configuraci\u00f3 no \u00e9s v\u00e0lid.",
"missing_configuration": "El component no est\u00e0 configurat. Mira'n la documentaci\u00f3.",
- "no_agreements": "Aquest compte no t\u00e9 pantalles Toon.",
- "no_app": "Has de configurar Toon abans de poder autenticar-t'hi. Llegeix les [instruccions](https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "S'ha produ\u00eft un error inesperat durant l'autenticaci\u00f3."
- },
- "error": {
- "credentials": "Les credencials proporcionades no s\u00f3n v\u00e0lides.",
- "display_exists": "La pantalla seleccionada ja est\u00e0 configurada."
+ "no_agreements": "Aquest compte no t\u00e9 pantalles Toon."
},
"step": {
"agreement": {
"data": {
"agreement": "Acord"
},
- "description": "Seleccioneu l'adre\u00e7a de l'acord a afegir.",
+ "description": "Selecciona l'adre\u00e7a de l'acord a afegir.",
"title": "Selecciona acord"
},
- "authenticate": {
- "data": {
- "password": "Contrasenya",
- "tenant": "Tenant",
- "username": "Nom d'usuari"
- },
- "description": "Autentica't amb el teu compte d'Eneco Toon (no el compte de desenvolupador).",
- "title": "Enlla\u00e7ar compte de Toon"
- },
- "display": {
- "data": {
- "display": "Tria la visualitzaci\u00f3"
- },
- "description": "Selecciona la pantalla Toon amb la qual vols connectar-te.",
- "title": "Selecci\u00f3 de pantalla"
- },
"pick_implementation": {
"title": "Tria amb quin vols autenticar-te"
}
diff --git a/homeassistant/components/toon/translations/cs.json b/homeassistant/components/toon/translations/cs.json
index ad1cdd5e76f..e514edf63aa 100644
--- a/homeassistant/components/toon/translations/cs.json
+++ b/homeassistant/components/toon/translations/cs.json
@@ -8,12 +8,6 @@
"agreement": {
"title": "Vyberte si smlouvu"
},
- "authenticate": {
- "data": {
- "password": "Heslo",
- "username": "U\u017eivatelsk\u00e9 jm\u00e9no"
- }
- },
"pick_implementation": {
"title": "Zvolte pomoc\u00ed kter\u00e9ho poskytovatele chcete slu\u017ebu ov\u011b\u0159it."
}
diff --git a/homeassistant/components/toon/translations/da.json b/homeassistant/components/toon/translations/da.json
index 73d18f22911..37215949d6f 100644
--- a/homeassistant/components/toon/translations/da.json
+++ b/homeassistant/components/toon/translations/da.json
@@ -1,33 +1,7 @@
{
"config": {
"abort": {
- "client_id": "Klient-id'et fra konfigurationen er ugyldigt.",
- "client_secret": "Klientens hemmelighed fra konfigurationen er ugyldig.",
- "no_agreements": "Denne konto har ingen Toon-sk\u00e6rme.",
- "no_app": "Du skal konfigurere Toon f\u00f8r du kan godkende med det. [L\u00e6s venligst vejledningen](https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Der opstod en uventet fejl under godkendelse."
- },
- "error": {
- "credentials": "De angivne legitimationsoplysninger er ugyldige.",
- "display_exists": "Den valgte sk\u00e6rm er allerede konfigureret."
- },
- "step": {
- "authenticate": {
- "data": {
- "password": "Adgangskode",
- "tenant": "Tenant",
- "username": "Brugernavn"
- },
- "description": "Godkend med din Eneco Toon-konto (ikke udviklerkontoen).",
- "title": "Forbind din Toon-konto"
- },
- "display": {
- "data": {
- "display": "V\u00e6lg sk\u00e6rm"
- },
- "description": "V\u00e6lg den Toon sk\u00e6rm, du vil oprette forbindelse til.",
- "title": "V\u00e6lg sk\u00e6rm"
- }
+ "no_agreements": "Denne konto har ingen Toon-sk\u00e6rme."
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/toon/translations/de.json b/homeassistant/components/toon/translations/de.json
index 0c4aee8cdbf..4f4dd8a0956 100644
--- a/homeassistant/components/toon/translations/de.json
+++ b/homeassistant/components/toon/translations/de.json
@@ -1,33 +1,7 @@
{
"config": {
"abort": {
- "client_id": "Die Client-ID aus der Konfiguration ist ung\u00fcltig.",
- "client_secret": "Das Client-Secret aus der Konfiguration ist ung\u00fcltig.",
- "no_agreements": "Dieses Konto hat keine Toon-Anzeigen.",
- "no_app": "Toon muss konfiguriert werden, bevor die Authentifizierung durchgef\u00fchrt werden kann. [Lies bitte die Anleitung](https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Beim Authentifizieren ist ein unerwarteter Fehler aufgetreten."
- },
- "error": {
- "credentials": "Die angegebenen Anmeldeinformationen sind ung\u00fcltig.",
- "display_exists": "Die ausgew\u00e4hlte Anzeige ist bereits konfiguriert."
- },
- "step": {
- "authenticate": {
- "data": {
- "password": "Passwort",
- "tenant": "Tenant",
- "username": "Benutzername"
- },
- "description": "Authentifiziere dich mit deinem Eneco Toon-Konto (nicht dem Entwicklerkonto).",
- "title": "Verkn\u00fcpfe dein Toon-Konto"
- },
- "display": {
- "data": {
- "display": "Anzeige w\u00e4hlen"
- },
- "description": "W\u00e4hle die Toon-Anzeige aus, die verbunden werden soll.",
- "title": "Anzeige ausw\u00e4hlen"
- }
+ "no_agreements": "Dieses Konto hat keine Toon-Anzeigen."
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/toon/translations/en.json b/homeassistant/components/toon/translations/en.json
index 0f0b5c5598b..b15caa77aba 100644
--- a/homeassistant/components/toon/translations/en.json
+++ b/homeassistant/components/toon/translations/en.json
@@ -4,16 +4,8 @@
"already_configured": "The selected agreement is already configured.",
"authorize_url_fail": "Unknown error generating an authorize url.",
"authorize_url_timeout": "Timeout generating authorize URL.",
- "client_id": "The client ID from the configuration is invalid.",
- "client_secret": "The client secret from the configuration is invalid.",
"missing_configuration": "The component is not configured. Please follow the documentation.",
- "no_agreements": "This account has no Toon displays.",
- "no_app": "You need to configure Toon before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Unexpected error occurred, while authenticating."
- },
- "error": {
- "credentials": "The provided credentials are invalid.",
- "display_exists": "The selected display is already configured."
+ "no_agreements": "This account has no Toon displays."
},
"step": {
"agreement": {
@@ -23,22 +15,6 @@
"description": "Select the agreement address you want to add.",
"title": "Select your agreement"
},
- "authenticate": {
- "data": {
- "password": "Password",
- "tenant": "Tenant",
- "username": "Username"
- },
- "description": "Authenticate with your Eneco Toon account (not the developer account).",
- "title": "Link your Toon account"
- },
- "display": {
- "data": {
- "display": "Choose display"
- },
- "description": "Select the Toon display to connect with.",
- "title": "Select display"
- },
"pick_implementation": {
"title": "Choose your tenant to authenticate with"
}
diff --git a/homeassistant/components/toon/translations/es-419.json b/homeassistant/components/toon/translations/es-419.json
index af39c29971d..3a8ca204d2e 100644
--- a/homeassistant/components/toon/translations/es-419.json
+++ b/homeassistant/components/toon/translations/es-419.json
@@ -1,33 +1,7 @@
{
"config": {
"abort": {
- "client_id": "La identificaci\u00f3n del cliente de la configuraci\u00f3n no es v\u00e1lida.",
- "client_secret": "El secreto del cliente de la configuraci\u00f3n no es v\u00e1lido.",
- "no_agreements": "Esta cuenta no tiene pantallas Toon.",
- "no_app": "Debe configurar Toon antes de poder autenticarse con \u00e9l. [Lea las instrucciones] (https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Ocurri\u00f3 un error inesperado, mientras se autenticaba."
- },
- "error": {
- "credentials": "Las credenciales proporcionadas no son v\u00e1lidas.",
- "display_exists": "La pantalla seleccionada ya est\u00e1 configurada."
- },
- "step": {
- "authenticate": {
- "data": {
- "password": "Contrase\u00f1a",
- "tenant": "Tenant",
- "username": "Nombre de usuario"
- },
- "description": "Autent\u00edquese con su cuenta de Eneco Toon (no con la cuenta de desarrollador).",
- "title": "Vincula tu cuenta de Toon"
- },
- "display": {
- "data": {
- "display": "Elegir pantalla"
- },
- "description": "Seleccione la pantalla Toon para conectarse.",
- "title": "Seleccionar pantalla"
- }
+ "no_agreements": "Esta cuenta no tiene pantallas Toon."
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/toon/translations/es.json b/homeassistant/components/toon/translations/es.json
index 4861abd8b92..28e8a1dcb61 100644
--- a/homeassistant/components/toon/translations/es.json
+++ b/homeassistant/components/toon/translations/es.json
@@ -4,16 +4,8 @@
"already_configured": "El acuerdo seleccionado ya est\u00e1 configurado.",
"authorize_url_fail": "Error desconocido generando una url de autorizaci\u00f3n",
"authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n.",
- "client_id": "El ID de cliente en la configuraci\u00f3n no es v\u00e1lido.",
- "client_secret": "El secreto de la configuraci\u00f3n no es v\u00e1lido.",
"missing_configuration": "El componente no est\u00e1 configurado. Consulta la documentaci\u00f3n.",
- "no_agreements": "Esta cuenta no tiene pantallas Toon.",
- "no_app": "Es necesario configurar Toon antes de poder autenticarse con \u00e9l. [Por favor, lee las instrucciones](https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Se ha producido un error inesperado al autenticar."
- },
- "error": {
- "credentials": "Las credenciales proporcionadas no son v\u00e1lidas.",
- "display_exists": "La pantalla seleccionada ya est\u00e1 configurada."
+ "no_agreements": "Esta cuenta no tiene pantallas Toon."
},
"step": {
"agreement": {
@@ -23,22 +15,6 @@
"description": "Selecciona la direcci\u00f3n del acuerdo que deseas a\u00f1adir.",
"title": "Selecciona tu acuerdo"
},
- "authenticate": {
- "data": {
- "password": "Contrase\u00f1a",
- "tenant": "Inquilino",
- "username": "Usuario"
- },
- "description": "Identif\u00edcate con tu cuenta de Eneco Toon (no con la cuenta de desarrollador).",
- "title": "Vincular tu cuenta Toon"
- },
- "display": {
- "data": {
- "display": "Elige una pantalla"
- },
- "description": "Selecciona la pantalla Toon que quieres conectar.",
- "title": "Seleccionar pantalla"
- },
"pick_implementation": {
"title": "Elige el arrendatario con el cual deseas autenticarte"
}
diff --git a/homeassistant/components/toon/translations/fi.json b/homeassistant/components/toon/translations/fi.json
deleted file mode 100644
index e269670b21b..00000000000
--- a/homeassistant/components/toon/translations/fi.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "config": {
- "step": {
- "authenticate": {
- "title": "Yhdist\u00e4 Toon-tili"
- },
- "display": {
- "data": {
- "display": "Valitse n\u00e4ytt\u00f6"
- },
- "title": "Valitse n\u00e4ytt\u00f6"
- }
- }
- }
-}
\ No newline at end of file
diff --git a/homeassistant/components/toon/translations/fr.json b/homeassistant/components/toon/translations/fr.json
index ec466a4745c..c3384f56319 100644
--- a/homeassistant/components/toon/translations/fr.json
+++ b/homeassistant/components/toon/translations/fr.json
@@ -4,16 +4,8 @@
"already_configured": "L'accord s\u00e9lectionn\u00e9 est d\u00e9j\u00e0 configur\u00e9.",
"authorize_url_fail": "Erreur inconnue lors de la g\u00e9n\u00e9ration d'une URL d'autorisation.",
"authorize_url_timeout": "Timout de g\u00e9n\u00e9ration de l'URL d'autorisation.",
- "client_id": "L'ID client de la configuration n'est pas valide.",
- "client_secret": "Le client secret de la configuration n'est pas valide.",
"missing_configuration": "The composant n'est pas configur\u00e9. Veuillez vous r\u00e9f\u00e9rer \u00e0 la documentation.",
- "no_agreements": "Ce compte n'a pas d'affichages Toon.",
- "no_app": "Vous devez configurer Toon avant de pouvoir vous authentifier avec celui-ci. [Veuillez lire les instructions] (https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Une erreur inattendue s'est produite lors de l'authentification."
- },
- "error": {
- "credentials": "Les informations d'identification fournies ne sont pas valides.",
- "display_exists": "L'affichage s\u00e9lectionn\u00e9 est d\u00e9j\u00e0 configur\u00e9."
+ "no_agreements": "Ce compte n'a pas d'affichages Toon."
},
"step": {
"agreement": {
@@ -23,22 +15,6 @@
"description": "S\u00e9lectionnez l'adresse d'accord que vous souhaitez ajouter.",
"title": "S\u00e9lectionnez votre accord"
},
- "authenticate": {
- "data": {
- "password": "Mot de passe",
- "tenant": "Locataire",
- "username": "Nom d'utilisateur"
- },
- "description": "Authentifiez-vous avec votre compte Eneco Toon (pas le compte d\u00e9veloppeur).",
- "title": "Lier un compte Toon"
- },
- "display": {
- "data": {
- "display": "Choisissez l'affichage"
- },
- "description": "S\u00e9lectionnez l'affichage Toon avec lequel vous connecter.",
- "title": "S\u00e9lectionnez l'affichage"
- },
"pick_implementation": {
"title": "Choisissez votre locataire pour vous authentifier"
}
diff --git a/homeassistant/components/toon/translations/hu.json b/homeassistant/components/toon/translations/hu.json
deleted file mode 100644
index 740e4bd381d..00000000000
--- a/homeassistant/components/toon/translations/hu.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "config": {
- "step": {
- "authenticate": {
- "data": {
- "password": "Jelsz\u00f3",
- "username": "Felhaszn\u00e1l\u00f3n\u00e9v"
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/homeassistant/components/toon/translations/it.json b/homeassistant/components/toon/translations/it.json
index 5e49e13bd7e..ed905ff899f 100644
--- a/homeassistant/components/toon/translations/it.json
+++ b/homeassistant/components/toon/translations/it.json
@@ -4,16 +4,8 @@
"already_configured": "L'accordo selezionato \u00e8 gi\u00e0 configurato.",
"authorize_url_fail": "Errore sconosciuto nel generare l'url di autorizzazione",
"authorize_url_timeout": "Tempo scaduto nel generare l'URL di autorizzazione.",
- "client_id": "L'ID client dalla configurazione non \u00e8 valido.",
- "client_secret": "Il client segreto della configurazione non \u00e8 valido.",
"missing_configuration": "Il componente non \u00e8 configurato. Si prega di seguire la documentazione.",
- "no_agreements": "Questo account non ha display Toon.",
- "no_app": "\u00c8 necessario configurare Toon prima di poter eseguire l'autenticazione con esso. [Si prega di leggere le istruzioni](https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Si \u00e8 verificato un errore imprevisto durante l'autenticazione."
- },
- "error": {
- "credentials": "Le credenziali fornite non sono valide.",
- "display_exists": "Il display selezionato \u00e8 gi\u00e0 configurato."
+ "no_agreements": "Questo account non ha display Toon."
},
"step": {
"agreement": {
@@ -23,22 +15,6 @@
"description": "Selezionare l'indirizzo del contratto che si desidera aggiungere.",
"title": "Seleziona il tuo contratto"
},
- "authenticate": {
- "data": {
- "password": "Password",
- "tenant": "Inquilino",
- "username": "Nome utente"
- },
- "description": "Autenticati con il tuo account Eneco Toon (non l'account sviluppatore).",
- "title": "Collega il tuo account Toon"
- },
- "display": {
- "data": {
- "display": "Seleziona il display"
- },
- "description": "Seleziona il display Toon con cui connettersi.",
- "title": "Seleziona il display"
- },
"pick_implementation": {
"title": "Scegliere il tenant con cui eseguire l'autenticazione"
}
diff --git a/homeassistant/components/toon/translations/ko.json b/homeassistant/components/toon/translations/ko.json
index c932126b9ed..bebd8bb912e 100644
--- a/homeassistant/components/toon/translations/ko.json
+++ b/homeassistant/components/toon/translations/ko.json
@@ -4,16 +4,8 @@
"already_configured": "\uc120\ud0dd\ub41c \uc57d\uc815\uc740 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
"authorize_url_fail": "\uc778\uc99d url \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.",
"authorize_url_timeout": "\uc778\uc99d URL \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
- "client_id": "\ud074\ub77c\uc774\uc5b8\ud2b8 ID \uac00 \uc720\ud6a8\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.",
- "client_secret": "\ud074\ub77c\uc774\uc5b8\ud2b8 \uc2dc\ud06c\ub9bf\uc774 \uc720\ud6a8\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.",
"missing_configuration": "\uad6c\uc131\uc694\uc18c\uac00 \uad6c\uc131\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \uc124\uba85\uc11c\ub97c \ucc38\uace0\ud574\uc8fc\uc138\uc694.",
- "no_agreements": "\uc774 \uacc4\uc815\uc5d0\ub294 Toon \ub514\uc2a4\ud50c\ub808\uc774\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.",
- "no_app": "Toon \uc744 \uc778\uc99d\ud558\ub824\uba74 \uba3c\uc800 Toon \uc744 \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/toon/) \ub97c \uc77d\uc5b4\ubcf4\uc138\uc694.",
- "unknown_auth_fail": "\uc778\uc99d\ud558\ub294 \ub3d9\uc548 \uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4."
- },
- "error": {
- "credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ud639\uc740 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
- "display_exists": "\uc120\ud0dd\ub41c \ub514\uc2a4\ud50c\ub808\uc774\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
+ "no_agreements": "\uc774 \uacc4\uc815\uc5d0\ub294 Toon \ub514\uc2a4\ud50c\ub808\uc774\uac00 \uc5c6\uc2b5\ub2c8\ub2e4."
},
"step": {
"agreement": {
@@ -23,22 +15,6 @@
"description": "\ucd94\uac00\ud560 \uc57d\uc815 \uc8fc\uc18c\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.",
"title": "\uc57d\uc815 \uc120\ud0dd\ud558\uae30"
},
- "authenticate": {
- "data": {
- "password": "\ube44\ubc00\ubc88\ud638",
- "tenant": "\uac70\uc8fc\uc790",
- "username": "\uc0ac\uc6a9\uc790 \uc774\ub984"
- },
- "description": "Eneco Toon \uacc4\uc815\uc73c\ub85c \uc778\uc99d\ud574\uc8fc\uc138\uc694. (\uac1c\ubc1c\uc790 \uacc4\uc815 \uc544\ub2d8)",
- "title": "Toon \uacc4\uc815 \uc5f0\uacb0\ud558\uae30"
- },
- "display": {
- "data": {
- "display": "\ub514\uc2a4\ud50c\ub808\uc774 \uc120\ud0dd"
- },
- "description": "\uc5f0\uacb0\ud560 Toon \ub514\uc2a4\ud50c\ub808\uc774\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.",
- "title": "\ub514\uc2a4\ud50c\ub808\uc774 \uc120\ud0dd\ud558\uae30"
- },
"pick_implementation": {
"title": "\uc778\uc99d \ub300\uc0c1 \uc0ac\uc6a9\uc790 \uc120\ud0dd\ud558\uae30"
}
diff --git a/homeassistant/components/toon/translations/lb.json b/homeassistant/components/toon/translations/lb.json
index 9c0d3711574..5d7095d0c85 100644
--- a/homeassistant/components/toon/translations/lb.json
+++ b/homeassistant/components/toon/translations/lb.json
@@ -4,16 +4,8 @@
"already_configured": "Den ausgewielten Accord ass scho konfigur\u00e9iert.",
"authorize_url_fail": "Onbekannte Feeler beim gener\u00e9ieren vun der Autorisatiouns URL.",
"authorize_url_timeout": "Z\u00e4itiwwerschraidung beim erstellen vun der Autorisatioun's URL.",
- "client_id": "Client ID vun der Konfiguratioun ass ong\u00eblteg.",
- "client_secret": "Client Passwuert vun der Konfiguratioun ass ong\u00eblteg.",
"missing_configuration": "Komponent ass net konfigur\u00e9iert. Folleg der Dokumentatioun.",
- "no_agreements": "D\u00ebse Kont huet keen Toon Ecran.",
- "no_app": "Dir musst Toon konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Onerwaarte Feeler bei der Authentifikatioun."
- },
- "error": {
- "credentials": "Ong\u00eblteg Login Informatioune.",
- "display_exists": "Den ausgewielten Ecran ass scho konfigur\u00e9iert."
+ "no_agreements": "D\u00ebse Kont huet keen Toon Ecran."
},
"step": {
"agreement": {
@@ -23,22 +15,6 @@
"description": "Wiel d'Adress vum Accord aus dee dob\u00e4igesaat soll ginn.",
"title": "D\u00e4in Accord auswielen"
},
- "authenticate": {
- "data": {
- "password": "Passwuert",
- "tenant": "Notzer",
- "username": "Benotzernumm"
- },
- "description": "Authentifikatioun mat \u00e4rem Eneco Toon Kont (net de Kont vum Entw\u00e9ckler)",
- "title": "Toon Kont verbannnen"
- },
- "display": {
- "data": {
- "display": "Ecran auswielen"
- },
- "description": "Wielt den Toon Ecran aus fir sech domat ze verbannen.",
- "title": "Ecran auswielen"
- },
"pick_implementation": {
"title": "Wielt de Locataire aus mat deem sech authentifiz\u00e9iert g\u00ebtt"
}
diff --git a/homeassistant/components/toon/translations/nl.json b/homeassistant/components/toon/translations/nl.json
index bcced85e29d..69eabaaf28b 100644
--- a/homeassistant/components/toon/translations/nl.json
+++ b/homeassistant/components/toon/translations/nl.json
@@ -1,33 +1,7 @@
{
"config": {
"abort": {
- "client_id": "De client ID uit de configuratie is ongeldig.",
- "client_secret": "De client secret uit de configuratie is ongeldig.",
- "no_agreements": "Dit account heeft geen Toon schermen.",
- "no_app": "Je moet Toon configureren voordat je ermee kunt aanmelden. [Lees de instructies](https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Onverwachte fout tijdens het verifi\u00ebren."
- },
- "error": {
- "credentials": "De opgegeven inloggegevens zijn ongeldig.",
- "display_exists": "Het gekozen scherm is al geconfigureerd."
- },
- "step": {
- "authenticate": {
- "data": {
- "password": "Wachtwoord",
- "tenant": "Huurder",
- "username": "Gebruikersnaam"
- },
- "description": "Verifieer met je Eneco Toon account (niet het ontwikkelaars account).",
- "title": "Link je Toon-account"
- },
- "display": {
- "data": {
- "display": "Kies scherm"
- },
- "description": "Kies het Toon-scherm om mee te verbinden.",
- "title": "Kies scherm"
- }
+ "no_agreements": "Dit account heeft geen Toon schermen."
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/toon/translations/no.json b/homeassistant/components/toon/translations/no.json
index b67f7386a22..37652c4aee1 100644
--- a/homeassistant/components/toon/translations/no.json
+++ b/homeassistant/components/toon/translations/no.json
@@ -4,16 +4,8 @@
"already_configured": "Den valgte avtalen er allerede konfigurert.",
"authorize_url_fail": "Ukjent feil ved generering av autoriseringsadresse.",
"authorize_url_timeout": "Tidsavbrudd som genererer autorer URL-adresse.",
- "client_id": "Klient ID fra konfigurasjonen er ugyldig.",
- "client_secret": "Klient hemmeligheten fra konfigurasjonen er ugyldig.",
"missing_configuration": "Komponenten er ikke konfigurert. Vennligst f\u00f8lg dokumentasjonen.",
- "no_agreements": "Denne kontoen har ingen Toon skjermer.",
- "no_app": "Du m\u00e5 konfigurere Toon f\u00f8r du kan autentisere den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Det oppstod en uventet feil under godkjenning."
- },
- "error": {
- "credentials": "De oppgitte legitimasjonene er ugyldige.",
- "display_exists": "Den valgte skjermen er allerede konfigurert."
+ "no_agreements": "Denne kontoen har ingen Toon skjermer."
},
"step": {
"agreement": {
@@ -23,22 +15,6 @@
"description": "Velg avtalsadressen du vil legge til.",
"title": "Velg din avtale"
},
- "authenticate": {
- "data": {
- "password": "Passord",
- "tenant": "Leietaker",
- "username": "Brukernavn"
- },
- "description": "Godkjenn med Eneco Toon kontoen din (ikke utviklerkontoen).",
- "title": "Koble til din Toon konto"
- },
- "display": {
- "data": {
- "display": "Velg skjerm"
- },
- "description": "Velg Toon skjerm \u00e5 koble til.",
- "title": "Velg skjerm"
- },
"pick_implementation": {
"title": "Velg din leietaker til \u00e5 autentisere med"
}
diff --git a/homeassistant/components/toon/translations/pl.json b/homeassistant/components/toon/translations/pl.json
index 43dc3d635c4..dba8c957a3b 100644
--- a/homeassistant/components/toon/translations/pl.json
+++ b/homeassistant/components/toon/translations/pl.json
@@ -2,38 +2,14 @@
"config": {
"abort": {
"authorize_url_timeout": "Przekroczono limit czasu generowania URL autoryzacji.",
- "client_id": "Identyfikator klienta z konfiguracji jest nieprawid\u0142owy.",
- "client_secret": "Tajny klucz klienta z konfiguracji jest nieprawid\u0142owy.",
"missing_configuration": "Komponent nie jest skonfigurowany. Post\u0119puj zgodnie z dokumentacj\u0105.",
- "no_agreements": "To konto nie posiada wy\u015bwietlaczy Toon.",
- "no_app": "Musisz skonfigurowa\u0107 Toon, aby m\u00f3c si\u0119 z nim uwierzytelni\u0107. Zapoznaj si\u0119 z [instrukcj\u0105](https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Nieoczekiwany b\u0142\u0105d podczas uwierzytelniania."
- },
- "error": {
- "credentials": "Wprowadzone dane logowania s\u0105 nieprawid\u0142owe.",
- "display_exists": "Wybrany ekran jest ju\u017c skonfigurowany."
+ "no_agreements": "To konto nie posiada wy\u015bwietlaczy Toon."
},
"step": {
"agreement": {
"data": {
"agreement": "Umowa"
}
- },
- "authenticate": {
- "data": {
- "password": "Has\u0142o",
- "tenant": "Najemca",
- "username": "Nazwa u\u017cytkownika"
- },
- "description": "Uwierzytelnij konto Eneco Toon (nie konto programisty).",
- "title": "Po\u0142\u0105cz konto Toon"
- },
- "display": {
- "data": {
- "display": "Wybierz wy\u015bwietlacz"
- },
- "description": "Wybierz wy\u015bwietlacz Toon, z kt\u00f3rym chcesz si\u0119 po\u0142\u0105czy\u0107.",
- "title": "Wybierz wy\u015bwietlacz"
}
}
}
diff --git a/homeassistant/components/toon/translations/pt-BR.json b/homeassistant/components/toon/translations/pt-BR.json
index 3e6829f6596..2e12ac49a8a 100644
--- a/homeassistant/components/toon/translations/pt-BR.json
+++ b/homeassistant/components/toon/translations/pt-BR.json
@@ -1,33 +1,7 @@
{
"config": {
"abort": {
- "client_id": "O ID do cliente da configura\u00e7\u00e3o \u00e9 inv\u00e1lido.",
- "client_secret": "O segredo do cliente da configura\u00e7\u00e3o \u00e9 inv\u00e1lido.",
- "no_agreements": "Esta conta n\u00e3o possui exibi\u00e7\u00f5es Toon.",
- "no_app": "Voc\u00ea precisa configurar o Toon antes de poder autenticar com ele. [Por favor, leia as instru\u00e7\u00f5es] (https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Ocorreu um erro inesperado durante a autentica\u00e7\u00e3o."
- },
- "error": {
- "credentials": "As credenciais fornecidas s\u00e3o inv\u00e1lidas.",
- "display_exists": "A exibi\u00e7\u00e3o selecionada j\u00e1 est\u00e1 configurada."
- },
- "step": {
- "authenticate": {
- "data": {
- "password": "Senha",
- "tenant": "Inquilino",
- "username": "Usu\u00e1rio"
- },
- "description": "Autentique-se com sua conta Eneco Toon (n\u00e3o com a conta do desenvolvedor).",
- "title": "Vincule sua conta Toon"
- },
- "display": {
- "data": {
- "display": "Escolha a exibi\u00e7\u00e3o"
- },
- "description": "Selecione a exibi\u00e7\u00e3o Toon para se conectar.",
- "title": "Selecione a exibi\u00e7\u00e3o"
- }
+ "no_agreements": "Esta conta n\u00e3o possui exibi\u00e7\u00f5es Toon."
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/toon/translations/pt.json b/homeassistant/components/toon/translations/pt.json
index 8ef1e99d12c..9ecaef216f9 100644
--- a/homeassistant/components/toon/translations/pt.json
+++ b/homeassistant/components/toon/translations/pt.json
@@ -2,13 +2,7 @@
"config": {
"abort": {
"authorize_url_fail": "Erro desconhecido ao gerar um URL de autoriza\u00e7\u00e3o.",
- "authorize_url_timeout": "Tempo excedido a gerar um URL de autoriza\u00e7\u00e3o",
- "client_id": "O ID do cliente da configura\u00e7\u00e3o \u00e9 inv\u00e1lido.",
- "client_secret": "O segredo do cliente da configura\u00e7\u00e3o \u00e9 inv\u00e1lido.",
- "unknown_auth_fail": "Ocorreu um erro inesperado durante a autentica\u00e7\u00e3o."
- },
- "error": {
- "credentials": "As credenciais fornecidas s\u00e3o inv\u00e1lidas."
+ "authorize_url_timeout": "Tempo excedido a gerar um URL de autoriza\u00e7\u00e3o"
},
"step": {
"agreement": {
@@ -16,16 +10,6 @@
"agreement": "Acordo"
},
"title": "Seleccione o seu acordo"
- },
- "authenticate": {
- "data": {
- "password": "Palavra-passe",
- "tenant": "Inquilino",
- "username": "Nome de Utilizador"
- }
- },
- "display": {
- "title": "Seleccionar visor"
}
}
}
diff --git a/homeassistant/components/toon/translations/ru.json b/homeassistant/components/toon/translations/ru.json
index 7bdaf0eb08e..b5ae66ee54e 100644
--- a/homeassistant/components/toon/translations/ru.json
+++ b/homeassistant/components/toon/translations/ru.json
@@ -4,16 +4,8 @@
"already_configured": "\u0412\u044b\u0431\u0440\u0430\u043d\u043d\u043e\u0435 \u0441\u043e\u0433\u043b\u0430\u0448\u0435\u043d\u0438\u0435 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u043e.",
"authorize_url_fail": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.",
"authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.",
- "client_id": "Client ID \u0438\u0437 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d.",
- "client_secret": "Client secret \u0438\u0437 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d.",
"missing_configuration": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438.",
- "no_agreements": "\u0423 \u044d\u0442\u043e\u0439 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 \u043d\u0435\u0442 \u0434\u0438\u0441\u043f\u043b\u0435\u0435\u0432 Toon.",
- "no_app": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 Toon \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "\u0412\u043e \u0432\u0440\u0435\u043c\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u043f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
- },
- "error": {
- "credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435.",
- "display_exists": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ "no_agreements": "\u0423 \u044d\u0442\u043e\u0439 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 \u043d\u0435\u0442 \u0434\u0438\u0441\u043f\u043b\u0435\u0435\u0432 Toon."
},
"step": {
"agreement": {
@@ -23,22 +15,6 @@
"description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u0441\u043e\u0433\u043b\u0430\u0448\u0435\u043d\u0438\u044f, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c.",
"title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0412\u0430\u0448\u0435 \u0441\u043e\u0433\u043b\u0430\u0448\u0435\u043d\u0438\u0435"
},
- "authenticate": {
- "data": {
- "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
- "tenant": "\u0412\u043b\u0430\u0434\u0435\u043b\u0435\u0446",
- "username": "\u041b\u043e\u0433\u0438\u043d"
- },
- "description": "\u0412\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u0441\u0432\u043e\u0435\u0439 \u0443\u0447\u0451\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Eneco Toon (\u043d\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0443\u0447\u0451\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0430).",
- "title": "\u041f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0443\u0447\u0451\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Toon"
- },
- "display": {
- "data": {
- "display": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0434\u0438\u0441\u043f\u043b\u0435\u0439"
- },
- "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0434\u0438\u0441\u043f\u043b\u0435\u0439 Toon \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.",
- "title": "Toon"
- },
"pick_implementation": {
"title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0430\u0440\u0435\u043d\u0434\u0430\u0442\u043e\u0440\u0430 \u0434\u043b\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438"
}
diff --git a/homeassistant/components/toon/translations/sl.json b/homeassistant/components/toon/translations/sl.json
index f86289d5a08..1883a5ab055 100644
--- a/homeassistant/components/toon/translations/sl.json
+++ b/homeassistant/components/toon/translations/sl.json
@@ -1,33 +1,7 @@
{
"config": {
"abort": {
- "client_id": "ID odjemalca iz konfiguracije je neveljaven.",
- "client_secret": "Skrivnost iz konfiguracije odjemalca ni veljaven.",
- "no_agreements": "Ta ra\u010dun nima prikazov Toon.",
- "no_app": "Toon morate konfigurirati, preden ga boste lahko uporabili za overitev. [Preberite navodila] (https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Med preverjanjem pristnosti je pri\u0161lo do nepri\u010dakovane napake."
- },
- "error": {
- "credentials": "Navedene poverilnice niso veljavne.",
- "display_exists": "Izbrani zaslon je \u017ee konfiguriran."
- },
- "step": {
- "authenticate": {
- "data": {
- "password": "Geslo",
- "tenant": "Najemnik",
- "username": "Uporabni\u0161ko ime"
- },
- "description": "Prijavite se s svojim Eneco toon ra\u010dunom (ne razvijalskim).",
- "title": "Pove\u017eite svoj Toon ra\u010dun"
- },
- "display": {
- "data": {
- "display": "Izberite zaslon"
- },
- "description": "Izberite zaslon Toon, s katerim se \u017eelite povezati.",
- "title": "Izberite zaslon"
- }
+ "no_agreements": "Ta ra\u010dun nima prikazov Toon."
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/toon/translations/sv.json b/homeassistant/components/toon/translations/sv.json
index bb1c89e1e3c..034f5f41e68 100644
--- a/homeassistant/components/toon/translations/sv.json
+++ b/homeassistant/components/toon/translations/sv.json
@@ -1,33 +1,7 @@
{
"config": {
"abort": {
- "client_id": "Client ID fr\u00e5n konfiguration \u00e4r ogiltig.",
- "client_secret": "Client secret fr\u00e5n konfigurationen \u00e4r ogiltig.",
- "no_agreements": "Det h\u00e4r kontot har inga Toon-sk\u00e4rmar.",
- "no_app": "Du m\u00e5ste konfigurera Toon innan du kan autentisera med den. [L\u00e4s instruktioner] (https://www.home-assistant.io/components/toon/).",
- "unknown_auth_fail": "Ov\u00e4ntat fel uppstod under autentisering."
- },
- "error": {
- "credentials": "De angivna uppgifterna \u00e4r ogiltiga.",
- "display_exists": "Den valda sk\u00e4rmen \u00e4r redan konfigurerad"
- },
- "step": {
- "authenticate": {
- "data": {
- "password": "L\u00f6senord",
- "tenant": "Hyresg\u00e4st",
- "username": "Anv\u00e4ndarnamn"
- },
- "description": "Autentisera med ditt Eneco Toon-konto (inte developer-kontot).",
- "title": "L\u00e4nk ditt Toon-konto"
- },
- "display": {
- "data": {
- "display": "V\u00e4lj sk\u00e4rm"
- },
- "description": "V\u00e4lj Toon-sk\u00e4rm att ansluta till.",
- "title": "V\u00e4lj sk\u00e4rm"
- }
+ "no_agreements": "Det h\u00e4r kontot har inga Toon-sk\u00e4rmar."
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/toon/translations/th.json b/homeassistant/components/toon/translations/th.json
deleted file mode 100644
index 896d9ba8176..00000000000
--- a/homeassistant/components/toon/translations/th.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "config": {
- "step": {
- "authenticate": {
- "data": {
- "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19",
- "username": "\u0e0a\u0e37\u0e48\u0e2d\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49"
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/homeassistant/components/toon/translations/zh-Hans.json b/homeassistant/components/toon/translations/zh-Hans.json
deleted file mode 100644
index 94fe18d1656..00000000000
--- a/homeassistant/components/toon/translations/zh-Hans.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
- "config": {
- "step": {
- "authenticate": {
- "data": {
- "password": "\u5bc6\u7801",
- "username": "\u7528\u6237\u540d"
- }
- }
- }
- }
-}
\ No newline at end of file
diff --git a/homeassistant/components/toon/translations/zh-Hant.json b/homeassistant/components/toon/translations/zh-Hant.json
index e9aa599f079..a6d5227afc6 100644
--- a/homeassistant/components/toon/translations/zh-Hant.json
+++ b/homeassistant/components/toon/translations/zh-Hant.json
@@ -4,16 +4,8 @@
"already_configured": "\u6240\u9078\u64c7\u7684\u5354\u8b70\u5730\u5740\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002",
"authorize_url_fail": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4",
"authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642\u3002",
- "client_id": "\u8a2d\u5b9a\u5167\u7528\u6236\u7aef ID \u7121\u6548\u3002",
- "client_secret": "\u8a2d\u5b9a\u5167\u5ba2\u6236\u7aef\u5bc6\u78bc\u7121\u6548\u3002",
"missing_configuration": "\u5143\u4ef6\u5c1a\u672a\u8a2d\u7f6e\uff0c\u8acb\u53c3\u95b1\u6587\u4ef6\u8aaa\u660e\u3002",
- "no_agreements": "\u6b64\u5e33\u865f\u4e26\u672a\u64c1\u6709 Toon \u986f\u793a\u8a2d\u5099\u3002",
- "no_app": "\u5fc5\u9808\u5148\u8a2d\u5b9a Toon \u65b9\u80fd\u9032\u884c\u8a8d\u8b49\u3002[\u8acb\u53c3\u95b1\u6559\u5b78\u6307\u5f15](https://www.home-assistant.io/components/toon/(\u3002",
- "unknown_auth_fail": "\u9a57\u8b49\u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002"
- },
- "error": {
- "credentials": "\u6240\u63d0\u4f9b\u7684\u6191\u8b49\u7121\u6548\u3002",
- "display_exists": "\u6240\u9078\u64c7\u7684\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002"
+ "no_agreements": "\u6b64\u5e33\u865f\u4e26\u672a\u64c1\u6709 Toon \u986f\u793a\u8a2d\u5099\u3002"
},
"step": {
"agreement": {
@@ -23,22 +15,6 @@
"description": "\u9078\u64c7\u6240\u8981\u65b0\u589e\u7684\u5354\u8b70\u5730\u5740\u3002",
"title": "\u9078\u64c7\u5354\u8b70\u5730\u5740"
},
- "authenticate": {
- "data": {
- "password": "\u5bc6\u78bc",
- "tenant": "\u79df\u7528",
- "username": "\u4f7f\u7528\u8005\u540d\u7a31"
- },
- "description": "\u4f7f\u7528 Eneco Toon \u5e33\u865f\uff08\u975e\u958b\u767c\u8005\u5e33\u865f\uff09\u9032\u884c\u9a57\u8b49\u3002",
- "title": "\u9023\u7d50 Toon \u5e33\u865f"
- },
- "display": {
- "data": {
- "display": "\u9078\u64c7\u8a2d\u5099"
- },
- "description": "\u9078\u64c7\u6240\u8981\u9023\u63a5\u7684 Toon display\u3002",
- "title": "\u9078\u64c7\u8a2d\u5099"
- },
"pick_implementation": {
"title": "\u9078\u64c7\u6240\u8981\u8a8d\u8b49\u7684\u5730\u5740"
}
diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py
index 25b1141bd9b..cf3f059cfb9 100644
--- a/homeassistant/components/totalconnect/__init__.py
+++ b/homeassistant/components/totalconnect/__init__.py
@@ -36,7 +36,9 @@ async def async_setup(hass: HomeAssistant, config: dict):
hass.async_create_task(
hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN],
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=config[DOMAIN],
)
)
diff --git a/homeassistant/components/totalconnect/translations/no.json b/homeassistant/components/totalconnect/translations/no.json
index c312f98f3d2..a8d6ac5dc23 100644
--- a/homeassistant/components/totalconnect/translations/no.json
+++ b/homeassistant/components/totalconnect/translations/no.json
@@ -11,8 +11,7 @@
"data": {
"password": "Passord",
"username": "Brukernavn"
- },
- "title": ""
+ }
}
}
}
diff --git a/homeassistant/components/totalconnect/translations/pt-BR.json b/homeassistant/components/totalconnect/translations/pt-BR.json
index 30b433108ec..e651bb03b44 100644
--- a/homeassistant/components/totalconnect/translations/pt-BR.json
+++ b/homeassistant/components/totalconnect/translations/pt-BR.json
@@ -8,6 +8,9 @@
},
"step": {
"user": {
+ "data": {
+ "username": "Usu\u00e1rio"
+ },
"title": "Total Connect"
}
}
diff --git a/homeassistant/components/totalconnect/translations/ru.json b/homeassistant/components/totalconnect/translations/ru.json
index b2ccada9e63..a3de1ee4555 100644
--- a/homeassistant/components/totalconnect/translations/ru.json
+++ b/homeassistant/components/totalconnect/translations/ru.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "already_configured": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430."
+ "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0432 Home Assistant."
},
"error": {
"login": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0432\u0445\u043e\u0434\u0430: \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043b\u043e\u0433\u0438\u043d \u0438 \u043f\u0430\u0440\u043e\u043b\u044c."
diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py
index 9c3d73b43b5..a5135704dad 100644
--- a/homeassistant/components/tplink/light.py
+++ b/homeassistant/components/tplink/light.py
@@ -191,14 +191,18 @@ class TPLinkSmartBulb(LightEntity):
await self._async_set_light_state_retry(
self._light_state,
self._light_state._replace(
- state=True, brightness=brightness, color_temp=color_tmp, hs=hue_sat,
+ state=True,
+ brightness=brightness,
+ color_temp=color_tmp,
+ hs=hue_sat,
),
)
async def async_turn_off(self, **kwargs):
"""Turn the light off."""
await self._async_set_light_state_retry(
- self._light_state, self._light_state._replace(state=False),
+ self._light_state,
+ self._light_state._replace(state=False),
)
@property
diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py
index 03482292135..5203ea5ec90 100644
--- a/homeassistant/components/traccar/__init__.py
+++ b/homeassistant/components/traccar/__init__.py
@@ -107,5 +107,4 @@ async def async_unload_entry(hass, entry):
return True
-# pylint: disable=invalid-name
async_remove_entry = config_entry_flow.webhook_async_remove_entry
diff --git a/homeassistant/components/traccar/translations/zh-Hant.json b/homeassistant/components/traccar/translations/zh-Hant.json
index c469020aef6..5135320f631 100644
--- a/homeassistant/components/traccar/translations/zh-Hant.json
+++ b/homeassistant/components/traccar/translations/zh-Hant.json
@@ -1,8 +1,8 @@
{
"config": {
"abort": {
- "not_internet_accessible": "Home Assistant \u7269\u4ef6\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 Traccar \u8a0a\u606f\u3002",
- "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002"
+ "not_internet_accessible": "Home Assistant \u8a2d\u5099\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 Traccar \u8a0a\u606f\u3002",
+ "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u8a2d\u5099\u5373\u53ef\u3002"
},
"create_entry": {
"default": "\u6b32\u50b3\u9001\u4e8b\u4ef6\u81f3 Home Assistant\uff0c\u5c07\u9700\u65bc Traccar \u5167\u8a2d\u5b9a webhook \u529f\u80fd\u3002\n\n\u8acb\u4f7f\u7528 url: `{webhook_url}`\n\n\u8acb\u53c3\u95b1 [\u6587\u4ef6]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u8a73\u7d30\u8cc7\u6599\u3002"
diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py
index cef22c636c1..002231e4e20 100644
--- a/homeassistant/components/tradfri/__init__.py
+++ b/homeassistant/components/tradfri/__init__.py
@@ -1,5 +1,5 @@
"""Support for IKEA Tradfri."""
-import logging
+import asyncio
from pytradfri import Gateway, RequestError
from pytradfri.api.aiocoap_api import APIFactory
@@ -24,13 +24,15 @@ from .const import (
CONF_KEY,
CONFIG_FILE,
DEFAULT_ALLOW_TRADFRI_GROUPS,
+ DEVICES,
DOMAIN,
+ GROUPS,
KEY_API,
- KEY_GATEWAY,
- TRADFRI_DEVICE_TYPES,
+ PLATFORMS,
)
-_LOGGER = logging.getLogger(__name__)
+FACTORY = "tradfri_factory"
+LISTENERS = "tradfri_listeners"
CONFIG_SCHEMA = vol.Schema(
{
@@ -95,8 +97,10 @@ async def async_setup(hass, config):
async def async_setup_entry(hass, entry):
"""Create a gateway."""
# host, identity, key, allow_tradfri_groups
+ tradfri_data = hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {}
+ listeners = tradfri_data[LISTENERS] = []
- factory = APIFactory(
+ factory = await APIFactory.init(
entry.data[CONF_HOST],
psk_id=entry.data[CONF_IDENTITY],
psk=entry.data[CONF_KEY],
@@ -106,19 +110,25 @@ async def async_setup_entry(hass, entry):
"""Close connection when hass stops."""
await factory.shutdown()
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
+ listeners.append(hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop))
api = factory.request
gateway = Gateway()
try:
gateway_info = await api(gateway.get_gateway_info())
- except RequestError:
+ devices_commands = await api(gateway.get_devices())
+ devices = await api(devices_commands)
+ groups_commands = await api(gateway.get_groups())
+ groups = await api(groups_commands)
+ except RequestError as err:
await factory.shutdown()
- raise ConfigEntryNotReady
+ raise ConfigEntryNotReady from err
- hass.data.setdefault(KEY_API, {})[entry.entry_id] = api
- hass.data.setdefault(KEY_GATEWAY, {})[entry.entry_id] = gateway
+ tradfri_data[KEY_API] = api
+ tradfri_data[FACTORY] = factory
+ tradfri_data[DEVICES] = devices
+ tradfri_data[GROUPS] = groups
dev_reg = await hass.helpers.device_registry.async_get_registry()
dev_reg.async_get_or_create(
@@ -132,9 +142,30 @@ async def async_setup_entry(hass, entry):
sw_version=gateway_info.firmware_version,
)
- for device in TRADFRI_DEVICE_TYPES:
+ for component in PLATFORMS:
hass.async_create_task(
- hass.config_entries.async_forward_entry_setup(entry, device)
+ hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
+
+
+async def async_unload_entry(hass, entry):
+ """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:
+ tradfri_data = hass.data[DOMAIN].pop(entry.entry_id)
+ factory = tradfri_data[FACTORY]
+ await factory.shutdown()
+ # unsubscribe listeners
+ for listener in tradfri_data[LISTENERS]:
+ listener()
+
+ return unload_ok
diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py
index 0850bec6c9b..0c9f2f7312f 100644
--- a/homeassistant/components/tradfri/base_class.py
+++ b/homeassistant/components/tradfri/base_class.py
@@ -1,4 +1,5 @@
"""Base class for IKEA TRADFRI."""
+from functools import wraps
import logging
from pytradfri.error import PytradfriError
@@ -11,6 +12,20 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
+def handle_error(func):
+ """Handle tradfri api call error."""
+
+ @wraps(func)
+ async def wrapper(command):
+ """Decorate api call."""
+ try:
+ await func(command)
+ except PytradfriError as err:
+ _LOGGER.error("Unable to execute command %s: %s", command, err)
+
+ return wrapper
+
+
class TradfriBaseClass(Entity):
"""Base class for IKEA TRADFRI.
@@ -19,7 +34,7 @@ class TradfriBaseClass(Entity):
def __init__(self, device, api, gateway_id):
"""Initialize a device."""
- self._api = api
+ self._api = handle_error(api)
self._device = None
self._device_control = None
self._device_data = None
diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py
index e438fd20170..7947c3ad6de 100644
--- a/homeassistant/components/tradfri/config_flow.py
+++ b/homeassistant/components/tradfri/config_flow.py
@@ -90,7 +90,7 @@ class FlowHandler(config_entries.ConfigFlow):
host = discovery_info["host"]
for entry in self._async_current_entries():
- if entry.data[CONF_HOST] != host:
+ if entry.data.get(CONF_HOST) != host:
continue
# Backwards compat, we update old entries
@@ -107,7 +107,7 @@ class FlowHandler(config_entries.ConfigFlow):
async def async_step_import(self, user_input):
"""Import a config entry."""
for entry in self._async_current_entries():
- if entry.data[CONF_HOST] == user_input["host"]:
+ if entry.data.get(CONF_HOST) == user_input["host"]:
return self.async_abort(reason="already_configured")
# Happens if user has host directly in configuration.yaml
@@ -141,8 +141,8 @@ class FlowHandler(config_entries.ConfigFlow):
same_hub_entries = [
entry.entry_id
for entry in self._async_current_entries()
- if entry.data[CONF_GATEWAY_ID] == gateway_id
- or entry.data[CONF_HOST] == host
+ if entry.data.get(CONF_GATEWAY_ID) == gateway_id
+ or entry.data.get(CONF_HOST) == host
]
if same_hub_entries:
@@ -161,15 +161,17 @@ async def authenticate(hass, host, security_code):
identity = uuid4().hex
- api_factory = APIFactory(host, psk_id=identity)
+ api_factory = await APIFactory.init(host, psk_id=identity)
try:
with async_timeout.timeout(5):
key = await api_factory.generate_psk(security_code)
- except RequestError:
- raise AuthError("invalid_security_code")
- except asyncio.TimeoutError:
- raise AuthError("timeout")
+ except RequestError as err:
+ raise AuthError("invalid_security_code") from err
+ except asyncio.TimeoutError as err:
+ raise AuthError("timeout") from err
+ finally:
+ await api_factory.shutdown()
return await get_gateway_info(hass, host, identity, key)
@@ -178,17 +180,17 @@ async def get_gateway_info(hass, host, identity, key):
"""Return info for the gateway."""
try:
- factory = APIFactory(host, psk_id=identity, psk=key)
+ factory = await APIFactory.init(host, psk_id=identity, psk=key)
api = factory.request
gateway = Gateway()
gateway_info_result = await api(gateway.get_gateway_info())
await factory.shutdown()
- except (OSError, RequestError):
+ except (OSError, RequestError) as err:
# We're also catching OSError as PyTradfri doesn't catch that one yet
# Upstream PR: https://github.com/ggravlingen/pytradfri/pull/189
- raise AuthError("cannot_connect")
+ raise AuthError("cannot_connect") from err
return {
CONF_HOST: host,
diff --git a/homeassistant/components/tradfri/const.py b/homeassistant/components/tradfri/const.py
index ffb5d64f6d7..f7c2bf6cbe5 100644
--- a/homeassistant/components/tradfri/const.py
+++ b/homeassistant/components/tradfri/const.py
@@ -19,8 +19,9 @@ CONFIG_FILE = ".tradfri_psk.conf"
DEFAULT_ALLOW_TRADFRI_GROUPS = False
DOMAIN = "tradfri"
KEY_API = "tradfri_api"
-KEY_GATEWAY = "tradfri_gateway"
+DEVICES = "tradfri_devices"
+GROUPS = "tradfri_groups"
KEY_SECURITY_CODE = "security_code"
SUPPORTED_GROUP_FEATURES = SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION
SUPPORTED_LIGHT_FEATURES = SUPPORT_TRANSITION
-TRADFRI_DEVICE_TYPES = ["cover", "light", "sensor", "switch"]
+PLATFORMS = ["cover", "light", "sensor", "switch"]
diff --git a/homeassistant/components/tradfri/cover.py b/homeassistant/components/tradfri/cover.py
index 6d8669eea91..2d99de7756a 100644
--- a/homeassistant/components/tradfri/cover.py
+++ b/homeassistant/components/tradfri/cover.py
@@ -3,17 +3,16 @@
from homeassistant.components.cover import ATTR_POSITION, CoverEntity
from .base_class import TradfriBaseDevice
-from .const import ATTR_MODEL, CONF_GATEWAY_ID, KEY_API, KEY_GATEWAY
+from .const import ATTR_MODEL, CONF_GATEWAY_ID, DEVICES, DOMAIN, KEY_API
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Load Tradfri covers based on a config entry."""
gateway_id = config_entry.data[CONF_GATEWAY_ID]
- api = hass.data[KEY_API][config_entry.entry_id]
- gateway = hass.data[KEY_GATEWAY][config_entry.entry_id]
+ tradfri_data = hass.data[DOMAIN][config_entry.entry_id]
+ api = tradfri_data[KEY_API]
+ devices = tradfri_data[DEVICES]
- devices_commands = await api(gateway.get_devices())
- devices = await api(devices_commands)
covers = [dev for dev in devices if dev.has_blind_control]
if covers:
async_add_entities(TradfriCover(cover, api, gateway_id) for cover in covers)
diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py
index 4e44b452d33..939968852d9 100644
--- a/homeassistant/components/tradfri/light.py
+++ b/homeassistant/components/tradfri/light.py
@@ -21,8 +21,10 @@ from .const import (
ATTR_TRANSITION_TIME,
CONF_GATEWAY_ID,
CONF_IMPORT_GROUPS,
+ DEVICES,
+ DOMAIN,
+ GROUPS,
KEY_API,
- KEY_GATEWAY,
SUPPORTED_GROUP_FEATURES,
SUPPORTED_LIGHT_FEATURES,
)
@@ -33,18 +35,16 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Load Tradfri lights based on a config entry."""
gateway_id = config_entry.data[CONF_GATEWAY_ID]
- api = hass.data[KEY_API][config_entry.entry_id]
- gateway = hass.data[KEY_GATEWAY][config_entry.entry_id]
+ tradfri_data = hass.data[DOMAIN][config_entry.entry_id]
+ api = tradfri_data[KEY_API]
+ devices = tradfri_data[DEVICES]
- devices_commands = await api(gateway.get_devices())
- devices = await api(devices_commands)
lights = [dev for dev in devices if dev.has_light_control]
if lights:
async_add_entities(TradfriLight(light, api, gateway_id) for light in lights)
if config_entry.data[CONF_IMPORT_GROUPS]:
- groups_commands = await api(gateway.get_groups())
- groups = await api(groups_commands)
+ groups = tradfri_data[GROUPS]
if groups:
async_add_entities(TradfriGroup(group, api, gateway_id) for group in groups)
diff --git a/homeassistant/components/tradfri/manifest.json b/homeassistant/components/tradfri/manifest.json
index 12457975eee..dace0f0739b 100644
--- a/homeassistant/components/tradfri/manifest.json
+++ b/homeassistant/components/tradfri/manifest.json
@@ -3,9 +3,15 @@
"name": "IKEA TRÅDFRI",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tradfri",
- "requirements": ["pytradfri[async]==6.4.0"],
+ "requirements": [
+ "pytradfri[async]==7.0.2"
+ ],
"homekit": {
- "models": ["TRADFRI"]
+ "models": [
+ "TRADFRI"
+ ]
},
- "codeowners": ["@ggravlingen"]
+ "codeowners": [
+ "@ggravlingen"
+ ]
}
diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py
index db12ab0a5cb..c2bf640e2aa 100644
--- a/homeassistant/components/tradfri/sensor.py
+++ b/homeassistant/components/tradfri/sensor.py
@@ -1,29 +1,28 @@
"""Support for IKEA Tradfri sensors."""
-from homeassistant.const import DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE
+from homeassistant.const import DEVICE_CLASS_BATTERY, PERCENTAGE
from .base_class import TradfriBaseDevice
-from .const import CONF_GATEWAY_ID, KEY_API, KEY_GATEWAY
+from .const import CONF_GATEWAY_ID, DEVICES, DOMAIN, KEY_API
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up a Tradfri config entry."""
gateway_id = config_entry.data[CONF_GATEWAY_ID]
- api = hass.data[KEY_API][config_entry.entry_id]
- gateway = hass.data[KEY_GATEWAY][config_entry.entry_id]
+ tradfri_data = hass.data[DOMAIN][config_entry.entry_id]
+ api = tradfri_data[KEY_API]
+ devices = tradfri_data[DEVICES]
- devices_commands = await api(gateway.get_devices())
- all_devices = await api(devices_commands)
- devices = (
+ sensors = (
dev
- for dev in all_devices
+ for dev in devices
if not dev.has_light_control
and not dev.has_socket_control
and not dev.has_blind_control
and not dev.has_signal_repeater_control
)
- if devices:
- async_add_entities(TradfriSensor(device, api, gateway_id) for device in devices)
+ if sensors:
+ async_add_entities(TradfriSensor(sensor, api, gateway_id) for sensor in sensors)
class TradfriSensor(TradfriBaseDevice):
@@ -47,4 +46,4 @@ class TradfriSensor(TradfriBaseDevice):
@property
def unit_of_measurement(self):
"""Return the unit_of_measurement of the device."""
- return UNIT_PERCENTAGE
+ return PERCENTAGE
diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py
index cf23ffeb445..6634090d00d 100644
--- a/homeassistant/components/tradfri/switch.py
+++ b/homeassistant/components/tradfri/switch.py
@@ -2,17 +2,16 @@
from homeassistant.components.switch import SwitchEntity
from .base_class import TradfriBaseDevice
-from .const import CONF_GATEWAY_ID, KEY_API, KEY_GATEWAY
+from .const import CONF_GATEWAY_ID, DEVICES, DOMAIN, KEY_API
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Load Tradfri switches based on a config entry."""
gateway_id = config_entry.data[CONF_GATEWAY_ID]
- api = hass.data[KEY_API][config_entry.entry_id]
- gateway = hass.data[KEY_GATEWAY][config_entry.entry_id]
+ tradfri_data = hass.data[DOMAIN][config_entry.entry_id]
+ api = tradfri_data[KEY_API]
+ devices = tradfri_data[DEVICES]
- devices_commands = await api(gateway.get_devices())
- devices = await api(devices_commands)
switches = [dev for dev in devices if dev.has_socket_control]
if switches:
async_add_entities(
diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json
index 8e5cc6cb3d3..6104305f66c 100644
--- a/homeassistant/components/trafikverket_train/manifest.json
+++ b/homeassistant/components/trafikverket_train/manifest.json
@@ -2,6 +2,6 @@
"domain": "trafikverket_train",
"name": "Trafikverket Train",
"documentation": "https://www.home-assistant.io/integrations/trafikverket_train",
- "requirements": ["pytrafikverket==0.1.6.1"],
+ "requirements": ["pytrafikverket==0.1.6.2"],
"codeowners": ["@endor-force"]
}
diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json
index a34dcdca874..1b3b7ea497a 100644
--- a/homeassistant/components/trafikverket_weatherstation/manifest.json
+++ b/homeassistant/components/trafikverket_weatherstation/manifest.json
@@ -2,6 +2,6 @@
"domain": "trafikverket_weatherstation",
"name": "Trafikverket Weather Station",
"documentation": "https://www.home-assistant.io/integrations/trafikverket_weatherstation",
- "requirements": ["pytrafikverket==0.1.6.1"],
- "codeowners": []
+ "requirements": ["pytrafikverket==0.1.6.2"],
+ "codeowners": ["@endor-force"]
}
diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py
index a8d992e02d5..958adfd6915 100644
--- a/homeassistant/components/trafikverket_weatherstation/sensor.py
+++ b/homeassistant/components/trafikverket_weatherstation/sensor.py
@@ -17,9 +17,9 @@ from homeassistant.const import (
DEGREE,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
+ PERCENTAGE,
SPEED_METERS_PER_SECOND,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
@@ -81,9 +81,16 @@ SENSOR_TYPES = {
"mdi:weather-windy",
None,
],
+ "wind_speed_max": [
+ "Wind speed max",
+ SPEED_METERS_PER_SECOND,
+ "windforcemax",
+ "mdi:weather-windy-variant",
+ None,
+ ],
"humidity": [
"Humidity",
- UNIT_PERCENTAGE,
+ PERCENTAGE,
"humidity",
"mdi:water-percent",
DEVICE_CLASS_HUMIDITY,
diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py
index 2fd7e60cf31..00fc2d1b3b5 100644
--- a/homeassistant/components/transmission/__init__.py
+++ b/homeassistant/components/transmission/__init__.py
@@ -136,13 +136,13 @@ async def get_api(hass, entry):
except TransmissionError as error:
if "401: Unauthorized" in str(error):
_LOGGER.error("Credentials for Transmission client are not valid")
- raise AuthenticationError
+ raise AuthenticationError from error
if "111: Connection refused" in str(error):
_LOGGER.error("Connecting to the Transmission client %s failed", host)
- raise CannotConnect
+ raise CannotConnect from error
_LOGGER.error(error)
- raise UnknownError
+ raise UnknownError from error
class TransmissionClient:
@@ -166,8 +166,8 @@ class TransmissionClient:
try:
self.tm_api = await get_api(self.hass, self.config_entry.data)
- except CannotConnect:
- raise ConfigEntryNotReady
+ except CannotConnect as error:
+ raise ConfigEntryNotReady from error
except (AuthenticationError, UnknownError):
return False
diff --git a/homeassistant/components/transmission/config_flow.py b/homeassistant/components/transmission/config_flow.py
index c457306310d..56ed3081b63 100644
--- a/homeassistant/components/transmission/config_flow.py
+++ b/homeassistant/components/transmission/config_flow.py
@@ -56,7 +56,10 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None:
for entry in self.hass.config_entries.async_entries(DOMAIN):
- if entry.data[CONF_HOST] == user_input[CONF_HOST]:
+ if (
+ entry.data[CONF_HOST] == user_input[CONF_HOST]
+ and entry.data[CONF_PORT] == user_input[CONF_PORT]
+ ):
return self.async_abort(reason="already_configured")
if entry.data[CONF_NAME] == user_input[CONF_NAME]:
errors[CONF_NAME] = "name_exists"
@@ -77,7 +80,9 @@ class TransmissionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
)
return self.async_show_form(
- step_id="user", data_schema=DATA_SCHEMA, errors=errors,
+ step_id="user",
+ data_schema=DATA_SCHEMA,
+ errors=errors,
)
async def async_step_import(self, import_config):
diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py
index baa70bf0b19..69388de1145 100644
--- a/homeassistant/components/transmission/sensor.py
+++ b/homeassistant/components/transmission/sensor.py
@@ -153,7 +153,9 @@ class TransmissionTorrentsSensor(TransmissionSensor):
order = self._tm_client.config_entry.options[CONF_ORDER]
torrents = self._tm_client.api.torrents[0:limit]
info = _torrents_info(
- torrents, order=order, statuses=self.SUBTYPE_MODES[self._sub_type],
+ torrents,
+ order=order,
+ statuses=self.SUBTYPE_MODES[self._sub_type],
)
return {
STATE_ATTR_TORRENT_INFO: info,
diff --git a/homeassistant/components/transmission/translations/ca.json b/homeassistant/components/transmission/translations/ca.json
index 7e319ca1cfd..96f3397af85 100644
--- a/homeassistant/components/transmission/translations/ca.json
+++ b/homeassistant/components/transmission/translations/ca.json
@@ -12,9 +12,7 @@
"user": {
"data": {
"host": "Amfitri\u00f3",
- "limit": "L\u00edmit",
"name": "Nom",
- "order": "Ordre",
"password": "Contrasenya",
"port": "Port",
"username": "Nom d'usuari"
diff --git a/homeassistant/components/transmission/translations/en.json b/homeassistant/components/transmission/translations/en.json
index 42ff361555c..fe3758b774a 100644
--- a/homeassistant/components/transmission/translations/en.json
+++ b/homeassistant/components/transmission/translations/en.json
@@ -12,9 +12,7 @@
"user": {
"data": {
"host": "Host",
- "limit": "Limit",
"name": "Name",
- "order": "Order",
"password": "Password",
"port": "Port",
"username": "Username"
diff --git a/homeassistant/components/transmission/translations/es.json b/homeassistant/components/transmission/translations/es.json
index a760cb65ac7..a1c2de1af7e 100644
--- a/homeassistant/components/transmission/translations/es.json
+++ b/homeassistant/components/transmission/translations/es.json
@@ -12,9 +12,7 @@
"user": {
"data": {
"host": "Host",
- "limit": "L\u00edmite",
"name": "Nombre",
- "order": "Pedido",
"password": "Contrase\u00f1a",
"port": "Puerto",
"username": "Usuario"
diff --git a/homeassistant/components/transmission/translations/fr.json b/homeassistant/components/transmission/translations/fr.json
index 83ff026e6e0..6bad4b15a81 100644
--- a/homeassistant/components/transmission/translations/fr.json
+++ b/homeassistant/components/transmission/translations/fr.json
@@ -12,9 +12,7 @@
"user": {
"data": {
"host": "H\u00f4te",
- "limit": "Limite",
"name": "Nom",
- "order": "Ordre",
"password": "Mot de passe",
"port": "Port",
"username": "Nom d'utilisateur"
diff --git a/homeassistant/components/transmission/translations/it.json b/homeassistant/components/transmission/translations/it.json
index 13a930bdfc7..0a9fa5a874e 100644
--- a/homeassistant/components/transmission/translations/it.json
+++ b/homeassistant/components/transmission/translations/it.json
@@ -12,9 +12,7 @@
"user": {
"data": {
"host": "Host",
- "limit": "Limite",
"name": "Nome",
- "order": "Ordine",
"password": "Password",
"port": "Porta",
"username": "Nome utente"
diff --git a/homeassistant/components/transmission/translations/ko.json b/homeassistant/components/transmission/translations/ko.json
index 4c1b968ddc4..5a041d8a54b 100644
--- a/homeassistant/components/transmission/translations/ko.json
+++ b/homeassistant/components/transmission/translations/ko.json
@@ -12,9 +12,7 @@
"user": {
"data": {
"host": "\ud638\uc2a4\ud2b8",
- "limit": "\uc81c\ud55c",
"name": "\uc774\ub984",
- "order": "\uc21c\uc11c",
"password": "\ube44\ubc00\ubc88\ud638",
"port": "\ud3ec\ud2b8",
"username": "\uc0ac\uc6a9\uc790 \uc774\ub984"
diff --git a/homeassistant/components/transmission/translations/lb.json b/homeassistant/components/transmission/translations/lb.json
index eb305ea7980..1da78c9ce0c 100644
--- a/homeassistant/components/transmission/translations/lb.json
+++ b/homeassistant/components/transmission/translations/lb.json
@@ -12,9 +12,7 @@
"user": {
"data": {
"host": "Host",
- "limit": "Limite",
"name": "Numm",
- "order": "Reiefolleg",
"password": "Passwuert",
"port": "Port",
"username": "Benotzernumm"
diff --git a/homeassistant/components/transmission/translations/no.json b/homeassistant/components/transmission/translations/no.json
index 89150467222..d71e2c2c590 100644
--- a/homeassistant/components/transmission/translations/no.json
+++ b/homeassistant/components/transmission/translations/no.json
@@ -12,11 +12,8 @@
"user": {
"data": {
"host": "Vert",
- "limit": "Grense",
"name": "Navn",
- "order": "Rekkef\u00f8lge",
"password": "Passord",
- "port": "",
"username": "Brukernavn"
},
"title": "Oppsett av Transmission-klient"
diff --git a/homeassistant/components/transmission/translations/pl.json b/homeassistant/components/transmission/translations/pl.json
index 56b569a8c10..4f62dcfb882 100644
--- a/homeassistant/components/transmission/translations/pl.json
+++ b/homeassistant/components/transmission/translations/pl.json
@@ -12,9 +12,7 @@
"user": {
"data": {
"host": "Nazwa hosta lub adres IP",
- "limit": "Limit",
"name": "Nazwa",
- "order": "Kolejno\u015b\u0107",
"password": "Has\u0142o",
"port": "Port",
"username": "Nazwa u\u017cytkownika"
diff --git a/homeassistant/components/transmission/translations/pt.json b/homeassistant/components/transmission/translations/pt.json
index 380fa6cc5d7..70b7789adee 100644
--- a/homeassistant/components/transmission/translations/pt.json
+++ b/homeassistant/components/transmission/translations/pt.json
@@ -7,9 +7,7 @@
"user": {
"data": {
"host": "Servidor",
- "limit": "Limite",
"name": "Nome",
- "order": "Ordem",
"password": "Palavra-passe",
"port": "Porta",
"username": "Nome de Utilizador"
diff --git a/homeassistant/components/transmission/translations/ru.json b/homeassistant/components/transmission/translations/ru.json
index c51d32733a8..b6bbe58ea1a 100644
--- a/homeassistant/components/transmission/translations/ru.json
+++ b/homeassistant/components/transmission/translations/ru.json
@@ -12,9 +12,7 @@
"user": {
"data": {
"host": "\u0425\u043e\u0441\u0442",
- "limit": "\u041e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u0435",
"name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435",
- "order": "\u041f\u043e\u0440\u044f\u0434\u043e\u043a",
"password": "\u041f\u0430\u0440\u043e\u043b\u044c",
"port": "\u041f\u043e\u0440\u0442",
"username": "\u041b\u043e\u0433\u0438\u043d"
diff --git a/homeassistant/components/transmission/translations/zh-Hant.json b/homeassistant/components/transmission/translations/zh-Hant.json
index 3d343ef2e3e..0dcbd97d183 100644
--- a/homeassistant/components/transmission/translations/zh-Hant.json
+++ b/homeassistant/components/transmission/translations/zh-Hant.json
@@ -12,9 +12,7 @@
"user": {
"data": {
"host": "\u4e3b\u6a5f\u7aef",
- "limit": "\u9650\u5236",
"name": "\u540d\u7a31",
- "order": "\u6392\u5e8f",
"password": "\u5bc6\u78bc",
"port": "\u901a\u8a0a\u57e0",
"username": "\u4f7f\u7528\u8005\u540d\u7a31"
diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py
index 77159060263..12ce7fdfe49 100644
--- a/homeassistant/components/trend/__init__.py
+++ b/homeassistant/components/trend/__init__.py
@@ -1 +1,4 @@
"""A sensor that monitors trends in other components."""
+
+DOMAIN = "trend"
+PLATFORMS = ["binary_sensor"]
diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py
index cd78618d156..1c6c75d6ce5 100644
--- a/homeassistant/components/trend/binary_sensor.py
+++ b/homeassistant/components/trend/binary_sensor.py
@@ -26,8 +26,11 @@ from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import generate_entity_id
from homeassistant.helpers.event import async_track_state_change_event
+from homeassistant.helpers.reload import setup_reload_service
from homeassistant.util import utcnow
+from . import DOMAIN, PLATFORMS
+
_LOGGER = logging.getLogger(__name__)
ATTR_ATTRIBUTE = "attribute"
@@ -63,6 +66,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the trend sensors."""
+
+ setup_reload_service(hass, DOMAIN, PLATFORMS)
+
sensors = []
for device_id, device_config in config[CONF_SENSORS].items():
@@ -179,8 +185,10 @@ class SensorTrend(BinarySensorEntity):
except (ValueError, TypeError) as ex:
_LOGGER.error(ex)
- async_track_state_change_event(
- self.hass, [self._entity_id], trend_sensor_state_listener
+ self.async_on_remove(
+ async_track_state_change_event(
+ self.hass, [self._entity_id], trend_sensor_state_listener
+ )
)
async def async_update(self):
diff --git a/homeassistant/components/trend/services.yaml b/homeassistant/components/trend/services.yaml
new file mode 100644
index 00000000000..6c4b027ef99
--- /dev/null
+++ b/homeassistant/components/trend/services.yaml
@@ -0,0 +1,2 @@
+reload:
+ description: Reload all trend entities.
diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py
index c7c0b986878..0f758d4a2eb 100644
--- a/homeassistant/components/tts/__init__.py
+++ b/homeassistant/components/tts/__init__.py
@@ -46,6 +46,8 @@ ATTR_MESSAGE = "message"
ATTR_OPTIONS = "options"
ATTR_PLATFORM = "platform"
+BASE_URL_KEY = "tts_base_url"
+
CONF_BASE_URL = "base_url"
CONF_CACHE = "cache"
CONF_CACHE_DIR = "cache_dir"
@@ -115,10 +117,11 @@ async def async_setup(hass, config):
cache_dir = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR)
time_memory = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY)
base_url = conf.get(CONF_BASE_URL) or get_url(hass)
+ hass.data[BASE_URL_KEY] = base_url
await tts.async_init_cache(use_cache, cache_dir, time_memory, base_url)
- except (HomeAssistantError, KeyError) as err:
- _LOGGER.error("Error on cache init %s", err)
+ except (HomeAssistantError, KeyError):
+ _LOGGER.exception("Error on cache init")
return False
hass.http.register_view(TextToSpeechView(tts))
@@ -251,14 +254,14 @@ class SpeechManager:
_init_tts_cache_dir, self.hass, cache_dir
)
except OSError as err:
- raise HomeAssistantError(f"Can't init cache dir {err}")
+ raise HomeAssistantError(f"Can't init cache dir {err}") from err
try:
cache_files = await self.hass.async_add_executor_job(
_get_cache_files, self.cache_dir
)
except OSError as err:
- raise HomeAssistantError(f"Can't read cache dir {err}")
+ raise HomeAssistantError(f"Can't read cache dir {err}") from err
if cache_files:
self.file_cache.update(cache_files)
@@ -405,9 +408,9 @@ class SpeechManager:
try:
data = await self.hass.async_add_executor_job(load_speech)
- except OSError:
+ except OSError as err:
del self.file_cache[key]
- raise HomeAssistantError(f"Can't read {voice_file}")
+ raise HomeAssistantError(f"Can't read {voice_file}") from err
self._async_store_to_memcache(key, filename, data)
@@ -599,3 +602,8 @@ class TextToSpeechView(HomeAssistantView):
return web.Response(status=HTTP_NOT_FOUND)
return web.Response(body=data, content_type=content)
+
+
+def get_base_url(hass):
+ """Get base URL."""
+ return hass.data[BASE_URL_KEY]
diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json
index a53357b1a2b..3db130d01bc 100644
--- a/homeassistant/components/tts/manifest.json
+++ b/homeassistant/components/tts/manifest.json
@@ -2,7 +2,7 @@
"domain": "tts",
"name": "Text-to-Speech (TTS)",
"documentation": "https://www.home-assistant.io/integrations/tts",
- "requirements": ["mutagen==1.44.0"],
+ "requirements": ["mutagen==1.45.1"],
"dependencies": ["http"],
"after_dependencies": ["media_player"],
"codeowners": ["@pvizeli"]
diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py
index e2304fc7f63..9d8bb873836 100644
--- a/homeassistant/components/tuya/__init__.py
+++ b/homeassistant/components/tuya/__init__.py
@@ -95,12 +95,13 @@ async def async_setup_entry(hass, entry):
await hass.async_add_executor_job(
tuya.init, username, password, country_code, platform
)
- except (TuyaNetException, TuyaServerException):
- raise ConfigEntryNotReady()
+ except (TuyaNetException, TuyaServerException) as exc:
+ raise ConfigEntryNotReady() from exc
except TuyaAPIException as exc:
_LOGGER.error(
- "Connection error during integration setup. Error: %s", exc,
+ "Connection error during integration setup. Error: %s",
+ exc,
)
return False
diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py
index 15cecefdb21..99939c4b9c0 100644
--- a/homeassistant/components/tuya/climate.py
+++ b/homeassistant/components/tuya/climate.py
@@ -54,7 +54,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
if not dev_ids:
return
entities = await hass.async_add_executor_job(
- _setup_entities, hass, dev_ids, platform,
+ _setup_entities,
+ hass,
+ dev_ids,
+ platform,
)
async_add_entities(entities)
diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py
index 538b819cb05..3c94ed6a53d 100644
--- a/homeassistant/components/tuya/cover.py
+++ b/homeassistant/components/tuya/cover.py
@@ -26,7 +26,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
if not dev_ids:
return
entities = await hass.async_add_executor_job(
- _setup_entities, hass, dev_ids, platform,
+ _setup_entities,
+ hass,
+ dev_ids,
+ platform,
)
async_add_entities(entities)
diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py
index 6144bd4ab96..cc8272fabba 100644
--- a/homeassistant/components/tuya/fan.py
+++ b/homeassistant/components/tuya/fan.py
@@ -25,7 +25,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
if not dev_ids:
return
entities = await hass.async_add_executor_job(
- _setup_entities, hass, dev_ids, platform,
+ _setup_entities,
+ hass,
+ dev_ids,
+ platform,
)
async_add_entities(entities)
diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py
index 9416089f898..ee9c92221df 100644
--- a/homeassistant/components/tuya/light.py
+++ b/homeassistant/components/tuya/light.py
@@ -30,7 +30,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
if not dev_ids:
return
entities = await hass.async_add_executor_job(
- _setup_entities, hass, dev_ids, platform,
+ _setup_entities,
+ hass,
+ dev_ids,
+ platform,
)
async_add_entities(entities)
diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py
index 39613318379..40f9fca11ee 100644
--- a/homeassistant/components/tuya/scene.py
+++ b/homeassistant/components/tuya/scene.py
@@ -23,7 +23,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
if not dev_ids:
return
entities = await hass.async_add_executor_job(
- _setup_entities, hass, dev_ids, platform,
+ _setup_entities,
+ hass,
+ dev_ids,
+ platform,
)
async_add_entities(entities)
diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py
index 13b4af94a48..e4d0778cab0 100644
--- a/homeassistant/components/tuya/switch.py
+++ b/homeassistant/components/tuya/switch.py
@@ -23,7 +23,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
if not dev_ids:
return
entities = await hass.async_add_executor_job(
- _setup_entities, hass, dev_ids, platform,
+ _setup_entities,
+ hass,
+ dev_ids,
+ platform,
)
async_add_entities(entities)
diff --git a/homeassistant/components/tuya/translations/fr.json b/homeassistant/components/tuya/translations/fr.json
index 6e181e2d646..cbd71b0cbd1 100644
--- a/homeassistant/components/tuya/translations/fr.json
+++ b/homeassistant/components/tuya/translations/fr.json
@@ -1,10 +1,21 @@
{
"config": {
+ "abort": {
+ "auth_failed": "Authentification invalide",
+ "conn_error": "\u00c9chec de connexion",
+ "single_instance_allowed": "D\u00e9j\u00e0 configur\u00e9. Une seule configuration possible."
+ },
+ "error": {
+ "auth_failed": "Authentification invalide"
+ },
"flow_title": "Configuration Tuya",
"step": {
"user": {
"data": {
- "platform": "L'application dans laquelle votre compte est enregistr\u00e9"
+ "country_code": "Le code de pays de votre compte (par exemple, 1 pour les \u00c9tats-Unis ou 86 pour la Chine)",
+ "password": "Mot de passe",
+ "platform": "L'application dans laquelle votre compte est enregistr\u00e9",
+ "username": "Nom d'utilisateur"
},
"description": "Saisissez vos informations d'identification Tuya.",
"title": "Tuya"
diff --git a/homeassistant/components/tuya/translations/no.json b/homeassistant/components/tuya/translations/no.json
index 5681f95d984..ca6de86f30b 100644
--- a/homeassistant/components/tuya/translations/no.json
+++ b/homeassistant/components/tuya/translations/no.json
@@ -17,8 +17,7 @@
"platform": "Appen der kontoen din registreres",
"username": "Brukernavn"
},
- "description": "Skriv inn din Tuya-legitimasjon.",
- "title": ""
+ "description": "Skriv inn din Tuya-legitimasjon."
}
}
}
diff --git a/homeassistant/components/tuya/translations/ru.json b/homeassistant/components/tuya/translations/ru.json
index dab853f3310..4eedc62396e 100644
--- a/homeassistant/components/tuya/translations/ru.json
+++ b/homeassistant/components/tuya/translations/ru.json
@@ -2,7 +2,7 @@
"config": {
"abort": {
"auth_failed": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.",
- "conn_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.",
+ "conn_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.",
"single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430. \u0412\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044e."
},
"error": {
diff --git a/homeassistant/components/twentemilieu/translations/no.json b/homeassistant/components/twentemilieu/translations/no.json
index a9d3c184495..0ed6471e4fd 100644
--- a/homeassistant/components/twentemilieu/translations/no.json
+++ b/homeassistant/components/twentemilieu/translations/no.json
@@ -14,8 +14,7 @@
"house_number": "Husnummer",
"post_code": "Postnummer"
},
- "description": "Sett opp Twente Milieu som gir informasjon om innsamling av avfall p\u00e5 adressen din.",
- "title": ""
+ "description": "Sett opp Twente Milieu som gir informasjon om innsamling av avfall p\u00e5 adressen din."
}
}
}
diff --git a/homeassistant/components/twentemilieu/translations/pt.json b/homeassistant/components/twentemilieu/translations/pt.json
new file mode 100644
index 00000000000..3d982a98998
--- /dev/null
+++ b/homeassistant/components/twentemilieu/translations/pt.json
@@ -0,0 +1,7 @@
+{
+ "config": {
+ "error": {
+ "connection_error": "Falha na liga\u00e7\u00e3o"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/__init__.py b/homeassistant/components/twilio/__init__.py
index 15c6697b2f7..7c65a693af1 100644
--- a/homeassistant/components/twilio/__init__.py
+++ b/homeassistant/components/twilio/__init__.py
@@ -64,5 +64,4 @@ async def async_unload_entry(hass, entry):
return True
-# pylint: disable=invalid-name
async_remove_entry = config_entry_flow.webhook_async_remove_entry
diff --git a/homeassistant/components/twilio/translations/zh-Hant.json b/homeassistant/components/twilio/translations/zh-Hant.json
index 8cb926fe259..cc198a4f43d 100644
--- a/homeassistant/components/twilio/translations/zh-Hant.json
+++ b/homeassistant/components/twilio/translations/zh-Hant.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "not_internet_accessible": "Home Assistant \u7269\u4ef6\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 Twilio \u8a0a\u606f\u3002",
+ "not_internet_accessible": "Home Assistant \u8a2d\u5099\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 Twilio \u8a0a\u606f\u3002",
"one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002"
},
"create_entry": {
diff --git a/homeassistant/components/twitter/manifest.json b/homeassistant/components/twitter/manifest.json
index 81048758889..044151094da 100644
--- a/homeassistant/components/twitter/manifest.json
+++ b/homeassistant/components/twitter/manifest.json
@@ -2,6 +2,6 @@
"domain": "twitter",
"name": "Twitter",
"documentation": "https://www.home-assistant.io/integrations/twitter",
- "requirements": ["TwitterAPI==2.5.11"],
+ "requirements": ["TwitterAPI==2.5.13"],
"codeowners": []
}
diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py
index 82314adb771..7c30a34f58f 100644
--- a/homeassistant/components/unifi/controller.py
+++ b/homeassistant/components/unifi/controller.py
@@ -295,8 +295,8 @@ class UniFiController:
description = await self.api.site_description()
self._site_role = description[0]["site_role"]
- except CannotConnect:
- raise ConfigEntryNotReady
+ except CannotConnect as err:
+ raise ConfigEntryNotReady from err
except Exception as err: # pylint: disable=broad-except
LOGGER.error("Unknown error connecting with UniFi controller: %s", err)
@@ -323,7 +323,9 @@ class UniFiController:
client = self.api.clients_all[mac]
self.api.clients.process_raw([client.raw])
LOGGER.debug(
- "Restore disconnected client %s (%s)", entity.entity_id, client.mac,
+ "Restore disconnected client %s (%s)",
+ entity.entity_id,
+ client.mac,
)
wireless_clients = self.hass.data[UNIFI_WIRELESS_CLIENTS]
@@ -426,14 +428,14 @@ async def get_controller(
await controller.login()
return controller
- except aiounifi.Unauthorized:
+ except aiounifi.Unauthorized as err:
LOGGER.warning("Connected to UniFi at %s but not registered.", host)
- raise AuthenticationRequired
+ raise AuthenticationRequired from err
- except (asyncio.TimeoutError, aiounifi.RequestError):
+ except (asyncio.TimeoutError, aiounifi.RequestError) as err:
LOGGER.error("Error connecting to the UniFi controller at %s", host)
- raise CannotConnect
+ raise CannotConnect from err
- except aiounifi.AiounifiException:
+ except aiounifi.AiounifiException as err:
LOGGER.exception("Unknown UniFi communication error occurred")
- raise AuthenticationRequired
+ raise AuthenticationRequired from err
diff --git a/homeassistant/components/unifi/translations/cs.json b/homeassistant/components/unifi/translations/cs.json
index fdbfbc343de..a2adfcce308 100644
--- a/homeassistant/components/unifi/translations/cs.json
+++ b/homeassistant/components/unifi/translations/cs.json
@@ -1,11 +1,12 @@
{
"config": {
"abort": {
- "already_configured": "\u0158adi\u010d je ji\u017e nakonfigurov\u00e1n"
+ "already_configured": "Ovlada\u010d je ji\u017e nakonfigurov\u00e1n"
},
"error": {
"faulty_credentials": "Chybn\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje",
- "service_unavailable": "Slu\u017eba nen\u00ed dostupn\u00e1"
+ "service_unavailable": "Nepoda\u0159ilo se p\u0159ipojit",
+ "unknown_client_mac": "Na t\u00e9to MAC adrese nen\u00ed dostupn\u00fd \u017e\u00e1dn\u00fd klient"
},
"step": {
"user": {
@@ -15,14 +16,30 @@
"port": "Port",
"site": "ID s\u00edt\u011b",
"username": "U\u017eivatelsk\u00e9 jm\u00e9no",
- "verify_ssl": "\u0158adi\u010d pou\u017e\u00edv\u00e1 spr\u00e1vn\u00fd certifik\u00e1t"
+ "verify_ssl": "Ovlada\u010d pou\u017e\u00edv\u00e1 spr\u00e1vn\u00fd certifik\u00e1t"
},
- "title": "Nastaven\u00ed UniFi \u0159adi\u010de"
+ "title": "Nastaven\u00ed UniFi ovlada\u010de"
}
}
},
"options": {
"step": {
+ "client_control": {
+ "data": {
+ "poe_clients": "Povolit u klient\u016f ovl\u00e1d\u00e1n\u00ed POE"
+ },
+ "title": "Mo\u017enosti UniFi 2/3"
+ },
+ "device_tracker": {
+ "data": {
+ "ssid_filter": "Vyberte SSID, ve kter\u00e9m budou sledov\u00e1ni bezdr\u00e1tov\u011b p\u0159ipojen\u00ed klienti",
+ "track_clients": "Sledovat p\u0159ipojen\u00e9 klienty",
+ "track_devices": "Sledovat p\u0159ipojen\u00e1 za\u0159\u00edzen\u00ed (Ubiquiti za\u0159\u00edzen\u00ed)",
+ "track_wired_clients": "V\u010detn\u011b klient\u016f p\u0159ipojen\u00fdch kabelem"
+ },
+ "description": "Konfigurace sledov\u00e1n\u00ed za\u0159\u00edzen\u00ed",
+ "title": "Mo\u017enosti UniFi 1/3"
+ },
"simple_options": {
"data": {
"track_clients": "Sledov\u00e1n\u00ed p\u0159ipojen\u00fdch za\u0159\u00edzen\u00ed",
@@ -32,8 +49,10 @@
},
"statistics_sensors": {
"data": {
- "allow_bandwidth_sensors": "Vytvo\u0159it senzory vyu\u017eit\u00ed \u0161\u00ed\u0159ky p\u00e1sma pro s\u00ed\u0165ov\u00e9 klienty"
- }
+ "allow_bandwidth_sensors": "Vytvo\u0159it senzory vyu\u017eit\u00ed \u0161\u00ed\u0159ky p\u00e1sma pro p\u0159ipojen\u00e9 klienty"
+ },
+ "description": "Konfigurovat statistick\u00e9 senzory",
+ "title": "Mo\u017enosti UniFi 3/3"
}
}
}
diff --git a/homeassistant/components/unifi/translations/fr.json b/homeassistant/components/unifi/translations/fr.json
index 31749e0a78f..e92c33c19de 100644
--- a/homeassistant/components/unifi/translations/fr.json
+++ b/homeassistant/components/unifi/translations/fr.json
@@ -4,8 +4,8 @@
"already_configured": "Le contr\u00f4leur est d\u00e9j\u00e0 configur\u00e9"
},
"error": {
- "faulty_credentials": "Mauvaises informations d'identification de l'utilisateur",
- "service_unavailable": "Aucun service disponible",
+ "faulty_credentials": "Authentification invalide",
+ "service_unavailable": "\u00c9chec de connexion",
"unknown_client_mac": "Aucun client disponible sur cette adresse MAC"
},
"step": {
diff --git a/homeassistant/components/unifi/translations/no.json b/homeassistant/components/unifi/translations/no.json
index a861790ba8d..2742ae6a0c5 100644
--- a/homeassistant/components/unifi/translations/no.json
+++ b/homeassistant/components/unifi/translations/no.json
@@ -13,7 +13,6 @@
"data": {
"host": "Vert",
"password": "Passord",
- "port": "",
"site": "Nettsted-ID",
"username": "Brukernavn",
"verify_ssl": "Kontroller bruker riktig sertifikat"
diff --git a/homeassistant/components/unifi/translations/ru.json b/homeassistant/components/unifi/translations/ru.json
index eecd5b53c56..550867b682b 100644
--- a/homeassistant/components/unifi/translations/ru.json
+++ b/homeassistant/components/unifi/translations/ru.json
@@ -5,7 +5,7 @@
},
"error": {
"faulty_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.",
- "service_unavailable": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.",
+ "service_unavailable": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.",
"unknown_client_mac": "\u041d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u043d\u0430 \u044d\u0442\u043e\u043c MAC-\u0430\u0434\u0440\u0435\u0441\u0435."
},
"step": {
diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py
index 289a57683cf..aedc27c2a29 100644
--- a/homeassistant/components/universal/media_player.py
+++ b/homeassistant/components/universal/media_player.py
@@ -68,8 +68,11 @@ from homeassistant.const import (
STATE_ON,
STATE_UNAVAILABLE,
)
-from homeassistant.core import callback
+from homeassistant.core import EVENT_HOMEASSISTANT_START, callback
+from homeassistant.exceptions import TemplateError
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.event import TrackTemplate, async_track_template_result
+from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.service import async_call_from_config
_LOGGER = logging.getLogger(__name__)
@@ -104,6 +107,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the universal media players."""
+
+ await async_setup_reload_service(hass, "universal", ["media_player"])
+
player = UniversalMediaPlayer(
hass,
config.get(CONF_NAME),
@@ -132,27 +138,53 @@ class UniversalMediaPlayer(MediaPlayerEntity):
attr.append(None)
self._attrs[key] = attr
self._child_state = None
+ self._state_template_result = None
self._state_template = state_template
- if state_template is not None:
- self._state_template.hass = hass
async def async_added_to_hass(self):
"""Subscribe to children and template state changes."""
@callback
- def async_on_dependency_update(*_):
+ def _async_on_dependency_update(event):
"""Update ha state when dependencies update."""
+ self.async_set_context(event.context)
self.async_schedule_update_ha_state(True)
+ @callback
+ def _async_on_template_update(event, updates):
+ """Update ha state when dependencies update."""
+ result = updates.pop().result
+
+ if isinstance(result, TemplateError):
+ self._state_template_result = None
+ else:
+ self._state_template_result = result
+
+ if event:
+ self.async_set_context(event.context)
+
+ self.async_schedule_update_ha_state(True)
+
+ if self._state_template is not None:
+ result = async_track_template_result(
+ self.hass,
+ [TrackTemplate(self._state_template, None)],
+ _async_on_template_update,
+ )
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, callback(lambda _: result.async_refresh())
+ )
+
+ self.async_on_remove(result.async_remove)
+
depend = copy(self._children)
for entity in self._attrs.values():
depend.append(entity[0])
- if self._state_template is not None:
- for entity in self._state_template.extract_entities():
- depend.append(entity)
- self.hass.helpers.event.async_track_state_change_event(
- list(set(depend)), async_on_dependency_update
+ self.async_on_remove(
+ self.hass.helpers.event.async_track_state_change_event(
+ list(set(depend)), _async_on_dependency_update
+ )
)
def _entity_lkp(self, entity_id, state_attr=None):
@@ -217,7 +249,7 @@ class UniversalMediaPlayer(MediaPlayerEntity):
def master_state(self):
"""Return the master state for entity or None."""
if self._state_template is not None:
- return self._state_template.async_render()
+ return self._state_template_result
if CONF_STATE in self._attrs:
master_state = self._entity_lkp(
self._attrs[CONF_STATE][0], self._attrs[CONF_STATE][1]
diff --git a/homeassistant/components/universal/services.yaml b/homeassistant/components/universal/services.yaml
index e69de29bb2d..ed8f550275e 100644
--- a/homeassistant/components/universal/services.yaml
+++ b/homeassistant/components/universal/services.yaml
@@ -0,0 +1,2 @@
+reload:
+ description: Reload all universal entities.
diff --git a/homeassistant/components/upb/translations/pt.json b/homeassistant/components/upb/translations/pt.json
new file mode 100644
index 00000000000..0c5c7760566
--- /dev/null
+++ b/homeassistant/components/upb/translations/pt.json
@@ -0,0 +1,7 @@
+{
+ "config": {
+ "error": {
+ "unknown": "Erro inesperado"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upc_connect/device_tracker.py b/homeassistant/components/upc_connect/device_tracker.py
index fc9225c6ef4..d68311a8793 100644
--- a/homeassistant/components/upc_connect/device_tracker.py
+++ b/homeassistant/components/upc_connect/device_tracker.py
@@ -69,8 +69,10 @@ class UPCDeviceScanner(DeviceScanner):
async def async_get_device_name(self, device: str) -> Optional[str]:
"""Get the device name (the name of the wireless device not used)."""
for connected_device in self.connect_box.devices:
- if connected_device != device:
- continue
- return connected_device.hostname
+ if (
+ connected_device.mac == device
+ and connected_device.hostname.lower() != "unknown"
+ ):
+ return connected_device.hostname
return None
diff --git a/homeassistant/components/upc_connect/manifest.json b/homeassistant/components/upc_connect/manifest.json
index 6236021f3c6..f34061e276a 100644
--- a/homeassistant/components/upc_connect/manifest.json
+++ b/homeassistant/components/upc_connect/manifest.json
@@ -2,6 +2,6 @@
"domain": "upc_connect",
"name": "UPC Connect Box",
"documentation": "https://www.home-assistant.io/integrations/upc_connect",
- "requirements": ["connect-box==0.2.5"],
- "codeowners": ["@pvizeli"]
+ "requirements": ["connect-box==0.2.8"],
+ "codeowners": ["@pvizeli", "@fabaff"]
}
diff --git a/homeassistant/components/updater/__init__.py b/homeassistant/components/updater/__init__.py
index f3c9483e4a8..3d7b8b626c9 100644
--- a/homeassistant/components/updater/__init__.py
+++ b/homeassistant/components/updater/__init__.py
@@ -148,13 +148,15 @@ async def get_newest_version(hass, huuid, include_components):
try:
res = await req.json()
- except ValueError:
+ except ValueError as err:
raise update_coordinator.UpdateFailed(
"Received invalid JSON from Home Assistant Update"
- )
+ ) from err
try:
res = RESPONSE_SCHEMA(res)
return res["version"], res["release-notes"]
except vol.Invalid as err:
- raise update_coordinator.UpdateFailed(f"Got unexpected response: {err}")
+ raise update_coordinator.UpdateFailed(
+ f"Got unexpected response: {err}"
+ ) from err
diff --git a/homeassistant/components/updater/binary_sensor.py b/homeassistant/components/updater/binary_sensor.py
index 5d45b368500..36e05513d43 100644
--- a/homeassistant/components/updater/binary_sensor.py
+++ b/homeassistant/components/updater/binary_sensor.py
@@ -1,6 +1,7 @@
"""Support for Home Assistant Updater binary sensors."""
from homeassistant.components.binary_sensor import BinarySensorEntity
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import ATTR_NEWEST_VERSION, ATTR_RELEASE_NOTES, DOMAIN as UPDATER_DOMAIN
@@ -13,13 +14,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
async_add_entities([UpdaterBinary(hass.data[UPDATER_DOMAIN])])
-class UpdaterBinary(BinarySensorEntity):
+class UpdaterBinary(CoordinatorEntity, BinarySensorEntity):
"""Representation of an updater binary sensor."""
- def __init__(self, coordinator):
- """Initialize the binary sensor."""
- self.coordinator = coordinator
-
@property
def name(self) -> str:
"""Return the name of the binary sensor, if any."""
@@ -37,16 +34,6 @@ class UpdaterBinary(BinarySensorEntity):
return None
return self.coordinator.data.update_available
- @property
- def available(self) -> bool:
- """Return True if entity is available."""
- return self.coordinator.last_update_success
-
- @property
- def should_poll(self) -> bool:
- """Return True if entity has to be polled for state."""
- return False
-
@property
def device_state_attributes(self) -> dict:
"""Return the optional state attributes."""
@@ -58,16 +45,3 @@ class UpdaterBinary(BinarySensorEntity):
if self.coordinator.data.newest_version:
data[ATTR_NEWEST_VERSION] = self.coordinator.data.newest_version
return data
-
- async def async_added_to_hass(self):
- """Register update dispatcher."""
- self.async_on_remove(
- self.coordinator.async_add_listener(self.async_write_ha_state)
- )
-
- async def async_update(self):
- """Update the entity.
-
- Only used by the generic entity update service.
- """
- await self.coordinator.async_request_refresh()
diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py
index 3a34cb26604..52cada89333 100644
--- a/homeassistant/components/upnp/__init__.py
+++ b/homeassistant/components/upnp/__init__.py
@@ -109,8 +109,8 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry)
st = config_entry.data.get(CONFIG_ENTRY_ST) # pylint: disable=invalid-name
try:
device = await async_discover_and_construct(hass, udn, st)
- except asyncio.TimeoutError:
- raise ConfigEntryNotReady
+ except asyncio.TimeoutError as err:
+ raise ConfigEntryNotReady from err
if not device:
_LOGGER.info("Unable to create UPnP/IGD, aborting")
@@ -122,7 +122,8 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry)
# Ensure entry has proper unique_id.
if config_entry.unique_id != device.unique_id:
hass.config_entries.async_update_entry(
- entry=config_entry, unique_id=device.unique_id,
+ entry=config_entry,
+ unique_id=device.unique_id,
)
# Create device registry entry.
diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py
index 0c57a2c243e..016a5a25017 100644
--- a/homeassistant/components/upnp/config_flow.py
+++ b/homeassistant/components/upnp/config_flow.py
@@ -90,7 +90,10 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
),
}
)
- return self.async_show_form(step_id="user", data_schema=data_schema,)
+ return self.async_show_form(
+ step_id="user",
+ data_schema=data_schema,
+ )
async def async_step_import(self, import_info: Optional[Mapping]):
"""Import a new UPnP/IGD device as a config entry.
@@ -183,11 +186,13 @@ class UpnpFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return UpnpOptionsFlowHandler(config_entry)
async def _async_create_entry_from_discovery(
- self, discovery: Mapping,
+ self,
+ discovery: Mapping,
):
"""Create an entry from discovery."""
_LOGGER.debug(
- "_async_create_entry_from_data: discovery: %s", discovery,
+ "_async_create_entry_from_data: discovery: %s",
+ discovery,
)
# Get name from device, if not found already.
if DISCOVERY_NAME not in discovery and DISCOVERY_LOCATION in discovery:
@@ -238,9 +243,10 @@ class UpnpOptionsFlowHandler(config_entries.OptionsFlow):
step_id="init",
data_schema=vol.Schema(
{
- vol.Optional(CONF_SCAN_INTERVAL, default=scan_interval,): vol.All(
- vol.Coerce(int), vol.Range(min=30)
- ),
+ vol.Optional(
+ CONF_SCAN_INTERVAL,
+ default=scan_interval,
+ ): vol.All(vol.Coerce(int), vol.Range(min=30)),
}
),
)
diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py
index 5a34405e0c1..80a5ce7021c 100644
--- a/homeassistant/components/upnp/sensor.py
+++ b/homeassistant/components/upnp/sensor.py
@@ -5,9 +5,11 @@ from typing import Any, Mapping
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import DATA_BYTES, DATA_RATE_KIBIBYTES_PER_SECOND
from homeassistant.helpers import device_registry as dr
-from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
+from homeassistant.helpers.update_coordinator import (
+ CoordinatorEntity,
+ DataUpdateCoordinator,
+)
from .const import (
BYTES_RECEIVED,
@@ -119,7 +121,7 @@ async def async_setup_entry(
async_add_entities(sensors, True)
-class UpnpSensor(Entity):
+class UpnpSensor(CoordinatorEntity):
"""Base class for UPnP/IGD sensors."""
def __init__(
@@ -130,17 +132,12 @@ class UpnpSensor(Entity):
update_multiplier: int = 2,
) -> None:
"""Initialize the base sensor."""
- self._coordinator = coordinator
+ super().__init__(coordinator)
self._device = device
self._sensor_type = sensor_type
self._update_counter_max = update_multiplier
self._update_counter = 0
- @property
- def should_poll(self) -> bool:
- """Inform we should not be polled."""
- return False
-
@property
def icon(self) -> str:
"""Icon to use in the frontend, if any."""
@@ -151,8 +148,8 @@ class UpnpSensor(Entity):
"""Return if entity is available."""
device_value_key = self._sensor_type["device_value_key"]
return (
- self._coordinator.last_update_success
- and device_value_key in self._coordinator.data
+ self.coordinator.last_update_success
+ and device_value_key in self.coordinator.data
)
@property
@@ -180,17 +177,6 @@ class UpnpSensor(Entity):
"model": self._device.model_name,
}
- async def async_update(self):
- """Request an update."""
- await self._coordinator.async_request_refresh()
-
- async def async_added_to_hass(self) -> None:
- """Subscribe to sensors events."""
- remove_from_coordinator = self._coordinator.async_add_listener(
- self.async_write_ha_state
- )
- self.async_on_remove(remove_from_coordinator)
-
class RawUpnpSensor(UpnpSensor):
"""Representation of a UPnP/IGD sensor."""
@@ -199,7 +185,7 @@ class RawUpnpSensor(UpnpSensor):
def state(self) -> str:
"""Return the state of the device."""
device_value_key = self._sensor_type["device_value_key"]
- value = self._coordinator.data[device_value_key]
+ value = self.coordinator.data[device_value_key]
if value is None:
return None
return format(value, "d")
@@ -238,10 +224,10 @@ class DerivedUpnpSensor(UpnpSensor):
"""Return the state of the device."""
# Can't calculate any derivative if we have only one value.
device_value_key = self._sensor_type["device_value_key"]
- current_value = self._coordinator.data[device_value_key]
+ current_value = self.coordinator.data[device_value_key]
if current_value is None:
return None
- current_timestamp = self._coordinator.data[TIMESTAMP]
+ current_timestamp = self.coordinator.data[TIMESTAMP]
if self._last_value is None or self._has_overflowed(current_value):
self._last_value = current_value
self._last_timestamp = current_timestamp
diff --git a/homeassistant/components/upnp/translations/nl.json b/homeassistant/components/upnp/translations/nl.json
index 9b579127f8c..cfa845f1b07 100644
--- a/homeassistant/components/upnp/translations/nl.json
+++ b/homeassistant/components/upnp/translations/nl.json
@@ -10,7 +10,7 @@
"one": "Een",
"other": "Ander"
},
- "flow_title": "[%%]: {naam}",
+ "flow_title": "UPnP/IGD: {name}",
"step": {
"ssdp_confirm": {
"description": "Wilt u [%%] instellen?"
diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py
index 3e0363f4f35..e07fac28d1f 100644
--- a/homeassistant/components/uvc/camera.py
+++ b/homeassistant/components/uvc/camera.py
@@ -58,10 +58,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
return False
except nvr.NvrError as ex:
_LOGGER.error("NVR refuses to talk to me: %s", str(ex))
- raise PlatformNotReady
+ raise PlatformNotReady from ex
except requests.exceptions.ConnectionError as ex:
_LOGGER.error("Unable to connect to NVR: %s", str(ex))
- raise PlatformNotReady
+ raise PlatformNotReady from ex
add_entities(
[
diff --git a/homeassistant/components/vacuum/device_trigger.py b/homeassistant/components/vacuum/device_trigger.py
index ee225ab3caa..29fc5628b22 100644
--- a/homeassistant/components/vacuum/device_trigger.py
+++ b/homeassistant/components/vacuum/device_trigger.py
@@ -3,8 +3,9 @@ from typing import List
import voluptuous as vol
-from homeassistant.components.automation import AutomationActionType, state
+from homeassistant.components.automation import AutomationActionType
from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA
+from homeassistant.components.homeassistant.triggers import state as state_trigger
from homeassistant.const import (
CONF_DEVICE_ID,
CONF_DOMAIN,
@@ -77,12 +78,12 @@ async def async_attach_trigger(
to_state = STATE_DOCKED
state_config = {
- state.CONF_PLATFORM: "state",
+ CONF_PLATFORM: "state",
CONF_ENTITY_ID: config[CONF_ENTITY_ID],
- state.CONF_FROM: from_state,
- state.CONF_TO: to_state,
+ state_trigger.CONF_FROM: from_state,
+ state_trigger.CONF_TO: to_state,
}
- state_config = state.TRIGGER_SCHEMA(state_config)
- return await state.async_attach_trigger(
+ state_config = state_trigger.TRIGGER_SCHEMA(state_config)
+ return await state_trigger.async_attach_trigger(
hass, state_config, action, automation_info, platform_type="device"
)
diff --git a/homeassistant/components/vacuum/translations/fr.json b/homeassistant/components/vacuum/translations/fr.json
index cf958c5f852..1c069a98132 100644
--- a/homeassistant/components/vacuum/translations/fr.json
+++ b/homeassistant/components/vacuum/translations/fr.json
@@ -19,8 +19,8 @@
"docked": "Sur la base",
"error": "Erreur",
"idle": "Inactif",
- "off": "Off",
- "on": "On",
+ "off": "Inactif",
+ "on": "Actif",
"paused": "En pause",
"returning": "Retourne \u00e0 la base"
}
diff --git a/homeassistant/components/vacuum/translations/pt-BR.json b/homeassistant/components/vacuum/translations/pt-BR.json
index 79f4b9b7e42..4e6f8c84748 100644
--- a/homeassistant/components/vacuum/translations/pt-BR.json
+++ b/homeassistant/components/vacuum/translations/pt-BR.json
@@ -11,5 +11,5 @@
"returning": "Retornando para base"
}
},
- "title": "Aspirando"
+ "title": "Aspirador"
}
\ No newline at end of file
diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py
index b3a7e8758a0..6f6755a05e7 100644
--- a/homeassistant/components/vallox/sensor.py
+++ b/homeassistant/components/vallox/sensor.py
@@ -7,8 +7,8 @@ from homeassistant.const import (
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
DEVICE_CLASS_TIMESTAMP,
+ PERCENTAGE,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -40,7 +40,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
state_proxy=state_proxy,
metric_key="A_CYC_FAN_SPEED",
device_class=None,
- unit_of_measurement=UNIT_PERCENTAGE,
+ unit_of_measurement=PERCENTAGE,
icon="mdi:fan",
),
ValloxSensor(
@@ -80,7 +80,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
state_proxy=state_proxy,
metric_key="A_CYC_RH_VALUE",
device_class=DEVICE_CLASS_HUMIDITY,
- unit_of_measurement=UNIT_PERCENTAGE,
+ unit_of_measurement=PERCENTAGE,
icon=None,
),
ValloxFilterRemainingSensor(
diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py
index 72ffda48b57..a859567e219 100644
--- a/homeassistant/components/velbus/__init__.py
+++ b/homeassistant/components/velbus/__init__.py
@@ -70,7 +70,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
controller.scan(callback)
except velbus.util.VelbusException as err:
_LOGGER.error("An error occurred: %s", err)
- raise ConfigEntryNotReady
+ raise ConfigEntryNotReady from err
def syn_clock(self, service=None):
try:
diff --git a/homeassistant/components/velbus/config_flow.py b/homeassistant/components/velbus/config_flow.py
index e85422d740a..361b5a01175 100644
--- a/homeassistant/components/velbus/config_flow.py
+++ b/homeassistant/components/velbus/config_flow.py
@@ -29,7 +29,7 @@ class VelbusConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._errors = {}
def _create_device(self, name: str, prt: str):
- """Create an antry async."""
+ """Create an entry async."""
return self.async_create_entry(title=name, data={CONF_PORT: prt})
def _test_connection(self, prt):
diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json
index ca3ae2b0df6..455aa98b34c 100644
--- a/homeassistant/components/velbus/manifest.json
+++ b/homeassistant/components/velbus/manifest.json
@@ -2,7 +2,7 @@
"domain": "velbus",
"name": "Velbus",
"documentation": "https://www.home-assistant.io/integrations/velbus",
- "requirements": ["python-velbus==2.0.43"],
+ "requirements": ["python-velbus==2.0.44"],
"config_flow": true,
"codeowners": ["@Cereal2nd", "@brefra"]
}
diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py
index 263f5f0025b..b45716b33d6 100644
--- a/homeassistant/components/vera/__init__.py
+++ b/homeassistant/components/vera/__init__.py
@@ -61,7 +61,9 @@ async def async_setup(hass: HomeAssistant, base_config: dict) -> bool:
hass.async_create_task(
hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config,
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data=config,
)
)
diff --git a/homeassistant/components/vera/config_flow.py b/homeassistant/components/vera/config_flow.py
index cac17951cc1..a040e4b96b5 100644
--- a/homeassistant/components/vera/config_flow.py
+++ b/homeassistant/components/vera/config_flow.py
@@ -42,10 +42,12 @@ def options_schema(options: dict = None) -> dict:
options = options or {}
return {
vol.Optional(
- CONF_LIGHTS, default=list_to_str(options.get(CONF_LIGHTS, [])),
+ CONF_LIGHTS,
+ default=list_to_str(options.get(CONF_LIGHTS, [])),
): str,
vol.Optional(
- CONF_EXCLUDE, default=list_to_str(options.get(CONF_EXCLUDE, [])),
+ CONF_EXCLUDE,
+ default=list_to_str(options.get(CONF_EXCLUDE, [])),
): str,
}
@@ -68,7 +70,10 @@ class OptionsFlowHandler(config_entries.OptionsFlow):
async def async_step_init(self, user_input=None):
"""Manage the options."""
if user_input is not None:
- return self.async_create_entry(title="", data=options_data(user_input),)
+ return self.async_create_entry(
+ title="",
+ data=options_data(user_input),
+ )
return self.async_show_form(
step_id="init",
diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py
index 60ebeeb1566..3c4e0974b85 100644
--- a/homeassistant/components/vera/sensor.py
+++ b/homeassistant/components/vera/sensor.py
@@ -7,7 +7,7 @@ import pyvera as veraApi
from homeassistant.components.sensor import DOMAIN as PLATFORM_DOMAIN, ENTITY_ID_FORMAT
from homeassistant.config_entries import ConfigEntry
-from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE, TEMP_CELSIUS, TEMP_FAHRENHEIT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
from homeassistant.util import convert
@@ -62,7 +62,7 @@ class VeraSensor(VeraDevice, Entity):
if self.vera_device.category == veraApi.CATEGORY_UV_SENSOR:
return "level"
if self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR:
- return UNIT_PERCENTAGE
+ return PERCENTAGE
if self.vera_device.category == veraApi.CATEGORY_POWER_METER:
return "watts"
diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py
index 384042b7210..ff1c31f7302 100644
--- a/homeassistant/components/verisure/sensor.py
+++ b/homeassistant/components/verisure/sensor.py
@@ -1,7 +1,7 @@
"""Support for Verisure sensors."""
import logging
-from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE, TEMP_CELSIUS
from homeassistant.helpers.entity import Entity
from . import CONF_HYDROMETERS, CONF_MOUSE, CONF_THERMOMETERS, HUB as hub
@@ -130,7 +130,7 @@ class VerisureHygrometer(Entity):
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity."""
- return UNIT_PERCENTAGE
+ return PERCENTAGE
# pylint: disable=no-self-use
def update(self):
diff --git a/homeassistant/components/version/manifest.json b/homeassistant/components/version/manifest.json
index ed3158040d5..7fc1a097d81 100644
--- a/homeassistant/components/version/manifest.json
+++ b/homeassistant/components/version/manifest.json
@@ -2,7 +2,7 @@
"domain": "version",
"name": "Version",
"documentation": "https://www.home-assistant.io/integrations/version",
- "requirements": ["pyhaversion==3.3.0"],
- "codeowners": ["@fabaff"],
+ "requirements": ["pyhaversion==3.4.2"],
+ "codeowners": ["@fabaff", "@ludeeus"],
"quality_scale": "internal"
}
diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py
index 636e564b816..dcfdc115376 100644
--- a/homeassistant/components/version/sensor.py
+++ b/homeassistant/components/version/sensor.py
@@ -35,6 +35,7 @@ ALL_IMAGES = [
"raspberrypi4-64",
"tinker",
"odroid-c2",
+ "odroid-n2",
"odroid-xu",
]
ALL_SOURCES = ["local", "pypi", "hassio", "docker", "haio"]
diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py
index 0f905b8d7ef..94a0d5c2f25 100644
--- a/homeassistant/components/vesync/__init__.py
+++ b/homeassistant/components/vesync/__init__.py
@@ -1,4 +1,5 @@
-"""Etekcity VeSync integration."""
+"""VeSync integration."""
+import asyncio
import logging
from pyvesync import VeSync
@@ -16,10 +17,13 @@ from .const import (
SERVICE_UPDATE_DEVS,
VS_DISCOVERY,
VS_DISPATCHERS,
+ VS_FANS,
VS_MANAGER,
VS_SWITCHES,
)
+PLATFORMS = ["switch", "fan"]
+
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = vol.Schema(
@@ -80,6 +84,7 @@ async def async_setup_entry(hass, config_entry):
hass.data[DOMAIN][VS_MANAGER] = manager
switches = hass.data[DOMAIN][VS_SWITCHES] = []
+ fans = hass.data[DOMAIN][VS_FANS] = []
hass.data[DOMAIN][VS_DISPATCHERS] = []
@@ -87,13 +92,19 @@ async def async_setup_entry(hass, config_entry):
switches.extend(device_dict[VS_SWITCHES])
hass.async_create_task(forward_setup(config_entry, "switch"))
+ if device_dict[VS_FANS]:
+ fans.extend(device_dict[VS_FANS])
+ hass.async_create_task(forward_setup(config_entry, "fan"))
+
async def async_new_device_discovery(service):
"""Discover if new devices should be added."""
manager = hass.data[DOMAIN][VS_MANAGER]
switches = hass.data[DOMAIN][VS_SWITCHES]
+ fans = hass.data[DOMAIN][VS_FANS]
dev_dict = await async_process_devices(hass, manager)
switch_devs = dev_dict.get(VS_SWITCHES, [])
+ fan_devs = dev_dict.get(VS_FANS, [])
switch_set = set(switch_devs)
new_switches = list(switch_set.difference(switches))
@@ -105,6 +116,16 @@ async def async_setup_entry(hass, config_entry):
switches.extend(new_switches)
hass.async_create_task(forward_setup(config_entry, "switch"))
+ fan_set = set(fan_devs)
+ new_fans = list(fan_set.difference(fans))
+ if new_fans and fans:
+ fans.extend(new_fans)
+ async_dispatcher_send(hass, VS_DISCOVERY.format(VS_FANS), new_fans)
+ return
+ if new_fans and not fans:
+ fans.extend(new_fans)
+ hass.async_create_task(forward_setup(config_entry, "fan"))
+
hass.services.async_register(
DOMAIN, SERVICE_UPDATE_DEVS, async_new_device_discovery
)
@@ -114,14 +135,15 @@ async def async_setup_entry(hass, config_entry):
async def async_unload_entry(hass, entry):
"""Unload a config entry."""
- forward_unload = hass.config_entries.async_forward_entry_unload
- remove_switches = False
- if hass.data[DOMAIN][VS_SWITCHES]:
- remove_switches = await forward_unload(entry, "switch")
+ 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)
- if remove_switches:
- hass.services.async_remove(DOMAIN, SERVICE_UPDATE_DEVS)
- del hass.data[DOMAIN]
- return True
-
- return False
+ return unload_ok
diff --git a/homeassistant/components/vesync/common.py b/homeassistant/components/vesync/common.py
index d2ffa5281e9..42e3516f085 100644
--- a/homeassistant/components/vesync/common.py
+++ b/homeassistant/components/vesync/common.py
@@ -3,7 +3,7 @@ import logging
from homeassistant.helpers.entity import ToggleEntity
-from .const import VS_SWITCHES
+from .const import VS_FANS, VS_SWITCHES
_LOGGER = logging.getLogger(__name__)
@@ -12,9 +12,14 @@ async def async_process_devices(hass, manager):
"""Assign devices to proper component."""
devices = {}
devices[VS_SWITCHES] = []
+ devices[VS_FANS] = []
await hass.async_add_executor_job(manager.update)
+ if manager.fans:
+ devices[VS_FANS].extend(manager.fans)
+ _LOGGER.info("%d VeSync fans found", len(manager.fans))
+
if manager.outlets:
devices[VS_SWITCHES].extend(manager.outlets)
_LOGGER.info("%d VeSync outlets found", len(manager.outlets))
@@ -49,7 +54,7 @@ class VeSyncDevice(ToggleEntity):
@property
def is_on(self):
- """Return True if switch is on."""
+ """Return True if device is on."""
return self.device.device_status == "on"
@property
@@ -57,10 +62,6 @@ class VeSyncDevice(ToggleEntity):
"""Return True if device is available."""
return self.device.connection_status == "online"
- def turn_on(self, **kwargs):
- """Turn the device on."""
- self.device.turn_on()
-
def turn_off(self, **kwargs):
"""Turn the device off."""
self.device.turn_off()
diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py
index d65edc949c7..9923ab94ecf 100644
--- a/homeassistant/components/vesync/const.py
+++ b/homeassistant/components/vesync/const.py
@@ -6,4 +6,5 @@ VS_DISCOVERY = "vesync_discovery_{}"
SERVICE_UPDATE_DEVS = "update_devices"
VS_SWITCHES = "switches"
+VS_FANS = "fans"
VS_MANAGER = "manager"
diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py
new file mode 100644
index 00000000000..7d395d93a74
--- /dev/null
+++ b/homeassistant/components/vesync/fan.py
@@ -0,0 +1,117 @@
+"""Support for VeSync fans."""
+import logging
+
+from homeassistant.components.fan import (
+ SPEED_HIGH,
+ SPEED_LOW,
+ SPEED_MEDIUM,
+ SPEED_OFF,
+ SUPPORT_SET_SPEED,
+ FanEntity,
+)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .common import VeSyncDevice
+from .const import DOMAIN, VS_DISCOVERY, VS_DISPATCHERS, VS_FANS
+
+_LOGGER = logging.getLogger(__name__)
+
+DEV_TYPE_TO_HA = {
+ "LV-PUR131S": "fan",
+}
+
+SPEED_AUTO = "auto"
+FAN_SPEEDS = [SPEED_AUTO, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the VeSync fan platform."""
+
+ async def async_discover(devices):
+ """Add new devices to platform."""
+ _async_setup_entities(devices, async_add_entities)
+
+ disp = async_dispatcher_connect(hass, VS_DISCOVERY.format(VS_FANS), async_discover)
+ hass.data[DOMAIN][VS_DISPATCHERS].append(disp)
+
+ _async_setup_entities(hass.data[DOMAIN][VS_FANS], async_add_entities)
+ return True
+
+
+@callback
+def _async_setup_entities(devices, async_add_entities):
+ """Check if device is online and add entity."""
+ dev_list = []
+ for dev in devices:
+ if DEV_TYPE_TO_HA.get(dev.device_type) == "fan":
+ dev_list.append(VeSyncFanHA(dev))
+ else:
+ _LOGGER.warning(
+ "%s - Unknown device type - %s", dev.device_name, dev.device_type
+ )
+ continue
+
+ async_add_entities(dev_list, update_before_add=True)
+
+
+class VeSyncFanHA(VeSyncDevice, FanEntity):
+ """Representation of a VeSync fan."""
+
+ def __init__(self, fan):
+ """Initialize the VeSync fan device."""
+ super().__init__(fan)
+ self.smartfan = fan
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_SET_SPEED
+
+ @property
+ def speed(self):
+ """Return the current speed."""
+ if self.smartfan.mode == SPEED_AUTO:
+ return SPEED_AUTO
+ if self.smartfan.mode == "manual":
+ current_level = self.smartfan.fan_level
+ if current_level is not None:
+ return FAN_SPEEDS[current_level]
+ return None
+
+ @property
+ def speed_list(self):
+ """Get the list of available speeds."""
+ return FAN_SPEEDS
+
+ @property
+ def unique_info(self):
+ """Return the ID of this fan."""
+ return self.smartfan.uuid
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the fan."""
+ return {
+ "mode": self.smartfan.mode,
+ "active_time": self.smartfan.active_time,
+ "filter_life": self.smartfan.filter_life,
+ "air_quality": self.smartfan.air_quality,
+ "screen_status": self.smartfan.screen_status,
+ }
+
+ def set_speed(self, speed):
+ """Set the speed of the device."""
+ if not self.smartfan.is_on:
+ self.smartfan.turn_on()
+
+ if speed is None or speed == SPEED_AUTO:
+ self.smartfan.auto_mode()
+ else:
+ self.smartfan.manual_mode()
+ self.smartfan.change_fan_speed(FAN_SPEEDS.index(speed))
+
+ def turn_on(self, speed: str = None, **kwargs) -> None:
+ """Turn the device on."""
+ self.smartfan.turn_on()
+ self.set_speed(speed)
diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json
index 7ac8e89fb60..a4e786c2a12 100644
--- a/homeassistant/components/vesync/manifest.json
+++ b/homeassistant/components/vesync/manifest.json
@@ -1,8 +1,14 @@
{
"domain": "vesync",
- "name": "Etekcity VeSync",
+ "name": "VeSync",
"documentation": "https://www.home-assistant.io/integrations/vesync",
- "codeowners": ["@markperdue", "@webdjoe"],
- "requirements": ["pyvesync==1.1.0"],
+ "codeowners": [
+ "@markperdue",
+ "@webdjoe",
+ "@thegardenmonkey"
+ ],
+ "requirements": [
+ "pyvesync==1.1.0"
+ ],
"config_flow": true
-}
+}
\ No newline at end of file
diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py
index fb6e83227e9..939240349d1 100644
--- a/homeassistant/components/vesync/switch.py
+++ b/homeassistant/components/vesync/switch.py
@@ -1,4 +1,4 @@
-"""Support for Etekcity VeSync switches."""
+"""Support for VeSync switches."""
import logging
from homeassistant.components.switch import SwitchEntity
@@ -55,7 +55,15 @@ def _async_setup_entities(devices, async_add_entities):
async_add_entities(dev_list, update_before_add=True)
-class VeSyncSwitchHA(VeSyncDevice, SwitchEntity):
+class VeSyncBaseSwitch(VeSyncDevice, SwitchEntity):
+ """Base class for VeSync switch Device Representations."""
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ self.device.turn_on()
+
+
+class VeSyncSwitchHA(VeSyncBaseSwitch, SwitchEntity):
"""Representation of a VeSync switch."""
def __init__(self, plug):
@@ -90,7 +98,7 @@ class VeSyncSwitchHA(VeSyncDevice, SwitchEntity):
self.smartplug.update_energy()
-class VeSyncLightSwitch(VeSyncDevice, SwitchEntity):
+class VeSyncLightSwitch(VeSyncBaseSwitch, SwitchEntity):
"""Handle representation of VeSync Light Switch."""
def __init__(self, switch):
diff --git a/homeassistant/components/vesync/translations/fr.json b/homeassistant/components/vesync/translations/fr.json
index 6db0922e684..0d00100ae05 100644
--- a/homeassistant/components/vesync/translations/fr.json
+++ b/homeassistant/components/vesync/translations/fr.json
@@ -10,7 +10,7 @@
"user": {
"data": {
"password": "Mot de passe",
- "username": "Adresse e-mail"
+ "username": "Email"
},
"title": "Entrez vos identifiants"
}
diff --git a/homeassistant/components/vesync/translations/zh-Hant.json b/homeassistant/components/vesync/translations/zh-Hant.json
index efdbee6873c..6d099a9d125 100644
--- a/homeassistant/components/vesync/translations/zh-Hant.json
+++ b/homeassistant/components/vesync/translations/zh-Hant.json
@@ -1,7 +1,7 @@
{
"config": {
"abort": {
- "already_setup": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 Vesync \u7269\u4ef6"
+ "already_setup": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 Vesync \u8a2d\u5099"
},
"error": {
"invalid_login": "\u4f7f\u7528\u8005\u540d\u7a31\u6216\u5bc6\u78bc\u7121\u6548"
diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py
index 35ce6dc787b..d54fc6001cc 100644
--- a/homeassistant/components/vicare/sensor.py
+++ b/homeassistant/components/vicare/sensor.py
@@ -11,9 +11,9 @@ from homeassistant.const import (
DEVICE_CLASS_POWER,
DEVICE_CLASS_TEMPERATURE,
ENERGY_KILO_WATT_HOUR,
+ PERCENTAGE,
POWER_WATT,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
from homeassistant.helpers.entity import Entity
@@ -81,7 +81,7 @@ SENSOR_TYPES = {
SENSOR_BURNER_MODULATION: {
CONF_NAME: "Burner modulation",
CONF_ICON: "mdi:percent",
- CONF_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE,
+ CONF_UNIT_OF_MEASUREMENT: PERCENTAGE,
CONF_GETTER: lambda api: api.getBurnerModulation(),
CONF_DEVICE_CLASS: None,
},
diff --git a/homeassistant/components/vilfo/const.py b/homeassistant/components/vilfo/const.py
index 658d77e1fbc..74eb813bcc5 100644
--- a/homeassistant/components/vilfo/const.py
+++ b/homeassistant/components/vilfo/const.py
@@ -1,5 +1,5 @@
"""Constants for the Vilfo Router integration."""
-from homeassistant.const import DEVICE_CLASS_TIMESTAMP, UNIT_PERCENTAGE
+from homeassistant.const import DEVICE_CLASS_TIMESTAMP, PERCENTAGE
DOMAIN = "vilfo"
@@ -21,7 +21,7 @@ ROUTER_MANUFACTURER = "Vilfo AB"
SENSOR_TYPES = {
ATTR_LOAD: {
ATTR_LABEL: "Load",
- ATTR_UNIT: UNIT_PERCENTAGE,
+ ATTR_UNIT: PERCENTAGE,
ATTR_ICON: "mdi:memory",
ATTR_API_DATA_FIELD: ATTR_API_DATA_FIELD_LOAD,
},
diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py
index a52b395c5c9..25960da72cf 100644
--- a/homeassistant/components/vizio/__init__.py
+++ b/homeassistant/components/vizio/__init__.py
@@ -1,15 +1,24 @@
"""The vizio component."""
import asyncio
+from datetime import timedelta
+import logging
+from typing import Any, Dict, List
+from pyvizio.const import APPS
+from pyvizio.util import gen_apps_list_from_url
import voluptuous as vol
from homeassistant.components.media_player import DEVICE_CLASS_TV
-from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.config_entries import ENTRY_STATE_LOADED, SOURCE_IMPORT, ConfigEntry
from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_APPS, CONF_DEVICE_CLASS, DOMAIN, VIZIO_SCHEMA
+_LOGGER = logging.getLogger(__name__)
+
def validate_apps(config: ConfigType) -> ConfigType:
"""Validate CONF_APPS is only used when CONF_DEVICE_CLASS == DEVICE_CLASS_TV."""
@@ -47,6 +56,16 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) -> bool:
"""Load the saved entities."""
+
+ hass.data.setdefault(DOMAIN, {})
+ if (
+ CONF_APPS not in hass.data[DOMAIN]
+ and config_entry.data[CONF_DEVICE_CLASS] == DEVICE_CLASS_TV
+ ):
+ coordinator = VizioAppsDataUpdateCoordinator(hass)
+ await coordinator.async_refresh()
+ hass.data[DOMAIN][CONF_APPS] = coordinator
+
for platform in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, platform)
@@ -68,4 +87,38 @@ async def async_unload_entry(
)
)
+ # Exclude this config entry because its not unloaded yet
+ if not any(
+ entry.state == ENTRY_STATE_LOADED
+ and entry.entry_id != config_entry.entry_id
+ and entry.data[CONF_DEVICE_CLASS] == DEVICE_CLASS_TV
+ for entry in hass.config_entries.async_entries(DOMAIN)
+ ):
+ hass.data[DOMAIN].pop(CONF_APPS)
+
+ if not hass.data[DOMAIN]:
+ hass.data.pop(DOMAIN)
+
return unload_ok
+
+
+class VizioAppsDataUpdateCoordinator(DataUpdateCoordinator):
+ """Define an object to hold Vizio app config data."""
+
+ def __init__(self, hass: HomeAssistantType) -> None:
+ """Initialize."""
+ super().__init__(
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=timedelta(days=1),
+ update_method=self._async_update_data,
+ )
+ self.data = APPS
+
+ async def _async_update_data(self) -> List[Dict[str, Any]]:
+ """Update data via library."""
+ data = await gen_apps_list_from_url(session=async_get_clientsession(self.hass))
+ if not data:
+ raise UpdateFailed
+ return sorted(data, key=lambda app: app["name"])
diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py
index 335f138ee7e..74fc0d746e3 100644
--- a/homeassistant/components/vizio/config_flow.py
+++ b/homeassistant/components/vizio/config_flow.py
@@ -5,6 +5,7 @@ import socket
from typing import Any, Dict, Optional
from pyvizio import VizioAsync, async_guess_device_type
+from pyvizio.const import APP_HOME
import voluptuous as vol
from homeassistant import config_entries
@@ -123,14 +124,16 @@ class VizioOptionsConfigFlow(config_entries.OptionsFlow):
return self.async_create_entry(title="", data=user_input)
- options = {
- vol.Optional(
- CONF_VOLUME_STEP,
- default=self.config_entry.options.get(
- CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP
- ),
- ): vol.All(vol.Coerce(int), vol.Range(min=1, max=10))
- }
+ options = vol.Schema(
+ {
+ vol.Optional(
+ CONF_VOLUME_STEP,
+ default=self.config_entry.options.get(
+ CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP
+ ),
+ ): vol.All(vol.Coerce(int), vol.Range(min=1, max=10))
+ }
+ )
if self.config_entry.data[CONF_DEVICE_CLASS] == DEVICE_CLASS_TV:
default_include_or_exclude = (
@@ -139,7 +142,7 @@ class VizioOptionsConfigFlow(config_entries.OptionsFlow):
and CONF_EXCLUDE in self.config_entry.options.get(CONF_APPS, {})
else CONF_INCLUDE
)
- options.update(
+ options = options.extend(
{
vol.Optional(
CONF_INCLUDE_OR_EXCLUDE,
@@ -152,11 +155,19 @@ class VizioOptionsConfigFlow(config_entries.OptionsFlow):
default=self.config_entry.options.get(CONF_APPS, {}).get(
default_include_or_exclude, []
),
- ): cv.multi_select(VizioAsync.get_apps_list()),
+ ): cv.multi_select(
+ [
+ APP_HOME["name"],
+ *[
+ app["name"]
+ for app in self.hass.data[DOMAIN][CONF_APPS].data
+ ],
+ ]
+ ),
}
)
- return self.async_show_form(step_id="init", data_schema=vol.Schema(options))
+ return self.async_show_form(step_id="init", data_schema=options)
class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
@@ -180,14 +191,8 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._data = None
self._apps = {}
- async def _create_entry_if_unique(
- self, input_dict: Dict[str, Any]
- ) -> Dict[str, Any]:
- """
- Create entry if ID is unique.
-
- If it is, create entry. If it isn't, abort config flow.
- """
+ async def _create_entry(self, input_dict: Dict[str, Any]) -> Dict[str, Any]:
+ """Create vizio config entry."""
# Remove extra keys that will not be used by entry setup
input_dict.pop(CONF_APPS_TO_INCLUDE_OR_EXCLUDE, None)
input_dict.pop(CONF_INCLUDE_OR_EXCLUDE, None)
@@ -206,19 +211,25 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
if user_input is not None:
# Store current values in case setup fails and user needs to edit
self._user_schema = _get_config_schema(user_input)
+ unique_id = await VizioAsync.get_unique_id(
+ user_input[CONF_HOST],
+ user_input[CONF_DEVICE_CLASS],
+ session=async_get_clientsession(self.hass, False),
+ )
- # Check if new config entry matches any existing config entries
- for entry in self.hass.config_entries.async_entries(DOMAIN):
- # If source is ignore bypass host and name check and continue through loop
- if entry.source == SOURCE_IGNORE:
- continue
- if await self.hass.async_add_executor_job(
- _host_is_same, entry.data[CONF_HOST], user_input[CONF_HOST]
- ):
- errors[CONF_HOST] = "host_exists"
-
- if entry.data[CONF_NAME] == user_input[CONF_NAME]:
- errors[CONF_NAME] = "name_exists"
+ if not unique_id:
+ errors[CONF_HOST] = "cannot_connect"
+ else:
+ # Set unique ID and abort if a flow with the same unique ID is already in progress
+ existing_entry = await self.async_set_unique_id(
+ unique_id=unique_id, raise_on_progress=True
+ )
+ # If device was discovered, abort if existing entry found, otherwise display an error
+ # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
+ if self.context["source"] == SOURCE_ZEROCONF:
+ self._abort_if_unique_id_configured()
+ elif existing_entry:
+ errors[CONF_HOST] = "existing_config_entry_found"
if not errors:
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
@@ -239,21 +250,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
errors["base"] = "cannot_connect"
if not errors:
- unique_id = await VizioAsync.get_unique_id(
- user_input[CONF_HOST],
- user_input.get(CONF_ACCESS_TOKEN),
- user_input[CONF_DEVICE_CLASS],
- session=async_get_clientsession(self.hass, False),
- )
-
- # Set unique ID and abort if unique ID is already configured on an entry or a flow
- # with the unique ID is already in progress
- await self.async_set_unique_id(
- unique_id=unique_id, raise_on_progress=True
- )
- self._abort_if_unique_id_configured()
-
- return await self._create_entry_if_unique(user_input)
+ return await self._create_entry(user_input)
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
elif self._must_show_form and self.context["source"] == SOURCE_IMPORT:
# Import should always display the config form if CONF_ACCESS_TOKEN
@@ -350,27 +347,10 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
) -> Dict[str, Any]:
"""Handle zeroconf discovery."""
- # Set unique ID early to prevent device from getting rediscovered multiple times
- await self.async_set_unique_id(
- unique_id=discovery_info[CONF_HOST].split(":")[0], raise_on_progress=True
- )
- self._abort_if_unique_id_configured()
-
discovery_info[
CONF_HOST
] = f"{discovery_info[CONF_HOST]}:{discovery_info[CONF_PORT]}"
- # Check if new config entry matches any existing config entries and abort if so
- for entry in self.hass.config_entries.async_entries(DOMAIN):
- # If source is ignore bypass host check and continue through loop
- if entry.source == SOURCE_IGNORE:
- continue
-
- if await self.hass.async_add_executor_job(
- _host_is_same, entry.data[CONF_HOST], discovery_info[CONF_HOST]
- ):
- return self.async_abort(reason="already_configured_device")
-
# Set default name to discovered device name by stripping zeroconf service
# (`type`) from `name`
num_chars_to_strip = len(discovery_info[CONF_TYPE]) + 1
@@ -436,20 +416,6 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
self._data[CONF_ACCESS_TOKEN] = pair_data.auth_token
self._must_show_form = True
- unique_id = await VizioAsync.get_unique_id(
- self._data[CONF_HOST],
- self._data[CONF_ACCESS_TOKEN],
- self._data[CONF_DEVICE_CLASS],
- session=async_get_clientsession(self.hass, False),
- )
-
- # Set unique ID and abort if unique ID is already configured on an entry or a flow
- # with the unique ID is already in progress
- await self.async_set_unique_id(
- unique_id=unique_id, raise_on_progress=True
- )
- self._abort_if_unique_id_configured()
-
# pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
if self.context["source"] == SOURCE_IMPORT:
# If user is pairing via config import, show different message
@@ -470,7 +436,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
async def _pairing_complete(self, step_id: str) -> Dict[str, Any]:
"""Handle config flow completion."""
if not self._must_show_form:
- return await self._create_entry_if_unique(self._data)
+ return await self._create_entry(self._data)
self._must_show_form = False
return self.async_show_form(
diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py
index 72bd9b6b08a..bcfc38950d3 100644
--- a/homeassistant/components/vizio/const.py
+++ b/homeassistant/components/vizio/const.py
@@ -1,5 +1,4 @@
"""Constants used by vizio component."""
-from pyvizio import VizioAsync
from pyvizio.const import (
DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER,
DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV,
@@ -27,6 +26,18 @@ from homeassistant.const import (
)
import homeassistant.helpers.config_validation as cv
+SERVICE_UPDATE_SETTING = "update_setting"
+
+ATTR_SETTING_TYPE = "setting_type"
+ATTR_SETTING_NAME = "setting_name"
+ATTR_NEW_VALUE = "new_value"
+
+UPDATE_SETTING_SCHEMA = {
+ vol.Required(ATTR_SETTING_TYPE): vol.All(cv.string, vol.Lower, cv.slugify),
+ vol.Required(ATTR_SETTING_NAME): vol.All(cv.string, vol.Lower, cv.slugify),
+ vol.Required(ATTR_NEW_VALUE): vol.Any(vol.Coerce(int), cv.string),
+}
+
CONF_ADDITIONAL_CONFIGS = "additional_configs"
CONF_APP_ID = "APP_ID"
CONF_APPS = "apps"
@@ -66,6 +77,8 @@ SUPPORTED_COMMANDS = {
VIZIO_SOUND_MODE = "eq"
VIZIO_AUDIO_SETTINGS = "audio"
VIZIO_MUTE_ON = "on"
+VIZIO_VOLUME = "volume"
+VIZIO_MUTE = "mute"
# Since Vizio component relies on device class, this dict will ensure that changes to
# the values of DEVICE_CLASS_SPEAKER or DEVICE_CLASS_TV don't require changes to pyvizio.
@@ -87,10 +100,10 @@ VIZIO_SCHEMA = {
vol.Optional(CONF_APPS): vol.All(
{
vol.Exclusive(CONF_INCLUDE, "apps_filter"): vol.All(
- cv.ensure_list, [vol.All(cv.string, vol.In(VizioAsync.get_apps_list()))]
+ cv.ensure_list, [cv.string]
),
vol.Exclusive(CONF_EXCLUDE, "apps_filter"): vol.All(
- cv.ensure_list, [vol.All(cv.string, vol.In(VizioAsync.get_apps_list()))]
+ cv.ensure_list, [cv.string]
),
vol.Optional(CONF_ADDITIONAL_CONFIGS): vol.All(
cv.ensure_list,
diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json
index 6223a95821c..7aa544b4a0b 100644
--- a/homeassistant/components/vizio/manifest.json
+++ b/homeassistant/components/vizio/manifest.json
@@ -2,7 +2,7 @@
"domain": "vizio",
"name": "VIZIO SmartCast",
"documentation": "https://www.home-assistant.io/integrations/vizio",
- "requirements": ["pyvizio==0.1.49"],
+ "requirements": ["pyvizio==0.1.56"],
"codeowners": ["@raman325"],
"config_flow": true,
"zeroconf": ["_viziocast._tcp.local."],
diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py
index 4a93e7886ef..ab5386c151b 100644
--- a/homeassistant/components/vizio/media_player.py
+++ b/homeassistant/components/vizio/media_player.py
@@ -1,14 +1,15 @@
"""Vizio SmartCast Device support."""
from datetime import timedelta
import logging
-from typing import Any, Callable, Dict, List, Optional
+from typing import Any, Callable, Dict, List, Optional, Union
from pyvizio import VizioAsync
from pyvizio.api.apps import find_app_name
-from pyvizio.const import APP_HOME, APPS, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP
+from pyvizio.const import APP_HOME, INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP
from homeassistant.components.media_player import (
DEVICE_CLASS_SPEAKER,
+ DEVICE_CLASS_TV,
SUPPORT_SELECT_SOUND_MODE,
MediaPlayerEntity,
)
@@ -23,7 +24,9 @@ from homeassistant.const import (
STATE_OFF,
STATE_ON,
)
+from homeassistant.core import callback
from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers import entity_platform
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
@@ -31,6 +34,7 @@ from homeassistant.helpers.dispatcher import (
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import (
CONF_ADDITIONAL_CONFIGS,
@@ -41,16 +45,20 @@ from .const import (
DEVICE_ID,
DOMAIN,
ICON,
+ SERVICE_UPDATE_SETTING,
SUPPORTED_COMMANDS,
+ UPDATE_SETTING_SCHEMA,
VIZIO_AUDIO_SETTINGS,
VIZIO_DEVICE_CLASSES,
+ VIZIO_MUTE,
VIZIO_MUTE_ON,
VIZIO_SOUND_MODE,
+ VIZIO_VOLUME,
)
_LOGGER = logging.getLogger(__name__)
-SCAN_INTERVAL = timedelta(seconds=10)
+SCAN_INTERVAL = timedelta(seconds=30)
PARALLEL_UPDATES = 0
@@ -73,6 +81,7 @@ async def async_setup_entry(
params = {}
if not config_entry.options:
params["options"] = {CONF_VOLUME_STEP: volume_step}
+
include_or_exclude_key = next(
(
key
@@ -110,9 +119,15 @@ async def async_setup_entry(
_LOGGER.warning("Failed to connect to %s", host)
raise PlatformNotReady
- entity = VizioDevice(config_entry, device, name, device_class)
+ apps_coordinator = hass.data[DOMAIN].get(CONF_APPS)
+
+ entity = VizioDevice(config_entry, device, name, device_class, apps_coordinator)
async_add_entities([entity], update_before_add=True)
+ platform = entity_platform.current_platform.get()
+ platform.async_register_entity_service(
+ SERVICE_UPDATE_SETTING, UPDATE_SETTING_SCHEMA, "async_update_setting"
+ )
class VizioDevice(MediaPlayerEntity):
@@ -124,10 +139,12 @@ class VizioDevice(MediaPlayerEntity):
device: VizioAsync,
name: str,
device_class: str,
+ apps_coordinator: DataUpdateCoordinator,
) -> None:
"""Initialize Vizio device."""
self._config_entry = config_entry
self._async_unsub_listeners = []
+ self._apps_coordinator = apps_coordinator
self._name = name
self._state = None
@@ -141,6 +158,7 @@ class VizioDevice(MediaPlayerEntity):
self._available_sound_modes = []
self._available_inputs = []
self._available_apps = []
+ self._all_apps = apps_coordinator.data if apps_coordinator else None
self._conf_apps = config_entry.options.get(CONF_APPS, {})
self._additional_app_configs = config_entry.data.get(CONF_APPS, {}).get(
CONF_ADDITIONAL_CONFIGS, []
@@ -203,10 +221,13 @@ class VizioDevice(MediaPlayerEntity):
audio_settings = await self._device.get_all_settings(
VIZIO_AUDIO_SETTINGS, log_api_exception=False
)
+
if audio_settings:
- self._volume_level = float(audio_settings["volume"]) / self._max_volume
- if "mute" in audio_settings:
- self._is_volume_muted = audio_settings["mute"].lower() == VIZIO_MUTE_ON
+ self._volume_level = float(audio_settings[VIZIO_VOLUME]) / self._max_volume
+ if VIZIO_MUTE in audio_settings:
+ self._is_volume_muted = (
+ audio_settings[VIZIO_MUTE].lower() == VIZIO_MUTE_ON
+ )
else:
self._is_volume_muted = None
@@ -214,8 +235,10 @@ class VizioDevice(MediaPlayerEntity):
self._supported_commands |= SUPPORT_SELECT_SOUND_MODE
self._current_sound_mode = audio_settings[VIZIO_SOUND_MODE]
if not self._available_sound_modes:
- self._available_sound_modes = await self._device.get_setting_options(
- VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE
+ self._available_sound_modes = (
+ await self._device.get_setting_options(
+ VIZIO_AUDIO_SETTINGS, VIZIO_SOUND_MODE
+ )
)
else:
# Explicitly remove SUPPORT_SELECT_SOUND_MODE from supported features
@@ -241,14 +264,15 @@ class VizioDevice(MediaPlayerEntity):
# Create list of available known apps from known app list after
# filtering by CONF_INCLUDE/CONF_EXCLUDE
- self._available_apps = self._apps_list(self._device.get_apps_list())
+ self._available_apps = self._apps_list([app["name"] for app in self._all_apps])
self._current_app_config = await self._device.get_current_app_config(
log_api_exception=False
)
self._current_app = find_app_name(
- self._current_app_config, [APP_HOME, *APPS, *self._additional_app_configs]
+ self._current_app_config,
+ [APP_HOME, *self._all_apps, *self._additional_app_configs],
)
if self._current_app == NO_APP_RUNNING:
@@ -272,9 +296,20 @@ class VizioDevice(MediaPlayerEntity):
async def _async_update_options(self, config_entry: ConfigEntry) -> None:
"""Update options if the update signal comes from this entity."""
self._volume_step = config_entry.options[CONF_VOLUME_STEP]
+ # Update so that CONF_ADDITIONAL_CONFIGS gets retained for imports
self._conf_apps.update(config_entry.options.get(CONF_APPS, {}))
- async def async_added_to_hass(self):
+ async def async_update_setting(
+ self, setting_type: str, setting_name: str, new_value: Union[int, str]
+ ) -> None:
+ """Update a setting when update_setting service is called."""
+ await self._device.set_setting(
+ setting_type,
+ setting_name,
+ new_value,
+ )
+
+ async def async_added_to_hass(self) -> None:
"""Register callbacks when entity is added."""
# Register callback for when config entry is updated.
self._async_unsub_listeners.append(
@@ -290,7 +325,19 @@ class VizioDevice(MediaPlayerEntity):
)
)
- async def async_will_remove_from_hass(self):
+ # Register callback for app list updates if device is a TV
+ @callback
+ def apps_list_update():
+ """Update list of all apps."""
+ self._all_apps = self._apps_coordinator.data
+ self.async_write_ha_state()
+
+ if self._device_class == DEVICE_CLASS_TV:
+ self._async_unsub_listeners.append(
+ self._apps_coordinator.async_add_listener(apps_list_update)
+ )
+
+ async def async_will_remove_from_hass(self) -> None:
"""Disconnect callbacks when entity is removed."""
for listener in self._async_unsub_listeners:
listener()
@@ -303,7 +350,7 @@ class VizioDevice(MediaPlayerEntity):
return self._available
@property
- def state(self) -> str:
+ def state(self) -> Optional[str]:
"""Return the state of the device."""
return self._state
@@ -318,7 +365,7 @@ class VizioDevice(MediaPlayerEntity):
return self._icon
@property
- def volume_level(self) -> float:
+ def volume_level(self) -> Optional[float]:
"""Return the volume level of the device."""
return self._volume_level
@@ -328,7 +375,7 @@ class VizioDevice(MediaPlayerEntity):
return self._is_volume_muted
@property
- def source(self) -> str:
+ def source(self) -> Optional[str]:
"""Return current input of the device."""
if self._current_app is not None and self._current_input in INPUT_APPS:
return self._current_app
@@ -455,7 +502,7 @@ class VizioDevice(MediaPlayerEntity):
)
)
elif source in self._available_apps:
- await self._device.launch_app(source)
+ await self._device.launch_app(source, self._all_apps)
async def async_volume_up(self) -> None:
"""Increase volume of the device."""
diff --git a/homeassistant/components/vizio/services.yaml b/homeassistant/components/vizio/services.yaml
new file mode 100644
index 00000000000..50bde6cab78
--- /dev/null
+++ b/homeassistant/components/vizio/services.yaml
@@ -0,0 +1,15 @@
+update_setting:
+ description: Update the value of a setting on a particular Vizio media player device.
+ fields:
+ entity_id:
+ description: Name of an entity to send command to.
+ example: "media_player.vizio_smartcast"
+ setting_type:
+ description: The type of setting to be changed. Available types are listed in the `setting_types` property.
+ example: "audio"
+ setting_name:
+ description: The name of the setting to be changed. Available settings for a given setting_type are listed in the `_settings` property.
+ example: "eq"
+ new_value:
+ description: The new value for the setting
+ example: "Music"
diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json
index 0de1a380d12..8979f6fd82e 100644
--- a/homeassistant/components/vizio/strings.json
+++ b/homeassistant/components/vizio/strings.json
@@ -28,10 +28,9 @@
}
},
"error": {
- "host_exists": "[%key:component::vizio::config::step::user::title%] with specified host already configured.",
- "name_exists": "[%key:component::vizio::config::step::user::title%] with specified name already configured.",
"complete_pairing_failed": "Unable to complete pairing. Ensure the PIN you provided is correct and the TV is still powered and connected to the network before resubmitting.",
- "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+ "existing_config_entry_found": "An existing [%key:component::vizio::config::step::user::title%] config entry with the same serial number has already been configured. You must delete the existing entry in order to configure this one."
},
"abort": {
"already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]",
diff --git a/homeassistant/components/vizio/translations/ca.json b/homeassistant/components/vizio/translations/ca.json
index 50a9751a45d..34ce551a0fe 100644
--- a/homeassistant/components/vizio/translations/ca.json
+++ b/homeassistant/components/vizio/translations/ca.json
@@ -6,7 +6,8 @@
},
"error": {
"cannot_connect": "Ha fallat la connexi\u00f3",
- "complete_pairing_failed": "No s'ha pogut completar l'emparellament. Verifica que el PIN proporcionat sigui el correcte i que el televisor segueix connectat a la xarxa abans de provar-ho de nou.",
+ "complete_pairing_failed": "No s'ha pogut completar la vinculaci\u00f3. Verifica que el PIN proporcionat sigui el correcte i que el televisor segueix connectat a la xarxa abans de provar-ho de nou.",
+ "existing_config_entry_found": "Ja s'ha configurat una entrada de configuraci\u00f3 del Dispositiu VIZIO SmartCast amb el mateix n\u00famero de s\u00e8rie. Per poder configurar aquesta, has de suprimir l'entrada existent.",
"host_exists": "Dispositiu VIZIO SmartCast amb aquest nom d'amfitri\u00f3 ja configurat.",
"name_exists": "Dispositiu VIZIO SmartCast amb aquest nom ja configurat."
},
@@ -15,16 +16,16 @@
"data": {
"pin": "PIN"
},
- "description": "El televisor hauria d'estar mostrant un codi. Introdueix aquest codi al formulari i segueix amb els seg\u00fcents passos per completar l'emparellament.",
- "title": "Proc\u00e9s d'aparellament complet"
+ "description": "El televisor hauria d'estar mostrant un codi. Introdueix aquest codi al formulari i segueix amb els seg\u00fcents passos per completar la vinculaci\u00f3.",
+ "title": "Proc\u00e9s de vinculaci\u00f3 complet"
},
"pairing_complete": {
"description": "El Dispositiu VIZIO SmartCast est\u00e0 connectat a Home Assistant.",
- "title": "Emparellament completat"
+ "title": "Vinculaci\u00f3 completada"
},
"pairing_complete_import": {
"description": "El Dispositiu VIZIO SmartCast est\u00e0 connectat a Home Assistant.\n\nEl teu [%key::common::config_flow::data::access_token%] \u00e9s '**{access_token}**'.",
- "title": "Emparellament completat"
+ "title": "Vinculaci\u00f3 completada"
},
"user": {
"data": {
@@ -33,7 +34,7 @@
"host": "Amfitri\u00f3",
"name": "Nom"
},
- "description": "Nom\u00e9s es necessita el [%key::common::config_flow::data::access_token%] per als televisors. Si est\u00e0s configurant un televisor i encara no tens un [%key::common::config_flow::data::access_token%], deixa-ho en blanc per poder fer el proc\u00e9s d'emparellament.",
+ "description": "Nom\u00e9s es necessita el [%key::common::config_flow::data::access_token%] per als televisors. Si est\u00e0s configurant un televisor i encara no tens un [%key::common::config_flow::data::access_token%], deixa-ho en blanc per poder fer el proc\u00e9s de vinculaci\u00f3.",
"title": "Dispositiu VIZIO SmartCast"
}
}
diff --git a/homeassistant/components/vizio/translations/en.json b/homeassistant/components/vizio/translations/en.json
index 42dcfe6d96d..0c18c2d581e 100644
--- a/homeassistant/components/vizio/translations/en.json
+++ b/homeassistant/components/vizio/translations/en.json
@@ -7,6 +7,7 @@
"error": {
"cannot_connect": "Failed to connect",
"complete_pairing_failed": "Unable to complete pairing. Ensure the PIN you provided is correct and the TV is still powered and connected to the network before resubmitting.",
+ "existing_config_entry_found": "An existing VIZIO SmartCast Device config entry with the same serial number has already been configured. You must delete the existing entry in order to configure this one.",
"host_exists": "VIZIO SmartCast Device with specified host already configured.",
"name_exists": "VIZIO SmartCast Device with specified name already configured."
},
diff --git a/homeassistant/components/vizio/translations/es.json b/homeassistant/components/vizio/translations/es.json
index 9daaa0973b7..ec20977b459 100644
--- a/homeassistant/components/vizio/translations/es.json
+++ b/homeassistant/components/vizio/translations/es.json
@@ -7,6 +7,7 @@
"error": {
"cannot_connect": "No se pudo conectar",
"complete_pairing_failed": "No se pudo completar el emparejamiento. Aseg\u00farate de que el PIN que has proporcionado es correcto y que el televisor sigue encendido y conectado a la red antes de volver a enviarlo.",
+ "existing_config_entry_found": "Ya se ha configurado una entrada VIZIO SmartCast Device con el mismo n\u00famero de serie. Debes borrar la entrada existente para configurar \u00e9sta.",
"host_exists": "Ya existe un VIZIO SmartCast Device configurado con ese host.",
"name_exists": "Ya existe un VIZIO SmartCast Device configurado con ese nombre."
},
diff --git a/homeassistant/components/vizio/translations/fr.json b/homeassistant/components/vizio/translations/fr.json
index 914d35d58d3..9f4f55080bc 100644
--- a/homeassistant/components/vizio/translations/fr.json
+++ b/homeassistant/components/vizio/translations/fr.json
@@ -7,8 +7,9 @@
"error": {
"cannot_connect": "\u00c9chec de connexion",
"complete_pairing_failed": "Impossible de terminer l'appairage. Assurez-vous que le code PIN que vous avez fourni est correct, que le t\u00e9l\u00e9viseur est toujours aliment\u00e9 et connect\u00e9 au r\u00e9seau avant de tenter \u00e0 nouveau.",
- "host_exists": "H\u00f4te d\u00e9j\u00e0 configur\u00e9.",
- "name_exists": "Nom d\u00e9j\u00e0 configur\u00e9."
+ "existing_config_entry_found": "Une entr\u00e9e de configuration existante Appareil VIZIO Smartcast avec le m\u00eame num\u00e9ro de s\u00e9rie a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9e. Vous devez supprimer l'entr\u00e9e existante pour configurer celle-ci.",
+ "host_exists": "Appareil VIZIO Smartcast d\u00e9j\u00e0 configur\u00e9 avec l'h\u00f4te sp\u00e9cifi\u00e9",
+ "name_exists": "L'appareil VIZIO SmartCast avec le nom sp\u00e9cifi\u00e9 est d\u00e9j\u00e0 configur\u00e9."
},
"step": {
"pair_tv": {
@@ -19,21 +20,22 @@
"title": "Processus de couplage complet"
},
"pairing_complete": {
- "description": "Votre appareil Vizio SmartCast est maintenant connect\u00e9 \u00e0 Home Assistant.",
+ "description": "Votre Appareil VIZIO Smartcast est maintenant connect\u00e9 \u00e0 Home Assistant.",
"title": "Appairage termin\u00e9"
},
"pairing_complete_import": {
+ "description": "Votre Appareil VIZIO Smartcast est maintenant connect\u00e9 \u00e0 Home Assistant.\n\nVotre Jeton d'acc\u00e8s est '**{access_token}**'.",
"title": "Appairage termin\u00e9"
},
"user": {
"data": {
"access_token": "Jeton d'acc\u00e8s",
"device_class": "Type d'appareil",
- "host": ":",
+ "host": "H\u00f4te",
"name": "Nom"
},
"description": "Un jeton d'acc\u00e8s n'est n\u00e9cessaire que pour les t\u00e9l\u00e9viseurs. Si vous configurez un t\u00e9l\u00e9viseur et que vous n'avez pas encore de jeton d'acc\u00e8s, laissez-le vide pour passer par un processus de couplage.",
- "title": "Configurer le client Vizio SmartCast"
+ "title": "Appareil VIZIO Smartcast"
}
}
},
diff --git a/homeassistant/components/vizio/translations/it.json b/homeassistant/components/vizio/translations/it.json
index 3877d9458eb..d42562b82b2 100644
--- a/homeassistant/components/vizio/translations/it.json
+++ b/homeassistant/components/vizio/translations/it.json
@@ -7,6 +7,7 @@
"error": {
"cannot_connect": "Impossibile connettersi",
"complete_pairing_failed": "Impossibile completare l'associazione. Assicurarsi che il PIN fornito sia corretto e che la TV sia ancora accesa e collegata alla rete prima di inviare nuovamente.",
+ "existing_config_entry_found": "\u00c8 gi\u00e0 stata configurata una voce di configurazione esistente Dispositivo SmartCast VIZIO con lo stesso numero di serie. \u00c8 necessario eliminare la voce esistente per configurare questa.",
"host_exists": "Il Dispositivo SmartCast VIZIO con host specificato \u00e8 gi\u00e0 configurato.",
"name_exists": "Il Dispositivo SmartCast VIZIO con il nome specificato \u00e8 gi\u00e0 configurato."
},
diff --git a/homeassistant/components/vizio/translations/lb.json b/homeassistant/components/vizio/translations/lb.json
index 63160393165..2aa1db3afa6 100644
--- a/homeassistant/components/vizio/translations/lb.json
+++ b/homeassistant/components/vizio/translations/lb.json
@@ -7,6 +7,7 @@
"error": {
"cannot_connect": "Feeler beim verbannen",
"complete_pairing_failed": "Feeler beim ofschl\u00e9isse vun der Kopplung. Iwwerpr\u00e9if dass de PIN korrekt an da de Fernsee nach \u00ebmmer ugeschalt a mam Netzwierk verbonnen ass ier de n\u00e4chste Versuch gestart g\u00ebtt.",
+ "existing_config_entry_found": "Eng bestoend VIZIO SmartCast Konfiguratioun Entr\u00e9e mat der selwechter Seriennummer ass scho konfigur\u00e9iert. Du musst d\u00e9i existent Entr\u00e9e l\u00e4sche fir d\u00ebs k\u00ebnnen ze konfigur\u00e9ieren.",
"host_exists": "VIZIO Apparat mat d\u00ebsem Host ass scho konfigur\u00e9iert.",
"name_exists": "VIZIO Apparat mat d\u00ebsen Numm ass scho konfigur\u00e9iert."
},
diff --git a/homeassistant/components/vizio/translations/no.json b/homeassistant/components/vizio/translations/no.json
index 0f341ce63b5..ede021f1a3a 100644
--- a/homeassistant/components/vizio/translations/no.json
+++ b/homeassistant/components/vizio/translations/no.json
@@ -7,6 +7,7 @@
"error": {
"cannot_connect": "Tilkobling mislyktes.",
"complete_pairing_failed": "Kan ikke fullf\u00f8re sammenkoblingen. Forsikre deg om at PIN-koden du oppga er riktig, og at TV-en fortsatt er p\u00e5 og tilkoblet nettverket f\u00f8r du sender inn p\u00e5 nytt.",
+ "existing_config_entry_found": "En eksisterende VIZIO SmartCast-enhet konfigurasjonsinngang med samme serienummer er allerede konfigurert. Du m\u00e5 slette den eksisterende oppf\u00f8ringen for \u00e5 konfigurere denne.",
"host_exists": "VIZIO SmartCast-enhet with specified host already configured.",
"name_exists": "VIZIO SmartCast-enhet with specified name already configured."
},
diff --git a/homeassistant/components/vizio/translations/pt-BR.json b/homeassistant/components/vizio/translations/pt-BR.json
index 425d1b91f5e..bca1aeeaf3d 100644
--- a/homeassistant/components/vizio/translations/pt-BR.json
+++ b/homeassistant/components/vizio/translations/pt-BR.json
@@ -1,7 +1,8 @@
{
"config": {
"error": {
- "complete_pairing_failed": "N\u00e3o foi poss\u00edvel concluir o pareamento. Verifique se o PIN que voc\u00ea forneceu est\u00e1 correto e a TV ainda est\u00e1 ligada e conectada \u00e0 internet antes de reenviar."
+ "complete_pairing_failed": "N\u00e3o foi poss\u00edvel concluir o pareamento. Verifique se o PIN que voc\u00ea forneceu est\u00e1 correto e a TV ainda est\u00e1 ligada e conectada \u00e0 internet antes de reenviar.",
+ "existing_config_entry_found": "Uma entrada j\u00e1 existente configurada com o mesmo n\u00famero de s\u00e9rie j\u00e1 foi configurada. Voc\u00ea deve apagar a entrada existente para poder configurar esta."
},
"step": {
"pair_tv": {
diff --git a/homeassistant/components/vizio/translations/pt.json b/homeassistant/components/vizio/translations/pt.json
index 695ad541026..b1a4f0d7b36 100644
--- a/homeassistant/components/vizio/translations/pt.json
+++ b/homeassistant/components/vizio/translations/pt.json
@@ -8,6 +8,8 @@
},
"user": {
"data": {
+ "access_token": "Token de Acesso",
+ "host": "Servidor",
"name": "Nome"
}
}
diff --git a/homeassistant/components/vizio/translations/ru.json b/homeassistant/components/vizio/translations/ru.json
index d21b0c39615..8211e39959e 100644
--- a/homeassistant/components/vizio/translations/ru.json
+++ b/homeassistant/components/vizio/translations/ru.json
@@ -1,12 +1,13 @@
{
"config": {
"abort": {
- "already_configured_device": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
+ "already_configured_device": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.",
"updated_entry": "\u042d\u0442\u0430 \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430, \u043d\u043e \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b, \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u043d\u044b\u0435 \u0432 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438, \u043d\u0435 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u044e\u0442 \u0440\u0430\u043d\u0435\u0435 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u043c \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f\u043c, \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0431\u044b\u043b\u0430 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u043c \u043e\u0431\u0440\u0430\u0437\u043e\u043c \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0430."
},
"error": {
- "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.",
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.",
"complete_pairing_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435. \u041f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u044c \u043f\u043e\u043f\u044b\u0442\u043a\u0443, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0432\u0432\u0435\u0434\u0435\u043d\u043d\u044b\u0439 \u0412\u0430\u043c\u0438 PIN-\u043a\u043e\u0434 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439, \u0430 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 \u0432\u043a\u043b\u044e\u0447\u0435\u043d \u0438 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d \u043a \u0441\u0435\u0442\u0438.",
+ "existing_config_entry_found": "\u0421\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c VIZIO SmartCast \u0441 \u0442\u0435\u043c \u0436\u0435 \u0441\u0430\u043c\u044b\u043c \u0441\u0435\u0440\u0438\u0439\u043d\u044b\u043c \u043d\u043e\u043c\u0435\u0440\u043e\u043c \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430. \u0412\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u0443\u0434\u0430\u043b\u0438\u0442\u044c \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c, \u0447\u0442\u043e\u0431\u044b \u0441\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0435\u0451.",
"host_exists": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0445\u043e\u0441\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
"name_exists": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0441 \u0442\u0430\u043a\u0438\u043c \u0436\u0435 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043c \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
},
diff --git a/homeassistant/components/vizio/translations/zh-Hant.json b/homeassistant/components/vizio/translations/zh-Hant.json
index 0ddf8f8b88f..133c2d24381 100644
--- a/homeassistant/components/vizio/translations/zh-Hant.json
+++ b/homeassistant/components/vizio/translations/zh-Hant.json
@@ -2,11 +2,12 @@
"config": {
"abort": {
"already_configured_device": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
- "updated_entry": "\u6b64\u7269\u4ef6\u5df2\u7d93\u8a2d\u5b9a\uff0c\u4f46\u8a2d\u5b9a\u4e4b\u540d\u7a31\u3001App \u53ca/\u6216\u9078\u9805\u8207\u5148\u524d\u532f\u5165\u7684\u7269\u4ef6\u9078\u9805\u503c\u4e0d\u5408\uff0c\u56e0\u6b64\u8a2d\u5b9a\u5c07\u6703\u8ddf\u8457\u66f4\u65b0\u3002"
+ "updated_entry": "\u6b64\u5be6\u9ad4\u5df2\u7d93\u8a2d\u5b9a\uff0c\u4f46\u8a2d\u5b9a\u4e4b\u540d\u7a31\u3001App \u53ca/\u6216\u9078\u9805\u8207\u5148\u524d\u532f\u5165\u7684\u5be6\u9ad4\u9078\u9805\u503c\u4e0d\u5408\uff0c\u56e0\u6b64\u8a2d\u5b9a\u5c07\u6703\u8ddf\u8457\u66f4\u65b0\u3002"
},
"error": {
"cannot_connect": "\u9023\u7dda\u5931\u6557",
"complete_pairing_failed": "\u7121\u6cd5\u5b8c\u6210\u914d\u5c0d\uff0c\u50b3\u9001\u524d\u3001\u8acb\u78ba\u5b9a\u6240\u8f38\u5165\u7684 PIN \u78bc\u3001\u540c\u6642\u96fb\u8996\u5df2\u7d93\u958b\u555f\u4e26\u9023\u7dda\u81f3\u7db2\u8def\u3002",
+ "existing_config_entry_found": "\u5df2\u6709\u4e00\u7d44\u4f7f\u7528\u76f8\u540c\u5e8f\u865f\u7684 VIZIO SmartCast \u8a2d\u5099 \u5df2\u8a2d\u5b9a\u3002\u5fc5\u9808\u5148\u9032\u884c\u522a\u9664\u5f8c\u624d\u80fd\u91cd\u65b0\u8a2d\u5b9a\u3002",
"host_exists": "\u4f9d\u4e3b\u6a5f\u7aef\u4e4b VIZIO SmartCast \u8a2d\u5099 \u8a2d\u5b9a\u5df2\u8a2d\u5b9a\u5b8c\u6210\u3002",
"name_exists": "\u4f9d\u540d\u7a31\u4e4b VIZIO SmartCast \u8a2d\u5099 \u8a2d\u5b9a\u5df2\u8a2d\u5b9a\u5b8c\u6210\u3002"
},
diff --git a/homeassistant/components/volkszaehler/manifest.json b/homeassistant/components/volkszaehler/manifest.json
index 0e28675ce87..b133550b327 100644
--- a/homeassistant/components/volkszaehler/manifest.json
+++ b/homeassistant/components/volkszaehler/manifest.json
@@ -2,6 +2,6 @@
"domain": "volkszaehler",
"name": "Volkszaehler",
"documentation": "https://www.home-assistant.io/integrations/volkszaehler",
- "requirements": ["volkszaehler==0.1.2"],
- "codeowners": []
+ "requirements": ["volkszaehler==0.1.3"],
+ "codeowners": ["@fabaff"]
}
diff --git a/homeassistant/components/volumio/manifest.json b/homeassistant/components/volumio/manifest.json
index c5d14859f05..08c0167df0a 100644
--- a/homeassistant/components/volumio/manifest.json
+++ b/homeassistant/components/volumio/manifest.json
@@ -5,5 +5,5 @@
"codeowners": ["@OnFreund"],
"config_flow": true,
"zeroconf": ["_Volumio._tcp.local."],
- "requirements": ["pyvolumio==0.1.1"]
+ "requirements": ["pyvolumio==0.1.2"]
}
\ No newline at end of file
diff --git a/homeassistant/components/volumio/translations/pt.json b/homeassistant/components/volumio/translations/pt.json
index f681da4210f..3616141b35b 100644
--- a/homeassistant/components/volumio/translations/pt.json
+++ b/homeassistant/components/volumio/translations/pt.json
@@ -1,5 +1,12 @@
{
"config": {
+ "abort": {
+ "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado"
+ },
+ "error": {
+ "cannot_connect": "Falha na liga\u00e7\u00e3o",
+ "unknown": "Erro inesperado"
+ },
"step": {
"user": {
"data": {
diff --git a/homeassistant/components/volumio/translations/ru.json b/homeassistant/components/volumio/translations/ru.json
index 84267ff4a4e..905ecbe8e4b 100644
--- a/homeassistant/components/volumio/translations/ru.json
+++ b/homeassistant/components/volumio/translations/ru.json
@@ -1,11 +1,11 @@
{
"config": {
"abort": {
- "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
+ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.",
"cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u043e\u043c\u0443 Volumio."
},
"error": {
- "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.",
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.",
"unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
},
"step": {
diff --git a/homeassistant/components/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py
index dc6ea358acd..94bc83b7dd4 100644
--- a/homeassistant/components/wake_on_lan/switch.py
+++ b/homeassistant/components/wake_on_lan/switch.py
@@ -81,7 +81,10 @@ class WolSwitch(SwitchEntity):
self._mac_address = mac_address
self._broadcast_address = broadcast_address
self._broadcast_port = broadcast_port
- self._off_script = Script(hass, off_action) if off_action else None
+ domain = __name__.split(".")[-2]
+ self._off_script = (
+ Script(hass, off_action, name, domain) if off_action else None
+ )
self._state = False
@property
diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py
index cdac719d890..ec18880b5ba 100644
--- a/homeassistant/components/waqi/sensor.py
+++ b/homeassistant/components/waqi/sensor.py
@@ -74,15 +74,22 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
_LOGGER.debug("The following stations were returned: %s", stations)
for station in stations:
waqi_sensor = WaqiSensor(client, station)
- if not station_filter or {
- waqi_sensor.uid,
- waqi_sensor.url,
- waqi_sensor.station_name,
- } & set(station_filter):
+ if (
+ not station_filter
+ or {
+ waqi_sensor.uid,
+ waqi_sensor.url,
+ waqi_sensor.station_name,
+ }
+ & set(station_filter)
+ ):
dev.append(waqi_sensor)
- except (aiohttp.client_exceptions.ClientConnectorError, asyncio.TimeoutError):
+ except (
+ aiohttp.client_exceptions.ClientConnectorError,
+ asyncio.TimeoutError,
+ ) as err:
_LOGGER.exception("Failed to connect to WAQI servers")
- raise PlatformNotReady
+ raise PlatformNotReady from err
async_add_entities(dev, True)
diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py
index 9378694f5f3..dfb960fe819 100644
--- a/homeassistant/components/waterfurnace/sensor.py
+++ b/homeassistant/components/waterfurnace/sensor.py
@@ -1,7 +1,7 @@
"""Support for Waterfurnace."""
from homeassistant.components.sensor import ENTITY_ID_FORMAT
-from homeassistant.const import POWER_WATT, TEMP_FAHRENHEIT, UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE, POWER_WATT, TEMP_FAHRENHEIT
from homeassistant.core import callback
from homeassistant.helpers.entity import Entity
from homeassistant.util import slugify
@@ -34,10 +34,10 @@ SENSORS = [
"Loop Temp", "enteringwatertemp", "mdi:thermometer", TEMP_FAHRENHEIT
),
WFSensorConfig(
- "Humidity Set Point", "tstathumidsetpoint", "mdi:water-percent", UNIT_PERCENTAGE
+ "Humidity Set Point", "tstathumidsetpoint", "mdi:water-percent", PERCENTAGE
),
WFSensorConfig(
- "Humidity", "tstatrelativehumidity", "mdi:water-percent", UNIT_PERCENTAGE
+ "Humidity", "tstatrelativehumidity", "mdi:water-percent", PERCENTAGE
),
WFSensorConfig("Compressor Power", "compressorpower", "mdi:flash", POWER_WATT),
WFSensorConfig("Fan Power", "fanpower", "mdi:flash", POWER_WATT),
diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py
index 5a6fcc2d80b..8ddcf052e1f 100644
--- a/homeassistant/components/weather/__init__.py
+++ b/homeassistant/components/weather/__init__.py
@@ -16,6 +16,21 @@ from homeassistant.helpers.temperature import display_temp as show_temp
_LOGGER = logging.getLogger(__name__)
ATTR_CONDITION_CLASS = "condition_class"
+ATTR_CONDITION_CLEAR_NIGHT = "clear-night"
+ATTR_CONDITION_CLOUDY = "cloudy"
+ATTR_CONDITION_EXCEPTIONAL = "exceptional"
+ATTR_CONDITION_FOG = "fog"
+ATTR_CONDITION_HAIL = "hail"
+ATTR_CONDITION_LIGHTNING = "lightning"
+ATTR_CONDITION_LIGHTNING_RAINY = "lightning-rainy"
+ATTR_CONDITION_PARTLYCLOUDY = "partlycloudy"
+ATTR_CONDITION_POURING = "pouring"
+ATTR_CONDITION_RAINY = "rainy"
+ATTR_CONDITION_SNOWY = "snowy"
+ATTR_CONDITION_SNOWY_RAINY = "snowy-rainy"
+ATTR_CONDITION_SUNNY = "sunny"
+ATTR_CONDITION_WINDY = "windy"
+ATTR_CONDITION_WINDY_VARIANT = "windy-variant"
ATTR_FORECAST = "forecast"
ATTR_FORECAST_CONDITION = "condition"
ATTR_FORECAST_PRECIPITATION = "precipitation"
diff --git a/homeassistant/components/weather/translations/pl.json b/homeassistant/components/weather/translations/pl.json
index c7d387690ca..dcd70a3d0e3 100644
--- a/homeassistant/components/weather/translations/pl.json
+++ b/homeassistant/components/weather/translations/pl.json
@@ -1,21 +1,21 @@
{
"state": {
"_": {
- "clear-night": "pogodnie, noc",
- "cloudy": "pochmurno",
+ "clear-night": "Pogodna noc",
+ "cloudy": "Pochmurno",
"exceptional": "wyj\u0105tkowy",
- "fog": "mg\u0142a",
- "hail": "grad",
- "lightning": "b\u0142yskawice",
- "lightning-rainy": "burza",
- "partlycloudy": "cz\u0119\u015bciowe zachmurzenie",
- "pouring": "ulewa",
- "rainy": "deszczowo",
- "snowy": "\u015bnie\u017cnie",
- "snowy-rainy": "\u015bnie\u017cnie, deszczowo",
- "sunny": "s\u0142onecznie",
- "windy": "wietrznie",
- "windy-variant": "wietrznie"
+ "fog": "Mg\u0142a",
+ "hail": "Grad",
+ "lightning": "B\u0142yskawice",
+ "lightning-rainy": "Burza",
+ "partlycloudy": "Cz\u0119\u015bciowe zachmurzenie",
+ "pouring": "Ulewa",
+ "rainy": "Deszczowo",
+ "snowy": "\u015anie\u017cnie",
+ "snowy-rainy": "\u015anie\u017cnie, deszczowo",
+ "sunny": "S\u0142onecznie",
+ "windy": "Wietrznie",
+ "windy-variant": "Wietrznie"
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py
index 99226cabaa7..9c6dfe45e74 100644
--- a/homeassistant/components/webhook/__init__.py
+++ b/homeassistant/components/webhook/__init__.py
@@ -6,7 +6,6 @@ from aiohttp.web import Request, Response
import voluptuous as vol
from homeassistant.components import websocket_api
-from homeassistant.components.http.const import KEY_REAL_IP
from homeassistant.components.http.view import HomeAssistantView
from homeassistant.const import HTTP_OK
from homeassistant.core import callback
@@ -80,7 +79,7 @@ async def async_handle_webhook(hass, webhook_id, request):
if isinstance(request, MockRequest):
received_from = request.mock_source
else:
- received_from = request[KEY_REAL_IP]
+ received_from = request.remote
_LOGGER.warning(
"Received message for unregistered webhook %s from %s",
diff --git a/homeassistant/components/automation/webhook.py b/homeassistant/components/webhook/trigger.py
similarity index 95%
rename from homeassistant/components/automation/webhook.py
rename to homeassistant/components/webhook/trigger.py
index 5d01c6454a8..cc03f74922f 100644
--- a/homeassistant/components/automation/webhook.py
+++ b/homeassistant/components/webhook/trigger.py
@@ -9,8 +9,6 @@ from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID
from homeassistant.core import callback
import homeassistant.helpers.config_validation as cv
-from . import DOMAIN as AUTOMATION_DOMAIN
-
# mypy: allow-untyped-defs
DEPENDENCIES = ("webhook",)
@@ -32,6 +30,7 @@ async def _handle_webhook(action, hass, webhook_id, request):
result["data"] = await request.post()
result["query"] = request.query
+ result["description"] = "webhook"
hass.async_run_job(action, {"trigger": result})
@@ -39,7 +38,7 @@ async def async_attach_trigger(hass, config, action, automation_info):
"""Trigger based on incoming webhooks."""
webhook_id = config.get(CONF_WEBHOOK_ID)
hass.components.webhook.async_register(
- AUTOMATION_DOMAIN,
+ automation_info["domain"],
automation_info["name"],
webhook_id,
partial(_handle_webhook, action),
diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py
index 1cbf844b289..e53e3185651 100644
--- a/homeassistant/components/webostv/media_player.py
+++ b/homeassistant/components/webostv/media_player.py
@@ -75,7 +75,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
turn_on_action = discovery_info.get(CONF_ON_ACTION)
client = hass.data[DOMAIN][host]["client"]
- on_script = Script(hass, turn_on_action) if turn_on_action else None
+ on_script = Script(hass, turn_on_action, name, DOMAIN) if turn_on_action else None
entity = LgWebOSMediaPlayerEntity(client, name, customize, on_script)
@@ -331,7 +331,7 @@ class LgWebOSMediaPlayerEntity(MediaPlayerEntity):
async def async_turn_on(self):
"""Turn on the media player."""
if self._on_script:
- await self._on_script.async_run()
+ await self._on_script.async_run(context=self._context)
@cmd
async def async_volume_up(self):
diff --git a/homeassistant/components/webostv/services.yaml b/homeassistant/components/webostv/services.yaml
index b88b3d839d7..5719e339793 100644
--- a/homeassistant/components/webostv/services.yaml
+++ b/homeassistant/components/webostv/services.yaml
@@ -9,7 +9,7 @@ button:
button:
description: >-
Name of the button to press. Known possible values are
- LEFT, RIGHT, DOWN, UP, HOME, BACK, ENTER, DASH, INFO, ASTERISK, CC, EXIT,
+ LEFT, RIGHT, DOWN, UP, HOME, MENU, BACK, ENTER, DASH, INFO, ASTERISK, CC, EXIT,
MUTE, RED, GREEN, BLUE, VOLUMEUP, VOLUMEDOWN, CHANNELUP, CHANNELDOWN,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9
example: "LEFT"
diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py
index 60177fcde90..bfcfc796bad 100644
--- a/homeassistant/components/websocket_api/__init__.py
+++ b/homeassistant/components/websocket_api/__init__.py
@@ -15,7 +15,6 @@ DOMAIN = const.DOMAIN
DEPENDENCIES = ("http",)
# Backwards compat / Make it easier to integrate
-# pylint: disable=invalid-name
ActiveConnection = connection.ActiveConnection
BASE_COMMAND_MESSAGE_SCHEMA = messages.BASE_COMMAND_MESSAGE_SCHEMA
error_message = messages.error_message
@@ -25,7 +24,6 @@ async_response = decorators.async_response
require_admin = decorators.require_admin
ws_require_user = decorators.ws_require_user
websocket_command = decorators.websocket_command
-# pylint: enable=invalid-name
@bind_hass
diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py
index f5b29f49b1e..3c795902900 100644
--- a/homeassistant/components/websocket_api/auth.py
+++ b/homeassistant/components/websocket_api/auth.py
@@ -62,7 +62,7 @@ class AuthPhase:
)
self._logger.warning(error_msg)
self._send_message(auth_invalid_message(error_msg))
- raise Disconnect
+ raise Disconnect from err
if "access_token" in msg:
self._logger.debug("Received access_token")
diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py
index d7e2fa1ac83..036cd690da2 100644
--- a/homeassistant/components/websocket_api/commands.py
+++ b/homeassistant/components/websocket_api/commands.py
@@ -1,19 +1,28 @@
"""Commands part of Websocket API."""
import asyncio
+import logging
import voluptuous as vol
-from homeassistant.auth.permissions.const import POLICY_READ
+from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_READ
+from homeassistant.components.websocket_api.const import ERR_NOT_FOUND
from homeassistant.const import EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL
from homeassistant.core import DOMAIN as HASS_DOMAIN, callback
-from homeassistant.exceptions import HomeAssistantError, ServiceNotFound, Unauthorized
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.event import async_track_state_change_event
+from homeassistant.exceptions import (
+ HomeAssistantError,
+ ServiceNotFound,
+ TemplateError,
+ Unauthorized,
+)
+from homeassistant.helpers import config_validation as cv, entity
+from homeassistant.helpers.event import TrackTemplate, async_track_template_result
from homeassistant.helpers.service import async_get_all_descriptions
from homeassistant.loader import IntegrationNotFound, async_get_integration
from . import const, decorators, messages
+_LOGGER = logging.getLogger(__name__)
+
# mypy: allow-untyped-calls, allow-untyped-defs
@@ -30,6 +39,9 @@ def async_register_commands(hass, async_reg):
async_reg(hass, handle_render_template)
async_reg(hass, handle_manifest_list)
async_reg(hass, handle_manifest_get)
+ async_reg(hass, handle_entity_source)
+ async_reg(hass, handle_subscribe_trigger)
+ async_reg(hass, handle_test_condition)
def pong_message(iden):
@@ -241,25 +253,143 @@ def handle_render_template(hass, connection, msg):
template.hass = hass
variables = msg.get("variables")
-
- entity_ids = msg.get("entity_ids")
- if entity_ids is None:
- entity_ids = template.extract_entities(variables)
+ info = None
@callback
- def state_listener(*_):
+ def _template_listener(event, updates):
+ nonlocal info
+ track_template_result = updates.pop()
+ result = track_template_result.result
+ if isinstance(result, TemplateError):
+ _LOGGER.error(
+ "TemplateError('%s') " "while processing template '%s'",
+ result,
+ track_template_result.template,
+ )
+
+ result = None
+
connection.send_message(
messages.event_message(
- msg["id"], {"result": template.async_render(variables)}
+ msg["id"], {"result": result, "listeners": info.listeners} # type: ignore
)
)
- if entity_ids and entity_ids != MATCH_ALL:
- connection.subscriptions[msg["id"]] = async_track_state_change_event(
- hass, entity_ids, state_listener
- )
- else:
- connection.subscriptions[msg["id"]] = lambda: None
+ info = async_track_template_result(
+ hass, [TrackTemplate(template, variables)], _template_listener
+ )
+
+ connection.subscriptions[msg["id"]] = info.async_remove
connection.send_result(msg["id"])
- state_listener()
+
+ hass.loop.call_soon_threadsafe(info.async_refresh)
+
+
+@callback
+@decorators.websocket_command(
+ {vol.Required("type"): "entity/source", vol.Optional("entity_id"): [cv.entity_id]}
+)
+def handle_entity_source(hass, connection, msg):
+ """Handle entity source command."""
+ raw_sources = entity.entity_sources(hass)
+ entity_perm = connection.user.permissions.check_entity
+
+ if "entity_id" not in msg:
+ if connection.user.permissions.access_all_entities("read"):
+ sources = raw_sources
+ else:
+ sources = {
+ entity_id: source
+ for entity_id, source in raw_sources.items()
+ if entity_perm(entity_id, "read")
+ }
+
+ connection.send_message(messages.result_message(msg["id"], sources))
+ return
+
+ sources = {}
+
+ for entity_id in msg["entity_id"]:
+ if not entity_perm(entity_id, "read"):
+ raise Unauthorized(
+ context=connection.context(msg),
+ permission=POLICY_READ,
+ perm_category=CAT_ENTITIES,
+ )
+
+ source = raw_sources.get(entity_id)
+
+ if source is None:
+ connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found")
+ return
+
+ sources[entity_id] = source
+
+ connection.send_result(msg["id"], sources)
+
+
+@callback
+@decorators.websocket_command(
+ {
+ vol.Required("type"): "subscribe_trigger",
+ vol.Required("trigger"): cv.TRIGGER_SCHEMA,
+ vol.Optional("variables"): dict,
+ }
+)
+@decorators.require_admin
+@decorators.async_response
+async def handle_subscribe_trigger(hass, connection, msg):
+ """Handle subscribe trigger command."""
+ # Circular dep
+ # pylint: disable=import-outside-toplevel
+ from homeassistant.helpers import trigger
+
+ trigger_config = await trigger.async_validate_trigger_config(hass, msg["trigger"])
+
+ @callback
+ def forward_triggers(variables, context=None):
+ """Forward events to websocket."""
+ connection.send_message(
+ messages.event_message(
+ msg["id"], {"variables": variables, "context": context}
+ )
+ )
+
+ connection.subscriptions[msg["id"]] = (
+ await trigger.async_initialize_triggers(
+ hass,
+ trigger_config,
+ forward_triggers,
+ const.DOMAIN,
+ const.DOMAIN,
+ connection.logger.log,
+ variables=msg.get("variables"),
+ )
+ ) or (
+ # Some triggers won't return an unsub function. Since the caller expects
+ # a subscription, we're going to fake one.
+ lambda: None
+ )
+ connection.send_result(msg["id"])
+
+
+@decorators.websocket_command(
+ {
+ vol.Required("type"): "test_condition",
+ vol.Required("condition"): cv.CONDITION_SCHEMA,
+ vol.Optional("variables"): dict,
+ }
+)
+@decorators.require_admin
+@decorators.async_response
+async def handle_test_condition(hass, connection, msg):
+ """Handle test condition command."""
+ # Circular dep
+ # pylint: disable=import-outside-toplevel
+ from homeassistant.helpers import condition
+
+ check_condition = await condition.async_from_config(hass, msg["condition"])
+ connection.send_result(
+ msg["id"], {"result": check_condition(hass, msg.get("variables"))}
+ )
diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py
index 121ea7496de..f01a2880b9d 100644
--- a/homeassistant/components/websocket_api/const.py
+++ b/homeassistant/components/websocket_api/const.py
@@ -23,6 +23,7 @@ MAX_PENDING_MSG = 2048
ERR_ID_REUSE = "id_reuse"
ERR_INVALID_FORMAT = "invalid_format"
ERR_NOT_FOUND = "not_found"
+ERR_NOT_SUPPORTED = "not_supported"
ERR_HOME_ASSISTANT_ERROR = "home_assistant_error"
ERR_UNKNOWN_COMMAND = "unknown_command"
ERR_UNKNOWN_ERROR = "unknown_error"
diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py
index ab412e06583..7c56fcbc606 100644
--- a/homeassistant/components/websocket_api/http.py
+++ b/homeassistant/components/websocket_api/http.py
@@ -180,9 +180,9 @@ class WebSocketHandler:
try:
with async_timeout.timeout(10):
msg = await wsock.receive()
- except asyncio.TimeoutError:
+ except asyncio.TimeoutError as err:
disconnect_warn = "Did not receive auth message within 10 seconds"
- raise Disconnect
+ raise Disconnect from err
if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING):
raise Disconnect
@@ -193,9 +193,9 @@ class WebSocketHandler:
try:
msg_data = msg.json()
- except ValueError:
+ except ValueError as err:
disconnect_warn = "Received invalid JSON."
- raise Disconnect
+ raise Disconnect from err
self._logger.debug("Received %s", msg_data)
connection = await auth.async_handle(msg_data)
diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py
index 9cac85dee09..0594747d0b2 100644
--- a/homeassistant/components/wemo/__init__.py
+++ b/homeassistant/components/wemo/__init__.py
@@ -121,7 +121,8 @@ async def async_setup_entry(hass, entry):
_LOGGER.debug("Scanning network for WeMo devices...")
for device in await hass.async_add_executor_job(pywemo.discover_devices):
devices.setdefault(
- device.serialnumber, device,
+ device.serialnumber,
+ device,
)
loaded_components = set()
@@ -153,7 +154,9 @@ async def async_setup_entry(hass, entry):
else:
async_dispatcher_send(
- hass, f"{DOMAIN}.{component}", device,
+ hass,
+ f"{DOMAIN}.{component}",
+ device,
)
return True
@@ -172,7 +175,10 @@ def validate_static_config(host, port):
try:
device = pywemo.discovery.device_from_description(url, None)
- except (requests.exceptions.ConnectionError, requests.exceptions.Timeout,) as err:
+ except (
+ requests.exceptions.ConnectionError,
+ requests.exceptions.Timeout,
+ ) as err:
_LOGGER.error("Unable to access WeMo at %s (%s)", url, err)
return None
diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py
index f2cb46fa32c..1bc477277c9 100644
--- a/homeassistant/components/wemo/fan.py
+++ b/homeassistant/components/wemo/fan.py
@@ -129,7 +129,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
# Register service(s)
hass.services.async_register(
- WEMO_DOMAIN, SERVICE_SET_HUMIDITY, service_handle, schema=SET_HUMIDITY_SCHEMA,
+ WEMO_DOMAIN,
+ SERVICE_SET_HUMIDITY,
+ service_handle,
+ schema=SET_HUMIDITY_SCHEMA,
)
hass.services.async_register(
diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json
index 6200c3b46ed..bc9755414c6 100644
--- a/homeassistant/components/wemo/manifest.json
+++ b/homeassistant/components/wemo/manifest.json
@@ -10,7 +10,7 @@
}
],
"homekit": {
- "models": ["Wemo"]
+ "models": ["Socket", "Wemo"]
},
"codeowners": []
}
diff --git a/homeassistant/components/whois/manifest.json b/homeassistant/components/whois/manifest.json
index 4330604f9bd..39cc1c194c8 100644
--- a/homeassistant/components/whois/manifest.json
+++ b/homeassistant/components/whois/manifest.json
@@ -2,6 +2,6 @@
"domain": "whois",
"name": "Whois",
"documentation": "https://www.home-assistant.io/integrations/whois",
- "requirements": ["python-whois==0.7.2"],
+ "requirements": ["python-whois==0.7.3"],
"codeowners": []
}
diff --git a/homeassistant/components/wilight/__init__.py b/homeassistant/components/wilight/__init__.py
new file mode 100644
index 00000000000..8821190bd32
--- /dev/null
+++ b/homeassistant/components/wilight/__init__.py
@@ -0,0 +1,125 @@
+"""The WiLight integration."""
+import asyncio
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.entity import Entity
+
+from .const import DOMAIN
+from .parent_device import WiLightParent
+
+# List the platforms that you want to support.
+PLATFORMS = ["light"]
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+ """Set up the WiLight with Config Flow component."""
+
+ hass.data[DOMAIN] = {}
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """Set up a wilight config entry."""
+
+ parent = WiLightParent(hass, entry)
+
+ if not await parent.async_setup():
+ raise ConfigEntryNotReady
+
+ hass.data[DOMAIN][entry.entry_id] = parent
+
+ # Set up all platforms for this device/entry.
+ 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):
+ """Unload WiLight config entry."""
+
+ # Unload entities for this entry/device.
+ await asyncio.gather(
+ *(
+ hass.config_entries.async_forward_entry_unload(entry, component)
+ for component in PLATFORMS
+ )
+ )
+
+ # Cleanup
+ parent = hass.data[DOMAIN][entry.entry_id]
+ await parent.async_reset()
+ del hass.data[DOMAIN][entry.entry_id]
+
+ return True
+
+
+class WiLightDevice(Entity):
+ """Representation of a WiLight device.
+
+ Contains the common logic for WiLight entities.
+ """
+
+ def __init__(self, api_device, index, item_name):
+ """Initialize the device."""
+ # WiLight specific attributes for every component type
+ self._device_id = api_device.device_id
+ self._sw_version = api_device.swversion
+ self._client = api_device.client
+ self._model = api_device.model
+ self._name = item_name
+ self._index = index
+ self._unique_id = f"{self._device_id}_{self._index}"
+ self._status = {}
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return a name for this WiLight item."""
+ return self._name
+
+ @property
+ def unique_id(self):
+ """Return the unique ID for this WiLight item."""
+ return self._unique_id
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ "name": self._name,
+ "identifiers": {(DOMAIN, self._unique_id)},
+ "model": self._model,
+ "manufacturer": "WiLight",
+ "sw_version": self._sw_version,
+ "via_device": (DOMAIN, self._device_id),
+ }
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return bool(self._client.is_connected)
+
+ @callback
+ def handle_event_callback(self, states):
+ """Propagate changes through ha."""
+ self._status = states
+ self.async_write_ha_state()
+
+ async def async_update(self):
+ """Synchronize state with api_device."""
+ await self._client.status(self._index)
+
+ async def async_added_to_hass(self):
+ """Register update callback."""
+ self._client.register_status_callback(self.handle_event_callback, self._index)
+ await self._client.status(self._index)
diff --git a/homeassistant/components/wilight/config_flow.py b/homeassistant/components/wilight/config_flow.py
new file mode 100644
index 00000000000..724bbb6547d
--- /dev/null
+++ b/homeassistant/components/wilight/config_flow.py
@@ -0,0 +1,106 @@
+"""Config flow to configure WiLight."""
+import logging
+from urllib.parse import urlparse
+
+import pywilight
+
+from homeassistant.components import ssdp
+from homeassistant.config_entries import CONN_CLASS_LOCAL_PUSH, ConfigFlow
+from homeassistant.const import CONF_HOST
+
+from .const import DOMAIN # pylint: disable=unused-import
+
+CONF_SERIAL_NUMBER = "serial_number"
+CONF_MODEL_NAME = "model_name"
+
+WILIGHT_MANUFACTURER = "All Automacao Ltda"
+
+# List the components supported by this integration.
+ALLOWED_WILIGHT_COMPONENTS = ["light"]
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class WiLightFlowHandler(ConfigFlow, domain=DOMAIN):
+ """Handle a WiLight config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = CONN_CLASS_LOCAL_PUSH
+
+ def __init__(self):
+ """Initialize the WiLight flow."""
+ self._host = None
+ self._serial_number = None
+ self._title = None
+ self._model_name = None
+ self._wilight_components = []
+ self._components_text = ""
+
+ def _wilight_update(self, host, serial_number, model_name):
+ self._host = host
+ self._serial_number = serial_number
+ self._title = f"WL{serial_number}"
+ self._model_name = model_name
+ self._wilight_components = pywilight.get_components_from_model(model_name)
+ self._components_text = ", ".join(self._wilight_components)
+ return self._components_text != ""
+
+ def _get_entry(self):
+ data = {
+ CONF_HOST: self._host,
+ CONF_SERIAL_NUMBER: self._serial_number,
+ CONF_MODEL_NAME: self._model_name,
+ }
+ return self.async_create_entry(title=self._title, data=data)
+
+ async def async_step_ssdp(self, discovery_info):
+ """Handle a discovered WiLight."""
+ # Filter out basic information
+ if (
+ ssdp.ATTR_SSDP_LOCATION not in discovery_info
+ or ssdp.ATTR_UPNP_MANUFACTURER not in discovery_info
+ or ssdp.ATTR_UPNP_SERIAL not in discovery_info
+ or ssdp.ATTR_UPNP_MODEL_NAME not in discovery_info
+ or ssdp.ATTR_UPNP_MODEL_NUMBER not in discovery_info
+ ):
+ return self.async_abort(reason="not_wilight_device")
+ # Filter out non-WiLight devices
+ if discovery_info[ssdp.ATTR_UPNP_MANUFACTURER] != WILIGHT_MANUFACTURER:
+ return self.async_abort(reason="not_wilight_device")
+
+ host = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname
+ serial_number = discovery_info[ssdp.ATTR_UPNP_SERIAL]
+ model_name = discovery_info[ssdp.ATTR_UPNP_MODEL_NAME]
+
+ if not self._wilight_update(host, serial_number, model_name):
+ return self.async_abort(reason="not_wilight_device")
+
+ # Check if all components of this WiLight are allowed in this version of the HA integration
+ component_ok = all(
+ wilight_component in ALLOWED_WILIGHT_COMPONENTS
+ for wilight_component in self._wilight_components
+ )
+
+ if not component_ok:
+ return self.async_abort(reason="not_supported_device")
+
+ await self.async_set_unique_id(self._serial_number)
+ self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
+
+ # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167
+ self.context["title_placeholders"] = {"name": self._title}
+ return await self.async_step_confirm()
+
+ async def async_step_confirm(self, user_input=None):
+ """Handle user-confirmation of discovered WiLight."""
+ if user_input is not None:
+ return self._get_entry()
+
+ return self.async_show_form(
+ step_id="confirm",
+ description_placeholders={
+ "name": self._title,
+ "components": self._components_text,
+ },
+ errors={},
+ )
diff --git a/homeassistant/components/wilight/const.py b/homeassistant/components/wilight/const.py
new file mode 100644
index 00000000000..a3d77da44ef
--- /dev/null
+++ b/homeassistant/components/wilight/const.py
@@ -0,0 +1,14 @@
+"""Constants for the WiLight integration."""
+
+DOMAIN = "wilight"
+
+# Item types
+ITEM_LIGHT = "light"
+
+# Light types
+LIGHT_ON_OFF = "light_on_off"
+LIGHT_DIMMER = "light_dimmer"
+LIGHT_COLOR = "light_rgb"
+
+# Light service support
+SUPPORT_NONE = 0
diff --git a/homeassistant/components/wilight/light.py b/homeassistant/components/wilight/light.py
new file mode 100644
index 00000000000..e4bf504165d
--- /dev/null
+++ b/homeassistant/components/wilight/light.py
@@ -0,0 +1,179 @@
+"""Support for WiLight lights."""
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS,
+ ATTR_HS_COLOR,
+ SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR,
+ LightEntity,
+)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+
+from . import WiLightDevice
+from .const import (
+ DOMAIN,
+ ITEM_LIGHT,
+ LIGHT_COLOR,
+ LIGHT_DIMMER,
+ LIGHT_ON_OFF,
+ SUPPORT_NONE,
+)
+
+
+def entities_from_discovered_wilight(hass, api_device):
+ """Parse configuration and add WiLight light entities."""
+ entities = []
+ for item in api_device.items:
+ if item["type"] != ITEM_LIGHT:
+ continue
+ index = item["index"]
+ item_name = item["name"]
+ if item["sub_type"] == LIGHT_ON_OFF:
+ entity = WiLightLightOnOff(api_device, index, item_name)
+ elif item["sub_type"] == LIGHT_DIMMER:
+ entity = WiLightLightDimmer(api_device, index, item_name)
+ elif item["sub_type"] == LIGHT_COLOR:
+ entity = WiLightLightColor(api_device, index, item_name)
+ else:
+ continue
+ entities.append(entity)
+
+ return entities
+
+
+async def async_setup_entry(
+ hass: HomeAssistant, entry: ConfigEntry, async_add_entities
+):
+ """Set up WiLight lights from a config entry."""
+ parent = hass.data[DOMAIN][entry.entry_id]
+
+ # Handle a discovered WiLight device.
+ entities = entities_from_discovered_wilight(hass, parent.api)
+ async_add_entities(entities)
+
+
+class WiLightLightOnOff(WiLightDevice, LightEntity):
+ """Representation of a WiLights light on-off."""
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_NONE
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._status.get("on")
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ await self._client.turn_on(self._index)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off."""
+ await self._client.turn_off(self._index)
+
+
+class WiLightLightDimmer(WiLightDevice, LightEntity):
+ """Representation of a WiLights light dimmer."""
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return int(self._status.get("brightness", 0))
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._status.get("on")
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on,set brightness if needed."""
+ # Dimmer switches use a range of [0, 255] to control
+ # brightness. Level 255 might mean to set it to previous value
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = kwargs[ATTR_BRIGHTNESS]
+ await self._client.set_brightness(self._index, brightness)
+ else:
+ await self._client.turn_on(self._index)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off."""
+ await self._client.turn_off(self._index)
+
+
+def wilight_to_hass_hue(value):
+ """Convert wilight hue 1..255 to hass 0..360 scale."""
+ return min(360, round((value * 360) / 255, 3))
+
+
+def hass_to_wilight_hue(value):
+ """Convert hass hue 0..360 to wilight 1..255 scale."""
+ return min(255, round((value * 255) / 360))
+
+
+def wilight_to_hass_saturation(value):
+ """Convert wilight saturation 1..255 to hass 0..100 scale."""
+ return min(100, round((value * 100) / 255, 3))
+
+
+def hass_to_wilight_saturation(value):
+ """Convert hass saturation 0..100 to wilight 1..255 scale."""
+ return min(255, round((value * 255) / 100))
+
+
+class WiLightLightColor(WiLightDevice, LightEntity):
+ """Representation of a WiLights light rgb."""
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS | SUPPORT_COLOR
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return int(self._status.get("brightness", 0))
+
+ @property
+ def hs_color(self):
+ """Return the hue and saturation color value [float, float]."""
+ return [
+ wilight_to_hass_hue(int(self._status.get("hue", 0))),
+ wilight_to_hass_saturation(int(self._status.get("saturation", 0))),
+ ]
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._status.get("on")
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on,set brightness if needed."""
+ # Brightness use a range of [0, 255] to control
+ # Hue use a range of [0, 360] to control
+ # Saturation use a range of [0, 100] to control
+ if ATTR_BRIGHTNESS in kwargs and ATTR_HS_COLOR in kwargs:
+ brightness = kwargs[ATTR_BRIGHTNESS]
+ hue = hass_to_wilight_hue(kwargs[ATTR_HS_COLOR][0])
+ saturation = hass_to_wilight_saturation(kwargs[ATTR_HS_COLOR][1])
+ await self._client.set_hsb_color(self._index, hue, saturation, brightness)
+ elif ATTR_BRIGHTNESS in kwargs and ATTR_HS_COLOR not in kwargs:
+ brightness = kwargs[ATTR_BRIGHTNESS]
+ await self._client.set_brightness(self._index, brightness)
+ elif ATTR_BRIGHTNESS not in kwargs and ATTR_HS_COLOR in kwargs:
+ hue = hass_to_wilight_hue(kwargs[ATTR_HS_COLOR][0])
+ saturation = hass_to_wilight_saturation(kwargs[ATTR_HS_COLOR][1])
+ await self._client.set_hs_color(self._index, hue, saturation)
+ else:
+ await self._client.turn_on(self._index)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off."""
+ await self._client.turn_off(self._index)
diff --git a/homeassistant/components/wilight/manifest.json b/homeassistant/components/wilight/manifest.json
new file mode 100644
index 00000000000..bb20da2b1ce
--- /dev/null
+++ b/homeassistant/components/wilight/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "wilight",
+ "name": "WiLight",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/integrations/wilight",
+ "requirements": ["pywilight==0.0.65"],
+ "ssdp": [
+ {
+ "manufacturer": "All Automacao Ltda"
+ }
+ ],
+ "codeowners": ["@leofig-rj"],
+ "quality_scale": "silver"
+}
diff --git a/homeassistant/components/wilight/parent_device.py b/homeassistant/components/wilight/parent_device.py
new file mode 100644
index 00000000000..a53bc352a7b
--- /dev/null
+++ b/homeassistant/components/wilight/parent_device.py
@@ -0,0 +1,105 @@
+"""The WiLight Device integration."""
+import asyncio
+import logging
+
+import pywilight
+import requests
+
+from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class WiLightParent:
+ """Manages a single WiLight Parent Device."""
+
+ def __init__(self, hass, config_entry):
+ """Initialize the system."""
+ self._host = config_entry.data[CONF_HOST]
+ self._hass = hass
+ self._api = None
+
+ @property
+ def host(self):
+ """Return the host of this parent."""
+ return self._host
+
+ @property
+ def api(self):
+ """Return the api of this parent."""
+ return self._api
+
+ async def async_setup(self):
+ """Set up a WiLight Parent Device based on host parameter."""
+ host = self._host
+ hass = self._hass
+
+ api_device = await hass.async_add_executor_job(create_api_device, host)
+
+ if api_device is None:
+ return False
+
+ @callback
+ def disconnected():
+ # Schedule reconnect after connection has been lost.
+ _LOGGER.warning("WiLight %s disconnected", api_device.device_id)
+ async_dispatcher_send(
+ hass, f"wilight_device_available_{api_device.device_id}", False
+ )
+
+ @callback
+ def reconnected():
+ # Schedule reconnect after connection has been lost.
+ _LOGGER.warning("WiLight %s reconnect", api_device.device_id)
+ async_dispatcher_send(
+ hass, f"wilight_device_available_{api_device.device_id}", True
+ )
+
+ async def connect(api_device):
+ # Set up connection and hook it into HA for reconnect/shutdown.
+ _LOGGER.debug("Initiating connection to %s", api_device.device_id)
+
+ client = await api_device.config_client(
+ disconnect_callback=disconnected,
+ reconnect_callback=reconnected,
+ loop=asyncio.get_running_loop(),
+ logger=_LOGGER,
+ )
+
+ # handle shutdown of WiLight asyncio transport
+ hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, lambda x: client.stop()
+ )
+
+ _LOGGER.info("Connected to WiLight device: %s", api_device.device_id)
+
+ await connect(api_device)
+
+ self._api = api_device
+
+ return True
+
+ async def async_reset(self):
+ """Reset api."""
+
+ # If the initialization was wrong.
+ if self._api is None:
+ return True
+
+ self._api.client.stop()
+
+
+def create_api_device(host):
+ """Create an API Device."""
+ try:
+ device = pywilight.device_from_host(host)
+ except (
+ requests.exceptions.ConnectionError,
+ requests.exceptions.Timeout,
+ ) as err:
+ _LOGGER.error("Unable to access WiLight at %s (%s)", host, err)
+ return None
+
+ return device
diff --git a/homeassistant/components/wilight/strings.json b/homeassistant/components/wilight/strings.json
new file mode 100644
index 00000000000..710543a5a53
--- /dev/null
+++ b/homeassistant/components/wilight/strings.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "flow_title": "WiLight: {name}",
+ "step": {
+ "confirm": {
+ "title": "WiLight",
+ "description": "Do you want to set up WiLight {name}?\n\n It supports: {components}"
+ }
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "not_supported_device": "This WiLight is currently not supported",
+ "not_wilight_device": "This Device is not WiLight"
+ }
+ }
+}
diff --git a/homeassistant/components/wilight/translations/ca.json b/homeassistant/components/wilight/translations/ca.json
new file mode 100644
index 00000000000..5920e54d258
--- /dev/null
+++ b/homeassistant/components/wilight/translations/ca.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositiu ja est\u00e0 configurat",
+ "not_supported_device": "Actualment aquest WiLight no \u00e9s compatible",
+ "not_wilight_device": "Aquest dispositiu no \u00e9s WiLight"
+ },
+ "flow_title": "WiLight: {name}",
+ "step": {
+ "confirm": {
+ "description": "Voleu configurar el WiLight {name}? \n\n Admet: {components}",
+ "title": "WiLight"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wilight/translations/cs.json b/homeassistant/components/wilight/translations/cs.json
new file mode 100644
index 00000000000..0dcaaf15e5d
--- /dev/null
+++ b/homeassistant/components/wilight/translations/cs.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "not_supported_device": "Toto za\u0159\u00edzen\u00ed WiLight nen\u00ed moment\u00e1ln\u011b podporov\u00e1no.",
+ "not_wilight_device": "Toto za\u0159\u00edzen\u00ed nen\u00ed WiLight"
+ },
+ "flow_title": "WiLight: {name}",
+ "step": {
+ "confirm": {
+ "description": "Chcete nastavit WiLight {name} ? \n\n Podporuje: {components}",
+ "title": "WiLight"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wilight/translations/en.json b/homeassistant/components/wilight/translations/en.json
new file mode 100644
index 00000000000..14724af4ae7
--- /dev/null
+++ b/homeassistant/components/wilight/translations/en.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Device is already configured",
+ "not_supported_device": "This WiLight is currently not supported",
+ "not_wilight_device": "This Device is not WiLight"
+ },
+ "flow_title": "WiLight: {name}",
+ "step": {
+ "confirm": {
+ "description": "Do you want to set up WiLight {name}?\n\n It supports: {components}",
+ "title": "WiLight"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wilight/translations/es.json b/homeassistant/components/wilight/translations/es.json
new file mode 100644
index 00000000000..b0104e6e44c
--- /dev/null
+++ b/homeassistant/components/wilight/translations/es.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositivo ya est\u00e1 configurado",
+ "not_supported_device": "Este WiLight no es compatible actualmente",
+ "not_wilight_device": "Este dispositivo no es un Wilight"
+ },
+ "flow_title": "WiLight: {name}",
+ "step": {
+ "confirm": {
+ "description": "\u00bfQuieres configurar WiLight {name} ? \n\n Es compatible con: {components}",
+ "title": "WiLight"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wilight/translations/fr.json b/homeassistant/components/wilight/translations/fr.json
new file mode 100644
index 00000000000..5d54070b401
--- /dev/null
+++ b/homeassistant/components/wilight/translations/fr.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9",
+ "not_supported_device": "Ce WiLight n'est actuellement pas pris en charge",
+ "not_wilight_device": "Cet appareil n'est pas WiLight"
+ },
+ "flow_title": "WiLight: {name}",
+ "step": {
+ "confirm": {
+ "description": "Voulez-vous configurer WiLight {name} ? \n\n Il prend en charge: {components}",
+ "title": "WiLight"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wilight/translations/it.json b/homeassistant/components/wilight/translations/it.json
new file mode 100644
index 00000000000..eb9cdcc8a55
--- /dev/null
+++ b/homeassistant/components/wilight/translations/it.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato",
+ "not_supported_device": "Questo WiLight non \u00e8 attualmente supportato",
+ "not_wilight_device": "Questo dispositivo non \u00e8 WiLight"
+ },
+ "flow_title": "WiLight: {name}",
+ "step": {
+ "confirm": {
+ "description": "Vuoi configurare WiLight {name}? \n\nSupporta: {components}",
+ "title": "WiLight"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wilight/translations/lb.json b/homeassistant/components/wilight/translations/lb.json
new file mode 100644
index 00000000000..2c0b831098e
--- /dev/null
+++ b/homeassistant/components/wilight/translations/lb.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Apparat ass scho konfigur\u00e9iert",
+ "not_supported_device": "D\u00ebse WiLight g\u00ebtt aktuell net \u00ebnnerst\u00ebtzt",
+ "not_wilight_device": "D\u00ebsen Apparat ass kee WiLight"
+ },
+ "flow_title": "Wilight: {name}",
+ "step": {
+ "confirm": {
+ "description": "Soll WiLight {name} konfigur\u00e9iert ginn?\n\nEt \u00ebnnerst\u00ebtzt: {components}",
+ "title": "WiLight"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wilight/translations/no.json b/homeassistant/components/wilight/translations/no.json
new file mode 100644
index 00000000000..d57458d49ea
--- /dev/null
+++ b/homeassistant/components/wilight/translations/no.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Enheten er allerede konfigurert",
+ "not_supported_device": "Dette WiLight st\u00f8ttes forel\u00f8pig ikke",
+ "not_wilight_device": "Denne enheten er ikke WiLight"
+ },
+ "flow_title": "WiLight: {name}",
+ "step": {
+ "confirm": {
+ "description": "Vil du konfigurere WiLight {name} ? \n\n Den st\u00f8tter: {components}",
+ "title": "WiLight"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wilight/translations/pt.json b/homeassistant/components/wilight/translations/pt.json
new file mode 100644
index 00000000000..5b1005a95f5
--- /dev/null
+++ b/homeassistant/components/wilight/translations/pt.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado",
+ "not_supported_device": "Este WiLight n\u00e3o \u00e9 compat\u00edvel atualmente",
+ "not_wilight_device": "Este dispositivo n\u00e3o \u00e9 WiLight"
+ },
+ "flow_title": "WiLight: {name}",
+ "step": {
+ "confirm": {
+ "description": "Deseja configurar o WiLight {name} ? \n\n Suporta: {components}",
+ "title": "WiLight"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wilight/translations/ru.json b/homeassistant/components/wilight/translations/ru.json
new file mode 100644
index 00000000000..9842b810f38
--- /dev/null
+++ b/homeassistant/components/wilight/translations/ru.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.",
+ "not_supported_device": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0432 \u043d\u0430\u0441\u0442\u043e\u044f\u0449\u0435\u0435 \u0432\u0440\u0435\u043c\u044f \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f.",
+ "not_wilight_device": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 WiLight."
+ },
+ "flow_title": "WiLight: {name}",
+ "step": {
+ "confirm": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c WiLight {name}? \n\n\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442: {components}",
+ "title": "WiLight"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wilight/translations/zh-Hant.json b/homeassistant/components/wilight/translations/zh-Hant.json
new file mode 100644
index 00000000000..8859e831d57
--- /dev/null
+++ b/homeassistant/components/wilight/translations/zh-Hant.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "not_supported_device": "\u4e0d\u652f\u63f4\u6b64\u6b3e WiLight \u8a2d\u5099\u3002",
+ "not_wilight_device": "\u6b64\u8a2d\u5099\u4e26\u975e WiLight"
+ },
+ "flow_title": "WiLight\uff1a{name}",
+ "step": {
+ "confirm": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a WiLight {name}\uff1f\n\n\u652f\u63f4\uff1a{components}",
+ "title": "WiLight"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py
index 2c2add94527..702479c4112 100644
--- a/homeassistant/components/wirelesstag/__init__.py
+++ b/homeassistant/components/wirelesstag/__init__.py
@@ -11,7 +11,7 @@ from homeassistant.const import (
ATTR_VOLTAGE,
CONF_PASSWORD,
CONF_USERNAME,
- UNIT_PERCENTAGE,
+ PERCENTAGE,
VOLT,
)
import homeassistant.helpers.config_validation as cv
@@ -122,7 +122,8 @@ class WirelessTagPlatform:
)
else:
_LOGGER.info(
- "Installed push notifications for all tags in %s", mac,
+ "Installed push notifications for all tags in %s",
+ mac,
)
@property
@@ -282,5 +283,5 @@ class WirelessTagBaseSensor(Entity):
ATTR_VOLTAGE: f"{self._tag.battery_volts:.2f}{VOLT}",
ATTR_TAG_SIGNAL_STRENGTH: f"{self._tag.signal_strength}dBm",
ATTR_TAG_OUT_OF_RANGE: not self._tag.is_in_range,
- ATTR_TAG_POWER_CONSUMPTION: f"{self._tag.power_consumption:.2f}{UNIT_PERCENTAGE}",
+ ATTR_TAG_POWER_CONSUMPTION: f"{self._tag.power_consumption:.2f}{PERCENTAGE}",
}
diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py
index 2f3f4bf849a..af81c0e68d3 100644
--- a/homeassistant/components/withings/common.py
+++ b/homeassistant/components/withings/common.py
@@ -30,9 +30,9 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_WEBHOOK_ID,
MASS_KILOGRAMS,
+ PERCENTAGE,
SPEED_METERS_PER_SECOND,
TIME_SECONDS,
- UNIT_PERCENTAGE,
)
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
@@ -53,7 +53,10 @@ from . import const
from .const import Measurement
_LOGGER = logging.getLogger(const.LOG_NAMESPACE)
-NOT_AUTHENTICATED_ERROR = re.compile("^401,.*", re.IGNORECASE,)
+NOT_AUTHENTICATED_ERROR = re.compile(
+ "^401,.*",
+ re.IGNORECASE,
+)
DATA_UPDATED_SIGNAL = "withings_entity_state_updated"
MeasurementData = Dict[Measurement, Any]
@@ -208,7 +211,7 @@ WITHINGS_ATTRIBUTES = [
Measurement.FAT_RATIO_PCT,
MeasureType.FAT_RATIO,
"Fat Ratio",
- UNIT_PERCENTAGE,
+ PERCENTAGE,
None,
SENSOR_DOMAIN,
True,
@@ -248,7 +251,7 @@ WITHINGS_ATTRIBUTES = [
Measurement.SPO2_PCT,
MeasureType.SP02,
"SP02",
- UNIT_PERCENTAGE,
+ PERCENTAGE,
None,
SENSOR_DOMAIN,
True,
@@ -621,8 +624,8 @@ class DataManager:
def empty_listener() -> None:
pass
- self._cancel_subscription_update = self.subscription_update_coordinator.async_add_listener(
- empty_listener
+ self._cancel_subscription_update = (
+ self.subscription_update_coordinator.async_add_listener(empty_listener)
)
def async_stop_polling_webhook_subscriptions(self) -> None:
@@ -753,7 +756,8 @@ class DataManager:
# Start a reauth flow.
await self._hass.config_entries.flow.async_init(
- const.DOMAIN, context=context,
+ const.DOMAIN,
+ context=context,
)
return
@@ -883,7 +887,9 @@ async def async_get_entity_id(
hass: HomeAssistant, attribute: WithingsAttribute, user_id: int
) -> Optional[str]:
"""Get an entity id for a user's attribute."""
- entity_registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry()
+ entity_registry: EntityRegistry = (
+ await hass.helpers.entity_registry.async_get_registry()
+ )
unique_id = get_attribute_unique_id(attribute, user_id)
entity_id = entity_registry.async_get_entity_id(
diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py
index a7580faa3d0..a55d83d717b 100644
--- a/homeassistant/components/withings/sensor.py
+++ b/homeassistant/components/withings/sensor.py
@@ -17,7 +17,10 @@ async def async_setup_entry(
"""Set up the sensor config entry."""
entities = await async_create_entities(
- hass, entry, WithingsHealthSensor, SENSOR_DOMAIN,
+ hass,
+ entry,
+ WithingsHealthSensor,
+ SENSOR_DOMAIN,
)
async_add_entities(entities, True)
diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json
index e7763a1db0c..c9d2d7ca22c 100644
--- a/homeassistant/components/withings/strings.json
+++ b/homeassistant/components/withings/strings.json
@@ -19,7 +19,8 @@
"abort": {
"authorize_url_timeout": "Timeout generating authorize url.",
"missing_configuration": "The Withings integration is not configured. Please follow the documentation.",
- "already_configured": "Configuration updated for profile."
+ "already_configured": "Configuration updated for profile.",
+ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]"
},
"create_entry": { "default": "Successfully authenticated with Withings." }
}
diff --git a/homeassistant/components/withings/translations/pl.json b/homeassistant/components/withings/translations/pl.json
index 626e74a36ac..e6b31e4d481 100644
--- a/homeassistant/components/withings/translations/pl.json
+++ b/homeassistant/components/withings/translations/pl.json
@@ -21,7 +21,7 @@
},
"reauth": {
"description": "Profil \"{profile}\" musi zosta\u0107 ponownie uwierzytelniony, aby nadal otrzymywa\u0107 dane Withings.",
- "title": "Ponownie uwierzytelnij {profile}"
+ "title": "Ponownie uwierzytelnij {rofile}"
}
}
}
diff --git a/homeassistant/components/wled/__init__.py b/homeassistant/components/wled/__init__.py
index 5cc2453d78c..d8aacd59881 100644
--- a/homeassistant/components/wled/__init__.py
+++ b/homeassistant/components/wled/__init__.py
@@ -14,9 +14,12 @@ from homeassistant.const import ATTR_NAME, CONF_HOST
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.typing import ConfigType
-from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+from homeassistant.helpers.update_coordinator import (
+ CoordinatorEntity,
+ DataUpdateCoordinator,
+ UpdateFailed,
+)
from .const import (
ATTR_IDENTIFIERS,
@@ -111,13 +114,19 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]):
"""Class to manage fetching WLED data from single endpoint."""
def __init__(
- self, hass: HomeAssistant, *, host: str,
+ self,
+ hass: HomeAssistant,
+ *,
+ host: str,
):
"""Initialize global WLED data updater."""
self.wled = WLED(host, session=async_get_clientsession(hass))
super().__init__(
- hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL,
+ hass,
+ _LOGGER,
+ name=DOMAIN,
+ update_interval=SCAN_INTERVAL,
)
def update_listeners(self) -> None:
@@ -130,10 +139,10 @@ class WLEDDataUpdateCoordinator(DataUpdateCoordinator[WLEDDevice]):
try:
return await self.wled.update(full_update=not self.last_update_success)
except WLEDError as error:
- raise UpdateFailed(f"Invalid response from API: {error}")
+ raise UpdateFailed(f"Invalid response from API: {error}") from error
-class WLEDEntity(Entity):
+class WLEDEntity(CoordinatorEntity):
"""Defines a base WLED entity."""
def __init__(
@@ -146,12 +155,12 @@ class WLEDEntity(Entity):
enabled_default: bool = True,
) -> None:
"""Initialize the WLED entity."""
+ super().__init__(coordinator)
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:
@@ -163,31 +172,11 @@ class WLEDEntity(Entity):
"""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.async_on_remove(
- self.coordinator.async_add_listener(self.async_write_ha_state)
- )
-
- async def async_update(self) -> None:
- """Update WLED entity."""
- await self.coordinator.async_request_refresh()
-
class WLEDDeviceEntity(WLEDEntity):
"""Defines a WLED device entity."""
diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json
index e7cc99813cb..a646c41d832 100644
--- a/homeassistant/components/wled/manifest.json
+++ b/homeassistant/components/wled/manifest.json
@@ -3,7 +3,7 @@
"name": "WLED",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/wled",
- "requirements": ["wled==0.4.3"],
+ "requirements": ["wled==0.4.4"],
"zeroconf": ["_wled._tcp.local."],
"codeowners": ["@frenck"],
"quality_scale": "platinum"
diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py
index b684bdc0977..63a253e6efc 100644
--- a/homeassistant/components/wled/sensor.py
+++ b/homeassistant/components/wled/sensor.py
@@ -1,14 +1,15 @@
"""Support for WLED sensors."""
from datetime import timedelta
import logging
-from typing import Any, Callable, Dict, List, Optional, Union
+from typing import Any, Callable, Dict, List, Optional
+from homeassistant.components.sensor import DEVICE_CLASS_CURRENT
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
DATA_BYTES,
DEVICE_CLASS_SIGNAL_STRENGTH,
DEVICE_CLASS_TIMESTAMP,
- UNIT_PERCENTAGE,
+ PERCENTAGE,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import HomeAssistantType
@@ -101,10 +102,15 @@ class WLEDEstimatedCurrentSensor(WLEDSensor):
}
@property
- def state(self) -> Union[None, str, int, float]:
+ def state(self) -> int:
"""Return the state of the sensor."""
return self.coordinator.data.info.leds.power
+ @property
+ def device_class(self) -> Optional[str]:
+ """Return the class of this sensor."""
+ return DEVICE_CLASS_CURRENT
+
class WLEDUptimeSensor(WLEDSensor):
"""Defines a WLED uptime sensor."""
@@ -121,7 +127,7 @@ class WLEDUptimeSensor(WLEDSensor):
)
@property
- def state(self) -> Union[None, str, int, float]:
+ def state(self) -> str:
"""Return the state of the sensor."""
uptime = utcnow() - timedelta(seconds=self.coordinator.data.info.uptime)
return uptime.replace(microsecond=0).isoformat()
@@ -148,7 +154,7 @@ class WLEDFreeHeapSensor(WLEDSensor):
)
@property
- def state(self) -> Union[None, str, int, float]:
+ def state(self) -> int:
"""Return the state of the sensor."""
return self.coordinator.data.info.free_heap
@@ -165,11 +171,11 @@ class WLEDWifiSignalSensor(WLEDSensor):
icon="mdi:wifi",
key="wifi_signal",
name=f"{coordinator.data.info.name} Wi-Fi Signal",
- unit_of_measurement=UNIT_PERCENTAGE,
+ unit_of_measurement=PERCENTAGE,
)
@property
- def state(self) -> Union[None, str, int, float]:
+ def state(self) -> int:
"""Return the state of the sensor."""
return self.coordinator.data.info.wifi.signal
@@ -190,7 +196,7 @@ class WLEDWifiRSSISensor(WLEDSensor):
)
@property
- def state(self) -> Union[None, str, int, float]:
+ def state(self) -> int:
"""Return the state of the sensor."""
return self.coordinator.data.info.wifi.rssi
@@ -215,7 +221,7 @@ class WLEDWifiChannelSensor(WLEDSensor):
)
@property
- def state(self) -> Union[None, str, int, float]:
+ def state(self) -> int:
"""Return the state of the sensor."""
return self.coordinator.data.info.wifi.channel
@@ -235,6 +241,6 @@ class WLEDWifiBSSIDSensor(WLEDSensor):
)
@property
- def state(self) -> Union[None, str, int, float]:
+ def state(self) -> str:
"""Return the state of the sensor."""
return self.coordinator.data.info.wifi.bssid
diff --git a/homeassistant/components/wled/translations/fr.json b/homeassistant/components/wled/translations/fr.json
index 14f43584bc7..694627b8baf 100644
--- a/homeassistant/components/wled/translations/fr.json
+++ b/homeassistant/components/wled/translations/fr.json
@@ -14,6 +14,10 @@
"host": "Nom d'h\u00f4te ou adresse IP"
},
"description": "Configurez votre WLED pour l'int\u00e9grer \u00e0 Home Assistant."
+ },
+ "zeroconf_confirm": {
+ "description": "Voulez-vous ajouter le dispositif WLED nomm\u00e9 `{name}` \u00e0 Home Assistant?",
+ "title": "Dispositif WLED d\u00e9couvert"
}
}
}
diff --git a/homeassistant/components/wolflink/__init__.py b/homeassistant/components/wolflink/__init__.py
index cce9d542446..9a272c502a0 100644
--- a/homeassistant/components/wolflink/__init__.py
+++ b/homeassistant/components/wolflink/__init__.py
@@ -53,9 +53,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
values = await wolf_client.fetch_value(gateway_id, device_id, parameters)
return {v.value_id: v.value for v in values}
except ConnectError as exception:
- raise UpdateFailed(f"Error communicating with API: {exception}")
- except InvalidAuth:
- raise UpdateFailed("Invalid authentication during update.")
+ raise UpdateFailed(
+ f"Error communicating with API: {exception}"
+ ) from exception
+ except InvalidAuth as exception:
+ raise UpdateFailed("Invalid authentication during update.") from exception
coordinator = DataUpdateCoordinator(
hass,
@@ -98,6 +100,6 @@ async def fetch_parameters(client: WolfClient, gateway_id: int, device_id: int):
fetched_parameters = await client.fetch_parameters(gateway_id, device_id)
return [param for param in fetched_parameters if param.name != "Reglertyp"]
except ConnectError as exception:
- raise UpdateFailed(f"Error communicating with API: {exception}")
- except InvalidAuth:
- raise UpdateFailed("Invalid authentication during update.")
+ raise UpdateFailed(f"Error communicating with API: {exception}") from exception
+ except InvalidAuth as exception:
+ raise UpdateFailed("Invalid authentication during update.") from exception
diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py
index a9deced9e91..97f48e27988 100644
--- a/homeassistant/components/wolflink/sensor.py
+++ b/homeassistant/components/wolflink/sensor.py
@@ -25,7 +25,7 @@ from homeassistant.const import (
TEMP_CELSIUS,
TIME_HOURS,
)
-from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
_LOGGER = logging.getLogger(__name__)
@@ -55,12 +55,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async_add_entities(entities, True)
-class WolfLinkSensor(Entity):
+class WolfLinkSensor(CoordinatorEntity):
"""Base class for all Wolf entities."""
def __init__(self, coordinator, wolf_object: Parameter, device_id):
"""Initialize."""
- self.coordinator = coordinator
+ super().__init__(coordinator)
self.wolf_object = wolf_object
self.device_id = device_id
@@ -88,27 +88,6 @@ class WolfLinkSensor(Entity):
"""Return a unique_id for this entity."""
return f"{self.device_id}:{self.wolf_object.parameter_id}"
- @property
- def available(self):
- """Return True if entity is available."""
- return self.coordinator.last_update_success
-
- @property
- def should_poll(self):
- """No need to poll. Coordinator notifies entity of updates."""
- return False
-
- async def async_added_to_hass(self):
- """When entity is added to hass."""
- self.async_on_remove(
- self.coordinator.async_add_listener(self.async_write_ha_state)
- )
-
- async def async_update(self):
- """Update the sensor."""
- await self.coordinator.async_request_refresh()
- _LOGGER.debug("Updating %s", self.coordinator.data[self.wolf_object.value_id])
-
class WolfLinkHours(WolfLinkSensor):
"""Class for hour based entities."""
diff --git a/homeassistant/components/wolflink/translations/lb.json b/homeassistant/components/wolflink/translations/lb.json
index 97a65b12d02..05ce6a27797 100644
--- a/homeassistant/components/wolflink/translations/lb.json
+++ b/homeassistant/components/wolflink/translations/lb.json
@@ -19,7 +19,8 @@
"data": {
"password": "Passwuert",
"username": "Benotzernumm"
- }
+ },
+ "title": "WOLF SmartSet Verbindung"
}
}
}
diff --git a/homeassistant/components/wolflink/translations/pt-BR.json b/homeassistant/components/wolflink/translations/pt-BR.json
new file mode 100644
index 00000000000..43e2720b365
--- /dev/null
+++ b/homeassistant/components/wolflink/translations/pt-BR.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "cannot_connect": "Falha na conex\u00e3o"
+ },
+ "step": {
+ "device": {
+ "data": {
+ "device_name": "Dispositivo"
+ }
+ },
+ "user": {
+ "data": {
+ "password": "Senha",
+ "username": "Usu\u00e1rio"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wolflink/translations/pt.json b/homeassistant/components/wolflink/translations/pt.json
new file mode 100644
index 00000000000..7953cf5625c
--- /dev/null
+++ b/homeassistant/components/wolflink/translations/pt.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado"
+ },
+ "error": {
+ "cannot_connect": "Falha na liga\u00e7\u00e3o",
+ "invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida",
+ "unknown": "Erro inesperado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Palavra-passe",
+ "username": "Nome de Utilizador"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wolflink/translations/ru.json b/homeassistant/components/wolflink/translations/ru.json
index a15fb94c8ff..841f7b26030 100644
--- a/homeassistant/components/wolflink/translations/ru.json
+++ b/homeassistant/components/wolflink/translations/ru.json
@@ -1,10 +1,10 @@
{
"config": {
"abort": {
- "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant."
},
"error": {
- "cannot_connect": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.",
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.",
"invalid_auth": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f.",
"unknown": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
},
diff --git a/homeassistant/components/wolflink/translations/sensor.ko.json b/homeassistant/components/wolflink/translations/sensor.ko.json
index 99e965e1b21..5b6c33d7231 100644
--- a/homeassistant/components/wolflink/translations/sensor.ko.json
+++ b/homeassistant/components/wolflink/translations/sensor.ko.json
@@ -6,7 +6,15 @@
"absenkbetrieb": "\uc911\ub2e8 \ub300\uccb4 \ubaa8\ub4dc",
"absenkstop": "\uc911\ub2e8 \ub300\uccb4 \uc911\uc9c0",
"aktiviert": "\ud65c\uc131\ud654",
- "antilegionellenfunktion": "\ud56d \ub808\uc9c0\uc624\ub12c\ub77c\uade0 \uae30\ub2a5"
+ "antilegionellenfunktion": "\ud56d \ub808\uc9c0\uc624\ub12c\ub77c\uade0 \uae30\ub2a5",
+ "at_abschaltung": "OT \ub044\uae30",
+ "at_frostschutz": "OT \ube59\uacb0 \ubcf4\ud638",
+ "aus": "\ube44\ud65c\uc131\ud654",
+ "auto": "\uc790\ub3d9",
+ "auto_off_cool": "\ub0c9\ubc29 \uc790\ub3d9 \uaebc\uc9d0",
+ "auto_on_cool": "\ub0c9\ubc29 \uc790\ub3d9 \ucf1c\uc9d0",
+ "automatik_aus": "\uc790\ub3d9 \uaebc\uc9d0",
+ "automatik_ein": "\uc790\ub3d9 \ucf1c\uc9d0"
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/wolflink/translations/sensor.lb.json b/homeassistant/components/wolflink/translations/sensor.lb.json
index 94c46e4f362..b57d2c8747a 100644
--- a/homeassistant/components/wolflink/translations/sensor.lb.json
+++ b/homeassistant/components/wolflink/translations/sensor.lb.json
@@ -2,7 +2,11 @@
"state": {
"wolflink__state": {
"1_x_warmwasser": "1x DHW",
+ "abgasklappe": "Ofgas Klapp",
+ "absenkbetrieb": "Absenk Betrib",
+ "absenkstop": "Absenk Stop",
"aktiviert": "Aktiv\u00e9iert",
+ "antilegionellenfunktion": "Anti-legionelle Funktioun",
"at_abschaltung": "OT ausmaachen",
"at_frostschutz": "OT Frostschutz",
"aus": "Deaktiv\u00e9iert",
@@ -12,18 +16,31 @@
"automatik_aus": "Automatik AUS",
"automatik_ein": "Automatik UN",
"bereit_keine_ladung": "Prett, lued net",
+ "betrieb_ohne_brenner": "Betrib ouni Brenner",
"cooling": "Ofkillen",
"deaktiviert": "Inaktiv",
"eco": "Eco",
"ein": "Aktiv\u00e9iert",
+ "estrichtrocknung": "Chape dr\u00e9chnen",
+ "externe_deaktivierung": "Externe D\u00e9aktivatioun",
+ "fernschalter_ein": "Fernsteierung aktiv\u00e9iert",
"frostschutz": "Frostschutz",
"gasdruck": "Gas Drock",
"glt_betrieb": "BMS Modus",
"heizbetrieb": "Heizung Modus",
+ "heizgerat_mit_speicher": "Boiler mat Sp\u00e4icher",
"heizung": "Heizung",
"initialisierung": "Initialis\u00e9ierung",
"kalibration": "Kalibratioun",
+ "kalibration_heizbetrieb": "Heizung Betrib Kalibratioun",
+ "kalibration_kombibetrieb": "Kombi Betrib Kalibratioun",
"kalibration_warmwasserbetrieb": "DHW Kalibratioun",
+ "kombibetrieb": "Kombi Betriib",
+ "kombigerat": "Kombiger\u00e4t",
+ "kombigerat_mit_solareinbindung": "Kombi Boiler mat Solar Integratioun",
+ "mindest_kombizeit": "Mindest Kombi Z\u00e4it",
+ "nachspulen": "Nospullen",
+ "nur_heizgerat": "N\u00ebmme Boiler",
"parallelbetrieb": "Parrallel Modus",
"partymodus": "Party Modus",
"permanent": "Permanent",
@@ -31,7 +48,9 @@
"reduzierter_betrieb": "Limit\u00e9ierte Modus",
"rt_abschaltung": "RT ausmaachen",
"rt_frostschutz": "RT Frostschutz",
+ "ruhekontakt": "Rou Kontakt",
"schornsteinfeger": "Emissioun Test",
+ "smart_grid": "SmartGrid",
"smart_home": "SmartHome",
"softstart": "Soft Start",
"solarbetrieb": "Solar Modus",
@@ -43,12 +62,17 @@
"standby": "Standby",
"start": "Start",
"storung": "Feeler",
+ "taktsperre": "Takt Sp\u00e4r",
+ "telefonfernschalter": "Telefon Fernsteierung",
"test": "Test",
"tpw": "TPW",
"urlaubsmodus": "Vakanze Modus",
+ "ventilprufung": "Ventil Test",
+ "vorspulen": "Virspullen",
"warmwasser": "DHW",
"warmwasser_schnellstart": "DHW Schnell Start",
"warmwasserbetrieb": "DHW Modus",
+ "warmwassernachlauf": "Waarmt Waaser Nolaf",
"warmwasservorrang": "DHW Priorit\u00e9it",
"zunden": "Z\u00fcndung"
}
diff --git a/homeassistant/components/wolflink/translations/sensor.pt-BR.json b/homeassistant/components/wolflink/translations/sensor.pt-BR.json
new file mode 100644
index 00000000000..2af363e8f1c
--- /dev/null
+++ b/homeassistant/components/wolflink/translations/sensor.pt-BR.json
@@ -0,0 +1,18 @@
+{
+ "state": {
+ "wolflink__state": {
+ "aktiviert": "Ativado",
+ "aus": "Desativado",
+ "deaktiviert": "Inativo",
+ "eco": "Econ\u00f4mico",
+ "ein": "Habilitado",
+ "stabilisierung": "Estabiliza\u00e7\u00e3o",
+ "standby": "Em espera",
+ "start": "Iniciar",
+ "storung": "Falha",
+ "test": "Teste",
+ "urlaubsmodus": "Modo de f\u00e9rias",
+ "ventilprufung": "Teste de v\u00e1lvula"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py
index 1613f10d66a..8f8b794515e 100644
--- a/homeassistant/components/workday/binary_sensor.py
+++ b/homeassistant/components/workday/binary_sensor.py
@@ -36,10 +36,10 @@ def valid_country(value: Any) -> str:
try:
raw_value = value.encode("utf-8")
- except UnicodeError:
+ except UnicodeError as err:
raise vol.Invalid(
"The country name or the abbreviation must be a valid UTF-8 string."
- )
+ ) from err
if not raw_value:
raise vol.Invalid("Country name or the abbreviation must not be empty.")
if value not in all_supported_countries:
diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py
index 86daa6e994a..3a3853d9f68 100644
--- a/homeassistant/components/worldclock/sensor.py
+++ b/homeassistant/components/worldclock/sensor.py
@@ -32,7 +32,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
time_zone = dt_util.get_time_zone(config.get(CONF_TIME_ZONE))
async_add_entities(
- [WorldClockSensor(time_zone, name, config.get(CONF_TIME_FORMAT),)], True,
+ [
+ WorldClockSensor(
+ time_zone,
+ name,
+ config.get(CONF_TIME_FORMAT),
+ )
+ ],
+ True,
)
diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py
index 8d651800a77..e4fe33f62f1 100644
--- a/homeassistant/components/worxlandroid/sensor.py
+++ b/homeassistant/components/worxlandroid/sensor.py
@@ -7,7 +7,7 @@ import async_timeout
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import CONF_HOST, CONF_PIN, CONF_TIMEOUT, UNIT_PERCENTAGE
+from homeassistant.const import CONF_HOST, CONF_PIN, CONF_TIMEOUT, PERCENTAGE
from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
@@ -77,7 +77,7 @@ class WorxLandroidSensor(Entity):
def unit_of_measurement(self):
"""Return the unit of measurement of the sensor."""
if self.sensor == "battery":
- return UNIT_PERCENTAGE
+ return PERCENTAGE
return None
async def async_update(self):
diff --git a/homeassistant/components/wunderground/sensor.py b/homeassistant/components/wunderground/sensor.py
index d6b7cb447f9..2d9c4f5c9c1 100644
--- a/homeassistant/components/wunderground/sensor.py
+++ b/homeassistant/components/wunderground/sensor.py
@@ -23,11 +23,11 @@ from homeassistant.const import (
LENGTH_INCHES,
LENGTH_KILOMETERS,
LENGTH_MILES,
+ PERCENTAGE,
SPEED_KILOMETERS_PER_HOUR,
SPEED_MILES_PER_HOUR,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
- UNIT_PERCENTAGE,
)
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -418,7 +418,7 @@ SENSOR_TYPES = {
"Relative Humidity",
"conditions",
value=lambda wu: int(wu.data["current_observation"]["relative_humidity"][:-1]),
- unit_of_measurement=UNIT_PERCENTAGE,
+ unit_of_measurement=PERCENTAGE,
icon="mdi:water-percent",
device_class="humidity",
),
@@ -926,7 +926,7 @@ SENSOR_TYPES = {
0,
"pop",
None,
- UNIT_PERCENTAGE,
+ PERCENTAGE,
"mdi:umbrella",
),
"precip_2d": WUDailySimpleForecastSensorConfig(
@@ -934,7 +934,7 @@ SENSOR_TYPES = {
1,
"pop",
None,
- UNIT_PERCENTAGE,
+ PERCENTAGE,
"mdi:umbrella",
),
"precip_3d": WUDailySimpleForecastSensorConfig(
@@ -942,7 +942,7 @@ SENSOR_TYPES = {
2,
"pop",
None,
- UNIT_PERCENTAGE,
+ PERCENTAGE,
"mdi:umbrella",
),
"precip_4d": WUDailySimpleForecastSensorConfig(
@@ -950,7 +950,7 @@ SENSOR_TYPES = {
3,
"pop",
None,
- UNIT_PERCENTAGE,
+ PERCENTAGE,
"mdi:umbrella",
),
}
diff --git a/homeassistant/components/xbee/__init__.py b/homeassistant/components/xbee/__init__.py
index a0f6bc69a0d..e6175a4dccf 100644
--- a/homeassistant/components/xbee/__init__.py
+++ b/homeassistant/components/xbee/__init__.py
@@ -15,7 +15,7 @@ from homeassistant.const import (
CONF_NAME,
CONF_PIN,
EVENT_HOMEASSISTANT_STOP,
- UNIT_PERCENTAGE,
+ PERCENTAGE,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send
@@ -421,7 +421,7 @@ class XBeeAnalogIn(Entity):
@property
def unit_of_measurement(self):
"""Return the unit this state is expressed in."""
- return UNIT_PERCENTAGE
+ return PERCENTAGE
def update(self):
"""Get the latest reading from the ADC."""
diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py
index 0709c7e83fa..fecdfd91a75 100644
--- a/homeassistant/components/xiaomi_aqara/binary_sensor.py
+++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py
@@ -295,7 +295,12 @@ class XiaomiDoorSensor(XiaomiBinarySensor):
else:
data_key = "window_status"
super().__init__(
- device, "Door Window Sensor", xiaomi_hub, data_key, "opening", config_entry,
+ device,
+ "Door Window Sensor",
+ xiaomi_hub,
+ data_key,
+ "opening",
+ config_entry,
)
@property
@@ -340,7 +345,12 @@ class XiaomiWaterLeakSensor(XiaomiBinarySensor):
else:
data_key = "wleak_status"
super().__init__(
- device, "Water Leak Sensor", xiaomi_hub, data_key, "moisture", config_entry,
+ device,
+ "Water Leak Sensor",
+ xiaomi_hub,
+ data_key,
+ "moisture",
+ config_entry,
)
def parse_data(self, data, raw_data):
diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py
index c42598c2665..6bf1aa4f4ee 100644
--- a/homeassistant/components/xiaomi_aqara/config_flow.py
+++ b/homeassistant/components/xiaomi_aqara/config_flow.py
@@ -18,6 +18,7 @@ from .const import (
CONF_SID,
DEFAULT_DISCOVERY_RETRY,
DOMAIN,
+ ZEROCONF_ACPARTNER,
ZEROCONF_GATEWAY,
)
@@ -158,7 +159,9 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="not_xiaomi_aqara")
# Check if the discovered device is an xiaomi aqara gateway.
- if not name.startswith(ZEROCONF_GATEWAY):
+ if not (
+ name.startswith(ZEROCONF_GATEWAY) or name.startswith(ZEROCONF_ACPARTNER)
+ ):
_LOGGER.debug(
"Xiaomi device '%s' discovered with host %s, not identified as xiaomi aqara gateway",
name,
diff --git a/homeassistant/components/xiaomi_aqara/const.py b/homeassistant/components/xiaomi_aqara/const.py
index 58932d7a0bc..fcfa3939c2c 100644
--- a/homeassistant/components/xiaomi_aqara/const.py
+++ b/homeassistant/components/xiaomi_aqara/const.py
@@ -6,6 +6,7 @@ GATEWAYS_KEY = "gateways"
LISTENER_KEY = "listener"
ZEROCONF_GATEWAY = "lumi-gateway"
+ZEROCONF_ACPARTNER = "lumi-acpartner"
CONF_INTERFACE = "interface"
CONF_PROTOCOL = "protocol"
@@ -53,3 +54,9 @@ BATTERY_MODELS = [
"lock.aq1",
"lock.acn02",
]
+
+POWER_MODELS = [
+ "86plug",
+ "ctrl_86plug",
+ "ctrl_86plug.aq1",
+]
diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py
index 3463cded56d..4a6c7ac14fd 100644
--- a/homeassistant/components/xiaomi_aqara/sensor.py
+++ b/homeassistant/components/xiaomi_aqara/sensor.py
@@ -6,24 +6,27 @@ from homeassistant.const import (
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_ILLUMINANCE,
+ DEVICE_CLASS_POWER,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
+ PERCENTAGE,
+ POWER_WATT,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
from . import XiaomiDevice
-from .const import BATTERY_MODELS, DOMAIN, GATEWAYS_KEY
+from .const import BATTERY_MODELS, DOMAIN, GATEWAYS_KEY, POWER_MODELS
_LOGGER = logging.getLogger(__name__)
SENSOR_TYPES = {
"temperature": [TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE],
- "humidity": [UNIT_PERCENTAGE, None, DEVICE_CLASS_HUMIDITY],
+ "humidity": [PERCENTAGE, None, DEVICE_CLASS_HUMIDITY],
"illumination": ["lm", None, DEVICE_CLASS_ILLUMINANCE],
"lux": ["lx", None, DEVICE_CLASS_ILLUMINANCE],
"pressure": ["hPa", None, DEVICE_CLASS_PRESSURE],
"bed_activity": ["μm", None, None],
+ "load_power": [POWER_WATT, None, DEVICE_CLASS_POWER],
}
@@ -89,7 +92,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
entities.append(
XiaomiBatterySensor(device, "Battery", gateway, config_entry)
)
-
+ if device["model"] in POWER_MODELS:
+ entities.append(
+ XiaomiSensor(
+ device, "Load Power", "load_power", gateway, config_entry
+ )
+ )
async_add_entities(entities)
@@ -163,7 +171,7 @@ class XiaomiBatterySensor(XiaomiDevice):
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any."""
- return UNIT_PERCENTAGE
+ return PERCENTAGE
@property
def device_class(self):
diff --git a/homeassistant/components/xiaomi_aqara/strings.json b/homeassistant/components/xiaomi_aqara/strings.json
index 5cbdc91a661..bc50c491866 100644
--- a/homeassistant/components/xiaomi_aqara/strings.json
+++ b/homeassistant/components/xiaomi_aqara/strings.json
@@ -31,7 +31,7 @@
"discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface",
"invalid_interface": "Invalid network interface",
"invalid_key": "Invalid gateway key",
- "invalid_host": "Invalid [%key:common::config_flow::data::ip%]",
+ "invalid_host": "Invalid [%key:common::config_flow::data::ip%], see https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem",
"invalid_mac": "Invalid Mac Address"
},
"abort": {
diff --git a/homeassistant/components/xiaomi_aqara/translations/ca.json b/homeassistant/components/xiaomi_aqara/translations/ca.json
index 3b90529d740..534a6b3654e 100644
--- a/homeassistant/components/xiaomi_aqara/translations/ca.json
+++ b/homeassistant/components/xiaomi_aqara/translations/ca.json
@@ -7,8 +7,10 @@
},
"error": {
"discovery_error": "No s'ha pogut descobrir cap passarel\u00b7la Xiaomi Aqara, prova d'utilitzar la IP del dispositiu que executa Home Assistant com a interf\u00edcie",
+ "invalid_host": "Adre\u00e7a IP no v\u00e0lida",
"invalid_interface": "Interf\u00edcie de xarxa no v\u00e0lida",
"invalid_key": "Clau de la passarel\u00b7la no v\u00e0lida",
+ "invalid_mac": "Adre\u00e7a MAC no v\u00e0lida",
"not_found_error": "No s'ha pogut descobrir cap passarel\u00b7la Zeroconf per obtenir informaci\u00f3 necess\u00e0ria, prova d'utilitzar la IP del dispositiu que executa Home Assistant com a interf\u00edcie"
},
"flow_title": "Passarel\u00b7la Xiaomi Aqara: {name}",
@@ -30,9 +32,11 @@
},
"user": {
"data": {
- "interface": "Interf\u00edcie de xarxa a utilitzar"
+ "host": "Adre\u00e7a IP (opcional)",
+ "interface": "Interf\u00edcie de xarxa a utilitzar",
+ "mac": "Adre\u00e7a MAC (opcional)"
},
- "description": "Connecta't a la passarel\u00b7la Xiaomi Aqara",
+ "description": "Connecta't a la teva passarel\u00b7la Xiaomi Aqara, si les adreces IP i MAC es deixen buides s'utilitzar\u00e0 els descobriment autom\u00e0tic",
"title": "Passarel\u00b7la Xiaomi Aqara"
}
}
diff --git a/homeassistant/components/xiaomi_aqara/translations/en.json b/homeassistant/components/xiaomi_aqara/translations/en.json
index b9f6fa7ab2a..4cdc7ee497a 100644
--- a/homeassistant/components/xiaomi_aqara/translations/en.json
+++ b/homeassistant/components/xiaomi_aqara/translations/en.json
@@ -1,16 +1,17 @@
{
"config": {
"abort": {
- "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "already_configured": "Device is already configured",
"already_in_progress": "Config flow for this gateway is already in progress",
"not_xiaomi_aqara": "Not a Xiaomi Aqara Gateway, discovered device did not match known gateways"
},
"error": {
"discovery_error": "Failed to discover a Xiaomi Aqara Gateway, try using the IP of the device running HomeAssistant as interface",
- "invalid_host": "Invalid [%key:common::config_flow::data::ip%]",
+ "invalid_host": "Invalid IP Address, see https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem",
"invalid_interface": "Invalid network interface",
"invalid_key": "Invalid gateway key",
- "invalid_mac": "Invalid Mac Address"
+ "invalid_mac": "Invalid Mac Address",
+ "not_found_error": "Zeroconf discovered Gateway could not be located to get the necessary information, try using the IP of the device running HomeAssistant as interface"
},
"flow_title": "Xiaomi Aqara Gateway: {name}",
"step": {
@@ -31,7 +32,7 @@
},
"user": {
"data": {
- "host": "[%key:common::config_flow::data::ip%] (optional)",
+ "host": "IP Address (optional)",
"interface": "The network interface to use",
"mac": "Mac Address (optional)"
},
diff --git a/homeassistant/components/xiaomi_aqara/translations/es.json b/homeassistant/components/xiaomi_aqara/translations/es.json
index 9d388203bcb..caf99aed710 100644
--- a/homeassistant/components/xiaomi_aqara/translations/es.json
+++ b/homeassistant/components/xiaomi_aqara/translations/es.json
@@ -7,8 +7,10 @@
},
"error": {
"discovery_error": "No se pudo descubrir un Xiaomi Aqara Gateway, intenta utilizar la IP del dispositivo que ejecuta HomeAssistant como interfaz",
+ "invalid_host": "Direcci\u00f3n IP no v\u00e1lida",
"invalid_interface": "Interfaz de red inv\u00e1lida",
"invalid_key": "Clave del gateway inv\u00e1lida",
+ "invalid_mac": "Direcci\u00f3n Mac no v\u00e1lida",
"not_found_error": "El Gateway descubierto por Zeroconf no puede localizarse para obtener toda la informaci\u00f3n necesaria, intenta usar la IP del dispositivo que ejecuta HomeAssistant como interfaz"
},
"flow_title": "Xiaomi Aqara Gateway: {name}",
@@ -30,7 +32,9 @@
},
"user": {
"data": {
- "interface": "La interfaz de la red a usar"
+ "host": "Direcci\u00f3n IP (opcional)",
+ "interface": "La interfaz de la red a usar",
+ "mac": "Direcci\u00f3n Mac (opcional)"
},
"description": "Conectar con tu Xiaomi Aqara Gateway",
"title": "Xiaomi Aqara Gateway"
diff --git a/homeassistant/components/xiaomi_aqara/translations/fr.json b/homeassistant/components/xiaomi_aqara/translations/fr.json
index d0ff2a94673..a46dc756390 100644
--- a/homeassistant/components/xiaomi_aqara/translations/fr.json
+++ b/homeassistant/components/xiaomi_aqara/translations/fr.json
@@ -7,8 +7,10 @@
},
"error": {
"discovery_error": "Impossible de d\u00e9couvrir une passerelle Xiaomi Aqara, essayez d'utiliser l'IP du p\u00e9riph\u00e9rique ex\u00e9cutant HomeAssistant comme interface",
+ "invalid_host": "Adresse IP non valide",
"invalid_interface": "Interface r\u00e9seau non valide",
"invalid_key": "Cl\u00e9 de passerelle non valide",
+ "invalid_mac": "Adresse MAC non valide",
"not_found_error": "La passerelle d\u00e9couverte par Zeroconf ne permet pas d'obtenir les informations n\u00e9cessaires, essayez d'utiliser l'IP du p\u00e9riph\u00e9rique ex\u00e9cutant HomeAssistant comme interface"
},
"flow_title": "Passerelle Xiaomi Aqara: {nom}",
@@ -30,8 +32,12 @@
},
"user": {
"data": {
- "interface": "Interface r\u00e9seau \u00e0 utiliser"
- }
+ "host": "Adresse IP (facultatif)",
+ "interface": "Interface r\u00e9seau \u00e0 utiliser",
+ "mac": "Adresse MAC (facultatif)"
+ },
+ "description": "Connectez-vous \u00e0 votre passerelle Xiaomi Aqara, si les adresses IP et mac sont laiss\u00e9es vides, la d\u00e9tection automatique est utilis\u00e9e",
+ "title": "Passerelle Xiaomi Aqara"
}
}
}
diff --git a/homeassistant/components/xiaomi_aqara/translations/fy.json b/homeassistant/components/xiaomi_aqara/translations/fy.json
new file mode 100644
index 00000000000..19bc44d2734
--- /dev/null
+++ b/homeassistant/components/xiaomi_aqara/translations/fy.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "mac": "MAC-adres (opsjoneel)"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/xiaomi_aqara/translations/it.json b/homeassistant/components/xiaomi_aqara/translations/it.json
index bc64862afec..6b7f6d907ae 100644
--- a/homeassistant/components/xiaomi_aqara/translations/it.json
+++ b/homeassistant/components/xiaomi_aqara/translations/it.json
@@ -7,8 +7,10 @@
},
"error": {
"discovery_error": "Impossibile individuare un gateway Xiaomi Aqara, provare a utilizzare l'IP del dispositivo che esegue HomeAssistant come interfaccia",
+ "invalid_host": "Indirizzo IP non valido",
"invalid_interface": "Interfaccia di rete non valida",
"invalid_key": "Chiave gateway non valida",
+ "invalid_mac": "Indirizzo Mac non valido",
"not_found_error": "Zeroconf ha scoperto che non \u00e8 stato possibile trovare il gateway per ottenere le informazioni necessarie, provare a utilizzare l'IP del dispositivo che esegue HomeAssistant come interfaccia"
},
"flow_title": "Xiaomi Aqara Gateway: {name}",
@@ -30,9 +32,11 @@
},
"user": {
"data": {
- "interface": "L'interfaccia di rete da utilizzare"
+ "host": "Indirizzo IP (opzionale)",
+ "interface": "L'interfaccia di rete da utilizzare",
+ "mac": "Indirizzo Mac (opzionale)"
},
- "description": "Connettiti al tuo Xiaomi Aqara Gateway",
+ "description": "Connettiti al tuo Xiaomi Aqara Gateway, se gli indirizzi IP e mac sono lasciati vuoti, verr\u00e0 utilizzato il rilevamento automatico",
"title": "Xiaomi Aqara Gateway"
}
}
diff --git a/homeassistant/components/xiaomi_aqara/translations/ko.json b/homeassistant/components/xiaomi_aqara/translations/ko.json
index 38f58148e4c..90a22ace2b8 100644
--- a/homeassistant/components/xiaomi_aqara/translations/ko.json
+++ b/homeassistant/components/xiaomi_aqara/translations/ko.json
@@ -32,7 +32,7 @@
"data": {
"interface": "\uc0ac\uc6a9\ud560 \ub124\ud2b8\uc6cc\ud06c \uc778\ud130\ud398\uc774\uc2a4"
},
- "description": "Xiaomi Aqara \uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud569\ub2c8\ub2e4",
+ "description": "Xiaomi Aqara \uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud569\ub2c8\ub2e4. IP \ubc0f Mac \uc8fc\uc18c\uac00 \uc124\uc815\ub418\uc9c0 \uc54a\uc740 \uacbd\uc6b0 \uc790\ub3d9 \uac80\uc0c9\uc774 \uc0ac\uc6a9\ub429\ub2c8\ub2e4",
"title": "Xiaomi Aqara \uac8c\uc774\ud2b8\uc6e8\uc774"
}
}
diff --git a/homeassistant/components/xiaomi_aqara/translations/lb.json b/homeassistant/components/xiaomi_aqara/translations/lb.json
index bfea843c56e..919fdeb21ea 100644
--- a/homeassistant/components/xiaomi_aqara/translations/lb.json
+++ b/homeassistant/components/xiaomi_aqara/translations/lb.json
@@ -7,8 +7,10 @@
},
"error": {
"discovery_error": "Feeler beim entdecken vun enger Xiaomi Aqara Gateway, prob\u00e9ier d'IP vum Apparat op deem Home Assistant leeft als Interface ze benotzen",
+ "invalid_host": "Ong\u00eblteg IP Adress",
"invalid_interface": "Ong\u00ebltege Netzwierk Interface",
"invalid_key": "Ong\u00ebltegen Gateway Schl\u00ebssel",
+ "invalid_mac": "Ong\u00eblteg Mac Adresse",
"not_found_error": "D\u00e9i Gateway d\u00e9i duerch de Zeroconf entdeckt gouf konnt net lokalis\u00e9iert ginn, prob\u00e9ier d'IP vum Apparat op deem Home Assistant leeft als Interface ze benotzen"
},
"flow_title": "Xiaomi Aqara Gateway: {name}",
@@ -30,7 +32,9 @@
},
"user": {
"data": {
- "interface": "Netzwierk Interface dee soll benotzt ginn"
+ "host": "IP Adress (Optionell)",
+ "interface": "Netzwierk Interface dee soll benotzt ginn",
+ "mac": "Mac Adress (Optionell)"
},
"description": "Mat denger Xiaomi Aqara Gateway verbannen",
"title": "Xiaomi Aqara Gateway"
diff --git a/homeassistant/components/xiaomi_aqara/translations/nl.json b/homeassistant/components/xiaomi_aqara/translations/nl.json
new file mode 100644
index 00000000000..24eeccf11a0
--- /dev/null
+++ b/homeassistant/components/xiaomi_aqara/translations/nl.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "mac": "MAC-adres (optioneel)"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/xiaomi_aqara/translations/no.json b/homeassistant/components/xiaomi_aqara/translations/no.json
index c94d79b16d5..0119813e3e4 100644
--- a/homeassistant/components/xiaomi_aqara/translations/no.json
+++ b/homeassistant/components/xiaomi_aqara/translations/no.json
@@ -7,16 +7,14 @@
},
"error": {
"discovery_error": "Kunne ikke oppdage en Xiaomi Aqara Gateway, pr\u00f8v \u00e5 bruke IP-adressen til enheten som kj\u00f8rer HomeAssistant som grensesnitt",
+ "invalid_host": "Ugyldig IP adresse , se https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem",
"invalid_interface": "Ugyldig nettverksgrensesnitt",
"invalid_key": "Ugyldig gateway-n\u00f8kkel",
+ "invalid_mac": "Ugyldig Mac-adresse",
"not_found_error": "Zeroconf oppdaget Gateway kunne ikke v\u00e6re plassert for \u00e5 f\u00e5 den n\u00f8dvendige informasjonen, kan du pr\u00f8ve \u00e5 bruke IP-adressen til enheten som kj\u00f8rer HomeAssistant som grensesnitt"
},
- "flow_title": "",
"step": {
"select": {
- "data": {
- "select_ip": ""
- },
"description": "Kj\u00f8r oppsettet igjen hvis du vil koble til tilleggsportaler",
"title": "Velg Xiaomi Aqara Gateway som du \u00f8nsker \u00e5 koble til"
},
@@ -30,10 +28,11 @@
},
"user": {
"data": {
- "interface": "Nettverksgrensesnittet som skal brukes"
+ "host": "IP adresse (valgfritt)",
+ "interface": "Nettverksgrensesnittet som skal brukes",
+ "mac": "Mac-adresse (valgfritt)"
},
- "description": "Koble til Xiaomi Aqara Gateway",
- "title": ""
+ "description": "Koble til Xiaomi Aqara Gateway, hvis IP- og mac-adressene er tomme, brukes automatisk oppdagelse"
}
}
}
diff --git a/homeassistant/components/xiaomi_aqara/translations/pl.json b/homeassistant/components/xiaomi_aqara/translations/pl.json
index d7d2e4fec3e..a603566d569 100644
--- a/homeassistant/components/xiaomi_aqara/translations/pl.json
+++ b/homeassistant/components/xiaomi_aqara/translations/pl.json
@@ -7,8 +7,10 @@
},
"error": {
"discovery_error": "Nie uda\u0142o si\u0119 wykry\u0107 bramki Xiaomi Aqara, spr\u00f3buj u\u017cy\u0107 adresu IP urz\u0105dzenia, na kt\u00f3rym pracuje Home Assistant jako interfejsu.",
+ "invalid_host": "Adres IP jest nieprawid\u0142owy, po pomoc w rozwi\u0105zaniu problemu wejd\u017a tutaj: https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem",
"invalid_interface": "Nieprawid\u0142owy interfejs sieciowy.",
"invalid_key": "Nieprawid\u0142owy klucz bramki.",
+ "invalid_mac": "Nieprawid\u0142owy adres MAC.",
"not_found_error": "Nie mo\u017cna odnale\u017a\u0107 wykrytej bramki, aby uzyska\u0107 niezb\u0119dne informacje, spr\u00f3buj u\u017cy\u0107 adresu IP urz\u0105dzenia, na kt\u00f3rym pracuje Home Assistant jako interfejsu."
},
"flow_title": "Bramka Xiaomi Aqara: {name}",
@@ -30,9 +32,11 @@
},
"user": {
"data": {
- "interface": "Interfejs sieciowy"
+ "host": "Adres IP (opcjonalnie)",
+ "interface": "Interfejs sieciowy",
+ "mac": "Adres MAC (opcjonalnie)"
},
- "description": "Po\u0142\u0105cz si\u0119 z bramk\u0105 Xiaomi Aqara",
+ "description": "Po\u0142\u0105cz si\u0119 z bramk\u0105 Xiaomi Aqara, je\u015bli zostawisz Adres IP oraz MAC puste to bramka zostanie automatycznie wykryta.",
"title": "Bramka Xiaomi Aqara"
}
}
diff --git a/homeassistant/components/xiaomi_aqara/translations/pt.json b/homeassistant/components/xiaomi_aqara/translations/pt.json
index 99dcae6ca47..1983f8b28a5 100644
--- a/homeassistant/components/xiaomi_aqara/translations/pt.json
+++ b/homeassistant/components/xiaomi_aqara/translations/pt.json
@@ -1,16 +1,19 @@
{
"config": {
+ "error": {
+ "invalid_host": "Endere\u00e7o IP Inv\u00e1lido"
+ },
"step": {
- "select": {
- "data": {
- "select_ip": ""
- }
- },
"settings": {
"data": {
"name": "Nome da Gateway"
},
"description": "A chave (palavra-passe) pode ser recuperada usando este tutorial: https://www.domoticz.com/wiki/Xiaomi_Gateway_(Aqara)#Adding_the_Xiaomi_Gateway_to_Domoticz. Se a chave n\u00e3o for fornecida, apenas os sensores estar\u00e3o acess\u00edveis"
+ },
+ "user": {
+ "data": {
+ "host": "Endere\u00e7o IP (optional)"
+ }
}
}
}
diff --git a/homeassistant/components/xiaomi_aqara/translations/ru.json b/homeassistant/components/xiaomi_aqara/translations/ru.json
index 70c19bf2aec..ddce68aa2bf 100644
--- a/homeassistant/components/xiaomi_aqara/translations/ru.json
+++ b/homeassistant/components/xiaomi_aqara/translations/ru.json
@@ -1,14 +1,16 @@
{
"config": {
"abort": {
- "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
+ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.",
"already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f.",
"not_xiaomi_aqara": "\u042d\u0442\u043e \u043d\u0435 \u0448\u043b\u044e\u0437 Xiaomi Aqara. \u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 \u0438\u0437\u0432\u0435\u0441\u0442\u043d\u044b\u043c \u0448\u043b\u044e\u0437\u0430\u043c."
},
"error": {
"discovery_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0438\u0442\u044c \u0448\u043b\u044e\u0437 Xiaomi Aqara, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c IP-\u0430\u0434\u0440\u0435\u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0441 HomeAssistant \u0432 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u0435 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430.",
+ "invalid_host": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 IP-\u0430\u0434\u0440\u0435\u0441. \u0421\u043f\u043e\u0441\u043e\u0431\u044b \u0440\u0435\u0448\u0435\u043d\u0438\u044f \u044d\u0442\u043e\u0439 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u043e\u043f\u0438\u0441\u0430\u043d\u044b \u0437\u0434\u0435\u0441\u044c: https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem.",
"invalid_interface": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u0441\u0435\u0442\u0435\u0432\u043e\u0439 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441.",
"invalid_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 \u0448\u043b\u044e\u0437\u0430.",
+ "invalid_mac": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 MAC-\u0430\u0434\u0440\u0435\u0441.",
"not_found_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043d\u043e\u0433\u043e \u0448\u043b\u044e\u0437\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c IP-\u0430\u0434\u0440\u0435\u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0441 HomeAssistant \u0432 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u0435 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430."
},
"flow_title": "\u0428\u043b\u044e\u0437 Xiaomi Aqara: {name}",
@@ -30,9 +32,11 @@
},
"user": {
"data": {
- "interface": "\u0421\u0435\u0442\u0435\u0432\u043e\u0439 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441"
+ "host": "IP-\u0430\u0434\u0440\u0435\u0441 (optional)",
+ "interface": "\u0421\u0435\u0442\u0435\u0432\u043e\u0439 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441",
+ "mac": "MAC-\u0430\u0434\u0440\u0435\u0441 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)"
},
- "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441\u043e \u0448\u043b\u044e\u0437\u043e\u043c Xiaomi Aqara.",
+ "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 Home Assistant \u0434\u043b\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u0438 \u0441\u043e \u0448\u043b\u044e\u0437\u043e\u043c Xiaomi Aqara. \u0414\u043b\u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0433\u043e \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u0438\u044f \u0448\u043b\u044e\u0437\u0430, \u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u043e\u043b\u044f IP \u0438 MAC \u0430\u0434\u0440\u0435\u0441\u043e\u0432 \u043f\u0443\u0441\u0442\u044b\u043c\u0438.",
"title": "\u0428\u043b\u044e\u0437 Xiaomi Aqara"
}
}
diff --git a/homeassistant/components/xiaomi_aqara/translations/tr.json b/homeassistant/components/xiaomi_aqara/translations/tr.json
new file mode 100644
index 00000000000..10d1374187e
--- /dev/null
+++ b/homeassistant/components/xiaomi_aqara/translations/tr.json
@@ -0,0 +1,7 @@
+{
+ "config": {
+ "error": {
+ "invalid_mac": "Ge\u00e7ersiz Mac Adresi"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/xiaomi_aqara/translations/zh-Hant.json b/homeassistant/components/xiaomi_aqara/translations/zh-Hant.json
index 99b677ddd7d..87ea0c6028a 100644
--- a/homeassistant/components/xiaomi_aqara/translations/zh-Hant.json
+++ b/homeassistant/components/xiaomi_aqara/translations/zh-Hant.json
@@ -7,8 +7,10 @@
},
"error": {
"discovery_error": "\u63a2\u7d22\u5c0f\u7c73 Aqara \u7db2\u95dc\u5931\u6557\uff0c\u8acb\u5617\u8a66\u4f7f\u7528\u57f7\u884c Home Assistant \u8a2d\u5099\u7684 IP \u4f5c\u70ba\u4ecb\u9762",
+ "invalid_host": "\u7121\u6548\u7684 IP \u4f4d\u5740\uff0c\u8acb\u53c3\u95b1 https://www.home-assistant.io/integrations/xiaomi_aqara/#connection-problem",
"invalid_interface": "\u7db2\u8def\u4ecb\u9762\u7121\u6548",
"invalid_key": "\u7db2\u95dc\u5bc6\u9470\u7121\u6548",
+ "invalid_mac": "\u7121\u6548\u7684 Mac \u4f4d\u5740",
"not_found_error": "Zeroconf \u6240\u63a2\u7d22\u7684\u7db2\u95dc\u7121\u6cd5\u53d6\u5f97\u5fc5\u8981\u7684\u8cc7\u8a0a\uff0c\u8acb\u5617\u8a66\u4f7f\u7528\u57f7\u884c Home Assistant \u7684\u8a2d\u5099 IP \u4f5c\u70ba\u4ecb\u9762"
},
"flow_title": "\u5c0f\u7c73 Aqara \u7db2\u95dc\uff1a{name}",
@@ -30,9 +32,11 @@
},
"user": {
"data": {
- "interface": "\u4f7f\u7528\u7684\u7db2\u8def\u4ecb\u9762"
+ "host": "IP \u4f4d\u5740\uff08\u9078\u9805\uff09",
+ "interface": "\u4f7f\u7528\u7684\u7db2\u8def\u4ecb\u9762",
+ "mac": "Mac \u4f4d\u5740\uff08\u9078\u9805\uff09"
},
- "description": "\u9023\u7dda\u81f3\u5c0f\u7c73 Aqara \u7db2\u95dc",
+ "description": "\u9023\u7dda\u81f3\u5c0f\u7c73 Aqara \u7db2\u95dc\uff0c\u5047\u5982 IP \u6216 Mac \u4f4d\u5740\u70ba\u7a7a\u767d\u3001\u5c07\u9032\u884c\u81ea\u52d5\u63a2\u7d22",
"title": "\u5c0f\u7c73 Aqara \u7db2\u95dc"
}
}
diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py
index 63a8bef54af..6957862ab7a 100644
--- a/homeassistant/components/xiaomi_miio/__init__.py
+++ b/homeassistant/components/xiaomi_miio/__init__.py
@@ -11,7 +11,7 @@ from .gateway import ConnectXiaomiGateway
_LOGGER = logging.getLogger(__name__)
-GATEWAY_PLATFORMS = ["alarm_control_panel", "sensor"]
+GATEWAY_PLATFORMS = ["alarm_control_panel", "sensor", "light"]
async def async_setup(hass: core.HomeAssistant, config: dict):
diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py
index 7da4da9c05d..baeb0bf39e5 100644
--- a/homeassistant/components/xiaomi_miio/air_quality.py
+++ b/homeassistant/components/xiaomi_miio/air_quality.py
@@ -53,8 +53,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
try:
device_info = await hass.async_add_executor_job(miio_device.info)
- except DeviceException:
- raise PlatformNotReady
+ except DeviceException as ex:
+ raise PlatformNotReady from ex
model = device_info.model
unique_id = f"{model}-{device_info.mac_address}"
diff --git a/homeassistant/components/xiaomi_miio/alarm_control_panel.py b/homeassistant/components/xiaomi_miio/alarm_control_panel.py
index 7df9dc54e5a..89fcb45b864 100644
--- a/homeassistant/components/xiaomi_miio/alarm_control_panel.py
+++ b/homeassistant/components/xiaomi_miio/alarm_control_panel.py
@@ -3,7 +3,7 @@
from functools import partial
import logging
-from miio import DeviceException
+from miio.gateway import GatewayException
from homeassistant.components.alarm_control_panel import (
SUPPORT_ALARM_ARM_AWAY,
@@ -103,7 +103,7 @@ class XiaomiGatewayAlarm(AlarmControlPanelEntity):
partial(func, *args, **kwargs)
)
_LOGGER.debug("Response received from miio device: %s", result)
- except DeviceException as exc:
+ except GatewayException as exc:
_LOGGER.error(mask_error, exc)
async def async_alarm_arm_away(self, code=None):
@@ -122,7 +122,7 @@ class XiaomiGatewayAlarm(AlarmControlPanelEntity):
"""Fetch state from the device."""
try:
state = await self.hass.async_add_executor_job(self._gateway.alarm.status)
- except DeviceException as ex:
+ except GatewayException as ex:
self._available = False
_LOGGER.error("Got exception while fetching the state: %s", ex)
return
diff --git a/homeassistant/components/xiaomi_miio/config_flow.py b/homeassistant/components/xiaomi_miio/config_flow.py
index 7de55835916..6a62fbead13 100644
--- a/homeassistant/components/xiaomi_miio/config_flow.py
+++ b/homeassistant/components/xiaomi_miio/config_flow.py
@@ -17,6 +17,7 @@ CONF_FLOW_TYPE = "config_flow_device"
CONF_GATEWAY = "gateway"
DEFAULT_GATEWAY_NAME = "Xiaomi Gateway"
ZEROCONF_GATEWAY = "lumi-gateway"
+ZEROCONF_ACPARTNER = "lumi-acpartner"
GATEWAY_SETTINGS = {
vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)),
@@ -61,7 +62,7 @@ class XiaomiMiioFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="not_xiaomi_miio")
# Check which device is discovered.
- if name.startswith(ZEROCONF_GATEWAY):
+ if name.startswith(ZEROCONF_GATEWAY) or name.startswith(ZEROCONF_ACPARTNER):
unique_id = format_mac(mac_address)
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured({CONF_HOST: self.host})
diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py
index 6be4831b6e0..3382ca910c3 100644
--- a/homeassistant/components/xiaomi_miio/fan.py
+++ b/homeassistant/components/xiaomi_miio/fan.py
@@ -523,8 +523,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
device_info.firmware_version,
device_info.hardware_version,
)
- except DeviceException:
- raise PlatformNotReady
+ except DeviceException as ex:
+ raise PlatformNotReady from ex
if model in PURIFIER_MIOT:
air_purifier = AirPurifierMiot(host, token)
diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py
index cc9343aa2c0..29c80faa892 100644
--- a/homeassistant/components/xiaomi_miio/light.py
+++ b/homeassistant/components/xiaomi_miio/light.py
@@ -14,6 +14,12 @@ from miio import ( # pylint: disable=import-error
PhilipsEyecare,
PhilipsMoonlight,
)
+from miio.gateway import (
+ GATEWAY_MODEL_AC_V1,
+ GATEWAY_MODEL_AC_V2,
+ GATEWAY_MODEL_AC_V3,
+ GatewayException,
+)
import voluptuous as vol
from homeassistant.components.light import (
@@ -31,6 +37,7 @@ from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.util import color, dt
+from .config_flow import CONF_FLOW_TYPE, CONF_GATEWAY
from .const import (
DOMAIN,
SERVICE_EYECARE_MODE_OFF,
@@ -102,7 +109,7 @@ SERVICE_SCHEMA_SET_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend(
)
SERVICE_SCHEMA_SET_DELAYED_TURN_OFF = XIAOMI_MIIO_SERVICE_SCHEMA.extend(
- {vol.Required(ATTR_TIME_PERIOD): vol.All(cv.time_period, cv.positive_timedelta)}
+ {vol.Required(ATTR_TIME_PERIOD): cv.positive_time_period}
)
SERVICE_TO_METHOD = {
@@ -123,6 +130,25 @@ SERVICE_TO_METHOD = {
}
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the Xiaomi light from a config entry."""
+ entities = []
+
+ if config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY:
+ gateway = hass.data[DOMAIN][config_entry.entry_id]
+ # Gateway light
+ if gateway.model not in [
+ GATEWAY_MODEL_AC_V1,
+ GATEWAY_MODEL_AC_V2,
+ GATEWAY_MODEL_AC_V3,
+ ]:
+ entities.append(
+ XiaomiGatewayLight(gateway, config_entry.title, config_entry.unique_id)
+ )
+
+ async_add_entities(entities, update_before_add=True)
+
+
async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
"""Set up the light from config."""
if DATA_KEY not in hass.data:
@@ -150,8 +176,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
device_info.firmware_version,
device_info.hardware_version,
)
- except DeviceException:
- raise PlatformNotReady
+ except DeviceException as ex:
+ raise PlatformNotReady from ex
if model == "philips.light.sread1":
light = PhilipsEyecare(host, token)
@@ -938,3 +964,104 @@ class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb):
async def async_set_delayed_turn_off(self, time_period: timedelta):
"""Set delayed turn off. Unsupported."""
return
+
+
+class XiaomiGatewayLight(LightEntity):
+ """Representation of a gateway device's light."""
+
+ def __init__(self, gateway_device, gateway_name, gateway_device_id):
+ """Initialize the XiaomiGatewayLight."""
+ self._gateway = gateway_device
+ self._name = f"{gateway_name} Light"
+ self._gateway_device_id = gateway_device_id
+ self._unique_id = gateway_device_id
+ self._available = False
+ self._is_on = None
+ self._brightness_pct = 100
+ self._rgb = (255, 255, 255)
+ self._hs = (0, 0)
+
+ @property
+ def unique_id(self):
+ """Return an unique ID."""
+ return self._unique_id
+
+ @property
+ def device_info(self):
+ """Return the device info of the gateway."""
+ return {
+ "identifiers": {(DOMAIN, self._gateway_device_id)},
+ }
+
+ @property
+ def name(self):
+ """Return the name of this entity, if any."""
+ return self._name
+
+ @property
+ def available(self):
+ """Return true when state is known."""
+ return self._available
+
+ @property
+ def is_on(self):
+ """Return true if it is on."""
+ return self._is_on
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return int(255 * self._brightness_pct / 100)
+
+ @property
+ def hs_color(self):
+ """Return the hs color value."""
+ return self._hs
+
+ @property
+ def supported_features(self):
+ """Return the supported features."""
+ return SUPPORT_BRIGHTNESS | SUPPORT_COLOR
+
+ def turn_on(self, **kwargs):
+ """Turn the light on."""
+ if ATTR_HS_COLOR in kwargs:
+ rgb = color.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR])
+ else:
+ rgb = self._rgb
+
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness_pct = int(100 * kwargs[ATTR_BRIGHTNESS] / 255)
+ else:
+ brightness_pct = self._brightness_pct
+
+ self._gateway.light.set_rgb(brightness_pct, rgb)
+
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the light off."""
+ self._gateway.light.set_rgb(0, self._rgb)
+ self.schedule_update_ha_state()
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ try:
+ state_dict = await self.hass.async_add_executor_job(
+ self._gateway.light.rgb_status
+ )
+ except GatewayException as ex:
+ if self._available:
+ self._available = False
+ _LOGGER.error(
+ "Got exception while fetching the gateway light state: %s", ex
+ )
+ return
+
+ self._available = True
+ self._is_on = state_dict["is_on"]
+
+ if self._is_on:
+ self._brightness_pct = state_dict["brightness"]
+ self._rgb = state_dict["rgb"]
+ self._hs = color.color_RGB_to_hs(*self._rgb)
diff --git a/homeassistant/components/xiaomi_miio/remote.py b/homeassistant/components/xiaomi_miio/remote.py
index eb4e6df9acd..c88b2d3663c 100644
--- a/homeassistant/components/xiaomi_miio/remote.py
+++ b/homeassistant/components/xiaomi_miio/remote.py
@@ -86,7 +86,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
)
except DeviceException as ex:
_LOGGER.error("Device unavailable or token incorrect: %s", ex)
- raise PlatformNotReady
+ raise PlatformNotReady from ex
if DATA_KEY not in hass.data:
hass.data[DATA_KEY] = {}
@@ -158,10 +158,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
async_service_learn_handler,
)
platform.async_register_entity_service(
- SERVICE_SET_REMOTE_LED_ON, {}, async_service_led_on_handler,
+ SERVICE_SET_REMOTE_LED_ON,
+ {},
+ async_service_led_on_handler,
)
platform.async_register_entity_service(
- SERVICE_SET_REMOTE_LED_OFF, {}, async_service_led_off_handler,
+ SERVICE_SET_REMOTE_LED_OFF,
+ {},
+ async_service_led_off_handler,
)
diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py
index 4b1442a8c55..15dc1bea8bd 100644
--- a/homeassistant/components/xiaomi_miio/sensor.py
+++ b/homeassistant/components/xiaomi_miio/sensor.py
@@ -3,7 +3,13 @@ from dataclasses import dataclass
import logging
from miio import AirQualityMonitor, DeviceException # pylint: disable=import-error
-from miio.gateway import DeviceType
+from miio.gateway import (
+ GATEWAY_MODEL_AC_V1,
+ GATEWAY_MODEL_AC_V2,
+ GATEWAY_MODEL_AC_V3,
+ DeviceType,
+ GatewayException,
+)
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
@@ -12,11 +18,12 @@ from homeassistant.const import (
CONF_NAME,
CONF_TOKEN,
DEVICE_CLASS_HUMIDITY,
+ DEVICE_CLASS_ILLUMINANCE,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
+ PERCENTAGE,
PRESSURE_HPA,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
from homeassistant.exceptions import PlatformNotReady
import homeassistant.helpers.config_validation as cv
@@ -66,7 +73,7 @@ GATEWAY_SENSOR_TYPES = {
unit=TEMP_CELSIUS, icon=None, device_class=DEVICE_CLASS_TEMPERATURE
),
"humidity": SensorType(
- unit=UNIT_PERCENTAGE, icon=None, device_class=DEVICE_CLASS_HUMIDITY
+ unit=PERCENTAGE, icon=None, device_class=DEVICE_CLASS_HUMIDITY
),
"pressure": SensorType(
unit=PRESSURE_HPA, icon=None, device_class=DEVICE_CLASS_PRESSURE
@@ -78,9 +85,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Xiaomi sensor from a config entry."""
entities = []
- # Gateway sub devices
if config_entry.data[CONF_FLOW_TYPE] == CONF_GATEWAY:
gateway = hass.data[DOMAIN][config_entry.entry_id]
+ # Gateway illuminance sensor
+ if gateway.model not in [
+ GATEWAY_MODEL_AC_V1,
+ GATEWAY_MODEL_AC_V2,
+ GATEWAY_MODEL_AC_V3,
+ ]:
+ entities.append(
+ XiaomiGatewayIlluminanceSensor(
+ gateway, config_entry.title, config_entry.unique_id
+ )
+ )
+ # Gateway sub devices
sub_devices = gateway.devices
for sub_device in sub_devices.values():
sensor_variables = None
@@ -122,8 +140,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
device_info.hardware_version,
)
device = XiaomiAirQualityMonitor(name, air_quality_monitor, model, unique_id)
- except DeviceException:
- raise PlatformNotReady
+ except DeviceException as ex:
+ raise PlatformNotReady from ex
hass.data[DATA_KEY][host] = device
async_add_entities([device], update_before_add=True)
@@ -250,3 +268,67 @@ class XiaomiGatewaySensor(XiaomiGatewayDevice):
def state(self):
"""Return the state of the sensor."""
return self._sub_device.status[self._data_key]
+
+
+class XiaomiGatewayIlluminanceSensor(Entity):
+ """Representation of the gateway device's illuminance sensor."""
+
+ def __init__(self, gateway_device, gateway_name, gateway_device_id):
+ """Initialize the entity."""
+ self._gateway = gateway_device
+ self._name = f"{gateway_name} Illuminance"
+ self._gateway_device_id = gateway_device_id
+ self._unique_id = f"{gateway_device_id}-illuminance"
+ self._available = False
+ self._state = None
+
+ @property
+ def unique_id(self):
+ """Return an unique ID."""
+ return self._unique_id
+
+ @property
+ def device_info(self):
+ """Return the device info of the gateway."""
+ return {
+ "identifiers": {(DOMAIN, self._gateway_device_id)},
+ }
+
+ @property
+ def name(self):
+ """Return the name of this entity, if any."""
+ return self._name
+
+ @property
+ def available(self):
+ """Return true when state is known."""
+ return self._available
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return "lux"
+
+ @property
+ def device_class(self):
+ """Return the device class of this entity."""
+ return DEVICE_CLASS_ILLUMINANCE
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ try:
+ self._state = await self.hass.async_add_executor_job(
+ self._gateway.get_illumination
+ )
+ self._available = True
+ except GatewayException as ex:
+ if self._available:
+ self._available = False
+ _LOGGER.error(
+ "Got exception while fetching the gateway illuminance state: %s", ex
+ )
diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py
index d8552244ce8..6dfcc539a44 100644
--- a/homeassistant/components/xiaomi_miio/switch.py
+++ b/homeassistant/components/xiaomi_miio/switch.py
@@ -57,6 +57,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
"chuangmi.plug.v3",
"chuangmi.plug.hmi205",
"chuangmi.plug.hmi206",
+ "chuangmi.plug.hmi208",
"lumi.acpartner.v3",
]
),
@@ -139,10 +140,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
device_info.firmware_version,
device_info.hardware_version,
)
- except DeviceException:
- raise PlatformNotReady
+ except DeviceException as ex:
+ raise PlatformNotReady from ex
- if model in ["chuangmi.plug.v1", "chuangmi.plug.v3"]:
+ if model in ["chuangmi.plug.v1", "chuangmi.plug.v3", "chuangmi.plug.hmi208"]:
plug = ChuangmiPlug(host, token, model=model)
# The device has two switchable channels (mains and a USB port).
diff --git a/homeassistant/components/xiaomi_miio/translations/fr.json b/homeassistant/components/xiaomi_miio/translations/fr.json
index cbc890dc415..e76e5a1ef69 100644
--- a/homeassistant/components/xiaomi_miio/translations/fr.json
+++ b/homeassistant/components/xiaomi_miio/translations/fr.json
@@ -1,10 +1,11 @@
{
"config": {
"abort": {
+ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9",
"already_in_progress": "Le flux de configuration pour cet appareil Xiaomi Miio est d\u00e9j\u00e0 en cours."
},
"error": {
- "connect_error": "Impossible de se connecter, veuillez r\u00e9essayer",
+ "connect_error": "\u00c9chec de connexion",
"no_device_selected": "Aucun appareil s\u00e9lectionn\u00e9, veuillez s\u00e9lectionner un appareil."
},
"flow_title": "Xiaomi Miio: {name}",
diff --git a/homeassistant/components/xiaomi_miio/translations/no.json b/homeassistant/components/xiaomi_miio/translations/no.json
index cf978d4a015..ff0ba48d98a 100644
--- a/homeassistant/components/xiaomi_miio/translations/no.json
+++ b/homeassistant/components/xiaomi_miio/translations/no.json
@@ -23,8 +23,7 @@
"data": {
"gateway": "Koble til en Xiaomi Gateway"
},
- "description": "Velg hvilken enhet du vil koble til.",
- "title": ""
+ "description": "Velg hvilken enhet du vil koble til."
}
}
}
diff --git a/homeassistant/components/xiaomi_miio/translations/pl.json b/homeassistant/components/xiaomi_miio/translations/pl.json
index b4bd9a5546d..90f191a58c4 100644
--- a/homeassistant/components/xiaomi_miio/translations/pl.json
+++ b/homeassistant/components/xiaomi_miio/translations/pl.json
@@ -16,7 +16,7 @@
"name": "Nazwa bramki",
"token": "Token API"
},
- "description": "B\u0119dziesz potrzebowa\u0107 tokenu API, odwied\u017a https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token, aby uzyska\u0107 instrukcje.",
+ "description": "B\u0119dziesz potrzebowa\u0107 tokenu API (32 znaki), odwied\u017a https://www.home-assistant.io/integrations/vacuum.xiaomi_miio/#retrieving-the-access-token, aby uzyska\u0107 instrukcje. Zauwa\u017c i\u017c jest to inny token ni\u017c w integracji Xiaomi Aqara .",
"title": "Po\u0142\u0105cz si\u0119 z bramk\u0105 Xiaomi"
},
"user": {
diff --git a/homeassistant/components/xiaomi_miio/translations/pt-BR.json b/homeassistant/components/xiaomi_miio/translations/pt-BR.json
index 2f7f84af27e..4c5e0bf1ccb 100644
--- a/homeassistant/components/xiaomi_miio/translations/pt-BR.json
+++ b/homeassistant/components/xiaomi_miio/translations/pt-BR.json
@@ -2,6 +2,16 @@
"config": {
"abort": {
"already_in_progress": "O fluxo de configura\u00e7\u00e3o para este dispositivo Xiaomi Miio j\u00e1 est\u00e1 em andamento."
+ },
+ "error": {
+ "connect_error": "Falha na conex\u00e3o"
+ },
+ "step": {
+ "gateway": {
+ "data": {
+ "host": "Endere\u00e7o IP"
+ }
+ }
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/xiaomi_miio/translations/ru.json b/homeassistant/components/xiaomi_miio/translations/ru.json
index d8c1d84e2b6..1c934e2784c 100644
--- a/homeassistant/components/xiaomi_miio/translations/ru.json
+++ b/homeassistant/components/xiaomi_miio/translations/ru.json
@@ -1,11 +1,11 @@
{
"config": {
"abort": {
- "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
+ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.",
"already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u044d\u0442\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f."
},
"error": {
- "connect_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.",
+ "connect_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f.",
"no_device_selected": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0434\u043d\u043e \u0438\u0437 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432."
},
"flow_title": "Xiaomi Miio: {name}",
diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py
index 106e8f9dfc4..3e414016fc5 100644
--- a/homeassistant/components/xiaomi_miio/vacuum.py
+++ b/homeassistant/components/xiaomi_miio/vacuum.py
@@ -91,20 +91,26 @@ SUPPORT_XIAOMI = (
STATE_CODE_TO_STATE = {
- 2: STATE_IDLE,
- 3: STATE_IDLE,
- 5: STATE_CLEANING,
- 6: STATE_RETURNING,
- 7: STATE_CLEANING,
- 8: STATE_DOCKED,
- 9: STATE_ERROR,
- 10: STATE_PAUSED,
- 11: STATE_CLEANING,
- 12: STATE_ERROR,
- 15: STATE_RETURNING,
- 16: STATE_CLEANING,
- 17: STATE_CLEANING,
- 18: STATE_CLEANING,
+ 1: STATE_IDLE, # "Starting"
+ 2: STATE_IDLE, # "Charger disconnected"
+ 3: STATE_IDLE, # "Idle"
+ 4: STATE_CLEANING, # "Remote control active"
+ 5: STATE_CLEANING, # "Cleaning"
+ 6: STATE_RETURNING, # "Returning home"
+ 7: STATE_CLEANING, # "Manual mode"
+ 8: STATE_DOCKED, # "Charging"
+ 9: STATE_ERROR, # "Charging problem"
+ 10: STATE_PAUSED, # "Paused"
+ 11: STATE_CLEANING, # "Spot cleaning"
+ 12: STATE_ERROR, # "Error"
+ 13: STATE_IDLE, # "Shutting down"
+ 14: STATE_DOCKED, # "Updating"
+ 15: STATE_RETURNING, # "Docking"
+ 16: STATE_CLEANING, # "Going to target"
+ 17: STATE_CLEANING, # "Zoned cleaning"
+ 18: STATE_CLEANING, # "Segment cleaning"
+ 100: STATE_DOCKED, # "Charging complete"
+ 101: STATE_ERROR, # "Device offline"
}
diff --git a/homeassistant/components/xmpp/manifest.json b/homeassistant/components/xmpp/manifest.json
index a82e3492599..2453ce61b05 100644
--- a/homeassistant/components/xmpp/manifest.json
+++ b/homeassistant/components/xmpp/manifest.json
@@ -2,6 +2,6 @@
"domain": "xmpp",
"name": "Jabber (XMPP)",
"documentation": "https://www.home-assistant.io/integrations/xmpp",
- "requirements": ["slixmpp==1.5.1"],
+ "requirements": ["slixmpp==1.5.2"],
"codeowners": ["@fabaff", "@flowolf"]
}
diff --git a/homeassistant/components/xs1/__init__.py b/homeassistant/components/xs1/__init__.py
index 1fbcb49d0c9..8651c33546c 100644
--- a/homeassistant/components/xs1/__init__.py
+++ b/homeassistant/components/xs1/__init__.py
@@ -63,7 +63,8 @@ def setup(hass, config):
)
except ConnectionError as error:
_LOGGER.error(
- "Failed to create XS1 API client because of a connection error: %s", error,
+ "Failed to create XS1 API client because of a connection error: %s",
+ error,
)
return False
diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py
index 196e6605eab..5147e57dfc6 100644
--- a/homeassistant/components/yamaha/media_player.py
+++ b/homeassistant/components/yamaha/media_player.py
@@ -154,7 +154,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=
# Register Service 'select_scene'
platform = entity_platform.current_platform.get()
platform.async_register_entity_service(
- SERVICE_SELECT_SCENE, {vol.Required(ATTR_SCENE): cv.string}, "set_scene",
+ SERVICE_SELECT_SCENE,
+ {vol.Required(ATTR_SCENE): cv.string},
+ "set_scene",
)
# Register Service 'enable_output'
platform.async_register_entity_service(
diff --git a/homeassistant/components/yandex_transport/manifest.json b/homeassistant/components/yandex_transport/manifest.json
index bb53be8f170..b8afe738a07 100644
--- a/homeassistant/components/yandex_transport/manifest.json
+++ b/homeassistant/components/yandex_transport/manifest.json
@@ -2,6 +2,6 @@
"domain": "yandex_transport",
"name": "Yandex Transport",
"documentation": "https://www.home-assistant.io/integrations/yandex_transport",
- "requirements": ["aioymaps==1.0.0"],
+ "requirements": ["aioymaps==1.1.0"],
"codeowners": ["@rishatik92", "@devbis"]
}
diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py
index cde115cb12f..957844e519d 100644
--- a/homeassistant/components/yandex_transport/sensor.py
+++ b/homeassistant/components/yandex_transport/sensor.py
@@ -60,7 +60,7 @@ class DiscoverYandexTransport(Entity):
self._name = name
self._attrs = None
- async def async_update(self):
+ async def async_update(self, *, tries=0):
"""Get the latest data from maps.yandex.ru and update the states."""
attrs = {}
closer_time = None
@@ -73,8 +73,12 @@ class DiscoverYandexTransport(Entity):
key_error,
yandex_reply,
)
+ if tries > 0:
+ return
await self.requester.set_new_session()
- data = (await self.requester.get_stop_info(self._stop_id))["data"]
+ await self.async_update(tries=tries + 1)
+ return
+
stop_name = data["name"]
transport_list = data["transports"]
for transport in transport_list:
diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py
index b0413599fe3..f5403062faa 100644
--- a/homeassistant/components/yeelight/__init__.py
+++ b/homeassistant/components/yeelight/__init__.py
@@ -1,27 +1,25 @@
"""Support for Xiaomi Yeelight WiFi color bulb."""
-
+import asyncio
from datetime import timedelta
import logging
from typing import Optional
import voluptuous as vol
-from yeelight import Bulb, BulbException
+from yeelight import Bulb, BulbException, discover_bulbs
-from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
-from homeassistant.components.discovery import SERVICE_YEELIGHT
-from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryNotReady
from homeassistant.const import (
- ATTR_ENTITY_ID,
CONF_DEVICES,
CONF_HOST,
+ CONF_ID,
CONF_NAME,
CONF_SCAN_INTERVAL,
)
-from homeassistant.helpers import discovery
+from homeassistant.core import HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.discovery import load_platform
-from homeassistant.helpers.dispatcher import dispatcher_connect, dispatcher_send
-from homeassistant.helpers.event import track_time_interval
+from homeassistant.helpers.dispatcher import dispatcher_send
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_time_interval
_LOGGER = logging.getLogger(__name__)
@@ -32,6 +30,9 @@ DEVICE_INITIALIZED = f"{DOMAIN}_device_initialized"
DEFAULT_NAME = "Yeelight"
DEFAULT_TRANSITION = 350
+DEFAULT_MODE_MUSIC = False
+DEFAULT_SAVE_ON_CHANGE = False
+DEFAULT_NIGHTLIGHT_SWITCH = False
CONF_MODEL = "model"
CONF_TRANSITION = "transition"
@@ -40,6 +41,14 @@ CONF_MODE_MUSIC = "use_music_mode"
CONF_FLOW_PARAMS = "flow_params"
CONF_CUSTOM_EFFECTS = "custom_effects"
CONF_NIGHTLIGHT_SWITCH_TYPE = "nightlight_switch_type"
+CONF_NIGHTLIGHT_SWITCH = "nightlight_switch"
+CONF_DEVICE = "device"
+
+DATA_CONFIG_ENTRIES = "config_entries"
+DATA_CUSTOM_EFFECTS = "custom_effects"
+DATA_SCAN_INTERVAL = "scan_interval"
+DATA_DEVICE = "device"
+DATA_UNSUB_UPDATE_LISTENER = "unsub_update_listener"
ATTR_COUNT = "count"
ATTR_ACTION = "action"
@@ -55,6 +64,7 @@ ACTIVE_COLOR_FLOWING = "1"
NIGHTLIGHT_SWITCH_TYPE_LIGHT = "light"
SCAN_INTERVAL = timedelta(seconds=30)
+DISCOVERY_INTERVAL = timedelta(seconds=60)
YEELIGHT_RGB_TRANSITION = "RGBTransition"
YEELIGHT_HSV_TRANSACTION = "HSVTransition"
@@ -115,8 +125,6 @@ CONFIG_SCHEMA = vol.Schema(
extra=vol.ALLOW_EXTRA,
)
-YEELIGHT_SERVICE_SCHEMA = vol.Schema({vol.Required(ATTR_ENTITY_ID): cv.entity_ids})
-
UPDATE_REQUEST_PROPERTIES = [
"power",
"main_power",
@@ -139,73 +147,221 @@ UPDATE_REQUEST_PROPERTIES = [
"active_mode",
]
+PLATFORMS = ["binary_sensor", "light"]
-def setup(hass, config):
+
+async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up the Yeelight bulbs."""
conf = config.get(DOMAIN, {})
- yeelight_data = hass.data[DATA_YEELIGHT] = {}
+ hass.data[DOMAIN] = {
+ DATA_CUSTOM_EFFECTS: conf.get(CONF_CUSTOM_EFFECTS, {}),
+ DATA_CONFIG_ENTRIES: {},
+ DATA_SCAN_INTERVAL: conf.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL),
+ }
- def device_discovered(_, info):
- _LOGGER.debug("Adding autodetected %s", info["hostname"])
-
- name = "yeelight_{}_{}".format(info["device_type"], info["properties"]["mac"])
-
- device_config = DEVICE_SCHEMA({CONF_NAME: name})
-
- _setup_device(hass, config, info[CONF_HOST], device_config)
-
- discovery.listen(hass, SERVICE_YEELIGHT, device_discovered)
-
- def update(_):
- for device in list(yeelight_data.values()):
- device.update()
-
- track_time_interval(hass, update, conf.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL))
-
- def load_platforms(ipaddr):
- platform_config = hass.data[DATA_YEELIGHT][ipaddr].config.copy()
- platform_config[CONF_HOST] = ipaddr
- platform_config[CONF_CUSTOM_EFFECTS] = config.get(DOMAIN, {}).get(
- CONF_CUSTOM_EFFECTS, {}
+ # Import manually configured devices
+ for host, device_config in config.get(DOMAIN, {}).get(CONF_DEVICES, {}).items():
+ _LOGGER.debug("Importing configured %s", host)
+ entry_config = {
+ CONF_HOST: host,
+ **device_config,
+ }
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=entry_config,
+ ),
)
- load_platform(hass, LIGHT_DOMAIN, DOMAIN, platform_config, config)
- load_platform(hass, BINARY_SENSOR_DOMAIN, DOMAIN, platform_config, config)
-
- dispatcher_connect(hass, DEVICE_INITIALIZED, load_platforms)
-
- if DOMAIN in config:
- for ipaddr, device_config in conf[CONF_DEVICES].items():
- _LOGGER.debug("Adding configured %s", device_config[CONF_NAME])
- _setup_device(hass, config, ipaddr, device_config)
return True
-def _setup_device(hass, _, ipaddr, device_config):
- devices = hass.data[DATA_YEELIGHT]
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up Yeelight from a config entry."""
- if ipaddr in devices:
- return
+ async def _initialize(host: str) -> None:
+ device = await _async_setup_device(hass, host, entry.options)
+ hass.data[DOMAIN][DATA_CONFIG_ENTRIES][entry.entry_id][DATA_DEVICE] = device
+ for component in PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component)
+ )
- device = YeelightDevice(hass, ipaddr, device_config)
+ # Move options from data for imported entries
+ # Initialize options with default values for other entries
+ if not entry.options:
+ hass.config_entries.async_update_entry(
+ entry,
+ data={
+ CONF_HOST: entry.data.get(CONF_HOST),
+ CONF_ID: entry.data.get(CONF_ID),
+ },
+ options={
+ CONF_NAME: entry.data.get(CONF_NAME, ""),
+ CONF_MODEL: entry.data.get(CONF_MODEL, ""),
+ CONF_TRANSITION: entry.data.get(CONF_TRANSITION, DEFAULT_TRANSITION),
+ CONF_MODE_MUSIC: entry.data.get(CONF_MODE_MUSIC, DEFAULT_MODE_MUSIC),
+ CONF_SAVE_ON_CHANGE: entry.data.get(
+ CONF_SAVE_ON_CHANGE, DEFAULT_SAVE_ON_CHANGE
+ ),
+ CONF_NIGHTLIGHT_SWITCH: entry.data.get(
+ CONF_NIGHTLIGHT_SWITCH, DEFAULT_NIGHTLIGHT_SWITCH
+ ),
+ },
+ )
- devices[ipaddr] = device
- hass.add_job(device.setup)
+ hass.data[DOMAIN][DATA_CONFIG_ENTRIES][entry.entry_id] = {
+ DATA_UNSUB_UPDATE_LISTENER: entry.add_update_listener(_async_update_listener)
+ }
+
+ if entry.data.get(CONF_HOST):
+ # manually added device
+ await _initialize(entry.data[CONF_HOST])
+ else:
+ # discovery
+ scanner = YeelightScanner.async_get(hass)
+ scanner.async_register_callback(entry.data[CONF_ID], _initialize)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
+ """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:
+ data = hass.data[DOMAIN][DATA_CONFIG_ENTRIES].pop(entry.entry_id)
+ data[DATA_UNSUB_UPDATE_LISTENER]()
+ data[DATA_DEVICE].async_unload()
+ if entry.data[CONF_ID]:
+ # discovery
+ scanner = YeelightScanner.async_get(hass)
+ scanner.async_unregister_callback(entry.data[CONF_ID])
+
+ return unload_ok
+
+
+async def _async_setup_device(
+ hass: HomeAssistant,
+ host: str,
+ config: dict,
+) -> None:
+ # Set up device
+ bulb = Bulb(host, model=config.get(CONF_MODEL) or None)
+ capabilities = await hass.async_add_executor_job(bulb.get_capabilities)
+ if capabilities is None: # timeout
+ _LOGGER.error("Failed to get capabilities from %s", host)
+ raise ConfigEntryNotReady
+ device = YeelightDevice(hass, host, config, bulb)
+ await hass.async_add_executor_job(device.update)
+ await device.async_setup()
+ return device
+
+
+async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry):
+ """Handle options update."""
+ await hass.config_entries.async_reload(entry.entry_id)
+
+
+class YeelightScanner:
+ """Scan for Yeelight devices."""
+
+ _scanner = None
+
+ @classmethod
+ @callback
+ def async_get(cls, hass: HomeAssistant):
+ """Get scanner instance."""
+ if cls._scanner is None:
+ cls._scanner = cls(hass)
+ return cls._scanner
+
+ def __init__(self, hass: HomeAssistant):
+ """Initialize class."""
+ self._hass = hass
+ self._seen = {}
+ self._callbacks = {}
+ self._scan_task = None
+
+ async def _async_scan(self):
+ _LOGGER.debug("Yeelight scanning")
+ # Run 3 times as packets can get lost
+ for _ in range(3):
+ devices = await self._hass.async_add_executor_job(discover_bulbs)
+ for device in devices:
+ unique_id = device["capabilities"]["id"]
+ if unique_id in self._seen:
+ continue
+ host = device["ip"]
+ self._seen[unique_id] = host
+ _LOGGER.debug("Yeelight discovered at %s", host)
+ if unique_id in self._callbacks:
+ self._hass.async_create_task(self._callbacks[unique_id](host))
+ self._callbacks.pop(unique_id)
+ if len(self._callbacks) == 0:
+ self._async_stop_scan()
+
+ await asyncio.sleep(SCAN_INTERVAL.seconds)
+ self._scan_task = self._hass.loop.create_task(self._async_scan())
+
+ @callback
+ def _async_start_scan(self):
+ """Start scanning for Yeelight devices."""
+ _LOGGER.debug("Start scanning")
+ # Use loop directly to avoid home assistant track this task
+ self._scan_task = self._hass.loop.create_task(self._async_scan())
+
+ @callback
+ def _async_stop_scan(self):
+ """Stop scanning."""
+ _LOGGER.debug("Stop scanning")
+ if self._scan_task is not None:
+ self._scan_task.cancel()
+ self._scan_task = None
+
+ @callback
+ def async_register_callback(self, unique_id, callback_func):
+ """Register callback function."""
+ host = self._seen.get(unique_id)
+ if host is not None:
+ self._hass.async_add_job(callback_func(host))
+ else:
+ self._callbacks[unique_id] = callback_func
+ if len(self._callbacks) == 1:
+ self._async_start_scan()
+
+ @callback
+ def async_unregister_callback(self, unique_id):
+ """Unregister callback function."""
+ if unique_id not in self._callbacks:
+ return
+ self._callbacks.pop(unique_id)
+ if len(self._callbacks) == 0:
+ self._async_stop_scan()
class YeelightDevice:
"""Represents single Yeelight device."""
- def __init__(self, hass, ipaddr, config):
+ def __init__(self, hass, host, config, bulb):
"""Initialize device."""
self._hass = hass
self._config = config
- self._ipaddr = ipaddr
- self._name = config.get(CONF_NAME)
- self._bulb_device = Bulb(self.ipaddr, model=config.get(CONF_MODEL))
+ self._host = host
+ unique_id = bulb.capabilities.get("id")
+ self._name = config.get(CONF_NAME) or f"yeelight_{bulb.model}_{unique_id}"
+ self._bulb_device = bulb
self._device_type = None
self._available = False
- self._initialized = False
+ self._remove_time_tracker = None
@property
def bulb(self):
@@ -223,9 +379,9 @@ class YeelightDevice:
return self._config
@property
- def ipaddr(self):
- """Return ip address."""
- return self._ipaddr
+ def host(self):
+ """Return hostname."""
+ return self._host
@property
def available(self):
@@ -237,6 +393,11 @@ class YeelightDevice:
"""Return configured/autodetected device model."""
return self._bulb_device.model
+ @property
+ def fw_version(self):
+ """Return the firmware version."""
+ return self._bulb_device.capabilities.get("fw_ver")
+
@property
def is_nightlight_supported(self) -> bool:
"""
@@ -308,7 +469,7 @@ class YeelightDevice:
self.bulb.turn_off(duration=duration, light_type=light_type)
except BulbException as ex:
_LOGGER.error(
- "Unable to turn the bulb off: %s, %s: %s", self.ipaddr, self.name, ex
+ "Unable to turn the bulb off: %s, %s: %s", self._host, self.name, ex
)
def _update_properties(self):
@@ -319,12 +480,10 @@ class YeelightDevice:
try:
self.bulb.get_properties(UPDATE_REQUEST_PROPERTIES)
self._available = True
- if not self._initialized:
- self._initialize_device()
except BulbException as ex:
if self._available: # just inform once
_LOGGER.error(
- "Unable to update device %s, %s: %s", self.ipaddr, self.name, ex
+ "Unable to update device %s, %s: %s", self._host, self.name, ex
)
self._available = False
@@ -336,28 +495,68 @@ class YeelightDevice:
self.bulb.get_capabilities()
_LOGGER.debug(
"Device %s, %s capabilities: %s",
- self.ipaddr,
+ self._host,
self.name,
self.bulb.capabilities,
)
except BulbException as ex:
_LOGGER.error(
"Unable to get device capabilities %s, %s: %s",
- self.ipaddr,
+ self._host,
self.name,
ex,
)
- def _initialize_device(self):
- self._get_capabilities()
- self._initialized = True
- dispatcher_send(self._hass, DEVICE_INITIALIZED, self.ipaddr)
-
def update(self):
"""Update device properties and send data updated signal."""
self._update_properties()
- dispatcher_send(self._hass, DATA_UPDATED.format(self._ipaddr))
+ dispatcher_send(self._hass, DATA_UPDATED.format(self._host))
- def setup(self):
- """Fetch initial device properties."""
- self._update_properties()
+ async def async_setup(self):
+ """Set up the device."""
+
+ async def _async_update(_):
+ await self._hass.async_add_executor_job(self.update)
+
+ await _async_update(None)
+ self._remove_time_tracker = async_track_time_interval(
+ self._hass, _async_update, self._hass.data[DOMAIN][DATA_SCAN_INTERVAL]
+ )
+
+ @callback
+ def async_unload(self):
+ """Unload the device."""
+ self._remove_time_tracker()
+
+
+class YeelightEntity(Entity):
+ """Represents single Yeelight entity."""
+
+ def __init__(self, device: YeelightDevice):
+ """Initialize the entity."""
+ self._device = device
+
+ @property
+ def device_info(self) -> dict:
+ """Return the device info."""
+ return {
+ "identifiers": {(DOMAIN, self._device.unique_id)},
+ "name": self._device.name,
+ "manufacturer": "Yeelight",
+ "model": self._device.model,
+ "sw_version": self._device.fw_version,
+ }
+
+ @property
+ def available(self) -> bool:
+ """Return if bulb is available."""
+ return self._device.available
+
+ @property
+ def should_poll(self) -> bool:
+ """No polling needed."""
+ return False
+
+ def update(self) -> None:
+ """Update the entity."""
+ self._device.update()
diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py
index 1696ca9bcb2..6d9de45c837 100644
--- a/homeassistant/components/yeelight/binary_sensor.py
+++ b/homeassistant/components/yeelight/binary_sensor.py
@@ -3,38 +3,34 @@ import logging
from typing import Optional
from homeassistant.components.binary_sensor import BinarySensorEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from . import DATA_UPDATED, DATA_YEELIGHT
+from . import DATA_CONFIG_ENTRIES, DATA_DEVICE, DATA_UPDATED, DOMAIN, YeelightEntity
_LOGGER = logging.getLogger(__name__)
-def setup_platform(hass, config, add_entities, discovery_info=None):
- """Set up the Yeelight sensors."""
- if not discovery_info:
- return
-
- device = hass.data[DATA_YEELIGHT][discovery_info["host"]]
-
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities
+) -> None:
+ """Set up Yeelight from a config entry."""
+ device = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE]
if device.is_nightlight_supported:
_LOGGER.debug("Adding nightlight mode sensor for %s", device.name)
- add_entities([YeelightNightlightModeSensor(device)])
+ async_add_entities([YeelightNightlightModeSensor(device)])
-class YeelightNightlightModeSensor(BinarySensorEntity):
+class YeelightNightlightModeSensor(YeelightEntity, BinarySensorEntity):
"""Representation of a Yeelight nightlight mode sensor."""
- def __init__(self, device):
- """Initialize nightlight mode sensor."""
- self._device = device
-
async def async_added_to_hass(self):
"""Handle entity which will be added."""
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- DATA_UPDATED.format(self._device.ipaddr),
+ DATA_UPDATED.format(self._device.host),
self.async_write_ha_state,
)
)
@@ -49,16 +45,6 @@ class YeelightNightlightModeSensor(BinarySensorEntity):
return None
- @property
- def available(self) -> bool:
- """Return if bulb is available."""
- return self._device.available
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
@property
def name(self):
"""Return the name of the sensor."""
diff --git a/homeassistant/components/yeelight/config_flow.py b/homeassistant/components/yeelight/config_flow.py
new file mode 100644
index 00000000000..84f1bbdd975
--- /dev/null
+++ b/homeassistant/components/yeelight/config_flow.py
@@ -0,0 +1,194 @@
+"""Config flow for Yeelight integration."""
+import logging
+
+import voluptuous as vol
+import yeelight
+
+from homeassistant import config_entries, exceptions
+from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+
+from . import (
+ CONF_DEVICE,
+ CONF_MODE_MUSIC,
+ CONF_MODEL,
+ CONF_NIGHTLIGHT_SWITCH,
+ CONF_NIGHTLIGHT_SWITCH_TYPE,
+ CONF_SAVE_ON_CHANGE,
+ CONF_TRANSITION,
+ NIGHTLIGHT_SWITCH_TYPE_LIGHT,
+)
+from . import DOMAIN # pylint:disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+ """Handle a config flow for Yeelight."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(config_entry):
+ """Return the options flow."""
+ return OptionsFlowHandler(config_entry)
+
+ def __init__(self):
+ """Initialize the config flow."""
+ self._capabilities = None
+ self._discovered_devices = {}
+
+ async def async_step_user(self, user_input=None):
+ """Handle the initial step."""
+ errors = {}
+ if user_input is not None:
+ if user_input.get(CONF_HOST):
+ try:
+ await self._async_try_connect(user_input[CONF_HOST])
+ return self.async_create_entry(
+ title=self._async_default_name(),
+ data=user_input,
+ )
+ except CannotConnect:
+ errors["base"] = "cannot_connect"
+ except AlreadyConfigured:
+ return self.async_abort(reason="already_configured")
+ else:
+ return await self.async_step_pick_device()
+
+ return self.async_show_form(
+ step_id="user",
+ data_schema=vol.Schema({vol.Optional(CONF_HOST): str}),
+ errors=errors,
+ )
+
+ async def async_step_pick_device(self, user_input=None):
+ """Handle the step to pick discovered device."""
+ if user_input is not None:
+ unique_id = user_input[CONF_DEVICE]
+ self._capabilities = self._discovered_devices[unique_id]
+ return self.async_create_entry(
+ title=self._async_default_name(),
+ data={CONF_ID: unique_id},
+ )
+
+ configured_devices = {
+ entry.data[CONF_ID]
+ for entry in self._async_current_entries()
+ if entry.data[CONF_ID]
+ }
+ devices_name = {}
+ # Run 3 times as packets can get lost
+ for _ in range(3):
+ devices = await self.hass.async_add_executor_job(yeelight.discover_bulbs)
+ for device in devices:
+ capabilities = device["capabilities"]
+ unique_id = capabilities["id"]
+ if unique_id in configured_devices:
+ continue # ignore configured devices
+ model = capabilities["model"]
+ host = device["ip"]
+ name = f"{host} {model} {unique_id}"
+ self._discovered_devices[unique_id] = capabilities
+ devices_name[unique_id] = name
+
+ # Check if there is at least one device
+ if not devices_name:
+ return self.async_abort(reason="no_devices_found")
+ return self.async_show_form(
+ step_id="pick_device",
+ data_schema=vol.Schema({vol.Required(CONF_DEVICE): vol.In(devices_name)}),
+ )
+
+ async def async_step_import(self, user_input=None):
+ """Handle import step."""
+ host = user_input[CONF_HOST]
+ try:
+ await self._async_try_connect(host)
+ except CannotConnect:
+ _LOGGER.error("Failed to import %s: cannot connect", host)
+ return self.async_abort(reason="cannot_connect")
+ except AlreadyConfigured:
+ return self.async_abort(reason="already_configured")
+ if CONF_NIGHTLIGHT_SWITCH_TYPE in user_input:
+ user_input[CONF_NIGHTLIGHT_SWITCH] = (
+ user_input.pop(CONF_NIGHTLIGHT_SWITCH_TYPE)
+ == NIGHTLIGHT_SWITCH_TYPE_LIGHT
+ )
+ return self.async_create_entry(title=user_input[CONF_NAME], data=user_input)
+
+ async def _async_try_connect(self, host):
+ """Set up with options."""
+ bulb = yeelight.Bulb(host)
+ try:
+ capabilities = await self.hass.async_add_executor_job(bulb.get_capabilities)
+ if capabilities is None: # timeout
+ _LOGGER.error("Failed to get capabilities from %s: timeout", host)
+ raise CannotConnect
+ except OSError as err:
+ _LOGGER.error("Failed to get capabilities from %s: %s", host, err)
+ raise CannotConnect from err
+ _LOGGER.debug("Get capabilities: %s", capabilities)
+ self._capabilities = capabilities
+ await self.async_set_unique_id(capabilities["id"])
+ self._abort_if_unique_id_configured()
+
+ @callback
+ def _async_default_name(self):
+ model = self._capabilities["model"]
+ unique_id = self._capabilities["id"]
+ return f"yeelight_{model}_{unique_id}"
+
+
+class OptionsFlowHandler(config_entries.OptionsFlow):
+ """Handle a option flow for Yeelight."""
+
+ def __init__(self, config_entry):
+ """Initialize the option flow."""
+ self._config_entry = config_entry
+
+ async def async_step_init(self, user_input=None):
+ """Handle the initial step."""
+ if user_input is not None:
+ # keep the name from imported entries
+ options = {
+ CONF_NAME: self._config_entry.options.get(CONF_NAME),
+ **user_input,
+ }
+ return self.async_create_entry(title="", data=options)
+
+ options = self._config_entry.options
+ return self.async_show_form(
+ step_id="init",
+ data_schema=vol.Schema(
+ {
+ vol.Optional(CONF_MODEL, default=options[CONF_MODEL]): str,
+ vol.Required(
+ CONF_TRANSITION,
+ default=options[CONF_TRANSITION],
+ ): cv.positive_int,
+ vol.Required(
+ CONF_MODE_MUSIC, default=options[CONF_MODE_MUSIC]
+ ): bool,
+ vol.Required(
+ CONF_SAVE_ON_CHANGE,
+ default=options[CONF_SAVE_ON_CHANGE],
+ ): bool,
+ vol.Required(
+ CONF_NIGHTLIGHT_SWITCH,
+ default=options[CONF_NIGHTLIGHT_SWITCH],
+ ): bool,
+ }
+ ),
+ )
+
+
+class CannotConnect(exceptions.HomeAssistantError):
+ """Error to indicate we cannot connect."""
+
+
+class AlreadyConfigured(exceptions.HomeAssistantError):
+ """Indicate the ip address is already configured."""
diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py
index 793af380db9..a84ebebf6e3 100644
--- a/homeassistant/components/yeelight/light.py
+++ b/homeassistant/components/yeelight/light.py
@@ -1,4 +1,5 @@
"""Light platform support for yeelight."""
+from functools import partial
import logging
from typing import Optional
@@ -32,11 +33,12 @@ from homeassistant.components.light import (
SUPPORT_TRANSITION,
LightEntity,
)
-from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, CONF_HOST, CONF_NAME
-from homeassistant.core import callback
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_ENTITY_ID, ATTR_MODE, CONF_NAME
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_connect
-from homeassistant.helpers.service import extract_entity_ids
import homeassistant.util.color as color_util
from homeassistant.util.color import (
color_temperature_kelvin_to_mired as kelvin_to_mired,
@@ -48,24 +50,22 @@ from . import (
ATTR_ACTION,
ATTR_COUNT,
ATTR_TRANSITIONS,
- CONF_CUSTOM_EFFECTS,
CONF_FLOW_PARAMS,
CONF_MODE_MUSIC,
- CONF_NIGHTLIGHT_SWITCH_TYPE,
+ CONF_NIGHTLIGHT_SWITCH,
CONF_SAVE_ON_CHANGE,
CONF_TRANSITION,
+ DATA_CONFIG_ENTRIES,
+ DATA_CUSTOM_EFFECTS,
+ DATA_DEVICE,
DATA_UPDATED,
- DATA_YEELIGHT,
DOMAIN,
- NIGHTLIGHT_SWITCH_TYPE_LIGHT,
YEELIGHT_FLOW_TRANSITION_SCHEMA,
- YEELIGHT_SERVICE_SCHEMA,
+ YeelightEntity,
)
_LOGGER = logging.getLogger(__name__)
-PLATFORM_DATA_KEY = f"{DATA_YEELIGHT}_lights"
-
SUPPORT_YEELIGHT = (
SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_FLASH | SUPPORT_EFFECT
)
@@ -144,59 +144,46 @@ EFFECTS_MAP = {
VALID_BRIGHTNESS = vol.All(vol.Coerce(int), vol.Range(min=1, max=100))
-SERVICE_SCHEMA_SET_MODE = YEELIGHT_SERVICE_SCHEMA.extend(
- {vol.Required(ATTR_MODE): vol.In([mode.name.lower() for mode in PowerMode])}
-)
+SERVICE_SCHEMA_SET_MODE = {
+ vol.Required(ATTR_MODE): vol.In([mode.name.lower() for mode in PowerMode])
+}
-SERVICE_SCHEMA_START_FLOW = YEELIGHT_SERVICE_SCHEMA.extend(
- YEELIGHT_FLOW_TRANSITION_SCHEMA
-)
+SERVICE_SCHEMA_START_FLOW = YEELIGHT_FLOW_TRANSITION_SCHEMA
-SERVICE_SCHEMA_SET_COLOR_SCENE = YEELIGHT_SERVICE_SCHEMA.extend(
- {
- vol.Required(ATTR_RGB_COLOR): vol.All(
- vol.ExactSequence((cv.byte, cv.byte, cv.byte)), vol.Coerce(tuple)
+SERVICE_SCHEMA_SET_COLOR_SCENE = {
+ vol.Required(ATTR_RGB_COLOR): vol.All(
+ vol.ExactSequence((cv.byte, cv.byte, cv.byte)), vol.Coerce(tuple)
+ ),
+ vol.Required(ATTR_BRIGHTNESS): VALID_BRIGHTNESS,
+}
+
+SERVICE_SCHEMA_SET_HSV_SCENE = {
+ vol.Required(ATTR_HS_COLOR): vol.All(
+ vol.ExactSequence(
+ (
+ vol.All(vol.Coerce(float), vol.Range(min=0, max=359)),
+ vol.All(vol.Coerce(float), vol.Range(min=0, max=100)),
+ )
),
- vol.Required(ATTR_BRIGHTNESS): VALID_BRIGHTNESS,
- }
-)
+ vol.Coerce(tuple),
+ ),
+ vol.Required(ATTR_BRIGHTNESS): VALID_BRIGHTNESS,
+}
-SERVICE_SCHEMA_SET_HSV_SCENE = YEELIGHT_SERVICE_SCHEMA.extend(
- {
- vol.Required(ATTR_HS_COLOR): vol.All(
- vol.ExactSequence(
- (
- vol.All(vol.Coerce(float), vol.Range(min=0, max=359)),
- vol.All(vol.Coerce(float), vol.Range(min=0, max=100)),
- )
- ),
- vol.Coerce(tuple),
- ),
- vol.Required(ATTR_BRIGHTNESS): VALID_BRIGHTNESS,
- }
-)
+SERVICE_SCHEMA_SET_COLOR_TEMP_SCENE = {
+ vol.Required(ATTR_KELVIN): vol.All(vol.Coerce(int), vol.Range(min=1700, max=6500)),
+ vol.Required(ATTR_BRIGHTNESS): VALID_BRIGHTNESS,
+}
-SERVICE_SCHEMA_SET_COLOR_TEMP_SCENE = YEELIGHT_SERVICE_SCHEMA.extend(
- {
- vol.Required(ATTR_KELVIN): vol.All(
- vol.Coerce(int), vol.Range(min=1700, max=6500)
- ),
- vol.Required(ATTR_BRIGHTNESS): VALID_BRIGHTNESS,
- }
-)
+SERVICE_SCHEMA_SET_COLOR_FLOW_SCENE = YEELIGHT_FLOW_TRANSITION_SCHEMA
-SERVICE_SCHEMA_SET_COLOR_FLOW_SCENE = YEELIGHT_SERVICE_SCHEMA.extend(
- YEELIGHT_FLOW_TRANSITION_SCHEMA
-)
-
-SERVICE_SCHEMA_SET_AUTO_DELAY_OFF = YEELIGHT_SERVICE_SCHEMA.extend(
- {
- vol.Required(ATTR_MINUTES): vol.All(vol.Coerce(int), vol.Range(min=1, max=60)),
- vol.Required(ATTR_BRIGHTNESS): VALID_BRIGHTNESS,
- }
-)
+SERVICE_SCHEMA_SET_AUTO_DELAY_OFF_SCENE = {
+ vol.Required(ATTR_MINUTES): vol.All(vol.Coerce(int), vol.Range(min=1, max=60)),
+ vol.Required(ATTR_BRIGHTNESS): VALID_BRIGHTNESS,
+}
+@callback
def _transitions_config_parser(transitions):
"""Parse transitions config into initialized objects."""
transition_objects = []
@@ -207,6 +194,7 @@ def _transitions_config_parser(transitions):
return transition_objects
+@callback
def _parse_custom_effects(effects_config):
effects = {}
for config in effects_config:
@@ -236,22 +224,17 @@ def _cmd(func):
return _wrap
-def setup_platform(hass, config, add_entities, discovery_info=None):
- """Set up the Yeelight bulbs."""
+async def async_setup_entry(
+ hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities
+) -> None:
+ """Set up Yeelight from a config entry."""
- if not discovery_info:
- return
+ custom_effects = _parse_custom_effects(hass.data[DOMAIN][DATA_CUSTOM_EFFECTS])
- if PLATFORM_DATA_KEY not in hass.data:
- hass.data[PLATFORM_DATA_KEY] = []
-
- device = hass.data[DATA_YEELIGHT][discovery_info[CONF_HOST]]
+ device = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][config_entry.entry_id][DATA_DEVICE]
_LOGGER.debug("Adding %s", device.name)
- custom_effects = _parse_custom_effects(discovery_info[CONF_CUSTOM_EFFECTS])
- nl_switch_light = (
- discovery_info.get(CONF_NIGHTLIGHT_SWITCH_TYPE) == NIGHTLIGHT_SWITCH_TYPE_LIGHT
- )
+ nl_switch_light = device.config.get(CONF_NIGHTLIGHT_SWITCH)
lights = []
@@ -285,134 +268,125 @@ def setup_platform(hass, config, add_entities, discovery_info=None):
_lights_setup_helper(YeelightGenericLight)
_LOGGER.warning(
"Cannot determine device type for %s, %s. Falling back to white only",
- device.ipaddr,
+ device.host,
device.name,
)
- hass.data[PLATFORM_DATA_KEY] += lights
- add_entities(lights, True)
- setup_services(hass)
+ async_add_entities(lights, True)
+ _async_setup_services(hass)
-def setup_services(hass):
- """Set up the service listeners."""
+@callback
+def _async_setup_services(hass: HomeAssistant):
+ """Set up custom services."""
- def service_call(func):
- def service_to_entities(service):
- """Return the known entities that a service call mentions."""
-
- entity_ids = extract_entity_ids(hass, service)
- target_devices = [
- light
- for light in hass.data[PLATFORM_DATA_KEY]
- if light.entity_id in entity_ids
- ]
-
- return target_devices
-
- def service_to_params(service):
- """Return service call params, without entity_id."""
- return {
- key: value
- for key, value in service.data.items()
- if key != ATTR_ENTITY_ID
- }
-
- def wrapper(service):
- params = service_to_params(service)
- target_devices = service_to_entities(service)
- for device in target_devices:
- func(device, params)
-
- return wrapper
-
- @service_call
- def service_set_mode(target_device, params):
- target_device.set_mode(**params)
-
- @service_call
- def service_start_flow(target_devices, params):
+ async def _async_start_flow(entity, service_call):
+ params = {**service_call.data}
+ params.pop(ATTR_ENTITY_ID)
params[ATTR_TRANSITIONS] = _transitions_config_parser(params[ATTR_TRANSITIONS])
- target_devices.start_flow(**params)
+ await hass.async_add_executor_job(partial(entity.start_flow, **params))
- @service_call
- def service_set_color_scene(target_device, params):
- target_device.set_scene(
- SceneClass.COLOR, *[*params[ATTR_RGB_COLOR], params[ATTR_BRIGHTNESS]]
+ async def _async_set_color_scene(entity, service_call):
+ await hass.async_add_executor_job(
+ partial(
+ entity.set_scene,
+ SceneClass.COLOR,
+ *service_call.data[ATTR_RGB_COLOR],
+ service_call.data[ATTR_BRIGHTNESS],
+ )
)
- @service_call
- def service_set_hsv_scene(target_device, params):
- target_device.set_scene(
- SceneClass.HSV, *[*params[ATTR_HS_COLOR], params[ATTR_BRIGHTNESS]]
+ async def _async_set_hsv_scene(entity, service_call):
+ await hass.async_add_executor_job(
+ partial(
+ entity.set_scene,
+ SceneClass.HSV,
+ *service_call.data[ATTR_HS_COLOR],
+ service_call.data[ATTR_BRIGHTNESS],
+ )
)
- @service_call
- def service_set_color_temp_scene(target_device, params):
- target_device.set_scene(
- SceneClass.CT, params[ATTR_KELVIN], params[ATTR_BRIGHTNESS]
+ async def _async_set_color_temp_scene(entity, service_call):
+ await hass.async_add_executor_job(
+ partial(
+ entity.set_scene,
+ SceneClass.CT,
+ service_call.data[ATTR_KELVIN],
+ service_call.data[ATTR_BRIGHTNESS],
+ )
)
- @service_call
- def service_set_color_flow_scene(target_device, params):
+ async def _async_set_color_flow_scene(entity, service_call):
flow = Flow(
- count=params[ATTR_COUNT],
- action=Flow.actions[params[ATTR_ACTION]],
- transitions=_transitions_config_parser(params[ATTR_TRANSITIONS]),
+ count=service_call.data[ATTR_COUNT],
+ action=Flow.actions[service_call.data[ATTR_ACTION]],
+ transitions=_transitions_config_parser(service_call.data[ATTR_TRANSITIONS]),
)
- target_device.set_scene(SceneClass.CF, flow)
-
- @service_call
- def service_set_auto_delay_off_scene(target_device, params):
- target_device.set_scene(
- SceneClass.AUTO_DELAY_OFF, params[ATTR_BRIGHTNESS], params[ATTR_MINUTES]
+ await hass.async_add_executor_job(
+ partial(
+ entity.set_scene,
+ SceneClass.CF,
+ flow,
+ )
)
- hass.services.register(
- DOMAIN, SERVICE_SET_MODE, service_set_mode, schema=SERVICE_SCHEMA_SET_MODE
+ async def _async_set_auto_delay_off_scene(entity, service_call):
+ await hass.async_add_executor_job(
+ partial(
+ entity.set_scene,
+ SceneClass.AUTO_DELAY_OFF,
+ service_call.data[ATTR_BRIGHTNESS],
+ service_call.data[ATTR_MINUTES],
+ )
+ )
+
+ platform = entity_platform.current_platform.get()
+
+ platform.async_register_entity_service(
+ SERVICE_SET_MODE,
+ SERVICE_SCHEMA_SET_MODE,
+ "set_mode",
)
- hass.services.register(
- DOMAIN, SERVICE_START_FLOW, service_start_flow, schema=SERVICE_SCHEMA_START_FLOW
+ platform.async_register_entity_service(
+ SERVICE_START_FLOW,
+ SERVICE_SCHEMA_START_FLOW,
+ _async_start_flow,
)
- hass.services.register(
- DOMAIN,
+ platform.async_register_entity_service(
SERVICE_SET_COLOR_SCENE,
- service_set_color_scene,
- schema=SERVICE_SCHEMA_SET_COLOR_SCENE,
+ SERVICE_SCHEMA_SET_COLOR_SCENE,
+ _async_set_color_scene,
)
- hass.services.register(
- DOMAIN,
+ platform.async_register_entity_service(
SERVICE_SET_HSV_SCENE,
- service_set_hsv_scene,
- schema=SERVICE_SCHEMA_SET_HSV_SCENE,
+ SERVICE_SCHEMA_SET_HSV_SCENE,
+ _async_set_hsv_scene,
)
- hass.services.register(
- DOMAIN,
+ platform.async_register_entity_service(
SERVICE_SET_COLOR_TEMP_SCENE,
- service_set_color_temp_scene,
- schema=SERVICE_SCHEMA_SET_COLOR_TEMP_SCENE,
+ SERVICE_SCHEMA_SET_COLOR_TEMP_SCENE,
+ _async_set_color_temp_scene,
)
- hass.services.register(
- DOMAIN,
+ platform.async_register_entity_service(
SERVICE_SET_COLOR_FLOW_SCENE,
- service_set_color_flow_scene,
- schema=SERVICE_SCHEMA_SET_COLOR_FLOW_SCENE,
+ SERVICE_SCHEMA_SET_COLOR_FLOW_SCENE,
+ _async_set_color_flow_scene,
)
- hass.services.register(
- DOMAIN,
+ platform.async_register_entity_service(
SERVICE_SET_AUTO_DELAY_OFF_SCENE,
- service_set_auto_delay_off_scene,
- schema=SERVICE_SCHEMA_SET_AUTO_DELAY_OFF,
+ SERVICE_SCHEMA_SET_AUTO_DELAY_OFF_SCENE,
+ _async_set_auto_delay_off_scene,
)
-class YeelightGenericLight(LightEntity):
+class YeelightGenericLight(YeelightEntity, LightEntity):
"""Representation of a Yeelight generic light."""
def __init__(self, device, custom_effects=None):
"""Initialize the Yeelight light."""
+ super().__init__(device)
+
self.config = device.config
- self._device = device
self._brightness = None
self._color_temp = None
@@ -439,27 +413,17 @@ class YeelightGenericLight(LightEntity):
self.async_on_remove(
async_dispatcher_connect(
self.hass,
- DATA_UPDATED.format(self._device.ipaddr),
+ DATA_UPDATED.format(self._device.host),
self._schedule_immediate_update,
)
)
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
@property
def unique_id(self) -> Optional[str]:
"""Return a unique ID."""
return self.device.unique_id
- @property
- def available(self) -> bool:
- """Return if bulb is available."""
- return self.device.available
-
@property
def supported_features(self) -> int:
"""Flag supported features."""
diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json
index 32ccf1c117e..1e8c0472fdd 100644
--- a/homeassistant/components/yeelight/manifest.json
+++ b/homeassistant/components/yeelight/manifest.json
@@ -2,7 +2,13 @@
"domain": "yeelight",
"name": "Yeelight",
"documentation": "https://www.home-assistant.io/integrations/yeelight",
- "requirements": ["yeelight==0.5.2"],
- "after_dependencies": ["discovery"],
- "codeowners": ["@rytilahti", "@zewelor"]
-}
+ "requirements": [
+ "yeelight==0.5.3"
+ ],
+ "codeowners": [
+ "@rytilahti",
+ "@zewelor",
+ "@shenxn"
+ ],
+ "config_flow": true
+}
\ No newline at end of file
diff --git a/homeassistant/components/yeelight/strings.json b/homeassistant/components/yeelight/strings.json
new file mode 100644
index 00000000000..7fd3062ef87
--- /dev/null
+++ b/homeassistant/components/yeelight/strings.json
@@ -0,0 +1,39 @@
+{
+ "title": "Yeelight",
+ "config": {
+ "step": {
+ "user": {
+ "description": "If you leave the host empty, discovery will be used to find devices.",
+ "data": {
+ "host": "[%key:common::config_flow::data::host%]"
+ }
+ },
+ "pick_device": {
+ "data": {
+ "device": "Device"
+ }
+ }
+ },
+ "error": {
+ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
+ },
+ "abort": {
+ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
+ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "description": "If you leave model empty, it will be automatically detected.",
+ "data": {
+ "model": "Model (Optional)",
+ "transition": "Transition Time (ms)",
+ "use_music_mode": "Enable Music Mode",
+ "save_on_change": "Save Status On Change",
+ "nightlight_switch": "Use Nightlight Switch"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/yeelight/translations/ca.json b/homeassistant/components/yeelight/translations/ca.json
new file mode 100644
index 00000000000..28c2e796d3c
--- /dev/null
+++ b/homeassistant/components/yeelight/translations/ca.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositiu ja est\u00e0 configurat",
+ "no_devices_found": "No s'han trobat dispositius a la xarxa"
+ },
+ "error": {
+ "cannot_connect": "Ha fallat la connexi\u00f3"
+ },
+ "step": {
+ "pick_device": {
+ "data": {
+ "device": "Dispositiu"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "Amfitri\u00f3",
+ "ip_address": "Adre\u00e7a IP"
+ },
+ "description": "Si deixes l'amfitri\u00f3 buit, s'utilitzar\u00e0 el descobriment per cercar dispositius."
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "model": "Model (opcional)",
+ "nightlight_switch": "Utilitza l'interruptor NightLight",
+ "save_on_change": "Desa l'estat en canviar",
+ "transition": "Temps de transici\u00f3 (ms)",
+ "use_music_mode": "Activa el mode M\u00fasica"
+ },
+ "description": "Si deixes el model buit, es detectar\u00e0 autom\u00e0ticament."
+ }
+ }
+ },
+ "title": "Yeelight"
+}
\ No newline at end of file
diff --git a/homeassistant/components/yeelight/translations/en.json b/homeassistant/components/yeelight/translations/en.json
new file mode 100644
index 00000000000..abb51f13667
--- /dev/null
+++ b/homeassistant/components/yeelight/translations/en.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Device is already configured",
+ "no_devices_found": "No devices found on the network"
+ },
+ "error": {
+ "cannot_connect": "Failed to connect"
+ },
+ "step": {
+ "pick_device": {
+ "data": {
+ "device": "Device"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "Host",
+ "ip_address": "IP Address"
+ },
+ "description": "If you leave the host empty, discovery will be used to find devices."
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "model": "Model (Optional)",
+ "nightlight_switch": "Use Nightlight Switch",
+ "save_on_change": "Save Status On Change",
+ "transition": "Transition Time (ms)",
+ "use_music_mode": "Enable Music Mode"
+ },
+ "description": "If you leave model empty, it will be automatically detected."
+ }
+ }
+ },
+ "title": "Yeelight"
+}
\ No newline at end of file
diff --git a/homeassistant/components/yeelight/translations/es.json b/homeassistant/components/yeelight/translations/es.json
new file mode 100644
index 00000000000..08c2ca92dea
--- /dev/null
+++ b/homeassistant/components/yeelight/translations/es.json
@@ -0,0 +1,39 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositivo ya est\u00e1 configurado",
+ "no_devices_found": "No se encontraron dispositivos en la red"
+ },
+ "error": {
+ "cannot_connect": "No se pudo conectar"
+ },
+ "step": {
+ "pick_device": {
+ "data": {
+ "device": "Dispositivo"
+ }
+ },
+ "user": {
+ "data": {
+ "ip_address": "Direcci\u00f3n IP"
+ },
+ "description": "Si dejas la direcci\u00f3n IP vac\u00eda, se usar\u00e1 descubrimiento para encontrar dispositivos."
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "model": "Modelo (opcional)",
+ "nightlight_switch": "Usar interruptor de luz nocturna",
+ "save_on_change": "Guardar estado al cambiar",
+ "transition": "Tiempo de transici\u00f3n (ms)",
+ "use_music_mode": "Activar el Modo M\u00fasica"
+ },
+ "description": "Si dejas el modelo vac\u00edo, se detectar\u00e1 autom\u00e1ticamente."
+ }
+ }
+ },
+ "title": "Yeelight"
+}
\ No newline at end of file
diff --git a/homeassistant/components/yeelight/translations/fr.json b/homeassistant/components/yeelight/translations/fr.json
new file mode 100644
index 00000000000..37770c423ca
--- /dev/null
+++ b/homeassistant/components/yeelight/translations/fr.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9",
+ "no_devices_found": "Aucun appareil trouv\u00e9 sur le r\u00e9seau"
+ },
+ "error": {
+ "cannot_connect": "\u00c9chec de connexion"
+ },
+ "step": {
+ "pick_device": {
+ "data": {
+ "device": "Appareil"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "H\u00f4te",
+ "ip_address": "Adresse IP"
+ },
+ "description": "Si vous laissez l'adresse IP vide, la d\u00e9couverte sera utilis\u00e9e pour trouver des appareils."
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "model": "Mod\u00e8le (facultatif)",
+ "nightlight_switch": "Utiliser l'interrupteur de la veilleuse",
+ "save_on_change": "Sauvegarder le statut lors d'un changement",
+ "transition": "Temps de transition (ms)",
+ "use_music_mode": "Activer le mode musique"
+ },
+ "description": "Si vous ne pr\u00e9cisez pas le mod\u00e8le, il sera automatiquement d\u00e9tect\u00e9."
+ }
+ }
+ },
+ "title": "Yeelight"
+}
\ No newline at end of file
diff --git a/homeassistant/components/yeelight/translations/it.json b/homeassistant/components/yeelight/translations/it.json
new file mode 100644
index 00000000000..e3d965661b5
--- /dev/null
+++ b/homeassistant/components/yeelight/translations/it.json
@@ -0,0 +1,39 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato",
+ "no_devices_found": "Nessun dispositivo trovato sulla rete"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi"
+ },
+ "step": {
+ "pick_device": {
+ "data": {
+ "device": "Dispositivo"
+ }
+ },
+ "user": {
+ "data": {
+ "ip_address": "Indirizzo IP"
+ },
+ "description": "Se lasci vuoto l'indirizzo IP, verr\u00e0 utilizzato il rilevamento per trovare i dispositivi."
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "model": "Modello (opzionale)",
+ "nightlight_switch": "Usa l'interruttore luce notturna",
+ "save_on_change": "Salva stato su modifica",
+ "transition": "Tempo di transizione (ms)",
+ "use_music_mode": "Abilita la modalit\u00e0 musica"
+ },
+ "description": "Se lasci il modello vuoto, verr\u00e0 rilevato automaticamente."
+ }
+ }
+ },
+ "title": "Yeelight"
+}
\ No newline at end of file
diff --git a/homeassistant/components/yeelight/translations/no.json b/homeassistant/components/yeelight/translations/no.json
new file mode 100644
index 00000000000..365bdb7ba0f
--- /dev/null
+++ b/homeassistant/components/yeelight/translations/no.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Enheten er allerede konfigurert",
+ "no_devices_found": "Ingen enheter funnet p\u00e5 nettverket"
+ },
+ "error": {
+ "cannot_connect": "Tilkobling mislyktes."
+ },
+ "step": {
+ "pick_device": {
+ "data": {
+ "device": "Enhet"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "Vert",
+ "ip_address": "IP adresse"
+ },
+ "description": "Hvis du lar verten st\u00e5 tom, brukes oppdagelsen til \u00e5 finne enheter."
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "model": "Modell (valgfritt)",
+ "nightlight_switch": "Bruk nattlysbryter",
+ "save_on_change": "Lagre status ved endring",
+ "transition": "Overgangstid (ms)",
+ "use_music_mode": "Aktiver musikkmodus"
+ },
+ "description": "Hvis du lar modellen v\u00e6re tom, blir den automatisk oppdaget."
+ }
+ }
+ },
+ "title": "Yeelight"
+}
\ No newline at end of file
diff --git a/homeassistant/components/yeelight/translations/pl.json b/homeassistant/components/yeelight/translations/pl.json
new file mode 100644
index 00000000000..4a2636457aa
--- /dev/null
+++ b/homeassistant/components/yeelight/translations/pl.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane.",
+ "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 w sieci."
+ },
+ "error": {
+ "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia."
+ },
+ "step": {
+ "pick_device": {
+ "data": {
+ "device": "Urz\u0105dzenie"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "Nazwa hosta lub adres IP",
+ "ip_address": "Adres IP"
+ },
+ "description": "Je\u015bli nie podasz IP lub nazwy hosta, wykrywanie zostanie u\u017cyte do odnalezienia urz\u0105dze\u0144."
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "model": "Model (opcjonalne)",
+ "nightlight_switch": "U\u017cyj prze\u0142\u0105cznika Nocnego \u015bwiat\u0142a",
+ "save_on_change": "Zachowaj status po zmianie",
+ "transition": "Czas przej\u015bcia (ms)",
+ "use_music_mode": "W\u0142\u0105cz tryb muzyczny"
+ },
+ "description": "Je\u015bli nie podasz modelu urz\u0105dzenia, zostanie on automatycznie wykryty."
+ }
+ }
+ },
+ "title": "Yeelight"
+}
\ No newline at end of file
diff --git a/homeassistant/components/yeelight/translations/pt.json b/homeassistant/components/yeelight/translations/pt.json
new file mode 100644
index 00000000000..7da4aff16ec
--- /dev/null
+++ b/homeassistant/components/yeelight/translations/pt.json
@@ -0,0 +1,39 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado",
+ "no_devices_found": "Nenhum dispositivo encontrado na rede"
+ },
+ "error": {
+ "cannot_connect": "Falha na liga\u00e7\u00e3o"
+ },
+ "step": {
+ "pick_device": {
+ "data": {
+ "device": "Dispositivo"
+ }
+ },
+ "user": {
+ "data": {
+ "ip_address": "Endere\u00e7o IP"
+ },
+ "description": "Se voc\u00ea deixar o modelo vazio, ele ser\u00e1 detectado automaticamente."
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "model": "Modelo (Opcional)",
+ "nightlight_switch": "Use o bot\u00e3o Nightlight",
+ "save_on_change": "Salvar status ao alterar",
+ "transition": "Tempo de transi\u00e7\u00e3o (ms)",
+ "use_music_mode": "Ativar modo de m\u00fasica"
+ },
+ "description": "Se voc\u00ea deixar o modelo vazio, ele ser\u00e1 detectado automaticamente."
+ }
+ }
+ },
+ "title": "Yeelight"
+}
\ No newline at end of file
diff --git a/homeassistant/components/yeelight/translations/ru.json b/homeassistant/components/yeelight/translations/ru.json
new file mode 100644
index 00000000000..c078eae7d93
--- /dev/null
+++ b/homeassistant/components/yeelight/translations/ru.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u043e \u0432 Home Assistant.",
+ "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438."
+ },
+ "error": {
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f."
+ },
+ "step": {
+ "pick_device": {
+ "data": {
+ "device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "\u0425\u043e\u0441\u0442",
+ "ip_address": "IP-\u0430\u0434\u0440\u0435\u0441"
+ },
+ "description": "\u0415\u0441\u043b\u0438 \u043d\u0435 \u0443\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u0430\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430, \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0431\u0443\u0434\u0443\u0442 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u044b \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438."
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "model": "\u041c\u043e\u0434\u0435\u043b\u044c (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)",
+ "nightlight_switch": "\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0442\u0435\u043b\u044c \u0434\u043b\u044f \u043d\u043e\u0447\u043d\u0438\u043a\u0430",
+ "save_on_change": "\u0421\u043e\u0445\u0440\u0430\u043d\u044f\u0442\u044c \u0441\u0442\u0430\u0442\u0443\u0441 \u043f\u0440\u0438 \u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0438",
+ "transition": "\u0412\u0440\u0435\u043c\u044f \u043f\u0435\u0440\u0435\u0445\u043e\u0434\u0430 (\u0432 \u043c\u0438\u043b\u043b\u0438\u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)",
+ "use_music_mode": "\u041c\u0443\u0437\u044b\u043a\u0430\u043b\u044c\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c"
+ },
+ "description": "\u0415\u0441\u043b\u0438 \u043c\u043e\u0434\u0435\u043b\u044c \u043d\u0435 \u0432\u044b\u0431\u0440\u0430\u043d\u0430, \u043e\u043d\u0430 \u0431\u0443\u0434\u0435\u0442 \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u043d\u0430 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438."
+ }
+ }
+ },
+ "title": "Yeelight"
+}
\ No newline at end of file
diff --git a/homeassistant/components/yeelight/translations/zh-Hant.json b/homeassistant/components/yeelight/translations/zh-Hant.json
new file mode 100644
index 00000000000..9c41f3c3be6
--- /dev/null
+++ b/homeassistant/components/yeelight/translations/zh-Hant.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u8a2d\u5099\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "no_devices_found": "\u7db2\u8def\u4e0a\u627e\u4e0d\u5230\u8a2d\u5099"
+ },
+ "error": {
+ "cannot_connect": "\u9023\u7dda\u5931\u6557"
+ },
+ "step": {
+ "pick_device": {
+ "data": {
+ "device": "\u8a2d\u5099"
+ }
+ },
+ "user": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u7aef",
+ "ip_address": "IP \u4f4d\u5740"
+ },
+ "description": "\u5047\u5982\u4e3b\u6a5f\u7aef\u4f4d\u5740\u6b04\u4f4d\u70ba\u7a7a\u767d\uff0c\u5c07\u6703\u63a2\u7d22\u6240\u6709\u53ef\u7528\u8a2d\u5099\u3002"
+ }
+ }
+ },
+ "options": {
+ "step": {
+ "init": {
+ "data": {
+ "model": "\u578b\u865f\uff08\u9078\u9805\uff09",
+ "nightlight_switch": "\u4f7f\u7528\u591c\u71c8\u958b\u95dc",
+ "save_on_change": "\u65bc\u8b8a\u66f4\u6642\u5132\u5b58\u72c0\u614b",
+ "transition": "\u8f49\u63db\u6642\u9593\uff08\u6beb\u79d2\uff09",
+ "use_music_mode": "\u958b\u555f\u97f3\u6a02\u6a21\u5f0f"
+ },
+ "description": "\u5047\u5982\u578b\u865f\u6b04\u4f4d\u70ba\u7a7a\u767d\uff0c\u5c07\u6703\u81ea\u52d5\u5075\u6e2c\u578b\u865f\u3002"
+ }
+ }
+ },
+ "title": "Yeelight"
+}
\ No newline at end of file
diff --git a/homeassistant/components/yi/camera.py b/homeassistant/components/yi/camera.py
index 4273b5294ed..e669f530197 100644
--- a/homeassistant/components/yi/camera.py
+++ b/homeassistant/components/yi/camera.py
@@ -90,7 +90,7 @@ class YiCamera(Camera):
await ftp.connect(self.host)
await ftp.login(self.user, self.passwd)
except (ConnectionRefusedError, StatusCodeError) as err:
- raise PlatformNotReady(err)
+ raise PlatformNotReady(err) from err
try:
await ftp.change_directory(self.path)
diff --git a/homeassistant/components/yr/__init__.py b/homeassistant/components/yr/__init__.py
deleted file mode 100644
index 8d33bd56d43..00000000000
--- a/homeassistant/components/yr/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""The yr component."""
diff --git a/homeassistant/components/yr/manifest.json b/homeassistant/components/yr/manifest.json
deleted file mode 100644
index f21248c9632..00000000000
--- a/homeassistant/components/yr/manifest.json
+++ /dev/null
@@ -1,7 +0,0 @@
-{
- "domain": "yr",
- "name": "Yr",
- "documentation": "https://www.home-assistant.io/integrations/yr",
- "requirements": ["xmltodict==0.12.0"],
- "codeowners": ["@danielhiversen"]
-}
diff --git a/homeassistant/components/yr/sensor.py b/homeassistant/components/yr/sensor.py
deleted file mode 100644
index 8d7a91f24da..00000000000
--- a/homeassistant/components/yr/sensor.py
+++ /dev/null
@@ -1,281 +0,0 @@
-"""Support for Yr.no weather service."""
-import asyncio
-import logging
-from random import randrange
-from xml.parsers.expat import ExpatError
-
-import aiohttp
-import async_timeout
-import voluptuous as vol
-import xmltodict
-
-from homeassistant.components.sensor import PLATFORM_SCHEMA
-from homeassistant.const import (
- ATTR_ATTRIBUTION,
- CONF_ELEVATION,
- CONF_LATITUDE,
- CONF_LONGITUDE,
- CONF_MONITORED_CONDITIONS,
- CONF_NAME,
- DEGREE,
- DEVICE_CLASS_HUMIDITY,
- DEVICE_CLASS_PRESSURE,
- DEVICE_CLASS_TEMPERATURE,
- HTTP_BAD_REQUEST,
- PRESSURE_HPA,
- SPEED_METERS_PER_SECOND,
- TEMP_CELSIUS,
- UNIT_PERCENTAGE,
-)
-from homeassistant.helpers.aiohttp_client import async_get_clientsession
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.event import async_call_later, async_track_utc_time_change
-from homeassistant.util import dt as dt_util
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTRIBUTION = (
- "Weather forecast from met.no, delivered by the Norwegian "
- "Meteorological Institute."
-)
-# https://api.met.no/license_data.html
-
-SENSOR_TYPES = {
- "symbol": ["Symbol", None, None],
- "precipitation": ["Precipitation", "mm", None],
- "temperature": ["Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE],
- "windSpeed": ["Wind speed", SPEED_METERS_PER_SECOND, None],
- "windGust": ["Wind gust", SPEED_METERS_PER_SECOND, None],
- "pressure": ["Pressure", PRESSURE_HPA, DEVICE_CLASS_PRESSURE],
- "windDirection": ["Wind direction", DEGREE, None],
- "humidity": ["Humidity", UNIT_PERCENTAGE, DEVICE_CLASS_HUMIDITY],
- "fog": ["Fog", UNIT_PERCENTAGE, None],
- "cloudiness": ["Cloudiness", UNIT_PERCENTAGE, None],
- "lowClouds": ["Low clouds", UNIT_PERCENTAGE, None],
- "mediumClouds": ["Medium clouds", UNIT_PERCENTAGE, None],
- "highClouds": ["High clouds", UNIT_PERCENTAGE, None],
- "dewpointTemperature": [
- "Dewpoint temperature",
- TEMP_CELSIUS,
- DEVICE_CLASS_TEMPERATURE,
- ],
-}
-
-CONF_FORECAST = "forecast"
-
-DEFAULT_FORECAST = 0
-DEFAULT_NAME = "yr"
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
- {
- vol.Optional(CONF_ELEVATION): vol.Coerce(int),
- vol.Optional(CONF_FORECAST, default=DEFAULT_FORECAST): vol.Coerce(int),
- vol.Optional(CONF_LATITUDE): cv.latitude,
- vol.Optional(CONF_LONGITUDE): cv.longitude,
- vol.Optional(CONF_MONITORED_CONDITIONS, default=["symbol"]): vol.All(
- cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)]
- ),
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- }
-)
-
-
-async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
- """Set up the Yr.no sensor."""
- elevation = config.get(CONF_ELEVATION, hass.config.elevation or 0)
- forecast = config.get(CONF_FORECAST)
- latitude = config.get(CONF_LATITUDE, hass.config.latitude)
- longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
- name = config.get(CONF_NAME)
-
- if None in (latitude, longitude):
- _LOGGER.error("Latitude or longitude not set in Home Assistant config")
- return False
-
- coordinates = {"lat": str(latitude), "lon": str(longitude), "msl": str(elevation)}
-
- dev = []
- for sensor_type in config[CONF_MONITORED_CONDITIONS]:
- dev.append(YrSensor(name, sensor_type))
-
- weather = YrData(hass, coordinates, forecast, dev)
- async_track_utc_time_change(
- hass, weather.updating_devices, minute=randrange(60), second=0
- )
- await weather.fetching_data()
- async_add_entities(dev)
-
-
-class YrSensor(Entity):
- """Representation of an Yr.no sensor."""
-
- def __init__(self, name, sensor_type):
- """Initialize the sensor."""
- self.client_name = name
- self._name = SENSOR_TYPES[sensor_type][0]
- self.type = sensor_type
- self._state = None
- self._unit_of_measurement = SENSOR_TYPES[self.type][1]
- self._device_class = SENSOR_TYPES[self.type][2]
-
- @property
- def name(self):
- """Return the name of the sensor."""
- return f"{self.client_name} {self._name}"
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._state
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def entity_picture(self):
- """Weather symbol if type is symbol."""
- if self.type != "symbol":
- return None
- return (
- "https://api.met.no/weatherapi/weathericon/1.1/"
- f"?symbol={self._state};content_type=image/png"
- )
-
- @property
- def device_state_attributes(self):
- """Return the state attributes."""
- return {ATTR_ATTRIBUTION: ATTRIBUTION}
-
- @property
- def unit_of_measurement(self):
- """Return the unit of measurement of this entity, if any."""
- return self._unit_of_measurement
-
- @property
- def device_class(self):
- """Return the device class of this entity, if any."""
- return self._device_class
-
-
-class YrData:
- """Get the latest data and updates the states."""
-
- def __init__(self, hass, coordinates, forecast, devices):
- """Initialize the data object."""
- self._url = (
- "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/1.9/"
- )
- self._urlparams = coordinates
- self._forecast = forecast
- self.devices = devices
- self.data = {}
- self.hass = hass
-
- async def fetching_data(self, *_):
- """Get the latest data from yr.no."""
-
- def try_again(err: str):
- """Retry in 15 to 20 minutes."""
- minutes = 15 + randrange(6)
- _LOGGER.error("Retrying in %i minutes: %s", minutes, err)
- async_call_later(self.hass, minutes * 60, self.fetching_data)
-
- try:
- websession = async_get_clientsession(self.hass)
- with async_timeout.timeout(10):
- resp = await websession.get(self._url, params=self._urlparams)
- if resp.status >= HTTP_BAD_REQUEST:
- try_again(f"{resp.url} returned {resp.status}")
- return
- text = await resp.text()
-
- except (asyncio.TimeoutError, aiohttp.ClientError) as err:
- try_again(err)
- return
-
- try:
- self.data = xmltodict.parse(text)["weatherdata"]
- except (ExpatError, IndexError) as err:
- try_again(err)
- return
-
- await self.updating_devices()
- async_call_later(self.hass, 60 * 60, self.fetching_data)
-
- async def updating_devices(self, *_):
- """Find the current data from self.data."""
- if not self.data:
- return
-
- now = dt_util.utcnow()
- forecast_time = now + dt_util.dt.timedelta(hours=self._forecast)
-
- # Find the correct time entry. Since not all time entries contain all
- # types of data, we cannot just select one. Instead, we order them by
- # distance from the desired forecast_time, and for every device iterate
- # them in order of increasing distance, taking the first time_point
- # that contains the desired data.
-
- ordered_entries = []
-
- for time_entry in self.data["product"]["time"]:
- valid_from = dt_util.parse_datetime(time_entry["@from"])
- valid_to = dt_util.parse_datetime(time_entry["@to"])
-
- if now >= valid_to:
- # Has already passed. Never select this.
- continue
-
- average_dist = abs((valid_to - forecast_time).total_seconds()) + abs(
- (valid_from - forecast_time).total_seconds()
- )
-
- ordered_entries.append((average_dist, time_entry))
-
- ordered_entries.sort(key=lambda item: item[0])
-
- # Update all devices
- if ordered_entries:
- for dev in self.devices:
- new_state = None
-
- for (_, selected_time_entry) in ordered_entries:
- loc_data = selected_time_entry["location"]
-
- if dev.type not in loc_data:
- continue
-
- if dev.type == "precipitation":
- new_state = loc_data[dev.type]["@value"]
- elif dev.type == "symbol":
- new_state = loc_data[dev.type]["@number"]
- elif dev.type in (
- "temperature",
- "pressure",
- "humidity",
- "dewpointTemperature",
- ):
- new_state = loc_data[dev.type]["@value"]
- elif dev.type in ("windSpeed", "windGust"):
- new_state = loc_data[dev.type]["@mps"]
- elif dev.type == "windDirection":
- new_state = float(loc_data[dev.type]["@deg"])
- elif dev.type in (
- "fog",
- "cloudiness",
- "lowClouds",
- "mediumClouds",
- "highClouds",
- ):
- new_state = loc_data[dev.type]["@percent"]
-
- break
-
- # pylint: disable=protected-access
- if new_state != dev._state:
- dev._state = new_state
- if dev.hass:
- dev.async_write_ha_state()
diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py
index e893c854804..0aa5dea0687 100644
--- a/homeassistant/components/zamg/sensor.py
+++ b/homeassistant/components/zamg/sensor.py
@@ -19,9 +19,9 @@ from homeassistant.const import (
CONF_NAME,
DEGREE,
LENGTH_METERS,
+ PERCENTAGE,
SPEED_KILOMETERS_PER_HOUR,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
__version__,
)
import homeassistant.helpers.config_validation as cv
@@ -43,7 +43,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10)
SENSOR_TYPES = {
"pressure": ("Pressure", "hPa", "LDstat hPa", float),
"pressure_sealevel": ("Pressure at Sea Level", "hPa", "LDred hPa", float),
- "humidity": ("Humidity", UNIT_PERCENTAGE, "RF %", int),
+ "humidity": ("Humidity", PERCENTAGE, "RF %", int),
"wind_speed": (
"Wind Speed",
SPEED_KILOMETERS_PER_HOUR,
@@ -58,7 +58,7 @@ SENSOR_TYPES = {
float,
),
"wind_max_bearing": ("Top Wind Bearing", DEGREE, f"WSR {DEGREE}", int),
- "sun_last_hour": ("Sun Last Hour", UNIT_PERCENTAGE, f"SO {UNIT_PERCENTAGE}", int),
+ "sun_last_hour": ("Sun Last Hour", PERCENTAGE, f"SO {PERCENTAGE}", int),
"temperature": ("Temperature", TEMP_CELSIUS, f"T {TEMP_CELSIUS}", float),
"precipitation": ("Precipitation", "l/m²", "N l/m²", float),
"dewpoint": ("Dew Point", TEMP_CELSIUS, f"TP {TEMP_CELSIUS}", float),
diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py
index f4f2494666b..51da3638a9e 100644
--- a/homeassistant/components/zeroconf/__init__.py
+++ b/homeassistant/components/zeroconf/__init__.py
@@ -1,5 +1,6 @@
"""Support for exposing Home Assistant via Zeroconf."""
import asyncio
+import fnmatch
import ipaddress
import logging
import socket
@@ -31,6 +32,8 @@ from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.singleton import singleton
from homeassistant.loader import async_get_homekit, async_get_zeroconf
+from .usage import install_multiple_zeroconf_catcher
+
_LOGGER = logging.getLogger(__name__)
DOMAIN = "zeroconf"
@@ -53,6 +56,13 @@ HOMEKIT_PROPERTIES = "properties"
HOMEKIT_PAIRED_STATUS_FLAG = "sf"
HOMEKIT_MODEL = "md"
+# Property key=value has a max length of 255
+# so we use 230 to leave space for key=
+MAX_PROPERTY_VALUE_LEN = 230
+
+# Dns label max length
+MAX_NAME_LEN = 63
+
CONFIG_SCHEMA = vol.Schema(
{
DOMAIN: vol.Schema(
@@ -136,13 +146,17 @@ def setup(hass, config):
ipv6=zc_config.get(CONF_IPV6, DEFAULT_IPV6),
)
+ install_multiple_zeroconf_catcher(zeroconf)
+
# Get instance UUID
uuid = asyncio.run_coroutine_threadsafe(
hass.helpers.instance_id.async_get(), hass.loop
).result()
+ valid_location_name = _truncate_location_name_to_valid(hass.config.location_name)
+
params = {
- "location_name": hass.config.location_name,
+ "location_name": valid_location_name,
"uuid": uuid,
"version": __version__,
"external_url": "",
@@ -174,9 +188,11 @@ def setup(hass, config):
except OSError:
host_ip_pton = socket.inet_pton(socket.AF_INET6, host_ip)
+ _suppress_invalid_properties(params)
+
info = ServiceInfo(
ZEROCONF_TYPE,
- name=f"{hass.config.location_name}.{ZEROCONF_TYPE}",
+ name=f"{valid_location_name}.{ZEROCONF_TYPE}",
server=f"{uuid}.local.",
addresses=[host_ip_pton],
port=hass.http.server_port,
@@ -253,10 +269,26 @@ def setup(hass, config):
# likely bad homekit data
return
- for domain in zeroconf_types[service_type]:
+ for entry in zeroconf_types[service_type]:
+ if len(entry) > 1:
+ if "macaddress" in entry:
+ if "properties" not in info:
+ continue
+ if "macaddress" not in info["properties"]:
+ continue
+ if not fnmatch.fnmatch(
+ info["properties"]["macaddress"], entry["macaddress"]
+ ):
+ continue
+ if "name" in entry:
+ if "name" not in info:
+ continue
+ if not fnmatch.fnmatch(info["name"], entry["name"]):
+ continue
+
hass.add_job(
hass.config_entries.flow.async_init(
- domain, context={"source": DOMAIN}, data=info
+ entry["domain"], context={"source": DOMAIN}, data=info
)
)
@@ -354,3 +386,33 @@ def info_from_service(service):
}
return info
+
+
+def _suppress_invalid_properties(properties):
+ """Suppress any properties that will cause zeroconf to fail to startup."""
+
+ for prop, prop_value in properties.items():
+ if not isinstance(prop_value, str):
+ continue
+
+ if len(prop_value.encode("utf-8")) > MAX_PROPERTY_VALUE_LEN:
+ _LOGGER.error(
+ "The property '%s' was suppressed because it is longer than the maximum length of %d bytes: %s",
+ prop,
+ MAX_PROPERTY_VALUE_LEN,
+ prop_value,
+ )
+ properties[prop] = ""
+
+
+def _truncate_location_name_to_valid(location_name):
+ """Truncate or return the location name usable for zeroconf."""
+ if len(location_name.encode("utf-8")) < MAX_NAME_LEN:
+ return location_name
+
+ _LOGGER.warning(
+ "The location name was truncated because it is longer than the maximum length of %d bytes: %s",
+ MAX_NAME_LEN,
+ location_name,
+ )
+ return location_name.encode("utf-8")[:MAX_NAME_LEN].decode("utf-8", "ignore")
diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json
index 883188b31cf..7ac2cf9c5f1 100644
--- a/homeassistant/components/zeroconf/manifest.json
+++ b/homeassistant/components/zeroconf/manifest.json
@@ -2,7 +2,7 @@
"domain": "zeroconf",
"name": "Zero-configuration networking (zeroconf)",
"documentation": "https://www.home-assistant.io/integrations/zeroconf",
- "requirements": ["zeroconf==0.28.1"],
+ "requirements": ["zeroconf==0.28.5"],
"dependencies": ["api"],
"codeowners": ["@Kane610"],
"quality_scale": "internal"
diff --git a/homeassistant/components/zeroconf/usage.py b/homeassistant/components/zeroconf/usage.py
new file mode 100644
index 00000000000..1303412249c
--- /dev/null
+++ b/homeassistant/components/zeroconf/usage.py
@@ -0,0 +1,50 @@
+"""Zeroconf usage utility to warn about multiple instances."""
+
+import logging
+
+import zeroconf
+
+from homeassistant.helpers.frame import (
+ MissingIntegrationFrame,
+ get_integration_frame,
+ report_integration,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def install_multiple_zeroconf_catcher(hass_zc) -> None:
+ """Wrap the Zeroconf class to return the shared instance if multiple instances are detected."""
+
+ def new_zeroconf_new(self, *k, **kw):
+ _report(
+ "attempted to create another Zeroconf instance. Please use the shared Zeroconf via await homeassistant.components.zeroconf.async_get_instance(hass)",
+ )
+ return hass_zc
+
+ def new_zeroconf_init(self, *k, **kw):
+ return
+
+ zeroconf.Zeroconf.__new__ = new_zeroconf_new
+ zeroconf.Zeroconf.__init__ = new_zeroconf_init
+
+
+def _report(what: str) -> None:
+ """Report incorrect usage.
+
+ Async friendly.
+ """
+ integration_frame = None
+
+ try:
+ integration_frame = get_integration_frame(exclude_integrations={"zeroconf"})
+ except MissingIntegrationFrame:
+ pass
+
+ if not integration_frame:
+ _LOGGER.warning(
+ "Detected code that %s. Please report this issue.", what, stack_info=True
+ )
+ return
+
+ report_integration(what, integration_frame)
diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py
index 1ba9ada5413..bdfeb7815c5 100644
--- a/homeassistant/components/zha/api.py
+++ b/homeassistant/components/zha/api.py
@@ -316,8 +316,8 @@ def cv_group_member(value: Any) -> GroupMember:
group_member = GroupMember(
ieee=EUI64.convert(value["ieee"]), endpoint_id=value["endpoint_id"]
)
- except KeyError:
- raise vol.Invalid("Not a group member")
+ except KeyError as err:
+ raise vol.Invalid("Not a group member") from err
return group_member
@@ -724,8 +724,8 @@ def is_cluster_binding(value: Any) -> ClusterBinding:
id=value["id"],
endpoint_id=value["endpoint_id"],
)
- except KeyError:
- raise vol.Invalid("Not a cluster binding")
+ except KeyError as err:
+ raise vol.Invalid("Not a cluster binding") from err
return cluster_binding
diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py
index e3a2044454a..473d39c6f7a 100644
--- a/homeassistant/components/zha/config_flow.py
+++ b/homeassistant/components/zha/config_flow.py
@@ -60,7 +60,10 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
if auto_detected_data is not None:
title = f"{port.description}, s/n: {port.serial_number or 'n/a'}"
title += f" - {port.manufacturer}" if port.manufacturer else ""
- return self.async_create_entry(title=title, data=auto_detected_data,)
+ return self.async_create_entry(
+ title=title,
+ data=auto_detected_data,
+ )
# did not detect anything
self._device_path = dev_path
@@ -78,7 +81,8 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
schema = {vol.Required(CONF_RADIO_TYPE): vol.In(sorted(RadioType.list()))}
return self.async_show_form(
- step_id="pick_radio", data_schema=vol.Schema(schema),
+ step_id="pick_radio",
+ data_schema=vol.Schema(schema),
)
async def async_step_port_config(self, user_input=None):
@@ -113,7 +117,9 @@ class ZhaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
schema[param] = value
return self.async_show_form(
- step_id="port_config", data_schema=vol.Schema(schema), errors=errors,
+ step_id="port_config",
+ data_schema=vol.Schema(schema),
+ errors=errors,
)
diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py
index 56345916cd3..17fbb86fc63 100644
--- a/homeassistant/components/zha/core/channels/hvac.py
+++ b/homeassistant/components/zha/core/channels/hvac.py
@@ -332,7 +332,10 @@ class ThermostatChannel(ZigbeeChannel):
if not isinstance(res, list):
# assume default response
self.debug(
- "attr reporting for '%s' on '%s': %s", attrs, self.name, res,
+ "attr reporting for '%s' on '%s': %s",
+ attrs,
+ self.name,
+ res,
)
return
if res[0].status == Status.SUCCESS and len(res) == 1:
diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py
index cb6a698d72f..402c1505415 100644
--- a/homeassistant/components/zha/core/const.py
+++ b/homeassistant/components/zha/core/const.py
@@ -9,6 +9,7 @@ import zigpy_cc.zigbee.application
import zigpy_deconz.zigbee.application
import zigpy_xbee.zigbee.application
import zigpy_zigate.zigbee.application
+import zigpy_znp.zigbee.application
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
from homeassistant.components.climate import DOMAIN as CLIMATE
@@ -169,6 +170,10 @@ POWER_BATTERY_OR_UNKNOWN = "Battery or Unknown"
class RadioType(enum.Enum):
"""Possible options for radio type."""
+ znp = (
+ "ZNP = Texas Instruments Z-Stack ZNP protocol: CC253x, CC26x2, CC13x2",
+ zigpy_znp.zigbee.application.ControllerApplication,
+ )
ezsp = (
"EZSP = Silicon Labs EmberZNet protocol: Elelabs, HUSBZB-1, Telegesis",
bellows.zigbee.application.ControllerApplication,
@@ -178,7 +183,7 @@ class RadioType(enum.Enum):
zigpy_deconz.zigbee.application.ControllerApplication,
)
ti_cc = (
- "TI_CC = Texas Instruments Z-Stack ZNP protocol: CC253x, CC26x2, CC13x2",
+ "Legacy TI_CC = Texas Instruments Z-Stack ZNP protocol: CC253x, CC26x2, CC13x2",
zigpy_cc.zigbee.application.ControllerApplication,
)
zigate = (
@@ -274,7 +279,6 @@ SIGNAL_REMOVE = "remove"
SIGNAL_SET_LEVEL = "set_level"
SIGNAL_STATE_ATTR = "update_state_attribute"
SIGNAL_UPDATE_DEVICE = "{}_zha_update_device"
-SIGNAL_REMOVE_GROUP = "remove_group"
SIGNAL_GROUP_ENTITY_REMOVED = "group_entity_removed"
SIGNAL_GROUP_MEMBERSHIP_CHANGE = "group_membership_change"
diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py
index 53b1dcc163b..b8229793a48 100644
--- a/homeassistant/components/zha/core/device.py
+++ b/homeassistant/components/zha/core/device.py
@@ -30,6 +30,7 @@ from .const import (
ATTR_CLUSTER_ID,
ATTR_COMMAND,
ATTR_COMMAND_TYPE,
+ ATTR_DEVICE_IEEE,
ATTR_DEVICE_TYPE,
ATTR_ENDPOINT_ID,
ATTR_ENDPOINTS,
@@ -247,9 +248,14 @@ class ZHADevice(LogMixin):
@property
def device_automation_triggers(self):
"""Return the device automation triggers for this device."""
+ triggers = {
+ ("device_offline", "device_offline"): {
+ "device_event_type": "device_offline"
+ }
+ }
if hasattr(self._zigpy_device, "device_automation_triggers"):
- return self._zigpy_device.device_automation_triggers
- return None
+ triggers.update(self._zigpy_device.device_automation_triggers)
+ return triggers
@property
def available_signal(self):
@@ -346,6 +352,14 @@ class ZHADevice(LogMixin):
# reinit channels then signal entities
self.hass.async_create_task(self._async_became_available())
return
+ if availability_changed and not available:
+ self.hass.bus.async_fire(
+ "zha_event",
+ {
+ ATTR_DEVICE_IEEE: str(self.ieee),
+ "device_event_type": "device_offline",
+ },
+ )
async_dispatcher_send(self.hass, f"{self._available_signal}_entity")
async def _async_became_available(self) -> None:
diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py
index ef39c408ec5..bb57d7f03f4 100644
--- a/homeassistant/components/zha/core/gateway.py
+++ b/homeassistant/components/zha/core/gateway.py
@@ -26,6 +26,7 @@ from homeassistant.helpers.entity_registry import (
async_entries_for_device,
async_get_registry as get_ent_reg,
)
+from homeassistant.helpers.event import async_track_time_interval
from . import discovery, typing as zha_typing
from .const import (
@@ -57,7 +58,6 @@ from .const import (
SIGNAL_ADD_ENTITIES,
SIGNAL_GROUP_MEMBERSHIP_CHANGE,
SIGNAL_REMOVE,
- SIGNAL_REMOVE_GROUP,
UNKNOWN_MANUFACTURER,
UNKNOWN_MODEL,
ZHA_GW_MSG,
@@ -118,6 +118,7 @@ class ZHAGateway:
self.debug_enabled = False
self._log_relay_handler = LogRelayHandler(hass, self)
self._config_entry = config_entry
+ self._unsubs = []
async def async_initialize(self):
"""Initialize controller and connect radio."""
@@ -187,6 +188,13 @@ class ZHAGateway:
"available" if zha_device.available else "unavailable",
delta_msg,
)
+ # update the last seen time for devices every 10 minutes to avoid thrashing
+ # writes and shutdown issues where storage isn't updated
+ self._unsubs.append(
+ async_track_time_interval(
+ self._hass, self.async_update_device_storage, timedelta(minutes=10)
+ )
+ )
@callback
def async_load_groups(self) -> None:
@@ -298,13 +306,10 @@ class ZHAGateway:
self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_ADDED)
def group_removed(self, zigpy_group: ZigpyGroupType) -> None:
- """Handle zigpy group added event."""
+ """Handle zigpy group removed event."""
self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_REMOVED)
zha_group = self._groups.pop(zigpy_group.group_id, None)
zha_group.info("group_removed")
- async_dispatcher_send(
- self._hass, f"{SIGNAL_REMOVE_GROUP}_0x{zigpy_group.group_id:04x}"
- )
self._cleanup_group_entity_registry_entries(zigpy_group)
def _send_group_gateway_message(
@@ -519,7 +524,7 @@ class ZHAGateway:
if device.status is DeviceStatus.INITIALIZED:
device.update_available(available)
- async def async_update_device_storage(self):
+ async def async_update_device_storage(self, *_):
"""Update the devices in the store."""
for device in self.devices.values():
self.zha_storage.async_update_device(device)
@@ -619,7 +624,7 @@ class ZHAGateway:
if not group:
_LOGGER.debug("Group: %s:0x%04x could not be found", group.name, group_id)
return
- if group and group.members:
+ if group.members:
tasks = []
for member in group.members:
tasks.append(member.async_remove_from_group())
@@ -630,6 +635,8 @@ class ZHAGateway:
async def shutdown(self):
"""Stop ZHA Controller Application."""
_LOGGER.debug("Shutting down ZHA ControllerApplication")
+ for unsubscribe in self._unsubs:
+ unsubscribe()
await self.application_controller.shutdown()
diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py
index 0df3b1070bf..0cc350ba4b8 100644
--- a/homeassistant/components/zha/core/registries.py
+++ b/homeassistant/components/zha/core/registries.py
@@ -240,12 +240,9 @@ class MatchRule:
return matches
-RegistryDictType = Dict[
- str, Dict[MatchRule, CALLABLE_T]
-] # pylint: disable=invalid-name
+RegistryDictType = Dict[str, Dict[MatchRule, CALLABLE_T]]
-
-GroupRegistryDictType = Dict[str, CALLABLE_T] # pylint: disable=invalid-name
+GroupRegistryDictType = Dict[str, CALLABLE_T]
class ZHAEntityRegistry:
diff --git a/homeassistant/components/zha/core/typing.py b/homeassistant/components/zha/core/typing.py
index bce4a058ac6..0fe46a4628e 100644
--- a/homeassistant/components/zha/core/typing.py
+++ b/homeassistant/components/zha/core/typing.py
@@ -26,13 +26,13 @@ ZigpyGroupType = zigpy.group.Group
ZigpyZdoType = zigpy.zdo.ZDO
if TYPE_CHECKING:
+ import homeassistant.components.zha.core.channels
import homeassistant.components.zha.core.channels as channels
import homeassistant.components.zha.core.channels.base as base_channels
import homeassistant.components.zha.core.device
import homeassistant.components.zha.core.gateway
import homeassistant.components.zha.core.group
import homeassistant.components.zha.entity
- import homeassistant.components.zha.core.channels
ChannelType = base_channels.ZigbeeChannel
ChannelsType = channels.Channels
diff --git a/homeassistant/components/zha/device_trigger.py b/homeassistant/components/zha/device_trigger.py
index 5f842d7f380..9d04d36f748 100644
--- a/homeassistant/components/zha/device_trigger.py
+++ b/homeassistant/components/zha/device_trigger.py
@@ -1,11 +1,11 @@
"""Provides device automations for ZHA devices that emit events."""
import voluptuous as vol
-import homeassistant.components.automation.event as event
from homeassistant.components.device_automation import TRIGGER_BASE_SCHEMA
from homeassistant.components.device_automation.exceptions import (
InvalidDeviceAutomationConfig,
)
+from homeassistant.components.homeassistant.triggers import event as event_trigger
from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE
from . import DOMAIN
@@ -29,8 +29,8 @@ async def async_validate_trigger_config(hass, config):
trigger = (config[CONF_TYPE], config[CONF_SUBTYPE])
try:
zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID])
- except (KeyError, AttributeError):
- raise InvalidDeviceAutomationConfig
+ except (KeyError, AttributeError) as err:
+ raise InvalidDeviceAutomationConfig from err
if (
zha_device.device_automation_triggers is None
or trigger not in zha_device.device_automation_triggers
@@ -54,13 +54,13 @@ async def async_attach_trigger(hass, config, action, automation_info):
trigger = zha_device.device_automation_triggers[trigger]
event_config = {
- event.CONF_PLATFORM: "event",
- event.CONF_EVENT_TYPE: ZHA_EVENT,
- event.CONF_EVENT_DATA: {DEVICE_IEEE: str(zha_device.ieee), **trigger},
+ event_trigger.CONF_PLATFORM: "event",
+ event_trigger.CONF_EVENT_TYPE: ZHA_EVENT,
+ event_trigger.CONF_EVENT_DATA: {DEVICE_IEEE: str(zha_device.ieee), **trigger},
}
- event_config = event.TRIGGER_SCHEMA(event_config)
- return await event.async_attach_trigger(
+ event_config = event_trigger.TRIGGER_SCHEMA(event_config)
+ return await event_trigger.async_attach_trigger(
hass, event_config, action, automation_info, platform_type="device"
)
diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py
index d583f89c9bc..96f005ba288 100644
--- a/homeassistant/components/zha/entity.py
+++ b/homeassistant/components/zha/entity.py
@@ -24,7 +24,6 @@ from .core.const import (
SIGNAL_GROUP_ENTITY_REMOVED,
SIGNAL_GROUP_MEMBERSHIP_CHANGE,
SIGNAL_REMOVE,
- SIGNAL_REMOVE_GROUP,
)
from .core.helpers import LogMixin
from .core.typing import CALLABLE_T, ChannelType, ZhaDeviceType
@@ -203,9 +202,13 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity):
async def async_update(self) -> None:
"""Retrieve latest state."""
- for channel in self.cluster_channels.values():
- if hasattr(channel, "async_update"):
- await channel.async_update()
+ tasks = [
+ channel.async_update()
+ for channel in self.cluster_channels.values()
+ if hasattr(channel, "async_update")
+ ]
+ if tasks:
+ await asyncio.gather(*tasks)
class ZhaGroupEntity(BaseZhaEntity):
@@ -217,32 +220,35 @@ class ZhaGroupEntity(BaseZhaEntity):
"""Initialize a light group."""
super().__init__(unique_id, zha_device, **kwargs)
self._available = False
- self._name = (
- f"{zha_device.gateway.groups.get(group_id).name}_zha_group_0x{group_id:04x}"
- )
+ self._group = zha_device.gateway.groups.get(group_id)
+ self._name = f"{self._group.name}_zha_group_0x{group_id:04x}"
self._group_id: int = group_id
self._entity_ids: List[str] = entity_ids
self._async_unsub_state_changed: Optional[CALLBACK_TYPE] = None
+ self._handled_group_membership = False
@property
def available(self) -> bool:
"""Return entity availability."""
return self._available
+ async def _handle_group_membership_changed(self):
+ """Handle group membership changed."""
+ # Make sure we don't call remove twice as members are removed
+ if self._handled_group_membership:
+ return
+
+ self._handled_group_membership = True
+ await self.async_remove()
+
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
await super().async_added_to_hass()
- self.async_accept_signal(
- None,
- f"{SIGNAL_REMOVE_GROUP}_0x{self._group_id:04x}",
- self.async_remove,
- signal_override=True,
- )
self.async_accept_signal(
None,
f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{self._group_id:04x}",
- self.async_remove,
+ self._handle_group_membership_changed,
signal_override=True,
)
@@ -256,7 +262,6 @@ class ZhaGroupEntity(BaseZhaEntity):
)
self.async_on_remove(send_removed_signal)
- await self.async_update()
@callback
def async_state_changed_listener(self, event: Event):
diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py
index ff080562190..ba05d63df12 100644
--- a/homeassistant/components/zha/light.py
+++ b/homeassistant/components/zha/light.py
@@ -410,13 +410,10 @@ class Light(BaseLight, ZhaEntity):
if "effect" in last_state.attributes:
self._effect = last_state.attributes["effect"]
- async def async_update(self):
- """Attempt to retrieve on off state from the light."""
- await super().async_update()
- await self.async_get_state()
-
async def async_get_state(self, from_cache=True):
"""Attempt to retrieve on off state from the light."""
+ if not from_cache and not self.available:
+ return
self.debug("polling current state - from cache: %s", from_cache)
if self._on_off_channel:
state = await self._on_off_channel.get_attribute_value(
diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json
index b9d2caf0137..c6b0fa78799 100644
--- a/homeassistant/components/zha/manifest.json
+++ b/homeassistant/components/zha/manifest.json
@@ -4,14 +4,15 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/zha",
"requirements": [
- "bellows==0.18.1",
+ "bellows==0.20.2",
"pyserial==3.4",
- "zha-quirks==0.0.43",
- "zigpy-cc==0.4.4",
+ "zha-quirks==0.0.44",
+ "zigpy-cc==0.5.2",
"zigpy-deconz==0.9.2",
- "zigpy==0.22.2",
- "zigpy-xbee==0.12.1",
- "zigpy-zigate==0.6.1"
+ "zigpy==0.23.2",
+ "zigpy-xbee==0.13.0",
+ "zigpy-zigate==0.6.2",
+ "zigpy-znp==0.1.1"
],
"codeowners": ["@dmulcahey", "@adminiuga"]
}
diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py
index 38a9f19dce2..6e2878f371b 100644
--- a/homeassistant/components/zha/sensor.py
+++ b/homeassistant/components/zha/sensor.py
@@ -14,10 +14,10 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
+ PERCENTAGE,
POWER_WATT,
STATE_UNKNOWN,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
@@ -157,7 +157,7 @@ class Battery(Sensor):
SENSOR_ATTR = "battery_percentage_remaining"
_device_class = DEVICE_CLASS_BATTERY
- _unit = UNIT_PERCENTAGE
+ _unit = PERCENTAGE
@staticmethod
def formatter(value):
@@ -210,6 +210,12 @@ class ElectricalMeasurement(Sensor):
return round(value, self._decimals)
return round(value)
+ async def async_update(self) -> None:
+ """Retrieve latest state."""
+ if not self.available:
+ return
+ await super().async_update()
+
@STRICT_MATCH(generic_ids=CHANNEL_ST_HUMIDITY_CLUSTER)
@STRICT_MATCH(channel_names=CHANNEL_HUMIDITY)
@@ -219,7 +225,7 @@ class Humidity(Sensor):
SENSOR_ATTR = "measured_value"
_device_class = DEVICE_CLASS_HUMIDITY
_divisor = 100
- _unit = UNIT_PERCENTAGE
+ _unit = PERCENTAGE
@STRICT_MATCH(channel_names=CHANNEL_ILLUMINANCE)
diff --git a/homeassistant/components/zha/services.yaml b/homeassistant/components/zha/services.yaml
index 971321fbfd2..257d1026f7f 100644
--- a/homeassistant/components/zha/services.yaml
+++ b/homeassistant/components/zha/services.yaml
@@ -132,11 +132,11 @@ warning_device_warn:
example: "00:0d:6f:00:05:7d:2d:34"
mode:
description: >-
- The Warning Mode field is used as an 4-bit enumeration, can have one of the values defined below in table 8-20 of the ZCL spec. The exact behavior of the WD device in each mode is according to the relevant security standards.
+ The Warning Mode field is used as an 4-bit enumeration, can have one of the values 0-6 defined below in table 8-20 of the ZCL spec. The exact behavior of the WD device in each mode is according to the relevant security standards.
example: 1
strobe:
description: >-
- The Strobe field is used as a 2-bit enumeration, and determines if the visual indication is required in addition to the audible siren, as indicated in Table 8-21 of the ZCL spec. If the strobe field is “1” and the Warning Mode is “0” (“Stop”) then only the strobe is activated.
+ The Strobe field is used as a 2-bit enumeration, and determines if the visual indication is required in addition to the audible siren, as indicated in Table 8-21 of the ZCL spec. "0" means no strobe, "1" means strobe. If the strobe field is “1” and the Warning Mode is “0” (“Stop”) then only the strobe is activated.
example: 1
level:
description: >-
@@ -144,12 +144,12 @@ warning_device_warn:
example: 2
duration:
description: >-
- Requested duration of warning, in seconds. If both Strobe and Warning Mode are "0" this field SHALL be ignored.
+ Requested duration of warning, in seconds (16 bit). If both Strobe and Warning Mode are "0" this field SHALL be ignored.
example: 2
duty_cycle:
description: >-
Indicates the length of the flash cycle. This provides a means of varying the flash duration for different alarm types (e.g., fire, police, burglar). Valid range is 0-100 in increments of 10. All other values SHALL be rounded to the nearest valid value. Strobe SHALL calculate duty cycle over a duration of one second. The ON state SHALL precede the OFF state. For example, if Strobe Duty Cycle Field specifies “40,” then the strobe SHALL flash ON for 4/10ths of a second and then turn OFF for 6/10ths of a second.
- example: 2
+ example: 50
intensity:
description: >-
Indicates the intensity of the strobe as shown in Table 8-23 of the ZCL spec. This attribute is designed to vary the output of the strobe (i.e., brightness) and not its frequency, which is detailed in section 8.4.2.3.1.6 of the ZCL spec.
diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json
index b26cebbd40a..915501b2437 100644
--- a/homeassistant/components/zha/strings.json
+++ b/homeassistant/components/zha/strings.json
@@ -51,7 +51,8 @@
"device_tilted": "Device tilted",
"device_knocked": "Device knocked \"{subtype}\"",
"device_dropped": "Device dropped",
- "device_flipped": "Device flipped \"{subtype}\""
+ "device_flipped": "Device flipped \"{subtype}\"",
+ "device_offline": "Device offline"
},
"trigger_subtype": {
"turn_on": "Turn on",
diff --git a/homeassistant/components/zha/translations/ca.json b/homeassistant/components/zha/translations/ca.json
index 8b2e9a9aed0..c1092875b01 100644
--- a/homeassistant/components/zha/translations/ca.json
+++ b/homeassistant/components/zha/translations/ca.json
@@ -65,6 +65,7 @@
"device_dropped": "Dispositiu caigut",
"device_flipped": "Dispositiu voltejat a \"{subtype}\"",
"device_knocked": "Dispositiu colpejat a \"{subtype}\"",
+ "device_offline": "Dispositiu desconnectat",
"device_rotated": "Dispositiu rotat a \"{subtype}\"",
"device_shaken": "Dispositiu sacsejat",
"device_slid": "Dispositiu lliscat a \"{subtype}\"",
diff --git a/homeassistant/components/zha/translations/en.json b/homeassistant/components/zha/translations/en.json
index 6a1eb4bac8e..d54eec90b5f 100644
--- a/homeassistant/components/zha/translations/en.json
+++ b/homeassistant/components/zha/translations/en.json
@@ -65,6 +65,7 @@
"device_dropped": "Device dropped",
"device_flipped": "Device flipped \"{subtype}\"",
"device_knocked": "Device knocked \"{subtype}\"",
+ "device_offline": "Device offline",
"device_rotated": "Device rotated \"{subtype}\"",
"device_shaken": "Device shaken",
"device_slid": "Device slid \"{subtype}\"",
diff --git a/homeassistant/components/zha/translations/es.json b/homeassistant/components/zha/translations/es.json
index fd06722a445..fb2445f0f8c 100644
--- a/homeassistant/components/zha/translations/es.json
+++ b/homeassistant/components/zha/translations/es.json
@@ -65,6 +65,7 @@
"device_dropped": "Dispositivo ca\u00eddo",
"device_flipped": "Dispositivo volteado \" {subtype} \"",
"device_knocked": "Dispositivo eliminado \" {subtype} \"",
+ "device_offline": "Dispositivo desconectado",
"device_rotated": "Dispositivo girado \" {subtype} \"",
"device_shaken": "Dispositivo agitado",
"device_slid": "Dispositivo deslizado \" {subtype} \"",
diff --git a/homeassistant/components/zha/translations/fr.json b/homeassistant/components/zha/translations/fr.json
index 11e58f7be31..1bfe4e5a3ac 100644
--- a/homeassistant/components/zha/translations/fr.json
+++ b/homeassistant/components/zha/translations/fr.json
@@ -65,6 +65,7 @@
"device_dropped": "Appareil tomb\u00e9",
"device_flipped": "Appareil retourn\u00e9 \"{subtype}\"",
"device_knocked": "Appareil frapp\u00e9 \"{subtype}\"",
+ "device_offline": "Appareil hors ligne",
"device_rotated": "Appareil tourn\u00e9 \"{subtype}\"",
"device_shaken": "Appareil secou\u00e9",
"device_slid": "Appareil gliss\u00e9 \"{subtype}\"",
diff --git a/homeassistant/components/zha/translations/it.json b/homeassistant/components/zha/translations/it.json
index 7b2531c1290..8330583cf74 100644
--- a/homeassistant/components/zha/translations/it.json
+++ b/homeassistant/components/zha/translations/it.json
@@ -65,6 +65,7 @@
"device_dropped": "Dispositivo caduto",
"device_flipped": "Dispositivo capovolto \" {subtype} \"",
"device_knocked": "Dispositivo bussato \" {subtype} \"",
+ "device_offline": "Dispositivo offline",
"device_rotated": "Dispositivo ruotato \" {subtype} \"",
"device_shaken": "Dispositivo in vibrazione",
"device_slid": "Dispositivo scivolato \"{sottotipo}\"",
diff --git a/homeassistant/components/zha/translations/lb.json b/homeassistant/components/zha/translations/lb.json
index 62498a7dfe2..e2c4a63f8d7 100644
--- a/homeassistant/components/zha/translations/lb.json
+++ b/homeassistant/components/zha/translations/lb.json
@@ -65,6 +65,7 @@
"device_dropped": "Apparat gefall",
"device_flipped": "Apparat \u00ebmgedr\u00e9int \"{subtype}\"",
"device_knocked": "Apparat geklappt \"{subtype}\"",
+ "device_offline": "Apparat net ereechbar",
"device_rotated": "Apparat gedr\u00e9int \"{subtype}\"",
"device_shaken": "Apparat ger\u00ebselt",
"device_slid": "Apparat gerutscht \"{subtype}\"",
diff --git a/homeassistant/components/zha/translations/no.json b/homeassistant/components/zha/translations/no.json
index 9699a7219ad..c5112235239 100644
--- a/homeassistant/components/zha/translations/no.json
+++ b/homeassistant/components/zha/translations/no.json
@@ -27,8 +27,7 @@
"data": {
"path": "Seriell enhetsbane"
},
- "description": "Velg seriell port for Zigbee radio",
- "title": ""
+ "description": "Velg seriell port for Zigbee radio"
}
}
},
@@ -65,6 +64,7 @@
"device_dropped": "Enhet droppet",
"device_flipped": "Enheten snudd \"{subtype}\"",
"device_knocked": "Enheten sl\u00e5tt \"{subtype}\"",
+ "device_offline": "Enheten er frakoblet",
"device_rotated": "Enheten roterte \"{subtype}\"",
"device_shaken": "Enhet er ristet",
"device_slid": "Enheten skled \"{subtype}\"",
diff --git a/homeassistant/components/zha/translations/pl.json b/homeassistant/components/zha/translations/pl.json
index b9780d8427f..b2089e0dad8 100644
--- a/homeassistant/components/zha/translations/pl.json
+++ b/homeassistant/components/zha/translations/pl.json
@@ -39,15 +39,15 @@
},
"trigger_subtype": {
"both_buttons": "oba przyciski",
- "button_1": "pierwszy przycisk",
- "button_2": "drugi przycisk",
- "button_3": "trzeci przycisk",
- "button_4": "czwarty przycisk",
- "button_5": "pi\u0105ty przycisk",
- "button_6": "sz\u00f3sty przycisk",
- "close": "nast\u0105pi zamkni\u0119cie",
- "dim_down": "nast\u0105pi zmniejszenie jasno\u015bci",
- "dim_up": "nast\u0105pi zwi\u0119kszenie jasno\u015bci",
+ "button_1": "pierwszy",
+ "button_2": "drugi",
+ "button_3": "trzeci",
+ "button_4": "czwarty",
+ "button_5": "pi\u0105ty",
+ "button_6": "sz\u00f3sty",
+ "close": "zamknij",
+ "dim_down": "zmniejszenie jasno\u015bci",
+ "dim_up": "zwi\u0119kszenie jasno\u015bci",
"face_1": "z aktywowan\u0105 twarz\u0105 1",
"face_2": "z aktywowan\u0105 twarz\u0105 2",
"face_3": "z aktywowan\u0105 twarz\u0105 3",
@@ -56,10 +56,10 @@
"face_6": "z aktywowan\u0105 twarz\u0105 6",
"face_any": "z dowoln\u0105 twarz\u0105 aktywowan\u0105",
"left": "w lewo",
- "open": "otwarcie",
+ "open": "otw\u00f3rz",
"right": "w prawo",
- "turn_off": "nast\u0105pi wy\u0142\u0105czenie",
- "turn_on": "nast\u0105pi w\u0142\u0105czenie"
+ "turn_off": "wy\u0142\u0105cz",
+ "turn_on": "w\u0142\u0105cz"
},
"trigger_type": {
"device_dropped": "nast\u0105pi upadek urz\u0105dzenia",
diff --git a/homeassistant/components/zha/translations/pt-BR.json b/homeassistant/components/zha/translations/pt-BR.json
index 4b943032d28..e06bff43993 100644
--- a/homeassistant/components/zha/translations/pt-BR.json
+++ b/homeassistant/components/zha/translations/pt-BR.json
@@ -29,6 +29,12 @@
"action_type": {
"squawk": "Squawk",
"warn": "Aviso"
+ },
+ "trigger_subtype": {
+ "close": "Fechado"
+ },
+ "trigger_type": {
+ "device_offline": "Dispositivo offline"
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/zha/translations/ru.json b/homeassistant/components/zha/translations/ru.json
index d535e9dd588..eea55972c62 100644
--- a/homeassistant/components/zha/translations/ru.json
+++ b/homeassistant/components/zha/translations/ru.json
@@ -65,6 +65,7 @@
"device_dropped": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441\u0431\u0440\u043e\u0441\u0438\u043b\u0438",
"device_flipped": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u0435\u0440\u0435\u0432\u0435\u0440\u043d\u0443\u043b\u0438 {subtype}",
"device_knocked": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c \u043f\u043e\u0441\u0442\u0443\u0447\u0430\u043b\u0438 {subtype}",
+ "device_offline": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u0432 \u0441\u0435\u0442\u0438",
"device_rotated": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u0432\u0435\u0440\u043d\u0443\u043b\u0438 {subtype}",
"device_shaken": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u0442\u0440\u044f\u0441\u043b\u0438",
"device_slid": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441\u0434\u0432\u0438\u043d\u0443\u043b\u0438 {subtype}",
diff --git a/homeassistant/components/zha/translations/zh-Hant.json b/homeassistant/components/zha/translations/zh-Hant.json
index cbe4d8fbedd..5965911d459 100644
--- a/homeassistant/components/zha/translations/zh-Hant.json
+++ b/homeassistant/components/zha/translations/zh-Hant.json
@@ -65,6 +65,7 @@
"device_dropped": "\u8a2d\u5099\u6389\u843d",
"device_flipped": "\u7ffb\u52d5 \"{subtype}\" \u8a2d\u5099",
"device_knocked": "\u6572\u64ca \"{subtype}\" \u8a2d\u5099",
+ "device_offline": "\u8a2d\u5099\u96e2\u7dda",
"device_rotated": "\u65cb\u8f49 \"{subtype}\" \u8a2d\u5099",
"device_shaken": "\u8a2d\u5099\u6416\u6643",
"device_slid": "\u63a8\u52d5 \"{subtype}\" \u8a2d\u5099",
diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py
index 2a7c3f01a27..c3e1beb44af 100644
--- a/homeassistant/components/zone/__init__.py
+++ b/homeassistant/components/zone/__init__.py
@@ -76,7 +76,8 @@ def empty_value(value: Any) -> Any:
CONFIG_SCHEMA = vol.Schema(
{
vol.Optional(DOMAIN, default=[]): vol.Any(
- vol.All(cv.ensure_list, [vol.Schema(CREATE_FIELDS)]), empty_value,
+ vol.All(cv.ensure_list, [vol.Schema(CREATE_FIELDS)]),
+ empty_value,
)
},
extra=vol.ALLOW_EXTRA,
@@ -188,7 +189,7 @@ async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
storage_collection = ZoneStorageCollection(
storage.Store(hass, STORAGE_VERSION, STORAGE_KEY),
- logging.getLogger(f"{__name__}_storage_collection"),
+ logging.getLogger(f"{__name__}.storage_collection"),
id_manager,
)
collection.attach_entity_component_collection(
@@ -234,7 +235,10 @@ async def async_setup(hass: HomeAssistant, config: Dict) -> bool:
if component.get_entity("zone.home"):
return True
- home_zone = Zone(_home_conf(hass), True,)
+ home_zone = Zone(
+ _home_conf(hass),
+ True,
+ )
home_zone.entity_id = ENTITY_ID_HOME
await component.async_add_entities([home_zone])
diff --git a/homeassistant/components/zone/translations/no.json b/homeassistant/components/zone/translations/no.json
index 9bf6e189369..415c0a6afaa 100644
--- a/homeassistant/components/zone/translations/no.json
+++ b/homeassistant/components/zone/translations/no.json
@@ -10,8 +10,7 @@
"latitude": "Breddegrad",
"longitude": "Lengdegrad",
"name": "Navn",
- "passive": "Passiv",
- "radius": ""
+ "passive": "Passiv"
},
"title": "Definer sone parametere"
}
diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/zone/trigger.py
similarity index 71%
rename from homeassistant/components/automation/zone.py
rename to homeassistant/components/zone/trigger.py
index 3b794f698a1..1f0856513dd 100644
--- a/homeassistant/components/automation/zone.py
+++ b/homeassistant/components/zone/trigger.py
@@ -1,7 +1,13 @@
"""Offer zone automation rules."""
import voluptuous as vol
-from homeassistant.const import CONF_ENTITY_ID, CONF_EVENT, CONF_PLATFORM, CONF_ZONE
+from homeassistant.const import (
+ ATTR_FRIENDLY_NAME,
+ CONF_ENTITY_ID,
+ CONF_EVENT,
+ CONF_PLATFORM,
+ CONF_ZONE,
+)
from homeassistant.core import callback
from homeassistant.helpers import condition, config_validation as cv, location
from homeassistant.helpers.event import async_track_state_change_event
@@ -12,6 +18,8 @@ EVENT_ENTER = "enter"
EVENT_LEAVE = "leave"
DEFAULT_EVENT = EVENT_ENTER
+_EVENT_DESCRIPTION = {EVENT_ENTER: "entering", EVENT_LEAVE: "leaving"}
+
TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_PLATFORM): "zone",
@@ -56,20 +64,21 @@ async def async_attach_trigger(hass, config, action, automation_info):
and from_match
and not to_match
):
+ description = f"{entity} {_EVENT_DESCRIPTION[event]} {zone_state.attributes[ATTR_FRIENDLY_NAME]}"
hass.async_run_job(
- action(
- {
- "trigger": {
- "platform": "zone",
- "entity_id": entity,
- "from_state": from_s,
- "to_state": to_s,
- "zone": zone_state,
- "event": event,
- }
- },
- context=to_s.context,
- )
+ action,
+ {
+ "trigger": {
+ "platform": "zone",
+ "entity_id": entity,
+ "from_state": from_s,
+ "to_state": to_s,
+ "zone": zone_state,
+ "event": event,
+ "description": description,
+ }
+ },
+ to_s.context,
)
return async_track_state_change_event(hass, entity_id, zone_automation_listener)
diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py
index 4e0598fed95..d679de7cfbd 100644
--- a/homeassistant/components/zwave/__init__.py
+++ b/homeassistant/components/zwave/__init__.py
@@ -335,12 +335,13 @@ async def async_setup_entry(hass, config_entry):
Will automatically load components to support devices found on the network.
"""
- from pydispatch import dispatcher
-
# pylint: disable=import-error
- from openzwave.option import ZWaveOption
- from openzwave.network import ZWaveNetwork
from openzwave.group import ZWaveGroup
+ from openzwave.network import ZWaveNetwork
+ from openzwave.option import ZWaveOption
+
+ # pylint: enable=import-error
+ from pydispatch import dispatcher
# Merge config entry and yaml config
config = config_entry.data
@@ -593,7 +594,7 @@ async def async_setup_entry(hass, config_entry):
async def rename_node(service):
"""Rename a node."""
node_id = service.data.get(const.ATTR_NODE_ID)
- node = network.nodes[node_id]
+ node = network.nodes[node_id] # pylint: disable=unsubscriptable-object
name = service.data.get(const.ATTR_NAME)
node.name = name
_LOGGER.info("Renamed Z-Wave node %d to %s", node_id, name)
@@ -613,7 +614,7 @@ async def async_setup_entry(hass, config_entry):
"""Rename a node value."""
node_id = service.data.get(const.ATTR_NODE_ID)
value_id = service.data.get(const.ATTR_VALUE_ID)
- node = network.nodes[node_id]
+ node = network.nodes[node_id] # pylint: disable=unsubscriptable-object
value = node.values[value_id]
name = service.data.get(const.ATTR_NAME)
value.label = name
@@ -629,7 +630,7 @@ async def async_setup_entry(hass, config_entry):
"""Set the polling intensity of a node value."""
node_id = service.data.get(const.ATTR_NODE_ID)
value_id = service.data.get(const.ATTR_VALUE_ID)
- node = network.nodes[node_id]
+ node = network.nodes[node_id] # pylint: disable=unsubscriptable-object
value = node.values[value_id]
intensity = service.data.get(const.ATTR_POLL_INTENSITY)
if intensity == 0:
@@ -667,7 +668,7 @@ async def async_setup_entry(hass, config_entry):
def set_config_parameter(service):
"""Set a config parameter to a node."""
node_id = service.data.get(const.ATTR_NODE_ID)
- node = network.nodes[node_id]
+ node = network.nodes[node_id] # pylint: disable=unsubscriptable-object
param = service.data.get(const.ATTR_CONFIG_PARAMETER)
selection = service.data.get(const.ATTR_CONFIG_VALUE)
size = service.data.get(const.ATTR_CONFIG_SIZE)
@@ -725,7 +726,7 @@ async def async_setup_entry(hass, config_entry):
"""Refresh the specified value from a node."""
node_id = service.data.get(const.ATTR_NODE_ID)
value_id = service.data.get(const.ATTR_VALUE_ID)
- node = network.nodes[node_id]
+ node = network.nodes[node_id] # pylint: disable=unsubscriptable-object
node.values[value_id].refresh()
_LOGGER.info("Node %s value %s refreshed", node_id, value_id)
@@ -734,14 +735,14 @@ async def async_setup_entry(hass, config_entry):
node_id = service.data.get(const.ATTR_NODE_ID)
value_id = service.data.get(const.ATTR_VALUE_ID)
value = service.data.get(const.ATTR_CONFIG_VALUE)
- node = network.nodes[node_id]
+ node = network.nodes[node_id] # pylint: disable=unsubscriptable-object
node.values[value_id].data = value
_LOGGER.info("Node %s value %s set to %s", node_id, value_id, value)
def print_config_parameter(service):
"""Print a config parameter from a node."""
node_id = service.data.get(const.ATTR_NODE_ID)
- node = network.nodes[node_id]
+ node = network.nodes[node_id] # pylint: disable=unsubscriptable-object
param = service.data.get(const.ATTR_CONFIG_PARAMETER)
_LOGGER.info(
"Config parameter %s on Node %s: %s",
@@ -753,13 +754,13 @@ async def async_setup_entry(hass, config_entry):
def print_node(service):
"""Print all information about z-wave node."""
node_id = service.data.get(const.ATTR_NODE_ID)
- node = network.nodes[node_id]
+ node = network.nodes[node_id] # pylint: disable=unsubscriptable-object
nice_print_node(node)
def set_wakeup(service):
"""Set wake-up interval of a node."""
node_id = service.data.get(const.ATTR_NODE_ID)
- node = network.nodes[node_id]
+ node = network.nodes[node_id] # pylint: disable=unsubscriptable-object
value = service.data.get(const.ATTR_CONFIG_VALUE)
if node.can_wake_up():
for value_id in node.get_values(class_id=const.COMMAND_CLASS_WAKE_UP):
@@ -806,14 +807,14 @@ async def async_setup_entry(hass, config_entry):
def refresh_node(service):
"""Refresh all node info."""
node_id = service.data.get(const.ATTR_NODE_ID)
- node = network.nodes[node_id]
+ node = network.nodes[node_id] # pylint: disable=unsubscriptable-object
node.refresh_info()
def reset_node_meters(service):
"""Reset meter counters of a node."""
node_id = service.data.get(const.ATTR_NODE_ID)
instance = service.data.get(const.ATTR_INSTANCE)
- node = network.nodes[node_id]
+ node = network.nodes[node_id] # pylint: disable=unsubscriptable-object
for value in node.get_values(class_id=const.COMMAND_CLASS_METER).values():
if value.index != const.INDEX_METER_RESET:
continue
@@ -833,7 +834,7 @@ async def async_setup_entry(hass, config_entry):
"""Heal a node on the network."""
node_id = service.data.get(const.ATTR_NODE_ID)
update_return_routes = service.data.get(const.ATTR_RETURN_ROUTES)
- node = network.nodes[node_id]
+ node = network.nodes[node_id] # pylint: disable=unsubscriptable-object
_LOGGER.info("Z-Wave node heal running for node %s", node_id)
node.heal(update_return_routes)
@@ -841,7 +842,7 @@ async def async_setup_entry(hass, config_entry):
"""Send test messages to a node on the network."""
node_id = service.data.get(const.ATTR_NODE_ID)
messages = service.data.get(const.ATTR_MESSAGES)
- node = network.nodes[node_id]
+ node = network.nodes[node_id] # pylint: disable=unsubscriptable-object
_LOGGER.info("Sending %s test-messages to node %s", messages, node_id)
node.test(messages)
diff --git a/homeassistant/components/zwave/config_flow.py b/homeassistant/components/zwave/config_flow.py
index cff197b7e97..d6c64e914f6 100644
--- a/homeassistant/components/zwave/config_flow.py
+++ b/homeassistant/components/zwave/config_flow.py
@@ -43,8 +43,8 @@ class ZwaveFlowHandler(config_entries.ConfigFlow):
if user_input is not None:
# Check if USB path is valid
- from openzwave.option import ZWaveOption
from openzwave.object import ZWaveException
+ from openzwave.option import ZWaveOption
try:
from functools import partial
diff --git a/homeassistant/components/zwave/translations/fr.json b/homeassistant/components/zwave/translations/fr.json
index ccd5db34d3c..d60a1c9f11e 100644
--- a/homeassistant/components/zwave/translations/fr.json
+++ b/homeassistant/components/zwave/translations/fr.json
@@ -11,7 +11,7 @@
"user": {
"data": {
"network_key": "Cl\u00e9 r\u00e9seau (laisser vide pour g\u00e9n\u00e9rer automatiquement)",
- "usb_path": "Chemin USB"
+ "usb_path": "Chemin du p\u00e9riph\u00e9rique USB"
},
"description": "Voir https://www.home-assistant.io/docs/z-wave/installation/ pour plus d'informations sur les variables de configuration.",
"title": "Configurer Z-Wave"
@@ -26,8 +26,8 @@
"sleeping": "En veille"
},
"query_stage": {
- "dead": "Morte ( {query_stage} )",
- "initializing": "Initialisation ( {query_stage} )"
+ "dead": "Morte",
+ "initializing": "Initialisation"
}
}
}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/translations/pl.json b/homeassistant/components/zwave/translations/pl.json
index f871ab928b6..ecff84ceb97 100644
--- a/homeassistant/components/zwave/translations/pl.json
+++ b/homeassistant/components/zwave/translations/pl.json
@@ -21,9 +21,9 @@
"state": {
"_": {
"dead": "martwy",
- "initializing": "inicjalizacja",
- "ready": "gotowy",
- "sleeping": "u\u015bpiony"
+ "initializing": "Inicjowanie",
+ "ready": "Gotowe",
+ "sleeping": "U\u015bpiony"
},
"query_stage": {
"dead": "martwy",
diff --git a/homeassistant/components/zwave/websocket_api.py b/homeassistant/components/zwave/websocket_api.py
index 7454f2e2c6a..24bf8d80a75 100644
--- a/homeassistant/components/zwave/websocket_api.py
+++ b/homeassistant/components/zwave/websocket_api.py
@@ -10,6 +10,7 @@ from homeassistant.core import callback
from .const import (
CONF_AUTOHEAL,
CONF_DEBUG,
+ CONF_NETWORK_KEY,
CONF_POLLING_INTERVAL,
CONF_USB_STICK_PATH,
DATA_NETWORK,
@@ -46,8 +47,23 @@ def websocket_get_config(hass, connection, msg):
)
+@websocket_api.require_admin
+@websocket_api.websocket_command({vol.Required(TYPE): "zwave/get_migration_config"})
+def websocket_get_migration_config(hass, connection, msg):
+ """Get Z-Wave configuration for migration."""
+ config = hass.data[DATA_ZWAVE_CONFIG]
+ connection.send_result(
+ msg[ID],
+ {
+ CONF_USB_STICK_PATH: config[CONF_USB_STICK_PATH],
+ CONF_NETWORK_KEY: config[CONF_NETWORK_KEY],
+ },
+ )
+
+
@callback
def async_load_websocket_api(hass):
"""Set up the web socket API."""
websocket_api.async_register_command(hass, websocket_network_status)
websocket_api.async_register_command(hass, websocket_get_config)
+ websocket_api.async_register_command(hass, websocket_get_migration_config)
diff --git a/homeassistant/config.py b/homeassistant/config.py
index a327ca630f8..8f9dc7c3d62 100644
--- a/homeassistant/config.py
+++ b/homeassistant/config.py
@@ -33,6 +33,7 @@ from homeassistant.const import (
CONF_INTERNAL_URL,
CONF_LATITUDE,
CONF_LONGITUDE,
+ CONF_MEDIA_DIRS,
CONF_NAME,
CONF_PACKAGES,
CONF_TEMPERATURE_UNIT,
@@ -221,6 +222,8 @@ CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend(
],
_no_duplicate_auth_mfa_module,
),
+ # pylint: disable=no-value-for-parameter
+ vol.Optional(CONF_MEDIA_DIRS): cv.schema_with_slug_keys(vol.IsDir()),
}
)
@@ -485,6 +488,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: Dict) -> Non
CONF_UNIT_SYSTEM,
CONF_EXTERNAL_URL,
CONF_INTERNAL_URL,
+ CONF_MEDIA_DIRS,
]
):
hac.config_source = SOURCE_YAML
@@ -496,6 +500,7 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: Dict) -> Non
(CONF_ELEVATION, "elevation"),
(CONF_INTERNAL_URL, "internal_url"),
(CONF_EXTERNAL_URL, "external_url"),
+ (CONF_MEDIA_DIRS, "media_dirs"),
):
if key in config:
setattr(hac, attr, config[key])
@@ -503,8 +508,14 @@ async def async_process_ha_core_config(hass: HomeAssistant, config: Dict) -> Non
if CONF_TIME_ZONE in config:
hac.set_time_zone(config[CONF_TIME_ZONE])
+ if CONF_MEDIA_DIRS not in config:
+ if is_docker_env():
+ hac.media_dirs = {"local": "/media"}
+ else:
+ hac.media_dirs = {"local": hass.config.path("media")}
+
# Init whitelist external dir
- hac.allowlist_external_dirs = {hass.config.path("www")}
+ hac.allowlist_external_dirs = {hass.config.path("www"), *hac.media_dirs.values()}
if CONF_ALLOWLIST_EXTERNAL_DIRS in config:
hac.allowlist_external_dirs.update(set(config[CONF_ALLOWLIST_EXTERNAL_DIRS]))
@@ -809,9 +820,7 @@ async def async_process_component_config(
# Validate platform specific schema
if hasattr(platform, "PLATFORM_SCHEMA"):
try:
- p_validated = platform.PLATFORM_SCHEMA( # type: ignore
- p_config
- )
+ p_validated = platform.PLATFORM_SCHEMA(p_config) # type: ignore
except vol.Invalid as ex:
async_log_exception(
ex,
diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py
index f36e2c6accb..347ca294d34 100644
--- a/homeassistant/config_entries.py
+++ b/homeassistant/config_entries.py
@@ -4,7 +4,6 @@ import functools
import logging
from types import MappingProxyType
from typing import Any, Callable, Dict, List, Optional, Set, Union, cast
-import uuid
import weakref
import attr
@@ -16,6 +15,7 @@ from homeassistant.helpers import entity_registry
from homeassistant.helpers.event import Event
from homeassistant.setup import async_process_deps_reqs, async_setup_component
from homeassistant.util.decorator import Registry
+import homeassistant.util.uuid as uuid_util
_LOGGER = logging.getLogger(__name__)
_UNDEF: dict = {}
@@ -110,6 +110,7 @@ class ConfigEntry:
"data",
"options",
"unique_id",
+ "supports_unload",
"system_options",
"source",
"connection_class",
@@ -135,7 +136,7 @@ class ConfigEntry:
) -> None:
"""Initialize a config entry."""
# Unique id of the config entry
- self.entry_id = entry_id or uuid.uuid4().hex
+ self.entry_id = entry_id or uuid_util.uuid_v1mc_hex()
# Version of the configuration.
self.version = version
@@ -167,6 +168,9 @@ class ConfigEntry:
# Unique ID of this entry.
self.unique_id = unique_id
+ # Supports unload
+ self.supports_unload = False
+
# Listeners to call on update
self.update_listeners: List[weakref.ReferenceType[UpdateListenerType]] = []
@@ -187,6 +191,8 @@ class ConfigEntry:
if integration is None:
integration = await loader.async_get_integration(hass, self.domain)
+ self.supports_unload = await support_entry_unload(hass, self.domain)
+
try:
component = integration.get_component()
except ImportError as err:
@@ -219,9 +225,7 @@ class ConfigEntry:
return
try:
- result = await component.async_setup_entry( # type: ignore
- hass, self
- )
+ result = await component.async_setup_entry(hass, self) # type: ignore
if not isinstance(result, bool):
_LOGGER.error(
@@ -306,9 +310,7 @@ class ConfigEntry:
return False
try:
- result = await component.async_unload_entry( # type: ignore
- hass, self
- )
+ result = await component.async_unload_entry(hass, self) # type: ignore
assert isinstance(result, bool)
@@ -343,9 +345,7 @@ class ConfigEntry:
if not hasattr(component, "async_remove_entry"):
return
try:
- await component.async_remove_entry( # type: ignore
- hass, self
- )
+ await component.async_remove_entry(hass, self) # type: ignore
except Exception: # pylint: disable=broad-except
_LOGGER.exception(
"Error calling entry remove callback %s for %s",
@@ -383,9 +383,7 @@ class ConfigEntry:
return False
try:
- result = await component.async_migrate_entry( # type: ignore
- hass, self
- )
+ result = await component.async_migrate_entry(hass, self) # type: ignore
if not isinstance(result, bool):
_LOGGER.error(
"%s.async_migrate_entry did not return boolean", self.domain
@@ -518,9 +516,9 @@ class ConfigEntriesFlowManager(data_entry_flow.FlowManager):
"""
try:
integration = await loader.async_get_integration(self.hass, handler_key)
- except loader.IntegrationNotFound:
+ except loader.IntegrationNotFound as err:
_LOGGER.error("Cannot find integration %s", handler_key)
- raise data_entry_flow.UnknownHandler
+ raise data_entry_flow.UnknownHandler from err
# Make sure requirements and dependencies of component are resolved
await async_process_deps_reqs(self.hass, self._hass_config, integration)
@@ -745,28 +743,49 @@ class ConfigEntries:
self,
entry: ConfigEntry,
*,
+ # pylint: disable=dangerous-default-value # _UNDEFs not modified
unique_id: Union[str, dict, None] = _UNDEF,
title: Union[str, dict] = _UNDEF,
data: dict = _UNDEF,
options: dict = _UNDEF,
system_options: dict = _UNDEF,
- ) -> None:
- """Update a config entry."""
- if unique_id is not _UNDEF:
+ ) -> bool:
+ """Update a config entry.
+
+ If the entry was changed, the update_listeners are
+ fired and this function returns True
+
+ If the entry was not changed, the update_listeners are
+ not fired and this function returns False
+ """
+ changed = False
+
+ if unique_id is not _UNDEF and entry.unique_id != unique_id:
+ changed = True
entry.unique_id = cast(Optional[str], unique_id)
- if title is not _UNDEF:
+ if title is not _UNDEF and entry.title != title:
+ changed = True
entry.title = cast(str, title)
- if data is not _UNDEF:
+ if data is not _UNDEF and entry.data != data: # type: ignore
+ changed = True
entry.data = MappingProxyType(data)
- if options is not _UNDEF:
+ if options is not _UNDEF and entry.options != options: # type: ignore
+ changed = True
entry.options = MappingProxyType(options)
- if system_options is not _UNDEF:
+ if (
+ system_options is not _UNDEF
+ and entry.system_options.as_dict() != system_options
+ ):
+ changed = True
entry.system_options.update(**system_options)
+ if not changed:
+ return False
+
for listener_ref in entry.update_listeners:
listener = listener_ref()
if listener is not None:
@@ -774,6 +793,8 @@ class ConfigEntries:
self._async_schedule_save()
+ return True
+
async def async_forward_entry_setup(self, entry: ConfigEntry, domain: str) -> bool:
"""Forward the setup of an entry to a different component.
@@ -850,7 +871,9 @@ class ConfigFlow(data_entry_flow.FlowHandler):
@callback
def _abort_if_unique_id_configured(
- self, updates: Optional[Dict[Any, Any]] = None
+ self,
+ updates: Optional[Dict[Any, Any]] = None,
+ reload_on_update: bool = True,
) -> None:
"""Abort if the unique ID is already configured."""
assert self.hass
@@ -859,10 +882,18 @@ class ConfigFlow(data_entry_flow.FlowHandler):
for entry in self._async_current_entries():
if entry.unique_id == self.unique_id:
- if updates is not None and not updates.items() <= entry.data.items():
- self.hass.config_entries.async_update_entry(
+ if updates is not None:
+ changed = self.hass.config_entries.async_update_entry(
entry, data={**entry.data, **updates}
)
+ if (
+ changed
+ and reload_on_update
+ and entry.state in (ENTRY_STATE_LOADED, ENTRY_STATE_SETUP_RETRY)
+ ):
+ self.hass.async_create_task(
+ self.hass.config_entries.async_reload(entry.entry_id)
+ )
raise data_entry_flow.AbortFlow("already_configured")
async def async_set_unique_id(
@@ -1086,9 +1117,7 @@ class EntityRegistryDisabledHandler:
)
assert config_entry is not None
- if config_entry.entry_id not in self.changed and await support_entry_unload(
- self.hass, config_entry.domain
- ):
+ if config_entry.entry_id not in self.changed and config_entry.supports_unload:
self.changed.add(config_entry.entry_id)
if not self.changed:
diff --git a/homeassistant/const.py b/homeassistant/const.py
index 9dd74f6709c..80129e097d0 100644
--- a/homeassistant/const.py
+++ b/homeassistant/const.py
@@ -1,7 +1,7 @@
"""Constants used by Home Assistant components."""
MAJOR_VERSION = 0
-MINOR_VERSION = 114
-PATCH_VERSION = "4"
+MINOR_VERSION = 115
+PATCH_VERSION = "0"
__short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__ = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER = (3, 7, 1)
@@ -37,9 +37,10 @@ CONF_API_KEY = "api_key"
CONF_API_VERSION = "api_version"
CONF_ARMING_TIME = "arming_time"
CONF_AT = "at"
-CONF_AUTHENTICATION = "authentication"
+CONF_ATTRIBUTE = "attribute"
CONF_AUTH_MFA_MODULES = "auth_mfa_modules"
CONF_AUTH_PROVIDERS = "auth_providers"
+CONF_AUTHENTICATION = "authentication"
CONF_BASE = "base"
CONF_BEFORE = "before"
CONF_BELOW = "below"
@@ -115,6 +116,7 @@ CONF_LIGHTS = "lights"
CONF_LONGITUDE = "longitude"
CONF_MAC = "mac"
CONF_MAXIMUM = "maximum"
+CONF_MEDIA_DIRS = "media_dirs"
CONF_METHOD = "method"
CONF_MINIMUM = "minimum"
CONF_MODE = "mode"
@@ -178,7 +180,9 @@ CONF_UNTIL = "until"
CONF_URL = "url"
CONF_USERNAME = "username"
CONF_VALUE_TEMPLATE = "value_template"
+CONF_VARIABLES = "variables"
CONF_VERIFY_SSL = "verify_ssl"
+CONF_WAIT_FOR_TRIGGER = "wait_for_trigger"
CONF_WAIT_TEMPLATE = "wait_template"
CONF_WEBHOOK_ID = "webhook_id"
CONF_WEEKDAY = "weekday"
@@ -218,6 +222,10 @@ DEVICE_CLASS_TEMPERATURE = "temperature"
DEVICE_CLASS_TIMESTAMP = "timestamp"
DEVICE_CLASS_PRESSURE = "pressure"
DEVICE_CLASS_POWER = "power"
+DEVICE_CLASS_CURRENT = "current"
+DEVICE_CLASS_ENERGY = "energy"
+DEVICE_CLASS_POWER_FACTOR = "power_factor"
+DEVICE_CLASS_VOLTAGE = "voltage"
# #### STATES ####
STATE_ON = "on"
@@ -438,7 +446,7 @@ CONDUCTIVITY: str = f"µS/{LENGTH_CENTIMETERS}"
UV_INDEX: str = "UV index"
# Percentage units
-UNIT_PERCENTAGE = "%"
+PERCENTAGE = "%"
# Irradiation units
IRRADIATION_WATTS_PER_SQUARE_METER = f"{POWER_WATT}/{AREA_SQUARE_METERS}"
diff --git a/homeassistant/core.py b/homeassistant/core.py
index da40c17c411..f230fef01eb 100644
--- a/homeassistant/core.py
+++ b/homeassistant/core.py
@@ -12,7 +12,6 @@ from ipaddress import ip_address
import logging
import os
import pathlib
-import random
import re
import threading
from time import monotonic
@@ -33,7 +32,6 @@ from typing import (
Union,
cast,
)
-import uuid
import attr
import voluptuous as vol
@@ -77,12 +75,13 @@ import homeassistant.util.dt as dt_util
from homeassistant.util.thread import fix_threading_exception_logging
from homeassistant.util.timeout import TimeoutManager
from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem
+import homeassistant.util.uuid as uuid_util
# Typing imports that create a circular dependency
if TYPE_CHECKING:
from homeassistant.auth import AuthManager
- from homeassistant.config_entries import ConfigEntries
from homeassistant.components.http import HomeAssistantHTTP
+ from homeassistant.config_entries import ConfigEntries
block_async_io.enable()
@@ -159,7 +158,7 @@ class CoreState(enum.Enum):
final_write = "FINAL_WRITE"
stopped = "STOPPED"
- def __str__(self) -> str:
+ def __str__(self) -> str: # pylint: disable=invalid-str-returned
"""Return the event."""
return self.value # type: ignore
@@ -303,6 +302,9 @@ class HomeAssistant:
target: target to call.
args: parameters for method to call.
"""
+ if target is None:
+ raise ValueError("Don't call async_add_job with None")
+
task = None
# Check for partials to properly determine if coroutine function
@@ -317,9 +319,7 @@ class HomeAssistant:
elif is_callback(check_target):
self.loop.call_soon(target, *args)
else:
- task = self.loop.run_in_executor( # type: ignore
- None, target, *args
- )
+ task = self.loop.run_in_executor(None, target, *args) # type: ignore
# If a task is scheduled
if self._track_task and task is not None:
@@ -510,13 +510,7 @@ class Context:
user_id: str = attr.ib(default=None)
parent_id: Optional[str] = attr.ib(default=None)
- # The uuid1 uses a random multicast MAC address instead of the real MAC address
- # of the machine without the overhead of calling the getrandom() system call.
- #
- # This is effectively equivalent to PostgreSQL's uuid_generate_v1mc() function
- id: str = attr.ib(
- factory=lambda: uuid.uuid1(node=random.getrandbits(48) | (1 << 40)).hex
- )
+ id: str = attr.ib(factory=uuid_util.uuid_v1mc_hex)
def as_dict(self) -> dict:
"""Return a dictionary representation of the context."""
@@ -529,7 +523,7 @@ class EventOrigin(enum.Enum):
local = "LOCAL"
remote = "REMOTE"
- def __str__(self) -> str:
+ def __str__(self) -> str: # pylint: disable=invalid-str-returned
"""Return the event."""
return self.value # type: ignore
@@ -759,6 +753,7 @@ class State:
last_changed: last time the state was changed, not the attributes.
last_updated: last time this object was updated.
context: Context in which it was created
+ domain: Domain of this state.
"""
__slots__ = [
@@ -768,6 +763,7 @@ class State:
"last_changed",
"last_updated",
"context",
+ "domain",
]
def __init__(
@@ -801,11 +797,7 @@ class State:
self.last_updated = last_updated or dt_util.utcnow()
self.last_changed = last_changed or self.last_updated
self.context = context or Context()
-
- @property
- def domain(self) -> str:
- """Domain of this state."""
- return split_entity_id(self.entity_id)[0]
+ self.domain = split_entity_id(self.entity_id)[0]
@property
def object_id(self) -> str:
@@ -907,7 +899,9 @@ class StateMachine:
return future.result()
@callback
- def async_entity_ids(self, domain_filter: Optional[str] = None) -> List[str]:
+ def async_entity_ids(
+ self, domain_filter: Optional[Union[str, Iterable]] = None
+ ) -> List[str]:
"""List of entity ids that are being tracked.
This method must be run in the event loop.
@@ -915,25 +909,38 @@ class StateMachine:
if domain_filter is None:
return list(self._states.keys())
- domain_filter = domain_filter.lower()
+ if isinstance(domain_filter, str):
+ domain_filter = (domain_filter.lower(),)
return [
state.entity_id
for state in self._states.values()
- if state.domain == domain_filter
+ if state.domain in domain_filter
]
- def all(self) -> List[State]:
+ def all(self, domain_filter: Optional[Union[str, Iterable]] = None) -> List[State]:
"""Create a list of all states."""
- return run_callback_threadsafe(self._loop, self.async_all).result()
+ return run_callback_threadsafe(
+ self._loop, self.async_all, domain_filter
+ ).result()
@callback
- def async_all(self) -> List[State]:
- """Create a list of all states.
+ def async_all(
+ self, domain_filter: Optional[Union[str, Iterable]] = None
+ ) -> List[State]:
+ """Create a list of all states matching the filter.
This method must be run in the event loop.
"""
- return list(self._states.values())
+ if domain_filter is None:
+ return list(self._states.values())
+
+ if isinstance(domain_filter, str):
+ domain_filter = (domain_filter.lower(),)
+
+ return [
+ state for state in self._states.values() if state.domain in domain_filter
+ ]
def get(self, entity_id: str) -> Optional[State]:
"""Retrieve state of entity_id or None if not found.
@@ -1383,6 +1390,9 @@ class Config:
# List of allowed external URLs that integrations may use
self.allowlist_external_urls: Set[str] = set()
+ # Dictionary of Media folders that integrations may use
+ self.media_dirs: Dict[str, str] = {}
+
# If Home Assistant is running in safe mode
self.safe_mode: bool = False
@@ -1488,6 +1498,7 @@ class Config:
unit_system: Optional[str] = None,
location_name: Optional[str] = None,
time_zone: Optional[str] = None,
+ # pylint: disable=dangerous-default-value # _UNDEFs not modified
external_url: Optional[Union[str, dict]] = _UNDEF,
internal_url: Optional[Union[str, dict]] = _UNDEF,
) -> None:
diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py
index 49195e1a89a..b553c190f55 100644
--- a/homeassistant/data_entry_flow.py
+++ b/homeassistant/data_entry_flow.py
@@ -51,7 +51,10 @@ class AbortFlow(FlowError):
class FlowManager(abc.ABC):
"""Manage all the flows that are in progress."""
- def __init__(self, hass: HomeAssistant,) -> None:
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ ) -> None:
"""Initialize the flow manager."""
self.hass = hass
self._initializing: Dict[str, List[asyncio.Future]] = {}
diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py
index d085c1a9021..44587fec043 100644
--- a/homeassistant/exceptions.py
+++ b/homeassistant/exceptions.py
@@ -54,6 +54,10 @@ class Unauthorized(HomeAssistantError):
"""Unauthorized error."""
super().__init__(self.__class__.__name__)
self.context = context
+
+ if user_id is None and context is not None:
+ user_id = context.user_id
+
self.user_id = user_id
self.entity_id = entity_id
self.config_entry_id = config_entry_id
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 3b4216377e5..86d778db825 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -27,6 +27,7 @@ FLOWS = [
"blink",
"bond",
"braviatv",
+ "broadlink",
"brother",
"bsblan",
"cast",
@@ -44,6 +45,7 @@ FLOWS = [
"doorbird",
"dunehd",
"dynalite",
+ "eafm",
"ecobee",
"elgato",
"elkm1",
@@ -51,6 +53,7 @@ FLOWS = [
"enocean",
"esphome",
"flick_electric",
+ "flo",
"flume",
"flunearyou",
"forked_daapd",
@@ -83,6 +86,7 @@ FLOWS = [
"iaqualink",
"icloud",
"ifttt",
+ "insteon",
"ios",
"ipma",
"ipp",
@@ -91,6 +95,7 @@ FLOWS = [
"isy994",
"izone",
"juicenet",
+ "kodi",
"konnected",
"life360",
"lifx",
@@ -114,13 +119,16 @@ FLOWS = [
"nest",
"netatmo",
"nexia",
+ "nightscout",
"notion",
"nuheat",
"nut",
"nws",
+ "nzbget",
"onvif",
"opentherm_gw",
"openuv",
+ "openweathermap",
"ovo_energy",
"owntracks",
"ozw",
@@ -133,19 +141,25 @@ FLOWS = [
"point",
"poolsense",
"powerwall",
+ "progettihwsw",
"ps4",
"pvpc_hourly_pricing",
"rachio",
"rainmachine",
"ring",
+ "risco",
"roku",
"roomba",
+ "roon",
"samsungtv",
"sense",
"sentry",
+ "sharkiq",
+ "shelly",
"shopping_list",
"simplisafe",
"smappee",
+ "smart_meter_texas",
"smarthab",
"smartthings",
"smhi",
@@ -189,11 +203,13 @@ FLOWS = [
"volumio",
"wemo",
"wiffi",
+ "wilight",
"withings",
"wled",
"wolflink",
"xiaomi_aqara",
"xiaomi_miio",
+ "yeelight",
"zerproc",
"zha",
"zwave"
diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py
index d58842fe88e..f66c5f0999d 100644
--- a/homeassistant/generated/ssdp.py
+++ b/homeassistant/generated/ssdp.py
@@ -172,5 +172,10 @@ SSDP = {
{
"manufacturer": "Belkin International Inc."
}
+ ],
+ "wilight": [
+ {
+ "manufacturer": "All Automacao Ltda"
+ }
]
}
diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py
index a61444a42c0..ba49666ded3 100644
--- a/homeassistant/generated/zeroconf.py
+++ b/homeassistant/generated/zeroconf.py
@@ -7,63 +7,137 @@ To update, run python3 -m script.hassfest
ZEROCONF = {
"_Volumio._tcp.local.": [
- "volumio"
+ {
+ "domain": "volumio"
+ }
],
"_api._udp.local.": [
- "guardian"
+ {
+ "domain": "guardian"
+ }
],
"_axis-video._tcp.local.": [
- "axis",
- "doorbird"
+ {
+ "domain": "axis",
+ "macaddress": "00408C*"
+ },
+ {
+ "domain": "axis",
+ "macaddress": "ACCC8E*"
+ },
+ {
+ "domain": "axis",
+ "macaddress": "B8A44F*"
+ },
+ {
+ "domain": "doorbird",
+ "macaddress": "1CCAE3*"
+ }
],
"_bond._tcp.local.": [
- "bond"
+ {
+ "domain": "bond"
+ }
],
"_daap._tcp.local.": [
- "forked_daapd"
+ {
+ "domain": "forked_daapd"
+ }
],
"_dkapi._tcp.local.": [
- "daikin"
+ {
+ "domain": "daikin"
+ }
],
"_elg._tcp.local.": [
- "elgato"
+ {
+ "domain": "elgato"
+ }
],
"_esphomelib._tcp.local.": [
- "esphome"
+ {
+ "domain": "esphome"
+ }
],
"_googlecast._tcp.local.": [
- "cast"
+ {
+ "domain": "cast"
+ }
],
"_hap._tcp.local.": [
- "homekit_controller"
+ {
+ "domain": "homekit_controller"
+ }
+ ],
+ "_http._tcp.local.": [
+ {
+ "domain": "shelly",
+ "name": "shelly*"
+ }
],
"_ipp._tcp.local.": [
- "ipp"
+ {
+ "domain": "ipp"
+ }
],
"_ipps._tcp.local.": [
- "ipp"
+ {
+ "domain": "ipp"
+ }
],
"_miio._udp.local.": [
- "xiaomi_aqara",
- "xiaomi_miio"
+ {
+ "domain": "xiaomi_aqara"
+ },
+ {
+ "domain": "xiaomi_miio"
+ }
],
"_nut._tcp.local.": [
- "nut"
+ {
+ "domain": "nut"
+ }
],
"_plugwise._tcp.local.": [
- "plugwise"
+ {
+ "domain": "plugwise"
+ }
],
"_printer._tcp.local.": [
- "brother"
+ {
+ "domain": "brother",
+ "name": "brother*"
+ }
],
"_spotify-connect._tcp.local.": [
- "spotify"
+ {
+ "domain": "spotify"
+ }
+ ],
+ "_ssh._tcp.local.": [
+ {
+ "domain": "smappee",
+ "name": "smappee1*"
+ },
+ {
+ "domain": "smappee",
+ "name": "smappee2*"
+ }
],
"_viziocast._tcp.local.": [
- "vizio"
+ {
+ "domain": "vizio"
+ }
],
"_wled._tcp.local.": [
- "wled"
+ {
+ "domain": "wled"
+ }
+ ],
+ "_xbmc-jsonrpc-h._tcp.local.": [
+ {
+ "domain": "kodi"
+ }
]
}
@@ -79,8 +153,10 @@ HOMEKIT = {
"PowerView": "hunterdouglas_powerview",
"Presence": "netatmo",
"Rachio": "rachio",
+ "Socket": "wemo",
"TRADFRI": "tradfri",
"Welcome": "netatmo",
"Wemo": "wemo",
+ "iSmartGate": "gogogate2",
"tado": "tado"
}
diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py
index 316c02a82f0..d20e34cca36 100644
--- a/homeassistant/helpers/aiohttp_client.py
+++ b/homeassistant/helpers/aiohttp_client.py
@@ -68,7 +68,9 @@ def async_create_clientsession(
connector = _async_get_connector(hass, verify_ssl)
clientsession = aiohttp.ClientSession(
- connector=connector, headers={USER_AGENT: SERVER_SOFTWARE}, **kwargs,
+ connector=connector,
+ headers={USER_AGENT: SERVER_SOFTWARE},
+ **kwargs,
)
clientsession.close = warn_use( # type: ignore
diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py
index 5def290766f..3e3a3e03f6a 100644
--- a/homeassistant/helpers/area_registry.py
+++ b/homeassistant/helpers/area_registry.py
@@ -3,12 +3,12 @@ from asyncio import Event
from collections import OrderedDict
import logging
from typing import Dict, Iterable, List, MutableMapping, Optional, cast
-import uuid
import attr
from homeassistant.core import callback
from homeassistant.loader import bind_hass
+import homeassistant.util.uuid as uuid_util
from .typing import HomeAssistantType
@@ -26,7 +26,7 @@ class AreaEntry:
"""Area Registry Entry."""
name: Optional[str] = attr.ib(default=None)
- id: str = attr.ib(factory=lambda: uuid.uuid4().hex)
+ id: str = attr.ib(factory=uuid_util.uuid_v1mc_hex)
class AreaRegistry:
diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py
index 06c86d3aa1c..9e7c6061987 100644
--- a/homeassistant/helpers/collection.py
+++ b/homeassistant/helpers/collection.py
@@ -353,7 +353,13 @@ class StorageCollectionWebsocket:
return f"{self.model_name}_id"
@callback
- def async_setup(self, hass: HomeAssistant, *, create_list: bool = True) -> None:
+ def async_setup(
+ self,
+ hass: HomeAssistant,
+ *,
+ create_list: bool = True,
+ create_create: bool = True,
+ ) -> None:
"""Set up the websocket commands."""
if create_list:
websocket_api.async_register_command(
@@ -365,19 +371,20 @@ class StorageCollectionWebsocket:
),
)
- websocket_api.async_register_command(
- hass,
- f"{self.api_prefix}/create",
- websocket_api.require_admin(
- websocket_api.async_response(self.ws_create_item)
- ),
- websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
- {
- **self.create_schema,
- vol.Required("type"): f"{self.api_prefix}/create",
- }
- ),
- )
+ if create_create:
+ websocket_api.async_register_command(
+ hass,
+ f"{self.api_prefix}/create",
+ websocket_api.require_admin(
+ websocket_api.async_response(self.ws_create_item)
+ ),
+ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend(
+ {
+ **self.create_schema,
+ vol.Required("type"): f"{self.api_prefix}/create",
+ }
+ ),
+ )
websocket_api.async_register_command(
hass,
diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py
index 5c7313f6716..f67b9a4b0ab 100644
--- a/homeassistant/helpers/condition.py
+++ b/homeassistant/helpers/condition.py
@@ -4,8 +4,9 @@ from collections import deque
from datetime import datetime, timedelta
import functools as ft
import logging
+import re
import sys
-from typing import Callable, Container, List, Optional, Set, Union, cast
+from typing import Any, Callable, Container, List, Optional, Set, Union, cast
from homeassistant.components import zone as zone_cmp
from homeassistant.components.device_automation import (
@@ -17,6 +18,7 @@ from homeassistant.const import (
ATTR_LONGITUDE,
CONF_ABOVE,
CONF_AFTER,
+ CONF_ATTRIBUTE,
CONF_BEFORE,
CONF_BELOW,
CONF_CONDITION,
@@ -47,30 +49,38 @@ ASYNC_FROM_CONFIG_FORMAT = "async_{}_from_config"
_LOGGER = logging.getLogger(__name__)
+INPUT_ENTITY_ID = re.compile(
+ r"^input_(?:select|text|number|boolean|datetime)\.(?!.+__)(?!_)[\da-z_]+(? ConditionCheckerType:
"""Turn a condition configuration into a method.
Should be run on the event loop.
"""
+ if isinstance(config, Template):
+ # We got a condition template, wrap it in a configuration to pass along.
+ config = {
+ CONF_CONDITION: "template",
+ CONF_VALUE_TEMPLATE: config,
+ }
+
+ condition = config.get(CONF_CONDITION)
for fmt in (ASYNC_FROM_CONFIG_FORMAT, FROM_CONFIG_FORMAT):
- factory = getattr(
- sys.modules[__name__], fmt.format(config.get(CONF_CONDITION)), None
- )
+ factory = getattr(sys.modules[__name__], fmt.format(condition), None)
if factory:
break
if factory is None:
- raise HomeAssistantError(
- 'Invalid condition "{}" specified {}'.format(
- config.get(CONF_CONDITION), config
- )
- )
+ raise HomeAssistantError(f'Invalid condition "{condition}" specified {config}')
# Check for partials to properly determine if coroutine function
check_factory = factory
@@ -166,8 +176,8 @@ async def async_not_from_config(
def numeric_state(
hass: HomeAssistant,
entity: Union[None, str, State],
- below: Optional[float] = None,
- above: Optional[float] = None,
+ below: Optional[Union[float, str]] = None,
+ above: Optional[Union[float, str]] = None,
value_template: Optional[Template] = None,
variables: TemplateVarsType = None,
) -> bool:
@@ -187,20 +197,25 @@ def numeric_state(
def async_numeric_state(
hass: HomeAssistant,
entity: Union[None, str, State],
- below: Optional[float] = None,
- above: Optional[float] = None,
+ below: Optional[Union[float, str]] = None,
+ above: Optional[Union[float, str]] = None,
value_template: Optional[Template] = None,
variables: TemplateVarsType = None,
+ attribute: Optional[str] = None,
) -> bool:
"""Test a numeric state condition."""
if isinstance(entity, str):
entity = hass.states.get(entity)
- if entity is None:
+ if entity is None or (attribute is not None and attribute not in entity.attributes):
return False
+ value: Any = None
if value_template is None:
- value = entity.state
+ if attribute is None:
+ value = entity.state
+ else:
+ value = entity.attributes.get(attribute)
else:
variables = dict(variables or {})
variables["state"] = entity
@@ -223,11 +238,29 @@ def async_numeric_state(
)
return False
- if below is not None and fvalue >= below:
- return False
+ if below is not None:
+ if isinstance(below, str):
+ below_entity = hass.states.get(below)
+ if (
+ not below_entity
+ or below_entity.state in (STATE_UNAVAILABLE, STATE_UNKNOWN)
+ or fvalue >= float(below_entity.state)
+ ):
+ return False
+ elif fvalue >= below:
+ return False
- if above is not None and fvalue <= above:
- return False
+ if above is not None:
+ if isinstance(above, str):
+ above_entity = hass.states.get(above)
+ if (
+ not above_entity
+ or above_entity.state in (STATE_UNAVAILABLE, STATE_UNKNOWN)
+ or fvalue <= float(above_entity.state)
+ ):
+ return False
+ elif fvalue <= above:
+ return False
return True
@@ -239,6 +272,7 @@ def async_numeric_state_from_config(
if config_validation:
config = cv.NUMERIC_STATE_CONDITION_SCHEMA(config)
entity_ids = config.get(CONF_ENTITY_ID, [])
+ attribute = config.get(CONF_ATTRIBUTE)
below = config.get(CONF_BELOW)
above = config.get(CONF_ABOVE)
value_template = config.get(CONF_VALUE_TEMPLATE)
@@ -252,7 +286,7 @@ def async_numeric_state_from_config(
return all(
async_numeric_state(
- hass, entity_id, below, above, value_template, variables
+ hass, entity_id, below, above, value_template, variables, attribute
)
for entity_id in entity_ids
)
@@ -265,6 +299,7 @@ def state(
entity: Union[None, str, State],
req_state: Union[str, List[str]],
for_period: Optional[timedelta] = None,
+ attribute: Optional[str] = None,
) -> bool:
"""Test if state matches requirements.
@@ -273,14 +308,30 @@ def state(
if isinstance(entity, str):
entity = hass.states.get(entity)
- if entity is None:
+ if entity is None or (attribute is not None and attribute not in entity.attributes):
return False
+
assert isinstance(entity, State)
+ if attribute is None:
+ value = entity.state
+ else:
+ value = str(entity.attributes.get(attribute))
+
if isinstance(req_state, str):
req_state = [req_state]
- is_state = entity.state in req_state
+ is_state = False
+ for req_state_value in req_state:
+ state_value = req_state_value
+ if INPUT_ENTITY_ID.match(req_state_value) is not None:
+ state_entity = hass.states.get(req_state_value)
+ if not state_entity:
+ continue
+ state_value = state_entity.state
+ is_state = value == state_value
+ if is_state:
+ break
if for_period is None or not is_state:
return is_state
@@ -297,6 +348,7 @@ def state_from_config(
entity_ids = config.get(CONF_ENTITY_ID, [])
req_states: Union[str, List[str]] = config.get(CONF_STATE, [])
for_period = config.get("for")
+ attribute = config.get(CONF_ATTRIBUTE)
if not isinstance(req_states, list):
req_states = [req_states]
@@ -304,7 +356,8 @@ def state_from_config(
def if_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
"""Test if condition."""
return all(
- state(hass, entity_id, req_states, for_period) for entity_id in entity_ids
+ state(hass, entity_id, req_states, for_period, attribute)
+ for entity_id in entity_ids
)
return if_state
@@ -423,8 +476,9 @@ def async_template_from_config(
def time(
- before: Optional[dt_util.dt.time] = None,
- after: Optional[dt_util.dt.time] = None,
+ hass: HomeAssistant,
+ before: Optional[Union[dt_util.dt.time, str]] = None,
+ after: Optional[Union[dt_util.dt.time, str]] = None,
weekday: Union[None, str, Container[str]] = None,
) -> bool:
"""Test if local time condition matches.
@@ -439,8 +493,28 @@ def time(
if after is None:
after = dt_util.dt.time(0)
+ elif isinstance(after, str):
+ after_entity = hass.states.get(after)
+ if not after_entity:
+ return False
+ after = dt_util.dt.time(
+ after_entity.attributes.get("hour", 23),
+ after_entity.attributes.get("minute", 59),
+ after_entity.attributes.get("second", 59),
+ )
+
if before is None:
before = dt_util.dt.time(23, 59, 59, 999999)
+ elif isinstance(before, str):
+ before_entity = hass.states.get(before)
+ if not before_entity:
+ return False
+ before = dt_util.dt.time(
+ before_entity.attributes.get("hour", 23),
+ before_entity.attributes.get("minute", 59),
+ before_entity.attributes.get("second", 59),
+ 999999,
+ )
if after < before:
if not after <= now_time < before:
@@ -474,7 +548,7 @@ def time_from_config(
def time_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
"""Validate time based if-condition."""
- return time(before, after, weekday)
+ return time(hass, before, after, weekday)
return time_if
@@ -549,9 +623,12 @@ async def async_device_from_config(
async def async_validate_condition_config(
- hass: HomeAssistant, config: ConfigType
-) -> ConfigType:
+ hass: HomeAssistant, config: Union[ConfigType, Template]
+) -> Union[ConfigType, Template]:
"""Validate config."""
+ if isinstance(config, Template):
+ return config
+
condition = config[CONF_CONDITION]
if condition in ("and", "not", "or"):
conditions = []
@@ -562,6 +639,7 @@ async def async_validate_condition_config(
if condition == "device":
config = cv.DEVICE_CONDITION_SCHEMA(config)
+ assert not isinstance(config, Template)
platform = await async_get_device_automation_platform(
hass, config[CONF_DOMAIN], "condition"
)
@@ -571,13 +649,16 @@ async def async_validate_condition_config(
@callback
-def async_extract_entities(config: ConfigType) -> Set[str]:
+def async_extract_entities(config: Union[ConfigType, Template]) -> Set[str]:
"""Extract entities from a condition."""
referenced: Set[str] = set()
to_process = deque([config])
while to_process:
config = to_process.popleft()
+ if isinstance(config, Template):
+ continue
+
condition = config[CONF_CONDITION]
if condition in ("and", "not", "or"):
@@ -596,13 +677,16 @@ def async_extract_entities(config: ConfigType) -> Set[str]:
@callback
-def async_extract_devices(config: ConfigType) -> Set[str]:
+def async_extract_devices(config: Union[ConfigType, Template]) -> Set[str]:
"""Extract devices from a condition."""
referenced = set()
to_process = deque([config])
while to_process:
config = to_process.popleft()
+ if isinstance(config, Template):
+ continue
+
condition = config[CONF_CONDITION]
if condition in ("and", "not", "or"):
diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py
index d349820978e..2b967286b95 100644
--- a/homeassistant/helpers/config_entry_flow.py
+++ b/homeassistant/helpers/config_entry_flow.py
@@ -5,8 +5,6 @@ from homeassistant import config_entries
from .typing import HomeAssistantType
-# mypy: allow-untyped-defs, no-check-untyped-defs
-
DiscoveryFunctionType = Callable[[], Union[Awaitable[bool], bool]]
diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py
index acaa0e52ab1..55ec3984b82 100644
--- a/homeassistant/helpers/config_entry_oauth2_flow.py
+++ b/homeassistant/helpers/config_entry_oauth2_flow.py
@@ -21,10 +21,12 @@ from yarl import URL
from homeassistant import config_entries
from homeassistant.components.http import HomeAssistantView
from homeassistant.core import HomeAssistant, callback
-from homeassistant.helpers.network import get_url
+from homeassistant.helpers.network import NoURLAvailableError, get_url
from .aiohttp_client import async_get_clientsession
+_LOGGER = logging.getLogger(__name__)
+
DATA_JWT_SECRET = "oauth2_jwt_secret"
DATA_VIEW_REGISTERED = "oauth2_view_reg"
DATA_IMPLEMENTATIONS = "oauth2_impl"
@@ -77,6 +79,8 @@ class AbstractOAuth2Implementation(ABC):
async def async_refresh_token(self, token: dict) -> dict:
"""Refresh a token and update expires info."""
new_token = await self._async_refresh_token(token)
+ # Force int for non-compliant oauth2 providers
+ new_token["expires_in"] = int(new_token["expires_in"])
new_token["expires_at"] = time.time() + new_token["expires_in"]
return new_token
@@ -118,7 +122,7 @@ class LocalOAuth2Implementation(AbstractOAuth2Implementation):
@property
def redirect_uri(self) -> str:
"""Return the redirect uri."""
- return f"{get_url(self.hass)}{AUTH_CALLBACK_PATH}"
+ return f"{get_url(self.hass, require_current_request=True)}{AUTH_CALLBACK_PATH}"
@property
def extra_authorize_data(self) -> dict:
@@ -247,6 +251,13 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta):
url = await self.flow_impl.async_generate_authorize_url(self.flow_id)
except asyncio.TimeoutError:
return self.async_abort(reason="authorize_url_timeout")
+ except NoURLAvailableError:
+ return self.async_abort(
+ reason="no_url_available",
+ description_placeholders={
+ "docs_url": "https://www.home-assistant.io/more-info/no-url-available"
+ },
+ )
url = str(URL(url).update_query(self.extra_authorize_data))
@@ -257,6 +268,12 @@ class AbstractOAuth2FlowHandler(config_entries.ConfigFlow, metaclass=ABCMeta):
) -> Dict[str, Any]:
"""Create config entry from external data."""
token = await self.flow_impl.async_resolve_external_data(self.external_data)
+ # Force int for non-compliant oauth2 providers
+ try:
+ token["expires_in"] = int(token["expires_in"])
+ except ValueError as err:
+ _LOGGER.warning("Error converting expires_in to int: %s", err)
+ return self.async_abort(reason="oauth_error")
token["expires_at"] = time.time() + token["expires_in"]
self.logger.info("Successfully authenticated")
diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py
index e5b113f8a4d..282e63e6440 100644
--- a/homeassistant/helpers/config_validation.py
+++ b/homeassistant/helpers/config_validation.py
@@ -37,6 +37,7 @@ from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_ABOVE,
CONF_ALIAS,
+ CONF_ATTRIBUTE,
CONF_BELOW,
CONF_CHOOSE,
CONF_CONDITION,
@@ -66,6 +67,8 @@ from homeassistant.const import (
CONF_UNIT_SYSTEM_METRIC,
CONF_UNTIL,
CONF_VALUE_TEMPLATE,
+ CONF_VARIABLES,
+ CONF_WAIT_FOR_TRIGGER,
CONF_WAIT_TEMPLATE,
CONF_WHILE,
ENTITY_MATCH_ALL,
@@ -79,7 +82,10 @@ from homeassistant.const import (
)
from homeassistant.core import split_entity_id, valid_entity_id
from homeassistant.exceptions import TemplateError
-from homeassistant.helpers import template as template_helper
+from homeassistant.helpers import (
+ script_variables as script_variables_helper,
+ template as template_helper,
+)
from homeassistant.helpers.logging import KeywordStyleAdapter
from homeassistant.util import slugify as util_slugify
import homeassistant.util.dt as dt_util
@@ -155,13 +161,24 @@ def boolean(value: Any) -> bool:
raise vol.Invalid(f"invalid boolean value {value}")
+_WS = re.compile("\\s*")
+
+
+def whitespace(value: Any) -> str:
+ """Validate result contains only whitespace."""
+ if isinstance(value, str) and _WS.fullmatch(value):
+ return value
+
+ raise vol.Invalid(f"contains non-whitespace: {value}")
+
+
def isdevice(value: Any) -> str:
"""Validate that value is a real device."""
try:
os.stat(value)
return str(value)
- except OSError:
- raise vol.Invalid(f"No device at {value} found")
+ except OSError as err:
+ raise vol.Invalid(f"No device at {value} found") from err
def matches_regex(regex: str) -> Callable[[Any], str]:
@@ -188,12 +205,12 @@ def is_regex(value: Any) -> Pattern[Any]:
try:
r = re.compile(value)
return r
- except TypeError:
+ except TypeError as err:
raise vol.Invalid(
f"value {value} is of the wrong type for a regular expression"
- )
- except re.error:
- raise vol.Invalid(f"value {value} is not a valid regular expression")
+ ) from err
+ except re.error as err:
+ raise vol.Invalid(f"value {value} is not a valid regular expression") from err
def isfile(value: Any) -> str:
@@ -318,8 +335,8 @@ def time(value: Any) -> time_sys:
try:
time_val = dt_util.parse_time(value)
- except TypeError:
- raise vol.Invalid("Not a parseable type")
+ except TypeError as err:
+ raise vol.Invalid("Not a parseable type") from err
if time_val is None:
raise vol.Invalid(f"Invalid time specified: {value}")
@@ -334,8 +351,8 @@ def date(value: Any) -> date_sys:
try:
date_val = dt_util.parse_date(value)
- except TypeError:
- raise vol.Invalid("Not a parseable type")
+ except TypeError as err:
+ raise vol.Invalid("Not a parseable type") from err
if date_val is None:
raise vol.Invalid("Could not parse date")
@@ -367,8 +384,8 @@ def time_period_str(value: str) -> timedelta:
second = float(parsed[2])
except IndexError:
second = 0
- except ValueError:
- raise vol.Invalid(TIME_PERIOD_ERROR.format(value))
+ except ValueError as err:
+ raise vol.Invalid(TIME_PERIOD_ERROR.format(value)) from err
offset = timedelta(hours=hour, minutes=minute, seconds=second)
@@ -382,8 +399,8 @@ def time_period_seconds(value: Union[float, str]) -> timedelta:
"""Validate and transform seconds to a time offset."""
try:
return timedelta(seconds=float(value))
- except (ValueError, TypeError):
- raise vol.Invalid(f"Expected seconds, got {value}")
+ except (ValueError, TypeError) as err:
+ raise vol.Invalid(f"Expected seconds, got {value}") from err
time_period = vol.Any(time_period_str, time_period_seconds, timedelta, time_period_dict)
@@ -402,6 +419,7 @@ def positive_timedelta(value: timedelta) -> timedelta:
positive_time_period_dict = vol.All(time_period_dict, positive_timedelta)
+positive_time_period = vol.All(time_period, positive_timedelta)
def remove_falsy(value: List[T]) -> List[T]:
@@ -415,6 +433,7 @@ def service(value: Any) -> str:
str_value = string(value).lower()
if valid_entity_id(str_value):
return str_value
+
raise vol.Invalid(f"Service {value} does not match format .")
@@ -510,7 +529,25 @@ def template(value: Optional[Any]) -> template_helper.Template:
template_value.ensure_valid()
return cast(template_helper.Template, template_value)
except TemplateError as ex:
- raise vol.Invalid(f"invalid template ({ex})")
+ raise vol.Invalid(f"invalid template ({ex})") from ex
+
+
+def dynamic_template(value: Optional[Any]) -> template_helper.Template:
+ """Validate a dynamic (non static) jinja2 template."""
+
+ if value is None:
+ raise vol.Invalid("template value is None")
+ if isinstance(value, (list, dict, template_helper.Template)):
+ raise vol.Invalid("template value should be a string")
+ if not template_helper.is_template_string(str(value)):
+ raise vol.Invalid("template value does not contain a dynmamic template")
+
+ template_value = template_helper.Template(str(value)) # type: ignore
+ try:
+ template_value.ensure_valid()
+ return cast(template_helper.Template, template_value)
+ except TemplateError as ex:
+ raise vol.Invalid(f"invalid template ({ex})") from ex
def template_complex(value: Any) -> Any:
@@ -521,15 +558,21 @@ def template_complex(value: Any) -> Any:
return_list[idx] = template_complex(element)
return return_list
if isinstance(value, dict):
- return_dict = value.copy()
- for key, element in return_dict.items():
- return_dict[key] = template_complex(element)
- return return_dict
- if isinstance(value, str):
+ return {
+ template_complex(key): template_complex(element)
+ for key, element in value.items()
+ }
+ if isinstance(value, str) and template_helper.is_template_string(value):
return template(value)
+
return value
+positive_time_period_template = vol.Any(
+ positive_time_period, template, template_complex
+)
+
+
def datetime(value: Any) -> datetime_sys:
"""Validate datetime."""
if isinstance(value, datetime_sys):
@@ -824,6 +867,13 @@ def make_entity_service_schema(
)
+SCRIPT_VARIABLES_SCHEMA = vol.All(
+ vol.Schema({str: template_complex}),
+ # pylint: disable=unnecessary-lambda
+ lambda val: script_variables_helper.ScriptVariables(val),
+)
+
+
def script_action(value: Any) -> dict:
"""Validate a script action."""
if not isinstance(value, dict):
@@ -838,8 +888,8 @@ EVENT_SCHEMA = vol.Schema(
{
vol.Optional(CONF_ALIAS): string,
vol.Required(CONF_EVENT): string,
- vol.Optional(CONF_EVENT_DATA): dict,
- vol.Optional(CONF_EVENT_DATA_TEMPLATE): {match_all: template_complex},
+ vol.Optional(CONF_EVENT_DATA): vol.All(dict, template_complex),
+ vol.Optional(CONF_EVENT_DATA_TEMPLATE): vol.All(dict, template_complex),
}
)
@@ -847,10 +897,14 @@ SERVICE_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(CONF_ALIAS): string,
- vol.Exclusive(CONF_SERVICE, "service name"): service,
- vol.Exclusive(CONF_SERVICE_TEMPLATE, "service name"): template,
- vol.Optional("data"): dict,
- vol.Optional("data_template"): {match_all: template_complex},
+ vol.Exclusive(CONF_SERVICE, "service name"): vol.Any(
+ service, dynamic_template
+ ),
+ vol.Exclusive(CONF_SERVICE_TEMPLATE, "service name"): vol.Any(
+ service, dynamic_template
+ ),
+ vol.Optional("data"): vol.All(dict, template_complex),
+ vol.Optional("data_template"): vol.All(dict, template_complex),
vol.Optional(CONF_ENTITY_ID): comp_entity_ids,
}
),
@@ -862,8 +916,13 @@ NUMERIC_STATE_CONDITION_SCHEMA = vol.All(
{
vol.Required(CONF_CONDITION): "numeric_state",
vol.Required(CONF_ENTITY_ID): entity_ids,
- CONF_BELOW: vol.Coerce(float),
- CONF_ABOVE: vol.Coerce(float),
+ vol.Optional(CONF_ATTRIBUTE): str,
+ CONF_BELOW: vol.Any(
+ vol.Coerce(float), vol.All(str, entity_domain("input_number"))
+ ),
+ CONF_ABOVE: vol.Any(
+ vol.Coerce(float), vol.All(str, entity_domain("input_number"))
+ ),
vol.Optional(CONF_VALUE_TEMPLATE): template,
}
),
@@ -875,8 +934,9 @@ STATE_CONDITION_SCHEMA = vol.All(
{
vol.Required(CONF_CONDITION): "state",
vol.Required(CONF_ENTITY_ID): entity_ids,
+ vol.Optional(CONF_ATTRIBUTE): str,
vol.Required(CONF_STATE): vol.Any(str, [str]),
- vol.Optional(CONF_FOR): vol.All(time_period, positive_timedelta),
+ vol.Optional(CONF_FOR): positive_time_period,
# To support use_trigger_value in automation
# Deprecated 2016/04/25
vol.Optional("from"): str,
@@ -911,8 +971,8 @@ TIME_CONDITION_SCHEMA = vol.All(
vol.Schema(
{
vol.Required(CONF_CONDITION): "time",
- "before": time,
- "after": time,
+ "before": vol.Any(time, vol.All(str, entity_domain("input_datetime"))),
+ "after": vol.Any(time, vol.All(str, entity_domain("input_datetime"))),
"weekday": weekdays,
}
),
@@ -973,28 +1033,35 @@ DEVICE_CONDITION_BASE_SCHEMA = vol.Schema(
DEVICE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA)
-CONDITION_SCHEMA: vol.Schema = key_value_schemas(
- CONF_CONDITION,
- {
- "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA,
- "state": STATE_CONDITION_SCHEMA,
- "sun": SUN_CONDITION_SCHEMA,
- "template": TEMPLATE_CONDITION_SCHEMA,
- "time": TIME_CONDITION_SCHEMA,
- "zone": ZONE_CONDITION_SCHEMA,
- "and": AND_CONDITION_SCHEMA,
- "or": OR_CONDITION_SCHEMA,
- "not": NOT_CONDITION_SCHEMA,
- "device": DEVICE_CONDITION_SCHEMA,
- },
+CONDITION_SCHEMA: vol.Schema = vol.Schema(
+ vol.Any(
+ key_value_schemas(
+ CONF_CONDITION,
+ {
+ "numeric_state": NUMERIC_STATE_CONDITION_SCHEMA,
+ "state": STATE_CONDITION_SCHEMA,
+ "sun": SUN_CONDITION_SCHEMA,
+ "template": TEMPLATE_CONDITION_SCHEMA,
+ "time": TIME_CONDITION_SCHEMA,
+ "zone": ZONE_CONDITION_SCHEMA,
+ "and": AND_CONDITION_SCHEMA,
+ "or": OR_CONDITION_SCHEMA,
+ "not": NOT_CONDITION_SCHEMA,
+ "device": DEVICE_CONDITION_SCHEMA,
+ },
+ ),
+ dynamic_template,
+ )
+)
+
+TRIGGER_SCHEMA = vol.All(
+ ensure_list, [vol.Schema({vol.Required(CONF_PLATFORM): str}, extra=vol.ALLOW_EXTRA)]
)
_SCRIPT_DELAY_SCHEMA = vol.Schema(
{
vol.Optional(CONF_ALIAS): string,
- vol.Required(CONF_DELAY): vol.Any(
- vol.All(time_period, positive_timedelta), template, template_complex
- ),
+ vol.Required(CONF_DELAY): positive_time_period_template,
}
)
@@ -1002,7 +1069,7 @@ _SCRIPT_WAIT_TEMPLATE_SCHEMA = vol.Schema(
{
vol.Optional(CONF_ALIAS): string,
vol.Required(CONF_WAIT_TEMPLATE): template,
- vol.Optional(CONF_TIMEOUT): vol.All(time_period, positive_timedelta),
+ vol.Optional(CONF_TIMEOUT): positive_time_period_template,
vol.Optional(CONF_CONTINUE_ON_TIMEOUT): boolean,
}
)
@@ -1052,6 +1119,22 @@ _SCRIPT_CHOOSE_SCHEMA = vol.Schema(
}
)
+_SCRIPT_WAIT_FOR_TRIGGER_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_ALIAS): string,
+ vol.Required(CONF_WAIT_FOR_TRIGGER): TRIGGER_SCHEMA,
+ vol.Optional(CONF_TIMEOUT): positive_time_period_template,
+ vol.Optional(CONF_CONTINUE_ON_TIMEOUT): boolean,
+ }
+)
+
+_SCRIPT_SET_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_ALIAS): string,
+ vol.Required(CONF_VARIABLES): SCRIPT_VARIABLES_SCHEMA,
+ }
+)
+
SCRIPT_ACTION_DELAY = "delay"
SCRIPT_ACTION_WAIT_TEMPLATE = "wait_template"
SCRIPT_ACTION_CHECK_CONDITION = "condition"
@@ -1061,6 +1144,8 @@ SCRIPT_ACTION_DEVICE_AUTOMATION = "device"
SCRIPT_ACTION_ACTIVATE_SCENE = "scene"
SCRIPT_ACTION_REPEAT = "repeat"
SCRIPT_ACTION_CHOOSE = "choose"
+SCRIPT_ACTION_WAIT_FOR_TRIGGER = "wait_for_trigger"
+SCRIPT_ACTION_VARIABLES = "variables"
def determine_script_action(action: dict) -> str:
@@ -1089,6 +1174,12 @@ def determine_script_action(action: dict) -> str:
if CONF_CHOOSE in action:
return SCRIPT_ACTION_CHOOSE
+ if CONF_WAIT_FOR_TRIGGER in action:
+ return SCRIPT_ACTION_WAIT_FOR_TRIGGER
+
+ if CONF_VARIABLES in action:
+ return SCRIPT_ACTION_VARIABLES
+
return SCRIPT_ACTION_CALL_SERVICE
@@ -1102,4 +1193,6 @@ ACTION_TYPE_SCHEMAS: Dict[str, Callable[[Any], dict]] = {
SCRIPT_ACTION_ACTIVATE_SCENE: _SCRIPT_SCENE_SCHEMA,
SCRIPT_ACTION_REPEAT: _SCRIPT_REPEAT_SCHEMA,
SCRIPT_ACTION_CHOOSE: _SCRIPT_CHOOSE_SCHEMA,
+ SCRIPT_ACTION_WAIT_FOR_TRIGGER: _SCRIPT_WAIT_FOR_TRIGGER_SCHEMA,
+ SCRIPT_ACTION_VARIABLES: _SCRIPT_SET_SCHEMA,
}
diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py
index b2e3bfd7a32..1dff2ef9483 100644
--- a/homeassistant/helpers/device_registry.py
+++ b/homeassistant/helpers/device_registry.py
@@ -2,12 +2,12 @@
from collections import OrderedDict
import logging
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union
-import uuid
import attr
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import Event, callback
+import homeassistant.util.uuid as uuid_util
from .debounce import Debouncer
from .singleton import singleton
@@ -73,7 +73,7 @@ class DeviceEntry:
area_id: str = attr.ib(default=None)
name_by_user: str = attr.ib(default=None)
entry_type: str = attr.ib(default=None)
- id: str = attr.ib(factory=lambda: uuid.uuid4().hex)
+ id: str = attr.ib(factory=uuid_util.uuid_v1mc_hex)
# This value is not stored, just used to keep track of events to fire.
is_new: bool = attr.ib(default=False)
@@ -209,6 +209,9 @@ class DeviceRegistry:
manufacturer=_UNDEF,
model=_UNDEF,
name=_UNDEF,
+ default_manufacturer=_UNDEF,
+ default_model=_UNDEF,
+ default_name=_UNDEF,
sw_version=_UNDEF,
entry_type=_UNDEF,
via_device=None,
@@ -236,6 +239,15 @@ class DeviceRegistry:
device = deleted_device.to_device_entry()
self._add_device(device)
+ if default_manufacturer is not _UNDEF and device.manufacturer is None:
+ manufacturer = default_manufacturer
+
+ if default_model is not _UNDEF and device.model is None:
+ model = default_model
+
+ if default_name is not _UNDEF and device.name is None:
+ name = default_name
+
if via_device is not None:
via = self.async_get_device({via_device}, set())
via_device_id = via.id if via else _UNDEF
diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py
index 4fd54f4ee8a..5b3366d7554 100644
--- a/homeassistant/helpers/entity.py
+++ b/homeassistant/helpers/entity.py
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
import functools as ft
import logging
from timeit import default_timer as timer
-from typing import Any, Awaitable, Dict, Iterable, List, Optional, Union
+from typing import Any, Awaitable, Dict, Iterable, List, Optional
from homeassistant.config import DATA_CUSTOMIZE
from homeassistant.const import (
@@ -25,14 +25,26 @@ from homeassistant.const import (
TEMP_FAHRENHEIT,
)
from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback
-from homeassistant.exceptions import NoEntitySpecifiedError
+from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.event import Event, async_track_entity_registry_updated_event
+from homeassistant.helpers.typing import StateType
+from homeassistant.loader import bind_hass
from homeassistant.util import dt as dt_util, ensure_unique_string, slugify
_LOGGER = logging.getLogger(__name__)
SLOW_UPDATE_WARNING = 10
+DATA_ENTITY_SOURCE = "entity_info"
+SOURCE_CONFIG_ENTRY = "config_entry"
+SOURCE_PLATFORM_CONFIG = "platform_config"
+
+
+@callback
+@bind_hass
+def entity_sources(hass: HomeAssistant) -> Dict[str, Dict[str, str]]:
+ """Get the entity sources."""
+ return hass.data.get(DATA_ENTITY_SOURCE, {})
def generate_entity_id(
@@ -108,6 +120,9 @@ class Entity(ABC):
_context: Optional[Context] = None
_context_set: Optional[datetime] = None
+ # If entity is added to an entity platform
+ _added = False
+
@property
def should_poll(self) -> bool:
"""Return True if entity has to be polled for state.
@@ -127,7 +142,7 @@ class Entity(ABC):
return None
@property
- def state(self) -> Union[None, str, int, float]:
+ def state(self) -> StateType:
"""Return the state of the entity."""
return STATE_UNKNOWN
@@ -453,9 +468,7 @@ class Entity(ABC):
if hasattr(self, "async_update"):
await self.async_update() # type: ignore
elif hasattr(self, "update"):
- await self.hass.async_add_executor_job(
- self.update # type: ignore
- )
+ await self.hass.async_add_executor_job(self.update) # type: ignore
finally:
self._update_staged = False
if warning:
@@ -476,10 +489,49 @@ class Entity(ABC):
To be extended by integrations.
"""
+ @callback
+ def add_to_platform_start(
+ self,
+ hass: HomeAssistant,
+ platform: EntityPlatform,
+ parallel_updates: Optional[asyncio.Semaphore],
+ ) -> None:
+ """Start adding an entity to a platform."""
+ if self._added:
+ raise HomeAssistantError(
+ f"Entity {self.entity_id} cannot be added a second time to an entity platform"
+ )
+
+ self.hass = hass
+ self.platform = platform
+ self.parallel_updates = parallel_updates
+ self._added = True
+
+ @callback
+ def add_to_platform_abort(self) -> None:
+ """Abort adding an entity to a platform."""
+ self.hass = None
+ self.platform = None
+ self.parallel_updates = None
+ self._added = False
+
+ async def add_to_platform_finish(self) -> None:
+ """Finish adding an entity to a platform."""
+ await self.async_internal_added_to_hass()
+ await self.async_added_to_hass()
+ self.async_write_ha_state()
+
async def async_remove(self) -> None:
"""Remove entity from Home Assistant."""
assert self.hass is not None
+ if self.platform and not self._added:
+ raise HomeAssistantError(
+ f"Entity {self.entity_id} async_remove called twice"
+ )
+
+ self._added = False
+
if self._on_remove is not None:
while self._on_remove:
self._on_remove.pop()()
@@ -506,8 +558,25 @@ class Entity(ABC):
Not to be extended by integrations.
"""
+ assert self.hass is not None
+
+ if self.platform:
+ info = {"domain": self.platform.platform_name}
+
+ if self.platform.config_entry:
+ info["source"] = SOURCE_CONFIG_ENTRY
+ info["config_entry"] = self.platform.config_entry.entry_id
+ else:
+ info["source"] = SOURCE_PLATFORM_CONFIG
+
+ self.hass.data.setdefault(DATA_ENTITY_SOURCE, {})[self.entity_id] = info
+
if self.registry_entry is not None:
- assert self.hass is not None
+ # This is an assert as it should never happen, but helps in tests
+ assert (
+ not self.registry_entry.disabled_by
+ ), f"Entity {self.entity_id} is being added while it's disabled"
+
self.async_on_remove(
async_track_entity_registry_updated_event(
self.hass, self.entity_id, self._async_registry_updated
@@ -519,6 +588,9 @@ class Entity(ABC):
Not to be extended by integrations.
"""
+ if self.platform:
+ assert self.hass is not None
+ self.hass.data[DATA_ENTITY_SOURCE].pop(self.entity_id)
async def _async_registry_updated(self, event: Event) -> None:
"""Handle entity registry update."""
diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py
index e651d2e8cc7..b9d073dbe2e 100644
--- a/homeassistant/helpers/entity_component.py
+++ b/homeassistant/helpers/entity_component.py
@@ -106,11 +106,7 @@ class EntityComponent:
This doesn't block the executor to protect from deadlocks.
"""
- self.hass.add_job(
- self.async_setup( # type: ignore
- config
- )
- )
+ self.hass.add_job(self.async_setup(config)) # type: ignore
async def async_setup(self, config: ConfigType) -> None:
"""Set up a full entity component.
@@ -164,7 +160,7 @@ class EntityComponent:
scan_interval=getattr(platform, "SCAN_INTERVAL", None),
)
- return await self._platforms[key].async_setup_entry(config_entry) # type: ignore
+ return await self._platforms[key].async_setup_entry(config_entry)
async def async_unload_entry(self, config_entry: ConfigEntry) -> bool:
"""Unload a config entry."""
diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py
index 7d6b6b52a9b..da1a3635d72 100644
--- a/homeassistant/helpers/entity_platform.py
+++ b/homeassistant/helpers/entity_platform.py
@@ -4,10 +4,17 @@ from contextvars import ContextVar
from datetime import datetime, timedelta
from logging import Logger
from types import ModuleType
-from typing import TYPE_CHECKING, Dict, Iterable, List, Optional
+from typing import TYPE_CHECKING, Callable, Coroutine, Dict, Iterable, List, Optional
+from homeassistant import config_entries
from homeassistant.const import DEVICE_DEFAULT_NAME
-from homeassistant.core import CALLBACK_TYPE, callback, split_entity_id, valid_entity_id
+from homeassistant.core import (
+ CALLBACK_TYPE,
+ ServiceCall,
+ callback,
+ split_entity_id,
+ valid_entity_id,
+)
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers import config_validation as cv, service
from homeassistant.helpers.typing import HomeAssistantType
@@ -19,7 +26,7 @@ from .event import async_call_later, async_track_time_interval
if TYPE_CHECKING:
from .entity import Entity
-# mypy: allow-untyped-defs, no-check-untyped-defs
+# mypy: allow-untyped-defs
SLOW_SETUP_WARNING = 10
SLOW_SETUP_MAX_WAIT = 60
@@ -53,7 +60,7 @@ class EntityPlatform:
self.platform = platform
self.scan_interval = scan_interval
self.entity_namespace = entity_namespace
- self.config_entry = None
+ self.config_entry: Optional[config_entries.ConfigEntry] = None
self.entities: Dict[str, Entity] = {} # pylint: disable=used-before-assignment
self._tasks: List[asyncio.Future] = []
# Method to cancel the state change listener
@@ -119,10 +126,10 @@ class EntityPlatform:
return
@callback
- def async_create_setup_task():
+ def async_create_setup_task() -> Coroutine:
"""Get task to set up platform."""
if getattr(platform, "async_setup_platform", None):
- return platform.async_setup_platform(
+ return platform.async_setup_platform( # type: ignore
hass,
platform_config,
self._async_schedule_add_entities,
@@ -133,7 +140,7 @@ class EntityPlatform:
# we don't want to track this task in case it blocks startup.
return hass.loop.run_in_executor(
None,
- platform.setup_platform,
+ platform.setup_platform, # type: ignore
hass,
platform_config,
self._schedule_add_entities,
@@ -142,7 +149,7 @@ class EntityPlatform:
await self._async_setup_platform(async_create_setup_task)
- async def async_setup_entry(self, config_entry):
+ async def async_setup_entry(self, config_entry: config_entries.ConfigEntry) -> bool:
"""Set up the platform from a config entry."""
# Store it so that we can save config entry ID in entity registry
self.config_entry = config_entry
@@ -151,13 +158,15 @@ class EntityPlatform:
@callback
def async_create_setup_task():
"""Get task to set up platform."""
- return platform.async_setup_entry(
+ return platform.async_setup_entry( # type: ignore
self.hass, config_entry, self._async_schedule_add_entities
)
return await self._async_setup_platform(async_create_setup_task)
- async def _async_setup_platform(self, async_create_setup_task, tries=0):
+ async def _async_setup_platform(
+ self, async_create_setup_task: Callable[[], Coroutine], tries: int = 0
+ ) -> bool:
"""Set up a platform via config file or config entry.
async_create_setup_task creates a coroutine that sets up platform.
@@ -313,7 +322,9 @@ class EntityPlatform:
return
self._async_unsub_polling = async_track_time_interval(
- self.hass, self._update_entity_states, self.scan_interval,
+ self.hass,
+ self._update_entity_states,
+ self.scan_interval,
)
async def _async_add_entity(
@@ -323,10 +334,10 @@ class EntityPlatform:
if entity is None:
raise ValueError("Entity cannot be None")
- entity.hass = self.hass
- entity.platform = self
- entity.parallel_updates = self._get_parallel_updates_semaphore(
- hasattr(entity, "async_update")
+ entity.add_to_platform_start(
+ self.hass,
+ self,
+ self._get_parallel_updates_semaphore(hasattr(entity, "async_update")),
)
# Update properties before we generate the entity_id
@@ -335,12 +346,11 @@ class EntityPlatform:
await entity.async_device_update(warning=False)
except Exception: # pylint: disable=broad-except
self.logger.exception("%s: Error on device update!", self.platform_name)
- entity.hass = None
- entity.platform = None
+ entity.add_to_platform_abort()
return
requested_entity_id = None
- suggested_object_id = None
+ suggested_object_id: Optional[str] = None
# Get entity_id from unique ID registration
if entity.unique_id is not None:
@@ -354,7 +364,7 @@ class EntityPlatform:
suggested_object_id = f"{self.entity_namespace} {suggested_object_id}"
if self.config_entry is not None:
- config_entry_id = self.config_entry.entry_id
+ config_entry_id: Optional[str] = self.config_entry.entry_id
else:
config_entry_id = None
@@ -369,6 +379,9 @@ class EntityPlatform:
"manufacturer",
"model",
"name",
+ "default_manufacturer",
+ "default_model",
+ "default_name",
"sw_version",
"entry_type",
"via_device",
@@ -411,8 +424,7 @@ class EntityPlatform:
or entity.name
or f'"{self.platform_name} {entity.unique_id}"',
)
- entity.hass = None
- entity.platform = None
+ entity.add_to_platform_abort()
return
# We won't generate an entity ID if the platform has already set one
@@ -438,8 +450,7 @@ class EntityPlatform:
# Make sure it is valid in case an entity set the value themselves
if not valid_entity_id(entity.entity_id):
- entity.hass = None
- entity.platform = None
+ entity.add_to_platform_abort()
raise HomeAssistantError(f"Invalid entity id: {entity.entity_id}")
already_exists = entity.entity_id in self.entities
@@ -460,18 +471,14 @@ class EntityPlatform:
else:
msg = f"Entity id already exists - ignoring: {entity.entity_id}"
self.logger.error(msg)
- entity.hass = None
- entity.platform = None
+ entity.add_to_platform_abort()
return
entity_id = entity.entity_id
self.entities[entity_id] = entity
entity.async_on_remove(lambda: self.entities.pop(entity_id))
- await entity.async_internal_added_to_hass()
- await entity.async_added_to_hass()
-
- entity.async_write_ha_state()
+ await entity.add_to_platform_finish()
async def async_reset(self) -> None:
"""Remove all entities and reset data.
@@ -512,7 +519,9 @@ class EntityPlatform:
self._async_unsub_polling()
self._async_unsub_polling = None
- async def async_extract_from_service(self, service_call, expand_group=True):
+ async def async_extract_from_service(
+ self, service_call: ServiceCall, expand_group: bool = True
+ ) -> List["Entity"]:
"""Extract all known and available entities from a service call.
Will return an empty list if entities specified but unknown.
@@ -535,9 +544,9 @@ class EntityPlatform:
if isinstance(schema, dict):
schema = cv.make_entity_service_schema(schema)
- async def handle_service(call):
+ async def handle_service(call: ServiceCall) -> None:
"""Handle the service."""
- await service.entity_service_call(
+ await service.entity_service_call( # type: ignore
self.hass,
[
plf
@@ -586,3 +595,19 @@ class EntityPlatform:
current_platform: ContextVar[Optional[EntityPlatform]] = ContextVar(
"current_platform", default=None
)
+
+
+@callback
+def async_get_platforms(
+ hass: HomeAssistantType, integration_name: str
+) -> List[EntityPlatform]:
+ """Find existing platforms."""
+ if (
+ DATA_ENTITY_PLATFORM not in hass.data
+ or integration_name not in hass.data[DATA_ENTITY_PLATFORM]
+ ):
+ return []
+
+ platforms: List[EntityPlatform] = hass.data[DATA_ENTITY_PLATFORM][integration_name]
+
+ return platforms
diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py
index be002a1b204..435a265d9e0 100644
--- a/homeassistant/helpers/event.py
+++ b/homeassistant/helpers/event.py
@@ -1,14 +1,27 @@
"""Helpers for listening to events."""
import asyncio
+from dataclasses import dataclass
from datetime import datetime, timedelta
import functools as ft
import logging
import time
-from typing import Any, Awaitable, Callable, Dict, Iterable, Optional, Union
+from typing import (
+ Any,
+ Awaitable,
+ Callable,
+ Dict,
+ Iterable,
+ List,
+ Optional,
+ Set,
+ Tuple,
+ Union,
+)
import attr
from homeassistant.const import (
+ ATTR_ENTITY_ID,
ATTR_NOW,
EVENT_CORE_CONFIG_UPDATE,
EVENT_STATE_CHANGED,
@@ -17,24 +30,66 @@ from homeassistant.const import (
SUN_EVENT_SUNRISE,
SUN_EVENT_SUNSET,
)
-from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, State, callback
+from homeassistant.core import (
+ CALLBACK_TYPE,
+ Event,
+ HomeAssistant,
+ State,
+ callback,
+ split_entity_id,
+)
+from homeassistant.exceptions import TemplateError
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
from homeassistant.helpers.sun import get_astral_event_next
-from homeassistant.helpers.template import Template
+from homeassistant.helpers.template import RenderInfo, Template, result_as_boolean
+from homeassistant.helpers.typing import TemplateVarsType
from homeassistant.loader import bind_hass
from homeassistant.util import dt as dt_util
from homeassistant.util.async_ import run_callback_threadsafe
+MAX_TIME_TRACKING_ERROR = 0.001
+
TRACK_STATE_CHANGE_CALLBACKS = "track_state_change_callbacks"
TRACK_STATE_CHANGE_LISTENER = "track_state_change_listener"
+TRACK_STATE_ADDED_DOMAIN_CALLBACKS = "track_state_added_domain_callbacks"
+TRACK_STATE_ADDED_DOMAIN_LISTENER = "track_state_added_domain_listener"
+
TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS = "track_entity_registry_updated_callbacks"
TRACK_ENTITY_REGISTRY_UPDATED_LISTENER = "track_entity_registry_updated_listener"
_LOGGER = logging.getLogger(__name__)
-# PyLint does not like the use of threaded_listener_factory
-# pylint: disable=invalid-name
+
+@dataclass
+class TrackTemplate:
+ """Class for keeping track of a template with variables.
+
+ The template is template to calculate.
+ The variables are variables to pass to the template.
+ """
+
+ template: Template
+ variables: TemplateVarsType
+
+
+@dataclass
+class TrackTemplateResult:
+ """Class for result of template tracking.
+
+ template
+ The template that has changed.
+ last_result
+ The output from the template on the last successful run, or None
+ if no previous successful run.
+ result
+ Result from the template run. This will be a string or an
+ TemplateError if the template resulted in an error.
+ """
+
+ template: Template
+ last_result: Union[str, None, TemplateError]
+ result: Union[str, TemplateError]
def threaded_listener_factory(async_factory: Callable[..., Any]) -> CALLBACK_TYPE:
@@ -191,7 +246,7 @@ def async_track_state_change_event(
@callback
def remove_listener() -> None:
"""Remove state change listener."""
- _async_remove_entity_listeners(
+ _async_remove_indexed_listeners(
hass,
TRACK_STATE_CHANGE_CALLBACKS,
TRACK_STATE_CHANGE_LISTENER,
@@ -203,23 +258,23 @@ def async_track_state_change_event(
@callback
-def _async_remove_entity_listeners(
+def _async_remove_indexed_listeners(
hass: HomeAssistant,
- storage_key: str,
+ data_key: str,
listener_key: str,
- entity_ids: Iterable[str],
+ storage_keys: Iterable[str],
action: Callable[[Event], Any],
) -> None:
"""Remove a listener."""
- entity_callbacks = hass.data[storage_key]
+ callbacks = hass.data[data_key]
- for entity_id in entity_ids:
- entity_callbacks[entity_id].remove(action)
- if len(entity_callbacks[entity_id]) == 0:
- del entity_callbacks[entity_id]
+ for storage_key in storage_keys:
+ callbacks[storage_key].remove(action)
+ if len(callbacks[storage_key]) == 0:
+ del callbacks[storage_key]
- if not entity_callbacks:
+ if not callbacks:
hass.data[listener_key]()
del hass.data[listener_key]
@@ -271,7 +326,7 @@ def async_track_entity_registry_updated_event(
@callback
def remove_listener() -> None:
"""Remove state change listener."""
- _async_remove_entity_listeners(
+ _async_remove_indexed_listeners(
hass,
TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS,
TRACK_ENTITY_REGISTRY_UPDATED_LISTENER,
@@ -282,41 +337,448 @@ def async_track_entity_registry_updated_event(
return remove_listener
+@bind_hass
+def async_track_state_added_domain(
+ hass: HomeAssistant,
+ domains: Union[str, Iterable[str]],
+ action: Callable[[Event], Any],
+) -> Callable[[], None]:
+ """Track state change events when an entity is added to domains."""
+
+ domain_callbacks = hass.data.setdefault(TRACK_STATE_ADDED_DOMAIN_CALLBACKS, {})
+
+ if TRACK_STATE_ADDED_DOMAIN_LISTENER not in hass.data:
+
+ @callback
+ def _async_state_change_dispatcher(event: Event) -> None:
+ """Dispatch state changes by entity_id."""
+ if event.data.get("old_state") is not None:
+ return
+
+ domain = split_entity_id(event.data["entity_id"])[0]
+
+ if domain not in domain_callbacks:
+ return
+
+ for action in domain_callbacks[domain][:]:
+ try:
+ hass.async_run_job(action, event)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception(
+ "Error while processing state added for %s", domain
+ )
+
+ hass.data[TRACK_STATE_ADDED_DOMAIN_LISTENER] = hass.bus.async_listen(
+ EVENT_STATE_CHANGED, _async_state_change_dispatcher
+ )
+
+ if isinstance(domains, str):
+ domains = [domains]
+
+ domains = [domains.lower() for domains in domains]
+
+ for domain in domains:
+ domain_callbacks.setdefault(domain, []).append(action)
+
+ @callback
+ def remove_listener() -> None:
+ """Remove state change listener."""
+ _async_remove_indexed_listeners(
+ hass,
+ TRACK_STATE_ADDED_DOMAIN_CALLBACKS,
+ TRACK_STATE_ADDED_DOMAIN_LISTENER,
+ domains,
+ action,
+ )
+
+ return remove_listener
+
+
@callback
@bind_hass
def async_track_template(
hass: HomeAssistant,
template: Template,
- action: Callable[[str, State, State], None],
- variables: Optional[Dict[str, Any]] = None,
-) -> CALLBACK_TYPE:
- """Add a listener that track state changes with template condition."""
- from . import condition # pylint: disable=import-outside-toplevel
+ action: Callable[[str, Optional[State], Optional[State]], None],
+ variables: Optional[TemplateVarsType] = None,
+) -> Callable[[], None]:
+ """Add a listener that fires when a a template evaluates to 'true'.
- # Local variable to keep track of if the action has already been triggered
- already_triggered = False
+ Listen for the result of the template becoming true, or a true-like
+ string result, such as 'On', 'Open', or 'Yes'. If the template results
+ in an error state when the value changes, this will be logged and not
+ passed through.
+
+ If the initial check of the template is invalid and results in an
+ exception, the listener will still be registered but will only
+ fire if the template result becomes true without an exception.
+
+ Action arguments
+ ----------------
+ entity_id
+ ID of the entity that triggered the state change.
+ old_state
+ The old state of the entity that changed.
+ new_state
+ New state of the entity that changed.
+
+ Parameters
+ ----------
+ hass
+ Home assistant object.
+ template
+ The template to calculate.
+ action
+ Callable to call with results. See above for arguments.
+ variables
+ Variables to pass to the template.
+
+ Returns
+ -------
+ Callable to unregister the listener.
+
+ """
@callback
- def template_condition_listener(entity_id: str, from_s: State, to_s: State) -> None:
+ def _template_changed_listener(
+ event: Event, updates: List[TrackTemplateResult]
+ ) -> None:
"""Check if condition is correct and run action."""
- nonlocal already_triggered
- template_result = condition.async_template(hass, template, variables)
+ track_result = updates.pop()
- # Check to see if template returns true
- if template_result and not already_triggered:
- already_triggered = True
- hass.async_run_job(action, entity_id, from_s, to_s)
- elif not template_result:
- already_triggered = False
+ template = track_result.template
+ last_result = track_result.last_result
+ result = track_result.result
- return async_track_state_change(
- hass, template.extract_entities(variables), template_condition_listener
+ if isinstance(result, TemplateError):
+ _LOGGER.error(
+ "Error while processing template: %s",
+ template.template,
+ exc_info=result,
+ )
+ return
+
+ if (
+ not isinstance(last_result, TemplateError)
+ and result_as_boolean(last_result)
+ or not result_as_boolean(result)
+ ):
+ return
+
+ hass.async_run_job(
+ action,
+ event.data.get("entity_id"),
+ event.data.get("old_state"),
+ event.data.get("new_state"),
+ )
+
+ info = async_track_template_result(
+ hass, [TrackTemplate(template, variables)], _template_changed_listener
)
+ return info.async_remove
+
track_template = threaded_listener_factory(async_track_template)
+class _TrackTemplateResultInfo:
+ """Handle removal / refresh of tracker."""
+
+ def __init__(
+ self,
+ hass: HomeAssistant,
+ track_templates: Iterable[TrackTemplate],
+ action: Callable,
+ ):
+ """Handle removal / refresh of tracker init."""
+ self.hass = hass
+ self._action = action
+
+ for track_template_ in track_templates:
+ track_template_.template.hass = hass
+ self._track_templates = track_templates
+
+ self._all_listener: Optional[Callable] = None
+ self._domains_listener: Optional[Callable] = None
+ self._entities_listener: Optional[Callable] = None
+
+ self._last_result: Dict[Template, Union[str, TemplateError]] = {}
+ self._last_info: Dict[Template, RenderInfo] = {}
+ self._info: Dict[Template, RenderInfo] = {}
+ self._last_domains: Set = set()
+ self._last_entities: Set = set()
+
+ def async_setup(self) -> None:
+ """Activation of template tracking."""
+ for track_template_ in self._track_templates:
+ template = track_template_.template
+ variables = track_template_.variables
+
+ self._info[template] = template.async_render_to_info(variables)
+ if self._info[template].exception:
+ _LOGGER.error(
+ "Error while processing template: %s",
+ track_template_.template,
+ exc_info=self._info[template].exception,
+ )
+
+ self._last_info = self._info.copy()
+ self._create_listeners()
+
+ @property
+ def listeners(self) -> Dict:
+ """State changes that will cause a re-render."""
+ return {
+ "all": self._all_listener is not None,
+ "entities": self._last_entities,
+ "domains": self._last_domains,
+ }
+
+ @property
+ def _needs_all_listener(self) -> bool:
+ for track_template_ in self._track_templates:
+ template = track_template_.template
+
+ # Tracking all states
+ if self._info[template].all_states:
+ return True
+
+ # Previous call had an exception
+ # so we do not know which states
+ # to track
+ if self._info[template].exception:
+ return True
+
+ return False
+
+ @property
+ def _all_templates_are_static(self) -> bool:
+ for track_template_ in self._track_templates:
+ if not self._info[track_template_.template].is_static:
+ return False
+
+ return True
+
+ @callback
+ def _create_listeners(self) -> None:
+ if self._all_templates_are_static:
+ return
+
+ if self._needs_all_listener:
+ self._setup_all_listener()
+ return
+
+ self._last_entities, self._last_domains = _entities_domains_from_info(
+ self._info.values()
+ )
+ self._setup_domains_listener(self._last_domains)
+ self._setup_entities_listener(self._last_domains, self._last_entities)
+
+ @callback
+ def _cancel_domains_listener(self) -> None:
+ if self._domains_listener is None:
+ return
+ self._domains_listener()
+ self._domains_listener = None
+
+ @callback
+ def _cancel_entities_listener(self) -> None:
+ if self._entities_listener is None:
+ return
+ self._entities_listener()
+ self._entities_listener = None
+
+ @callback
+ def _cancel_all_listener(self) -> None:
+ if self._all_listener is None:
+ return
+ self._all_listener()
+ self._all_listener = None
+
+ @callback
+ def _update_listeners(self) -> None:
+ if self._needs_all_listener:
+ if self._all_listener:
+ return
+ self._last_domains = set()
+ self._last_entities = set()
+ self._cancel_domains_listener()
+ self._cancel_entities_listener()
+ self._setup_all_listener()
+ return
+
+ had_all_listener = self._all_listener is not None
+ if had_all_listener:
+ self._cancel_all_listener()
+
+ entities, domains = _entities_domains_from_info(self._info.values())
+ domains_changed = domains != self._last_domains
+
+ if had_all_listener or domains_changed:
+ domains_changed = True
+ self._cancel_domains_listener()
+ self._setup_domains_listener(domains)
+
+ if had_all_listener or domains_changed or entities != self._last_entities:
+ self._cancel_entities_listener()
+ self._setup_entities_listener(domains, entities)
+
+ self._last_domains = domains
+ self._last_entities = entities
+
+ @callback
+ def _setup_entities_listener(self, domains: Set, entities: Set) -> None:
+ if domains:
+ entities = entities.copy()
+ entities.update(self.hass.states.async_entity_ids(domains))
+
+ # Entities has changed to none
+ if not entities:
+ return
+
+ self._entities_listener = async_track_state_change_event(
+ self.hass, entities, self._refresh
+ )
+
+ @callback
+ def _setup_domains_listener(self, domains: Set) -> None:
+ if not domains:
+ return
+
+ self._domains_listener = async_track_state_added_domain(
+ self.hass, domains, self._refresh
+ )
+
+ @callback
+ def _setup_all_listener(self) -> None:
+ self._all_listener = self.hass.bus.async_listen(
+ EVENT_STATE_CHANGED, self._refresh
+ )
+
+ @callback
+ def async_remove(self) -> None:
+ """Cancel the listener."""
+ self._cancel_all_listener()
+ self._cancel_domains_listener()
+ self._cancel_entities_listener()
+
+ @callback
+ def async_refresh(self) -> None:
+ """Force recalculate the template."""
+ self._refresh(None)
+
+ @callback
+ def _refresh(self, event: Optional[Event]) -> None:
+ entity_id = event and event.data.get(ATTR_ENTITY_ID)
+ updates = []
+ info_changed = False
+
+ for track_template_ in self._track_templates:
+ template = track_template_.template
+ if (
+ entity_id
+ and len(self._last_info) > 1
+ and not self._last_info[template].filter_lifecycle(entity_id)
+ ):
+ continue
+
+ self._info[template] = template.async_render_to_info(
+ track_template_.variables
+ )
+ info_changed = True
+
+ try:
+ result: Union[str, TemplateError] = self._info[template].result()
+ except TemplateError as ex:
+ result = ex
+
+ last_result = self._last_result.get(template)
+
+ # Check to see if the result has changed
+ if result == last_result:
+ continue
+
+ if isinstance(result, TemplateError) and isinstance(
+ last_result, TemplateError
+ ):
+ continue
+
+ updates.append(TrackTemplateResult(template, last_result, result))
+
+ if info_changed:
+ self._update_listeners()
+ self._last_info = self._info.copy()
+
+ if not updates:
+ return
+
+ for track_result in updates:
+ self._last_result[track_result.template] = track_result.result
+
+ self.hass.async_run_job(self._action, event, updates)
+
+
+TrackTemplateResultListener = Callable[
+ [
+ Event,
+ List[TrackTemplateResult],
+ ],
+ None,
+]
+"""Type for the listener for template results.
+
+ Action arguments
+ ----------------
+ event
+ Event that caused the template to change output. None if not
+ triggered by an event.
+ updates
+ A list of TrackTemplateResult
+"""
+
+
+@callback
+@bind_hass
+def async_track_template_result(
+ hass: HomeAssistant,
+ track_templates: Iterable[TrackTemplate],
+ action: TrackTemplateResultListener,
+) -> _TrackTemplateResultInfo:
+ """Add a listener that fires when a the result of a template changes.
+
+ The action will fire with the initial result from the template, and
+ then whenever the output from the template changes. The template will
+ be reevaluated if any states referenced in the last run of the
+ template change, or if manually triggered. If the result of the
+ evaluation is different from the previous run, the listener is passed
+ the result.
+
+ If the template results in an TemplateError, this will be returned to
+ the listener the first time this happens but not for subsequent errors.
+ Once the template returns to a non-error condition the result is sent
+ to the action as usual.
+
+ Parameters
+ ----------
+ hass
+ Home assistant object.
+ track_templates
+ An iterable of TrackTemplate.
+
+ action
+ Callable to call with results.
+
+ Returns
+ -------
+ Info object used to unregister the listener, and refresh the template.
+
+ """
+ tracker = _TrackTemplateResultInfo(hass, track_templates, action)
+ tracker.async_setup()
+ return tracker
+
+
@callback
@bind_hass
def async_track_same_state(
@@ -620,19 +1082,40 @@ def async_track_utc_time_change(
calculate_next(now + timedelta(seconds=1))
- # We always get time.time() first to avoid time.time()
- # ticking forward after fetching hass.loop.time()
- # and callback being scheduled a few microseconds early
cancel_callback = hass.loop.call_at(
- -time.time() + hass.loop.time() + next_time.timestamp(),
+ -time.time()
+ + hass.loop.time()
+ + next_time.timestamp()
+ + MAX_TIME_TRACKING_ERROR,
pattern_time_change_listener,
)
# We always get time.time() first to avoid time.time()
# ticking forward after fetching hass.loop.time()
- # and callback being scheduled a few microseconds early
+ # and callback being scheduled a few microseconds early.
+ #
+ # Since we loose additional time calling `hass.loop.time()`
+ # we add MAX_TIME_TRACKING_ERROR to ensure
+ # we always schedule the call within the time window between
+ # second and the next second.
+ #
+ # For example:
+ # If the clock ticks forward 30 microseconds when fectching
+ # `hass.loop.time()` and we want the event to fire at exactly
+ # 03:00:00.000000, the event would actually fire around
+ # 02:59:59.999970. To ensure we always fire sometime between
+ # 03:00:00.000000 and 03:00:00.999999 we add
+ # MAX_TIME_TRACKING_ERROR to make up for the time
+ # lost fetching the time. This ensures we do not fire the
+ # event before the next time pattern match which would result
+ # in the event being fired again since we would otherwise
+ # potentially fire early.
+ #
cancel_callback = hass.loop.call_at(
- -time.time() + hass.loop.time() + next_time.timestamp(),
+ -time.time()
+ + hass.loop.time()
+ + next_time.timestamp()
+ + MAX_TIME_TRACKING_ERROR,
pattern_time_change_listener,
)
@@ -677,3 +1160,16 @@ def process_state_match(
parameter_set = set(parameter)
return lambda state: state in parameter_set
+
+
+def _entities_domains_from_info(render_infos: Iterable[RenderInfo]) -> Tuple[Set, Set]:
+ """Combine from multiple RenderInfo."""
+ entities = set()
+ domains = set()
+
+ for render_info in render_infos:
+ if render_info.entities:
+ entities.update(render_info.entities)
+ if render_info.domains:
+ domains.update(render_info.domains)
+ return entities, domains
diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py
index 63d7cba4ec5..def2508ff92 100644
--- a/homeassistant/helpers/frame.py
+++ b/homeassistant/helpers/frame.py
@@ -3,7 +3,7 @@ import asyncio
import functools
import logging
from traceback import FrameSummary, extract_stack
-from typing import Any, Callable, Tuple, TypeVar, cast
+from typing import Any, Callable, Optional, Tuple, TypeVar, cast
from homeassistant.exceptions import HomeAssistantError
@@ -12,15 +12,24 @@ _LOGGER = logging.getLogger(__name__)
CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) # pylint: disable=invalid-name
-def get_integration_frame() -> Tuple[FrameSummary, str, str]:
+def get_integration_frame(
+ exclude_integrations: Optional[set] = None,
+) -> Tuple[FrameSummary, str, str]:
"""Return the frame, integration and integration path of the current stack frame."""
found_frame = None
+ if not exclude_integrations:
+ exclude_integrations = set()
for frame in reversed(extract_stack()):
for path in ("custom_components/", "homeassistant/components/"):
try:
index = frame.filename.index(path)
- found_frame = frame
+ start = index + len(path)
+ end = frame.filename.index("/", start)
+ integration = frame.filename[start:end]
+ if integration not in exclude_integrations:
+ found_frame = frame
+
break
except ValueError:
continue
@@ -31,11 +40,6 @@ def get_integration_frame() -> Tuple[FrameSummary, str, str]:
if found_frame is None:
raise MissingIntegrationFrame
- start = index + len(path)
- end = found_frame.filename.index("/", start)
-
- integration = found_frame.filename[start:end]
-
return found_frame, integration, path
@@ -49,10 +53,25 @@ def report(what: str) -> None:
Async friendly.
"""
try:
- found_frame, integration, path = get_integration_frame()
- except MissingIntegrationFrame:
+ integration_frame = get_integration_frame()
+ except MissingIntegrationFrame as err:
# Did not source from an integration? Hard error.
- raise RuntimeError(f"Detected code that {what}. Please report this issue.")
+ raise RuntimeError(
+ f"Detected code that {what}. Please report this issue."
+ ) from err
+
+ report_integration(what, integration_frame)
+
+
+def report_integration(
+ what: str, integration_frame: Tuple[FrameSummary, str, str]
+) -> None:
+ """Report incorrect usage in an integration.
+
+ Async friendly.
+ """
+
+ found_frame, integration, path = integration_frame
index = found_frame.filename.index(path)
if path == "custom_components/":
diff --git a/homeassistant/helpers/instance_id.py b/homeassistant/helpers/instance_id.py
index 1df039da47a..5feca605099 100644
--- a/homeassistant/helpers/instance_id.py
+++ b/homeassistant/helpers/instance_id.py
@@ -18,7 +18,9 @@ async def async_get(hass: HomeAssistant) -> str:
store = storage.Store(hass, DATA_VERSION, DATA_KEY, True)
data: Optional[Dict[str, str]] = await storage.async_migrator( # type: ignore
- hass, hass.config.path(LEGACY_UUID_FILE), store,
+ hass,
+ hass.config.path(LEGACY_UUID_FILE),
+ store,
)
if data is not None:
diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py
index 025cac11641..4acb9fd500a 100644
--- a/homeassistant/helpers/json.py
+++ b/homeassistant/helpers/json.py
@@ -10,7 +10,6 @@ _LOGGER = logging.getLogger(__name__)
class JSONEncoder(json.JSONEncoder):
"""JSONEncoder that supports Home Assistant objects."""
- # pylint: disable=method-hidden
def default(self, o: Any) -> Any:
"""Convert Home Assistant objects.
diff --git a/homeassistant/helpers/network.py b/homeassistant/helpers/network.py
index cebe0318496..8bdfc286c1a 100644
--- a/homeassistant/helpers/network.py
+++ b/homeassistant/helpers/network.py
@@ -1,9 +1,10 @@
"""Network helpers."""
from ipaddress import ip_address
-from typing import cast
+from typing import Optional, cast
import yarl
+from homeassistant.components.http import current_request
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.loader import bind_hass
@@ -27,6 +28,7 @@ class NoURLAvailableError(HomeAssistantError):
def get_url(
hass: HomeAssistant,
*,
+ require_current_request: bool = False,
require_ssl: bool = False,
require_standard_port: bool = False,
allow_internal: bool = True,
@@ -37,6 +39,9 @@ def get_url(
prefer_cloud: bool = False,
) -> str:
"""Get a URL to this instance."""
+ if require_current_request and current_request.get() is None:
+ raise NoURLAvailableError
+
order = [TYPE_URL_INTERNAL, TYPE_URL_EXTERNAL]
if prefer_external:
order.reverse()
@@ -49,6 +54,7 @@ def get_url(
return _get_internal_url(
hass,
allow_ip=allow_ip,
+ require_current_request=require_current_request,
require_ssl=require_ssl,
require_standard_port=require_standard_port,
)
@@ -62,21 +68,63 @@ def get_url(
allow_cloud=allow_cloud,
allow_ip=allow_ip,
prefer_cloud=prefer_cloud,
+ require_current_request=require_current_request,
require_ssl=require_ssl,
require_standard_port=require_standard_port,
)
except NoURLAvailableError:
pass
+ # For current request, we accept loopback interfaces (e.g., 127.0.0.1),
+ # the Supervisor hostname and localhost transparently
+ request_host = _get_request_host()
+ if (
+ require_current_request
+ and request_host is not None
+ and hass.config.api is not None
+ ):
+ scheme = "https" if hass.config.api.use_ssl else "http"
+ current_url = yarl.URL.build(
+ scheme=scheme, host=request_host, port=hass.config.api.port
+ )
+
+ known_hostname = None
+ if hass.components.hassio.is_hassio():
+ host_info = hass.components.hassio.get_host_info()
+ known_hostname = f"{host_info['hostname']}.local"
+
+ if (
+ (
+ (
+ allow_ip
+ and is_ip_address(request_host)
+ and is_loopback(ip_address(request_host))
+ )
+ or request_host in ["localhost", known_hostname]
+ )
+ and (not require_ssl or current_url.scheme == "https")
+ and (not require_standard_port or current_url.is_default_port())
+ ):
+ return normalize_url(str(current_url))
+
# We have to be honest now, we have no viable option available
raise NoURLAvailableError
+def _get_request_host() -> Optional[str]:
+ """Get the host address of the current request."""
+ request = current_request.get()
+ if request is None:
+ raise NoURLAvailableError
+ return yarl.URL(request.url).host
+
+
@bind_hass
def _get_internal_url(
hass: HomeAssistant,
*,
allow_ip: bool = True,
+ require_current_request: bool = False,
require_ssl: bool = False,
require_standard_port: bool = False,
) -> str:
@@ -84,7 +132,8 @@ def _get_internal_url(
if hass.config.internal_url:
internal_url = yarl.URL(hass.config.internal_url)
if (
- (not require_ssl or internal_url.scheme == "https")
+ (not require_current_request or internal_url.host == _get_request_host())
+ and (not require_ssl or internal_url.scheme == "https")
and (not require_standard_port or internal_url.is_default_port())
and (allow_ip or not is_ip_address(str(internal_url.host)))
):
@@ -96,6 +145,7 @@ def _get_internal_url(
hass,
internal=True,
allow_ip=allow_ip,
+ require_current_request=require_current_request,
require_ssl=require_ssl,
require_standard_port=require_standard_port,
)
@@ -109,8 +159,10 @@ def _get_internal_url(
ip_url = yarl.URL.build(
scheme="http", host=hass.config.api.local_ip, port=hass.config.api.port
)
- if not is_loopback(ip_address(ip_url.host)) and (
- not require_standard_port or ip_url.is_default_port()
+ if (
+ not is_loopback(ip_address(ip_url.host))
+ and (not require_current_request or ip_url.host == _get_request_host())
+ and (not require_standard_port or ip_url.is_default_port())
):
return normalize_url(str(ip_url))
@@ -124,6 +176,7 @@ def _get_external_url(
allow_cloud: bool = True,
allow_ip: bool = True,
prefer_cloud: bool = False,
+ require_current_request: bool = False,
require_ssl: bool = False,
require_standard_port: bool = False,
) -> str:
@@ -138,6 +191,9 @@ def _get_external_url(
external_url = yarl.URL(hass.config.external_url)
if (
(allow_ip or not is_ip_address(str(external_url.host)))
+ and (
+ not require_current_request or external_url.host == _get_request_host()
+ )
and (not require_standard_port or external_url.is_default_port())
and (
not require_ssl
@@ -153,6 +209,7 @@ def _get_external_url(
return _get_deprecated_base_url(
hass,
allow_ip=allow_ip,
+ require_current_request=require_current_request,
require_ssl=require_ssl,
require_standard_port=require_standard_port,
)
@@ -161,7 +218,7 @@ def _get_external_url(
if allow_cloud:
try:
- return _get_cloud_url(hass)
+ return _get_cloud_url(hass, require_current_request=require_current_request)
except NoURLAvailableError:
pass
@@ -169,13 +226,16 @@ def _get_external_url(
@bind_hass
-def _get_cloud_url(hass: HomeAssistant) -> str:
+def _get_cloud_url(hass: HomeAssistant, require_current_request: bool = False) -> str:
"""Get external Home Assistant Cloud URL of this instance."""
if "cloud" in hass.config.components:
try:
- return cast(str, hass.components.cloud.async_remote_ui_url())
- except hass.components.cloud.CloudNotAvailable:
- pass
+ cloud_url = yarl.URL(cast(str, hass.components.cloud.async_remote_ui_url()))
+ except hass.components.cloud.CloudNotAvailable as err:
+ raise NoURLAvailableError from err
+
+ if not require_current_request or cloud_url.host == _get_request_host():
+ return normalize_url(str(cloud_url))
raise NoURLAvailableError
@@ -186,6 +246,7 @@ def _get_deprecated_base_url(
*,
internal: bool = False,
allow_ip: bool = True,
+ require_current_request: bool = False,
require_ssl: bool = False,
require_standard_port: bool = False,
) -> str:
@@ -197,6 +258,7 @@ def _get_deprecated_base_url(
# Rules that apply to both internal and external
if (
(allow_ip or not is_ip_address(str(base_url.host)))
+ and (not require_current_request or base_url.host == _get_request_host())
and (not require_ssl or base_url.scheme == "https")
and (not require_standard_port or base_url.is_default_port())
):
diff --git a/homeassistant/helpers/reload.py b/homeassistant/helpers/reload.py
new file mode 100644
index 00000000000..1c11afdb46b
--- /dev/null
+++ b/homeassistant/helpers/reload.py
@@ -0,0 +1,179 @@
+"""Class to reload platforms."""
+
+import asyncio
+import logging
+from typing import Any, Dict, Iterable, List, Optional
+
+from homeassistant import config as conf_util
+from homeassistant.const import SERVICE_RELOAD
+from homeassistant.core import Event, callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_per_platform
+from homeassistant.helpers.entity_platform import EntityPlatform, async_get_platforms
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.loader import async_get_integration
+from homeassistant.setup import async_setup_component
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_reload_integration_platforms(
+ hass: HomeAssistantType, integration_name: str, integration_platforms: Iterable
+) -> None:
+ """Reload an integration's platforms.
+
+ The platform must support being re-setup.
+
+ This functionality is only intended to be used for integrations that process
+ Home Assistant data and make this available to other integrations.
+
+ Examples are template, stats, derivative, utility meter.
+ """
+ try:
+ unprocessed_conf = await conf_util.async_hass_config_yaml(hass)
+ except HomeAssistantError as err:
+ _LOGGER.error(err)
+ return
+
+ tasks = [
+ _resetup_platform(
+ hass, integration_name, integration_platform, unprocessed_conf
+ )
+ for integration_platform in integration_platforms
+ ]
+
+ await asyncio.gather(*tasks)
+
+
+async def _resetup_platform(
+ hass: HomeAssistantType,
+ integration_name: str,
+ integration_platform: str,
+ unprocessed_conf: Dict,
+) -> None:
+ """Resetup a platform."""
+ integration = await async_get_integration(hass, integration_platform)
+
+ conf = await conf_util.async_process_component_config(
+ hass, unprocessed_conf, integration
+ )
+
+ if not conf:
+ return
+
+ root_config: Dict = {integration_platform: []}
+ # Extract only the config for template, ignore the rest.
+ for p_type, p_config in config_per_platform(conf, integration_platform):
+ if p_type != integration_name:
+ continue
+
+ root_config[integration_platform].append(p_config)
+
+ component = integration.get_component()
+
+ if hasattr(component, "async_reset_platform"):
+ # If the integration has its own way to reset
+ # use this method.
+ await component.async_reset_platform(hass, integration_name) # type: ignore
+ await component.async_setup(hass, root_config) # type: ignore
+ return
+
+ # If its an entity platform, we use the entity_platform
+ # async_reset method
+ platform = async_get_platform(hass, integration_name, integration_platform)
+ if platform:
+ await _async_reconfig_platform(platform, root_config[integration_platform])
+ return
+
+ if not root_config[integration_platform]:
+ # No config for this platform
+ # and its not loaded. Nothing to do
+ return
+
+ await _async_setup_platform(
+ hass, integration_name, integration_platform, root_config[integration_platform]
+ )
+
+
+async def _async_setup_platform(
+ hass: HomeAssistantType,
+ integration_name: str,
+ integration_platform: str,
+ platform_configs: List[Dict],
+) -> None:
+ """Platform for the first time when new configuration is added."""
+ if integration_platform not in hass.data:
+ await async_setup_component(
+ hass, integration_platform, {integration_platform: platform_configs}
+ )
+ return
+
+ entity_component = hass.data[integration_platform]
+ tasks = [
+ entity_component.async_setup_platform(integration_name, p_config)
+ for p_config in platform_configs
+ ]
+ await asyncio.gather(*tasks)
+
+
+async def _async_reconfig_platform(
+ platform: EntityPlatform, platform_configs: List[Dict]
+) -> None:
+ """Reconfigure an already loaded platform."""
+ await platform.async_reset()
+ tasks = [platform.async_setup(p_config) for p_config in platform_configs] # type: ignore
+ await asyncio.gather(*tasks)
+
+
+async def async_integration_yaml_config(
+ hass: HomeAssistantType, integration_name: str
+) -> Optional[Dict[Any, Any]]:
+ """Fetch the latest yaml configuration for an integration."""
+ integration = await async_get_integration(hass, integration_name)
+
+ return await conf_util.async_process_component_config(
+ hass, await conf_util.async_hass_config_yaml(hass), integration
+ )
+
+
+@callback
+def async_get_platform(
+ hass: HomeAssistantType, integration_name: str, integration_platform_name: str
+) -> Optional[EntityPlatform]:
+ """Find an existing platform."""
+ for integration_platform in async_get_platforms(hass, integration_name):
+ if integration_platform.domain == integration_platform_name:
+ platform: EntityPlatform = integration_platform
+ return platform
+
+ return None
+
+
+async def async_setup_reload_service(
+ hass: HomeAssistantType, domain: str, platforms: Iterable
+) -> None:
+ """Create the reload service for the domain."""
+
+ if hass.services.has_service(domain, SERVICE_RELOAD):
+ return
+
+ async def _reload_config(call: Event) -> None:
+ """Reload the platforms."""
+
+ await async_reload_integration_platforms(hass, domain, platforms)
+ hass.bus.async_fire(f"event_{domain}_reloaded", context=call.context)
+
+ hass.helpers.service.async_register_admin_service(
+ domain, SERVICE_RELOAD, _reload_config
+ )
+
+
+def setup_reload_service(
+ hass: HomeAssistantType, domain: str, platforms: Iterable
+) -> None:
+ """Sync version of async_setup_reload_service."""
+
+ asyncio.run_coroutine_threadsafe(
+ async_setup_reload_service(hass, domain, platforms),
+ hass.loop,
+ ).result()
diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py
index c75d9c840ed..97069913c80 100644
--- a/homeassistant/helpers/restore_state.py
+++ b/homeassistant/helpers/restore_state.py
@@ -2,7 +2,7 @@
import asyncio
from datetime import datetime, timedelta
import logging
-from typing import Any, Awaitable, Dict, List, Optional, Set, cast
+from typing import Any, Dict, List, Optional, Set, cast
from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import (
@@ -17,6 +17,7 @@ from homeassistant.helpers import entity_registry
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.json import JSONEncoder
+from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.storage import Store
import homeassistant.util.dt as dt_util
@@ -63,45 +64,39 @@ class RestoreStateData:
@classmethod
async def async_get_instance(cls, hass: HomeAssistant) -> "RestoreStateData":
"""Get the singleton instance of this data helper."""
- task = hass.data.get(DATA_RESTORE_STATE_TASK)
- if task is None:
+ @singleton(DATA_RESTORE_STATE_TASK)
+ async def load_instance(hass: HomeAssistant) -> "RestoreStateData":
+ """Get the singleton instance of this data helper."""
+ data = cls(hass)
- async def load_instance(hass: HomeAssistant) -> "RestoreStateData":
- """Set up the restore state helper."""
- data = cls(hass)
+ try:
+ stored_states = await data.store.async_load()
+ except HomeAssistantError as exc:
+ _LOGGER.error("Error loading last states", exc_info=exc)
+ stored_states = None
- try:
- stored_states = await data.store.async_load()
- except HomeAssistantError as exc:
- _LOGGER.error("Error loading last states", exc_info=exc)
- stored_states = None
+ if stored_states is None:
+ _LOGGER.debug("Not creating cache - no saved states found")
+ data.last_states = {}
+ else:
+ data.last_states = {
+ item["state"]["entity_id"]: StoredState.from_dict(item)
+ for item in stored_states
+ if valid_entity_id(item["state"]["entity_id"])
+ }
+ _LOGGER.debug("Created cache with %s", list(data.last_states))
- if stored_states is None:
- _LOGGER.debug("Not creating cache - no saved states found")
- data.last_states = {}
- else:
- data.last_states = {
- item["state"]["entity_id"]: StoredState.from_dict(item)
- for item in stored_states
- if valid_entity_id(item["state"]["entity_id"])
- }
- _LOGGER.debug("Created cache with %s", list(data.last_states))
+ if hass.state == CoreState.running:
+ data.async_setup_dump()
+ else:
+ hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, data.async_setup_dump
+ )
- if hass.state == CoreState.running:
- data.async_setup_dump()
- else:
- hass.bus.async_listen_once(
- EVENT_HOMEASSISTANT_START, data.async_setup_dump
- )
+ return data
- return data
-
- task = hass.data[DATA_RESTORE_STATE_TASK] = hass.async_create_task(
- load_instance(hass)
- )
-
- return await cast(Awaitable["RestoreStateData"], task)
+ return cast(RestoreStateData, await load_instance(hass))
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the restore state data class."""
diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py
index 6fed54227a3..717e9c3980c 100644
--- a/homeassistant/helpers/script.py
+++ b/homeassistant/helpers/script.py
@@ -1,6 +1,6 @@
"""Helpers to execute scripts."""
import asyncio
-from datetime import datetime
+from datetime import datetime, timedelta
from functools import partial
import itertools
import logging
@@ -23,6 +23,7 @@ import voluptuous as vol
from homeassistant import exceptions
import homeassistant.components.device_automation as device_automation
+from homeassistant.components.logger import LOGSEVERITY
import homeassistant.components.scene as scene
from homeassistant.const import (
ATTR_ENTITY_ID,
@@ -45,22 +46,25 @@ from homeassistant.const import (
CONF_SEQUENCE,
CONF_TIMEOUT,
CONF_UNTIL,
+ CONF_VARIABLES,
+ CONF_WAIT_FOR_TRIGGER,
CONF_WAIT_TEMPLATE,
CONF_WHILE,
EVENT_HOMEASSISTANT_STOP,
SERVICE_TURN_ON,
)
from homeassistant.core import SERVICE_CALL_LIMIT, Context, HomeAssistant, callback
-from homeassistant.helpers import (
- condition,
- config_validation as cv,
- template as template,
-)
+from homeassistant.helpers import condition, config_validation as cv, template
from homeassistant.helpers.event import async_call_later, async_track_template
+from homeassistant.helpers.script_variables import ScriptVariables
from homeassistant.helpers.service import (
CONF_SERVICE_DATA,
async_prepare_call_from_config,
)
+from homeassistant.helpers.trigger import (
+ async_initialize_triggers,
+ async_validate_trigger_config,
+)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import slugify
from homeassistant.util.dt import utcnow
@@ -82,6 +86,10 @@ DEFAULT_SCRIPT_MODE = SCRIPT_MODE_SINGLE
CONF_MAX = "max"
DEFAULT_MAX = 10
+CONF_MAX_EXCEEDED = "max_exceeded"
+_MAX_EXCEEDED_CHOICES = list(LOGSEVERITY) + ["SILENT"]
+DEFAULT_MAX_EXCEEDED = "WARNING"
+
ATTR_CUR = "current"
ATTR_MAX = "max"
ATTR_MODE = "mode"
@@ -107,6 +115,9 @@ def make_script_schema(schema, default_script_mode, extra=vol.PREVENT_EXTRA):
vol.Optional(CONF_MAX, default=DEFAULT_MAX): vol.All(
vol.Coerce(int), vol.Range(min=2)
),
+ vol.Optional(CONF_MAX_EXCEEDED, default=DEFAULT_MAX_EXCEEDED): vol.All(
+ vol.Upper, vol.In(_MAX_EXCEEDED_CHOICES)
+ ),
},
extra=extra,
)
@@ -123,7 +134,7 @@ async def async_validate_action_config(
hass, config[CONF_DOMAIN], "action"
)
config = platform.ACTION_SCHEMA(config) # type: ignore
- if (
+ elif (
action_type == cv.SCRIPT_ACTION_CHECK_CONDITION
and config[CONF_CONDITION] == "device"
):
@@ -131,6 +142,10 @@ async def async_validate_action_config(
hass, config[CONF_DOMAIN], "condition"
)
config = platform.CONDITION_SCHEMA(config) # type: ignore
+ elif action_type == cv.SCRIPT_ACTION_WAIT_FOR_TRIGGER:
+ config[CONF_WAIT_FOR_TRIGGER] = await async_validate_trigger_config(
+ hass, config[CONF_WAIT_FOR_TRIGGER]
+ )
return config
@@ -176,7 +191,7 @@ class _ScriptRun:
try:
if self._stop.is_set():
return
- self._log("Running script")
+ self._log("Running %s", self._script.running_description)
for self._step, self._action in enumerate(self._script.sequence):
if self._stop.is_set():
break
@@ -241,20 +256,24 @@ class _ScriptRun:
level=level,
)
- async def _async_delay_step(self):
- """Handle delay."""
+ def _get_pos_time_period_template(self, key):
try:
- delay = vol.All(cv.time_period, cv.positive_timedelta)(
- template.render_complex(self._action[CONF_DELAY], self._variables)
+ return cv.positive_time_period(
+ template.render_complex(self._action[key], self._variables)
)
except (exceptions.TemplateError, vol.Invalid) as ex:
self._log(
- "Error rendering %s delay template: %s",
+ "Error rendering %s %s template: %s",
self._script.name,
+ key,
ex,
level=logging.ERROR,
)
- raise _StopScript
+ raise _StopScript from ex
+
+ async def _async_delay_step(self):
+ """Handle delay."""
+ delay = self._get_pos_time_period_template(CONF_DELAY)
self._script.last_action = self._action.get(CONF_ALIAS, f"delay {delay}")
self._log("Executing step %s", self._script.last_action)
@@ -269,41 +288,55 @@ class _ScriptRun:
async def _async_wait_template_step(self):
"""Handle a wait template."""
+ if CONF_TIMEOUT in self._action:
+ delay = self._get_pos_time_period_template(CONF_TIMEOUT).total_seconds()
+ else:
+ delay = None
+
self._script.last_action = self._action.get(CONF_ALIAS, "wait template")
- self._log("Executing step %s", self._script.last_action)
+ self._log(
+ "Executing step %s%s",
+ self._script.last_action,
+ "" if delay is None else f" (timeout: {timedelta(seconds=delay)})",
+ )
+
+ self._variables["wait"] = {"remaining": delay, "completed": False}
wait_template = self._action[CONF_WAIT_TEMPLATE]
wait_template.hass = self._hass
# check if condition already okay
if condition.async_template(self._hass, wait_template, self._variables):
+ self._variables["wait"]["completed"] = True
return
@callback
def async_script_wait(entity_id, from_s, to_s):
"""Handle script after template condition is true."""
+ self._variables["wait"] = {
+ "remaining": to_context.remaining if to_context else delay,
+ "completed": True,
+ }
done.set()
+ to_context = None
unsub = async_track_template(
self._hass, wait_template, async_script_wait, self._variables
)
self._changed()
- try:
- delay = self._action[CONF_TIMEOUT].total_seconds()
- except KeyError:
- delay = None
done = asyncio.Event()
tasks = [
self._hass.async_create_task(flag.wait()) for flag in (self._stop, done)
]
try:
- async with timeout(delay):
+ async with timeout(delay) as to_context:
await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
- except asyncio.TimeoutError:
+ except asyncio.TimeoutError as ex:
if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True):
self._log(_TIMEOUT_MSG)
- raise _StopScript
+ raise _StopScript from ex
+ self._variables["wait"]["remaining"] = 0.0
finally:
for task in tasks:
task.cancel()
@@ -411,13 +444,14 @@ class _ScriptRun:
CONF_ALIAS, self._action[CONF_EVENT]
)
self._log("Executing step %s", self._script.last_action)
- event_data = dict(self._action.get(CONF_EVENT_DATA, {}))
- if CONF_EVENT_DATA_TEMPLATE in self._action:
+ event_data = {}
+ for conf in [CONF_EVENT_DATA, CONF_EVENT_DATA_TEMPLATE]:
+ if conf not in self._action:
+ continue
+
try:
event_data.update(
- template.render_complex(
- self._action[CONF_EVENT_DATA_TEMPLATE], self._variables
- )
+ template.render_complex(self._action[conf], self._variables)
)
except exceptions.TemplateError as ex:
self._log(
@@ -471,7 +505,7 @@ class _ScriptRun:
ex,
level=logging.ERROR,
)
- raise _StopScript
+ raise _StopScript from ex
extra_msg = f" of {count}"
for iteration in range(1, count + 1):
set_repeat_var(iteration, count)
@@ -521,6 +555,72 @@ class _ScriptRun:
if choose_data["default"]:
await self._async_run_script(choose_data["default"])
+ async def _async_wait_for_trigger_step(self):
+ """Wait for a trigger event."""
+ if CONF_TIMEOUT in self._action:
+ delay = self._get_pos_time_period_template(CONF_TIMEOUT).total_seconds()
+ else:
+ delay = None
+
+ self._script.last_action = self._action.get(CONF_ALIAS, "wait for trigger")
+ self._log(
+ "Executing step %s%s",
+ self._script.last_action,
+ "" if delay is None else f" (timeout: {timedelta(seconds=delay)})",
+ )
+
+ variables = {**self._variables}
+ self._variables["wait"] = {"remaining": delay, "trigger": None}
+
+ async def async_done(variables, context=None):
+ self._variables["wait"] = {
+ "remaining": to_context.remaining if to_context else delay,
+ "trigger": variables["trigger"],
+ }
+ done.set()
+
+ def log_cb(level, msg):
+ self._log(msg, level=level)
+
+ to_context = None
+ remove_triggers = await async_initialize_triggers(
+ self._hass,
+ self._action[CONF_WAIT_FOR_TRIGGER],
+ async_done,
+ self._script.domain,
+ self._script.name,
+ log_cb,
+ variables=variables,
+ )
+ if not remove_triggers:
+ return
+
+ self._changed()
+ done = asyncio.Event()
+ tasks = [
+ self._hass.async_create_task(flag.wait()) for flag in (self._stop, done)
+ ]
+ try:
+ async with timeout(delay) as to_context:
+ await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
+ except asyncio.TimeoutError as ex:
+ if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True):
+ self._log(_TIMEOUT_MSG)
+ raise _StopScript from ex
+ self._variables["wait"]["remaining"] = 0.0
+ finally:
+ for task in tasks:
+ task.cancel()
+ remove_triggers()
+
+ async def _async_variables_step(self):
+ """Set a variable value."""
+ self._script.last_action = self._action.get(CONF_ALIAS, "setting variables")
+ self._log("Executing step %s", self._script.last_action)
+ self._variables = self._action[CONF_VARIABLES].async_render(
+ self._hass, self._variables, render_as_defaults=False
+ )
+
async def _async_run_script(self, script):
"""Execute a script."""
await self._async_run_long_action(
@@ -615,13 +715,19 @@ class Script:
self,
hass: HomeAssistant,
sequence: Sequence[Dict[str, Any]],
- name: Optional[str] = None,
+ name: str,
+ domain: str,
+ *,
+ # Used in "Running " log message
+ running_description: Optional[str] = None,
change_listener: Optional[Callable[..., Any]] = None,
script_mode: str = DEFAULT_SCRIPT_MODE,
max_runs: int = DEFAULT_MAX,
+ max_exceeded: str = DEFAULT_MAX_EXCEEDED,
logger: Optional[logging.Logger] = None,
log_exceptions: bool = True,
top_level: bool = True,
+ variables: Optional[ScriptVariables] = None,
) -> None:
"""Initialize the script."""
all_scripts = hass.data.get(DATA_SCRIPTS)
@@ -640,6 +746,8 @@ class Script:
self.sequence = sequence
template.attach(hass, self.sequence)
self.name = name
+ self.domain = domain
+ self.running_description = running_description or f"{domain} script"
self.change_listener = change_listener
self.script_mode = script_mode
self._set_logger(logger)
@@ -650,6 +758,7 @@ class Script:
self._runs: List[_ScriptRun] = []
self.max_runs = max_runs
+ self._max_exceeded = max_exceeded
if script_mode == SCRIPT_MODE_QUEUED:
self._queue_lck = asyncio.Lock()
self._config_cache: Dict[Set[Tuple], Callable[..., bool]] = {}
@@ -657,15 +766,16 @@ class Script:
self._choose_data: Dict[int, Dict[str, Any]] = {}
self._referenced_entities: Optional[Set[str]] = None
self._referenced_devices: Optional[Set[str]] = None
+ self.variables = variables
+ self._variables_dynamic = template.is_complex(variables)
+ if self._variables_dynamic:
+ template.attach(hass, variables)
def _set_logger(self, logger: Optional[logging.Logger] = None) -> None:
if logger:
self._logger = logger
else:
- logger_name = __name__
- if self.name:
- logger_name = ".".join([logger_name, slugify(self.name)])
- self._logger = logging.getLogger(logger_name)
+ self._logger = logging.getLogger(f"{__name__}.{slugify(self.name)}")
def update_logger(self, logger: Optional[logging.Logger] = None) -> None:
"""Update logger."""
@@ -767,25 +877,55 @@ class Script:
).result()
async def async_run(
- self, variables: Optional[_VarsType] = None, context: Optional[Context] = None
+ self,
+ run_variables: Optional[_VarsType] = None,
+ context: Optional[Context] = None,
+ started_action: Optional[Callable[..., Any]] = None,
) -> None:
"""Run script."""
+ if context is None:
+ self._log(
+ "Running script requires passing in a context", level=logging.WARNING
+ )
+ context = Context()
+
if self.is_running:
if self.script_mode == SCRIPT_MODE_SINGLE:
- self._log("Already running", level=logging.WARNING)
+ if self._max_exceeded != "SILENT":
+ self._log("Already running", level=LOGSEVERITY[self._max_exceeded])
return
if self.script_mode == SCRIPT_MODE_RESTART:
self._log("Restarting")
await self.async_stop(update_state=False)
elif len(self._runs) == self.max_runs:
- self._log("Maximum number of runs exceeded", level=logging.WARNING)
+ if self._max_exceeded != "SILENT":
+ self._log(
+ "Maximum number of runs exceeded",
+ level=LOGSEVERITY[self._max_exceeded],
+ )
return
# If this is a top level Script then make a copy of the variables in case they
# are read-only, but more importantly, so as not to leak any variables created
# during the run back to the caller.
if self._top_level:
- variables = dict(variables) if variables is not None else {}
+ if self.variables:
+ try:
+ variables = self.variables.async_render(
+ self._hass,
+ run_variables,
+ )
+ except template.TemplateError as err:
+ self._log("Error rendering variables: %s", err, level=logging.ERROR)
+ raise
+ elif run_variables:
+ variables = dict(run_variables)
+ else:
+ variables = {}
+
+ variables["context"] = context
+ else:
+ variables = cast(dict, run_variables)
if self.script_mode != SCRIPT_MODE_QUEUED:
cls = _ScriptRun
@@ -795,6 +935,8 @@ class Script:
self._hass, self, cast(dict, variables), context, self._log_exceptions
)
self._runs.append(run)
+ if started_action:
+ self._hass.async_run_job(started_action)
self.last_triggered = utcnow()
self._changed()
@@ -818,7 +960,10 @@ class Script:
await asyncio.shield(self._async_stop(update_state))
async def _async_get_condition(self, config):
- config_cache_key = frozenset((k, str(v)) for k, v in config.items())
+ if isinstance(config, template.Template):
+ config_cache_key = config.template
+ else:
+ config_cache_key = frozenset((k, str(v)) for k, v in config.items())
cond = self._config_cache.get(config_cache_key)
if not cond:
cond = await condition.async_from_config(self._hass, config, False)
@@ -832,6 +977,8 @@ class Script:
self._hass,
action[CONF_REPEAT][CONF_SEQUENCE],
f"{self.name}: {step_name}",
+ self.domain,
+ running_description=self.running_description,
script_mode=SCRIPT_MODE_PARALLEL,
max_runs=self.max_runs,
logger=self._logger,
@@ -860,6 +1007,8 @@ class Script:
self._hass,
choice[CONF_SEQUENCE],
f"{self.name}: {step_name}: choice {idx}",
+ self.domain,
+ running_description=self.running_description,
script_mode=SCRIPT_MODE_PARALLEL,
max_runs=self.max_runs,
logger=self._logger,
@@ -875,6 +1024,8 @@ class Script:
self._hass,
action[CONF_DEFAULT],
f"{self.name}: {step_name}: default",
+ self.domain,
+ running_description=self.running_description,
script_mode=SCRIPT_MODE_PARALLEL,
max_runs=self.max_runs,
logger=self._logger,
@@ -896,9 +1047,8 @@ class Script:
return choose_data
def _log(self, msg, *args, level=logging.INFO):
- if self.name:
- msg = f"%s: {msg}"
- args = [self.name, *args]
+ msg = f"%s: {msg}"
+ args = [self.name, *args]
if level == _LOG_EXCEPTION:
self._logger.exception(msg, *args)
diff --git a/homeassistant/helpers/script_variables.py b/homeassistant/helpers/script_variables.py
new file mode 100644
index 00000000000..3140fc4dced
--- /dev/null
+++ b/homeassistant/helpers/script_variables.py
@@ -0,0 +1,64 @@
+"""Script variables."""
+from typing import Any, Dict, Mapping, Optional
+
+from homeassistant.core import HomeAssistant, callback
+
+from . import template
+
+
+class ScriptVariables:
+ """Class to hold and render script variables."""
+
+ def __init__(self, variables: Dict[str, Any]):
+ """Initialize script variables."""
+ self.variables = variables
+ self._has_template: Optional[bool] = None
+
+ @callback
+ def async_render(
+ self,
+ hass: HomeAssistant,
+ run_variables: Optional[Mapping[str, Any]],
+ *,
+ render_as_defaults: bool = True,
+ ) -> Dict[str, Any]:
+ """Render script variables.
+
+ The run variables are used to compute the static variables.
+
+ If `render_as_defaults` is True, the run variables will not be overridden.
+
+ """
+ if self._has_template is None:
+ self._has_template = template.is_complex(self.variables)
+ template.attach(hass, self.variables)
+
+ if not self._has_template:
+ if render_as_defaults:
+ rendered_variables = dict(self.variables)
+
+ if run_variables is not None:
+ rendered_variables.update(run_variables)
+ else:
+ rendered_variables = (
+ {} if run_variables is None else dict(run_variables)
+ )
+ rendered_variables.update(self.variables)
+
+ return rendered_variables
+
+ rendered_variables = {} if run_variables is None else dict(run_variables)
+
+ for key, value in self.variables.items():
+ # We can skip if we're going to override this key with
+ # run variables anyway
+ if render_as_defaults and key in rendered_variables:
+ continue
+
+ rendered_variables[key] = template.render_complex(value, rendered_variables)
+
+ return rendered_variables
+
+ def as_dict(self) -> dict:
+ """Return dict version of this class."""
+ return self.variables
diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py
index 80164d34b69..ad5a36467cf 100644
--- a/homeassistant/helpers/service.py
+++ b/homeassistant/helpers/service.py
@@ -5,6 +5,7 @@ import logging
from typing import (
TYPE_CHECKING,
Any,
+ Awaitable,
Callable,
Dict,
Iterable,
@@ -34,6 +35,7 @@ from homeassistant.exceptions import (
)
from homeassistant.helpers import template
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.template import Template
from homeassistant.helpers.typing import ConfigType, HomeAssistantType, TemplateVarsType
from homeassistant.loader import async_get_integration, bind_hass
from homeassistant.util.yaml import load_yaml
@@ -109,9 +111,12 @@ def async_prepare_call_from_config(
if CONF_SERVICE in config:
domain_service = config[CONF_SERVICE]
else:
+ domain_service = config[CONF_SERVICE_TEMPLATE]
+
+ if isinstance(domain_service, Template):
try:
- config[CONF_SERVICE_TEMPLATE].hass = hass
- domain_service = config[CONF_SERVICE_TEMPLATE].async_render(variables)
+ domain_service.hass = hass
+ domain_service = domain_service.async_render(variables)
domain_service = cv.service(domain_service)
except TemplateError as ex:
raise HomeAssistantError(
@@ -123,14 +128,14 @@ def async_prepare_call_from_config(
) from ex
domain, service = domain_service.split(".", 1)
- service_data = dict(config.get(CONF_SERVICE_DATA, {}))
- if CONF_SERVICE_DATA_TEMPLATE in config:
+ service_data = {}
+ for conf in [CONF_SERVICE_DATA, CONF_SERVICE_DATA_TEMPLATE]:
+ if conf not in config:
+ continue
try:
- template.attach(hass, config[CONF_SERVICE_DATA_TEMPLATE])
- service_data.update(
- template.render_complex(config[CONF_SERVICE_DATA_TEMPLATE], variables)
- )
+ template.attach(hass, config[conf])
+ service_data.update(template.render_complex(config[conf], variables))
except TemplateError as ex:
raise HomeAssistantError(f"Error rendering data template: {ex}") from ex
@@ -499,7 +504,7 @@ def async_register_admin_service(
hass: HomeAssistantType,
domain: str,
service: str,
- service_func: Callable,
+ service_func: Callable[[ha.ServiceCall], Optional[Awaitable]],
schema: vol.Schema = vol.Schema({}, extra=vol.PREVENT_EXTRA),
) -> None:
"""Register a service that requires admin access."""
diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py
index 13525b4dab1..daf9e2f6a89 100644
--- a/homeassistant/helpers/storage.py
+++ b/homeassistant/helpers/storage.py
@@ -20,7 +20,12 @@ _LOGGER = logging.getLogger(__name__)
@bind_hass
async def async_migrator(
- hass, old_path, store, *, old_conf_load_func=None, old_conf_migrate_func=None,
+ hass,
+ old_path,
+ store,
+ *,
+ old_conf_load_func=None,
+ old_conf_migrate_func=None,
):
"""Migrate old data to a store and then load data.
diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py
index ef0b578811e..917581fac07 100644
--- a/homeassistant/helpers/template.py
+++ b/homeassistant/helpers/template.py
@@ -8,7 +8,7 @@ import logging
import math
import random
import re
-from typing import Any, Dict, Iterable, List, Optional, Union
+from typing import Any, Iterable, List, Optional, Union
from urllib.parse import urlencode as urllib_urlencode
import weakref
@@ -16,6 +16,7 @@ import jinja2
from jinja2 import contextfilter, contextfunction
from jinja2.sandbox import ImmutableSandboxedEnvironment
from jinja2.utils import Namespace # type: ignore
+import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
@@ -28,7 +29,8 @@ from homeassistant.const import (
)
from homeassistant.core import State, callback, split_entity_id, valid_entity_id
from homeassistant.exceptions import TemplateError
-from homeassistant.helpers import location as loc_helper
+from homeassistant.helpers import config_validation as cv, location as loc_helper
+from homeassistant.helpers.frame import report
from homeassistant.helpers.typing import HomeAssistantType, TemplateVarsType
from homeassistant.loader import bind_hass
from homeassistant.util import convert, dt as dt_util, location as loc_util
@@ -46,11 +48,15 @@ _ENVIRONMENT = "template.environment"
_RE_NONE_ENTITIES = re.compile(r"distance\(|closest\(", re.I | re.M)
_RE_GET_ENTITIES = re.compile(
- r"(?:(?:states\.|(?Pis_state|is_state_attr|state_attr|states|expand)"
- r"\((?:[\ \'\"]?))(?P[\w]+\.[\w]+)|(?P[\w]+))",
+ r"(?:(?:(?:states\.|(?Pis_state|is_state_attr|state_attr|states|expand)\((?:[\ \'\"]?))(?P[\w]+\.[\w]+)|states\.(?P[a-z]+)|states\[(?:[\'\"]?)(?P[\w]+))|(?P[\w]+))",
re.I | re.M,
)
-_RE_JINJA_DELIMITERS = re.compile(r"\{%|\{\{")
+
+_RE_JINJA_DELIMITERS = re.compile(r"\{%|\{\{|\{#")
+
+_RESERVED_NAMES = {"contextfunction", "evalcontextfunction", "environmentfunction"}
+
+_GROUP_DOMAIN_PREFIX = "group."
@bind_hass
@@ -59,9 +65,10 @@ def attach(hass: HomeAssistantType, obj: Any) -> None:
if isinstance(obj, list):
for child in obj:
attach(hass, child)
- elif isinstance(obj, dict):
- for child in obj.values():
- attach(hass, child)
+ elif isinstance(obj, collections.abc.Mapping):
+ for child_key, child_value in obj.items():
+ attach(hass, child_key)
+ attach(hass, child_value)
elif isinstance(obj, Template):
obj.hass = hass
@@ -70,20 +77,47 @@ def render_complex(value: Any, variables: TemplateVarsType = None) -> Any:
"""Recursive template creator helper function."""
if isinstance(value, list):
return [render_complex(item, variables) for item in value]
- if isinstance(value, dict):
- return {key: render_complex(item, variables) for key, item in value.items()}
+ if isinstance(value, collections.abc.Mapping):
+ return {
+ render_complex(key, variables): render_complex(item, variables)
+ for key, item in value.items()
+ }
if isinstance(value, Template):
return value.async_render(variables)
+
return value
+def is_complex(value: Any) -> bool:
+ """Test if data structure is a complex template."""
+ if isinstance(value, Template):
+ return True
+ if isinstance(value, list):
+ return any(is_complex(val) for val in value)
+ if isinstance(value, collections.abc.Mapping):
+ return any(is_complex(val) for val in value.keys()) or any(
+ is_complex(val) for val in value.values()
+ )
+ return False
+
+
+def is_template_string(maybe_template: str) -> bool:
+ """Check if the input is a Jinja2 template."""
+ return _RE_JINJA_DELIMITERS.search(maybe_template) is not None
+
+
def extract_entities(
hass: HomeAssistantType,
template: Optional[str],
- variables: Optional[Dict[str, Any]] = None,
+ variables: TemplateVarsType = None,
) -> Union[str, List[str]]:
"""Extract all entities for state_changed listener from template string."""
- if template is None or _RE_JINJA_DELIMITERS.search(template) is None:
+
+ report(
+ "called template.extract_entities. Please use event.async_track_template_result instead as it can accurately handle watching entities"
+ )
+
+ if template is None or not is_template_string(template):
return []
if _RE_NONE_ENTITIES.search(template):
@@ -105,6 +139,12 @@ def extract_entities(
extraction_final.append(entity.entity_id)
extraction_final.append(result.group("entity_id"))
+ elif result.group("domain_inner") or result.group("domain_outer"):
+ extraction_final.extend(
+ hass.states.async_entity_ids(
+ result.group("domain_inner") or result.group("domain_outer")
+ )
+ )
if (
variables
@@ -132,39 +172,44 @@ class RenderInfo:
# Will be set sensibly once frozen.
self.filter_lifecycle = _true
self._result = None
- self._exception = None
- self._all_states = False
- self._domains = []
- self._entities = []
+ self.is_static = False
+ self.exception = None
+ self.all_states = False
+ self.domains = set()
+ self.entities = set()
def filter(self, entity_id: str) -> bool:
"""Template should re-render if the state changes."""
- return entity_id in self._entities
+ return entity_id in self.entities
def _filter_lifecycle(self, entity_id: str) -> bool:
"""Template should re-render if the state changes."""
return (
- split_entity_id(entity_id)[0] in self._domains
- or entity_id in self._entities
+ split_entity_id(entity_id)[0] in self.domains or entity_id in self.entities
)
- @property
def result(self) -> str:
"""Results of the template computation."""
- if self._exception is not None:
- raise self._exception
+ if self.exception is not None:
+ raise self.exception
return self._result
+ def _freeze_static(self) -> None:
+ self.is_static = True
+ self.entities = frozenset(self.entities)
+ self.domains = frozenset(self.domains)
+ self.all_states = False
+
def _freeze(self) -> None:
- self._entities = frozenset(self._entities)
- if self._all_states:
- # Leave lifecycle_filter as True
- del self._domains
- elif not self._domains:
- del self._domains
+ self.entities = frozenset(self.entities)
+ self.domains = frozenset(self.domains)
+
+ if self.all_states or self.exception:
+ return
+
+ if not self.domains:
self.filter_lifecycle = self.filter
else:
- self._domains = frozenset(self._domains)
self.filter_lifecycle = self._filter_lifecycle
@@ -180,6 +225,7 @@ class Template:
self._compiled_code = None
self._compiled = None
self.hass = hass
+ self.is_static = not is_template_string(template)
@property
def _env(self):
@@ -198,16 +244,22 @@ class Template:
try:
self._compiled_code = self._env.compile(self.template)
except jinja2.exceptions.TemplateSyntaxError as err:
- raise TemplateError(err)
+ raise TemplateError(err) from err
def extract_entities(
- self, variables: Optional[Dict[str, Any]] = None
+ self, variables: TemplateVarsType = None
) -> Union[str, List[str]]:
"""Extract all entities for state_changed listener."""
+ if self.is_static:
+ return []
+
return extract_entities(self.hass, self.template, variables)
def render(self, variables: TemplateVarsType = None, **kwargs: Any) -> str:
"""Render given template."""
+ if self.is_static:
+ return self.template.strip()
+
if variables is not None:
kwargs.update(variables)
@@ -221,6 +273,9 @@ class Template:
This method must be run in the event loop.
"""
+ if self.is_static:
+ return self.template.strip()
+
compiled = self._compiled or self._ensure_compiled()
if variables is not None:
@@ -229,7 +284,7 @@ class Template:
try:
return compiled.render(kwargs).strip()
except jinja2.TemplateError as err:
- raise TemplateError(err)
+ raise TemplateError(err) from err
@callback
def async_render_to_info(
@@ -237,15 +292,24 @@ class Template:
) -> RenderInfo:
"""Render the template and collect an entity filter."""
assert self.hass and _RENDER_INFO not in self.hass.data
- render_info = self.hass.data[_RENDER_INFO] = RenderInfo(self)
+
+ render_info = RenderInfo(self)
+
# pylint: disable=protected-access
+ if self.is_static:
+ render_info._result = self.template.strip()
+ render_info._freeze_static()
+ return render_info
+
+ self.hass.data[_RENDER_INFO] = render_info
try:
render_info._result = self.async_render(variables, **kwargs)
except TemplateError as ex:
- render_info._exception = ex
+ render_info.exception = ex
finally:
del self.hass.data[_RENDER_INFO]
- render_info._freeze()
+
+ render_info._freeze()
return render_info
def render_with_possible_json_value(self, value, error_value=_SENTINEL):
@@ -253,6 +317,9 @@ class Template:
If valid JSON will expose value_json too.
"""
+ if self.is_static:
+ return self.template
+
return run_callback_threadsafe(
self.hass.loop,
self.async_render_with_possible_json_value,
@@ -270,6 +337,9 @@ class Template:
This method must be run in the event loop.
"""
+ if self.is_static:
+ return self.template
+
if self._compiled is None:
self._ensure_compiled()
@@ -337,15 +407,19 @@ class AllStates:
if not valid_entity_id(name):
raise TemplateError(f"Invalid entity ID '{name}'")
return _get_state(self._hass, name)
+
+ if name in _RESERVED_NAMES:
+ return None
+
if not valid_entity_id(f"{name}.entity"):
raise TemplateError(f"Invalid domain name '{name}'")
+
return DomainStates(self._hass, name)
def _collect_all(self) -> None:
render_info = self._hass.data.get(_RENDER_INFO)
if render_info is not None:
- # pylint: disable=protected-access
- render_info._all_states = True
+ render_info.all_states = True
def __iter__(self):
"""Return all states."""
@@ -390,8 +464,7 @@ class DomainStates:
def _collect_domain(self) -> None:
entity_collect = self._hass.data.get(_RENDER_INFO)
if entity_collect is not None:
- # pylint: disable=protected-access
- entity_collect._domains.append(self._domain)
+ entity_collect.domains.add(self._domain)
def __iter__(self):
"""Return the iteration over all the states."""
@@ -400,8 +473,7 @@ class DomainStates:
sorted(
(
_wrap_state(self._hass, state)
- for state in self._hass.states.async_all()
- if state.domain == self._domain
+ for state in self._hass.states.async_all(self._domain)
),
key=lambda state: state.entity_id,
)
@@ -430,7 +502,6 @@ class TemplateState(State):
def _access_state(self):
state = object.__getattribute__(self, "_state")
hass = object.__getattribute__(self, "_hass")
-
_collect_state(hass, state.entity_id)
return state
@@ -443,6 +514,13 @@ class TemplateState(State):
return state.state
return f"{state.state} {unit}"
+ def __eq__(self, other: Any) -> bool:
+ """Ensure we collect on equality check."""
+ state = object.__getattribute__(self, "_state")
+ hass = object.__getattribute__(self, "_hass")
+ _collect_state(hass, state.entity_id)
+ return super().__eq__(other)
+
def __getattribute__(self, name):
"""Return an attribute of the state."""
# This one doesn't count as an access of the state
@@ -466,8 +544,7 @@ class TemplateState(State):
def _collect_state(hass: HomeAssistantType, entity_id: str) -> None:
entity_collect = hass.data.get(_RENDER_INFO)
if entity_collect is not None:
- # pylint: disable=protected-access
- entity_collect._entities.append(entity_id)
+ entity_collect.entities.add(entity_id)
def _wrap_state(
@@ -498,6 +575,19 @@ def _resolve_state(
return None
+def result_as_boolean(template_result: Optional[str]) -> bool:
+ """Convert the template result to a boolean.
+
+ True/not 0/'1'/'true'/'yes'/'on'/'enable' are considered truthy
+ False/0/None/'0'/'false'/'no'/'off'/'disable' are considered falsy
+
+ """
+ try:
+ return cv.boolean(template_result)
+ except vol.Invalid:
+ return False
+
+
def expand(hass: HomeAssistantType, *args: Any) -> Iterable[State]:
"""Expand out any groups into entity states."""
search = list(args)
@@ -518,16 +608,15 @@ def expand(hass: HomeAssistantType, *args: Any) -> Iterable[State]:
# ignore other types
continue
- # pylint: disable=import-outside-toplevel
- from homeassistant.components import group
-
- if split_entity_id(entity_id)[0] == group.DOMAIN:
+ if entity_id.startswith(_GROUP_DOMAIN_PREFIX):
# Collect state will be called in here since it's wrapped
group_entities = entity.attributes.get(ATTR_ENTITY_ID)
if group_entities:
search += group_entities
else:
+ _collect_state(hass, entity_id)
found[entity_id] = entity
+
return sorted(found.values(), key=lambda a: a.entity_id)
@@ -613,7 +702,10 @@ def distance(hass, *args):
while to_process:
value = to_process.pop(0)
- point_state = _resolve_state(hass, value)
+ if isinstance(value, str) and not valid_entity_id(value):
+ point_state = None
+ else:
+ point_state = _resolve_state(hass, value)
if point_state is None:
# We expect this and next value to be lat&lng
@@ -972,6 +1064,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.filters["atan2"] = arc_tangent2
self.filters["sqrt"] = square_root
self.filters["as_timestamp"] = forgiving_as_timestamp
+ self.filters["as_local"] = dt_util.as_local
self.filters["timestamp_custom"] = timestamp_custom
self.filters["timestamp_local"] = timestamp_local
self.filters["timestamp_utc"] = timestamp_utc
@@ -1005,6 +1098,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
self.globals["atan2"] = arc_tangent2
self.globals["float"] = forgiving_float
self.globals["now"] = dt_util.now
+ self.globals["as_local"] = dt_util.as_local
self.globals["utcnow"] = dt_util.utcnow
self.globals["as_timestamp"] = forgiving_as_timestamp
self.globals["relative_time"] = relative_time
@@ -1042,7 +1136,13 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
def is_safe_attribute(self, obj, attr, value):
"""Test if attribute is safe."""
- return isinstance(obj, Namespace) or super().is_safe_attribute(obj, attr, value)
+ if isinstance(obj, Namespace):
+ return True
+
+ if isinstance(obj, (AllStates, DomainStates, TemplateState)):
+ return not attr.startswith("_")
+
+ return super().is_safe_attribute(obj, attr, value)
def compile(self, source, name=None, filename=None, raw=False, defer_init=False):
"""Compile the template."""
diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py
index 217f16d1841..53d808a5a85 100644
--- a/homeassistant/helpers/translation.py
+++ b/homeassistant/helpers/translation.py
@@ -92,7 +92,9 @@ def load_translations_files(
def merge_resources(
- translation_strings: Dict[str, Dict[str, Any]], components: Set[str], category: str,
+ translation_strings: Dict[str, Dict[str, Any]],
+ components: Set[str],
+ category: str,
) -> Dict[str, Dict[str, Any]]:
"""Build and merge the resources response for the given components and platforms."""
# Build response
@@ -153,7 +155,9 @@ def merge_resources(
def build_resources(
- translation_strings: Dict[str, Dict[str, Any]], components: Set[str], category: str,
+ translation_strings: Dict[str, Dict[str, Any]],
+ components: Set[str],
+ category: str,
) -> Dict[str, Dict[str, Any]]:
"""Build the resources response for the given components."""
# Build response
diff --git a/homeassistant/helpers/trigger.py b/homeassistant/helpers/trigger.py
new file mode 100644
index 00000000000..f9dd91dc2f1
--- /dev/null
+++ b/homeassistant/helpers/trigger.py
@@ -0,0 +1,95 @@
+"""Triggers."""
+import asyncio
+import logging
+from types import MappingProxyType
+from typing import Any, Callable, Dict, List, Optional, Union
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_PLATFORM
+from homeassistant.core import CALLBACK_TYPE, callback
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+from homeassistant.loader import IntegrationNotFound, async_get_integration
+
+_PLATFORM_ALIASES = {
+ "device_automation": ("device",),
+ "homeassistant": ("event", "numeric_state", "state", "time_pattern", "time"),
+}
+
+
+async def _async_get_trigger_platform(
+ hass: HomeAssistantType, config: ConfigType
+) -> Any:
+ platform = config[CONF_PLATFORM]
+ for alias, triggers in _PLATFORM_ALIASES.items():
+ if platform in triggers:
+ platform = alias
+ break
+ try:
+ integration = await async_get_integration(hass, platform)
+ except IntegrationNotFound:
+ raise vol.Invalid(f"Invalid platform '{platform}' specified") from None
+ try:
+ return integration.get_platform("trigger")
+ except ImportError:
+ raise vol.Invalid(
+ f"Integration '{platform}' does not provide trigger support"
+ ) from None
+
+
+async def async_validate_trigger_config(
+ hass: HomeAssistantType, trigger_config: List[ConfigType]
+) -> List[ConfigType]:
+ """Validate triggers."""
+ config = []
+ for conf in trigger_config:
+ platform = await _async_get_trigger_platform(hass, conf)
+ if hasattr(platform, "async_validate_trigger_config"):
+ conf = await platform.async_validate_trigger_config(hass, conf)
+ else:
+ conf = platform.TRIGGER_SCHEMA(conf)
+ config.append(conf)
+ return config
+
+
+async def async_initialize_triggers(
+ hass: HomeAssistantType,
+ trigger_config: List[ConfigType],
+ action: Callable,
+ domain: str,
+ name: str,
+ log_cb: Callable,
+ home_assistant_start: bool = False,
+ variables: Optional[Union[Dict[str, Any], MappingProxyType]] = None,
+) -> Optional[CALLBACK_TYPE]:
+ """Initialize triggers."""
+ info = {
+ "domain": domain,
+ "name": name,
+ "home_assistant_start": home_assistant_start,
+ "variables": variables,
+ }
+
+ triggers = []
+ for conf in trigger_config:
+ platform = await _async_get_trigger_platform(hass, conf)
+ triggers.append(platform.async_attach_trigger(hass, conf, action, info))
+
+ removes = await asyncio.gather(*triggers)
+
+ if None in removes:
+ log_cb(logging.ERROR, "Error setting up trigger")
+
+ removes = list(filter(None, removes))
+ if not removes:
+ return None
+
+ log_cb(logging.INFO, "Initialized trigger")
+
+ @callback
+ def remove_triggers(): # type: ignore
+ """Remove triggers."""
+ for remove in removes:
+ remove()
+
+ return remove_triggers
diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py
index c7859d9d1d9..bed0d2b8d17 100644
--- a/homeassistant/helpers/typing.py
+++ b/homeassistant/helpers/typing.py
@@ -1,10 +1,8 @@
"""Typing Helpers for Home Assistant."""
-from typing import Any, Dict, Optional, Tuple
+from typing import Any, Dict, Mapping, Optional, Tuple, Union
import homeassistant.core
-# pylint: disable=invalid-name
-
GPSType = Tuple[float, float]
ConfigType = Dict[str, Any]
ContextType = homeassistant.core.Context
@@ -13,7 +11,8 @@ EventType = homeassistant.core.Event
HomeAssistantType = homeassistant.core.HomeAssistant
ServiceCallType = homeassistant.core.ServiceCall
ServiceDataType = Dict[str, Any]
-TemplateVarsType = Optional[Dict[str, Any]]
+StateType = Union[None, str, int, float]
+TemplateVarsType = Optional[Mapping[str, Any]]
# Custom type for recorder Queries
QueryType = Any
diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py
index 7b7e6af4d62..44e10243598 100644
--- a/homeassistant/helpers/update_coordinator.py
+++ b/homeassistant/helpers/update_coordinator.py
@@ -10,7 +10,7 @@ import aiohttp
import requests
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
-from homeassistant.helpers import event
+from homeassistant.helpers import entity, event
from homeassistant.util.dt import utcnow
from .debounce import Debouncer
@@ -190,3 +190,40 @@ class DataUpdateCoordinator(Generic[T]):
for update_callback in self._listeners:
update_callback()
+
+
+class CoordinatorEntity(entity.Entity):
+ """A class for entities using DataUpdateCoordinator."""
+
+ def __init__(self, coordinator: DataUpdateCoordinator) -> None:
+ """Create the entity with a DataUpdateCoordinator."""
+ self.coordinator = coordinator
+
+ @property
+ def should_poll(self) -> bool:
+ """No need to poll. Coordinator notifies entity of updates."""
+ return False
+
+ @property
+ def available(self) -> bool:
+ """Return if entity is available."""
+ return self.coordinator.last_update_success
+
+ async def async_added_to_hass(self) -> None:
+ """When entity is added to hass."""
+ await super().async_added_to_hass()
+ self.async_on_remove(
+ self.coordinator.async_add_listener(self.async_write_ha_state)
+ )
+
+ async def async_update(self) -> None:
+ """Update the entity.
+
+ Only used by the generic entity update service.
+ """
+
+ # Ignore manual update requests if the entity is disabled
+ if not self.enabled:
+ return
+
+ await self.coordinator.async_request_refresh()
diff --git a/homeassistant/loader.py b/homeassistant/loader.py
index b82f2c0109a..53f793678c0 100644
--- a/homeassistant/loader.py
+++ b/homeassistant/loader.py
@@ -145,18 +145,25 @@ async def async_get_config_flows(hass: "HomeAssistant") -> Set[str]:
return flows
-async def async_get_zeroconf(hass: "HomeAssistant") -> Dict[str, List]:
+async def async_get_zeroconf(hass: "HomeAssistant") -> Dict[str, List[Dict[str, str]]]:
"""Return cached list of zeroconf types."""
- zeroconf: Dict[str, List] = ZEROCONF.copy()
+ zeroconf: Dict[str, List[Dict[str, str]]] = ZEROCONF.copy()
integrations = await async_get_custom_components(hass)
for integration in integrations.values():
if not integration.zeroconf:
continue
- for typ in integration.zeroconf:
- zeroconf.setdefault(typ, [])
- if integration.domain not in zeroconf[typ]:
- zeroconf[typ].append(integration.domain)
+ for entry in integration.zeroconf:
+ data = {"domain": integration.domain}
+ if isinstance(entry, dict):
+ typ = entry["type"]
+ entry_without_type = entry.copy()
+ del entry_without_type["type"]
+ data.update(entry_without_type)
+ else:
+ typ = entry
+
+ zeroconf.setdefault(typ, []).append(data)
return zeroconf
@@ -271,6 +278,11 @@ class Integration:
"""Return name."""
return cast(str, self.manifest["name"])
+ @property
+ def disabled(self) -> Optional[str]:
+ """Return reason integration is disabled."""
+ return cast(Optional[str], self.manifest.get("disabled"))
+
@property
def domain(self) -> str:
"""Return domain."""
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
index 58d3e3be883..e5978245bac 100644
--- a/homeassistant/package_constraints.txt
+++ b/homeassistant/package_constraints.txt
@@ -6,29 +6,30 @@ astral==1.10.1
async_timeout==3.0.1
attrs==19.3.0
bcrypt==3.1.7
-certifi>=2020.4.5.1
+certifi>=2020.6.20
ciso8601==2.1.3
cryptography==2.9.2
defusedxml==0.6.0
distro==1.5.0
emoji==0.5.4
-hass-nabucasa==0.35.0
-home-assistant-frontend==20200811.0
+hass-nabucasa==0.37.0
+home-assistant-frontend==20200917.1
importlib-metadata==1.6.0;python_version<'3.8'
-jinja2>=2.11.1
+jinja2>=2.11.2
netdisco==2.8.2
paho-mqtt==1.5.0
+pillow==7.2.0
pip>=8.0.3
python-slugify==4.0.1
pytz>=2020.1
pyyaml==5.3.1
requests==2.24.0
ruamel.yaml==0.15.100
-sqlalchemy==1.3.18
+sqlalchemy==1.3.19
voluptuous-serialize==2.4.0
voluptuous==0.11.7
yarl==1.4.2
-zeroconf==0.28.1
+zeroconf==0.28.5
pycryptodome>=3.6.6
@@ -38,8 +39,14 @@ urllib3>=1.24.3
# Constrain httplib2 to protect against CVE-2020-11078
httplib2>=0.18.0
-# Not needed for our supported Python versions
-enum34==1000000000.0.0
-
# This is a old unmaintained library and is replaced with pycryptodome
pycrypto==1000000000.0.0
+
+# To remove reliance on typing
+btlewrap>=0.0.10
+
+# This overrides a built-in Python package
+enum34==1000000000.0.0
+typing==1000000000.0.0
+uuid==1000000000.0.0
+
diff --git a/homeassistant/runner.py b/homeassistant/runner.py
index b397f9438f2..a5cf0f88a40 100644
--- a/homeassistant/runner.py
+++ b/homeassistant/runner.py
@@ -46,7 +46,7 @@ class RuntimeConfig:
if sys.platform == "win32" and sys.version_info[:2] < (3, 8):
PolicyBase = asyncio.WindowsProactorEventLoopPolicy
else:
- PolicyBase = asyncio.DefaultEventLoopPolicy # pylint: disable=invalid-name
+ PolicyBase = asyncio.DefaultEventLoopPolicy
class HassEventLoopPolicy(PolicyBase): # type: ignore
@@ -117,7 +117,7 @@ def _async_loop_exception_handler(_: Any, context: Dict) -> None:
)
-async def setup_and_run_hass(runtime_config: RuntimeConfig,) -> int:
+async def setup_and_run_hass(runtime_config: RuntimeConfig) -> int:
"""Set up Home Assistant and run."""
hass = await bootstrap.async_setup_hass(runtime_config)
diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py
index d2091bb6003..48e6d7d5302 100644
--- a/homeassistant/scripts/benchmark/__init__.py
+++ b/homeassistant/scripts/benchmark/__init__.py
@@ -231,7 +231,7 @@ async def _logbook_filtering(hass, last_changed, last_updated):
start = timer()
- list(logbook.humanify(hass, yield_events(event), entity_attr_cache))
+ list(logbook.humanify(hass, yield_events(event), entity_attr_cache, {}))
return timer() - start
diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py
index e96bc57624f..8d0235413db 100644
--- a/homeassistant/scripts/check_config.py
+++ b/homeassistant/scripts/check_config.py
@@ -17,7 +17,7 @@ import homeassistant.util.yaml.loader as yaml_loader
# mypy: allow-untyped-calls, allow-untyped-defs
-REQUIREMENTS = ("colorlog==4.1.0",)
+REQUIREMENTS = ("colorlog==4.2.1",)
_LOGGER = logging.getLogger(__name__)
# pylint: disable=protected-access
@@ -45,7 +45,7 @@ def color(the_color, *args, reset=None):
return parse_colors(the_color)
return parse_colors(the_color) + " ".join(args) + escape_codes[reset or "reset"]
except KeyError as k:
- raise ValueError(f"Invalid color {k!s} in {the_color}")
+ raise ValueError(f"Invalid color {k!s} in {the_color}") from k
def run(script_args: List) -> int:
diff --git a/homeassistant/setup.py b/homeassistant/setup.py
index 578cd33b097..818e28479fb 100644
--- a/homeassistant/setup.py
+++ b/homeassistant/setup.py
@@ -71,27 +71,47 @@ async def _async_process_dependencies(
hass: core.HomeAssistant, config: ConfigType, integration: loader.Integration
) -> bool:
"""Ensure all dependencies are set up."""
- tasks = {
+ dependencies_tasks = {
dep: hass.loop.create_task(async_setup_component(hass, dep, config))
for dep in integration.dependencies
+ if dep not in hass.config.components
}
+ after_dependencies_tasks = dict()
to_be_loaded = hass.data.get(DATA_SETUP_DONE, {})
for dep in integration.after_dependencies:
- if dep in to_be_loaded and dep not in hass.config.components:
- tasks[dep] = hass.loop.create_task(to_be_loaded[dep].wait())
+ if (
+ dep not in dependencies_tasks
+ and dep in to_be_loaded
+ and dep not in hass.config.components
+ ):
+ after_dependencies_tasks[dep] = hass.loop.create_task(
+ to_be_loaded[dep].wait()
+ )
- if not tasks:
+ if not dependencies_tasks and not after_dependencies_tasks:
return True
- _LOGGER.debug("Dependency %s will wait for %s", integration.domain, list(tasks))
+ if dependencies_tasks:
+ _LOGGER.debug(
+ "Dependency %s will wait for dependencies %s",
+ integration.domain,
+ list(dependencies_tasks),
+ )
+ if after_dependencies_tasks:
+ _LOGGER.debug(
+ "Dependency %s will wait for after dependencies %s",
+ integration.domain,
+ list(after_dependencies_tasks),
+ )
+
async with hass.timeout.async_freeze(integration.domain):
- results = await asyncio.gather(*tasks.values())
+ results = await asyncio.gather(
+ *dependencies_tasks.values(), *after_dependencies_tasks.values()
+ )
failed = [
- domain
- for idx, domain in enumerate(integration.dependencies)
- if not results[idx]
+ domain for idx, domain in enumerate(dependencies_tasks) if not results[idx]
]
if failed:
@@ -124,6 +144,10 @@ async def _async_setup_component(
log_error("Integration not found.")
return False
+ if integration.disabled:
+ log_error(f"dependency is disabled - {integration.disabled}")
+ return False
+
# Validate all dependencies exist and there are no circular dependencies
if not await integration.resolve_dependencies():
return False
@@ -173,9 +197,7 @@ async def _async_setup_component(
try:
if hasattr(component, "async_setup"):
- task = component.async_setup( # type: ignore
- hass, processed_config
- )
+ task = component.async_setup(hass, processed_config) # type: ignore
elif hasattr(component, "setup"):
# This should not be replaced with hass.async_add_executor_job because
# we don't want to track this task in case it blocks startup.
diff --git a/homeassistant/strings.json b/homeassistant/strings.json
index 26622feadfb..05bc2e3c247 100644
--- a/homeassistant/strings.json
+++ b/homeassistant/strings.json
@@ -31,6 +31,7 @@
"host": "Host",
"ip": "IP Address",
"port": "Port",
+ "url": "URL",
"usb_path": "USB Device Path",
"access_token": "Access Token",
"api_key": "API Key"
@@ -52,7 +53,8 @@
"already_configured_device": "Device is already configured",
"no_devices_found": "No devices found on the network",
"oauth2_missing_configuration": "The component is not configured. Please follow the documentation.",
- "oauth2_authorize_url_timeout": "Timeout generating authorize URL."
+ "oauth2_authorize_url_timeout": "Timeout generating authorize URL.",
+ "oauth2_no_url_available": "No URL available. For information about this error, [check the help section]({docs_url})"
}
}
}
diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py
index a33f548a8a9..0b48e2159c0 100644
--- a/homeassistant/util/async_.py
+++ b/homeassistant/util/async_.py
@@ -10,7 +10,7 @@ from typing import Any, Callable, Coroutine, TypeVar
_LOGGER = logging.getLogger(__name__)
-T = TypeVar("T") # pylint: disable=invalid-name
+T = TypeVar("T")
def fire_coroutine_threadsafe(coro: Coroutine, loop: AbstractEventLoop) -> None:
diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py
index 413c8d4f920..d16e02913b5 100644
--- a/homeassistant/util/dt.py
+++ b/homeassistant/util/dt.py
@@ -52,7 +52,7 @@ def get_time_zone(time_zone_str: str) -> Optional[dt.tzinfo]:
def utcnow() -> dt.datetime:
"""Get now in UTC time."""
- return dt.datetime.now(UTC)
+ return dt.datetime.utcnow().replace(tzinfo=UTC)
def now(time_zone: Optional[dt.tzinfo] = None) -> dt.datetime:
diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py
index 51d7c26a554..e906462a250 100644
--- a/homeassistant/util/json.py
+++ b/homeassistant/util/json.py
@@ -35,10 +35,10 @@ def load_json(
_LOGGER.debug("JSON file not found: %s", filename)
except ValueError as error:
_LOGGER.exception("Could not parse JSON content: %s", filename)
- raise HomeAssistantError(error)
+ raise HomeAssistantError(error) from error
except OSError as error:
_LOGGER.exception("JSON file reading failed: %s", filename)
- raise HomeAssistantError(error)
+ raise HomeAssistantError(error) from error
return {} if default is None else default
@@ -54,11 +54,11 @@ def save_json(
Returns True on success.
"""
try:
- json_data = json.dumps(data, sort_keys=True, indent=4, cls=encoder)
- except TypeError:
+ json_data = json.dumps(data, indent=4, cls=encoder)
+ except TypeError as error:
msg = f"Failed to serialize to JSON: {filename}. Bad data at {format_unserializable_data(find_paths_unserializable_data(data))}"
_LOGGER.error(msg)
- raise SerializationError(msg)
+ raise SerializationError(msg) from error
tmp_filename = ""
tmp_path = os.path.split(filename)[0]
@@ -74,7 +74,7 @@ def save_json(
os.replace(tmp_filename, filename)
except OSError as error:
_LOGGER.exception("Saving JSON file failed: %s", filename)
- raise WriteError(error)
+ raise WriteError(error) from error
finally:
if os.path.exists(tmp_filename):
try:
diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py
index ed710f573f4..629928f43d7 100644
--- a/homeassistant/util/logging.py
+++ b/homeassistant/util/logging.py
@@ -34,7 +34,7 @@ class HomeAssistantQueueHandler(logging.handlers.QueueHandler):
"""Emit a log record."""
try:
self.enqueue(record)
- except asyncio.CancelledError: # pylint: disable=try-except-raise
+ except asyncio.CancelledError:
raise
except Exception: # pylint: disable=broad-except
self.handleError(record)
diff --git a/homeassistant/util/ruamel_yaml.py b/homeassistant/util/ruamel_yaml.py
index 8635de00fe4..496ca377936 100644
--- a/homeassistant/util/ruamel_yaml.py
+++ b/homeassistant/util/ruamel_yaml.py
@@ -70,7 +70,7 @@ def object_to_yaml(data: JSON_TYPE) -> str:
return result
except YAMLError as exc:
_LOGGER.error("YAML error: %s", exc)
- raise HomeAssistantError(exc)
+ raise HomeAssistantError(exc) from exc
def yaml_to_object(data: str) -> JSON_TYPE:
@@ -81,7 +81,7 @@ def yaml_to_object(data: str) -> JSON_TYPE:
return result
except YAMLError as exc:
_LOGGER.error("YAML error: %s", exc)
- raise HomeAssistantError(exc)
+ raise HomeAssistantError(exc) from exc
def load_yaml(fname: str, round_trip: bool = False) -> JSON_TYPE:
@@ -102,10 +102,10 @@ def load_yaml(fname: str, round_trip: bool = False) -> JSON_TYPE:
return yaml.load(conf_file) or OrderedDict()
except YAMLError as exc:
_LOGGER.error("YAML error in %s: %s", fname, exc)
- raise HomeAssistantError(exc)
+ raise HomeAssistantError(exc) from exc
except UnicodeDecodeError as exc:
_LOGGER.error("Unable to read file %s: %s", fname, exc)
- raise HomeAssistantError(exc)
+ raise HomeAssistantError(exc) from exc
def save_yaml(fname: str, data: JSON_TYPE) -> None:
@@ -132,10 +132,10 @@ def save_yaml(fname: str, data: JSON_TYPE) -> None:
pass
except YAMLError as exc:
_LOGGER.error(str(exc))
- raise HomeAssistantError(exc)
+ raise HomeAssistantError(exc) from exc
except OSError as exc:
_LOGGER.exception("Saving YAML file %s failed: %s", fname, exc)
- raise WriteError(exc)
+ raise WriteError(exc) from exc
finally:
if os.path.exists(tmp_fname):
try:
diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py
index 719910987e8..7b987d8eeb2 100644
--- a/homeassistant/util/ssl.py
+++ b/homeassistant/util/ssl.py
@@ -1,4 +1,5 @@
"""Helper to create SSL contexts."""
+from os import environ
import ssl
import certifi
@@ -6,9 +7,12 @@ import certifi
def client_context() -> ssl.SSLContext:
"""Return an SSL context for making requests."""
- context = ssl.create_default_context(
- purpose=ssl.Purpose.SERVER_AUTH, cafile=certifi.where()
- )
+
+ # Reuse environment variable definition from requests, since it's already a requirement
+ # If the environment variable has no value, fall back to using certs from certifi package
+ cafile = environ.get("REQUESTS_CA_BUNDLE", certifi.where())
+
+ context = ssl.create_default_context(purpose=ssl.Purpose.SERVER_AUTH, cafile=cafile)
return context
diff --git a/homeassistant/util/timeout.py b/homeassistant/util/timeout.py
index 908d36a41bb..1193c701712 100644
--- a/homeassistant/util/timeout.py
+++ b/homeassistant/util/timeout.py
@@ -255,7 +255,10 @@ class _ZoneTaskContext:
"""Context manager that tracks an active task for a zone."""
def __init__(
- self, zone: _ZoneTimeoutManager, task: asyncio.Task[Any], timeout: float,
+ self,
+ zone: _ZoneTimeoutManager,
+ task: asyncio.Task[Any],
+ timeout: float,
) -> None:
"""Initialize internal timeout context manager."""
self._loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py
index 8b276da432d..bac66cd28bc 100644
--- a/homeassistant/util/unit_system.py
+++ b/homeassistant/util/unit_system.py
@@ -137,9 +137,7 @@ class UnitSystem:
raise TypeError(f"{volume!s} is not a numeric value.")
# type ignore: https://github.com/python/mypy/issues/7207
- return volume_util.convert( # type: ignore
- volume, from_unit, self.volume_unit
- )
+ return volume_util.convert(volume, from_unit, self.volume_unit) # type: ignore
def as_dict(self) -> dict:
"""Convert the unit system to a dictionary."""
diff --git a/homeassistant/util/uuid.py b/homeassistant/util/uuid.py
new file mode 100644
index 00000000000..c91cfe0dd12
--- /dev/null
+++ b/homeassistant/util/uuid.py
@@ -0,0 +1,15 @@
+"""Helpers to generate uuids."""
+
+import random
+import uuid
+
+
+def uuid_v1mc_hex() -> str:
+ """Generate a uuid1 with a random multicast MAC address.
+
+ The uuid1 uses a random multicast MAC address instead of the real MAC address
+ of the machine without the overhead of calling the getrandom() system call.
+
+ This is effectively equivalent to PostgreSQL's uuid_generate_v1mc() function
+ """
+ return uuid.uuid1(node=random.getrandbits(48) | (1 << 40)).hex
diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py
index 37df6bb89f5..3d11e943a91 100644
--- a/homeassistant/util/yaml/dumper.py
+++ b/homeassistant/util/yaml/dumper.py
@@ -10,9 +10,9 @@ from .objects import NodeListClass
def dump(_dict: dict) -> str:
"""Dump YAML to a string and remove null."""
- return yaml.safe_dump(_dict, default_flow_style=False, allow_unicode=True).replace(
- ": null\n", ":\n"
- )
+ return yaml.safe_dump(
+ _dict, default_flow_style=False, allow_unicode=True, sort_keys=False
+ ).replace(": null\n", ":\n")
def save_yaml(path: str, data: dict) -> None:
diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py
index 982446597e5..7e954f21e1a 100644
--- a/homeassistant/util/yaml/loader.py
+++ b/homeassistant/util/yaml/loader.py
@@ -61,10 +61,10 @@ def load_yaml(fname: str) -> JSON_TYPE:
return yaml.load(conf_file, Loader=SafeLineLoader) or OrderedDict()
except yaml.YAMLError as exc:
_LOGGER.error(str(exc))
- raise HomeAssistantError(exc)
+ raise HomeAssistantError(exc) from exc
except UnicodeDecodeError as exc:
_LOGGER.error("Unable to read file %s: %s", fname, exc)
- raise HomeAssistantError(exc)
+ raise HomeAssistantError(exc) from exc
@overload
@@ -88,9 +88,7 @@ def _add_reference(
...
-def _add_reference( # type: ignore
- obj, loader: SafeLineLoader, node: yaml.nodes.Node
-):
+def _add_reference(obj, loader: SafeLineLoader, node: yaml.nodes.Node): # type: ignore
"""Add file reference information to an object."""
if isinstance(obj, list):
obj = NodeListClass(obj)
@@ -111,8 +109,10 @@ def _include_yaml(loader: SafeLineLoader, node: yaml.nodes.Node) -> JSON_TYPE:
fname = os.path.join(os.path.dirname(loader.name), node.value)
try:
return _add_reference(load_yaml(fname), loader, node)
- except FileNotFoundError:
- raise HomeAssistantError(f"{node.start_mark}: Unable to read file {fname}.")
+ except FileNotFoundError as exc:
+ raise HomeAssistantError(
+ f"{node.start_mark}: Unable to read file {fname}."
+ ) from exc
def _is_file_valid(name: str) -> bool:
@@ -197,12 +197,12 @@ def _ordered_dict(loader: SafeLineLoader, node: yaml.nodes.MappingNode) -> Order
try:
hash(key)
- except TypeError:
+ except TypeError as exc:
fname = getattr(loader.stream, "name", "")
raise yaml.MarkedYAMLError(
context=f'invalid key: "{key}"',
context_mark=yaml.Mark(fname, 0, line, -1, None, None),
- )
+ ) from exc
if key in seen:
fname = getattr(loader.stream, "name", "")
diff --git a/pylintrc b/pylintrc
index f2860026cd8..86f3d4caea0 100644
--- a/pylintrc
+++ b/pylintrc
@@ -2,7 +2,8 @@
ignore=tests
# Use a conservative default here; 2 should speed up most setups and not hurt
# any too bad. Override on command line as appropriate.
-jobs=2
+# Disabled for now: https://github.com/PyCQA/pylint/issues/3584
+#jobs=2
load-plugins=pylint_strict_informational
persistent=no
extension-pkg-whitelist=ciso8601,cv2
@@ -46,6 +47,7 @@ disable=
too-many-boolean-expressions,
unused-argument,
wrong-import-order
+# enable useless-suppression temporarily every now and then to clean them up
enable=
use-symbolic-message-instead
diff --git a/requirements.txt b/requirements.txt
index 702e4eaf19f..baa48241a06 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,10 +6,10 @@ astral==1.10.1
async_timeout==3.0.1
attrs==19.3.0
bcrypt==3.1.7
-certifi>=2020.4.5.1
+certifi>=2020.6.20
ciso8601==2.1.3
importlib-metadata==1.6.0;python_version<'3.8'
-jinja2>=2.11.1
+jinja2>=2.11.2
PyJWT==1.7.1
cryptography==2.9.2
pip>=8.0.3
diff --git a/requirements_all.txt b/requirements_all.txt
index 05998cfa10d..7ff2d0695b5 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -1,5 +1,4 @@
# Home Assistant Core, full dependency set
--c homeassistant/package_constraints.txt
-r requirements.txt
# homeassistant.components.nuimo_controller
@@ -27,7 +26,7 @@ Mastodon.py==1.5.1
OPi.GPIO==0.4.0
# homeassistant.components.plugwise
-Plugwise_Smile==1.1.0
+Plugwise_Smile==1.4.0
# homeassistant.components.essent
PyEssent==0.13
@@ -85,7 +84,7 @@ RtmAPI==0.7.2
TravisPy==0.3.5
# homeassistant.components.twitter
-TwitterAPI==2.5.11
+TwitterAPI==2.5.13
# homeassistant.components.tof
# VL53L1X2==0.1.5
@@ -103,7 +102,7 @@ YesssSMS==0.4.1
abodepy==1.1.0
# homeassistant.components.accuweather
-accuweather==0.0.9
+accuweather==0.0.10
# homeassistant.components.mcp23017
adafruit-blinka==3.9.0
@@ -145,7 +144,7 @@ aio_georss_gdacs==0.3
aioambient==1.2.1
# homeassistant.components.asuswrt
-aioasuswrt==1.2.7
+aioasuswrt==1.2.8
# homeassistant.components.azure_devops
aioazuredevops==1.3.5
@@ -157,8 +156,14 @@ aiobotocore==0.11.1
# homeassistant.components.minecraft_server
aiodns==2.0.0
+# homeassistant.components.eafm
+aioeafm==0.1.2
+
# homeassistant.components.esphome
-aioesphomeapi==2.6.1
+aioesphomeapi==2.6.3
+
+# homeassistant.components.flo
+aioflo==0.4.1
# homeassistant.components.freebox
aiofreepybox==0.0.8
@@ -173,7 +178,7 @@ aioguardian==1.0.1
aioharmony==0.2.6
# homeassistant.components.homekit_controller
-aiohomekit[IP]==0.2.46
+aiohomekit==0.2.53
# homeassistant.components.emulated_hue
# homeassistant.components.http
@@ -210,19 +215,22 @@ aiopulse==0.4.0
aiopvapi==1.6.14
# homeassistant.components.pvpc_hourly_pricing
-aiopvpc==1.0.2
+aiopvpc==2.0.2
# homeassistant.components.webostv
aiopylgtv==0.3.3
+# homeassistant.components.shelly
+aioshelly==0.3.2
+
# homeassistant.components.switcher_kis
-aioswitcher==1.2.0
+aioswitcher==1.2.1
# homeassistant.components.unifi
aiounifi==23
# homeassistant.components.yandex_transport
-aioymaps==1.0.0
+aioymaps==1.1.0
# homeassistant.components.airly
airly==0.0.2
@@ -240,7 +248,7 @@ ambiclimate==0.2.1
amcrest==1.7.0
# homeassistant.components.androidtv
-androidtv[async]==0.0.47
+androidtv[async]==0.0.50
# homeassistant.components.anel_pwrctrl
anel_pwrctrl-homeassistant==0.0.1.dev2
@@ -255,7 +263,7 @@ apcaccess==0.0.13
apns2==0.3.0
# homeassistant.components.apprise
-apprise==0.8.5
+apprise==0.8.8
# homeassistant.components.aprs
aprslib==0.6.46
@@ -264,7 +272,7 @@ aprslib==0.6.46
aqualogic==1.0
# homeassistant.components.arcam_fmj
-arcam-fmj==0.5.1
+arcam-fmj==0.5.3
# homeassistant.components.arris_tg2492lg
arris-tg2492lg==1.0.0
@@ -279,6 +287,9 @@ asterisk_mbox==0.5.0
# homeassistant.components.upnp
async-upnp-client==0.14.13
+# homeassistant.components.supla
+asyncpysupla==0.0.5
+
# homeassistant.components.aten_pe
atenpdu==0.3.0
@@ -289,7 +300,7 @@ aurorapy==0.2.6
av==8.0.2
# homeassistant.components.avea
-avea==1.4
+# avea==1.4
# homeassistant.components.avion
# avion==0.10
@@ -298,7 +309,7 @@ avea==1.4
avri-api==0.1.7
# homeassistant.components.axis
-axis==33
+axis==35
# homeassistant.components.azure_event_hub
azure-eventhub==5.1.0
@@ -322,13 +333,13 @@ batinfo==0.4.2
# beacontools[scan]==1.2.3
# homeassistant.components.scrape
-beautifulsoup4==4.9.0
+beautifulsoup4==4.9.1
# homeassistant.components.beewi_smartclim
-beewi_smartclim==0.0.7
+# beewi_smartclim==0.0.7
# homeassistant.components.zha
-bellows==0.18.1
+bellows==0.20.2
# homeassistant.components.bmw_connected_drive
bimmer_connected==0.7.7
@@ -359,7 +370,7 @@ blockchain==1.4.4
# bme680==1.0.5
# homeassistant.components.bom
-bomradarloop==0.1.4
+bomradarloop==0.1.5
# homeassistant.components.bond
bond-api==0.1.8
@@ -372,10 +383,10 @@ boto3==1.9.252
bravia-tv==1.0.6
# homeassistant.components.broadlink
-broadlink==0.14.0
+broadlink==0.14.1
# homeassistant.components.brother
-brother==0.1.14
+brother==0.1.17
# homeassistant.components.brottsplatskartan
brottsplatskartan==0.0.1
@@ -423,13 +434,13 @@ coinbase==2.1.0
coinmarketcap==5.0.3
# homeassistant.scripts.check_config
-colorlog==4.1.0
+colorlog==4.2.1
# homeassistant.components.concord232
concord232==0.15
# homeassistant.components.upc_connect
-connect-box==0.2.5
+connect-box==0.2.8
# homeassistant.components.eddystone_temperature
# homeassistant.components.eq3btsmart
@@ -452,7 +463,7 @@ datadog==0.15.0
datapoint==0.9.5
# homeassistant.components.debugpy
-debugpy==1.0.0b12
+debugpy==1.0.0rc2
# homeassistant.components.decora
# decora==0.6
@@ -473,16 +484,16 @@ deluge-client==1.7.1
denonavr==0.9.4
# homeassistant.components.devolo_home_control
-devolo-home-control-api==0.11.0
+devolo-home-control-api==0.13.0
# homeassistant.components.directv
directv==0.3.0
# homeassistant.components.discogs
-discogs_client==2.2.2
+discogs_client==2.3.0
# homeassistant.components.discord
-discord.py==1.3.4
+discord.py==1.4.1
# homeassistant.components.updater
distro==1.5.0
@@ -499,11 +510,14 @@ dovado==0.4.1
# homeassistant.components.dsmr
dsmr_parser==0.18
+# homeassistant.components.dwd_weather_warnings
+dwdwfsapi==1.0.2
+
# homeassistant.components.dweet
dweepy==0.3.0
# homeassistant.components.dynalite
-dynalite_devices==0.1.41
+dynalite_devices==0.1.46
# homeassistant.components.rainforest_eagle
eagle200_reader==0.2.4
@@ -539,7 +553,7 @@ enocean==0.50
enturclient==0.2.1
# homeassistant.components.environment_canada
-env_canada==0.1.0
+# env_canada==0.2.0
# homeassistant.components.envirophat
# envirophat==0.0.6
@@ -557,7 +571,7 @@ epson-projector==0.1.3
epsonprinter==0.0.9
# homeassistant.components.netgear_lte
-eternalegypt==0.0.11
+eternalegypt==0.0.12
# homeassistant.components.keyboard_remote
# evdev==1.1.2
@@ -643,7 +657,7 @@ georss_qld_bushfire_alert_client==0.3
getmac==0.8.2
# homeassistant.components.gios
-gios==0.1.1
+gios==0.1.4
# homeassistant.components.gitter
gitterpy==0.1.7
@@ -655,7 +669,7 @@ glances_api==0.2.0
gntp==1.0.3
# homeassistant.components.gogogate2
-gogogate2-api==1.0.4
+gogogate2-api==2.0.2
# homeassistant.components.google
google-api-python-client==1.6.4
@@ -688,7 +702,7 @@ greenwavereality==0.5.1
griddypower==0.1.0
# homeassistant.components.growatt_server
-growattServer==0.0.4
+growattServer==0.1.1
# homeassistant.components.gstreamer
gstreamer-player==1.1.2
@@ -703,10 +717,10 @@ ha-philipsjs==0.0.8
habitipy==0.2.0
# homeassistant.components.hangouts
-hangups==0.4.9
+hangups==0.4.10
# homeassistant.components.cloud
-hass-nabucasa==0.35.0
+hass-nabucasa==0.37.0
# homeassistant.components.jewish_calendar
hdate==0.9.5
@@ -724,7 +738,7 @@ hikvision==0.4
hkavr==0.0.5
# homeassistant.components.hlk_sw16
-hlk-sw16==0.0.8
+hlk-sw16==0.0.9
# homeassistant.components.pi_hole
hole==0.5.1
@@ -733,7 +747,7 @@ hole==0.5.1
holidays==0.10.3
# homeassistant.components.frontend
-home-assistant-frontend==20200811.0
+home-assistant-frontend==20200917.1
# homeassistant.components.zwave
homeassistant-pyozw==0.1.10
@@ -742,7 +756,7 @@ homeassistant-pyozw==0.1.10
homeconnect==0.5
# homeassistant.components.homematicip_cloud
-homematicip==0.10.19
+homematicip==0.11.0
# homeassistant.components.horizon
horimote==0.4.1
@@ -774,6 +788,9 @@ ibm-watson==4.0.1
# homeassistant.components.watson_iot
ibmiotf==0.3.4
+# homeassistant.components.ping
+icmplib==1.1.3
+
# homeassistant.components.iglo
iglo==1.2.7
@@ -796,12 +813,6 @@ iperf3==0.1.11
# homeassistant.components.verisure
jsonpath==0.82
-# homeassistant.components.kodi
-jsonrpc-async==0.6
-
-# homeassistant.components.kodi
-jsonrpc-websocket==0.6
-
# homeassistant.components.kaiterra
kaiterra-async-client==0.0.2
@@ -818,7 +829,7 @@ keyrings.alt==3.4.0
kiwiki-client==0.1.1
# homeassistant.components.konnected
-konnected==1.1.0
+konnected==1.2.0
# homeassistant.components.eufy
lakeside==0.12
@@ -908,7 +919,7 @@ meteofrance-api==0.1.1
mficlient==0.3.0
# homeassistant.components.miflora
-miflora==0.6.0
+miflora==0.7.0
# homeassistant.components.mill
millheater==0.3.4
@@ -920,7 +931,7 @@ minio==4.0.9
mitemp_bt==0.0.3
# homeassistant.components.tts
-mutagen==1.44.0
+mutagen==1.45.1
# homeassistant.components.mychevy
mychevy==2.0.1
@@ -951,7 +962,7 @@ netdisco==2.8.2
neurio==0.3.1
# homeassistant.components.nexia
-nexia==0.9.3
+nexia==0.9.4
# homeassistant.components.nextcloud
nextcloudmonitor==1.1.0
@@ -962,6 +973,9 @@ niko-home-control==0.2.1
# homeassistant.components.nilu
niluclient==0.1.2
+# homeassistant.components.noaa_tides
+noaa-coops==0.1.8
+
# homeassistant.components.notify_events
notify-events==1.0.4
@@ -996,7 +1010,7 @@ oemthermostat==1.1
onkyo-eiscp==1.2.7
# homeassistant.components.onvif
-onvif-zeep-async==0.4.0
+onvif-zeep-async==0.5.0
# homeassistant.components.opengarage
open-garage==0.1.4
@@ -1008,7 +1022,7 @@ open-garage==0.1.4
openerz-api==0.1.0
# homeassistant.components.openevse
-openevsewifi==0.4
+openevsewifi==1.1.0
# homeassistant.components.openhome
openhomedevice==0.7.2
@@ -1020,7 +1034,7 @@ opensensemap-api==0.1.5
openwebifpy==3.1.1
# homeassistant.components.luci
-openwrt-luci-rpc==1.1.3
+openwrt-luci-rpc==1.1.6
# homeassistant.components.oru
oru==0.1.11
@@ -1039,7 +1053,7 @@ paho-mqtt==1.5.0
panacotta==0.1
# homeassistant.components.panasonic_viera
-panasonic_viera==0.3.5
+panasonic_viera==0.3.6
# homeassistant.components.pcal9535a
pcal9535a==0.7
@@ -1072,18 +1086,19 @@ piglow==1.2.4
pilight==0.1.1
# homeassistant.components.doods
+# homeassistant.components.image
# homeassistant.components.proxy
# homeassistant.components.qrcode
# homeassistant.components.seven_segments
# homeassistant.components.sighthound
# homeassistant.components.tensorflow
-pillow==7.1.2
+pillow==7.2.0
# homeassistant.components.dominos
pizzapi==0.0.3
# homeassistant.components.plex
-plexapi==4.0.0
+plexapi==4.1.0
# homeassistant.components.plex
plexauth==0.0.5
@@ -1105,13 +1120,13 @@ pocketcasts==0.1
poolsense==0.0.8
# homeassistant.components.reddit
-praw==6.5.1
+praw==7.1.0
# homeassistant.components.islamic_prayer_times
prayer_times_calculator==0.0.3
-# homeassistant.components.prezzibenzina
-prezzibenzina-py==1.1.4
+# homeassistant.components.progettihwsw
+progettihwsw==0.1.1
# homeassistant.components.proliphix
proliphix==0.4.1
@@ -1119,14 +1134,11 @@ proliphix==0.4.1
# homeassistant.components.prometheus
prometheus_client==0.7.1
-# homeassistant.components.tensorflow
-protobuf==3.12.2
-
# homeassistant.components.proxmoxve
proxmoxer==1.1.1
# homeassistant.components.systemmonitor
-psutil==5.7.0
+psutil==5.7.2
# homeassistant.components.ptvsd
ptvsd==4.3.2
@@ -1138,7 +1150,7 @@ pubnubsub-handler==1.0.8
pulsectl==20.2.4
# homeassistant.components.androidtv
-pure-python-adb==0.2.2.dev0
+pure-python-adb[async]==0.3.0.dev0
# homeassistant.components.pushbullet
pushbullet.py==0.11.0
@@ -1156,10 +1168,13 @@ py-august==0.25.0
py-canary==0.5.0
# homeassistant.components.cpuspeed
-py-cpuinfo==5.0.0
+py-cpuinfo==7.0.0
# homeassistant.components.melissa
-py-melissa-climate==2.0.0
+py-melissa-climate==2.1.4
+
+# homeassistant.components.nightscout
+py-nightscout==1.2.1
# homeassistant.components.schluter
py-schluter==0.1.7
@@ -1170,9 +1185,6 @@ py-synology==0.2.0
# homeassistant.components.seventeentrack
py17track==2.2.2
-# homeassistant.components.hdmi_cec
-pyCEC==0.4.13
-
# homeassistant.components.control4
pyControl4==0.0.6
@@ -1181,7 +1193,7 @@ pyHS100==0.3.5.1
# homeassistant.components.met
# homeassistant.components.norway_air
-pyMetno==0.7.1
+pyMetno==0.8.1
# homeassistant.components.rfxtrx
pyRFXtrx==0.25
@@ -1201,11 +1213,8 @@ pyW800rf32==0.1
# homeassistant.components.nextbus
py_nextbusnext==0.1.4
-# homeassistant.components.noaa_tides
-# py_noaa==0.3.0
-
# homeassistant.components.ads
-pyads==3.2.1
+pyads==3.2.2
# homeassistant.components.hisense_aehw4a1
pyaehw4a1==0.3.9
@@ -1268,7 +1277,7 @@ pycocotools==2.0.1
pycomfoconnect==0.3
# homeassistant.components.coolmaster
-pycoolmasternet==0.0.4
+pycoolmasternet-async==0.1.1
# homeassistant.components.avri
pycountry==19.8.18
@@ -1286,10 +1295,10 @@ pydaikin==2.3.1
pydanfossair==0.1.0
# homeassistant.components.deconz
-pydeconz==72
+pydeconz==73
# homeassistant.components.delijn
-pydelijn==0.6.0
+pydelijn==0.6.1
# homeassistant.components.dexcom
pydexcom==0.2.0
@@ -1327,9 +1336,6 @@ pyephember==0.3.1
# homeassistant.components.everlights
pyeverlights==0.1.0
-# homeassistant.components.ezviz
-pyezviz==0.1.5
-
# homeassistant.components.fido
pyfido==2.1.1
@@ -1371,7 +1377,7 @@ pygtfs==0.1.5
pygti==0.6.0
# homeassistant.components.version
-pyhaversion==3.3.0
+pyhaversion==3.4.2
# homeassistant.components.heos
pyheos==0.6.0
@@ -1404,7 +1410,7 @@ pyintesishome==1.7.5
pyipma==2.0.5
# homeassistant.components.ipp
-pyipp==0.10.1
+pyipp==0.11.0
# homeassistant.components.iqvia
pyiqvia==0.2.1
@@ -1424,6 +1430,9 @@ pyitachip2ir==0.0.7
# homeassistant.components.kira
pykira==0.1.1
+# homeassistant.components.kodi
+pykodi==0.2.0
+
# homeassistant.components.kwb
pykwb==0.0.8
@@ -1431,7 +1440,7 @@ pykwb==0.0.8
pylacrosse==0.4.0
# homeassistant.components.lastfm
-pylast==3.2.1
+pylast==3.3.0
# homeassistant.components.launch_library
pylaunches==0.2.0
@@ -1446,7 +1455,7 @@ pylibrespot-java==0.1.0
pylitejet==0.1
# homeassistant.components.loopenergy
-pyloopenergy==0.1.3
+pyloopenergy==0.2.1
# homeassistant.components.lutron_caseta
pylutron-caseta==0.6.1
@@ -1461,7 +1470,7 @@ pymailgunner==1.4
pymata-express==1.13
# homeassistant.components.mediaroom
-pymediaroom==0.6.4
+pymediaroom==0.6.4.1
# homeassistant.components.melcloud
pymelcloud==2.5.2
@@ -1585,6 +1594,9 @@ pyrecswitch==1.0.2
# homeassistant.components.repetier
pyrepetier==3.0.5
+# homeassistant.components.risco
+pyrisco==0.3.0
+
# homeassistant.components.sabnzbd
pysabnzbd==1.1.0
@@ -1614,13 +1626,13 @@ pysher==1.0.1
pysignalclirestapi==0.3.4
# homeassistant.components.sky_hub
-pyskyqhub==0.1.1
+pyskyqhub==0.1.3
# homeassistant.components.sma
pysma==0.3.5
# homeassistant.components.smappee
-pysmappee==0.1.5
+pysmappee==0.2.13
# homeassistant.components.smartthings
pysmartapp==0.3.2
@@ -1641,25 +1653,22 @@ pysnmp==4.4.12
pysoma==0.0.10
# homeassistant.components.sonos
-pysonos==0.0.32
+pysonos==0.0.33
# homeassistant.components.spc
pyspcwebgw==0.4.0
# homeassistant.components.squeezebox
-pysqueezebox==0.2.4
+pysqueezebox==0.3.1
# homeassistant.components.stiebel_eltron
pystiebeleltron==0.0.1.dev2
# homeassistant.components.suez_water
-pysuez==0.1.17
-
-# homeassistant.components.supla
-pysupla==0.0.3
+pysuez==0.1.19
# homeassistant.components.syncthru
-pysyncthru==0.5.0
+pysyncthru==0.7.0
# homeassistant.components.tankerkoenig
pytankerkoenig==0.0.6
@@ -1698,7 +1707,7 @@ python-family-hub-local==0.0.2
python-forecastio==1.4.0
# homeassistant.components.sms
-# python-gammu==3.0
+# python-gammu==3.1
# homeassistant.components.gc100
python-gc100==1.0.3a
@@ -1737,7 +1746,7 @@ python-nest==4.1.0
python-nmap==0.6.1
# homeassistant.components.ozw
-python-openzwave-mqtt==1.0.2
+python-openzwave-mqtt==1.0.5
# homeassistant.components.qbittorrent
python-qbittorrent==0.4.1
@@ -1767,13 +1776,13 @@ python-telnet-vlc==1.0.4
python-twitch-client==0.6.0
# homeassistant.components.velbus
-python-velbus==2.0.43
+python-velbus==2.0.44
# homeassistant.components.vlc
python-vlc==1.1.2
# homeassistant.components.whois
-python-whois==0.7.2
+python-whois==0.7.3
# homeassistant.components.wink
python-wink==1.10.5
@@ -1800,11 +1809,11 @@ pytraccar==0.9.0
pytrackr==0.0.5
# homeassistant.components.tradfri
-pytradfri[async]==6.4.0
+pytradfri[async]==7.0.2
# homeassistant.components.trafikverket_train
# homeassistant.components.trafikverket_weatherstation
-pytrafikverket==0.1.6.1
+pytrafikverket==0.1.6.2
# homeassistant.components.ubee
pyubee==0.10
@@ -1825,13 +1834,13 @@ pyversasense==0.0.6
pyvesync==1.1.0
# homeassistant.components.vizio
-pyvizio==0.1.49
+pyvizio==0.1.56
# homeassistant.components.velux
pyvlx==0.2.16
# homeassistant.components.volumio
-pyvolumio==0.1.1
+pyvolumio==0.1.2
# homeassistant.components.html5
pywebpush==1.9.2
@@ -1839,6 +1848,9 @@ pywebpush==1.9.2
# homeassistant.components.wemo
pywemo==0.4.46
+# homeassistant.components.wilight
+pywilight==0.0.65
+
# homeassistant.components.xeoma
pyxeoma==1.4.1
@@ -1858,7 +1870,7 @@ qnapstats==0.3.0
quantum-gateway==0.0.5
# homeassistant.components.rachio
-rachiopy==0.1.3
+rachiopy==0.1.4
# homeassistant.components.radiotherm
radiotherm==2.0.0
@@ -1885,7 +1897,7 @@ restrictedpython==5.0
rfk101py==0.0.1
# homeassistant.components.rflink
-rflink==0.0.52
+rflink==0.0.54
# homeassistant.components.ring
ring_doorbell==0.6.0
@@ -1900,11 +1912,14 @@ rjpl==0.3.6
rocketchat-API==0.6.1
# homeassistant.components.roku
-rokuecp==0.5.0
+rokuecp==0.6.0
# homeassistant.components.roomba
roombapy==1.6.1
+# homeassistant.components.roon
+roonapi==0.0.21
+
# homeassistant.components.rova
rova==0.1.0
@@ -1939,16 +1954,20 @@ schiene==0.23
scsgate==0.1.0
# homeassistant.components.sendgrid
-sendgrid==6.2.1
+sendgrid==6.4.6
# homeassistant.components.sensehat
sense-hat==2.2.0
+# homeassistant.components.emulated_kasa
# homeassistant.components.sense
-sense_energy==0.7.2
+sense_energy==0.8.0
# homeassistant.components.sentry
-sentry-sdk==0.13.5
+sentry-sdk==0.17.3
+
+# homeassistant.components.sharkiq
+sharkiqpy==0.1.8
# homeassistant.components.aquostv
sharp_aquos_rc==0.3.2
@@ -1978,7 +1997,10 @@ slackclient==2.5.0
sleepyq==0.7
# homeassistant.components.xmpp
-slixmpp==1.5.1
+slixmpp==1.5.2
+
+# homeassistant.components.smart_meter_texas
+smart-meter-texas==0.4.0
# homeassistant.components.smarthab
smarthab==0.21
@@ -2031,11 +2053,11 @@ spiderpy==1.3.1
spotcrime==1.0.4
# homeassistant.components.spotify
-spotipy==2.12.0
+spotipy==2.14.0
# homeassistant.components.recorder
# homeassistant.components.sql
-sqlalchemy==1.3.18
+sqlalchemy==1.3.19
# homeassistant.components.starline
starline==0.1.3
@@ -2095,25 +2117,22 @@ tellcore-py==1.1.2
tellduslive==0.10.11
# homeassistant.components.lg_soundbar
-temescal==0.1
+temescal==0.3
# homeassistant.components.temper
temperusb==1.5.3
# homeassistant.components.tensorflow
-# tensorflow==2.2.0
+# tensorflow==2.3.0
# homeassistant.components.powerwall
tesla-powerwall==0.2.12
# homeassistant.components.tesla
-teslajsonpy==0.10.1
+teslajsonpy==0.10.4
# homeassistant.components.tensorflow
-# tf-models-official==2.2.1
-
-# homeassistant.components.tensorflow
-tf-slim==1.1.0
+# tf-models-official==2.3.0
# homeassistant.components.thermoworks_smoke
thermoworks_smoke==0.1.8
@@ -2183,7 +2202,7 @@ venstarcolortouch==0.12
vilfo-api-client==0.3.2
# homeassistant.components.volkszaehler
-volkszaehler==0.1.2
+volkszaehler==0.1.3
# homeassistant.components.volvooncall
volvooncall==0.8.12
@@ -2225,7 +2244,7 @@ wirelesstagpy==0.4.1
withings-api==2.1.6
# homeassistant.components.wled
-wled==0.4.3
+wled==0.4.4
# homeassistant.components.wolflink
wolf_smartset==0.1.4
@@ -2240,13 +2259,12 @@ xboxapi==2.0.1
xfinity-gateway==0.0.4
# homeassistant.components.knx
-xknx==0.11.3
+xknx==0.13.0
# homeassistant.components.bluesound
# homeassistant.components.rest
# homeassistant.components.startca
# homeassistant.components.ted5000
-# homeassistant.components.yr
# homeassistant.components.zestimate
xmltodict==0.12.0
@@ -2257,7 +2275,7 @@ xs1-api-client==3.0.0
yalesmartalarmclient==0.1.6
# homeassistant.components.yeelight
-yeelight==0.5.2
+yeelight==0.5.3
# homeassistant.components.yeelightsunflower
yeelightsunflower==0.0.10
@@ -2269,10 +2287,10 @@ youtube_dl==2020.07.28
zengge==0.2
# homeassistant.components.zeroconf
-zeroconf==0.28.1
+zeroconf==0.28.5
# homeassistant.components.zha
-zha-quirks==0.0.43
+zha-quirks==0.0.44
# homeassistant.components.zhong_hong
zhong_hong_hvac==1.0.9
@@ -2281,19 +2299,22 @@ zhong_hong_hvac==1.0.9
ziggo-mediabox-xl==1.1.0
# homeassistant.components.zha
-zigpy-cc==0.4.4
+zigpy-cc==0.5.2
# homeassistant.components.zha
zigpy-deconz==0.9.2
# homeassistant.components.zha
-zigpy-xbee==0.12.1
+zigpy-xbee==0.13.0
# homeassistant.components.zha
-zigpy-zigate==0.6.1
+zigpy-zigate==0.6.2
# homeassistant.components.zha
-zigpy==0.22.2
+zigpy-znp==0.1.1
+
+# homeassistant.components.zha
+zigpy==0.23.2
# homeassistant.components.zoneminder
zm-py==0.4.0
diff --git a/requirements_test.txt b/requirements_test.txt
index 25e3db656da..e36837edf63 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -7,11 +7,13 @@
asynctest==0.13.0
codecov==2.1.0
coverage==5.2.1
+jsonpickle==1.4.1
mock-open==1.4.0
mypy==0.780
-pre-commit==2.6.0
-pylint==2.4.4
-astroid==2.3.3
+pre-commit==2.7.1
+pylint==2.6.0
+astroid==2.4.2
+pipdeptree==1.0.0
pylint-strict-informational==0.1
pytest-aiohttp==0.3.0
pytest-cov==2.10.0
@@ -22,3 +24,5 @@ pytest-xdist==1.32.0
pytest==5.4.3
requests_mock==1.8.0
responses==0.10.6
+stdlib-list==0.7.0
+tqdm==4.48.2
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index c4c9051c9bf..d492e3acbb5 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -1,14 +1,13 @@
# Home Assistant tests, full dependency set
# Automatically generated by gen_requirements_all.py, do not edit
--c homeassistant/package_constraints.txt
-r requirements_test.txt
# homeassistant.components.homekit
HAP-python==3.0.0
# homeassistant.components.plugwise
-Plugwise_Smile==1.1.0
+Plugwise_Smile==1.4.0
# homeassistant.components.flick_electric
PyFlick==0.0.2
@@ -46,7 +45,7 @@ YesssSMS==0.4.1
abodepy==1.1.0
# homeassistant.components.accuweather
-accuweather==0.0.9
+accuweather==0.0.10
# homeassistant.components.androidtv
adb-shell[async]==0.2.1
@@ -73,7 +72,7 @@ aio_georss_gdacs==0.3
aioambient==1.2.1
# homeassistant.components.asuswrt
-aioasuswrt==1.2.7
+aioasuswrt==1.2.8
# homeassistant.components.azure_devops
aioazuredevops==1.3.5
@@ -85,8 +84,14 @@ aiobotocore==0.11.1
# homeassistant.components.minecraft_server
aiodns==2.0.0
+# homeassistant.components.eafm
+aioeafm==0.1.2
+
# homeassistant.components.esphome
-aioesphomeapi==2.6.1
+aioesphomeapi==2.6.3
+
+# homeassistant.components.flo
+aioflo==0.4.1
# homeassistant.components.freebox
aiofreepybox==0.0.8
@@ -98,7 +103,7 @@ aioguardian==1.0.1
aioharmony==0.2.6
# homeassistant.components.homekit_controller
-aiohomekit[IP]==0.2.46
+aiohomekit==0.2.53
# homeassistant.components.emulated_hue
# homeassistant.components.http
@@ -120,19 +125,22 @@ aiopulse==0.4.0
aiopvapi==1.6.14
# homeassistant.components.pvpc_hourly_pricing
-aiopvpc==1.0.2
+aiopvpc==2.0.2
# homeassistant.components.webostv
aiopylgtv==0.3.3
+# homeassistant.components.shelly
+aioshelly==0.3.2
+
# homeassistant.components.switcher_kis
-aioswitcher==1.2.0
+aioswitcher==1.2.1
# homeassistant.components.unifi
aiounifi==23
# homeassistant.components.yandex_transport
-aioymaps==1.0.0
+aioymaps==1.1.0
# homeassistant.components.airly
airly==0.0.2
@@ -141,19 +149,19 @@ airly==0.0.2
ambiclimate==0.2.1
# homeassistant.components.androidtv
-androidtv[async]==0.0.47
+androidtv[async]==0.0.50
# homeassistant.components.apns
apns2==0.3.0
# homeassistant.components.apprise
-apprise==0.8.5
+apprise==0.8.8
# homeassistant.components.aprs
aprslib==0.6.46
# homeassistant.components.arcam_fmj
-arcam-fmj==0.5.1
+arcam-fmj==0.5.3
# homeassistant.components.dlna_dmr
# homeassistant.components.upnp
@@ -166,7 +174,7 @@ av==8.0.2
avri-api==0.1.7
# homeassistant.components.axis
-axis==33
+axis==35
# homeassistant.components.azure_event_hub
azure-eventhub==5.1.0
@@ -175,7 +183,7 @@ azure-eventhub==5.1.0
base36==0.1.1
# homeassistant.components.zha
-bellows==0.18.1
+bellows==0.20.2
# homeassistant.components.blebox
blebox_uniapi==1.3.2
@@ -184,7 +192,7 @@ blebox_uniapi==1.3.2
blinkpy==0.16.3
# homeassistant.components.bom
-bomradarloop==0.1.4
+bomradarloop==0.1.5
# homeassistant.components.bond
bond-api==0.1.8
@@ -193,10 +201,10 @@ bond-api==0.1.8
bravia-tv==1.0.6
# homeassistant.components.broadlink
-broadlink==0.14.0
+broadlink==0.14.1
# homeassistant.components.brother
-brother==0.1.14
+brother==0.1.17
# homeassistant.components.bsblan
bsblan==0.3.7
@@ -211,7 +219,7 @@ caldav==0.6.1
coinmarketcap==5.0.3
# homeassistant.scripts.check_config
-colorlog==4.1.0
+colorlog==4.2.1
# homeassistant.components.eddystone_temperature
# homeassistant.components.eq3btsmart
@@ -231,7 +239,7 @@ datadog==0.15.0
datapoint==0.9.5
# homeassistant.components.debugpy
-debugpy==1.0.0b12
+debugpy==1.0.0rc2
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@@ -243,7 +251,7 @@ defusedxml==0.6.0
denonavr==0.9.4
# homeassistant.components.devolo_home_control
-devolo-home-control-api==0.11.0
+devolo-home-control-api==0.13.0
# homeassistant.components.directv
directv==0.3.0
@@ -258,7 +266,7 @@ doorbirdpy==2.1.0
dsmr_parser==0.18
# homeassistant.components.dynalite
-dynalite_devices==0.1.41
+dynalite_devices==0.1.46
# homeassistant.components.ee_brightbox
eebrightbox==0.0.4
@@ -320,13 +328,13 @@ georss_qld_bushfire_alert_client==0.3
getmac==0.8.2
# homeassistant.components.gios
-gios==0.1.1
+gios==0.1.4
# homeassistant.components.glances
glances_api==0.2.0
# homeassistant.components.gogogate2
-gogogate2-api==1.0.4
+gogogate2-api==2.0.2
# homeassistant.components.google
google-api-python-client==1.6.4
@@ -341,10 +349,10 @@ griddypower==0.1.0
ha-ffmpeg==2.0
# homeassistant.components.hangouts
-hangups==0.4.9
+hangups==0.4.10
# homeassistant.components.cloud
-hass-nabucasa==0.35.0
+hass-nabucasa==0.37.0
# homeassistant.components.jewish_calendar
hdate==0.9.5
@@ -353,7 +361,7 @@ hdate==0.9.5
herepy==2.0.0
# homeassistant.components.hlk_sw16
-hlk-sw16==0.0.8
+hlk-sw16==0.0.9
# homeassistant.components.pi_hole
hole==0.5.1
@@ -362,7 +370,7 @@ hole==0.5.1
holidays==0.10.3
# homeassistant.components.frontend
-home-assistant-frontend==20200811.0
+home-assistant-frontend==20200917.1
# homeassistant.components.zwave
homeassistant-pyozw==0.1.10
@@ -371,7 +379,7 @@ homeassistant-pyozw==0.1.10
homeconnect==0.5
# homeassistant.components.homematicip_cloud
-homematicip==0.10.19
+homematicip==0.11.0
# homeassistant.components.google
# homeassistant.components.remember_the_milk
@@ -383,6 +391,9 @@ huawei-lte-api==1.4.12
# homeassistant.components.iaqualink
iaqualink==0.3.4
+# homeassistant.components.ping
+icmplib==1.1.3
+
# homeassistant.components.influxdb
influxdb-client==1.8.0
@@ -400,7 +411,7 @@ keyring==21.2.0
keyrings.alt==3.4.0
# homeassistant.components.konnected
-konnected==1.1.0
+konnected==1.2.0
# homeassistant.components.dyson
libpurecool==0.6.3
@@ -436,7 +447,7 @@ millheater==0.3.4
minio==4.0.9
# homeassistant.components.tts
-mutagen==1.44.0
+mutagen==1.45.1
# homeassistant.components.ness_alarm
nessclient==0.9.15
@@ -446,7 +457,7 @@ nessclient==0.9.15
netdisco==2.8.2
# homeassistant.components.nexia
-nexia==0.9.3
+nexia==0.9.4
# homeassistant.components.nsw_fuel_station
nsw-fuel-api-client==1.0.10
@@ -467,7 +478,7 @@ numpy==1.19.1
oauth2client==4.0.0
# homeassistant.components.onvif
-onvif-zeep-async==0.4.0
+onvif-zeep-async==0.5.0
# homeassistant.components.openerz
openerz-api==0.1.0
@@ -480,7 +491,7 @@ ovoenergy==1.1.7
paho-mqtt==1.5.0
# homeassistant.components.panasonic_viera
-panasonic_viera==0.3.5
+panasonic_viera==0.3.6
# homeassistant.components.dunehd
pdunehd==1.3.2
@@ -495,15 +506,16 @@ pexpect==4.6.0
pilight==0.1.1
# homeassistant.components.doods
+# homeassistant.components.image
# homeassistant.components.proxy
# homeassistant.components.qrcode
# homeassistant.components.seven_segments
# homeassistant.components.sighthound
# homeassistant.components.tensorflow
-pillow==7.1.2
+pillow==7.2.0
# homeassistant.components.plex
-plexapi==4.0.0
+plexapi==4.1.0
# homeassistant.components.plex
plexauth==0.0.5
@@ -522,11 +534,14 @@ pmsensor==0.4
poolsense==0.0.8
# homeassistant.components.reddit
-praw==6.5.1
+praw==7.1.0
# homeassistant.components.islamic_prayer_times
prayer_times_calculator==0.0.3
+# homeassistant.components.progettihwsw
+progettihwsw==0.1.1
+
# homeassistant.components.prometheus
prometheus_client==0.7.1
@@ -534,7 +549,7 @@ prometheus_client==0.7.1
ptvsd==4.3.2
# homeassistant.components.androidtv
-pure-python-adb==0.2.2.dev0
+pure-python-adb[async]==0.3.0.dev0
# homeassistant.components.pushbullet
pushbullet.py==0.11.0
@@ -546,7 +561,10 @@ py-august==0.25.0
py-canary==0.5.0
# homeassistant.components.melissa
-py-melissa-climate==2.0.0
+py-melissa-climate==2.1.4
+
+# homeassistant.components.nightscout
+py-nightscout==1.2.1
# homeassistant.components.seventeentrack
py17track==2.2.2
@@ -559,7 +577,7 @@ pyHS100==0.3.5.1
# homeassistant.components.met
# homeassistant.components.norway_air
-pyMetno==0.7.1
+pyMetno==0.8.1
# homeassistant.components.rfxtrx
pyRFXtrx==0.25
@@ -598,7 +616,7 @@ pybotvac==0.0.17
pychromecast==7.2.1
# homeassistant.components.coolmaster
-pycoolmasternet==0.0.4
+pycoolmasternet-async==0.1.1
# homeassistant.components.avri
pycountry==19.8.18
@@ -607,7 +625,7 @@ pycountry==19.8.18
pydaikin==2.3.1
# homeassistant.components.deconz
-pydeconz==72
+pydeconz==73
# homeassistant.components.dexcom
pydexcom==0.2.0
@@ -644,7 +662,7 @@ pygatt[GATTTOOL]==4.0.5
pygti==0.6.0
# homeassistant.components.version
-pyhaversion==3.3.0
+pyhaversion==3.4.2
# homeassistant.components.heos
pyheos==0.6.0
@@ -655,11 +673,14 @@ pyhomematic==0.1.68
# homeassistant.components.icloud
pyicloud==0.9.7
+# homeassistant.components.insteon
+pyinsteon==1.0.7
+
# homeassistant.components.ipma
pyipma==2.0.5
# homeassistant.components.ipp
-pyipp==0.10.1
+pyipp==0.11.0
# homeassistant.components.iqvia
pyiqvia==0.2.1
@@ -670,8 +691,11 @@ pyisy==2.0.2
# homeassistant.components.kira
pykira==0.1.1
+# homeassistant.components.kodi
+pykodi==0.2.0
+
# homeassistant.components.lastfm
-pylast==3.2.1
+pylast==3.3.0
# homeassistant.components.forked_daapd
pylibrespot-java==0.1.0
@@ -715,6 +739,9 @@ pynws==1.2.1
# homeassistant.components.nx584
pynx584==0.5
+# homeassistant.components.nzbget
+pynzbgetapi==0.2.0
+
# homeassistant.components.openuv
pyopenuv==1.0.9
@@ -729,6 +756,9 @@ pyotgw==0.6b1
# homeassistant.components.otp
pyotp==2.3.0
+# homeassistant.components.openweathermap
+pyowm==2.10.0
+
# homeassistant.components.point
pypoint==1.1.2
@@ -738,6 +768,9 @@ pyps4-2ndscreen==1.1.1
# homeassistant.components.qwikswitch
pyqwikswitch==0.93
+# homeassistant.components.risco
+pyrisco==0.3.0
+
# homeassistant.components.acer_projector
# homeassistant.components.zha
pyserial==3.4
@@ -749,7 +782,7 @@ pysignalclirestapi==0.3.4
pysma==0.3.5
# homeassistant.components.smappee
-pysmappee==0.1.5
+pysmappee==0.2.13
# homeassistant.components.smartthings
pysmartapp==0.3.2
@@ -761,16 +794,16 @@ pysmartthings==0.7.3
pysoma==0.0.10
# homeassistant.components.sonos
-pysonos==0.0.32
+pysonos==0.0.33
# homeassistant.components.spc
pyspcwebgw==0.4.0
# homeassistant.components.squeezebox
-pysqueezebox==0.2.4
+pysqueezebox==0.3.1
# homeassistant.components.syncthru
-pysyncthru==0.5.0
+pysyncthru==0.7.0
# homeassistant.components.ecobee
python-ecobee-api==0.2.7
@@ -791,7 +824,7 @@ python-miio==0.5.3
python-nest==4.1.0
# homeassistant.components.ozw
-python-openzwave-mqtt==1.0.2
+python-openzwave-mqtt==1.0.5
# homeassistant.components.songpal
python-songpal==0.12
@@ -806,7 +839,7 @@ python-tado==0.8.1
python-twitch-client==0.6.0
# homeassistant.components.velbus
-python-velbus==2.0.43
+python-velbus==2.0.44
# homeassistant.components.awair
python_awair==0.1.1
@@ -818,7 +851,7 @@ pytile==4.0.0
pytraccar==0.9.0
# homeassistant.components.tradfri
-pytradfri[async]==6.4.0
+pytradfri[async]==7.0.2
# homeassistant.components.vera
pyvera==0.3.9
@@ -827,19 +860,22 @@ pyvera==0.3.9
pyvesync==1.1.0
# homeassistant.components.vizio
-pyvizio==0.1.49
+pyvizio==0.1.56
# homeassistant.components.volumio
-pyvolumio==0.1.1
+pyvolumio==0.1.2
# homeassistant.components.html5
pywebpush==1.9.2
+# homeassistant.components.wilight
+pywilight==0.0.65
+
# homeassistant.components.zerproc
pyzerproc==0.2.5
# homeassistant.components.rachio
-rachiopy==0.1.3
+rachiopy==0.1.4
# homeassistant.components.rainmachine
regenmaschine==2.1.0
@@ -848,17 +884,20 @@ regenmaschine==2.1.0
restrictedpython==5.0
# homeassistant.components.rflink
-rflink==0.0.52
+rflink==0.0.54
# homeassistant.components.ring
ring_doorbell==0.6.0
# homeassistant.components.roku
-rokuecp==0.5.0
+rokuecp==0.6.0
# homeassistant.components.roomba
roombapy==1.6.1
+# homeassistant.components.roon
+roonapi==0.0.21
+
# homeassistant.components.yamaha
rxv==0.6.0
@@ -868,11 +907,15 @@ samsungctl[websocket]==0.7.1
# homeassistant.components.samsungtv
samsungtvws[websocket]==1.4.0
+# homeassistant.components.emulated_kasa
# homeassistant.components.sense
-sense_energy==0.7.2
+sense_energy==0.8.0
# homeassistant.components.sentry
-sentry-sdk==0.13.5
+sentry-sdk==0.17.3
+
+# homeassistant.components.sharkiq
+sharkiqpy==0.1.8
# homeassistant.components.sighthound
simplehound==0.3
@@ -883,6 +926,9 @@ simplisafe-python==9.3.0
# homeassistant.components.sleepiq
sleepyq==0.7
+# homeassistant.components.smart_meter_texas
+smart-meter-texas==0.4.0
+
# homeassistant.components.smarthab
smarthab==0.21
@@ -908,11 +954,11 @@ speedtest-cli==2.1.2
spiderpy==1.3.1
# homeassistant.components.spotify
-spotipy==2.12.0
+spotipy==2.14.0
# homeassistant.components.recorder
# homeassistant.components.sql
-sqlalchemy==1.3.18
+sqlalchemy==1.3.19
# homeassistant.components.starline
starline==0.1.3
@@ -929,6 +975,9 @@ stringcase==1.2.0
# homeassistant.components.solarlog
sunwatcher==0.2.1
+# homeassistant.components.surepetcare
+surepy==0.2.5
+
# homeassistant.components.tellduslive
tellduslive==0.10.11
@@ -936,7 +985,7 @@ tellduslive==0.10.11
tesla-powerwall==0.2.12
# homeassistant.components.tesla
-teslajsonpy==0.10.1
+teslajsonpy==0.10.4
# homeassistant.components.toon
toonapi==0.2.0
@@ -988,7 +1037,7 @@ wiffi==1.0.1
withings-api==2.1.6
# homeassistant.components.wled
-wled==0.4.3
+wled==0.4.4
# homeassistant.components.wolflink
wolf_smartset==0.1.4
@@ -997,30 +1046,32 @@ wolf_smartset==0.1.4
# homeassistant.components.rest
# homeassistant.components.startca
# homeassistant.components.ted5000
-# homeassistant.components.yr
# homeassistant.components.zestimate
xmltodict==0.12.0
# homeassistant.components.yeelight
-yeelight==0.5.2
+yeelight==0.5.3
# homeassistant.components.zeroconf
-zeroconf==0.28.1
+zeroconf==0.28.5
# homeassistant.components.zha
-zha-quirks==0.0.43
+zha-quirks==0.0.44
# homeassistant.components.zha
-zigpy-cc==0.4.4
+zigpy-cc==0.5.2
# homeassistant.components.zha
zigpy-deconz==0.9.2
# homeassistant.components.zha
-zigpy-xbee==0.12.1
+zigpy-xbee==0.13.0
# homeassistant.components.zha
-zigpy-zigate==0.6.1
+zigpy-zigate==0.6.2
# homeassistant.components.zha
-zigpy==0.22.2
+zigpy-znp==0.1.1
+
+# homeassistant.components.zha
+zigpy==0.23.2
diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt
index 2574cb895e9..2daad6e33f0 100644
--- a/requirements_test_pre_commit.txt
+++ b/requirements_test_pre_commit.txt
@@ -1,11 +1,11 @@
# Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit
bandit==1.6.2
-black==19.10b0
-codespell==1.16.0
+black==20.8b1
+codespell==1.17.1
flake8-docstrings==1.5.0
flake8==3.8.3
-isort==4.3.21
-pydocstyle==5.0.2
-pyupgrade==2.3.0
-yamllint==1.23.0
+isort==5.5.1
+pydocstyle==5.1.1
+pyupgrade==2.7.2
+yamllint==1.24.2
diff --git a/script/bootstrap b/script/bootstrap
index 2b599950625..3166b8c7701 100755
--- a/script/bootstrap
+++ b/script/bootstrap
@@ -8,4 +8,4 @@ cd "$(dirname "$0")/.."
echo "Installing development dependencies..."
python3 -m pip install wheel --constraint homeassistant/package_constraints.txt
-python3 -m pip install tox colorlog pre-commit $(grep mypy requirements_test.txt) --constraint homeassistant/package_constraints.txt
+python3 -m pip install tox colorlog pre-commit $(grep mypy requirements_test.txt) $(grep stdlib-list requirements_test.txt) $(grep tqdm requirements_test.txt) $(grep pipdeptree requirements_test.txt) --constraint homeassistant/package_constraints.txt
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index 772b9af5034..27482a0c215 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -8,20 +8,22 @@ import pkgutil
import re
import sys
-from script.hassfest.model import Integration
-
from homeassistant.util.yaml.loader import load_yaml
+from script.hassfest.model import Integration
COMMENT_REQUIREMENTS = (
"Adafruit_BBIO",
"Adafruit-DHT",
+ "avea", # depends on bluepy
"avion",
"beacontools",
+ "beewi_smartclim", # depends on bluepy
"blinkt",
"bluepy",
"bme680",
"credstash",
"decora",
+ "env_canada",
"envirophat",
"evdev",
"face_recognition",
@@ -65,11 +67,17 @@ urllib3>=1.24.3
# Constrain httplib2 to protect against CVE-2020-11078
httplib2>=0.18.0
-# Not needed for our supported Python versions
-enum34==1000000000.0.0
-
# This is a old unmaintained library and is replaced with pycryptodome
pycrypto==1000000000.0.0
+
+# To remove reliance on typing
+btlewrap>=0.0.10
+
+# This overrides a built-in Python package
+enum34==1000000000.0.0
+typing==1000000000.0.0
+uuid==1000000000.0.0
+
"""
IGNORE_PRE_COMMIT_HOOK_ID = (
@@ -177,6 +185,9 @@ def gather_requirements_from_manifests(errors, reqs):
errors.append(f"The manifest for integration {domain} is invalid.")
continue
+ if integration.disabled:
+ continue
+
process_requirements(
errors, integration.requirements, f"homeassistant.components.{domain}", reqs
)
@@ -239,8 +250,7 @@ def requirements_output(reqs):
def requirements_all_output(reqs):
"""Generate output for requirements_all."""
output = [
- "# Home Assistant Core, full dependency set\n"
- "-c homeassistant/package_constraints.txt\n",
+ "# Home Assistant Core, full dependency set\n",
"-r requirements.txt\n",
]
output.append(generate_requirements_list(reqs))
@@ -248,15 +258,14 @@ def requirements_all_output(reqs):
return "".join(output)
-def requirements_test_output(reqs):
+def requirements_test_all_output(reqs):
"""Generate output for test_requirements."""
output = [
"# Home Assistant tests, full dependency set\n",
f"# Automatically generated by {Path(__file__).name}, do not edit\n",
"\n",
- "-c homeassistant/package_constraints.txt\n",
+ "-r requirements_test.txt\n",
]
- output.append("-r requirements_test.txt\n")
filtered = {
requirement: modules
@@ -335,7 +344,7 @@ def main(validate):
reqs_file = requirements_output(data)
reqs_all_file = requirements_all_output(data)
- reqs_test_file = requirements_test_output(data)
+ reqs_test_all_file = requirements_test_all_output(data)
reqs_pre_commit_file = requirements_pre_commit_output()
constraints = gather_constraints()
@@ -343,7 +352,7 @@ def main(validate):
("requirements.txt", reqs_file),
("requirements_all.txt", reqs_all_file),
("requirements_test_pre_commit.txt", reqs_pre_commit_file),
- ("requirements_test_all.txt", reqs_test_file),
+ ("requirements_test_all.txt", reqs_test_all_file),
("homeassistant/package_constraints.txt", constraints),
)
diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py
index 6fbefc11a2f..26af118d11e 100644
--- a/script/hassfest/__main__.py
+++ b/script/hassfest/__main__.py
@@ -11,6 +11,7 @@ from . import (
dependencies,
json,
manifest,
+ requirements,
services,
ssdp,
translations,
@@ -55,6 +56,11 @@ def get_config() -> Config:
type=valid_integration_path,
help="Validate a single integration",
)
+ parser.add_argument(
+ "--requirements",
+ action="store_true",
+ help="Validate requirements",
+ )
parsed = parser.parse_args()
if parsed.action is None:
@@ -75,6 +81,7 @@ def get_config() -> Config:
root=pathlib.Path(".").absolute(),
specific_integrations=parsed.integration_path,
action=parsed.action,
+ requirements=parsed.requirements,
)
@@ -86,7 +93,10 @@ def main():
print(err)
return 1
- plugins = INTEGRATION_PLUGINS
+ plugins = [*INTEGRATION_PLUGINS]
+
+ if config.requirements:
+ plugins.append(requirements)
if config.specific_integrations:
integrations = {}
@@ -104,6 +114,8 @@ def main():
try:
start = monotonic()
print(f"Validating {plugin.__name__.split('.')[-1]}...", end="", flush=True)
+ if plugin is requirements:
+ print()
plugin.validate(integrations, config)
print(" done in {:.2f}s".format(monotonic() - start))
except RuntimeError as err:
diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py
index cb592b63b53..b0148b0911a 100644
--- a/script/hassfest/manifest.py
+++ b/script/hassfest/manifest.py
@@ -38,7 +38,18 @@ MANIFEST_SCHEMA = vol.Schema(
vol.Required("domain"): str,
vol.Required("name"): str,
vol.Optional("config_flow"): bool,
- vol.Optional("zeroconf"): [str],
+ vol.Optional("zeroconf"): [
+ vol.Any(
+ str,
+ vol.Schema(
+ {
+ vol.Required("type"): str,
+ vol.Optional("macaddress"): str,
+ vol.Optional("name"): str,
+ }
+ ),
+ )
+ ],
vol.Optional("ssdp"): vol.Schema(
vol.All([vol.All(vol.Schema({}, extra=vol.ALLOW_EXTRA), vol.Length(min=1))])
),
@@ -54,6 +65,7 @@ MANIFEST_SCHEMA = vol.Schema(
vol.Optional("dependencies"): [str],
vol.Optional("after_dependencies"): [str],
vol.Required("codeowners"): [str],
+ vol.Optional("disabled"): str,
}
)
diff --git a/script/hassfest/model.py b/script/hassfest/model.py
index bb438f4d84f..8c55c2818f1 100644
--- a/script/hassfest/model.py
+++ b/script/hassfest/model.py
@@ -27,6 +27,7 @@ class Config:
specific_integrations: Optional[pathlib.Path] = attr.ib()
root: pathlib.Path = attr.ib()
action: str = attr.ib()
+ requirements: bool = attr.ib()
errors: List[Error] = attr.ib(factory=list)
cache: Dict[str, Any] = attr.ib(factory=dict)
@@ -73,6 +74,11 @@ class Integration:
"""Integration domain."""
return self.path.name
+ @property
+ def disabled(self) -> Optional[str]:
+ """List of disabled."""
+ return self.manifest.get("disabled")
+
@property
def requirements(self) -> List[str]:
"""List of requirements."""
diff --git a/script/hassfest/requirements.py b/script/hassfest/requirements.py
new file mode 100644
index 00000000000..ab43cd62bd5
--- /dev/null
+++ b/script/hassfest/requirements.py
@@ -0,0 +1,173 @@
+"""Validate requirements."""
+import operator
+import re
+import subprocess
+import sys
+from typing import Dict, Set
+
+from stdlib_list import stdlib_list
+from tqdm import tqdm
+
+from homeassistant.const import REQUIRED_PYTHON_VER
+import homeassistant.util.package as pkg_util
+from script.gen_requirements_all import COMMENT_REQUIREMENTS
+
+from .model import Config, Integration
+
+IGNORE_PACKAGES = {
+ commented.lower().replace("_", "-") for commented in COMMENT_REQUIREMENTS
+}
+PACKAGE_REGEX = re.compile(r"^(?:--.+\s)?([-_\.\w\d]+).*==.+$")
+PIP_REGEX = re.compile(r"^(--.+\s)?([-_\.\w\d]+.*(?:==|>=|<=|~=|!=|<|>|===)?.*$)")
+SUPPORTED_PYTHON_TUPLES = [
+ REQUIRED_PYTHON_VER[:2],
+ tuple(map(operator.add, REQUIRED_PYTHON_VER, (0, 1, 0)))[:2],
+]
+SUPPORTED_PYTHON_VERSIONS = [
+ ".".join(map(str, version_tuple)) for version_tuple in SUPPORTED_PYTHON_TUPLES
+]
+STD_LIBS = {version: set(stdlib_list(version)) for version in SUPPORTED_PYTHON_VERSIONS}
+
+
+def normalize_package_name(requirement: str) -> str:
+ """Return a normalized package name from a requirement string."""
+ match = PACKAGE_REGEX.search(requirement)
+ if not match:
+ return ""
+
+ # pipdeptree needs lowercase and dash instead of underscore as separator
+ package = match.group(1).lower().replace("_", "-")
+
+ return package
+
+
+def validate(integrations: Dict[str, Integration], config: Config):
+ """Handle requirements for integrations."""
+ # check for incompatible requirements
+ for integration in tqdm(integrations.values()):
+ if not integration.manifest:
+ continue
+
+ validate_requirements(integration)
+
+
+def validate_requirements(integration: Integration):
+ """Validate requirements."""
+ integration_requirements = set()
+ integration_packages = set()
+ for req in integration.requirements:
+ package = normalize_package_name(req)
+ if not package:
+ integration.add_error(
+ "requirements",
+ f"Failed to normalize package name from requirement {req}",
+ )
+ return
+ if package in IGNORE_PACKAGES:
+ continue
+ integration_requirements.add(req)
+ integration_packages.add(package)
+
+ install_ok = install_requirements(integration, integration_requirements)
+
+ if not install_ok:
+ return
+
+ all_integration_requirements = get_requirements(integration, integration_packages)
+
+ if integration_requirements and not all_integration_requirements:
+ integration.add_error(
+ "requirements",
+ f"Failed to resolve requirements {integration_requirements}",
+ )
+ return
+
+ # Check for requirements incompatible with standard library.
+ for version, std_libs in STD_LIBS.items():
+ for req in all_integration_requirements:
+ if req in std_libs:
+ integration.add_error(
+ "requirements",
+ f"Package {req} is not compatible with Python {version} standard library",
+ )
+
+
+def get_requirements(integration: Integration, packages: Set[str]) -> Set[str]:
+ """Return all (recursively) requirements for an integration."""
+ all_requirements = set()
+
+ for package in packages:
+ try:
+ result = subprocess.run(
+ ["pipdeptree", "-w", "silence", "--packages", package],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ except subprocess.SubprocessError:
+ integration.add_error(
+ "requirements", f"Failed to resolve requirements for {package}"
+ )
+ continue
+
+ # parse output to get a set of package names
+ output = result.stdout
+ lines = output.split("\n")
+ parent = lines[0].split("==")[0] # the first line is the parent package
+ if parent:
+ all_requirements.add(parent)
+
+ for line in lines[1:]: # skip the first line which we already processed
+ line = line.strip()
+ line = line.lstrip("- ")
+ package = line.split("[")[0]
+ package = package.strip()
+ if not package:
+ continue
+ all_requirements.add(package)
+
+ return all_requirements
+
+
+def install_requirements(integration: Integration, requirements: Set[str]) -> bool:
+ """Install integration requirements.
+
+ Return True if successful.
+ """
+ for req in requirements:
+ try:
+ is_installed = pkg_util.is_installed(req)
+ except ValueError:
+ is_installed = False
+
+ if is_installed:
+ continue
+
+ match = PIP_REGEX.search(req)
+
+ if not match:
+ integration.add_error(
+ "requirements",
+ f"Failed to parse requirement {req} before installation",
+ )
+ continue
+
+ install_args = match.group(1)
+ requirement_arg = match.group(2)
+
+ args = [sys.executable, "-m", "pip", "install", "--quiet"]
+ if install_args:
+ args.append(install_args)
+ args.append(requirement_arg)
+ try:
+ subprocess.run(args, check=True)
+ except subprocess.SubprocessError:
+ integration.add_error(
+ "requirements",
+ f"Requirement {req} failed to install",
+ )
+
+ if integration.errors:
+ return False
+
+ return True
diff --git a/script/hassfest/translations.py b/script/hassfest/translations.py
index 801a5112118..7a9208da7d1 100644
--- a/script/hassfest/translations.py
+++ b/script/hassfest/translations.py
@@ -6,12 +6,12 @@ import logging
import re
from typing import Dict
-from script.translations import upload
import voluptuous as vol
from voluptuous.humanize import humanize_error
import homeassistant.helpers.config_validation as cv
from homeassistant.util import slugify
+from script.translations import upload
from .model import Config, Integration
diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py
index d6b39bd0d27..61162b02761 100644
--- a/script/hassfest/zeroconf.py
+++ b/script/hassfest/zeroconf.py
@@ -37,8 +37,17 @@ def generate_and_validate(integrations: Dict[str, Integration]):
if not (service_types or homekit_models):
continue
- for service_type in service_types:
- service_type_dict[service_type].append(domain)
+ for entry in service_types:
+ data = {"domain": domain}
+ if isinstance(entry, dict):
+ typ = entry["type"]
+ entry_without_type = entry.copy()
+ del entry_without_type["type"]
+ data.update(entry_without_type)
+ else:
+ typ = entry
+
+ service_type_dict[typ].append(data)
for model in homekit_models:
if model in homekit_dict:
diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py
index d6dee8d0bd0..7cd09ad1f42 100644
--- a/script/scaffold/templates/config_flow/tests/test_config_flow.py
+++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py
@@ -21,7 +21,8 @@ async def test_form(hass):
), patch(
"homeassistant.components.NEW_DOMAIN.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.NEW_DOMAIN.async_setup_entry", return_value=True,
+ "homeassistant.components.NEW_DOMAIN.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
diff --git a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py
index d561f284caf..20b5a03206e 100644
--- a/script/scaffold/templates/config_flow_oauth2/integration/__init__.py
+++ b/script/scaffold/templates/config_flow_oauth2/integration/__init__.py
@@ -56,8 +56,10 @@ async def async_setup(hass: HomeAssistant, config: dict):
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up NEW_NAME from a config entry."""
- implementation = await config_entry_oauth2_flow.async_get_config_entry_implementation(
- hass, entry
+ implementation = (
+ await config_entry_oauth2_flow.async_get_config_entry_implementation(
+ hass, entry
+ )
)
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
diff --git a/script/scaffold/templates/config_flow_oauth2/integration/api.py b/script/scaffold/templates/config_flow_oauth2/integration/api.py
index bf0d9fd8817..14f7d60096c 100644
--- a/script/scaffold/templates/config_flow_oauth2/integration/api.py
+++ b/script/scaffold/templates/config_flow_oauth2/integration/api.py
@@ -52,7 +52,7 @@ class AsyncConfigEntryAuth(my_pypi_package.AbstractAuth):
async def async_get_access_token(self):
"""Return a valid access token."""
- if not self._oauth_session.is_valid:
+ if not self._oauth_session.valid_token:
await self._oauth_session.async_ensure_token_valid()
return self._oauth_session.token
diff --git a/script/translations/clean.py b/script/translations/clean.py
index 1c0178d4c0d..370c1d672ea 100644
--- a/script/translations/clean.py
+++ b/script/translations/clean.py
@@ -12,7 +12,10 @@ def get_arguments() -> argparse.Namespace:
"""Get parsed passed in arguments."""
parser = get_base_arg_parser()
parser.add_argument(
- "--target", type=str, default="core", choices=["core", "frontend"],
+ "--target",
+ type=str,
+ default="core",
+ choices=["core", "frontend"],
)
return parser.parse_args()
@@ -44,7 +47,10 @@ def find_core():
translations = int_dir / "translations" / "en.json"
strings_json = json.loads(strings.read_text())
- translations_json = json.loads(translations.read_text())
+ if translations.is_file():
+ translations_json = json.loads(translations.read_text())
+ else:
+ translations_json = {}
find_extra(
strings_json, translations_json, f"component::{int_dir.name}", missing_keys
diff --git a/setup.cfg b/setup.cfg
index 6dace4932db..8dd3e083a8b 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -38,19 +38,9 @@ ignore =
[isort]
# https://github.com/timothycrosley/isort
# https://github.com/timothycrosley/isort/wiki/isort-Settings
-# splits long import on multiple lines indented by 4 spaces
-multi_line_output = 3
-include_trailing_comma=True
-force_grid_wrap=0
-use_parentheses=True
-line_length=88
-indent = " "
-# by default isort don't check module indexes
-not_skip = __init__.py
+profile = black
# will group `import x` and `from x import` of the same module.
force_sort_within_sections = true
-sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
-default_section = THIRDPARTY
known_first_party = homeassistant,tests
forced_separate = tests
combine_as_imports = true
diff --git a/setup.py b/setup.py
index 81f8727ed60..0bbdf9f05a8 100755
--- a/setup.py
+++ b/setup.py
@@ -37,10 +37,10 @@ REQUIRES = [
"async_timeout==3.0.1",
"attrs==19.3.0",
"bcrypt==3.1.7",
- "certifi>=2020.4.5.1",
+ "certifi>=2020.6.20",
"ciso8601==2.1.3",
"importlib-metadata==1.6.0;python_version<'3.8'",
- "jinja2>=2.11.1",
+ "jinja2>=2.11.2",
"PyJWT==1.7.1",
# PyJWT has loose dependency. We want the latest one.
"cryptography==2.9.2",
diff --git a/tests/async_mock.py b/tests/async_mock.py
index 1942b2ca284..8257ddd3b3b 100644
--- a/tests/async_mock.py
+++ b/tests/async_mock.py
@@ -3,6 +3,7 @@ import sys
if sys.version_info[:2] < (3, 8):
from asynctest.mock import * # noqa
- from asynctest.mock import CoroutineMock as AsyncMock # noqa
+
+ AsyncMock = CoroutineMock # noqa: F405
else:
from unittest.mock import * # noqa
diff --git a/tests/common.py b/tests/common.py
index 16f349de800..d516873786b 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -205,6 +205,7 @@ async def async_test_home_assistant(loop):
hass.config.elevation = 0
hass.config.time_zone = date_util.get_time_zone("US/Pacific")
hass.config.units = METRIC_SYSTEM
+ hass.config.media_dirs = {"local": get_test_config_dir("media")}
hass.config.skip_pip = True
hass.config_entries = config_entries.ConfigEntries(hass, {})
@@ -261,8 +262,7 @@ def async_mock_intent(hass, intent_typ):
class MockIntentHandler(intent.IntentHandler):
intent_type = intent_typ
- @asyncio.coroutine
- def async_handle(self, intent):
+ async def async_handle(self, intent):
"""Handle the intent."""
intents.append(intent)
return intent.create_response()
@@ -818,11 +818,7 @@ def mock_restore_cache(hass, states):
_LOGGER.debug("Restore cache: %s", data.last_states)
assert len(data.last_states) == len(states), f"Duplicate entity_id? {states}"
- async def get_restore_state_data() -> restore_state.RestoreStateData:
- return data
-
- # Patch the singleton task in hass.data to return our new RestoreStateData
- hass.data[key] = hass.async_create_task(get_restore_state_data())
+ hass.data[key] = data
class MockEntity(entity.Entity):
diff --git a/tests/components/abode/test_alarm_control_panel.py b/tests/components/abode/test_alarm_control_panel.py
index d64b211f304..b45599c408e 100644
--- a/tests/components/abode/test_alarm_control_panel.py
+++ b/tests/components/abode/test_alarm_control_panel.py
@@ -61,7 +61,8 @@ async def test_set_alarm_away(hass):
mock_set_away.assert_called_once()
with patch(
- "abodepy.ALARM.AbodeAlarm.mode", new_callable=PropertyMock,
+ "abodepy.ALARM.AbodeAlarm.mode",
+ new_callable=PropertyMock,
) as mock_mode:
mock_mode.return_value = CONST.MODE_AWAY
diff --git a/tests/components/abode/test_sensor.py b/tests/components/abode/test_sensor.py
index 944c1bbeb8c..d99fac50dde 100644
--- a/tests/components/abode/test_sensor.py
+++ b/tests/components/abode/test_sensor.py
@@ -6,8 +6,8 @@ from homeassistant.const import (
ATTR_FRIENDLY_NAME,
ATTR_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_HUMIDITY,
+ PERCENTAGE,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
from .common import setup_platform
@@ -32,7 +32,7 @@ async def test_attributes(hass):
assert not state.attributes.get("battery_low")
assert not state.attributes.get("no_response")
assert state.attributes.get("device_type") == "LM"
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Environment Sensor Humidity"
assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY
diff --git a/tests/components/accuweather/__init__.py b/tests/components/accuweather/__init__.py
index 97ae531ddd0..28e53d1e2fc 100644
--- a/tests/components/accuweather/__init__.py
+++ b/tests/components/accuweather/__init__.py
@@ -1 +1,48 @@
"""Tests for AccuWeather."""
+import json
+
+from homeassistant.components.accuweather.const import DOMAIN
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry, load_fixture
+
+
+async def init_integration(
+ hass, forecast=False, unsupported_icon=False
+) -> MockConfigEntry:
+ """Set up the AccuWeather integration in Home Assistant."""
+ options = {}
+ if forecast:
+ options["forecast"] = True
+
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ title="Home",
+ unique_id="0123456",
+ data={
+ "api_key": "32-character-string-1234567890qw",
+ "latitude": 55.55,
+ "longitude": 122.12,
+ "name": "Home",
+ },
+ options=options,
+ )
+
+ current = json.loads(load_fixture("accuweather/current_conditions_data.json"))
+ forecast = json.loads(load_fixture("accuweather/forecast_data.json"))
+
+ if unsupported_icon:
+ current["WeatherIcon"] = 999
+
+ with patch(
+ "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions",
+ return_value=current,
+ ), patch(
+ "homeassistant.components.accuweather.AccuWeather.async_get_forecast",
+ return_value=forecast,
+ ):
+ entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ return entry
diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py
index 6b7430e524d..2d4769204ac 100644
--- a/tests/components/accuweather/test_config_flow.py
+++ b/tests/components/accuweather/test_config_flow.py
@@ -55,7 +55,9 @@ async def test_invalid_api_key(hass):
):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=VALID_CONFIG,
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=VALID_CONFIG,
)
assert result["errors"] == {CONF_API_KEY: "invalid_api_key"}
@@ -69,7 +71,9 @@ async def test_api_error(hass):
):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=VALID_CONFIG,
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=VALID_CONFIG,
)
assert result["errors"] == {"base": "cannot_connect"}
@@ -85,7 +89,9 @@ async def test_requests_exceeded_error(hass):
):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=VALID_CONFIG,
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=VALID_CONFIG,
)
assert result["errors"] == {CONF_API_KEY: "requests_exceeded"}
@@ -98,11 +104,15 @@ async def test_integration_already_exists(hass):
return_value=json.loads(load_fixture("accuweather/location_data.json")),
):
MockConfigEntry(
- domain=DOMAIN, unique_id="123456", data=VALID_CONFIG,
+ domain=DOMAIN,
+ unique_id="123456",
+ data=VALID_CONFIG,
).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=VALID_CONFIG,
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=VALID_CONFIG,
)
assert result["type"] == "abort"
@@ -119,7 +129,9 @@ async def test_create_entry(hass):
):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=VALID_CONFIG,
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=VALID_CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -133,7 +145,9 @@ async def test_create_entry(hass):
async def test_options_flow(hass):
"""Test config flow options."""
config_entry = MockConfigEntry(
- domain=DOMAIN, unique_id="123456", data=VALID_CONFIG,
+ domain=DOMAIN,
+ unique_id="123456",
+ data=VALID_CONFIG,
)
config_entry.add_to_hass(hass)
diff --git a/tests/components/accuweather/test_init.py b/tests/components/accuweather/test_init.py
new file mode 100644
index 00000000000..0a54132fd68
--- /dev/null
+++ b/tests/components/accuweather/test_init.py
@@ -0,0 +1,59 @@
+"""Test init of AccuWeather integration."""
+from homeassistant.components.accuweather.const import DOMAIN
+from homeassistant.config_entries import (
+ ENTRY_STATE_LOADED,
+ ENTRY_STATE_NOT_LOADED,
+ ENTRY_STATE_SETUP_RETRY,
+)
+from homeassistant.const import STATE_UNAVAILABLE
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+from tests.components.accuweather import init_integration
+
+
+async def test_async_setup_entry(hass):
+ """Test a successful setup entry."""
+ await init_integration(hass)
+
+ state = hass.states.get("weather.home")
+ assert state is not None
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "sunny"
+
+
+async def test_config_not_ready(hass):
+ """Test for setup failure if connection to AccuWeather is missing."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ title="Home",
+ unique_id="0123456",
+ data={
+ "api_key": "32-character-string-1234567890qw",
+ "latitude": 55.55,
+ "longitude": 122.12,
+ "name": "Home",
+ },
+ )
+
+ with patch(
+ "homeassistant.components.accuweather.AccuWeather._async_get_data",
+ side_effect=ConnectionError(),
+ ):
+ entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(entry.entry_id)
+ assert entry.state == ENTRY_STATE_SETUP_RETRY
+
+
+async def test_unload_entry(hass):
+ """Test successful unload of entry."""
+ entry = await init_integration(hass)
+
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+ assert entry.state == ENTRY_STATE_LOADED
+
+ assert await hass.config_entries.async_unload(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.state == ENTRY_STATE_NOT_LOADED
+ assert not hass.data.get(DOMAIN)
diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py
new file mode 100644
index 00000000000..b94d17066c8
--- /dev/null
+++ b/tests/components/accuweather/test_sensor.py
@@ -0,0 +1,598 @@
+"""Test sensor of AccuWeather integration."""
+from datetime import timedelta
+import json
+
+from homeassistant.components.accuweather.const import (
+ ATTRIBUTION,
+ CONCENTRATION_PARTS_PER_CUBIC_METER,
+ DOMAIN,
+ LENGTH_MILIMETERS,
+)
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.const import (
+ ATTR_ATTRIBUTION,
+ ATTR_DEVICE_CLASS,
+ ATTR_ENTITY_ID,
+ ATTR_ICON,
+ ATTR_UNIT_OF_MEASUREMENT,
+ DEVICE_CLASS_TEMPERATURE,
+ LENGTH_METERS,
+ PERCENTAGE,
+ SPEED_KILOMETERS_PER_HOUR,
+ STATE_UNAVAILABLE,
+ TEMP_CELSIUS,
+ TIME_HOURS,
+ UV_INDEX,
+)
+from homeassistant.setup import async_setup_component
+from homeassistant.util.dt import utcnow
+
+from tests.async_mock import patch
+from tests.common import async_fire_time_changed, load_fixture
+from tests.components.accuweather import init_integration
+
+
+async def test_sensor_without_forecast(hass):
+ """Test states of the sensor without forecast."""
+ await init_integration(hass)
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ state = hass.states.get("sensor.home_cloud_ceiling")
+ assert state
+ assert state.state == "3200"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-fog"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_METERS
+
+ entry = registry.async_get("sensor.home_cloud_ceiling")
+ assert entry
+ assert entry.unique_id == "0123456-ceiling"
+
+ state = hass.states.get("sensor.home_precipitation")
+ assert state
+ assert state.state == "0.0"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == LENGTH_MILIMETERS
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-rainy"
+ assert state.attributes.get("type") is None
+
+ entry = registry.async_get("sensor.home_precipitation")
+ assert entry
+ assert entry.unique_id == "0123456-precipitation"
+
+ state = hass.states.get("sensor.home_pressure_tendency")
+ assert state
+ assert state.state == "falling"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_ICON) == "mdi:gauge"
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == "accuweather__pressure_tendency"
+
+ entry = registry.async_get("sensor.home_pressure_tendency")
+ assert entry
+ assert entry.unique_id == "0123456-pressuretendency"
+
+ state = hass.states.get("sensor.home_realfeel_temperature")
+ assert state
+ assert state.state == "25.1"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE
+
+ entry = registry.async_get("sensor.home_realfeel_temperature")
+ assert entry
+ assert entry.unique_id == "0123456-realfeeltemperature"
+
+ state = hass.states.get("sensor.home_uv_index")
+ assert state
+ assert state.state == "6"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UV_INDEX
+ assert state.attributes.get("level") == "High"
+
+ entry = registry.async_get("sensor.home_uv_index")
+ assert entry
+ assert entry.unique_id == "0123456-uvindex"
+
+
+async def test_sensor_with_forecast(hass):
+ """Test states of the sensor with forecast."""
+ await init_integration(hass, forecast=True)
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ state = hass.states.get("sensor.home_hours_of_sun_0d")
+ assert state
+ assert state.state == "7.2"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-partly-cloudy"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TIME_HOURS
+
+ entry = registry.async_get("sensor.home_hours_of_sun_0d")
+ assert entry
+ assert entry.unique_id == "0123456-hoursofsun-0"
+
+ state = hass.states.get("sensor.home_realfeel_temperature_max_0d")
+ assert state
+ assert state.state == "29.8"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE
+
+ entry = registry.async_get("sensor.home_realfeel_temperature_max_0d")
+ assert entry
+
+ state = hass.states.get("sensor.home_realfeel_temperature_min_0d")
+ assert state
+ assert state.state == "15.1"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE
+
+ entry = registry.async_get("sensor.home_realfeel_temperature_min_0d")
+ assert entry
+ assert entry.unique_id == "0123456-realfeeltemperaturemin-0"
+
+ state = hass.states.get("sensor.home_thunderstorm_probability_day_0d")
+ assert state
+ assert state.state == "40"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
+
+ entry = registry.async_get("sensor.home_thunderstorm_probability_day_0d")
+ assert entry
+ assert entry.unique_id == "0123456-thunderstormprobabilityday-0"
+
+ state = hass.states.get("sensor.home_thunderstorm_probability_night_0d")
+ assert state
+ assert state.state == "40"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
+
+ entry = registry.async_get("sensor.home_thunderstorm_probability_night_0d")
+ assert entry
+ assert entry.unique_id == "0123456-thunderstormprobabilitynight-0"
+
+ state = hass.states.get("sensor.home_uv_index_0d")
+ assert state
+ assert state.state == "5"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny"
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UV_INDEX
+ assert state.attributes.get("level") == "Moderate"
+
+ entry = registry.async_get("sensor.home_uv_index_0d")
+ assert entry
+ assert entry.unique_id == "0123456-uvindex-0"
+
+
+async def test_sensor_disabled(hass):
+ """Test sensor disabled by default."""
+ await init_integration(hass)
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ entry = registry.async_get("sensor.home_apparent_temperature")
+ assert entry
+ assert entry.unique_id == "0123456-apparenttemperature"
+ assert entry.disabled
+ assert entry.disabled_by == "integration"
+
+ # Test enabling entity
+ updated_entry = registry.async_update_entity(
+ entry.entity_id, **{"disabled_by": None}
+ )
+
+ assert updated_entry != entry
+ assert updated_entry.disabled is False
+
+
+async def test_sensor_enabled_without_forecast(hass):
+ """Test enabling an advanced sensor."""
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-apparenttemperature",
+ suggested_object_id="home_apparent_temperature",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-cloudcover",
+ suggested_object_id="home_cloud_cover",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-dewpoint",
+ suggested_object_id="home_dew_point",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-realfeeltemperatureshade",
+ suggested_object_id="home_realfeel_temperature_shade",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-wetbulbtemperature",
+ suggested_object_id="home_wet_bulb_temperature",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-windchilltemperature",
+ suggested_object_id="home_wind_chill_temperature",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-windgust",
+ suggested_object_id="home_wind_gust",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-cloudcoverday-0",
+ suggested_object_id="home_cloud_cover_day_0d",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-cloudcovernight-0",
+ suggested_object_id="home_cloud_cover_night_0d",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-grass-0",
+ suggested_object_id="home_grass_pollen_0d",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-mold-0",
+ suggested_object_id="home_mold_pollen_0d",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-ozone-0",
+ suggested_object_id="home_ozone_0d",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-ragweed-0",
+ suggested_object_id="home_ragweed_pollen_0d",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-realfeeltemperatureshademax-0",
+ suggested_object_id="home_realfeel_temperature_shade_max_0d",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-realfeeltemperatureshademin-0",
+ suggested_object_id="home_realfeel_temperature_shade_min_0d",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-tree-0",
+ suggested_object_id="home_tree_pollen_0d",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-windgustday-0",
+ suggested_object_id="home_wind_gust_day_0d",
+ disabled_by=None,
+ )
+ registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "0123456-windgustnight-0",
+ suggested_object_id="home_wind_gust_night_0d",
+ disabled_by=None,
+ )
+
+ await init_integration(hass, forecast=True)
+
+ state = hass.states.get("sensor.home_apparent_temperature")
+ assert state
+ assert state.state == "22.8"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE
+
+ entry = registry.async_get("sensor.home_apparent_temperature")
+ assert entry
+ assert entry.unique_id == "0123456-apparenttemperature"
+
+ state = hass.states.get("sensor.home_cloud_cover")
+ assert state
+ assert state.state == "10"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy"
+
+ entry = registry.async_get("sensor.home_cloud_cover")
+ assert entry
+ assert entry.unique_id == "0123456-cloudcover"
+
+ state = hass.states.get("sensor.home_dew_point")
+ assert state
+ assert state.state == "16.2"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE
+
+ entry = registry.async_get("sensor.home_dew_point")
+ assert entry
+ assert entry.unique_id == "0123456-dewpoint"
+
+ state = hass.states.get("sensor.home_realfeel_temperature_shade")
+ assert state
+ assert state.state == "21.1"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE
+
+ entry = registry.async_get("sensor.home_realfeel_temperature_shade")
+ assert entry
+ assert entry.unique_id == "0123456-realfeeltemperatureshade"
+
+ state = hass.states.get("sensor.home_wet_bulb_temperature")
+ assert state
+ assert state.state == "18.6"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE
+
+ entry = registry.async_get("sensor.home_wet_bulb_temperature")
+ assert entry
+ assert entry.unique_id == "0123456-wetbulbtemperature"
+
+ state = hass.states.get("sensor.home_wind_chill_temperature")
+ assert state
+ assert state.state == "22.8"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE
+
+ entry = registry.async_get("sensor.home_wind_chill_temperature")
+ assert entry
+ assert entry.unique_id == "0123456-windchilltemperature"
+
+ state = hass.states.get("sensor.home_wind_gust")
+ assert state
+ assert state.state == "20.3"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SPEED_KILOMETERS_PER_HOUR
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy"
+
+ entry = registry.async_get("sensor.home_wind_gust")
+ assert entry
+ assert entry.unique_id == "0123456-windgust"
+
+ state = hass.states.get("sensor.home_cloud_cover_day_0d")
+ assert state
+ assert state.state == "58"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy"
+
+ entry = registry.async_get("sensor.home_cloud_cover_day_0d")
+ assert entry
+ assert entry.unique_id == "0123456-cloudcoverday-0"
+
+ state = hass.states.get("sensor.home_cloud_cover_night_0d")
+ assert state
+ assert state.state == "65"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-cloudy"
+
+ entry = registry.async_get("sensor.home_cloud_cover_night_0d")
+ assert entry
+
+ state = hass.states.get("sensor.home_grass_pollen_0d")
+ assert state
+ assert state.state == "0"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert (
+ state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ == CONCENTRATION_PARTS_PER_CUBIC_METER
+ )
+ assert state.attributes.get("level") == "Low"
+ assert state.attributes.get(ATTR_ICON) == "mdi:grass"
+
+ entry = registry.async_get("sensor.home_grass_pollen_0d")
+ assert entry
+ assert entry.unique_id == "0123456-grass-0"
+
+ state = hass.states.get("sensor.home_mold_pollen_0d")
+ assert state
+ assert state.state == "0"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert (
+ state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ == CONCENTRATION_PARTS_PER_CUBIC_METER
+ )
+ assert state.attributes.get("level") == "Low"
+ assert state.attributes.get(ATTR_ICON) == "mdi:blur"
+
+ entry = registry.async_get("sensor.home_mold_pollen_0d")
+ assert entry
+ assert entry.unique_id == "0123456-mold-0"
+
+ state = hass.states.get("sensor.home_ozone_0d")
+ assert state
+ assert state.state == "32"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get("level") == "Good"
+ assert state.attributes.get(ATTR_ICON) == "mdi:vector-triangle"
+
+ entry = registry.async_get("sensor.home_ozone_0d")
+ assert entry
+ assert entry.unique_id == "0123456-ozone-0"
+
+ state = hass.states.get("sensor.home_ragweed_pollen_0d")
+ assert state
+ assert state.state == "0"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert (
+ state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ == CONCENTRATION_PARTS_PER_CUBIC_METER
+ )
+ assert state.attributes.get("level") == "Low"
+ assert state.attributes.get(ATTR_ICON) == "mdi:sprout"
+
+ entry = registry.async_get("sensor.home_ragweed_pollen_0d")
+ assert entry
+ assert entry.unique_id == "0123456-ragweed-0"
+
+ state = hass.states.get("sensor.home_realfeel_temperature_shade_max_0d")
+ assert state
+ assert state.state == "28.0"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE
+
+ entry = registry.async_get("sensor.home_realfeel_temperature_shade_max_0d")
+ assert entry
+ assert entry.unique_id == "0123456-realfeeltemperatureshademax-0"
+
+ state = hass.states.get("sensor.home_realfeel_temperature_shade_min_0d")
+ assert state
+ assert state.state == "15.1"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TEMPERATURE
+
+ entry = registry.async_get("sensor.home_realfeel_temperature_shade_min_0d")
+ assert entry
+ assert entry.unique_id == "0123456-realfeeltemperatureshademin-0"
+
+ state = hass.states.get("sensor.home_tree_pollen_0d")
+ assert state
+ assert state.state == "0"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert (
+ state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ == CONCENTRATION_PARTS_PER_CUBIC_METER
+ )
+ assert state.attributes.get("level") == "Low"
+ assert state.attributes.get(ATTR_ICON) == "mdi:tree-outline"
+
+ entry = registry.async_get("sensor.home_tree_pollen_0d")
+ assert entry
+ assert entry.unique_id == "0123456-tree-0"
+
+ state = hass.states.get("sensor.home_wind_gust_day_0d")
+ assert state
+ assert state.state == "29.6"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SPEED_KILOMETERS_PER_HOUR
+ assert state.attributes.get("direction") == "S"
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy"
+
+ entry = registry.async_get("sensor.home_wind_gust_day_0d")
+ assert entry
+ assert entry.unique_id == "0123456-windgustday-0"
+
+ state = hass.states.get("sensor.home_wind_gust_night_0d")
+ assert state
+ assert state.state == "18.5"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == SPEED_KILOMETERS_PER_HOUR
+ assert state.attributes.get("direction") == "WSW"
+ assert state.attributes.get(ATTR_ICON) == "mdi:weather-windy"
+
+ entry = registry.async_get("sensor.home_wind_gust_night_0d")
+ assert entry
+ assert entry.unique_id == "0123456-windgustnight-0"
+
+
+async def test_availability(hass):
+ """Ensure that we mark the entities unavailable correctly when service is offline."""
+ await init_integration(hass)
+
+ state = hass.states.get("sensor.home_cloud_ceiling")
+ assert state
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "3200"
+
+ future = utcnow() + timedelta(minutes=60)
+ with patch(
+ "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions",
+ side_effect=ConnectionError(),
+ ):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.home_cloud_ceiling")
+ assert state
+ assert state.state == STATE_UNAVAILABLE
+
+ future = utcnow() + timedelta(minutes=120)
+ with patch(
+ "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions",
+ return_value=json.loads(
+ load_fixture("accuweather/current_conditions_data.json")
+ ),
+ ):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("sensor.home_cloud_ceiling")
+ assert state
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "3200"
+
+
+async def test_manual_update_entity(hass):
+ """Test manual update entity via service homeasasistant/update_entity."""
+ await init_integration(hass, forecast=True)
+
+ await async_setup_component(hass, "homeassistant", {})
+
+ current = json.loads(load_fixture("accuweather/current_conditions_data.json"))
+ forecast = json.loads(load_fixture("accuweather/forecast_data.json"))
+
+ with patch(
+ "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions",
+ return_value=current,
+ ) as mock_current, patch(
+ "homeassistant.components.accuweather.AccuWeather.async_get_forecast",
+ return_value=forecast,
+ ) as mock_forecast:
+ await hass.services.async_call(
+ "homeassistant",
+ "update_entity",
+ {ATTR_ENTITY_ID: ["sensor.home_cloud_ceiling"]},
+ blocking=True,
+ )
+ assert mock_current.call_count == 1
+ assert mock_forecast.call_count == 1
diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py
new file mode 100644
index 00000000000..692b2ae243f
--- /dev/null
+++ b/tests/components/accuweather/test_weather.py
@@ -0,0 +1,155 @@
+"""Test weather of AccuWeather integration."""
+from datetime import timedelta
+import json
+
+from homeassistant.components.accuweather.const import ATTRIBUTION
+from homeassistant.components.weather import (
+ ATTR_FORECAST,
+ ATTR_FORECAST_CONDITION,
+ ATTR_FORECAST_PRECIPITATION,
+ ATTR_FORECAST_PRECIPITATION_PROBABILITY,
+ ATTR_FORECAST_TEMP,
+ ATTR_FORECAST_TEMP_LOW,
+ ATTR_FORECAST_TIME,
+ ATTR_FORECAST_WIND_BEARING,
+ ATTR_FORECAST_WIND_SPEED,
+ ATTR_WEATHER_HUMIDITY,
+ ATTR_WEATHER_OZONE,
+ ATTR_WEATHER_PRESSURE,
+ ATTR_WEATHER_TEMPERATURE,
+ ATTR_WEATHER_VISIBILITY,
+ ATTR_WEATHER_WIND_BEARING,
+ ATTR_WEATHER_WIND_SPEED,
+)
+from homeassistant.const import ATTR_ATTRIBUTION, ATTR_ENTITY_ID, STATE_UNAVAILABLE
+from homeassistant.setup import async_setup_component
+from homeassistant.util.dt import utcnow
+
+from tests.async_mock import patch
+from tests.common import async_fire_time_changed, load_fixture
+from tests.components.accuweather import init_integration
+
+
+async def test_weather_without_forecast(hass):
+ """Test states of the weather without forecast."""
+ await init_integration(hass)
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ state = hass.states.get("weather.home")
+ assert state
+ assert state.state == "sunny"
+ assert not state.attributes.get(ATTR_FORECAST)
+ assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 67
+ assert not state.attributes.get(ATTR_WEATHER_OZONE)
+ assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1012.0
+ assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == 22.6
+ assert state.attributes.get(ATTR_WEATHER_VISIBILITY) == 16.1
+ assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 180
+ assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 14.5
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+
+ entry = registry.async_get("weather.home")
+ assert entry
+ assert entry.unique_id == "0123456"
+
+
+async def test_weather_with_forecast(hass):
+ """Test states of the weather with forecast."""
+ await init_integration(hass, forecast=True)
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ state = hass.states.get("weather.home")
+ assert state
+ assert state.state == "sunny"
+ assert state.attributes.get(ATTR_WEATHER_HUMIDITY) == 67
+ assert state.attributes.get(ATTR_WEATHER_OZONE) == 32
+ assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1012.0
+ assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == 22.6
+ assert state.attributes.get(ATTR_WEATHER_VISIBILITY) == 16.1
+ assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 180
+ assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 14.5
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ forecast = state.attributes.get(ATTR_FORECAST)[0]
+ assert forecast.get(ATTR_FORECAST_CONDITION) == "lightning-rainy"
+ assert forecast.get(ATTR_FORECAST_PRECIPITATION) == 4.8
+ assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 58
+ assert forecast.get(ATTR_FORECAST_TEMP) == 29.5
+ assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 15.4
+ assert forecast.get(ATTR_FORECAST_TIME) == "2020-07-26T05:00:00+00:00"
+ assert forecast.get(ATTR_FORECAST_WIND_BEARING) == 166
+ assert forecast.get(ATTR_FORECAST_WIND_SPEED) == 13.0
+
+ entry = registry.async_get("weather.home")
+ assert entry
+ assert entry.unique_id == "0123456"
+
+
+async def test_availability(hass):
+ """Ensure that we mark the entities unavailable correctly when service is offline."""
+ await init_integration(hass)
+
+ state = hass.states.get("weather.home")
+ assert state
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "sunny"
+
+ future = utcnow() + timedelta(minutes=60)
+ with patch(
+ "homeassistant.components.accuweather.AccuWeather._async_get_data",
+ side_effect=ConnectionError(),
+ ):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("weather.home")
+ assert state
+ assert state.state == STATE_UNAVAILABLE
+
+ future = utcnow() + timedelta(minutes=120)
+ with patch(
+ "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions",
+ return_value=json.loads(
+ load_fixture("accuweather/current_conditions_data.json")
+ ),
+ ):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("weather.home")
+ assert state
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "sunny"
+
+
+async def test_manual_update_entity(hass):
+ """Test manual update entity via service homeasasistant/update_entity."""
+ await init_integration(hass, forecast=True)
+
+ await async_setup_component(hass, "homeassistant", {})
+
+ current = json.loads(load_fixture("accuweather/current_conditions_data.json"))
+ forecast = json.loads(load_fixture("accuweather/forecast_data.json"))
+
+ with patch(
+ "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions",
+ return_value=current,
+ ) as mock_current, patch(
+ "homeassistant.components.accuweather.AccuWeather.async_get_forecast",
+ return_value=forecast,
+ ) as mock_forecast:
+ await hass.services.async_call(
+ "homeassistant",
+ "update_entity",
+ {ATTR_ENTITY_ID: ["weather.home"]},
+ blocking=True,
+ )
+ assert mock_current.call_count == 1
+ assert mock_forecast.call_count == 1
+
+
+async def test_unsupported_condition_icon_data(hass):
+ """Test with unsupported condition icon data."""
+ await init_integration(hass, forecast=True, unsupported_icon=True)
+
+ state = hass.states.get("weather.home")
+ assert state.attributes.get(ATTR_FORECAST_CONDITION) is None
diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py
index 69e1285889b..dae9e0da79d 100644
--- a/tests/components/adguard/test_config_flow.py
+++ b/tests/components/adguard/test_config_flow.py
@@ -156,16 +156,22 @@ async def test_hassio_update_instance_running(hass, aioclient_mock):
entry.add_to_hass(hass)
with patch.object(
- hass.config_entries, "async_forward_entry_setup", return_value=True,
+ hass.config_entries,
+ "async_forward_entry_setup",
+ return_value=True,
) as mock_load:
assert await hass.config_entries.async_setup(entry.entry_id)
assert entry.state == config_entries.ENTRY_STATE_LOADED
assert len(mock_load.mock_calls) == 2
with patch.object(
- hass.config_entries, "async_forward_entry_unload", return_value=True,
+ hass.config_entries,
+ "async_forward_entry_unload",
+ return_value=True,
) as mock_unload, patch.object(
- hass.config_entries, "async_forward_entry_setup", return_value=True,
+ hass.config_entries,
+ "async_forward_entry_setup",
+ return_value=True,
) as mock_load:
result = await hass.config_entries.flow.async_init(
"adguard",
diff --git a/tests/components/agent_dvr/__init__.py b/tests/components/agent_dvr/__init__.py
index f0c059d12e2..2cda0b8aa73 100644
--- a/tests/components/agent_dvr/__init__.py
+++ b/tests/components/agent_dvr/__init__.py
@@ -9,7 +9,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker
async def init_integration(
- hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, skip_setup: bool = False,
+ hass: HomeAssistant,
+ aioclient_mock: AiohttpClientMocker,
+ skip_setup: bool = False,
) -> MockConfigEntry:
"""Set up the Agent DVR integration in Home Assistant."""
diff --git a/tests/components/agent_dvr/test_config_flow.py b/tests/components/agent_dvr/test_config_flow.py
index 33b5805700f..403dd9f4bee 100644
--- a/tests/components/agent_dvr/test_config_flow.py
+++ b/tests/components/agent_dvr/test_config_flow.py
@@ -15,7 +15,8 @@ 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},
+ config_flow.DOMAIN,
+ context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
@@ -70,7 +71,8 @@ async def test_full_user_flow_implementation(
)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": SOURCE_USER},
+ config_flow.DOMAIN,
+ context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
diff --git a/tests/components/airly/test_sensor.py b/tests/components/airly/test_sensor.py
index 3131789c6e0..45b98d7c27c 100644
--- a/tests/components/airly/test_sensor.py
+++ b/tests/components/airly/test_sensor.py
@@ -13,10 +13,10 @@ from homeassistant.const import (
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_PRESSURE,
DEVICE_CLASS_TEMPERATURE,
+ PERCENTAGE,
PRESSURE_HPA,
STATE_UNAVAILABLE,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
from homeassistant.setup import async_setup_component
from homeassistant.util.dt import utcnow
@@ -35,7 +35,7 @@ async def test_sensor(hass):
assert state
assert state.state == "92.8"
assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_HUMIDITY
entry = registry.async_get("sensor.home_humidity")
diff --git a/tests/components/airvisual/test_config_flow.py b/tests/components/airvisual/test_config_flow.py
index 6741731e0e5..8912b0287d7 100644
--- a/tests/components/airvisual/test_config_flow.py
+++ b/tests/components/airvisual/test_config_flow.py
@@ -68,7 +68,8 @@ async def test_invalid_identifier(hass):
}
with patch(
- "pyairvisual.api.API.nearest_city", side_effect=InvalidKeyError,
+ "pyairvisual.api.API.nearest_city",
+ side_effect=InvalidKeyError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=geography_conf
@@ -82,7 +83,8 @@ async def test_node_pro_error(hass):
node_pro_conf = {CONF_IP_ADDRESS: "192.168.1.100", CONF_PASSWORD: "my_password"}
with patch(
- "pyairvisual.node.Node.from_samba", side_effect=NodeProError,
+ "pyairvisual.node.Node.from_samba",
+ side_effect=NodeProError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data={"type": "AirVisual Node/Pro"}
diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py
index 678a8e74027..2fcc3a236e3 100644
--- a/tests/components/alexa/test_capabilities.py
+++ b/tests/components/alexa/test_capabilities.py
@@ -33,6 +33,7 @@ from . import (
reported_properties,
)
+from tests.async_mock import patch
from tests.common import async_mock_service
@@ -756,3 +757,25 @@ async def test_report_image_processing(hass):
"humanPresenceDetectionState",
{"value": "DETECTED"},
)
+
+
+async def test_get_property_blowup(hass, caplog):
+ """Test we handle a property blowing up."""
+ hass.states.async_set(
+ "climate.downstairs",
+ climate.HVAC_MODE_AUTO,
+ {
+ "friendly_name": "Climate Downstairs",
+ "supported_features": 91,
+ climate.ATTR_CURRENT_TEMPERATURE: 34,
+ ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS,
+ },
+ )
+ with patch(
+ "homeassistant.components.alexa.capabilities.float",
+ side_effect=Exception("Boom Fail"),
+ ):
+ properties = await reported_properties(hass, "climate.downstairs")
+ properties.assert_not_has_property("Alexa.ThermostatController", "temperature")
+
+ assert "Boom Fail" in caplog.text
diff --git a/tests/components/alexa/test_entities.py b/tests/components/alexa/test_entities.py
index 8cae4fb9b77..6b48c313fcc 100644
--- a/tests/components/alexa/test_entities.py
+++ b/tests/components/alexa/test_entities.py
@@ -3,6 +3,8 @@ from homeassistant.components.alexa import smart_home
from . import DEFAULT_CONFIG, get_new_request
+from tests.async_mock import patch
+
async def test_unsupported_domain(hass):
"""Discovery ignores entities of unknown domains."""
@@ -16,3 +18,29 @@ async def test_unsupported_domain(hass):
msg = msg["event"]
assert not msg["payload"]["endpoints"]
+
+
+async def test_serialize_discovery_recovers(hass, caplog):
+ """Test we handle an interface raising unexpectedly during serialize discovery."""
+ request = get_new_request("Alexa.Discovery", "Discover")
+
+ hass.states.async_set("switch.bla", "on", {"friendly_name": "Boop Woz"})
+
+ with patch(
+ "homeassistant.components.alexa.capabilities.AlexaPowerController.serialize_discovery",
+ side_effect=TypeError,
+ ):
+ msg = await smart_home.async_handle_message(hass, DEFAULT_CONFIG, request)
+
+ assert "event" in msg
+ msg = msg["event"]
+
+ interfaces = {
+ ifc["interface"] for ifc in msg["payload"]["endpoints"][0]["capabilities"]
+ }
+
+ assert "Alexa.PowerController" not in interfaces
+ assert (
+ f"Error serializing Alexa.PowerController discovery for {hass.states.get('switch.bla')}"
+ in caplog.text
+ )
diff --git a/tests/components/alexa/test_init.py b/tests/components/alexa/test_init.py
index 605ca96f190..c0972351cce 100644
--- a/tests/components/alexa/test_init.py
+++ b/tests/components/alexa/test_init.py
@@ -44,6 +44,7 @@ async def test_humanify_alexa_event(hass):
),
],
entity_attr_cache,
+ {},
)
)
diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py
index 1ac01c3d3ff..c3bbcbb7a31 100644
--- a/tests/components/alexa/test_smart_home.py
+++ b/tests/components/alexa/test_smart_home.py
@@ -3296,7 +3296,10 @@ async def test_media_player_sound_mode_list_unsupported(hass):
# Test equalizer controller is not there
assert_endpoint_capabilities(
- appliance, "Alexa", "Alexa.PowerController", "Alexa.EndpointHealth",
+ appliance,
+ "Alexa",
+ "Alexa.PowerController",
+ "Alexa.EndpointHealth",
)
@@ -3832,7 +3835,8 @@ async def test_camera_hass_urls(hass, mock_stream, url, result):
{"friendly_name": "Test camera", "supported_features": 3},
)
await async_process_ha_core_config(
- hass, {"external_url": url},
+ hass,
+ {"external_url": url},
)
appliance = await discovery_test(device, hass)
@@ -3846,7 +3850,8 @@ async def test_initialize_camera_stream(hass, mock_camera, mock_stream):
)
await async_process_ha_core_config(
- hass, {"external_url": "https://mycamerastream.test"},
+ hass,
+ {"external_url": "https://mycamerastream.test"},
)
with patch(
diff --git a/tests/components/almond/test_config_flow.py b/tests/components/almond/test_config_flow.py
index a6785d2eff0..49694d84410 100644
--- a/tests/components/almond/test_config_flow.py
+++ b/tests/components/almond/test_config_flow.py
@@ -18,7 +18,9 @@ async def test_import(hass):
"""Test that we can import a config entry."""
with patch("pyalmond.WebAlmondAPI.async_list_apps"):
assert await setup.async_setup_component(
- hass, DOMAIN, {DOMAIN: {"type": "local", "host": "http://localhost:3000"}},
+ hass,
+ DOMAIN,
+ {DOMAIN: {"type": "local", "host": "http://localhost:3000"}},
)
await hass.async_block_till_done()
@@ -34,7 +36,9 @@ async def test_import_cannot_connect(hass):
"pyalmond.WebAlmondAPI.async_list_apps", side_effect=asyncio.TimeoutError
):
assert await setup.async_setup_component(
- hass, DOMAIN, {DOMAIN: {"type": "local", "host": "http://localhost:3000"}},
+ hass,
+ DOMAIN,
+ {DOMAIN: {"type": "local", "host": "http://localhost:3000"}},
)
await hass.async_block_till_done()
@@ -87,7 +91,7 @@ async def test_abort_if_existing_entry(hass):
assert result["reason"] == "already_setup"
-async def test_full_flow(hass, aiohttp_client, aioclient_mock):
+async def test_full_flow(hass, aiohttp_client, aioclient_mock, current_request):
"""Check full flow."""
assert await setup.async_setup_component(
hass,
diff --git a/tests/components/almond/test_init.py b/tests/components/almond/test_init.py
index 9fb228dbf66..35a641bac01 100644
--- a/tests/components/almond/test_init.py
+++ b/tests/components/almond/test_init.py
@@ -99,7 +99,8 @@ async def test_set_up_local(hass, aioclient_mock):
# Set up an internal URL, as Almond won't be set up if there is no URL available
await async_process_ha_core_config(
- hass, {"internal_url": "https://192.168.0.1"},
+ hass,
+ {"internal_url": "https://192.168.0.1"},
)
entry = MockConfigEntry(
diff --git a/tests/components/androidtv/patchers.py b/tests/components/androidtv/patchers.py
index 98c38719cf0..a89abe33de3 100644
--- a/tests/components/androidtv/patchers.py
+++ b/tests/components/androidtv/patchers.py
@@ -111,7 +111,7 @@ def patch_shell(response=None, error=False):
async def shell_fail_python(self, cmd, *args, **kwargs):
"""Mock the `AdbDeviceTcpAsyncFake.shell` method when it fails."""
self.shell_cmd = cmd
- raise AttributeError
+ raise ValueError
async def shell_fail_server(self, cmd):
"""Mock the `DeviceAsyncFake.shell` method when it fails."""
@@ -182,3 +182,9 @@ def patch_androidtv_update(
PATCH_LAUNCH_APP = patch("androidtv.basetv.basetv_async.BaseTVAsync.launch_app")
PATCH_STOP_APP = patch("androidtv.basetv.basetv_async.BaseTVAsync.stop_app")
+
+# Cause the update to raise an unexpected type of exception
+PATCH_ANDROIDTV_UPDATE_EXCEPTION = patch(
+ "androidtv.androidtv.androidtv_async.AndroidTVAsync.update",
+ side_effect=ZeroDivisionError,
+)
diff --git a/tests/components/androidtv/test_media_player.py b/tests/components/androidtv/test_media_player.py
index bf16957b07f..456a9313091 100644
--- a/tests/components/androidtv/test_media_player.py
+++ b/tests/components/androidtv/test_media_player.py
@@ -1,8 +1,10 @@
"""The tests for the androidtv platform."""
import base64
+import copy
import logging
from androidtv.exceptions import LockNotAcquiredException
+import pytest
from homeassistant.components.androidtv.media_player import (
ANDROIDTV_DOMAIN,
@@ -294,7 +296,7 @@ async def test_adb_shell_returns_none_firetv_adb_server(hass):
async def test_setup_with_adbkey(hass):
"""Test that setup succeeds when using an ADB key."""
- config = CONFIG_ANDROIDTV_PYTHON_ADB.copy()
+ config = copy.deepcopy(CONFIG_ANDROIDTV_PYTHON_ADB)
config[DOMAIN][CONF_ADBKEY] = hass.config.path("user_provided_adbkey")
patch_key, entity_id = _setup(config)
@@ -313,7 +315,7 @@ async def test_setup_with_adbkey(hass):
async def _test_sources(hass, config0):
"""Test that sources (i.e., apps) are handled correctly for Android TV and Fire TV devices."""
- config = config0.copy()
+ config = copy.deepcopy(config0)
config[DOMAIN][CONF_APPS] = {
"com.app.test1": "TEST 1",
"com.app.test3": None,
@@ -394,7 +396,7 @@ async def test_firetv_sources(hass):
async def _test_exclude_sources(hass, config0, expected_sources):
"""Test that sources (i.e., apps) are handled correctly when the `exclude_unnamed_apps` config parameter is provided."""
- config = config0.copy()
+ config = copy.deepcopy(config0)
config[DOMAIN][CONF_APPS] = {
"com.app.test1": "TEST 1",
"com.app.test3": None,
@@ -453,21 +455,21 @@ async def _test_exclude_sources(hass, config0, expected_sources):
async def test_androidtv_exclude_sources(hass):
"""Test that sources (i.e., apps) are handled correctly for Android TV devices when the `exclude_unnamed_apps` config parameter is provided as true."""
- config = CONFIG_ANDROIDTV_ADB_SERVER.copy()
+ config = copy.deepcopy(CONFIG_ANDROIDTV_ADB_SERVER)
config[DOMAIN][CONF_EXCLUDE_UNNAMED_APPS] = True
assert await _test_exclude_sources(hass, config, ["TEST 1"])
async def test_firetv_exclude_sources(hass):
"""Test that sources (i.e., apps) are handled correctly for Fire TV devices when the `exclude_unnamed_apps` config parameter is provided as true."""
- config = CONFIG_FIRETV_ADB_SERVER.copy()
+ config = copy.deepcopy(CONFIG_FIRETV_ADB_SERVER)
config[DOMAIN][CONF_EXCLUDE_UNNAMED_APPS] = True
assert await _test_exclude_sources(hass, config, ["TEST 1"])
async def _test_select_source(hass, config0, source, expected_arg, method_patch):
"""Test that the methods for launching and stopping apps are called correctly when selecting a source."""
- config = config0.copy()
+ config = copy.deepcopy(config0)
config[DOMAIN][CONF_APPS] = {"com.app.test1": "TEST 1", "com.app.test3": None}
patch_key, entity_id = _setup(config)
@@ -702,7 +704,7 @@ async def test_setup_two_devices(hass):
config = {
DOMAIN: [
CONFIG_ANDROIDTV_ADB_SERVER[DOMAIN],
- CONFIG_FIRETV_ADB_SERVER[DOMAIN].copy(),
+ copy.deepcopy(CONFIG_FIRETV_ADB_SERVER[DOMAIN]),
]
}
config[DOMAIN][1][CONF_HOST] = "127.0.0.2"
@@ -1085,7 +1087,10 @@ async def _test_service(
f"androidtv.{androidtv_patch}.{androidtv_method}", return_value=return_value
) as service_call:
await hass.services.async_call(
- DOMAIN, ha_service_name, service_data=service_data, blocking=True,
+ DOMAIN,
+ ha_service_name,
+ service_data=service_data,
+ blocking=True,
)
assert service_call.called
@@ -1142,7 +1147,7 @@ async def test_services_androidtv(hass):
async def test_services_firetv(hass):
"""Test media player services for a Fire TV device."""
patch_key, entity_id = _setup(CONFIG_FIRETV_ADB_SERVER)
- config = CONFIG_FIRETV_ADB_SERVER.copy()
+ config = copy.deepcopy(CONFIG_FIRETV_ADB_SERVER)
config[DOMAIN][CONF_TURN_OFF_COMMAND] = "test off"
config[DOMAIN][CONF_TURN_ON_COMMAND] = "test on"
@@ -1174,3 +1179,37 @@ async def test_connection_closed_on_ha_stop(hass):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
await hass.async_block_till_done()
assert adb_close.called
+
+
+async def test_exception(hass):
+ """Test that the ADB connection gets closed when there is an unforeseen exception.
+
+ HA will attempt to reconnect on the next update.
+ """
+ patch_key, entity_id = _setup(CONFIG_ANDROIDTV_PYTHON_ADB)
+
+ with patchers.PATCH_ADB_DEVICE_TCP, patchers.patch_connect(True)[
+ patch_key
+ ], patchers.patch_shell(SHELL_RESPONSE_OFF)[
+ patch_key
+ ], patchers.PATCH_KEYGEN, patchers.PATCH_ANDROIDTV_OPEN, patchers.PATCH_SIGNER:
+ assert await async_setup_component(hass, DOMAIN, CONFIG_ANDROIDTV_PYTHON_ADB)
+ await hass.async_block_till_done()
+
+ await hass.helpers.entity_component.async_update_entity(entity_id)
+ state = hass.states.get(entity_id)
+ assert state is not None
+ assert state.state == STATE_OFF
+
+ # When an unforessen exception occurs, we close the ADB connection and raise the exception
+ with patchers.PATCH_ANDROIDTV_UPDATE_EXCEPTION, pytest.raises(Exception):
+ await hass.helpers.entity_component.async_update_entity(entity_id)
+ state = hass.states.get(entity_id)
+ assert state is not None
+ assert state.state == STATE_UNAVAILABLE
+
+ # On the next update, HA will reconnect to the device
+ await hass.helpers.entity_component.async_update_entity(entity_id)
+ state = hass.states.get(entity_id)
+ assert state is not None
+ assert state.state == STATE_OFF
diff --git a/tests/components/arcam_fmj/test_config_flow.py b/tests/components/arcam_fmj/test_config_flow.py
index 9475c2f110c..032f57c3113 100644
--- a/tests/components/arcam_fmj/test_config_flow.py
+++ b/tests/components/arcam_fmj/test_config_flow.py
@@ -56,7 +56,9 @@ def dummy_client_fixture(hass):
async def test_ssdp(hass, dummy_client):
"""Test a ssdp import flow."""
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVER,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_SSDP},
+ data=MOCK_DISCOVER,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "confirm"
@@ -75,7 +77,9 @@ async def test_ssdp_abort(hass):
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVER,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_SSDP},
+ data=MOCK_DISCOVER,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
@@ -86,7 +90,9 @@ async def test_ssdp_unable_to_connect(hass, dummy_client):
dummy_client.start.side_effect = AsyncMock(side_effect=ConnectionFailed)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVER,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_SSDP},
+ data=MOCK_DISCOVER,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "confirm"
@@ -107,7 +113,9 @@ async def test_ssdp_update(hass):
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=MOCK_DISCOVER,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_SSDP},
+ data=MOCK_DISCOVER,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
@@ -119,7 +127,9 @@ async def test_user(hass, aioclient_mock):
"""Test a manual user configuration flow."""
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=None,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_USER},
+ data=None,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -149,7 +159,9 @@ async def test_invalid_ssdp(hass, aioclient_mock):
aioclient_mock.get(MOCK_UPNP_LOCATION, text="")
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_USER},
+ data=user_input,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == f"Arcam FMJ ({MOCK_HOST})"
@@ -166,7 +178,9 @@ async def test_user_wrong(hass, aioclient_mock):
aioclient_mock.get(MOCK_UPNP_LOCATION, status=404)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_USER},
+ data=user_input,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == f"Arcam FMJ ({MOCK_HOST})"
diff --git a/tests/components/arcam_fmj/test_device_trigger.py b/tests/components/arcam_fmj/test_device_trigger.py
index 7d0aca8628e..2a119fd2017 100644
--- a/tests/components/arcam_fmj/test_device_trigger.py
+++ b/tests/components/arcam_fmj/test_device_trigger.py
@@ -38,7 +38,8 @@ async def test_get_triggers(hass, device_reg, entity_reg):
config_entry = MockConfigEntry(domain=DOMAIN, data={})
config_entry.add_to_hass(hass)
device_entry = device_reg.async_get_or_create(
- config_entry_id=config_entry.entry_id, identifiers={(DOMAIN, "host", 1234)},
+ config_entry_id=config_entry.entry_id,
+ identifiers={(DOMAIN, "host", 1234)},
)
entity_reg.async_get_or_create(
"media_player", DOMAIN, "5678", device_id=device_entry.id
@@ -83,7 +84,10 @@ async def test_if_fires_on_turn_on_request(hass, calls, player_setup, state):
)
await hass.services.async_call(
- "media_player", "turn_on", {"entity_id": player_setup}, blocking=True,
+ "media_player",
+ "turn_on",
+ {"entity_id": player_setup},
+ blocking=True,
)
await hass.async_block_till_done()
diff --git a/tests/components/arcam_fmj/test_media_player.py b/tests/components/arcam_fmj/test_media_player.py
index d6c219a6d96..91117cff0a2 100644
--- a/tests/components/arcam_fmj/test_media_player.py
+++ b/tests/components/arcam_fmj/test_media_player.py
@@ -62,7 +62,7 @@ async def test_powered_on(player, state):
async def test_supported_features(player, state):
"""Test supported features."""
data = await update(player)
- assert data.attributes["supported_features"] == 69004
+ assert data.attributes["supported_features"] == 200588
async def test_turn_on(player, state):
diff --git a/tests/components/arlo/test_sensor.py b/tests/components/arlo/test_sensor.py
index e75db4a57dd..85a1d1e315a 100644
--- a/tests/components/arlo/test_sensor.py
+++ b/tests/components/arlo/test_sensor.py
@@ -8,7 +8,7 @@ from homeassistant.const import (
ATTR_ATTRIBUTION,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
- UNIT_PERCENTAGE,
+ PERCENTAGE,
)
from tests.async_mock import patch
@@ -170,7 +170,7 @@ def test_sensor_icon(temperature_sensor):
def test_unit_of_measure(default_sensor, battery_sensor):
"""Test the unit_of_measurement property."""
assert default_sensor.unit_of_measurement is None
- assert battery_sensor.unit_of_measurement == UNIT_PERCENTAGE
+ assert battery_sensor.unit_of_measurement == PERCENTAGE
def test_device_class(default_sensor, temperature_sensor, humidity_sensor):
diff --git a/tests/components/atag/test_climate.py b/tests/components/atag/test_climate.py
index 0ccb67d0eb5..48418050d2d 100644
--- a/tests/components/atag/test_climate.py
+++ b/tests/components/atag/test_climate.py
@@ -80,7 +80,8 @@ async def test_setting_climate(
async def test_incorrect_modes(
- hass: HomeAssistant, aioclient_mock: AiohttpClientMocker,
+ hass: HomeAssistant,
+ aioclient_mock: AiohttpClientMocker,
) -> None:
"""Test incorrect values are handled correctly."""
with patch(
diff --git a/tests/components/atag/test_config_flow.py b/tests/components/atag/test_config_flow.py
index dc675e24eba..63cac2a0e9c 100644
--- a/tests/components/atag/test_config_flow.py
+++ b/tests/components/atag/test_config_flow.py
@@ -38,7 +38,8 @@ async def test_adding_second_device(
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
with patch(
- "pyatag.AtagOne.id", new_callable=PropertyMock(return_value="secondary_device"),
+ "pyatag.AtagOne.id",
+ new_callable=PropertyMock(return_value="secondary_device"),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=USER_INPUT
@@ -50,7 +51,9 @@ async def test_connection_error(hass):
"""Test we show user form on Atag connection error."""
with patch("pyatag.AtagOne.authorize", side_effect=errors.AtagException()):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_USER}, data=USER_INPUT,
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data=USER_INPUT,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -62,7 +65,9 @@ async def test_unauthorized(hass):
"""Test we show correct form when Unauthorized error is raised."""
with patch("pyatag.AtagOne.authorize", side_effect=errors.Unauthorized()):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_USER}, data=USER_INPUT,
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data=USER_INPUT,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
@@ -74,13 +79,17 @@ async def test_full_flow_implementation(
) -> None:
"""Test registering an integration and finishing flow works."""
aioclient_mock.get(
- "http://127.0.0.1:10000/retrieve", json=RECEIVE_REPLY,
+ "http://127.0.0.1:10000/retrieve",
+ json=RECEIVE_REPLY,
)
aioclient_mock.post(
- "http://127.0.0.1:10000/pair", json=PAIR_REPLY,
+ "http://127.0.0.1:10000/pair",
+ json=PAIR_REPLY,
)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_USER}, data=USER_INPUT,
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data=USER_INPUT,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == UID
diff --git a/tests/components/august/test_config_flow.py b/tests/components/august/test_config_flow.py
index ed75bc3685c..2f20347acae 100644
--- a/tests/components/august/test_config_flow.py
+++ b/tests/components/august/test_config_flow.py
@@ -34,7 +34,8 @@ async def test_form(hass):
), patch(
"homeassistant.components.august.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.august.async_setup_entry", return_value=True,
+ "homeassistant.components.august.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -149,7 +150,8 @@ async def test_form_needs_validate(hass):
"homeassistant.components.august.async_setup_entry", return_value=True
) as mock_setup_entry:
result3 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {VERIFICATION_CODE_KEY: "incorrect"},
+ result["flow_id"],
+ {VERIFICATION_CODE_KEY: "incorrect"},
)
# Make sure we do not resend the code again
@@ -176,7 +178,8 @@ async def test_form_needs_validate(hass):
"homeassistant.components.august.async_setup_entry", return_value=True
) as mock_setup_entry:
result4 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {VERIFICATION_CODE_KEY: "correct"},
+ result["flow_id"],
+ {VERIFICATION_CODE_KEY: "correct"},
)
assert len(mock_send_verification_code.mock_calls) == 0
diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py
index f29403c9f21..bcc05e51c71 100644
--- a/tests/components/august/test_init.py
+++ b/tests/components/august/test_init.py
@@ -42,7 +42,9 @@ async def test_august_is_offline(hass):
"""Config entry state is ENTRY_STATE_SETUP_RETRY when august is offline."""
config_entry = MockConfigEntry(
- domain=DOMAIN, data=_mock_get_config()[DOMAIN], title="August august",
+ domain=DOMAIN,
+ data=_mock_get_config()[DOMAIN],
+ title="August august",
)
config_entry.add_to_hass(hass)
@@ -146,7 +148,8 @@ async def test_set_up_from_yaml(hass):
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
- "homeassistant.components.august.async_setup_august", return_value=True,
+ "homeassistant.components.august.async_setup_august",
+ return_value=True,
) as mock_setup_august, patch(
"homeassistant.components.august.config_flow.AugustGateway.async_authenticate",
return_value=True,
diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py
index e455960fef3..f36a5e3f180 100644
--- a/tests/components/august/test_lock.py
+++ b/tests/components/august/test_lock.py
@@ -105,7 +105,8 @@ async def test_one_lock_operation(hass):
async def test_one_lock_unknown_state(hass):
"""Test creation of a lock with doorsense and bridge."""
lock_one = await _mock_lock_from_fixture(
- hass, "get_lock.online.unknown_state.json",
+ hass,
+ "get_lock.online.unknown_state.json",
)
await _create_august_with_devices(hass, [lock_one])
diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py
index f7e46297532..7e69b59da07 100644
--- a/tests/components/august/test_sensor.py
+++ b/tests/components/august/test_sensor.py
@@ -1,6 +1,6 @@
"""The sensor tests for the august platform."""
-from homeassistant.const import STATE_UNAVAILABLE, UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE, STATE_UNAVAILABLE
from tests.components.august.mocks import (
_create_august_with_devices,
@@ -21,8 +21,7 @@ async def test_create_doorbell(hass):
)
assert sensor_k98gidt45gul_name_battery.state == "96"
assert (
- sensor_k98gidt45gul_name_battery.attributes["unit_of_measurement"]
- == UNIT_PERCENTAGE
+ sensor_k98gidt45gul_name_battery.attributes["unit_of_measurement"] == PERCENTAGE
)
@@ -34,9 +33,7 @@ async def test_create_doorbell_offline(hass):
sensor_tmt100_name_battery = hass.states.get("sensor.tmt100_name_battery")
assert sensor_tmt100_name_battery.state == "81"
- assert (
- sensor_tmt100_name_battery.attributes["unit_of_measurement"] == UNIT_PERCENTAGE
- )
+ assert sensor_tmt100_name_battery.attributes["unit_of_measurement"] == PERCENTAGE
entry = entity_registry.async_get("sensor.tmt100_name_battery")
assert entry
@@ -68,7 +65,7 @@ async def test_create_lock_with_linked_keypad(hass):
sensor_a6697750d607098bae8d6baa11ef8063_name_battery.attributes[
"unit_of_measurement"
]
- == UNIT_PERCENTAGE
+ == PERCENTAGE
)
entry = entity_registry.async_get(
"sensor.a6697750d607098bae8d6baa11ef8063_name_battery"
@@ -78,7 +75,7 @@ async def test_create_lock_with_linked_keypad(hass):
state = hass.states.get("sensor.front_door_lock_keypad_battery")
assert state.state == "60"
- assert state.attributes["unit_of_measurement"] == UNIT_PERCENTAGE
+ assert state.attributes["unit_of_measurement"] == PERCENTAGE
entry = entity_registry.async_get("sensor.front_door_lock_keypad_battery")
assert entry
assert entry.unique_id == "5bc65c24e6ef2a263e1450a8_linked_keypad_battery"
@@ -98,7 +95,7 @@ async def test_create_lock_with_low_battery_linked_keypad(hass):
sensor_a6697750d607098bae8d6baa11ef8063_name_battery.attributes[
"unit_of_measurement"
]
- == UNIT_PERCENTAGE
+ == PERCENTAGE
)
entry = entity_registry.async_get(
"sensor.a6697750d607098bae8d6baa11ef8063_name_battery"
@@ -108,7 +105,7 @@ async def test_create_lock_with_low_battery_linked_keypad(hass):
state = hass.states.get("sensor.front_door_lock_keypad_battery")
assert state.state == "10"
- assert state.attributes["unit_of_measurement"] == UNIT_PERCENTAGE
+ assert state.attributes["unit_of_measurement"] == PERCENTAGE
entry = entity_registry.async_get("sensor.front_door_lock_keypad_battery")
assert entry
assert entry.unique_id == "5bc65c24e6ef2a263e1450a8_linked_keypad_battery"
diff --git a/tests/components/auth/test_indieauth.py b/tests/components/auth/test_indieauth.py
index 8a02502e16c..e8aabce4678 100644
--- a/tests/components/auth/test_indieauth.py
+++ b/tests/components/auth/test_indieauth.py
@@ -166,7 +166,8 @@ async def test_find_link_tag_max_size(hass, mock_session):
@pytest.mark.parametrize(
- "client_id", ["https://home-assistant.io/android", "https://home-assistant.io/iOS"],
+ "client_id",
+ ["https://home-assistant.io/android", "https://home-assistant.io/iOS"],
)
async def test_verify_redirect_uri_android_ios(client_id):
"""Test that we verify redirect uri correctly for Android/iOS."""
diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py
index a832b26d752..9c38574945d 100644
--- a/tests/components/automation/test_init.py
+++ b/tests/components/automation/test_init.py
@@ -6,6 +6,7 @@ import pytest
from homeassistant.components import logbook
import homeassistant.components.automation as automation
from homeassistant.components.automation import (
+ ATTR_SOURCE,
DOMAIN,
EVENT_AUTOMATION_RELOADED,
EVENT_AUTOMATION_TRIGGERED,
@@ -72,7 +73,7 @@ async def test_service_specify_data(hass, calls):
time = dt_util.utcnow()
- with patch("homeassistant.components.automation.utcnow", return_value=time):
+ with patch("homeassistant.helpers.script.utcnow", return_value=time):
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
@@ -231,6 +232,31 @@ async def test_two_conditions_with_and(hass, calls):
assert len(calls) == 1
+async def test_shorthand_conditions_template(hass, calls):
+ """Test shorthand nation form in conditions."""
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: {
+ "trigger": [{"platform": "event", "event_type": "test_event"}],
+ "condition": "{{ is_state('test.entity', 'hello') }}",
+ "action": {"service": "test.automation"},
+ }
+ },
+ )
+
+ hass.states.async_set("test.entity", "hello")
+ hass.bus.async_fire("test_event")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ hass.states.async_set("test.entity", "goodbye")
+ hass.bus.async_fire("test_event")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+
async def test_automation_list_setting(hass, calls):
"""Event is not a valid condition."""
assert await async_setup_component(
@@ -324,6 +350,7 @@ async def test_shared_context(hass, calls):
# Ensure event data has all attributes set
assert args[0].data.get(ATTR_NAME) is not None
assert args[0].data.get(ATTR_ENTITY_ID) is not None
+ assert args[0].data.get(ATTR_SOURCE) is not None
# Ensure context set correctly for event fired by 'hello' automation
args, _ = first_automation_listener.call_args
@@ -341,6 +368,7 @@ async def test_shared_context(hass, calls):
# Ensure event data has all attributes set
assert args[0].data.get(ATTR_NAME) is not None
assert args[0].data.get(ATTR_ENTITY_ID) is not None
+ assert args[0].data.get(ATTR_SOURCE) is not None
# Ensure the service call from the second automation
# shares the same context
@@ -584,7 +612,7 @@ async def test_automation_stops(hass, calls, service):
],
}
}
- assert await async_setup_component(hass, automation.DOMAIN, config,)
+ assert await async_setup_component(hass, automation.DOMAIN, config)
running = asyncio.Event()
@@ -861,6 +889,22 @@ async def test_automation_not_trigger_on_bootstrap(hass):
assert ["hello.world"] == calls[0].data.get(ATTR_ENTITY_ID)
+async def test_automation_bad_trigger(hass, caplog):
+ """Test bad trigger configuration."""
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: {
+ "alias": "hello",
+ "trigger": {"platform": "automation"},
+ "action": [],
+ }
+ },
+ )
+ assert "Integration 'automation' does not provide trigger support." in caplog.text
+
+
async def test_automation_with_error_in_script(hass, caplog):
"""Test automation with an error in script."""
assert await async_setup_component(
@@ -1069,10 +1113,15 @@ async def test_logbook_humanify_automation_triggered_event(hass):
),
MockLazyEventPartialState(
EVENT_AUTOMATION_TRIGGERED,
- {ATTR_ENTITY_ID: "automation.bye", ATTR_NAME: "Bye Automation"},
+ {
+ ATTR_ENTITY_ID: "automation.bye",
+ ATTR_NAME: "Bye Automation",
+ ATTR_SOURCE: "source of trigger",
+ },
),
],
entity_attr_cache,
+ {},
)
)
@@ -1083,5 +1132,78 @@ async def test_logbook_humanify_automation_triggered_event(hass):
assert event2["name"] == "Bye Automation"
assert event2["domain"] == "automation"
- assert event2["message"] == "has been triggered"
+ assert event2["message"] == "has been triggered by source of trigger"
assert event2["entity_id"] == "automation.bye"
+
+
+async def test_automation_variables(hass, caplog):
+ """Test automation variables."""
+ calls = async_mock_service(hass, "test", "automation")
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "variables": {
+ "test_var": "defined_in_config",
+ "event_type": "{{ trigger.event.event_type }}",
+ },
+ "trigger": {"platform": "event", "event_type": "test_event"},
+ "action": {
+ "service": "test.automation",
+ "data": {
+ "value": "{{ test_var }}",
+ "event_type": "{{ event_type }}",
+ },
+ },
+ },
+ {
+ "variables": {
+ "test_var": "defined_in_config",
+ },
+ "trigger": {"platform": "event", "event_type": "test_event_2"},
+ "condition": {
+ "condition": "template",
+ "value_template": "{{ trigger.event.data.pass_condition }}",
+ },
+ "action": {
+ "service": "test.automation",
+ },
+ },
+ {
+ "variables": {
+ "test_var": "{{ trigger.event.data.break + 1 }}",
+ },
+ "trigger": {"platform": "event", "event_type": "test_event_3"},
+ "action": {
+ "service": "test.automation",
+ },
+ },
+ ]
+ },
+ )
+ hass.bus.async_fire("test_event")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+ assert calls[0].data["value"] == "defined_in_config"
+ assert calls[0].data["event_type"] == "test_event"
+
+ hass.bus.async_fire("test_event_2")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ hass.bus.async_fire("test_event_2", {"pass_condition": True})
+ await hass.async_block_till_done()
+ assert len(calls) == 2
+
+ assert "Error rendering variables" not in caplog.text
+ hass.bus.async_fire("test_event_3")
+ await hass.async_block_till_done()
+ assert len(calls) == 2
+ assert "Error rendering variables" in caplog.text
+
+ hass.bus.async_fire("test_event_3", {"break": 0})
+ await hass.async_block_till_done()
+ assert len(calls) == 3
diff --git a/tests/components/avri/test_config_flow.py b/tests/components/avri/test_config_flow.py
index c05935c62e4..2dba3c3c11d 100644
--- a/tests/components/avri/test_config_flow.py
+++ b/tests/components/avri/test_config_flow.py
@@ -15,7 +15,8 @@ async def test_form(hass):
assert result["errors"] == {}
with patch(
- "homeassistant.components.avri.async_setup_entry", return_value=True,
+ "homeassistant.components.avri.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
diff --git a/tests/components/awair/test_config_flow.py b/tests/components/awair/test_config_flow.py
index c36780a1803..63253993117 100644
--- a/tests/components/awair/test_config_flow.py
+++ b/tests/components/awair/test_config_flow.py
@@ -52,7 +52,8 @@ async def test_duplicate_error(hass):
with patch(
"python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE]
), patch(
- "homeassistant.components.awair.sensor.async_setup_entry", return_value=True,
+ "homeassistant.components.awair.sensor.async_setup_entry",
+ return_value=True,
):
MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG).add_to_hass(
hass
@@ -86,7 +87,8 @@ async def test_import(hass):
with patch(
"python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE]
), patch(
- "homeassistant.components.awair.sensor.async_setup_entry", return_value=True,
+ "homeassistant.components.awair.sensor.async_setup_entry",
+ return_value=True,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -120,7 +122,8 @@ async def test_import_aborts_if_configured(hass):
with patch(
"python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE]
), patch(
- "homeassistant.components.awair.sensor.async_setup_entry", return_value=True,
+ "homeassistant.components.awair.sensor.async_setup_entry",
+ return_value=True,
):
MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG).add_to_hass(
hass
@@ -141,7 +144,8 @@ async def test_reauth(hass):
with patch(
"python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE]
), patch(
- "homeassistant.components.awair.sensor.async_setup_entry", return_value=True,
+ "homeassistant.components.awair.sensor.async_setup_entry",
+ return_value=True,
):
mock_config = MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG)
mock_config.add_to_hass(hass)
@@ -150,7 +154,9 @@ async def test_reauth(hass):
)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": "reauth", "unique_id": UNIQUE_ID}, data=CONFIG,
+ DOMAIN,
+ context={"source": "reauth", "unique_id": UNIQUE_ID},
+ data=CONFIG,
)
assert result["type"] == "abort"
@@ -158,14 +164,18 @@ async def test_reauth(hass):
with patch("python_awair.AwairClient.query", side_effect=AuthError()):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": "reauth", "unique_id": UNIQUE_ID}, data=CONFIG,
+ DOMAIN,
+ context={"source": "reauth", "unique_id": UNIQUE_ID},
+ data=CONFIG,
)
assert result["errors"] == {CONF_ACCESS_TOKEN: "auth"}
with patch("python_awair.AwairClient.query", side_effect=AwairError()):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": "reauth", "unique_id": UNIQUE_ID}, data=CONFIG,
+ DOMAIN,
+ context={"source": "reauth", "unique_id": UNIQUE_ID},
+ data=CONFIG,
)
assert result["type"] == "abort"
@@ -178,7 +188,8 @@ async def test_create_entry(hass):
with patch(
"python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE]
), patch(
- "homeassistant.components.awair.sensor.async_setup_entry", return_value=True,
+ "homeassistant.components.awair.sensor.async_setup_entry",
+ return_value=True,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py
index 00c469e3747..4d48959632d 100644
--- a/tests/components/awair/test_sensor.py
+++ b/tests/components/awair/test_sensor.py
@@ -20,9 +20,9 @@ from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
+ PERCENTAGE,
STATE_UNAVAILABLE,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
from .const import (
@@ -98,7 +98,7 @@ async def test_awair_gen1_sensors(hass):
"sensor.living_room_humidity",
f"{AWAIR_UUID}_{SENSOR_TYPES[API_HUMID][ATTR_UNIQUE_ID]}",
"41.59",
- {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE, "awair_index": 0.0},
+ {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, "awair_index": 0.0},
)
assert_expected_properties(
diff --git a/tests/components/axis/test_binary_sensor.py b/tests/components/axis/test_binary_sensor.py
index 1ffe37ef857..e2d820a959e 100644
--- a/tests/components/axis/test_binary_sensor.py
+++ b/tests/components/axis/test_binary_sensor.py
@@ -63,7 +63,7 @@ async def test_binary_sensors(hass):
assert pir.name == f"{NAME} PIR 0"
assert pir.attributes["device_class"] == DEVICE_CLASS_MOTION
- vmd4 = hass.states.get(f"binary_sensor.{NAME}_vmd4_camera1profile1")
+ vmd4 = hass.states.get(f"binary_sensor.{NAME}_vmd4_profile_1")
assert vmd4.state == "on"
- assert vmd4.name == f"{NAME} VMD4 Camera1Profile1"
+ assert vmd4.name == f"{NAME} VMD4 Profile 1"
assert vmd4.attributes["device_class"] == DEVICE_CLASS_MOTION
diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py
index aa7d9db9027..e5a32c4489a 100644
--- a/tests/components/axis/test_config_flow.py
+++ b/tests/components/axis/test_config_flow.py
@@ -67,7 +67,12 @@ async def test_manual_configuration_update_configuration(hass):
assert result["type"] == "form"
assert result["step_id"] == "user"
- with patch("axis.vapix.session_request", new=vapix_session_request):
+ with patch(
+ "homeassistant.components.axis.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry, patch(
+ "axis.vapix.session_request", new=vapix_session_request
+ ):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
@@ -77,10 +82,12 @@ async def test_manual_configuration_update_configuration(hass):
CONF_PORT: 80,
},
)
+ await hass.async_block_till_done()
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
assert device.host == "2.3.4.5"
+ assert len(mock_setup_entry.mock_calls) == 1
async def test_flow_fails_already_configured(hass):
@@ -164,11 +171,13 @@ async def test_flow_fails_device_unavailable(hass):
async def test_flow_create_entry_multiple_existing_entries_of_same_model(hass):
"""Test that create entry can generate a name with other entries."""
entry = MockConfigEntry(
- domain=AXIS_DOMAIN, data={CONF_NAME: "M1065-LW 0", CONF_MODEL: "M1065-LW"},
+ domain=AXIS_DOMAIN,
+ data={CONF_NAME: "M1065-LW 0", CONF_MODEL: "M1065-LW"},
)
entry.add_to_hass(hass)
entry2 = MockConfigEntry(
- domain=AXIS_DOMAIN, data={CONF_NAME: "M1065-LW 1", CONF_MODEL: "M1065-LW"},
+ domain=AXIS_DOMAIN,
+ data={CONF_NAME: "M1065-LW 1", CONF_MODEL: "M1065-LW"},
)
entry2.add_to_hass(hass)
@@ -282,16 +291,23 @@ async def test_zeroconf_flow_updated_configuration(hass):
CONF_NAME: NAME,
}
- result = await hass.config_entries.flow.async_init(
- AXIS_DOMAIN,
- data={
- CONF_HOST: "2.3.4.5",
- CONF_PORT: 8080,
- "hostname": "name",
- "properties": {"macaddress": MAC},
- },
- context={"source": "zeroconf"},
- )
+ with patch(
+ "homeassistant.components.axis.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry, patch(
+ "axis.vapix.session_request", new=vapix_session_request
+ ):
+ result = await hass.config_entries.flow.async_init(
+ AXIS_DOMAIN,
+ data={
+ CONF_HOST: "2.3.4.5",
+ CONF_PORT: 8080,
+ "hostname": "name",
+ "properties": {"macaddress": MAC},
+ },
+ context={"source": "zeroconf"},
+ )
+ await hass.async_block_till_done()
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
@@ -304,6 +320,7 @@ async def test_zeroconf_flow_updated_configuration(hass):
CONF_MODEL: MODEL,
CONF_NAME: NAME,
}
+ assert len(mock_setup_entry.mock_calls) == 1
async def test_zeroconf_flow_ignore_non_axis_device(hass):
@@ -346,7 +363,8 @@ async def test_option_flow(hass):
}
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={CONF_STREAM_PROFILE: "profile_1"},
+ result["flow_id"],
+ user_input={CONF_STREAM_PROFILE: "profile_1"},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py
index 4350764c486..dc4cb607934 100644
--- a/tests/components/axis/test_device.py
+++ b/tests/components/axis/test_device.py
@@ -5,6 +5,8 @@ from unittest import mock
import axis as axislib
from axis.api_discovery import URL as API_DISCOVERY_URL
+from axis.applications import URL_LIST as APPLICATIONS_URL
+from axis.applications.vmd4 import URL as VMD4_URL
from axis.basic_device_info import URL as BASIC_DEVICE_INFO_URL
from axis.event_stream import OPERATION_INITIALIZED
from axis.light_control import URL as LIGHT_CONTROL_URL
@@ -79,6 +81,10 @@ API_DISCOVERY_PORT_MANAGEMENT = {
"name": "IO Port Management",
}
+APPLICATIONS_LIST_RESPONSE = """
+
+"""
+
BASIC_DEVICE_INFO_RESPONSE = {
"apiVersion": "1.1",
"data": {
@@ -138,6 +144,18 @@ PORT_MANAGEMENT_RESPONSE = {
},
}
+VMD4_RESPONSE = {
+ "apiVersion": "1.4",
+ "method": "getConfiguration",
+ "context": "Axis library",
+ "data": {
+ "cameras": [{"id": 1, "rotation": 0, "active": True}],
+ "profiles": [
+ {"filters": [], "camera": 1, "triggers": [], "name": "Profile 1", "uid": 1}
+ ],
+ },
+}
+
BRAND_RESPONSE = """root.Brand.Brand=AXIS
root.Brand.ProdFullName=AXIS M1065-LW Network Camera
root.Brand.ProdNbr=M1065-LW
@@ -158,6 +176,7 @@ root.Output.NbrOfOutputs=0
PROPERTIES_RESPONSE = """root.Properties.API.HTTP.Version=3
root.Properties.API.Metadata.Metadata=yes
root.Properties.API.Metadata.Version=1.0
+root.Properties.EmbeddedDevelopment.Version=2.16
root.Properties.Firmware.BuildDate=Feb 15 2019 09:42
root.Properties.Firmware.BuildNumber=26
root.Properties.Firmware.Version=9.10.1
@@ -182,6 +201,8 @@ def vapix_session_request(session, url, **kwargs):
"""Return data based on url."""
if API_DISCOVERY_URL in url:
return json.dumps(API_DISCOVERY_RESPONSE)
+ if APPLICATIONS_URL in url:
+ return APPLICATIONS_LIST_RESPONSE
if BASIC_DEVICE_INFO_URL in url:
return json.dumps(BASIC_DEVICE_INFO_RESPONSE)
if LIGHT_CONTROL_URL in url:
@@ -190,6 +211,8 @@ def vapix_session_request(session, url, **kwargs):
return json.dumps(MQTT_CLIENT_RESPONSE)
if PORT_MANAGEMENT_URL in url:
return json.dumps(PORT_MANAGEMENT_RESPONSE)
+ if VMD4_URL in url:
+ return json.dumps(VMD4_RESPONSE)
if BRAND_URL in url:
return BRAND_RESPONSE
if IOPORT_URL in url or INPUT_URL in url or OUTPUT_URL in url:
@@ -213,7 +236,8 @@ async def setup_axis_integration(hass, config=ENTRY_CONFIG, options=ENTRY_OPTION
config_entry.add_to_hass(hass)
with patch("axis.vapix.session_request", new=vapix_session_request), patch(
- "axis.rtsp.RTSPClient.start", return_value=True,
+ "axis.rtsp.RTSPClient.start",
+ return_value=True,
):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
@@ -290,19 +314,24 @@ async def test_update_address(hass):
device = await setup_axis_integration(hass)
assert device.api.config.host == "1.2.3.4"
- await hass.config_entries.flow.async_init(
- AXIS_DOMAIN,
- data={
- "host": "2.3.4.5",
- "port": 80,
- "hostname": "name",
- "properties": {"macaddress": MAC},
- },
- context={"source": "zeroconf"},
- )
- await hass.async_block_till_done()
+ with patch("axis.vapix.session_request", new=vapix_session_request), patch(
+ "homeassistant.components.axis.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ await hass.config_entries.flow.async_init(
+ AXIS_DOMAIN,
+ data={
+ "host": "2.3.4.5",
+ "port": 80,
+ "hostname": "name",
+ "properties": {"macaddress": MAC},
+ },
+ context={"source": "zeroconf"},
+ )
+ await hass.async_block_till_done()
assert device.api.config.host == "2.3.4.5"
+ assert len(mock_setup_entry.mock_calls) == 1
async def test_device_unavailable(hass):
diff --git a/tests/components/azure_devops/test_config_flow.py b/tests/components/azure_devops/test_config_flow.py
index b89c9cb69aa..fce4c6f5df3 100644
--- a/tests/components/azure_devops/test_config_flow.py
+++ b/tests/components/azure_devops/test_config_flow.py
@@ -44,7 +44,8 @@ async def test_authorization_error(hass: HomeAssistant) -> None:
assert result["step_id"] == "user"
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], FIXTURE_USER_INPUT,
+ result["flow_id"],
+ FIXTURE_USER_INPUT,
)
await hass.async_block_till_done()
@@ -67,7 +68,8 @@ async def test_reauth_authorization_error(hass: HomeAssistant) -> None:
assert result["step_id"] == "reauth"
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], FIXTURE_REAUTH_INPUT,
+ result["flow_id"],
+ FIXTURE_REAUTH_INPUT,
)
await hass.async_block_till_done()
@@ -90,7 +92,8 @@ async def test_connection_error(hass: HomeAssistant) -> None:
assert result["step_id"] == "user"
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], FIXTURE_USER_INPUT,
+ result["flow_id"],
+ FIXTURE_USER_INPUT,
)
await hass.async_block_till_done()
@@ -113,7 +116,8 @@ async def test_reauth_connection_error(hass: HomeAssistant) -> None:
assert result["step_id"] == "reauth"
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], FIXTURE_REAUTH_INPUT,
+ result["flow_id"],
+ FIXTURE_REAUTH_INPUT,
)
await hass.async_block_till_done()
@@ -141,7 +145,8 @@ async def test_project_error(hass: HomeAssistant) -> None:
assert result["step_id"] == "user"
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], FIXTURE_USER_INPUT,
+ result["flow_id"],
+ FIXTURE_USER_INPUT,
)
await hass.async_block_till_done()
@@ -169,7 +174,8 @@ async def test_reauth_project_error(hass: HomeAssistant) -> None:
assert result["step_id"] == "reauth"
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], FIXTURE_REAUTH_INPUT,
+ result["flow_id"],
+ FIXTURE_REAUTH_INPUT,
)
await hass.async_block_till_done()
@@ -209,7 +215,8 @@ async def test_reauth_flow(hass: HomeAssistant) -> None:
),
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], FIXTURE_REAUTH_INPUT,
+ result["flow_id"],
+ FIXTURE_REAUTH_INPUT,
)
await hass.async_block_till_done()
@@ -222,7 +229,8 @@ async def test_full_flow_implementation(hass: HomeAssistant) -> None:
with patch(
"homeassistant.components.azure_devops.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.azure_devops.async_setup_entry", return_value=True,
+ "homeassistant.components.azure_devops.async_setup_entry",
+ return_value=True,
) as mock_setup_entry, patch(
"homeassistant.components.azure_devops.config_flow.DevOpsClient.authorized",
return_value=True,
@@ -242,7 +250,8 @@ async def test_full_flow_implementation(hass: HomeAssistant) -> None:
assert result["step_id"] == "user"
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], FIXTURE_USER_INPUT,
+ result["flow_id"],
+ FIXTURE_USER_INPUT,
)
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py
index 7bbb9eeda27..5755a7e24e9 100644
--- a/tests/components/bayesian/test_binary_sensor.py
+++ b/tests/components/bayesian/test_binary_sensor.py
@@ -1,11 +1,25 @@
"""The test for the bayesian sensor platform."""
import json
+from os import path
import unittest
-from homeassistant.components.bayesian import binary_sensor as bayesian
-from homeassistant.const import STATE_UNKNOWN
-from homeassistant.setup import setup_component
+from homeassistant import config as hass_config
+from homeassistant.components.bayesian import DOMAIN, binary_sensor as bayesian
+from homeassistant.components.homeassistant import (
+ DOMAIN as HA_DOMAIN,
+ SERVICE_UPDATE_ENTITY,
+)
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ SERVICE_RELOAD,
+ STATE_OFF,
+ STATE_ON,
+ STATE_UNKNOWN,
+)
+from homeassistant.core import Context, callback
+from homeassistant.setup import async_setup_component, setup_component
+from tests.async_mock import patch
from tests.common import get_test_home_assistant
@@ -488,3 +502,270 @@ class TestBayesianBinarySensor(unittest.TestCase):
for key, attrs in state.attributes.items():
json.dumps(attrs)
+
+
+async def test_template_error(hass, caplog):
+ """Test sensor with template error."""
+ config = {
+ "binary_sensor": {
+ "name": "Test_Binary",
+ "platform": "bayesian",
+ "observations": [
+ {
+ "platform": "template",
+ "value_template": "{{ xyz + 1 }}",
+ "prob_given_true": 0.9,
+ },
+ ],
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ }
+ }
+
+ await async_setup_component(hass, "binary_sensor", config)
+ await hass.async_block_till_done()
+
+ assert hass.states.get("binary_sensor.test_binary").state == "off"
+
+ assert "TemplateError" in caplog.text
+ assert "xyz" in caplog.text
+
+
+async def test_update_request_with_template(hass):
+ """Test sensor on template platform observations that gets an update request."""
+ config = {
+ "binary_sensor": {
+ "name": "Test_Binary",
+ "platform": "bayesian",
+ "observations": [
+ {
+ "platform": "template",
+ "value_template": "{{states('sensor.test_monitored') == 'off'}}",
+ "prob_given_true": 0.8,
+ "prob_given_false": 0.4,
+ }
+ ],
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ }
+ }
+
+ await async_setup_component(hass, "binary_sensor", config)
+ await async_setup_component(hass, HA_DOMAIN, {})
+
+ await hass.async_block_till_done()
+
+ assert hass.states.get("binary_sensor.test_binary").state == "off"
+
+ await hass.services.async_call(
+ HA_DOMAIN,
+ SERVICE_UPDATE_ENTITY,
+ {ATTR_ENTITY_ID: "binary_sensor.test_binary"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ assert hass.states.get("binary_sensor.test_binary").state == "off"
+
+
+async def test_update_request_without_template(hass):
+ """Test sensor on template platform observations that gets an update request."""
+ config = {
+ "binary_sensor": {
+ "name": "Test_Binary",
+ "platform": "bayesian",
+ "observations": [
+ {
+ "platform": "state",
+ "entity_id": "sensor.test_monitored",
+ "to_state": "off",
+ "prob_given_true": 0.9,
+ "prob_given_false": 0.4,
+ },
+ ],
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ }
+ }
+
+ await async_setup_component(hass, "binary_sensor", config)
+ await async_setup_component(hass, HA_DOMAIN, {})
+
+ await hass.async_block_till_done()
+
+ hass.states.async_set("sensor.test_monitored", "on")
+ await hass.async_block_till_done()
+
+ assert hass.states.get("binary_sensor.test_binary").state == "off"
+
+ await hass.services.async_call(
+ HA_DOMAIN,
+ SERVICE_UPDATE_ENTITY,
+ {ATTR_ENTITY_ID: "binary_sensor.test_binary"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ assert hass.states.get("binary_sensor.test_binary").state == "off"
+
+
+async def test_monitored_sensor_goes_away(hass):
+ """Test sensor on template platform observations that goes away."""
+ config = {
+ "binary_sensor": {
+ "name": "Test_Binary",
+ "platform": "bayesian",
+ "observations": [
+ {
+ "platform": "state",
+ "entity_id": "sensor.test_monitored",
+ "to_state": "on",
+ "prob_given_true": 0.9,
+ "prob_given_false": 0.4,
+ },
+ ],
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ }
+ }
+
+ await async_setup_component(hass, "binary_sensor", config)
+ await async_setup_component(hass, HA_DOMAIN, {})
+
+ await hass.async_block_till_done()
+
+ hass.states.async_set("sensor.test_monitored", "on")
+ await hass.async_block_till_done()
+
+ assert hass.states.get("binary_sensor.test_binary").state == "on"
+
+ hass.states.async_remove("sensor.test_monitored")
+
+ await hass.async_block_till_done()
+ assert hass.states.get("binary_sensor.test_binary").state == "on"
+
+
+async def test_reload(hass):
+ """Verify we can reload bayesian sensors."""
+
+ config = {
+ "binary_sensor": {
+ "name": "test",
+ "platform": "bayesian",
+ "observations": [
+ {
+ "platform": "state",
+ "entity_id": "sensor.test_monitored",
+ "to_state": "on",
+ "prob_given_true": 0.9,
+ "prob_given_false": 0.4,
+ },
+ ],
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ }
+ }
+
+ await async_setup_component(hass, "binary_sensor", config)
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+ assert hass.states.get("binary_sensor.test")
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "bayesian/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+ assert hass.states.get("binary_sensor.test") is None
+ assert hass.states.get("binary_sensor.test2")
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
+
+
+async def test_template_triggers(hass):
+ """Test sensor with template triggers."""
+ hass.states.async_set("input_boolean.test", STATE_OFF)
+ config = {
+ "binary_sensor": {
+ "name": "Test_Binary",
+ "platform": "bayesian",
+ "observations": [
+ {
+ "platform": "template",
+ "value_template": "{{ states.input_boolean.test.state }}",
+ "prob_given_true": 1999.9,
+ },
+ ],
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ }
+ }
+
+ await async_setup_component(hass, "binary_sensor", config)
+ await hass.async_block_till_done()
+
+ assert hass.states.get("binary_sensor.test_binary").state == STATE_OFF
+
+ events = []
+ hass.helpers.event.async_track_state_change_event(
+ "binary_sensor.test_binary", callback(lambda event: events.append(event))
+ )
+
+ context = Context()
+ hass.states.async_set("input_boolean.test", STATE_ON, context=context)
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ assert events[0].context == context
+
+
+async def test_state_triggers(hass):
+ """Test sensor with state triggers."""
+ hass.states.async_set("sensor.test_monitored", STATE_OFF)
+
+ config = {
+ "binary_sensor": {
+ "name": "Test_Binary",
+ "platform": "bayesian",
+ "observations": [
+ {
+ "platform": "state",
+ "entity_id": "sensor.test_monitored",
+ "to_state": "off",
+ "prob_given_true": 999.9,
+ "prob_given_false": 999.4,
+ },
+ ],
+ "prior": 0.2,
+ "probability_threshold": 0.32,
+ }
+ }
+ await async_setup_component(hass, "binary_sensor", config)
+ await hass.async_block_till_done()
+
+ assert hass.states.get("binary_sensor.test_binary").state == STATE_OFF
+
+ events = []
+ hass.helpers.event.async_track_state_change_event(
+ "binary_sensor.test_binary", callback(lambda event: events.append(event))
+ )
+
+ context = Context()
+ hass.states.async_set("sensor.test_monitored", STATE_ON, context=context)
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ assert events[0].context == context
diff --git a/tests/components/binary_sensor/test_init.py b/tests/components/binary_sensor/test_init.py
index a40df84b899..99af954cd4d 100644
--- a/tests/components/binary_sensor/test_init.py
+++ b/tests/components/binary_sensor/test_init.py
@@ -10,11 +10,13 @@ def test_state():
sensor = binary_sensor.BinarySensorEntity()
assert STATE_OFF == sensor.state
with mock.patch(
- "homeassistant.components.binary_sensor.BinarySensorEntity.is_on", new=False,
+ "homeassistant.components.binary_sensor.BinarySensorEntity.is_on",
+ new=False,
):
assert STATE_OFF == binary_sensor.BinarySensorEntity().state
with mock.patch(
- "homeassistant.components.binary_sensor.BinarySensorEntity.is_on", new=True,
+ "homeassistant.components.binary_sensor.BinarySensorEntity.is_on",
+ new=True,
):
assert STATE_ON == binary_sensor.BinarySensorEntity().state
diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py
index e7c20b10b2c..cd060445bf7 100644
--- a/tests/components/blebox/test_cover.py
+++ b/tests/components/blebox/test_cover.py
@@ -210,7 +210,10 @@ async def test_open(feature, hass, config):
feature_mock.async_update = AsyncMock()
await hass.services.async_call(
- "cover", SERVICE_OPEN_COVER, {"entity_id": entity_id}, blocking=True,
+ "cover",
+ SERVICE_OPEN_COVER,
+ {"entity_id": entity_id},
+ blocking=True,
)
assert hass.states.get(entity_id).state == STATE_OPENING
diff --git a/tests/components/blebox/test_light.py b/tests/components/blebox/test_light.py
index fb0d1302e33..48af534545c 100644
--- a/tests/components/blebox/test_light.py
+++ b/tests/components/blebox/test_light.py
@@ -116,7 +116,10 @@ async def test_dimmer_on(dimmer, hass, config):
feature_mock.async_on = AsyncMock(side_effect=turn_on)
await hass.services.async_call(
- "light", SERVICE_TURN_ON, {"entity_id": entity_id}, blocking=True,
+ "light",
+ SERVICE_TURN_ON,
+ {"entity_id": entity_id},
+ blocking=True,
)
state = hass.states.get(entity_id)
@@ -186,7 +189,10 @@ async def test_dimmer_off(dimmer, hass, config):
feature_mock.async_off = AsyncMock(side_effect=turn_off)
await hass.services.async_call(
- "light", SERVICE_TURN_OFF, {"entity_id": entity_id}, blocking=True,
+ "light",
+ SERVICE_TURN_OFF,
+ {"entity_id": entity_id},
+ blocking=True,
)
state = hass.states.get(entity_id)
@@ -282,7 +288,10 @@ async def test_wlightbox_s_on(wlightbox_s, hass, config):
feature_mock.async_on = AsyncMock(side_effect=turn_on)
await hass.services.async_call(
- "light", SERVICE_TURN_ON, {"entity_id": entity_id}, blocking=True,
+ "light",
+ SERVICE_TURN_ON,
+ {"entity_id": entity_id},
+ blocking=True,
)
state = hass.states.get(entity_id)
@@ -523,7 +532,10 @@ async def test_wlightbox_on_to_last_color(wlightbox, hass, config):
feature_mock.sensible_on_value = "f1e2d3e4"
await hass.services.async_call(
- "light", SERVICE_TURN_ON, {"entity_id": entity_id}, blocking=True,
+ "light",
+ SERVICE_TURN_ON,
+ {"entity_id": entity_id},
+ blocking=True,
)
state = hass.states.get(entity_id)
@@ -555,7 +567,10 @@ async def test_wlightbox_off(wlightbox, hass, config):
feature_mock.async_off = AsyncMock(side_effect=turn_off)
await hass.services.async_call(
- "light", SERVICE_TURN_OFF, {"entity_id": entity_id}, blocking=True,
+ "light",
+ SERVICE_TURN_OFF,
+ {"entity_id": entity_id},
+ blocking=True,
)
state = hass.states.get(entity_id)
@@ -589,7 +604,10 @@ async def test_turn_on_failure(feature, hass, config, caplog):
feature_mock.sensible_on_value = 123
await hass.services.async_call(
- "light", SERVICE_TURN_ON, {"entity_id": entity_id}, blocking=True,
+ "light",
+ SERVICE_TURN_ON,
+ {"entity_id": entity_id},
+ blocking=True,
)
assert (
diff --git a/tests/components/blebox/test_switch.py b/tests/components/blebox/test_switch.py
index c41273757f2..43d7d811acc 100644
--- a/tests/components/blebox/test_switch.py
+++ b/tests/components/blebox/test_switch.py
@@ -117,7 +117,10 @@ async def test_switchbox_on(switchbox, hass, config):
feature_mock.async_turn_on = AsyncMock(side_effect=turn_on)
await hass.services.async_call(
- "switch", SERVICE_TURN_ON, {"entity_id": entity_id}, blocking=True,
+ "switch",
+ SERVICE_TURN_ON,
+ {"entity_id": entity_id},
+ blocking=True,
)
state = hass.states.get(entity_id)
@@ -142,7 +145,10 @@ async def test_switchbox_off(switchbox, hass, config):
feature_mock.async_turn_off = AsyncMock(side_effect=turn_off)
await hass.services.async_call(
- "switch", SERVICE_TURN_OFF, {"entity_id": entity_id}, blocking=True,
+ "switch",
+ SERVICE_TURN_OFF,
+ {"entity_id": entity_id},
+ blocking=True,
)
state = hass.states.get(entity_id)
assert state.state == STATE_OFF
@@ -279,7 +285,10 @@ async def test_switchbox_d_turn_first_on(switchbox_d, hass, config):
feature_mocks[0].async_turn_on = AsyncMock(side_effect=turn_on0)
await hass.services.async_call(
- "switch", SERVICE_TURN_ON, {"entity_id": entity_ids[0]}, blocking=True,
+ "switch",
+ SERVICE_TURN_ON,
+ {"entity_id": entity_ids[0]},
+ blocking=True,
)
assert hass.states.get(entity_ids[0]).state == STATE_ON
@@ -305,7 +314,10 @@ async def test_switchbox_d_second_on(switchbox_d, hass, config):
feature_mocks[1].async_turn_on = AsyncMock(side_effect=turn_on1)
await hass.services.async_call(
- "switch", SERVICE_TURN_ON, {"entity_id": entity_ids[1]}, blocking=True,
+ "switch",
+ SERVICE_TURN_ON,
+ {"entity_id": entity_ids[1]},
+ blocking=True,
)
assert hass.states.get(entity_ids[0]).state == STATE_OFF
@@ -331,7 +343,10 @@ async def test_switchbox_d_first_off(switchbox_d, hass, config):
feature_mocks[0].async_turn_off = AsyncMock(side_effect=turn_off0)
await hass.services.async_call(
- "switch", SERVICE_TURN_OFF, {"entity_id": entity_ids[0]}, blocking=True,
+ "switch",
+ SERVICE_TURN_OFF,
+ {"entity_id": entity_ids[0]},
+ blocking=True,
)
assert hass.states.get(entity_ids[0]).state == STATE_OFF
@@ -357,7 +372,10 @@ async def test_switchbox_d_second_off(switchbox_d, hass, config):
feature_mocks[1].async_turn_off = AsyncMock(side_effect=turn_off1)
await hass.services.async_call(
- "switch", SERVICE_TURN_OFF, {"entity_id": entity_ids[1]}, blocking=True,
+ "switch",
+ SERVICE_TURN_OFF,
+ {"entity_id": entity_ids[1]},
+ blocking=True,
)
assert hass.states.get(entity_ids[0]).state == STATE_ON
assert hass.states.get(entity_ids[1]).state == STATE_OFF
diff --git a/tests/components/blink/test_config_flow.py b/tests/components/blink/test_config_flow.py
index 99b20d9a73c..72a4a5272ed 100644
--- a/tests/components/blink/test_config_flow.py
+++ b/tests/components/blink/test_config_flow.py
@@ -24,10 +24,12 @@ async def test_form(hass):
), patch(
"homeassistant.components.blink.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.blink.async_setup_entry", return_value=True,
+ "homeassistant.components.blink.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"username": "blink@example.com", "password": "example"},
+ result["flow_id"],
+ {"username": "blink@example.com", "password": "example"},
)
assert result2["type"] == "create_entry"
@@ -62,7 +64,8 @@ async def test_form_2fa(hass):
"homeassistant.components.blink.async_setup", return_value=True
) as mock_setup:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"username": "blink@example.com", "password": "example"},
+ result["flow_id"],
+ {"username": "blink@example.com", "password": "example"},
)
assert result2["type"] == "form"
@@ -106,7 +109,8 @@ async def test_form_2fa_connect_error(hass):
return_value=True,
), patch("homeassistant.components.blink.async_setup", return_value=True):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"username": "blink@example.com", "password": "example"},
+ result["flow_id"],
+ {"username": "blink@example.com", "password": "example"},
)
assert result2["type"] == "form"
@@ -146,7 +150,8 @@ async def test_form_2fa_invalid_key(hass):
return_value=True,
), patch("homeassistant.components.blink.async_setup", return_value=True):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"username": "blink@example.com", "password": "example"},
+ result["flow_id"],
+ {"username": "blink@example.com", "password": "example"},
)
assert result2["type"] == "form"
@@ -186,7 +191,8 @@ async def test_form_2fa_unknown_error(hass):
return_value=True,
), patch("homeassistant.components.blink.async_setup", return_value=True):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"username": "blink@example.com", "password": "example"},
+ result["flow_id"],
+ {"username": "blink@example.com", "password": "example"},
)
assert result2["type"] == "form"
@@ -239,7 +245,8 @@ async def test_form_unknown_error(hass):
)
with patch(
- "homeassistant.components.blink.config_flow.Auth.startup", side_effect=KeyError,
+ "homeassistant.components.blink.config_flow.Auth.startup",
+ side_effect=KeyError,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {"username": "blink@example.com", "password": "example"}
@@ -288,7 +295,8 @@ async def test_options_flow(hass):
assert result["step_id"] == "simple_options"
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={"scan_interval": 5},
+ result["flow_id"],
+ user_input={"scan_interval": 5},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
diff --git a/tests/components/bond/common.py b/tests/components/bond/common.py
index 31308746555..d5e4a0b9418 100644
--- a/tests/components/bond/common.py
+++ b/tests/components/bond/common.py
@@ -113,7 +113,8 @@ def patch_bond_device_ids(enabled: bool = True, return_value=None, side_effect=N
def patch_bond_device(return_value=None):
"""Patch Bond API device endpoint."""
return patch(
- "homeassistant.components.bond.Bond.device", return_value=return_value,
+ "homeassistant.components.bond.Bond.device",
+ return_value=return_value,
)
diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py
index bd499b8ce61..cd98dd8090a 100644
--- a/tests/components/bond/test_config_flow.py
+++ b/tests/components/bond/test_config_flow.py
@@ -79,6 +79,24 @@ async def test_user_form_cannot_connect(hass: core.HomeAssistant):
assert result2["errors"] == {"base": "cannot_connect"}
+async def test_user_form_old_firmware(hass: core.HomeAssistant):
+ """Test we handle unsupported old firmware."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch_bond_version(
+ return_value={"no_bond_id": "present"}
+ ), patch_bond_device_ids():
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "old_firmware"}
+
+
async def test_user_form_unexpected_client_error(hass: core.HomeAssistant):
"""Test we handle unexpected client error gracefully."""
await _help_test_form_unexpected_error(
@@ -144,7 +162,8 @@ async def test_zeroconf_form(hass: core.HomeAssistant):
return_value={"bondid": "test-bond-id"}
), patch_bond_device_ids(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_ACCESS_TOKEN: "test-token"},
+ result["flow_id"],
+ {CONF_ACCESS_TOKEN: "test-token"},
)
assert result2["type"] == "create_entry"
@@ -231,4 +250,7 @@ def _patch_async_setup():
def _patch_async_setup_entry():
- return patch("homeassistant.components.bond.async_setup_entry", return_value=True,)
+ return patch(
+ "homeassistant.components.bond.async_setup_entry",
+ return_value=True,
+ )
diff --git a/tests/components/bond/test_fan.py b/tests/components/bond/test_fan.py
index fa7e59e30e6..1adea282a30 100644
--- a/tests/components/bond/test_fan.py
+++ b/tests/components/bond/test_fan.py
@@ -8,11 +8,14 @@ from homeassistant import core
from homeassistant.components import fan
from homeassistant.components.fan import (
ATTR_DIRECTION,
+ ATTR_SPEED,
ATTR_SPEED_LIST,
DIRECTION_FORWARD,
DIRECTION_REVERSE,
DOMAIN as FAN_DOMAIN,
SERVICE_SET_DIRECTION,
+ SERVICE_SET_SPEED,
+ SPEED_OFF,
)
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON
from homeassistant.helpers.entity_registry import EntityRegistry
@@ -45,7 +48,10 @@ async def turn_fan_on(
if speed:
service_data[fan.ATTR_SPEED] = speed
await hass.services.async_call(
- FAN_DOMAIN, SERVICE_TURN_ON, service_data=service_data, blocking=True,
+ FAN_DOMAIN,
+ SERVICE_TURN_ON,
+ service_data=service_data,
+ blocking=True,
)
await hass.async_block_till_done()
@@ -141,6 +147,36 @@ async def test_turn_on_fan_without_speed(hass: core.HomeAssistant):
mock_turn_on.assert_called_with("test-device-id", Action.turn_on())
+async def test_turn_on_fan_with_off_speed(hass: core.HomeAssistant):
+ """Tests that turn on command delegates to turn off API."""
+ await setup_platform(
+ hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id"
+ )
+
+ with patch_bond_action() as mock_turn_off, patch_bond_device_state():
+ await turn_fan_on(hass, "fan.name_1", fan.SPEED_OFF)
+
+ mock_turn_off.assert_called_with("test-device-id", Action.turn_off())
+
+
+async def test_set_speed_off(hass: core.HomeAssistant):
+ """Tests that set_speed(off) command delegates to turn off API."""
+ await setup_platform(
+ hass, FAN_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id"
+ )
+
+ with patch_bond_action() as mock_turn_off, patch_bond_device_state():
+ await hass.services.async_call(
+ FAN_DOMAIN,
+ SERVICE_SET_SPEED,
+ service_data={ATTR_ENTITY_ID: "fan.name_1", ATTR_SPEED: SPEED_OFF},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ mock_turn_off.assert_called_with("test-device-id", Action.turn_off())
+
+
async def test_turn_off_fan(hass: core.HomeAssistant):
"""Tests that turn off command delegates to API."""
await setup_platform(
@@ -149,7 +185,10 @@ async def test_turn_off_fan(hass: core.HomeAssistant):
with patch_bond_action() as mock_turn_off, patch_bond_device_state():
await hass.services.async_call(
- FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "fan.name_1"}, blocking=True,
+ FAN_DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "fan.name_1"},
+ blocking=True,
)
await hass.async_block_till_done()
diff --git a/tests/components/bond/test_init.py b/tests/components/bond/test_init.py
index 7a0f057f17b..3dd54f0de6f 100644
--- a/tests/components/bond/test_init.py
+++ b/tests/components/bond/test_init.py
@@ -27,7 +27,8 @@ async def test_async_setup_no_domain_config(hass: HomeAssistant):
async def test_async_setup_raises_entry_not_ready(hass: HomeAssistant):
"""Test that it throws ConfigEntryNotReady when exception occurs during setup."""
config_entry = MockConfigEntry(
- domain=DOMAIN, data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"},
+ domain=DOMAIN,
+ data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"},
)
config_entry.add_to_hass(hass)
@@ -39,7 +40,8 @@ async def test_async_setup_raises_entry_not_ready(hass: HomeAssistant):
async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAssistant):
"""Test that configuring entry sets up cover domain."""
config_entry = MockConfigEntry(
- domain=DOMAIN, data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"},
+ domain=DOMAIN,
+ data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"},
)
with patch_bond_version(
@@ -86,7 +88,8 @@ async def test_async_setup_entry_sets_up_hub_and_supported_domains(hass: HomeAss
async def test_unload_config_entry(hass: HomeAssistant):
"""Test that configuration entry supports unloading."""
config_entry = MockConfigEntry(
- domain=DOMAIN, data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"},
+ domain=DOMAIN,
+ data={CONF_HOST: "some host", CONF_ACCESS_TOKEN: "test-token"},
)
result = await setup_bond_entity(
diff --git a/tests/components/bond/test_light.py b/tests/components/bond/test_light.py
index 57ea859240a..17b8c87978f 100644
--- a/tests/components/bond/test_light.py
+++ b/tests/components/bond/test_light.py
@@ -165,7 +165,10 @@ async def test_brightness_support(hass: core.HomeAssistant):
async def test_brightness_not_supported(hass: core.HomeAssistant):
"""Tests that a non-dimmable light should not support the brightness feature."""
await setup_platform(
- hass, LIGHT_DOMAIN, ceiling_fan("name-1"), bond_device_id="test-device-id",
+ hass,
+ LIGHT_DOMAIN,
+ ceiling_fan("name-1"),
+ bond_device_id="test-device-id",
)
state = hass.states.get("light.name_1")
diff --git a/tests/components/broadlink/__init__.py b/tests/components/broadlink/__init__.py
index c2d16b9ab2a..4051f94c0d3 100644
--- a/tests/components/broadlink/__init__.py
+++ b/tests/components/broadlink/__init__.py
@@ -1 +1,131 @@
-"""The tests for broadlink platforms."""
+"""Tests for the Broadlink integration."""
+from homeassistant.components.broadlink.const import DOMAIN
+
+from tests.async_mock import MagicMock, patch
+from tests.common import MockConfigEntry
+
+# Do not edit/remove. Adding is ok.
+BROADLINK_DEVICES = {
+ "Entrance": (
+ "192.168.0.11",
+ "34ea34befc25",
+ "RM mini 3",
+ "Broadlink",
+ "RM2",
+ 0x2737,
+ 57,
+ 8,
+ ),
+ "Living Room": (
+ "192.168.0.12",
+ "34ea34b43b5a",
+ "RM mini 3",
+ "Broadlink",
+ "RM4",
+ 0x5F36,
+ 44017,
+ 10,
+ ),
+ "Office": (
+ "192.168.0.13",
+ "34ea34b43d22",
+ "RM pro",
+ "Broadlink",
+ "RM2",
+ 0x2787,
+ 20025,
+ 7,
+ ),
+ "Garage": (
+ "192.168.0.14",
+ "34ea34c43f31",
+ "RM4 pro",
+ "Broadlink",
+ "RM4",
+ 0x6026,
+ 52,
+ 4,
+ ),
+ "Bedroom": (
+ "192.168.0.15",
+ "34ea34b45d2c",
+ "e-Sensor",
+ "Broadlink",
+ "A1",
+ 0x2714,
+ 20025,
+ 5,
+ ),
+}
+
+
+class BroadlinkDevice:
+ """Representation of a Broadlink device."""
+
+ def __init__(
+ self, name, host, mac, model, manufacturer, type_, devtype, fwversion, timeout
+ ):
+ """Initialize the device."""
+ self.name: str = name
+ self.host: str = host
+ self.mac: str = mac
+ self.model: str = model
+ self.manufacturer: str = manufacturer
+ self.type: str = type_
+ self.devtype: int = devtype
+ self.timeout: int = timeout
+ self.fwversion: int = fwversion
+
+ async def setup_entry(self, hass, mock_api=None, mock_entry=None):
+ """Set up the device."""
+ mock_api = mock_api or self.get_mock_api()
+ mock_entry = mock_entry or self.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.broadlink.device.blk.gendevice",
+ return_value=mock_api,
+ ):
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+ await hass.async_block_till_done()
+
+ return mock_api, mock_entry
+
+ def get_mock_api(self):
+ """Return a mock device (API)."""
+ mock_api = MagicMock()
+ mock_api.name = self.name
+ mock_api.host = (self.host, 80)
+ mock_api.mac = bytes.fromhex(self.mac)
+ mock_api.model = self.model
+ mock_api.manufacturer = self.manufacturer
+ mock_api.type = self.type
+ mock_api.devtype = self.devtype
+ mock_api.timeout = self.timeout
+ mock_api.is_locked = False
+ mock_api.auth.return_value = True
+ mock_api.get_fwversion.return_value = self.fwversion
+ return mock_api
+
+ def get_mock_entry(self):
+ """Return a mock config entry."""
+ return MockConfigEntry(
+ domain=DOMAIN,
+ unique_id=self.mac,
+ title=self.name,
+ data=self.get_entry_data(),
+ )
+
+ def get_entry_data(self):
+ """Return entry data."""
+ return {
+ "host": self.host,
+ "mac": self.mac,
+ "type": self.devtype,
+ "timeout": self.timeout,
+ }
+
+
+def get_device(name):
+ """Get a device by name."""
+ return BroadlinkDevice(name, *BROADLINK_DEVICES[name])
diff --git a/tests/components/broadlink/test_config_flow.py b/tests/components/broadlink/test_config_flow.py
new file mode 100644
index 00000000000..4089c551ff5
--- /dev/null
+++ b/tests/components/broadlink/test_config_flow.py
@@ -0,0 +1,784 @@
+"""Test the Broadlink config flow."""
+import errno
+import socket
+
+import broadlink.exceptions as blke
+import pytest
+
+from homeassistant import config_entries
+from homeassistant.components.broadlink.const import DOMAIN
+
+from . import get_device
+
+from tests.async_mock import call, patch
+
+
+@pytest.fixture(autouse=True)
+def broadlink_setup_fixture():
+ """Mock broadlink entry setup."""
+ with patch(
+ "homeassistant.components.broadlink.async_setup_entry", return_value=True
+ ):
+ yield
+
+
+async def test_flow_user_works(hass):
+ """Test a config flow initiated by the user.
+
+ Best case scenario with no errors or locks.
+ """
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+ assert result["errors"] == {}
+
+ with patch("broadlink.discover", return_value=[mock_api]) as mock_discover:
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "finish"
+ assert result["errors"] == {}
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"name": device.name},
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == device.name
+ assert result["data"] == device.get_entry_data()
+
+ assert mock_discover.call_count == 1
+ assert mock_api.auth.call_count == 1
+
+
+async def test_flow_user_already_in_progress(hass):
+ """Test we do not accept more than one config flow per device."""
+ device = get_device("Living Room")
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", return_value=[device.get_mock_api()]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", return_value=[device.get_mock_api()]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_in_progress"
+
+
+async def test_flow_user_mac_already_configured(hass):
+ """Test we do not accept more than one config entry per device.
+
+ We need to abort the flow and update the existing entry.
+ """
+ device = get_device("Living Room")
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ device.host = "192.168.1.64"
+ device.timeout = 20
+ mock_api = device.get_mock_api()
+
+ with patch("broadlink.discover", return_value=[mock_api]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+
+ assert dict(mock_entry.data) == device.get_entry_data()
+ assert mock_api.auth.call_count == 0
+
+
+async def test_flow_user_invalid_ip_address(hass):
+ """Test we handle an invalid IP address in the user step."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", side_effect=OSError(errno.EINVAL, None)):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": "0.0.0.1"},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": "invalid_host"}
+
+
+async def test_flow_user_invalid_hostname(hass):
+ """Test we handle an invalid hostname in the user step."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", side_effect=OSError(socket.EAI_NONAME, None)):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": "pancakemaster.local"},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": "invalid_host"}
+
+
+async def test_flow_user_device_not_found(hass):
+ """Test we handle a device not found in the user step."""
+ device = get_device("Living Room")
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", return_value=[]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_flow_user_network_unreachable(hass):
+ """Test we handle a network unreachable in the user step."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", side_effect=OSError(errno.ENETUNREACH, None)):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": "192.168.1.32"},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_flow_user_os_error(hass):
+ """Test we handle an OS error in the user step."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", side_effect=OSError()):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": "192.168.1.32"},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": "unknown"}
+
+
+async def test_flow_auth_authentication_error(hass):
+ """Test we handle an authentication error in the auth step."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.auth.side_effect = blke.AuthenticationError()
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", return_value=[mock_api]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "reset"
+ assert result["errors"] == {"base": "invalid_auth"}
+
+
+async def test_flow_auth_device_offline(hass):
+ """Test we handle a device offline in the auth step."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.auth.side_effect = blke.DeviceOfflineError()
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", return_value=[mock_api]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "auth"
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_flow_auth_firmware_error(hass):
+ """Test we handle a firmware error in the auth step."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.auth.side_effect = blke.BroadlinkException()
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", return_value=[mock_api]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "auth"
+ assert result["errors"] == {"base": "unknown"}
+
+
+async def test_flow_auth_network_unreachable(hass):
+ """Test we handle a network unreachable in the auth step."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.auth.side_effect = OSError(errno.ENETUNREACH, None)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", return_value=[mock_api]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "auth"
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_flow_auth_os_error(hass):
+ """Test we handle an OS error in the auth step."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.auth.side_effect = OSError()
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", return_value=[mock_api]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "auth"
+ assert result["errors"] == {"base": "unknown"}
+
+
+async def test_flow_reset_works(hass):
+ """Test we finish a config flow after a factory reset."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.auth.side_effect = blke.AuthenticationError()
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", return_value=[mock_api]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ with patch("broadlink.discover", return_value=[device.get_mock_api()]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"name": device.name},
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == device.name
+ assert result["data"] == device.get_entry_data()
+
+
+async def test_flow_unlock_works(hass):
+ """Test we finish a config flow with an unlock request."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.is_locked = True
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", return_value=[mock_api]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "unlock"
+ assert result["errors"] == {}
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"unlock": True},
+ )
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"name": device.name},
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == device.name
+ assert result["data"] == device.get_entry_data()
+
+ assert mock_api.set_lock.call_args == call(False)
+ assert mock_api.set_lock.call_count == 1
+
+
+async def test_flow_unlock_device_offline(hass):
+ """Test we handle a device offline in the unlock step."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.is_locked = True
+ mock_api.set_lock.side_effect = blke.DeviceOfflineError
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", return_value=[mock_api]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"unlock": True},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "unlock"
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_flow_unlock_firmware_error(hass):
+ """Test we handle a firmware error in the unlock step."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.is_locked = True
+ mock_api.set_lock.side_effect = blke.BroadlinkException
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", return_value=[mock_api]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"unlock": True},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "unlock"
+ assert result["errors"] == {"base": "unknown"}
+
+
+async def test_flow_unlock_network_unreachable(hass):
+ """Test we handle a network unreachable in the unlock step."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.is_locked = True
+ mock_api.set_lock.side_effect = OSError(errno.ENETUNREACH, None)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", return_value=[mock_api]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"unlock": True},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "unlock"
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_flow_unlock_os_error(hass):
+ """Test we handle an OS error in the unlock step."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.is_locked = True
+ mock_api.set_lock.side_effect = OSError()
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", return_value=[mock_api]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"unlock": True},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "unlock"
+ assert result["errors"] == {"base": "unknown"}
+
+
+async def test_flow_do_not_unlock(hass):
+ """Test we do not unlock the device if the user does not want to."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.is_locked = True
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("broadlink.discover", return_value=[mock_api]):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"unlock": False},
+ )
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"name": device.name},
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == device.name
+ assert result["data"] == device.get_entry_data()
+
+ assert mock_api.set_lock.call_count == 0
+
+
+async def test_flow_import_works(hass):
+ """Test an import flow."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+
+ with patch("broadlink.discover", return_value=[mock_api]) as mock_discover:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={"host": device.host},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "finish"
+ assert result["errors"] == {}
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"name": device.name},
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == device.name
+ assert result["data"]["host"] == device.host
+ assert result["data"]["mac"] == device.mac
+ assert result["data"]["type"] == device.devtype
+
+ assert mock_api.auth.call_count == 1
+ assert mock_discover.call_count == 1
+
+
+async def test_flow_import_already_in_progress(hass):
+ """Test we do not import more than one flow per device."""
+ device = get_device("Living Room")
+ data = {"host": device.host}
+
+ with patch("broadlink.discover", return_value=[device.get_mock_api()]):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data
+ )
+
+ with patch("broadlink.discover", return_value=[device.get_mock_api()]):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=data
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_in_progress"
+
+
+async def test_flow_import_host_already_configured(hass):
+ """Test we do not import a host that is already configured."""
+ device = get_device("Living Room")
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+ mock_api = device.get_mock_api()
+
+ with patch("broadlink.discover", return_value=[mock_api]):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={"host": device.host},
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+
+
+async def test_flow_import_mac_already_configured(hass):
+ """Test we do not import more than one config entry per device.
+
+ We need to abort the flow and update the existing entry.
+ """
+ device = get_device("Living Room")
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ device.host = "192.168.1.16"
+ mock_api = device.get_mock_api()
+
+ with patch("broadlink.discover", return_value=[mock_api]):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={"host": device.host},
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+
+ assert mock_entry.data["host"] == device.host
+ assert mock_entry.data["mac"] == device.mac
+ assert mock_entry.data["type"] == device.devtype
+ assert mock_api.auth.call_count == 0
+
+
+async def test_flow_import_device_not_found(hass):
+ """Test we handle a device not found in the import step."""
+ with patch("broadlink.discover", return_value=[]):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={"host": "192.168.1.32"},
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "cannot_connect"
+
+
+async def test_flow_import_invalid_ip_address(hass):
+ """Test we handle an invalid IP address in the import step."""
+ with patch("broadlink.discover", side_effect=OSError(errno.EINVAL, None)):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={"host": "0.0.0.1"},
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "invalid_host"
+
+
+async def test_flow_import_invalid_hostname(hass):
+ """Test we handle an invalid hostname in the import step."""
+ with patch("broadlink.discover", side_effect=OSError(socket.EAI_NONAME, None)):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={"host": "hotdog.local"},
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "invalid_host"
+
+
+async def test_flow_import_network_unreachable(hass):
+ """Test we handle a network unreachable in the import step."""
+ with patch("broadlink.discover", side_effect=OSError(errno.ENETUNREACH, None)):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={"host": "192.168.1.64"},
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "cannot_connect"
+
+
+async def test_flow_import_os_error(hass):
+ """Test we handle an OS error in the import step."""
+ with patch("broadlink.discover", side_effect=OSError()):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={"host": "192.168.1.64"},
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "unknown"
+
+
+async def test_flow_reauth_works(hass):
+ """Test a reauthentication flow."""
+ device = get_device("Living Room")
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+ mock_api = device.get_mock_api()
+ mock_api.auth.side_effect = blke.AuthenticationError()
+ data = {"name": device.name, **device.get_entry_data()}
+
+ with patch("broadlink.gendevice", return_value=mock_api):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "reauth"}, data=data
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "reset"
+
+ mock_api = device.get_mock_api()
+
+ with patch("broadlink.discover", return_value=[mock_api]) as mock_discover:
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+
+ assert dict(mock_entry.data) == device.get_entry_data()
+ assert mock_api.auth.call_count == 1
+ assert mock_discover.call_count == 1
+
+
+async def test_flow_reauth_invalid_host(hass):
+ """Test we do not accept an invalid host for reauthentication.
+
+ The MAC address cannot change.
+ """
+ device = get_device("Living Room")
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+ mock_api = device.get_mock_api()
+ mock_api.auth.side_effect = blke.AuthenticationError()
+ data = {"name": device.name, **device.get_entry_data()}
+
+ with patch("broadlink.gendevice", return_value=mock_api):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "reauth"}, data=data
+ )
+
+ device.mac = get_device("Office").mac
+ mock_api = device.get_mock_api()
+
+ with patch("broadlink.discover", return_value=[mock_api]) as mock_discover:
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": "invalid_host"}
+
+ assert mock_discover.call_count == 1
+ assert mock_api.auth.call_count == 0
+
+
+async def test_flow_reauth_valid_host(hass):
+ """Test we accept a valid host for reauthentication.
+
+ The hostname/IP address may change. We need to update the entry.
+ """
+ device = get_device("Living Room")
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+ mock_api = device.get_mock_api()
+ mock_api.auth.side_effect = blke.AuthenticationError()
+ data = {"name": device.name, **device.get_entry_data()}
+
+ with patch("broadlink.gendevice", return_value=mock_api):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "reauth"}, data=data
+ )
+
+ device.host = "192.168.1.128"
+ mock_api = device.get_mock_api()
+
+ with patch("broadlink.discover", return_value=[mock_api]) as mock_discover:
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": device.host, "timeout": device.timeout},
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+
+ assert mock_entry.data["host"] == device.host
+ assert mock_discover.call_count == 1
+ assert mock_api.auth.call_count == 1
diff --git a/tests/components/broadlink/test_device.py b/tests/components/broadlink/test_device.py
new file mode 100644
index 00000000000..5cd0457b552
--- /dev/null
+++ b/tests/components/broadlink/test_device.py
@@ -0,0 +1,389 @@
+"""Tests for Broadlink devices."""
+import broadlink.exceptions as blke
+
+from homeassistant.components.broadlink.const import DOMAIN
+from homeassistant.components.broadlink.device import get_domains
+from homeassistant.config_entries import (
+ ENTRY_STATE_LOADED,
+ ENTRY_STATE_NOT_LOADED,
+ ENTRY_STATE_SETUP_ERROR,
+ ENTRY_STATE_SETUP_RETRY,
+)
+from homeassistant.helpers.entity_registry import async_entries_for_device
+
+from . import get_device
+
+from tests.async_mock import patch
+from tests.common import mock_device_registry, mock_registry
+
+
+async def test_device_setup(hass):
+ """Test a successful setup."""
+ device = get_device("Office")
+ mock_api = device.get_mock_api()
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api), patch.object(
+ hass.config_entries, "async_forward_entry_setup"
+ ) as mock_forward, patch.object(
+ hass.config_entries.flow, "async_init"
+ ) as mock_init:
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+
+ assert mock_entry.state == ENTRY_STATE_LOADED
+ assert mock_api.auth.call_count == 1
+ assert mock_api.get_fwversion.call_count == 1
+ forward_entries = {c[1][1] for c in mock_forward.mock_calls}
+ domains = get_domains(mock_api.type)
+ assert mock_forward.call_count == len(domains)
+ assert forward_entries == domains
+ assert mock_init.call_count == 0
+
+
+async def test_device_setup_authentication_error(hass):
+ """Test we handle an authentication error."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.auth.side_effect = blke.AuthenticationError()
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api), patch.object(
+ hass.config_entries, "async_forward_entry_setup"
+ ) as mock_forward, patch.object(
+ hass.config_entries.flow, "async_init"
+ ) as mock_init:
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+
+ assert mock_entry.state == ENTRY_STATE_SETUP_ERROR
+ assert mock_api.auth.call_count == 1
+ assert mock_forward.call_count == 0
+ assert mock_init.call_count == 1
+ assert mock_init.mock_calls[0][2]["context"]["source"] == "reauth"
+ assert mock_init.mock_calls[0][2]["data"] == {
+ "name": device.name,
+ **device.get_entry_data(),
+ }
+
+
+async def test_device_setup_device_offline(hass):
+ """Test we handle a device offline."""
+ device = get_device("Office")
+ mock_api = device.get_mock_api()
+ mock_api.auth.side_effect = blke.DeviceOfflineError()
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api), patch.object(
+ hass.config_entries, "async_forward_entry_setup"
+ ) as mock_forward, patch.object(
+ hass.config_entries.flow, "async_init"
+ ) as mock_init:
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+
+ assert mock_entry.state == ENTRY_STATE_SETUP_RETRY
+ assert mock_api.auth.call_count == 1
+ assert mock_forward.call_count == 0
+ assert mock_init.call_count == 0
+
+
+async def test_device_setup_os_error(hass):
+ """Test we handle an OS error."""
+ device = get_device("Office")
+ mock_api = device.get_mock_api()
+ mock_api.auth.side_effect = OSError()
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api), patch.object(
+ hass.config_entries, "async_forward_entry_setup"
+ ) as mock_forward, patch.object(
+ hass.config_entries.flow, "async_init"
+ ) as mock_init:
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+
+ assert mock_entry.state == ENTRY_STATE_SETUP_RETRY
+ assert mock_api.auth.call_count == 1
+ assert mock_forward.call_count == 0
+ assert mock_init.call_count == 0
+
+
+async def test_device_setup_broadlink_exception(hass):
+ """Test we handle a Broadlink exception."""
+ device = get_device("Office")
+ mock_api = device.get_mock_api()
+ mock_api.auth.side_effect = blke.BroadlinkException()
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api), patch.object(
+ hass.config_entries, "async_forward_entry_setup"
+ ) as mock_forward, patch.object(
+ hass.config_entries.flow, "async_init"
+ ) as mock_init:
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+
+ assert mock_entry.state == ENTRY_STATE_SETUP_ERROR
+ assert mock_api.auth.call_count == 1
+ assert mock_forward.call_count == 0
+ assert mock_init.call_count == 0
+
+
+async def test_device_setup_update_device_offline(hass):
+ """Test we handle a device offline in the update step."""
+ device = get_device("Office")
+ mock_api = device.get_mock_api()
+ mock_api.check_sensors.side_effect = blke.DeviceOfflineError()
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api), patch.object(
+ hass.config_entries, "async_forward_entry_setup"
+ ) as mock_forward, patch.object(
+ hass.config_entries.flow, "async_init"
+ ) as mock_init:
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+
+ assert mock_entry.state == ENTRY_STATE_SETUP_RETRY
+ assert mock_api.auth.call_count == 1
+ assert mock_api.check_sensors.call_count == 1
+ assert mock_forward.call_count == 0
+ assert mock_init.call_count == 0
+
+
+async def test_device_setup_update_authorization_error(hass):
+ """Test we handle an authorization error in the update step."""
+ device = get_device("Office")
+ mock_api = device.get_mock_api()
+ mock_api.check_sensors.side_effect = (blke.AuthorizationError(), None)
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api), patch.object(
+ hass.config_entries, "async_forward_entry_setup"
+ ) as mock_forward, patch.object(
+ hass.config_entries.flow, "async_init"
+ ) as mock_init:
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+
+ assert mock_entry.state == ENTRY_STATE_LOADED
+ assert mock_api.auth.call_count == 2
+ assert mock_api.check_sensors.call_count == 2
+ forward_entries = {c[1][1] for c in mock_forward.mock_calls}
+ domains = get_domains(mock_api.type)
+ assert mock_forward.call_count == len(domains)
+ assert forward_entries == domains
+ assert mock_init.call_count == 0
+
+
+async def test_device_setup_update_authentication_error(hass):
+ """Test we handle an authentication error in the update step."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.check_sensors.side_effect = blke.AuthorizationError()
+ mock_api.auth.side_effect = (None, blke.AuthenticationError())
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api), patch.object(
+ hass.config_entries, "async_forward_entry_setup"
+ ) as mock_forward, patch.object(
+ hass.config_entries.flow, "async_init"
+ ) as mock_init:
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+
+ assert mock_entry.state == ENTRY_STATE_SETUP_RETRY
+ assert mock_api.auth.call_count == 2
+ assert mock_api.check_sensors.call_count == 1
+ assert mock_forward.call_count == 0
+ assert mock_init.call_count == 1
+ assert mock_init.mock_calls[0][2]["context"]["source"] == "reauth"
+ assert mock_init.mock_calls[0][2]["data"] == {
+ "name": device.name,
+ **device.get_entry_data(),
+ }
+
+
+async def test_device_setup_update_broadlink_exception(hass):
+ """Test we handle a Broadlink exception in the update step."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.check_sensors.side_effect = blke.BroadlinkException()
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api), patch.object(
+ hass.config_entries, "async_forward_entry_setup"
+ ) as mock_forward, patch.object(
+ hass.config_entries.flow, "async_init"
+ ) as mock_init:
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+
+ assert mock_entry.state == ENTRY_STATE_SETUP_RETRY
+ assert mock_api.auth.call_count == 1
+ assert mock_api.check_sensors.call_count == 1
+ assert mock_forward.call_count == 0
+ assert mock_init.call_count == 0
+
+
+async def test_device_setup_get_fwversion_broadlink_exception(hass):
+ """Test we load the device even if we cannot read the firmware version."""
+ device = get_device("Office")
+ mock_api = device.get_mock_api()
+ mock_api.get_fwversion.side_effect = blke.BroadlinkException()
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api), patch.object(
+ hass.config_entries, "async_forward_entry_setup"
+ ) as mock_forward:
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+
+ assert mock_entry.state == ENTRY_STATE_LOADED
+ forward_entries = {c[1][1] for c in mock_forward.mock_calls}
+ domains = get_domains(mock_api.type)
+ assert mock_forward.call_count == len(domains)
+ assert forward_entries == domains
+
+
+async def test_device_setup_get_fwversion_os_error(hass):
+ """Test we load the device even if we cannot read the firmware version."""
+ device = get_device("Office")
+ mock_api = device.get_mock_api()
+ mock_api.get_fwversion.side_effect = OSError()
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api), patch.object(
+ hass.config_entries, "async_forward_entry_setup"
+ ) as mock_forward:
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+
+ assert mock_entry.state == ENTRY_STATE_LOADED
+ forward_entries = {c[1][1] for c in mock_forward.mock_calls}
+ domains = get_domains(mock_api.type)
+ assert mock_forward.call_count == len(domains)
+ assert forward_entries == domains
+
+
+async def test_device_setup_registry(hass):
+ """Test we register the device and the entries correctly."""
+ device = get_device("Office")
+ mock_api = device.get_mock_api()
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ device_registry = mock_device_registry(hass)
+ entity_registry = mock_registry(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api):
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert len(device_registry.devices) == 1
+
+ device_entry = device_registry.async_get_device(
+ {(DOMAIN, mock_entry.unique_id)}, set()
+ )
+ assert device_entry.identifiers == {(DOMAIN, device.mac)}
+ assert device_entry.name == device.name
+ assert device_entry.model == device.model
+ assert device_entry.manufacturer == device.manufacturer
+ assert device_entry.sw_version == device.fwversion
+
+ for entry in async_entries_for_device(entity_registry, device_entry.id):
+ assert entry.original_name.startswith(device.name)
+
+
+async def test_device_unload_works(hass):
+ """Test we unload the device."""
+ device = get_device("Office")
+ mock_api = device.get_mock_api()
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api), patch.object(
+ hass.config_entries, "async_forward_entry_setup"
+ ):
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+
+ with patch.object(
+ hass.config_entries, "async_forward_entry_unload", return_value=True
+ ) as mock_forward:
+ await hass.config_entries.async_unload(mock_entry.entry_id)
+
+ assert mock_entry.state == ENTRY_STATE_NOT_LOADED
+ forward_entries = {c[1][1] for c in mock_forward.mock_calls}
+ domains = get_domains(mock_api.type)
+ assert mock_forward.call_count == len(domains)
+ assert forward_entries == domains
+
+
+async def test_device_unload_authentication_error(hass):
+ """Test we unload a device that failed the authentication step."""
+ device = get_device("Living Room")
+ mock_api = device.get_mock_api()
+ mock_api.auth.side_effect = blke.AuthenticationError()
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api), patch.object(
+ hass.config_entries, "async_forward_entry_setup"
+ ), patch.object(hass.config_entries.flow, "async_init"):
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+
+ with patch.object(
+ hass.config_entries, "async_forward_entry_unload", return_value=True
+ ) as mock_forward:
+ await hass.config_entries.async_unload(mock_entry.entry_id)
+
+ assert mock_entry.state == ENTRY_STATE_NOT_LOADED
+ assert mock_forward.call_count == 0
+
+
+async def test_device_unload_update_failed(hass):
+ """Test we unload a device that failed the update step."""
+ device = get_device("Office")
+ mock_api = device.get_mock_api()
+ mock_api.check_sensors.side_effect = blke.DeviceOfflineError()
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api), patch.object(
+ hass.config_entries, "async_forward_entry_setup"
+ ):
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+
+ with patch.object(
+ hass.config_entries, "async_forward_entry_unload", return_value=True
+ ) as mock_forward:
+ await hass.config_entries.async_unload(mock_entry.entry_id)
+
+ assert mock_entry.state == ENTRY_STATE_NOT_LOADED
+ assert mock_forward.call_count == 0
+
+
+async def test_device_update_listener(hass):
+ """Test we update device and entity registry when the entry is renamed."""
+ device = get_device("Office")
+ mock_api = device.get_mock_api()
+ mock_entry = device.get_mock_entry()
+ mock_entry.add_to_hass(hass)
+
+ device_registry = mock_device_registry(hass)
+ entity_registry = mock_registry(hass)
+
+ with patch("broadlink.gendevice", return_value=mock_api):
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+ await hass.async_block_till_done()
+
+ hass.config_entries.async_update_entry(mock_entry, title="New Name")
+ await hass.async_block_till_done()
+
+ device_entry = device_registry.async_get_device(
+ {(DOMAIN, mock_entry.unique_id)}, set()
+ )
+ assert device_entry.name == "New Name"
+ for entry in async_entries_for_device(entity_registry, device_entry.id):
+ assert entry.original_name.startswith("New Name")
diff --git a/tests/components/broadlink/test_helpers.py b/tests/components/broadlink/test_helpers.py
new file mode 100644
index 00000000000..0983322d761
--- /dev/null
+++ b/tests/components/broadlink/test_helpers.py
@@ -0,0 +1,54 @@
+"""Tests for Broadlink helper functions."""
+import pytest
+import voluptuous as vol
+
+from homeassistant.components.broadlink.helpers import data_packet, mac_address
+
+
+async def test_padding(hass):
+ """Verify that non padding strings are allowed."""
+ assert data_packet("Jg") == b"&"
+ assert data_packet("Jg=") == b"&"
+ assert data_packet("Jg==") == b"&"
+
+
+async def test_valid_mac_address(hass):
+ """Test we convert a valid MAC address to bytes."""
+ valid = [
+ "A1B2C3D4E5F6",
+ "a1b2c3d4e5f6",
+ "A1B2-C3D4-E5F6",
+ "a1b2-c3d4-e5f6",
+ "A1B2.C3D4.E5F6",
+ "a1b2.c3d4.e5f6",
+ "A1-B2-C3-D4-E5-F6",
+ "a1-b2-c3-d4-e5-f6",
+ "A1:B2:C3:D4:E5:F6",
+ "a1:b2:c3:d4:e5:f6",
+ ]
+ for mac in valid:
+ assert mac_address(mac) == b"\xa1\xb2\xc3\xd4\xe5\xf6"
+
+
+async def test_invalid_mac_address(hass):
+ """Test we do not accept an invalid MAC address."""
+ invalid = [
+ None,
+ 123,
+ ["a", "b", "c"],
+ {"abc": "def"},
+ "a1b2c3d4e5f",
+ "a1b2.c3d4.e5f",
+ "a1-b2-c3-d4-e5-f",
+ "a1b2c3d4e5f66",
+ "a1b2.c3d4.e5f66",
+ "a1-b2-c3-d4-e5-f66",
+ "a1b2c3d4e5fg",
+ "a1b2.c3d4.e5fg",
+ "a1-b2-c3-d4-e5-fg",
+ "a1b.2c3d4.e5fg",
+ "a1b-2-c3-d4-e5-fg",
+ ]
+ for mac in invalid:
+ with pytest.raises((ValueError, vol.Invalid)):
+ mac_address(mac)
diff --git a/tests/components/broadlink/test_init.py b/tests/components/broadlink/test_init.py
deleted file mode 100644
index 5a359896bfa..00000000000
--- a/tests/components/broadlink/test_init.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""The tests for the broadlink component."""
-from base64 import b64decode
-from datetime import timedelta
-
-import pytest
-
-from homeassistant.components.broadlink import async_setup_service, data_packet
-from homeassistant.components.broadlink.const import DOMAIN, SERVICE_LEARN, SERVICE_SEND
-from homeassistant.components.broadlink.device import BroadlinkDevice
-from homeassistant.util.dt import utcnow
-
-from tests.async_mock import MagicMock, call, patch
-
-DUMMY_IR_PACKET = (
- "JgBGAJKVETkRORA6ERQRFBEUERQRFBE5ETkQOhAVEBUQFREUEBUQ"
- "OhEUERQRORE5EBURFBA6EBUQOhE5EBUQFRA6EDoRFBEADQUAAA=="
-)
-DUMMY_HOST = "192.168.0.2"
-
-
-@pytest.fixture(autouse=True)
-def dummy_broadlink():
- """Mock broadlink module so we don't have that dependency on tests."""
- broadlink = MagicMock()
- with patch.dict("sys.modules", {"broadlink": broadlink}):
- yield broadlink
-
-
-async def test_padding(hass):
- """Verify that non padding strings are allowed."""
- assert data_packet("Jg") == b"&"
- assert data_packet("Jg=") == b"&"
- assert data_packet("Jg==") == b"&"
-
-
-async def test_send(hass):
- """Test send service."""
- mock_api = MagicMock()
- mock_api.send_data.return_value = None
- device = BroadlinkDevice(hass, mock_api)
-
- await async_setup_service(hass, DUMMY_HOST, device)
- await hass.services.async_call(
- DOMAIN, SERVICE_SEND, {"host": DUMMY_HOST, "packet": (DUMMY_IR_PACKET)}
- )
- await hass.async_block_till_done()
-
- assert device.api.send_data.call_count == 1
- assert device.api.send_data.call_args == call(b64decode(DUMMY_IR_PACKET))
-
-
-async def test_learn(hass):
- """Test learn service."""
- mock_api = MagicMock()
- mock_api.enter_learning.return_value = None
- mock_api.check_data.return_value = b64decode(DUMMY_IR_PACKET)
- device = BroadlinkDevice(hass, mock_api)
-
- with patch.object(
- hass.components.persistent_notification, "async_create"
- ) as mock_create:
-
- await async_setup_service(hass, DUMMY_HOST, device)
- await hass.services.async_call(DOMAIN, SERVICE_LEARN, {"host": DUMMY_HOST})
- await hass.async_block_till_done()
-
- assert device.api.enter_learning.call_count == 1
- assert device.api.enter_learning.call_args == call()
-
- assert mock_create.call_count == 1
- assert mock_create.call_args == call(
- f"Received packet is: {DUMMY_IR_PACKET}", title="Broadlink switch"
- )
-
-
-async def test_learn_timeout(hass):
- """Test learn service."""
- mock_api = MagicMock()
- mock_api.enter_learning.return_value = None
- mock_api.check_data.return_value = None
- device = BroadlinkDevice(hass, mock_api)
-
- await async_setup_service(hass, DUMMY_HOST, device)
-
- now = utcnow()
-
- with patch.object(
- hass.components.persistent_notification, "async_create"
- ) as mock_create, patch("homeassistant.components.broadlink.utcnow") as mock_utcnow:
-
- mock_utcnow.side_effect = [now, now + timedelta(20)]
-
- await hass.services.async_call(DOMAIN, SERVICE_LEARN, {"host": DUMMY_HOST})
- await hass.async_block_till_done()
-
- assert device.api.enter_learning.call_count == 1
- assert device.api.enter_learning.call_args == call()
-
- assert mock_create.call_count == 1
- assert mock_create.call_args == call(
- "No signal was received", title="Broadlink switch"
- )
diff --git a/tests/components/broadlink/test_remote.py b/tests/components/broadlink/test_remote.py
new file mode 100644
index 00000000000..941dfc4d3ce
--- /dev/null
+++ b/tests/components/broadlink/test_remote.py
@@ -0,0 +1,120 @@
+"""Tests for Broadlink remotes."""
+from base64 import b64decode
+
+from homeassistant.components.broadlink.const import DOMAIN, REMOTE_DOMAIN
+from homeassistant.components.remote import (
+ SERVICE_SEND_COMMAND,
+ SERVICE_TURN_OFF,
+ SERVICE_TURN_ON,
+)
+from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.helpers.entity_registry import async_entries_for_device
+
+from . import get_device
+
+from tests.async_mock import call
+from tests.common import mock_device_registry, mock_registry
+
+REMOTE_DEVICES = ["Entrance", "Living Room", "Office", "Garage"]
+
+IR_PACKET = (
+ "JgBGAJKVETkRORA6ERQRFBEUERQRFBE5ETkQOhAVEBUQFREUEBUQ"
+ "OhEUERQRORE5EBURFBA6EBUQOhE5EBUQFRA6EDoRFBEADQUAAA=="
+)
+
+
+async def test_remote_setup_works(hass):
+ """Test a successful setup with all remotes."""
+ for device in map(get_device, REMOTE_DEVICES):
+ device_registry = mock_device_registry(hass)
+ entity_registry = mock_registry(hass)
+ mock_api, mock_entry = await device.setup_entry(hass)
+
+ device_entry = device_registry.async_get_device(
+ {(DOMAIN, mock_entry.unique_id)}, set()
+ )
+ entries = async_entries_for_device(entity_registry, device_entry.id)
+ remotes = {entry for entry in entries if entry.domain == REMOTE_DOMAIN}
+ assert len(remotes) == 1
+
+ remote = remotes.pop()
+ assert remote.original_name == f"{device.name} Remote"
+ assert hass.states.get(remote.entity_id).state == STATE_ON
+ assert mock_api.auth.call_count == 1
+
+
+async def test_remote_send_command(hass):
+ """Test sending a command with all remotes."""
+ for device in map(get_device, REMOTE_DEVICES):
+ device_registry = mock_device_registry(hass)
+ entity_registry = mock_registry(hass)
+ mock_api, mock_entry = await device.setup_entry(hass)
+
+ device_entry = device_registry.async_get_device(
+ {(DOMAIN, mock_entry.unique_id)}, set()
+ )
+ entries = async_entries_for_device(entity_registry, device_entry.id)
+ remotes = {entry for entry in entries if entry.domain == REMOTE_DOMAIN}
+ assert len(remotes) == 1
+
+ remote = remotes.pop()
+ await hass.services.async_call(
+ REMOTE_DOMAIN,
+ SERVICE_SEND_COMMAND,
+ {"entity_id": remote.entity_id, "command": "b64:" + IR_PACKET},
+ blocking=True,
+ )
+
+ assert mock_api.send_data.call_count == 1
+ assert mock_api.send_data.call_args == call(b64decode(IR_PACKET))
+ assert mock_api.auth.call_count == 1
+
+
+async def test_remote_turn_off_turn_on(hass):
+ """Test we do not send commands if the remotes are off."""
+ for device in map(get_device, REMOTE_DEVICES):
+ device_registry = mock_device_registry(hass)
+ entity_registry = mock_registry(hass)
+ mock_api, mock_entry = await device.setup_entry(hass)
+
+ device_entry = device_registry.async_get_device(
+ {(DOMAIN, mock_entry.unique_id)}, set()
+ )
+ entries = async_entries_for_device(entity_registry, device_entry.id)
+ remotes = {entry for entry in entries if entry.domain == REMOTE_DOMAIN}
+ assert len(remotes) == 1
+
+ remote = remotes.pop()
+ await hass.services.async_call(
+ REMOTE_DOMAIN,
+ SERVICE_TURN_OFF,
+ {"entity_id": remote.entity_id},
+ blocking=True,
+ )
+ assert hass.states.get(remote.entity_id).state == STATE_OFF
+
+ await hass.services.async_call(
+ REMOTE_DOMAIN,
+ SERVICE_SEND_COMMAND,
+ {"entity_id": remote.entity_id, "command": "b64:" + IR_PACKET},
+ blocking=True,
+ )
+ assert mock_api.send_data.call_count == 0
+
+ await hass.services.async_call(
+ REMOTE_DOMAIN,
+ SERVICE_TURN_ON,
+ {"entity_id": remote.entity_id},
+ blocking=True,
+ )
+ assert hass.states.get(remote.entity_id).state == STATE_ON
+
+ await hass.services.async_call(
+ REMOTE_DOMAIN,
+ SERVICE_SEND_COMMAND,
+ {"entity_id": remote.entity_id, "command": "b64:" + IR_PACKET},
+ blocking=True,
+ )
+ assert mock_api.send_data.call_count == 1
+ assert mock_api.send_data.call_args == call(b64decode(IR_PACKET))
+ assert mock_api.auth.call_count == 1
diff --git a/tests/components/broadlink/test_sensors.py b/tests/components/broadlink/test_sensors.py
new file mode 100644
index 00000000000..a7d6a304654
--- /dev/null
+++ b/tests/components/broadlink/test_sensors.py
@@ -0,0 +1,254 @@
+"""Tests for Broadlink sensors."""
+from homeassistant.components.broadlink.const import DOMAIN, SENSOR_DOMAIN
+from homeassistant.helpers.entity_registry import async_entries_for_device
+
+from . import get_device
+
+from tests.common import mock_device_registry, mock_registry
+
+
+async def test_a1_sensor_setup(hass):
+ """Test a successful e-Sensor setup."""
+ device = get_device("Bedroom")
+ mock_api = device.get_mock_api()
+ mock_api.check_sensors_raw.return_value = {
+ "temperature": 27.4,
+ "humidity": 59.3,
+ "air_quality": 3,
+ "light": 2,
+ "noise": 1,
+ }
+
+ device_registry = mock_device_registry(hass)
+ entity_registry = mock_registry(hass)
+
+ mock_api, mock_entry = await device.setup_entry(hass, mock_api=mock_api)
+
+ assert mock_api.check_sensors_raw.call_count == 1
+ device_entry = device_registry.async_get_device(
+ {(DOMAIN, mock_entry.unique_id)}, set()
+ )
+ entries = async_entries_for_device(entity_registry, device_entry.id)
+ sensors = {entry for entry in entries if entry.domain == SENSOR_DOMAIN}
+ assert len(sensors) == 5
+
+ sensors_and_states = {
+ (sensor.original_name, hass.states.get(sensor.entity_id).state)
+ for sensor in sensors
+ }
+ assert sensors_and_states == {
+ (f"{device.name} Temperature", "27.4"),
+ (f"{device.name} Humidity", "59.3"),
+ (f"{device.name} Air Quality", "3"),
+ (f"{device.name} Light", "2"),
+ (f"{device.name} Noise", "1"),
+ }
+
+
+async def test_a1_sensor_update(hass):
+ """Test a successful e-Sensor update."""
+ device = get_device("Bedroom")
+ mock_api = device.get_mock_api()
+ mock_api.check_sensors_raw.return_value = {
+ "temperature": 22.4,
+ "humidity": 47.3,
+ "air_quality": 3,
+ "light": 2,
+ "noise": 1,
+ }
+
+ device_registry = mock_device_registry(hass)
+ entity_registry = mock_registry(hass)
+
+ mock_api, mock_entry = await device.setup_entry(hass, mock_api=mock_api)
+
+ device_entry = device_registry.async_get_device(
+ {(DOMAIN, mock_entry.unique_id)}, set()
+ )
+ entries = async_entries_for_device(entity_registry, device_entry.id)
+ sensors = {entry for entry in entries if entry.domain == SENSOR_DOMAIN}
+ assert len(sensors) == 5
+
+ mock_api.check_sensors_raw.return_value = {
+ "temperature": 22.5,
+ "humidity": 47.4,
+ "air_quality": 2,
+ "light": 3,
+ "noise": 2,
+ }
+ await hass.helpers.entity_component.async_update_entity(
+ next(iter(sensors)).entity_id
+ )
+ assert mock_api.check_sensors_raw.call_count == 2
+
+ sensors_and_states = {
+ (sensor.original_name, hass.states.get(sensor.entity_id).state)
+ for sensor in sensors
+ }
+ assert sensors_and_states == {
+ (f"{device.name} Temperature", "22.5"),
+ (f"{device.name} Humidity", "47.4"),
+ (f"{device.name} Air Quality", "2"),
+ (f"{device.name} Light", "3"),
+ (f"{device.name} Noise", "2"),
+ }
+
+
+async def test_rm_pro_sensor_setup(hass):
+ """Test a successful RM pro sensor setup."""
+ device = get_device("Office")
+ mock_api = device.get_mock_api()
+ mock_api.check_sensors.return_value = {"temperature": 18.2}
+
+ device_registry = mock_device_registry(hass)
+ entity_registry = mock_registry(hass)
+
+ mock_api, mock_entry = await device.setup_entry(hass, mock_api=mock_api)
+
+ assert mock_api.check_sensors.call_count == 1
+ device_entry = device_registry.async_get_device(
+ {(DOMAIN, mock_entry.unique_id)}, set()
+ )
+ entries = async_entries_for_device(entity_registry, device_entry.id)
+ sensors = {entry for entry in entries if entry.domain == SENSOR_DOMAIN}
+ assert len(sensors) == 1
+
+ sensors_and_states = {
+ (sensor.original_name, hass.states.get(sensor.entity_id).state)
+ for sensor in sensors
+ }
+ assert sensors_and_states == {(f"{device.name} Temperature", "18.2")}
+
+
+async def test_rm_pro_sensor_update(hass):
+ """Test a successful RM pro sensor update."""
+ device = get_device("Office")
+ mock_api = device.get_mock_api()
+ mock_api.check_sensors.return_value = {"temperature": 25.7}
+
+ device_registry = mock_device_registry(hass)
+ entity_registry = mock_registry(hass)
+
+ mock_api, mock_entry = await device.setup_entry(hass, mock_api=mock_api)
+
+ device_entry = device_registry.async_get_device(
+ {(DOMAIN, mock_entry.unique_id)}, set()
+ )
+ entries = async_entries_for_device(entity_registry, device_entry.id)
+ sensors = {entry for entry in entries if entry.domain == SENSOR_DOMAIN}
+ assert len(sensors) == 1
+
+ mock_api.check_sensors.return_value = {"temperature": 25.8}
+ await hass.helpers.entity_component.async_update_entity(
+ next(iter(sensors)).entity_id
+ )
+ assert mock_api.check_sensors.call_count == 2
+
+ sensors_and_states = {
+ (sensor.original_name, hass.states.get(sensor.entity_id).state)
+ for sensor in sensors
+ }
+ assert sensors_and_states == {(f"{device.name} Temperature", "25.8")}
+
+
+async def test_rm_mini3_no_sensor(hass):
+ """Test we do not set up sensors for RM mini 3."""
+ device = get_device("Entrance")
+ mock_api = device.get_mock_api()
+ mock_api.check_sensors.return_value = {"temperature": 0}
+
+ device_registry = mock_device_registry(hass)
+ entity_registry = mock_registry(hass)
+
+ mock_api, mock_entry = await device.setup_entry(hass, mock_api=mock_api)
+
+ assert mock_api.check_sensors.call_count <= 1
+ device_entry = device_registry.async_get_device(
+ {(DOMAIN, mock_entry.unique_id)}, set()
+ )
+ entries = async_entries_for_device(entity_registry, device_entry.id)
+ sensors = {entry for entry in entries if entry.domain == SENSOR_DOMAIN}
+ assert len(sensors) == 0
+
+
+async def test_rm4_pro_hts2_sensor_setup(hass):
+ """Test a successful RM4 pro sensor setup with HTS2 cable."""
+ device = get_device("Garage")
+ mock_api = device.get_mock_api()
+ mock_api.check_sensors.return_value = {"temperature": 22.5, "humidity": 43.7}
+
+ device_registry = mock_device_registry(hass)
+ entity_registry = mock_registry(hass)
+
+ mock_api, mock_entry = await device.setup_entry(hass, mock_api=mock_api)
+
+ assert mock_api.check_sensors.call_count == 1
+ device_entry = device_registry.async_get_device(
+ {(DOMAIN, mock_entry.unique_id)}, set()
+ )
+ entries = async_entries_for_device(entity_registry, device_entry.id)
+ sensors = {entry for entry in entries if entry.domain == SENSOR_DOMAIN}
+ assert len(sensors) == 2
+
+ sensors_and_states = {
+ (sensor.original_name, hass.states.get(sensor.entity_id).state)
+ for sensor in sensors
+ }
+ assert sensors_and_states == {
+ (f"{device.name} Temperature", "22.5"),
+ (f"{device.name} Humidity", "43.7"),
+ }
+
+
+async def test_rm4_pro_hts2_sensor_update(hass):
+ """Test a successful RM4 pro sensor update with HTS2 cable."""
+ device = get_device("Garage")
+ mock_api = device.get_mock_api()
+ mock_api.check_sensors.return_value = {"temperature": 16.7, "humidity": 34.1}
+
+ device_registry = mock_device_registry(hass)
+ entity_registry = mock_registry(hass)
+
+ mock_api, mock_entry = await device.setup_entry(hass, mock_api=mock_api)
+
+ device_entry = device_registry.async_get_device(
+ {(DOMAIN, mock_entry.unique_id)}, set()
+ )
+ entries = async_entries_for_device(entity_registry, device_entry.id)
+ sensors = {entry for entry in entries if entry.domain == SENSOR_DOMAIN}
+ assert len(sensors) == 2
+
+ mock_api.check_sensors.return_value = {"temperature": 16.8, "humidity": 34.0}
+ await hass.helpers.entity_component.async_update_entity(
+ next(iter(sensors)).entity_id
+ )
+ assert mock_api.check_sensors.call_count == 2
+
+ sensors_and_states = {
+ (sensor.original_name, hass.states.get(sensor.entity_id).state)
+ for sensor in sensors
+ }
+ assert sensors_and_states == {
+ (f"{device.name} Temperature", "16.8"),
+ (f"{device.name} Humidity", "34.0"),
+ }
+
+
+async def test_rm4_pro_no_sensor(hass):
+ """Test we do not set up sensors for RM4 pro without HTS2 cable."""
+ device = get_device("Garage")
+ mock_api = device.get_mock_api()
+ mock_api.check_sensors.return_value = {"temperature": 0, "humidity": 0}
+
+ device_registry = mock_device_registry(hass)
+ entity_registry = mock_registry(hass)
+
+ mock_api, mock_entry = await device.setup_entry(hass, mock_api=mock_api)
+
+ assert mock_api.check_sensors.call_count <= 1
+ device_entry = device_registry.async_get_device(
+ {(DOMAIN, mock_entry.unique_id)}, set()
+ )
+ entries = async_entries_for_device(entity_registry, device_entry.id)
+ sensors = {entry for entry in entries if entry.domain == SENSOR_DOMAIN}
+ assert len(sensors) == 0
diff --git a/tests/components/brother/test_sensor.py b/tests/components/brother/test_sensor.py
index aeff5a3697d..c8dc91ebcf4 100644
--- a/tests/components/brother/test_sensor.py
+++ b/tests/components/brother/test_sensor.py
@@ -1,18 +1,19 @@
"""Test sensor of Brother integration."""
-from datetime import timedelta
+from datetime import datetime, timedelta
import json
from homeassistant.components.brother.const import UNIT_PAGES
from homeassistant.const import (
+ ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_ICON,
ATTR_UNIT_OF_MEASUREMENT,
+ DEVICE_CLASS_TIMESTAMP,
+ PERCENTAGE,
STATE_UNAVAILABLE,
- TIME_DAYS,
- UNIT_PERCENTAGE,
)
from homeassistant.setup import async_setup_component
-from homeassistant.util.dt import utcnow
+from homeassistant.util.dt import UTC, utcnow
from tests.async_mock import patch
from tests.common import async_fire_time_changed, load_fixture
@@ -24,7 +25,12 @@ ATTR_COUNTER = "counter"
async def test_sensors(hass):
"""Test states of the sensors."""
- await init_integration(hass)
+ test_time = datetime(2019, 11, 11, 9, 10, 32, tzinfo=UTC)
+ with patch(
+ "homeassistant.components.brother.sensor.utcnow", return_value=test_time
+ ):
+ await init_integration(hass)
+
registry = await hass.helpers.entity_registry.async_get_registry()
state = hass.states.get("sensor.hl_l2340dw_status")
@@ -39,7 +45,7 @@ async def test_sensors(hass):
state = hass.states.get("sensor.hl_l2340dw_black_toner_remaining")
assert state
assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d-nozzle"
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.state == "75"
entry = registry.async_get("sensor.hl_l2340dw_black_toner_remaining")
@@ -49,7 +55,7 @@ async def test_sensors(hass):
state = hass.states.get("sensor.hl_l2340dw_cyan_toner_remaining")
assert state
assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d-nozzle"
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.state == "10"
entry = registry.async_get("sensor.hl_l2340dw_cyan_toner_remaining")
@@ -59,7 +65,7 @@ async def test_sensors(hass):
state = hass.states.get("sensor.hl_l2340dw_magenta_toner_remaining")
assert state
assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d-nozzle"
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.state == "8"
entry = registry.async_get("sensor.hl_l2340dw_magenta_toner_remaining")
@@ -69,7 +75,7 @@ async def test_sensors(hass):
state = hass.states.get("sensor.hl_l2340dw_yellow_toner_remaining")
assert state
assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d-nozzle"
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.state == "2"
entry = registry.async_get("sensor.hl_l2340dw_yellow_toner_remaining")
@@ -81,7 +87,7 @@ async def test_sensors(hass):
assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut"
assert state.attributes.get(ATTR_REMAINING_PAGES) == 11014
assert state.attributes.get(ATTR_COUNTER) == 986
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.state == "92"
entry = registry.async_get("sensor.hl_l2340dw_drum_remaining_life")
@@ -93,7 +99,7 @@ async def test_sensors(hass):
assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut"
assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389
assert state.attributes.get(ATTR_COUNTER) == 1611
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.state == "92"
entry = registry.async_get("sensor.hl_l2340dw_black_drum_remaining_life")
@@ -105,7 +111,7 @@ async def test_sensors(hass):
assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut"
assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389
assert state.attributes.get(ATTR_COUNTER) == 1611
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.state == "92"
entry = registry.async_get("sensor.hl_l2340dw_cyan_drum_remaining_life")
@@ -117,7 +123,7 @@ async def test_sensors(hass):
assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut"
assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389
assert state.attributes.get(ATTR_COUNTER) == 1611
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.state == "92"
entry = registry.async_get("sensor.hl_l2340dw_magenta_drum_remaining_life")
@@ -129,7 +135,7 @@ async def test_sensors(hass):
assert state.attributes.get(ATTR_ICON) == "mdi:chart-donut"
assert state.attributes.get(ATTR_REMAINING_PAGES) == 16389
assert state.attributes.get(ATTR_COUNTER) == 1611
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.state == "92"
entry = registry.async_get("sensor.hl_l2340dw_yellow_drum_remaining_life")
@@ -139,7 +145,7 @@ async def test_sensors(hass):
state = hass.states.get("sensor.hl_l2340dw_fuser_remaining_life")
assert state
assert state.attributes.get(ATTR_ICON) == "mdi:water-outline"
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.state == "97"
entry = registry.async_get("sensor.hl_l2340dw_fuser_remaining_life")
@@ -149,7 +155,7 @@ async def test_sensors(hass):
state = hass.states.get("sensor.hl_l2340dw_belt_unit_remaining_life")
assert state
assert state.attributes.get(ATTR_ICON) == "mdi:current-ac"
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.state == "97"
entry = registry.async_get("sensor.hl_l2340dw_belt_unit_remaining_life")
@@ -159,7 +165,7 @@ async def test_sensors(hass):
state = hass.states.get("sensor.hl_l2340dw_pf_kit_1_remaining_life")
assert state
assert state.attributes.get(ATTR_ICON) == "mdi:printer-3d"
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.state == "98"
entry = registry.async_get("sensor.hl_l2340dw_pf_kit_1_remaining_life")
@@ -208,9 +214,10 @@ async def test_sensors(hass):
state = hass.states.get("sensor.hl_l2340dw_uptime")
assert state
- assert state.attributes.get(ATTR_ICON) == "mdi:timer-outline"
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TIME_DAYS
- assert state.state == "48"
+ assert state.attributes.get(ATTR_ICON) is None
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TIMESTAMP
+ assert state.state == "2019-09-24T12:14:56+00:00"
entry = registry.async_get("sensor.hl_l2340dw_uptime")
assert entry
diff --git a/tests/components/bsblan/__init__.py b/tests/components/bsblan/__init__.py
index 1541555de55..45b0f16c0a1 100644
--- a/tests/components/bsblan/__init__.py
+++ b/tests/components/bsblan/__init__.py
@@ -13,7 +13,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker
async def init_integration(
- hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, skip_setup: bool = False,
+ hass: HomeAssistant,
+ aioclient_mock: AiohttpClientMocker,
+ skip_setup: bool = False,
) -> MockConfigEntry:
"""Set up the BSBLan integration in Home Assistant."""
diff --git a/tests/components/bsblan/test_config_flow.py b/tests/components/bsblan/test_config_flow.py
index 48f71a8404f..c24fbc34f6a 100644
--- a/tests/components/bsblan/test_config_flow.py
+++ b/tests/components/bsblan/test_config_flow.py
@@ -17,7 +17,8 @@ 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},
+ config_flow.DOMAIN,
+ context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
@@ -70,7 +71,8 @@ async def test_full_user_flow_implementation(
)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": SOURCE_USER},
+ config_flow.DOMAIN,
+ context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py
index 8a79cb39ec9..c3db5067b1f 100644
--- a/tests/components/camera/test_init.py
+++ b/tests/components/camera/test_init.py
@@ -27,7 +27,8 @@ async def mock_camera_fixture(hass):
await hass.async_block_till_done()
with patch(
- "homeassistant.components.demo.camera.Path.read_bytes", return_value=b"Test",
+ "homeassistant.components.demo.camera.Path.read_bytes",
+ return_value=b"Test",
):
yield
@@ -258,7 +259,8 @@ async def test_play_stream_service_no_source(hass, mock_camera, mock_stream):
async def test_handle_play_stream_service(hass, mock_camera, mock_stream):
"""Test camera play_stream service."""
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com"},
+ hass,
+ {"external_url": "https://example.com"},
)
await async_setup_component(hass, "media_player", {})
with patch(
diff --git a/tests/components/canary/test_sensor.py b/tests/components/canary/test_sensor.py
index d7d16fa5f88..f328fb5a976 100644
--- a/tests/components/canary/test_sensor.py
+++ b/tests/components/canary/test_sensor.py
@@ -11,7 +11,7 @@ from homeassistant.components.canary.sensor import (
STATE_AIR_QUALITY_VERY_ABNORMAL,
CanarySensor,
)
-from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE, TEMP_CELSIUS
from tests.async_mock import Mock
from tests.common import get_test_home_assistant
@@ -95,7 +95,7 @@ class TestCanarySensorSetup(unittest.TestCase):
sensor.update()
assert sensor.name == "Home Family Room Humidity"
- assert sensor.unit_of_measurement == UNIT_PERCENTAGE
+ assert sensor.unit_of_measurement == PERCENTAGE
assert sensor.state == 50.46
assert sensor.icon == "mdi:water-percent"
@@ -182,7 +182,7 @@ class TestCanarySensorSetup(unittest.TestCase):
sensor.update()
assert sensor.name == "Home Family Room Battery"
- assert sensor.unit_of_measurement == UNIT_PERCENTAGE
+ assert sensor.unit_of_measurement == PERCENTAGE
assert sensor.state == 70.46
assert sensor.icon == "mdi:battery-70"
diff --git a/tests/components/cast/test_home_assistant_cast.py b/tests/components/cast/test_home_assistant_cast.py
index 8a3a935429f..2842ddc23f0 100644
--- a/tests/components/cast/test_home_assistant_cast.py
+++ b/tests/components/cast/test_home_assistant_cast.py
@@ -9,7 +9,8 @@ from tests.common import MockConfigEntry, async_mock_signal
async def test_service_show_view(hass):
"""Test we don't set app id in prod."""
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com"},
+ hass,
+ {"external_url": "https://example.com"},
)
await home_assistant_cast.async_setup_ha_cast(hass, MockConfigEntry())
calls = async_mock_signal(hass, home_assistant_cast.SIGNAL_HASS_CAST_SHOW_VIEW)
@@ -35,7 +36,8 @@ async def test_service_show_view(hass):
async def test_service_show_view_dashboard(hass):
"""Test casting a specific dashboard."""
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com"},
+ hass,
+ {"external_url": "https://example.com"},
)
await home_assistant_cast.async_setup_ha_cast(hass, MockConfigEntry())
calls = async_mock_signal(hass, home_assistant_cast.SIGNAL_HASS_CAST_SHOW_VIEW)
@@ -61,7 +63,8 @@ async def test_service_show_view_dashboard(hass):
async def test_use_cloud_url(hass):
"""Test that we fall back to cloud url."""
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
hass.config.components.add("cloud")
diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py
index 1338eb0f0cc..d3348241ec8 100644
--- a/tests/components/cast/test_media_player.py
+++ b/tests/components/cast/test_media_player.py
@@ -6,15 +6,17 @@ from uuid import UUID
import attr
import pytest
+from homeassistant.components import tts
from homeassistant.components.cast import media_player as cast
from homeassistant.components.cast.media_player import ChromecastInfo
+from homeassistant.config import async_process_ha_core_config
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.setup import async_setup_component
from tests.async_mock import AsyncMock, MagicMock, Mock, patch
-from tests.common import MockConfigEntry
+from tests.common import MockConfigEntry, assert_setup_component
@pytest.fixture(autouse=True)
@@ -440,6 +442,45 @@ async def test_entity_media_states(hass: HomeAssistantType):
assert state.state == "unknown"
+async def test_url_replace(hass: HomeAssistantType):
+ """Test functionality of replacing URL for HTTPS."""
+ info = get_fake_chromecast_info()
+ full_info = attr.evolve(
+ info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID
+ )
+
+ chromecast, entity = await async_setup_media_player_cast(hass, info)
+
+ entity._available = True
+ entity.schedule_update_ha_state()
+ await hass.async_block_till_done()
+
+ state = hass.states.get("media_player.speaker")
+ assert state is not None
+ assert state.name == "Speaker"
+ assert state.state == "unknown"
+ assert entity.unique_id == full_info.uuid
+
+ class FakeHTTPImage:
+ url = "http://example.com/test.png"
+
+ class FakeHTTPSImage:
+ url = "https://example.com/test.png"
+
+ media_status = MagicMock(images=[FakeHTTPImage()])
+ media_status.player_is_playing = True
+ entity.new_media_status(media_status)
+ await hass.async_block_till_done()
+ state = hass.states.get("media_player.speaker")
+ assert state.attributes.get("entity_picture") == "//example.com/test.png"
+
+ media_status.images = [FakeHTTPSImage()]
+ entity.new_media_status(media_status)
+ await hass.async_block_till_done()
+ state = hass.states.get("media_player.speaker")
+ assert state.attributes.get("entity_picture") == "https://example.com/test.png"
+
+
async def test_group_media_states(hass: HomeAssistantType):
"""Test media states are read from group if entity has no state."""
info = get_fake_chromecast_info()
@@ -538,6 +579,128 @@ async def test_group_media_control(hass: HomeAssistantType):
assert chromecast.media_controller.play_media.called
+async def test_failed_cast_on_idle(hass, caplog):
+ """Test no warning when unless player went idle with reason "ERROR"."""
+ info = get_fake_chromecast_info()
+ chromecast, entity = await async_setup_media_player_cast(hass, info)
+
+ media_status = MagicMock(images=None)
+ media_status.player_is_idle = False
+ media_status.idle_reason = "ERROR"
+ media_status.content_id = "http://example.com:8123/tts.mp3"
+ entity.new_media_status(media_status)
+ assert "Failed to cast media" not in caplog.text
+
+ media_status = MagicMock(images=None)
+ media_status.player_is_idle = True
+ media_status.idle_reason = "Other"
+ media_status.content_id = "http://example.com:8123/tts.mp3"
+ entity.new_media_status(media_status)
+ assert "Failed to cast media" not in caplog.text
+
+ media_status = MagicMock(images=None)
+ media_status.player_is_idle = True
+ media_status.idle_reason = "ERROR"
+ media_status.content_id = "http://example.com:8123/tts.mp3"
+ entity.new_media_status(media_status)
+ assert "Failed to cast media http://example.com:8123/tts.mp3." in caplog.text
+
+
+async def test_failed_cast_other_url(hass, caplog):
+ """Test warning when casting from internal_url fails."""
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(
+ hass,
+ tts.DOMAIN,
+ {tts.DOMAIN: {"platform": "demo", "base_url": "http://example.local:8123"}},
+ )
+
+ info = get_fake_chromecast_info()
+ chromecast, entity = await async_setup_media_player_cast(hass, info)
+
+ media_status = MagicMock(images=None)
+ media_status.player_is_idle = True
+ media_status.idle_reason = "ERROR"
+ media_status.content_id = "http://example.com:8123/tts.mp3"
+ entity.new_media_status(media_status)
+ assert "Failed to cast media http://example.com:8123/tts.mp3." in caplog.text
+
+
+async def test_failed_cast_internal_url(hass, caplog):
+ """Test warning when casting from internal_url fails."""
+ await async_process_ha_core_config(
+ hass,
+ {"internal_url": "http://example.local:8123"},
+ )
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(
+ hass, tts.DOMAIN, {tts.DOMAIN: {"platform": "demo"}}
+ )
+
+ info = get_fake_chromecast_info()
+ chromecast, entity = await async_setup_media_player_cast(hass, info)
+
+ media_status = MagicMock(images=None)
+ media_status.player_is_idle = True
+ media_status.idle_reason = "ERROR"
+ media_status.content_id = "http://example.local:8123/tts.mp3"
+ entity.new_media_status(media_status)
+ assert (
+ "Failed to cast media http://example.local:8123/tts.mp3 from internal_url"
+ in caplog.text
+ )
+
+
+async def test_failed_cast_external_url(hass, caplog):
+ """Test warning when casting from external_url fails."""
+ await async_process_ha_core_config(
+ hass,
+ {"external_url": "http://example.com:8123"},
+ )
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(
+ hass,
+ tts.DOMAIN,
+ {tts.DOMAIN: {"platform": "demo", "base_url": "http://example.com:8123"}},
+ )
+
+ info = get_fake_chromecast_info()
+ chromecast, entity = await async_setup_media_player_cast(hass, info)
+
+ media_status = MagicMock(images=None)
+ media_status.player_is_idle = True
+ media_status.idle_reason = "ERROR"
+ media_status.content_id = "http://example.com:8123/tts.mp3"
+ entity.new_media_status(media_status)
+ assert (
+ "Failed to cast media http://example.com:8123/tts.mp3 from external_url"
+ in caplog.text
+ )
+
+
+async def test_failed_cast_tts_base_url(hass, caplog):
+ """Test warning when casting from tts.base_url fails."""
+ with assert_setup_component(1, tts.DOMAIN):
+ assert await async_setup_component(
+ hass,
+ tts.DOMAIN,
+ {tts.DOMAIN: {"platform": "demo", "base_url": "http://example.local:8123"}},
+ )
+
+ info = get_fake_chromecast_info()
+ chromecast, entity = await async_setup_media_player_cast(hass, info)
+
+ media_status = MagicMock(images=None)
+ media_status.player_is_idle = True
+ media_status.idle_reason = "ERROR"
+ media_status.content_id = "http://example.local:8123/tts.mp3"
+ entity.new_media_status(media_status)
+ assert (
+ "Failed to cast media http://example.local:8123/tts.mp3 from tts.base_url"
+ in caplog.text
+ )
+
+
async def test_disconnect_on_stop(hass: HomeAssistantType):
"""Test cast device disconnects socket on stop."""
info = get_fake_chromecast_info()
diff --git a/tests/components/cloud/test_alexa_config.py b/tests/components/cloud/test_alexa_config.py
index b064a5c9605..e54a5dcde01 100644
--- a/tests/components/cloud/test_alexa_config.py
+++ b/tests/components/cloud/test_alexa_config.py
@@ -12,13 +12,24 @@ from tests.common import async_fire_time_changed
async def test_alexa_config_expose_entity_prefs(hass, cloud_prefs):
"""Test Alexa config should expose using prefs."""
entity_conf = {"should_expose": False}
- await cloud_prefs.async_update(alexa_entity_configs={"light.kitchen": entity_conf})
+ await cloud_prefs.async_update(
+ alexa_entity_configs={"light.kitchen": entity_conf},
+ alexa_default_expose=["light"],
+ )
conf = alexa_config.AlexaConfig(hass, ALEXA_SCHEMA({}), cloud_prefs, None)
assert not conf.should_expose("light.kitchen")
entity_conf["should_expose"] = True
assert conf.should_expose("light.kitchen")
+ entity_conf["should_expose"] = None
+ assert conf.should_expose("light.kitchen")
+
+ await cloud_prefs.async_update(
+ alexa_default_expose=["sensor"],
+ )
+ assert not conf.should_expose("light.kitchen")
+
async def test_alexa_config_report_state(hass, cloud_prefs):
"""Test Alexa config should expose using prefs."""
diff --git a/tests/components/cloud/test_google_config.py b/tests/components/cloud/test_google_config.py
index 5dd4afe883c..78207605830 100644
--- a/tests/components/cloud/test_google_config.py
+++ b/tests/components/cloud/test_google_config.py
@@ -1,9 +1,11 @@
"""Test the Cloud Google Config."""
+import pytest
+
from homeassistant.components.cloud import GACTIONS_SCHEMA
from homeassistant.components.cloud.google_config import CloudGoogleConfig
from homeassistant.components.google_assistant import helpers as ga_helpers
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, HTTP_NOT_FOUND
-from homeassistant.core import CoreState
+from homeassistant.core import CoreState, State
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
from homeassistant.util.dt import utcnow
@@ -11,19 +13,24 @@ from tests.async_mock import AsyncMock, Mock, patch
from tests.common import async_fire_time_changed
-async def test_google_update_report_state(hass, cloud_prefs):
- """Test Google config responds to updating preference."""
- config = CloudGoogleConfig(
+@pytest.fixture
+def mock_conf(hass, cloud_prefs):
+ """Mock Google conf."""
+ return CloudGoogleConfig(
hass,
GACTIONS_SCHEMA({}),
"mock-user-id",
cloud_prefs,
Mock(claims={"cognito:username": "abcdefghjkl"}),
)
- await config.async_initialize()
- await config.async_connect_agent_user("mock-user-id")
- with patch.object(config, "async_sync_entities") as mock_sync, patch(
+
+async def test_google_update_report_state(mock_conf, hass, cloud_prefs):
+ """Test Google config responds to updating preference."""
+ await mock_conf.async_initialize()
+ await mock_conf.async_connect_agent_user("mock-user-id")
+
+ with patch.object(mock_conf, "async_sync_entities") as mock_sync, patch(
"homeassistant.components.google_assistant.report_state.async_enable_report_state"
) as mock_report_state:
await cloud_prefs.async_update(google_report_state=True)
@@ -161,3 +168,26 @@ async def test_google_entity_registry_sync(hass, mock_cloud_login, cloud_prefs):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert len(mock_sync.mock_calls) == 1
+
+
+async def test_google_config_expose_entity_prefs(mock_conf, cloud_prefs):
+ """Test Google config should expose using prefs."""
+ entity_conf = {"should_expose": False}
+ await cloud_prefs.async_update(
+ google_entity_configs={"light.kitchen": entity_conf},
+ google_default_expose=["light"],
+ )
+
+ state = State("light.kitchen", "on")
+
+ assert not mock_conf.should_expose(state)
+ entity_conf["should_expose"] = True
+ assert mock_conf.should_expose(state)
+
+ entity_conf["should_expose"] = None
+ assert mock_conf.should_expose(state)
+
+ await cloud_prefs.async_update(
+ google_default_expose=["sensor"],
+ )
+ assert not mock_conf.should_expose(state)
diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py
index 13c4649f39e..a3c33b31ebb 100644
--- a/tests/components/cloud/test_http_api.py
+++ b/tests/components/cloud/test_http_api.py
@@ -355,6 +355,8 @@ async def test_websocket_status(
"google_enabled": True,
"google_entity_configs": {},
"google_secure_devices_pin": None,
+ "google_default_expose": None,
+ "alexa_default_expose": None,
"alexa_entity_configs": {},
"alexa_report_state": False,
"google_report_state": False,
@@ -462,7 +464,8 @@ async def test_websocket_subscription_not_logged_in(hass, hass_ws_client):
"""Test querying the status."""
client = await hass_ws_client(hass)
with patch(
- "hass_nabucasa.Cloud.fetch_subscription_info", return_value={"return": "value"},
+ "hass_nabucasa.Cloud.fetch_subscription_info",
+ return_value={"return": "value"},
):
await client.send_json({"id": 5, "type": "cloud/subscription"})
response = await client.receive_json()
@@ -486,6 +489,8 @@ async def test_websocket_update_preferences(
"alexa_enabled": False,
"google_enabled": False,
"google_secure_devices_pin": "1234",
+ "google_default_expose": ["light", "switch"],
+ "alexa_default_expose": ["sensor", "media_player"],
}
)
response = await client.receive_json()
@@ -494,6 +499,8 @@ async def test_websocket_update_preferences(
assert not setup_api.google_enabled
assert not setup_api.alexa_enabled
assert setup_api.google_secure_devices_pin == "1234"
+ assert setup_api.google_default_expose == ["light", "switch"]
+ assert setup_api.alexa_default_expose == ["sensor", "media_player"]
async def test_websocket_update_preferences_require_relink(
@@ -745,6 +752,25 @@ async def test_update_google_entity(hass, hass_ws_client, setup_api, mock_cloud_
"disable_2fa": False,
}
+ await client.send_json(
+ {
+ "id": 6,
+ "type": "cloud/google_assistant/entities/update",
+ "entity_id": "light.kitchen",
+ "should_expose": None,
+ }
+ )
+ response = await client.receive_json()
+
+ assert response["success"]
+ prefs = hass.data[DOMAIN].client.prefs
+ assert prefs.google_entity_configs["light.kitchen"] == {
+ "should_expose": None,
+ "override_name": "updated name",
+ "aliases": ["lefty", "righty"],
+ "disable_2fa": False,
+ }
+
async def test_enabling_remote_trusted_proxies_local4(
hass, hass_ws_client, setup_api, mock_cloud_login
@@ -833,6 +859,20 @@ async def test_update_alexa_entity(hass, hass_ws_client, setup_api, mock_cloud_l
prefs = hass.data[DOMAIN].client.prefs
assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": False}
+ await client.send_json(
+ {
+ "id": 6,
+ "type": "cloud/alexa/entities/update",
+ "entity_id": "light.kitchen",
+ "should_expose": None,
+ }
+ )
+ response = await client.receive_json()
+
+ assert response["success"]
+ prefs = hass.data[DOMAIN].client.prefs
+ assert prefs.alexa_entity_configs["light.kitchen"] == {"should_expose": None}
+
async def test_sync_alexa_entities_timeout(
hass, hass_ws_client, setup_api, mock_cloud_login
diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py
index cc91e521d68..508b1e1fb41 100644
--- a/tests/components/command_line/test_cover.py
+++ b/tests/components/command_line/test_cover.py
@@ -1,20 +1,25 @@
"""The tests the cover command line platform."""
import os
+from os import path
import tempfile
from unittest import mock
import pytest
+from homeassistant import config as hass_config
import homeassistant.components.command_line.cover as cmd_rs
from homeassistant.components.cover import DOMAIN
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_CLOSE_COVER,
SERVICE_OPEN_COVER,
+ SERVICE_RELOAD,
SERVICE_STOP_COVER,
)
from homeassistant.setup import async_setup_component
+from tests.async_mock import patch
+
@pytest.fixture
def rs(hass):
@@ -87,3 +92,44 @@ async def test_state_value(hass):
DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: "cover.test"}, blocking=True
)
assert "closed" == hass.states.get("cover.test").state
+
+
+async def test_reload(hass):
+ """Verify we can reload command_line covers."""
+
+ test_cover = {
+ "command_state": "echo open",
+ "value_template": "{{ value }}",
+ }
+ await async_setup_component(
+ hass,
+ DOMAIN,
+ {"cover": {"platform": "command_line", "covers": {"test": test_cover}}},
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+ assert hass.states.get("cover.test").state
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "command_line/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ "command_line",
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+ assert hass.states.get("cover.test") is None
+ assert hass.states.get("cover.from_yaml")
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py
index 623269b9c16..0a6e29ca00c 100644
--- a/tests/components/command_line/test_sensor.py
+++ b/tests/components/command_line/test_sensor.py
@@ -78,7 +78,9 @@ class TestCommandSensorSensor(unittest.TestCase):
return_value=b"Works\n",
) as check_output:
data = command_line.CommandSensorData(
- self.hass, 'echo "{{ states.sensor.test_state.state }}" "3 4"', 15,
+ self.hass,
+ 'echo "{{ states.sensor.test_state.state }}" "3 4"',
+ 15,
)
data.update()
diff --git a/tests/components/config/test_auth_provider_homeassistant.py b/tests/components/config/test_auth_provider_homeassistant.py
index 0a2f195e333..19568ff450b 100644
--- a/tests/components/config/test_auth_provider_homeassistant.py
+++ b/tests/components/config/test_auth_provider_homeassistant.py
@@ -4,7 +4,7 @@ import pytest
from homeassistant.auth.providers import homeassistant as prov_ha
from homeassistant.components.config import auth_provider_homeassistant as auth_ha
-from tests.common import MockUser, register_auth_provider
+from tests.common import CLIENT_ID, MockUser, register_auth_provider
@pytest.fixture(autouse=True)
@@ -16,17 +16,44 @@ def setup_config(hass):
hass.loop.run_until_complete(auth_ha.async_setup(hass))
-async def test_create_auth_system_generated_user(
- hass, hass_access_token, hass_ws_client
-):
+@pytest.fixture
+async def auth_provider(hass):
+ """Hass auth provider."""
+ provider = hass.auth.auth_providers[0]
+ await provider.async_initialize()
+ return provider
+
+
+@pytest.fixture
+async def owner_access_token(hass, hass_owner_user):
+ """Access token for owner user."""
+ refresh_token = await hass.auth.async_create_refresh_token(
+ hass_owner_user, CLIENT_ID
+ )
+ return hass.auth.async_create_access_token(refresh_token)
+
+
+@pytest.fixture
+async def test_user_credential(hass, auth_provider):
+ """Add a test user."""
+ await hass.async_add_executor_job(
+ auth_provider.data.add_auth, "test-user", "test-pass"
+ )
+
+ return await auth_provider.async_get_or_create_credentials(
+ {"username": "test-user"}
+ )
+
+
+async def test_create_auth_system_generated_user(hass, hass_ws_client):
"""Test we can't add auth to system generated users."""
system_user = MockUser(system_generated=True).add_to_hass(hass)
- client = await hass_ws_client(hass, hass_access_token)
+ client = await hass_ws_client(hass)
await client.send_json(
{
"id": 5,
- "type": auth_ha.WS_TYPE_CREATE,
+ "type": "config/auth_provider/homeassistant/create",
"user_id": system_user.id,
"username": "test-user",
"password": "test-pass",
@@ -44,14 +71,14 @@ async def test_create_auth_user_already_credentials():
# assert False
-async def test_create_auth_unknown_user(hass_ws_client, hass, hass_access_token):
+async def test_create_auth_unknown_user(hass_ws_client, hass):
"""Test create pointing at unknown user."""
- client = await hass_ws_client(hass, hass_access_token)
+ client = await hass_ws_client(hass)
await client.send_json(
{
"id": 5,
- "type": auth_ha.WS_TYPE_CREATE,
+ "type": "config/auth_provider/homeassistant/create",
"user_id": "test-id",
"username": "test-user",
"password": "test-pass",
@@ -73,7 +100,7 @@ async def test_create_auth_requires_admin(
await client.send_json(
{
"id": 5,
- "type": auth_ha.WS_TYPE_CREATE,
+ "type": "config/auth_provider/homeassistant/create",
"user_id": "test-id",
"username": "test-user",
"password": "test-pass",
@@ -85,9 +112,9 @@ async def test_create_auth_requires_admin(
assert result["error"]["code"] == "unauthorized"
-async def test_create_auth(hass, hass_ws_client, hass_access_token, hass_storage):
+async def test_create_auth(hass, hass_ws_client, hass_storage):
"""Test create auth command works."""
- client = await hass_ws_client(hass, hass_access_token)
+ client = await hass_ws_client(hass)
user = MockUser().add_to_hass(hass)
assert len(user.credentials) == 0
@@ -95,7 +122,7 @@ async def test_create_auth(hass, hass_ws_client, hass_access_token, hass_storage
await client.send_json(
{
"id": 5,
- "type": auth_ha.WS_TYPE_CREATE,
+ "type": "config/auth_provider/homeassistant/create",
"user_id": user.id,
"username": "test-user",
"password": "test-pass",
@@ -114,11 +141,9 @@ async def test_create_auth(hass, hass_ws_client, hass_access_token, hass_storage
assert entry["username"] == "test-user"
-async def test_create_auth_duplicate_username(
- hass, hass_ws_client, hass_access_token, hass_storage
-):
+async def test_create_auth_duplicate_username(hass, hass_ws_client, hass_storage):
"""Test we can't create auth with a duplicate username."""
- client = await hass_ws_client(hass, hass_access_token)
+ client = await hass_ws_client(hass)
user = MockUser().add_to_hass(hass)
hass_storage[prov_ha.STORAGE_KEY] = {
@@ -129,7 +154,7 @@ async def test_create_auth_duplicate_username(
await client.send_json(
{
"id": 5,
- "type": auth_ha.WS_TYPE_CREATE,
+ "type": "config/auth_provider/homeassistant/create",
"user_id": user.id,
"username": "test-user",
"password": "test-pass",
@@ -141,11 +166,9 @@ async def test_create_auth_duplicate_username(
assert result["error"]["code"] == "username_exists"
-async def test_delete_removes_just_auth(
- hass_ws_client, hass, hass_storage, hass_access_token
-):
+async def test_delete_removes_just_auth(hass_ws_client, hass, hass_storage):
"""Test deleting an auth without being connected to a user."""
- client = await hass_ws_client(hass, hass_access_token)
+ client = await hass_ws_client(hass)
hass_storage[prov_ha.STORAGE_KEY] = {
"version": 1,
@@ -153,7 +176,11 @@ async def test_delete_removes_just_auth(
}
await client.send_json(
- {"id": 5, "type": auth_ha.WS_TYPE_DELETE, "username": "test-user"}
+ {
+ "id": 5,
+ "type": "config/auth_provider/homeassistant/delete",
+ "username": "test-user",
+ }
)
result = await client.receive_json()
@@ -161,11 +188,9 @@ async def test_delete_removes_just_auth(
assert len(hass_storage[prov_ha.STORAGE_KEY]["data"]["users"]) == 0
-async def test_delete_removes_credential(
- hass, hass_ws_client, hass_access_token, hass_storage
-):
+async def test_delete_removes_credential(hass, hass_ws_client, hass_storage):
"""Test deleting auth that is connected to a user."""
- client = await hass_ws_client(hass, hass_access_token)
+ client = await hass_ws_client(hass)
user = MockUser().add_to_hass(hass)
hass_storage[prov_ha.STORAGE_KEY] = {
@@ -180,7 +205,11 @@ async def test_delete_removes_credential(
)
await client.send_json(
- {"id": 5, "type": auth_ha.WS_TYPE_DELETE, "username": "test-user"}
+ {
+ "id": 5,
+ "type": "config/auth_provider/homeassistant/delete",
+ "username": "test-user",
+ }
)
result = await client.receive_json()
@@ -193,7 +222,11 @@ async def test_delete_requires_admin(hass, hass_ws_client, hass_read_only_access
client = await hass_ws_client(hass, hass_read_only_access_token)
await client.send_json(
- {"id": 5, "type": auth_ha.WS_TYPE_DELETE, "username": "test-user"}
+ {
+ "id": 5,
+ "type": "config/auth_provider/homeassistant/delete",
+ "username": "test-user",
+ }
)
result = await client.receive_json()
@@ -201,12 +234,16 @@ async def test_delete_requires_admin(hass, hass_ws_client, hass_read_only_access
assert result["error"]["code"] == "unauthorized"
-async def test_delete_unknown_auth(hass, hass_ws_client, hass_access_token):
+async def test_delete_unknown_auth(hass, hass_ws_client):
"""Test trying to delete an unknown auth username."""
- client = await hass_ws_client(hass, hass_access_token)
+ client = await hass_ws_client(hass)
await client.send_json(
- {"id": 5, "type": auth_ha.WS_TYPE_DELETE, "username": "test-user"}
+ {
+ "id": 5,
+ "type": "config/auth_provider/homeassistant/delete",
+ "username": "test-user",
+ }
)
result = await client.receive_json()
@@ -214,25 +251,17 @@ async def test_delete_unknown_auth(hass, hass_ws_client, hass_access_token):
assert result["error"]["code"] == "auth_not_found"
-async def test_change_password(hass, hass_ws_client, hass_access_token):
+async def test_change_password(
+ hass, hass_ws_client, hass_admin_user, auth_provider, test_user_credential
+):
"""Test that change password succeeds with valid password."""
- provider = hass.auth.auth_providers[0]
- await provider.async_initialize()
- await hass.async_add_executor_job(provider.data.add_auth, "test-user", "test-pass")
+ await hass.auth.async_link_user(hass_admin_user, test_user_credential)
- credentials = await provider.async_get_or_create_credentials(
- {"username": "test-user"}
- )
-
- refresh_token = await hass.auth.async_validate_access_token(hass_access_token)
- user = refresh_token.user
- await hass.auth.async_link_user(user, credentials)
-
- client = await hass_ws_client(hass, hass_access_token)
+ client = await hass_ws_client(hass)
await client.send_json(
{
"id": 6,
- "type": auth_ha.WS_TYPE_CHANGE_PASSWORD,
+ "type": "config/auth_provider/homeassistant/change_password",
"current_password": "test-pass",
"new_password": "new-pass",
}
@@ -240,28 +269,20 @@ async def test_change_password(hass, hass_ws_client, hass_access_token):
result = await client.receive_json()
assert result["success"], result
- await provider.async_validate_login("test-user", "new-pass")
+ await auth_provider.async_validate_login("test-user", "new-pass")
-async def test_change_password_wrong_pw(hass, hass_ws_client, hass_access_token):
+async def test_change_password_wrong_pw(
+ hass, hass_ws_client, hass_admin_user, auth_provider, test_user_credential
+):
"""Test that change password fails with invalid password."""
- provider = hass.auth.auth_providers[0]
- await provider.async_initialize()
- await hass.async_add_executor_job(provider.data.add_auth, "test-user", "test-pass")
+ await hass.auth.async_link_user(hass_admin_user, test_user_credential)
- credentials = await provider.async_get_or_create_credentials(
- {"username": "test-user"}
- )
-
- refresh_token = await hass.auth.async_validate_access_token(hass_access_token)
- user = refresh_token.user
- await hass.auth.async_link_user(user, credentials)
-
- client = await hass_ws_client(hass, hass_access_token)
+ client = await hass_ws_client(hass)
await client.send_json(
{
"id": 6,
- "type": auth_ha.WS_TYPE_CHANGE_PASSWORD,
+ "type": "config/auth_provider/homeassistant/change_password",
"current_password": "wrong-pass",
"new_password": "new-pass",
}
@@ -271,17 +292,17 @@ async def test_change_password_wrong_pw(hass, hass_ws_client, hass_access_token)
assert not result["success"], result
assert result["error"]["code"] == "invalid_password"
with pytest.raises(prov_ha.InvalidAuth):
- await provider.async_validate_login("test-user", "new-pass")
+ await auth_provider.async_validate_login("test-user", "new-pass")
-async def test_change_password_no_creds(hass, hass_ws_client, hass_access_token):
+async def test_change_password_no_creds(hass, hass_ws_client):
"""Test that change password fails with no credentials."""
- client = await hass_ws_client(hass, hass_access_token)
+ client = await hass_ws_client(hass)
await client.send_json(
{
"id": 6,
- "type": auth_ha.WS_TYPE_CHANGE_PASSWORD,
+ "type": "config/auth_provider/homeassistant/change_password",
"current_password": "test-pass",
"new_password": "new-pass",
}
@@ -290,3 +311,92 @@ async def test_change_password_no_creds(hass, hass_ws_client, hass_access_token)
result = await client.receive_json()
assert not result["success"], result
assert result["error"]["code"] == "credentials_not_found"
+
+
+async def test_admin_change_password_not_owner(
+ hass, hass_ws_client, auth_provider, test_user_credential
+):
+ """Test that change password fails when not owner."""
+ client = await hass_ws_client(hass)
+
+ await client.send_json(
+ {
+ "id": 6,
+ "type": "config/auth_provider/homeassistant/admin_change_password",
+ "user_id": "test-user",
+ "password": "new-pass",
+ }
+ )
+
+ result = await client.receive_json()
+ assert not result["success"], result
+ assert result["error"]["code"] == "unauthorized"
+
+ # Validate old login still works
+ await auth_provider.async_validate_login("test-user", "test-pass")
+
+
+async def test_admin_change_password_no_user(hass, hass_ws_client, owner_access_token):
+ """Test that change password fails with unknown user."""
+ client = await hass_ws_client(hass, owner_access_token)
+
+ await client.send_json(
+ {
+ "id": 6,
+ "type": "config/auth_provider/homeassistant/admin_change_password",
+ "user_id": "non-existing",
+ "password": "new-pass",
+ }
+ )
+
+ result = await client.receive_json()
+ assert not result["success"], result
+ assert result["error"]["code"] == "user_not_found"
+
+
+async def test_admin_change_password_no_cred(
+ hass, hass_ws_client, owner_access_token, hass_admin_user
+):
+ """Test that change password fails with unknown credential."""
+ client = await hass_ws_client(hass, owner_access_token)
+
+ await client.send_json(
+ {
+ "id": 6,
+ "type": "config/auth_provider/homeassistant/admin_change_password",
+ "user_id": hass_admin_user.id,
+ "password": "new-pass",
+ }
+ )
+
+ result = await client.receive_json()
+ assert not result["success"], result
+ assert result["error"]["code"] == "credentials_not_found"
+
+
+async def test_admin_change_password(
+ hass,
+ hass_ws_client,
+ owner_access_token,
+ auth_provider,
+ test_user_credential,
+ hass_admin_user,
+):
+ """Test that owners can change any password."""
+ await hass.auth.async_link_user(hass_admin_user, test_user_credential)
+
+ client = await hass_ws_client(hass, owner_access_token)
+
+ await client.send_json(
+ {
+ "id": 6,
+ "type": "config/auth_provider/homeassistant/admin_change_password",
+ "user_id": hass_admin_user.id,
+ "password": "new-pass",
+ }
+ )
+
+ result = await client.receive_json()
+ assert result["success"], result
+
+ await auth_provider.async_validate_login("test-user", "new-pass")
diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py
index e5da27818fc..11299f4108b 100644
--- a/tests/components/config/test_config_entries.py
+++ b/tests/components/config/test_config_entries.py
@@ -53,12 +53,14 @@ async def test_get_entries(hass, client):
"comp2", "Comp 2", lambda: None, core_ce.CONN_CLASS_ASSUMED
)
- MockConfigEntry(
+ entry = MockConfigEntry(
domain="comp1",
title="Test 1",
source="bla",
connection_class=core_ce.CONN_CLASS_LOCAL_POLL,
- ).add_to_hass(hass)
+ )
+ entry.supports_unload = True
+ entry.add_to_hass(hass)
MockConfigEntry(
domain="comp2",
title="Test 2",
@@ -80,6 +82,7 @@ async def test_get_entries(hass, client):
"state": "not_loaded",
"connection_class": "local_poll",
"supports_options": True,
+ "supports_unload": True,
},
{
"domain": "comp2",
@@ -88,6 +91,7 @@ async def test_get_entries(hass, client):
"state": "loaded",
"connection_class": "assumed",
"supports_options": False,
+ "supports_unload": False,
},
]
@@ -103,6 +107,25 @@ async def test_remove_entry(hass, client):
assert len(hass.config_entries.async_entries()) == 0
+async def test_reload_entry(hass, client):
+ """Test reloading an entry via the API."""
+ entry = MockConfigEntry(domain="demo", state=core_ce.ENTRY_STATE_LOADED)
+ entry.add_to_hass(hass)
+ resp = await client.post(
+ f"/api/config/config_entries/entry/{entry.entry_id}/reload"
+ )
+ assert resp.status == 200
+ data = await resp.json()
+ assert data == {"require_restart": True}
+ assert len(hass.config_entries.async_entries()) == 1
+
+
+async def test_reload_invalid_entry(hass, client):
+ """Test reloading an invalid entry via the API."""
+ resp = await client.post("/api/config/config_entries/entry/invalid/reload")
+ assert resp.status == 404
+
+
async def test_remove_entry_unauth(hass, client, hass_admin_user):
"""Test removing an entry via the API."""
hass_admin_user.groups = []
@@ -113,6 +136,29 @@ async def test_remove_entry_unauth(hass, client, hass_admin_user):
assert len(hass.config_entries.async_entries()) == 1
+async def test_reload_entry_unauth(hass, client, hass_admin_user):
+ """Test reloading an entry via the API."""
+ hass_admin_user.groups = []
+ entry = MockConfigEntry(domain="demo", state=core_ce.ENTRY_STATE_LOADED)
+ entry.add_to_hass(hass)
+ resp = await client.post(
+ f"/api/config/config_entries/entry/{entry.entry_id}/reload"
+ )
+ assert resp.status == 401
+ assert len(hass.config_entries.async_entries()) == 1
+
+
+async def test_reload_entry_in_failed_state(hass, client, hass_admin_user):
+ """Test reloading an entry via the API that has already failed to unload."""
+ entry = MockConfigEntry(domain="demo", state=core_ce.ENTRY_STATE_FAILED_UNLOAD)
+ entry.add_to_hass(hass)
+ resp = await client.post(
+ f"/api/config/config_entries/entry/{entry.entry_id}/reload"
+ )
+ assert resp.status == 403
+ assert len(hass.config_entries.async_entries()) == 1
+
+
async def test_available_flows(hass, client):
"""Test querying the available flows."""
with patch.object(config_flows, "FLOWS", ["hello", "world"]):
@@ -360,7 +406,8 @@ async def test_continue_flow_unauth(hass, client, hass_admin_user):
hass_admin_user.groups = []
resp = await client.post(
- f"/api/config/config_entries/flow/{flow_id}", json={"user_title": "user-title"},
+ f"/api/config/config_entries/flow/{flow_id}",
+ json={"user_title": "user-title"},
)
assert resp.status == 401
diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py
index 6a7447d9409..1f379727b44 100644
--- a/tests/components/config/test_core.py
+++ b/tests/components/config/test_core.py
@@ -125,7 +125,8 @@ async def test_websocket_bad_core_update(hass, client):
async def test_detect_config(hass, client):
"""Test detect config."""
with patch(
- "homeassistant.util.location.async_detect_location_info", return_value=None,
+ "homeassistant.util.location.async_detect_location_info",
+ return_value=None,
):
await client.send_json({"id": 1, "type": "config/core/detect"})
diff --git a/tests/components/config/test_customize.py b/tests/components/config/test_customize.py
index 30a475dab77..ca3bcc98c7d 100644
--- a/tests/components/config/test_customize.py
+++ b/tests/components/config/test_customize.py
@@ -55,7 +55,8 @@ async def test_update_entity(hass, hass_client):
with patch("homeassistant.components.config._read", mock_read), patch(
"homeassistant.components.config._write", mock_write
), patch(
- "homeassistant.config.async_hass_config_yaml", return_value={},
+ "homeassistant.config.async_hass_config_yaml",
+ return_value={},
):
resp = await client.post(
"/api/config/customize/config/hello.world",
diff --git a/tests/components/conftest.py b/tests/components/conftest.py
index 96ab3bca543..acfbdeb8629 100644
--- a/tests/components/conftest.py
+++ b/tests/components/conftest.py
@@ -1,13 +1,21 @@
"""Fixtures for component testing."""
import pytest
+from homeassistant.components import zeroconf
+
from tests.async_mock import patch
+zeroconf.orig_install_multiple_zeroconf_catcher = (
+ zeroconf.install_multiple_zeroconf_catcher
+)
+zeroconf.install_multiple_zeroconf_catcher = lambda zc: None
+
@pytest.fixture(autouse=True)
def prevent_io():
"""Fixture to prevent certain I/O from happening."""
with patch(
- "homeassistant.components.http.ban.async_load_ip_bans_config", return_value=[],
+ "homeassistant.components.http.ban.async_load_ip_bans_config",
+ return_value=[],
):
yield
diff --git a/tests/components/control4/test_config_flow.py b/tests/components/control4/test_config_flow.py
index 6d3293b147a..f87c5af3484 100644
--- a/tests/components/control4/test_config_flow.py
+++ b/tests/components/control4/test_config_flow.py
@@ -64,7 +64,8 @@ async def test_form(hass):
), patch(
"homeassistant.components.control4.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.control4.async_setup_entry", return_value=True,
+ "homeassistant.components.control4.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -171,7 +172,8 @@ async def test_option_flow(hass):
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={CONF_SCAN_INTERVAL: 4},
+ result["flow_id"],
+ user_input={CONF_SCAN_INTERVAL: 4},
)
assert result["type"] == "create_entry"
assert result["data"] == {
diff --git a/tests/components/coolmaster/test_config_flow.py b/tests/components/coolmaster/test_config_flow.py
index 49058fc183e..fc296a53ddf 100644
--- a/tests/components/coolmaster/test_config_flow.py
+++ b/tests/components/coolmaster/test_config_flow.py
@@ -1,5 +1,5 @@
"""Test the Coolmaster config flow."""
-from homeassistant import config_entries, setup
+from homeassistant import config_entries
from homeassistant.components.coolmaster.const import AVAILABLE_MODES, DOMAIN
from tests.async_mock import patch
@@ -14,7 +14,6 @@ def _flow_data():
async def test_form(hass):
"""Test we get the form."""
- await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@@ -22,12 +21,13 @@ async def test_form(hass):
assert result["errors"] is None
with patch(
- "homeassistant.components.coolmaster.config_flow.CoolMasterNet.devices",
- return_value=[1],
+ "homeassistant.components.coolmaster.config_flow.CoolMasterNet.status",
+ return_value={"test_id": "test_unit"},
), patch(
"homeassistant.components.coolmaster.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.coolmaster.async_setup_entry", return_value=True,
+ "homeassistant.components.coolmaster.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], _flow_data()
@@ -52,7 +52,7 @@ async def test_form_timeout(hass):
)
with patch(
- "homeassistant.components.coolmaster.config_flow.CoolMasterNet.devices",
+ "homeassistant.components.coolmaster.config_flow.CoolMasterNet.status",
side_effect=TimeoutError(),
):
result2 = await hass.config_entries.flow.async_configure(
@@ -70,7 +70,7 @@ async def test_form_connection_refused(hass):
)
with patch(
- "homeassistant.components.coolmaster.config_flow.CoolMasterNet.devices",
+ "homeassistant.components.coolmaster.config_flow.CoolMasterNet.status",
side_effect=ConnectionRefusedError(),
):
result2 = await hass.config_entries.flow.async_configure(
@@ -88,8 +88,8 @@ async def test_form_no_units(hass):
)
with patch(
- "homeassistant.components.coolmaster.config_flow.CoolMasterNet.devices",
- return_value=[],
+ "homeassistant.components.coolmaster.config_flow.CoolMasterNet.status",
+ return_value={},
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], _flow_data()
diff --git a/tests/components/coronavirus/test_config_flow.py b/tests/components/coronavirus/test_config_flow.py
index b7af2e343b2..06d586ba2a5 100644
--- a/tests/components/coronavirus/test_config_flow.py
+++ b/tests/components/coronavirus/test_config_flow.py
@@ -13,7 +13,8 @@ async def test_form(hass):
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"country": OPTION_WORLDWIDE},
+ result["flow_id"],
+ {"country": OPTION_WORLDWIDE},
)
assert result2["type"] == "create_entry"
assert result2["title"] == "Worldwide"
diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py
index c315bcc32a8..324c73dfb81 100644
--- a/tests/components/daikin/test_config_flow.py
+++ b/tests/components/daikin/test_config_flow.py
@@ -54,14 +54,17 @@ def mock_daikin_discovery():
async def test_user(hass, mock_daikin):
"""Test user config."""
result = await hass.config_entries.flow.async_init(
- "daikin", context={"source": SOURCE_USER},
+ "daikin",
+ context={"source": SOURCE_USER},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_init(
- "daikin", context={"source": SOURCE_USER}, data={CONF_HOST: HOST},
+ "daikin",
+ context={"source": SOURCE_USER},
+ data={CONF_HOST: HOST},
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == HOST
@@ -73,7 +76,9 @@ async def test_abort_if_already_setup(hass, mock_daikin):
"""Test we abort if Daikin is already setup."""
MockConfigEntry(domain="daikin", unique_id=MAC).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
- "daikin", context={"source": SOURCE_USER}, data={CONF_HOST: HOST, KEY_MAC: MAC},
+ "daikin",
+ context={"source": SOURCE_USER},
+ data={CONF_HOST: HOST, KEY_MAC: MAC},
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -83,13 +88,17 @@ async def test_abort_if_already_setup(hass, mock_daikin):
async def test_import(hass, mock_daikin):
"""Test import step."""
result = await hass.config_entries.flow.async_init(
- "daikin", context={"source": SOURCE_IMPORT}, data={},
+ "daikin",
+ context={"source": SOURCE_IMPORT},
+ data={},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_init(
- "daikin", context={"source": SOURCE_IMPORT}, data={CONF_HOST: HOST},
+ "daikin",
+ context={"source": SOURCE_IMPORT},
+ data={CONF_HOST: HOST},
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == HOST
@@ -111,7 +120,9 @@ async def test_device_abort(hass, mock_daikin, s_effect, reason):
mock_daikin.factory.side_effect = s_effect
result = await hass.config_entries.flow.async_init(
- "daikin", context={"source": SOURCE_USER}, data={CONF_HOST: HOST, KEY_MAC: MAC},
+ "daikin",
+ context={"source": SOURCE_USER},
+ data={CONF_HOST: HOST, KEY_MAC: MAC},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] == {"base": reason}
@@ -130,7 +141,9 @@ async def test_discovery_zeroconf(
):
"""Test discovery/zeroconf step."""
result = await hass.config_entries.flow.async_init(
- "daikin", context={"source": source}, data=data,
+ "daikin",
+ context={"source": source},
+ data=data,
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
@@ -146,7 +159,9 @@ async def test_discovery_zeroconf(
assert result["reason"] == "already_configured"
result = await hass.config_entries.flow.async_init(
- "daikin", context={"source": source}, data=data,
+ "daikin",
+ context={"source": source},
+ data=data,
)
assert result["type"] == RESULT_TYPE_ABORT
diff --git a/tests/components/datadog/test_init.py b/tests/components/datadog/test_init.py
index da4076944d0..06788728115 100644
--- a/tests/components/datadog/test_init.py
+++ b/tests/components/datadog/test_init.py
@@ -161,7 +161,10 @@ class TestDatadog(unittest.TestCase):
)
assert mock_client.gauge.call_args == mock.call(
- "ha.sensor", out, sample_rate=1, tags=[f"entity:{state.entity_id}"],
+ "ha.sensor",
+ out,
+ sample_rate=1,
+ tags=[f"entity:{state.entity_id}"],
)
mock_client.gauge.reset_mock()
diff --git a/tests/components/debugpy/test_init.py b/tests/components/debugpy/test_init.py
index 1de8da9ac9a..86be0d788f6 100644
--- a/tests/components/debugpy/test_init.py
+++ b/tests/components/debugpy/test_init.py
@@ -53,7 +53,9 @@ async def test_on_demand(hass: HomeAssistant, mock_debugpy) -> None:
assert len(mock_debugpy.method_calls) == 0
await hass.services.async_call(
- DOMAIN, SERVICE_START, blocking=True,
+ DOMAIN,
+ SERVICE_START,
+ blocking=True,
)
mock_debugpy.listen.assert_called_once_with(("127.0.0.1", 80))
diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py
index b29e4303f7b..43536a44bbe 100644
--- a/tests/components/deconz/test_config_flow.py
+++ b/tests/components/deconz/test_config_flow.py
@@ -21,6 +21,8 @@ from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT
from .test_gateway import API_KEY, BRIDGEID, setup_deconz_integration
+from tests.async_mock import patch
+
async def test_flow_discovered_bridges(hass, aioclient_mock):
"""Test that config flow works for discovered bridges."""
@@ -86,7 +88,8 @@ async def test_flow_manual_configuration_decision(hass, aioclient_mock):
assert result["step_id"] == "manual_input"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 80},
+ result["flow_id"],
+ user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 80},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -133,7 +136,8 @@ async def test_flow_manual_configuration(hass, aioclient_mock):
assert result["step_id"] == "manual_input"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 80},
+ result["flow_id"],
+ user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 80},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -208,7 +212,8 @@ async def test_manual_configuration_update_configuration(hass, aioclient_mock):
assert result["step_id"] == "manual_input"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={CONF_HOST: "2.3.4.5", CONF_PORT: 80},
+ result["flow_id"],
+ user_input={CONF_HOST: "2.3.4.5", CONF_PORT: 80},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -253,7 +258,8 @@ async def test_manual_configuration_dont_update_configuration(hass, aioclient_mo
assert result["step_id"] == "manual_input"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 80},
+ result["flow_id"],
+ user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 80},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -295,7 +301,8 @@ async def test_manual_configuration_timeout_get_bridge(hass, aioclient_mock):
assert result["step_id"] == "manual_input"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 80},
+ result["flow_id"],
+ user_input={CONF_HOST: "1.2.3.4", CONF_PORT: 80},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -399,19 +406,25 @@ async def test_ssdp_discovery_update_configuration(hass):
"""Test if a discovered bridge is configured but updates with new attributes."""
gateway = await setup_deconz_integration(hass)
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- data={
- ssdp.ATTR_SSDP_LOCATION: "http://2.3.4.5:80/",
- ssdp.ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL,
- ssdp.ATTR_UPNP_SERIAL: BRIDGEID,
- },
- context={"source": "ssdp"},
- )
+ with patch(
+ "homeassistant.components.deconz.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ data={
+ ssdp.ATTR_SSDP_LOCATION: "http://2.3.4.5:80/",
+ ssdp.ATTR_UPNP_MANUFACTURER_URL: DECONZ_MANUFACTURERURL,
+ ssdp.ATTR_UPNP_SERIAL: BRIDGEID,
+ },
+ context={"source": "ssdp"},
+ )
+ await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
assert gateway.config_entry.data[CONF_HOST] == "2.3.4.5"
+ assert len(mock_setup_entry.mock_calls) == 1
async def test_ssdp_discovery_dont_update_configuration(hass):
@@ -469,9 +482,16 @@ async def test_flow_hassio_discovery(hass):
assert result["step_id"] == "hassio_confirm"
assert result["description_placeholders"] == {"addon": "Mock Addon"}
- result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={}
- )
+ with patch(
+ "homeassistant.components.deconz.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.deconz.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+ await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["result"].data == {
@@ -479,28 +499,36 @@ async def test_flow_hassio_discovery(hass):
CONF_PORT: 80,
CONF_API_KEY: API_KEY,
}
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
async def test_hassio_discovery_update_configuration(hass):
"""Test we can update an existing config entry."""
gateway = await setup_deconz_integration(hass)
- result = await hass.config_entries.flow.async_init(
- DOMAIN,
- data={
- CONF_HOST: "2.3.4.5",
- CONF_PORT: 8080,
- CONF_API_KEY: "updated",
- CONF_SERIAL: BRIDGEID,
- },
- context={"source": "hassio"},
- )
+ with patch(
+ "homeassistant.components.deconz.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ data={
+ CONF_HOST: "2.3.4.5",
+ CONF_PORT: 8080,
+ CONF_API_KEY: "updated",
+ CONF_SERIAL: BRIDGEID,
+ },
+ context={"source": "hassio"},
+ )
+ await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
assert gateway.config_entry.data[CONF_HOST] == "2.3.4.5"
assert gateway.config_entry.data[CONF_PORT] == 8080
assert gateway.config_entry.data[CONF_API_KEY] == "updated"
+ assert len(mock_setup_entry.mock_calls) == 1
async def test_hassio_discovery_dont_update_configuration(hass):
diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py
index 9af1a3151e8..15ff304459b 100644
--- a/tests/components/deconz/test_gateway.py
+++ b/tests/components/deconz/test_gateway.py
@@ -135,19 +135,24 @@ async def test_update_address(hass):
gateway = await setup_deconz_integration(hass)
assert gateway.api.host == "1.2.3.4"
- await hass.config_entries.flow.async_init(
- deconz.config_flow.DOMAIN,
- data={
- ssdp.ATTR_SSDP_LOCATION: "http://2.3.4.5:80/",
- ssdp.ATTR_UPNP_MANUFACTURER_URL: deconz.config_flow.DECONZ_MANUFACTURERURL,
- ssdp.ATTR_UPNP_SERIAL: BRIDGEID,
- ssdp.ATTR_UPNP_UDN: "uuid:456DEF",
- },
- context={"source": "ssdp"},
- )
- await hass.async_block_till_done()
+ with patch(
+ "homeassistant.components.deconz.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ await hass.config_entries.flow.async_init(
+ deconz.config_flow.DOMAIN,
+ data={
+ ssdp.ATTR_SSDP_LOCATION: "http://2.3.4.5:80/",
+ ssdp.ATTR_UPNP_MANUFACTURER_URL: deconz.config_flow.DECONZ_MANUFACTURERURL,
+ ssdp.ATTR_UPNP_SERIAL: BRIDGEID,
+ ssdp.ATTR_UPNP_UDN: "uuid:456DEF",
+ },
+ context={"source": "ssdp"},
+ )
+ await hass.async_block_till_done()
assert gateway.api.host == "2.3.4.5"
+ assert len(mock_setup_entry.mock_calls) == 1
async def test_reset_after_successful_setup(hass):
@@ -169,7 +174,8 @@ async def test_get_gateway(hass):
async def test_get_gateway_fails_unauthorized(hass):
"""Failed call."""
with patch(
- "pydeconz.DeconzSession.initialize", side_effect=pydeconz.errors.Unauthorized,
+ "pydeconz.DeconzSession.initialize",
+ side_effect=pydeconz.errors.Unauthorized,
), pytest.raises(deconz.errors.AuthenticationRequired):
assert (
await deconz.gateway.get_gateway(hass, ENTRY_CONFIG, Mock(), Mock())
@@ -180,7 +186,8 @@ async def test_get_gateway_fails_unauthorized(hass):
async def test_get_gateway_fails_cannot_connect(hass):
"""Failed call."""
with patch(
- "pydeconz.DeconzSession.initialize", side_effect=pydeconz.errors.RequestError,
+ "pydeconz.DeconzSession.initialize",
+ side_effect=pydeconz.errors.RequestError,
), pytest.raises(deconz.errors.CannotConnect):
assert (
await deconz.gateway.get_gateway(hass, ENTRY_CONFIG, Mock(), Mock())
diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py
index d070bd5b420..4e9f6b3d512 100644
--- a/tests/components/deconz/test_light.py
+++ b/tests/components/deconz/test_light.py
@@ -67,6 +67,15 @@ LIGHTS = {
"type": "On and Off light",
"uniqueid": "00:00:00:00:00:00:00:03-00",
},
+ "5": {
+ "ctmax": 1000,
+ "ctmin": 0,
+ "id": "Tunable white light with bad maxmin values id",
+ "name": "Tunable white light with bad maxmin values",
+ "state": {"on": True, "colormode": "ct", "ct": 2500, "reachable": True},
+ "type": "Tunable white light",
+ "uniqueid": "00:00:00:00:00:00:00:04-00",
+ },
}
@@ -101,7 +110,7 @@ async def test_lights_and_groups(hass):
assert "light.on_off_switch" not in gateway.deconz_ids
assert "light.on_off_light" in gateway.deconz_ids
- assert len(hass.states.async_all()) == 5
+ assert len(hass.states.async_all()) == 6
rgb_light = hass.states.get("light.rgb_light")
assert rgb_light.state == "on"
@@ -117,6 +126,15 @@ async def test_lights_and_groups(hass):
assert tunable_white_light.attributes["min_mireds"] == 155
assert tunable_white_light.attributes["supported_features"] == 2
+ tunable_white_light_bad_maxmin = hass.states.get(
+ "light.tunable_white_light_with_bad_maxmin_values"
+ )
+ assert tunable_white_light_bad_maxmin.state == "on"
+ assert tunable_white_light_bad_maxmin.attributes["color_temp"] == 2500
+ assert tunable_white_light_bad_maxmin.attributes["max_mireds"] == 650
+ assert tunable_white_light_bad_maxmin.attributes["min_mireds"] == 140
+ assert tunable_white_light_bad_maxmin.attributes["supported_features"] == 2
+
on_off_light = hass.states.get("light.on_off_light")
assert on_off_light.state == "on"
assert on_off_light.attributes["supported_features"] == 0
@@ -256,7 +274,7 @@ async def test_disable_light_groups(hass):
assert "light.empty_group" not in gateway.deconz_ids
assert "light.on_off_switch" not in gateway.deconz_ids
# 3 entities
- assert len(hass.states.async_all()) == 4
+ assert len(hass.states.async_all()) == 5
rgb_light = hass.states.get("light.rgb_light")
assert rgb_light is not None
@@ -281,7 +299,7 @@ async def test_disable_light_groups(hass):
assert "light.empty_group" not in gateway.deconz_ids
assert "light.on_off_switch" not in gateway.deconz_ids
# 3 entities
- assert len(hass.states.async_all()) == 5
+ assert len(hass.states.async_all()) == 6
hass.config_entries.async_update_entry(
gateway.config_entry, options={deconz.gateway.CONF_ALLOW_DECONZ_GROUPS: False}
@@ -294,4 +312,4 @@ async def test_disable_light_groups(hass):
assert "light.empty_group" not in gateway.deconz_ids
assert "light.on_off_switch" not in gateway.deconz_ids
# 3 entities
- assert len(hass.states.async_all()) == 4
+ assert len(hass.states.async_all()) == 5
diff --git a/tests/components/denonavr/test_config_flow.py b/tests/components/denonavr/test_config_flow.py
index f88ac38c46c..8fcc3af0f26 100644
--- a/tests/components/denonavr/test_config_flow.py
+++ b/tests/components/denonavr/test_config_flow.py
@@ -55,7 +55,8 @@ def denonavr_connect_fixture():
"homeassistant.components.denonavr.receiver.denonavr.DenonAVR.get_device_info",
return_value=True,
), patch(
- "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.name", TEST_NAME,
+ "homeassistant.components.denonavr.receiver.denonavr.DenonAVR.name",
+ TEST_NAME,
), patch(
"homeassistant.components.denonavr.receiver.denonavr.DenonAVR.model_name",
TEST_MODEL,
@@ -92,7 +93,8 @@ async def test_config_flow_manual_host_success(hass):
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: TEST_HOST},
+ result["flow_id"],
+ {CONF_HOST: TEST_HOST},
)
assert result["type"] == "create_entry"
@@ -125,7 +127,10 @@ async def test_config_flow_manual_discover_1_success(hass):
"homeassistant.components.denonavr.config_flow.denonavr.ssdp.identify_denonavr_receivers",
return_value=TEST_DISCOVER_1_RECEIVER,
):
- result = await hass.config_entries.flow.async_configure(result["flow_id"], {},)
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
assert result["type"] == "create_entry"
assert result["title"] == TEST_NAME
@@ -157,14 +162,18 @@ async def test_config_flow_manual_discover_2_success(hass):
"homeassistant.components.denonavr.config_flow.denonavr.ssdp.identify_denonavr_receivers",
return_value=TEST_DISCOVER_2_RECEIVER,
):
- result = await hass.config_entries.flow.async_configure(result["flow_id"], {},)
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
assert result["type"] == "form"
assert result["step_id"] == "select"
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"select_host": TEST_HOST2},
+ result["flow_id"],
+ {"select_host": TEST_HOST2},
)
assert result["type"] == "create_entry"
@@ -197,7 +206,10 @@ async def test_config_flow_manual_discover_error(hass):
"homeassistant.components.denonavr.config_flow.denonavr.ssdp.identify_denonavr_receivers",
return_value=[],
):
- result = await hass.config_entries.flow.async_configure(result["flow_id"], {},)
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
assert result["type"] == "form"
assert result["step_id"] == "user"
@@ -223,7 +235,8 @@ async def test_config_flow_manual_host_no_serial(hass):
None,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: TEST_HOST},
+ result["flow_id"],
+ {CONF_HOST: TEST_HOST},
)
assert result["type"] == "create_entry"
@@ -257,7 +270,8 @@ async def test_config_flow_manual_host_no_mac(hass):
return_value=None,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: TEST_HOST},
+ result["flow_id"],
+ {CONF_HOST: TEST_HOST},
)
assert result["type"] == "create_entry"
@@ -294,7 +308,8 @@ async def test_config_flow_manual_host_no_serial_no_mac(hass):
return_value=None,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: TEST_HOST},
+ result["flow_id"],
+ {CONF_HOST: TEST_HOST},
)
assert result["type"] == "create_entry"
@@ -331,7 +346,8 @@ async def test_config_flow_manual_host_no_serial_no_mac_exception(hass):
side_effect=OSError,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: TEST_HOST},
+ result["flow_id"],
+ {CONF_HOST: TEST_HOST},
)
assert result["type"] == "create_entry"
@@ -368,7 +384,8 @@ async def test_config_flow_manual_host_connection_error(hass):
None,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: TEST_HOST},
+ result["flow_id"],
+ {CONF_HOST: TEST_HOST},
)
assert result["type"] == "abort"
@@ -394,7 +411,8 @@ async def test_config_flow_manual_host_no_device_info(hass):
None,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: TEST_HOST},
+ result["flow_id"],
+ {CONF_HOST: TEST_HOST},
)
assert result["type"] == "abort"
@@ -417,7 +435,10 @@ async def test_config_flow_ssdp(hass):
assert result["type"] == "form"
assert result["step_id"] == "confirm"
- result = await hass.config_entries.flow.async_configure(result["flow_id"], {},)
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
assert result["type"] == "create_entry"
assert result["title"] == TEST_NAME
@@ -549,7 +570,8 @@ async def test_config_flow_manual_host_no_serial_double_config(hass):
None,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: TEST_HOST},
+ result["flow_id"],
+ {CONF_HOST: TEST_HOST},
)
assert result["type"] == "create_entry"
@@ -576,7 +598,8 @@ async def test_config_flow_manual_host_no_serial_double_config(hass):
None,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: TEST_HOST},
+ result["flow_id"],
+ {CONF_HOST: TEST_HOST},
)
assert result["type"] == "abort"
diff --git a/tests/components/denonavr/test_media_player.py b/tests/components/denonavr/test_media_player.py
index 980ad758c80..bb9f83b58d7 100644
--- a/tests/components/denonavr/test_media_player.py
+++ b/tests/components/denonavr/test_media_player.py
@@ -36,7 +36,8 @@ ENTITY_ID = f"{media_player.DOMAIN}.{TEST_NAME}"
def client_fixture():
"""Patch of client library for tests."""
with patch(
- "homeassistant.components.denonavr.receiver.denonavr.DenonAVR", autospec=True,
+ "homeassistant.components.denonavr.receiver.denonavr.DenonAVR",
+ autospec=True,
) as mock_client_class, patch(
"homeassistant.components.denonavr.receiver.denonavr.discover"
):
@@ -64,7 +65,9 @@ async def setup_denonavr(hass):
}
mock_entry = MockConfigEntry(
- domain=DOMAIN, unique_id=TEST_UNIQUE_ID, data=entry_data,
+ domain=DOMAIN,
+ unique_id=TEST_UNIQUE_ID,
+ data=entry_data,
)
mock_entry.add_to_hass(hass)
diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py
index d6a9fda00bc..19786bb08e8 100644
--- a/tests/components/device_automation/test_init.py
+++ b/tests/components/device_automation/test_init.py
@@ -764,7 +764,7 @@ async def test_automation_with_bad_trigger(hass, caplog):
async def test_websocket_device_not_found(hass, hass_ws_client):
- """Test caling command with unknown device."""
+ """Test calling command with unknown device."""
await async_setup_component(hass, "device_automation", {})
client = await hass_ws_client(hass)
await client.send_json(
diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py
index 2663018fc09..19715577e2a 100644
--- a/tests/components/device_sun_light_trigger/test_init.py
+++ b/tests/components/device_sun_light_trigger/test_init.py
@@ -222,7 +222,9 @@ async def test_initialize_start(hass):
"""Test we initialize when HA starts."""
hass.state = CoreState.not_running
assert await async_setup_component(
- hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}},
+ hass,
+ device_sun_light_trigger.DOMAIN,
+ {device_sun_light_trigger.DOMAIN: {}},
)
with patch(
diff --git a/tests/components/devolo_home_control/test_config_flow.py b/tests/components/devolo_home_control/test_config_flow.py
index 01b4603257a..6790c85b777 100644
--- a/tests/components/devolo_home_control/test_config_flow.py
+++ b/tests/components/devolo_home_control/test_config_flow.py
@@ -17,7 +17,8 @@ async def test_form(hass):
assert result["errors"] == {}
with patch(
- "homeassistant.components.devolo_home_control.async_setup", return_value=True,
+ "homeassistant.components.devolo_home_control.async_setup",
+ return_value=True,
) as mock_setup, patch(
"homeassistant.components.devolo_home_control.async_setup_entry",
return_value=True,
@@ -96,7 +97,8 @@ async def test_form_advanced_options(hass):
assert result["errors"] == {}
with patch(
- "homeassistant.components.devolo_home_control.async_setup", return_value=True,
+ "homeassistant.components.devolo_home_control.async_setup",
+ return_value=True,
) as mock_setup, patch(
"homeassistant.components.devolo_home_control.async_setup_entry",
return_value=True,
diff --git a/tests/components/dexcom/test_config_flow.py b/tests/components/dexcom/test_config_flow.py
index b244751a811..b95f796b230 100644
--- a/tests/components/dexcom/test_config_flow.py
+++ b/tests/components/dexcom/test_config_flow.py
@@ -25,10 +25,12 @@ async def test_form(hass):
), patch(
"homeassistant.components.dexcom.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.dexcom.async_setup_entry", return_value=True,
+ "homeassistant.components.dexcom.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], CONFIG,
+ result["flow_id"],
+ CONFIG,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -46,10 +48,12 @@ async def test_form_account_error(hass):
)
with patch(
- "homeassistant.components.dexcom.config_flow.Dexcom", side_effect=AccountError,
+ "homeassistant.components.dexcom.config_flow.Dexcom",
+ side_effect=AccountError,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], CONFIG,
+ result["flow_id"],
+ CONFIG,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -63,10 +67,12 @@ async def test_form_session_error(hass):
)
with patch(
- "homeassistant.components.dexcom.config_flow.Dexcom", side_effect=SessionError,
+ "homeassistant.components.dexcom.config_flow.Dexcom",
+ side_effect=SessionError,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], CONFIG,
+ result["flow_id"],
+ CONFIG,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -80,10 +86,12 @@ async def test_form_unknown_error(hass):
)
with patch(
- "homeassistant.components.dexcom.config_flow.Dexcom", side_effect=Exception,
+ "homeassistant.components.dexcom.config_flow.Dexcom",
+ side_effect=Exception,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], CONFIG,
+ result["flow_id"],
+ CONFIG,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -92,7 +100,11 @@ async def test_form_unknown_error(hass):
async def test_option_flow_default(hass):
"""Test config flow options."""
- entry = MockConfigEntry(domain=DOMAIN, data=CONFIG, options=None,)
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=CONFIG,
+ options=None,
+ )
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
@@ -101,7 +113,8 @@ async def test_option_flow_default(hass):
assert result["step_id"] == "init"
result2 = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={},
+ result["flow_id"],
+ user_input={},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result2["data"] == {
@@ -112,7 +125,9 @@ async def test_option_flow_default(hass):
async def test_option_flow(hass):
"""Test config flow options."""
entry = MockConfigEntry(
- domain=DOMAIN, data=CONFIG, options={CONF_UNIT_OF_MEASUREMENT: MG_DL},
+ domain=DOMAIN,
+ data=CONFIG,
+ options={CONF_UNIT_OF_MEASUREMENT: MG_DL},
)
entry.add_to_hass(hass)
@@ -122,7 +137,8 @@ async def test_option_flow(hass):
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={CONF_UNIT_OF_MEASUREMENT: MMOL_L},
+ result["flow_id"],
+ user_input={CONF_UNIT_OF_MEASUREMENT: MMOL_L},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"] == {
diff --git a/tests/components/dexcom/test_init.py b/tests/components/dexcom/test_init.py
index 393aa9cfe98..2cb3ad3bf79 100644
--- a/tests/components/dexcom/test_init.py
+++ b/tests/components/dexcom/test_init.py
@@ -19,7 +19,8 @@ async def test_setup_entry_account_error(hass):
options=None,
)
with patch(
- "homeassistant.components.dexcom.Dexcom", side_effect=AccountError,
+ "homeassistant.components.dexcom.Dexcom",
+ side_effect=AccountError,
):
entry.add_to_hass(hass)
result = await hass.config_entries.async_setup(entry.entry_id)
@@ -38,7 +39,8 @@ async def test_setup_entry_session_error(hass):
options=None,
)
with patch(
- "homeassistant.components.dexcom.Dexcom", side_effect=SessionError,
+ "homeassistant.components.dexcom.Dexcom",
+ side_effect=SessionError,
):
entry.add_to_hass(hass)
result = await hass.config_entries.async_setup(entry.entry_id)
diff --git a/tests/components/dexcom/test_sensor.py b/tests/components/dexcom/test_sensor.py
index de45e65155f..c9e00398140 100644
--- a/tests/components/dexcom/test_sensor.py
+++ b/tests/components/dexcom/test_sensor.py
@@ -98,7 +98,8 @@ async def test_sensors_options_changed(hass):
return_value="test_session_id",
):
hass.config_entries.async_update_entry(
- entry=entry, options={CONF_UNIT_OF_MEASUREMENT: MMOL_L},
+ entry=entry,
+ options={CONF_UNIT_OF_MEASUREMENT: MMOL_L},
)
await hass.async_block_till_done()
diff --git a/tests/components/dialogflow/test_init.py b/tests/components/dialogflow/test_init.py
index 6df59873bd1..2213e52bef7 100644
--- a/tests/components/dialogflow/test_init.py
+++ b/tests/components/dialogflow/test_init.py
@@ -79,7 +79,8 @@ async def fixture(hass, aiohttp_client):
)
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
result = await hass.config_entries.flow.async_init(
diff --git a/tests/components/directv/test_config_flow.py b/tests/components/directv/test_config_flow.py
index 0082f9a5439..df3c606f687 100644
--- a/tests/components/directv/test_config_flow.py
+++ b/tests/components/directv/test_config_flow.py
@@ -28,7 +28,8 @@ from tests.test_util.aiohttp import AiohttpClientMocker
async def test_show_user_form(hass: HomeAssistantType) -> None:
"""Test that the user set up form is served."""
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER},
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_USER},
)
assert result["step_id"] == "user"
@@ -59,7 +60,9 @@ async def test_cannot_connect(
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_USER},
+ data=user_input,
)
assert result["type"] == RESULT_TYPE_FORM
@@ -75,7 +78,9 @@ async def test_ssdp_cannot_connect(
discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_SSDP},
+ data=discovery_info,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -107,7 +112,9 @@ async def test_user_device_exists_abort(
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_USER},
+ data=user_input,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -122,7 +129,9 @@ async def test_ssdp_device_exists_abort(
discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_SSDP},
+ data=discovery_info,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -138,7 +147,9 @@ async def test_ssdp_with_receiver_id_device_exists_abort(
discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
discovery_info[ATTR_UPNP_SERIAL] = UPNP_SERIAL
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_SSDP},
+ data=discovery_info,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -155,7 +166,9 @@ async def test_unknown_error(
side_effect=Exception,
):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_USER},
+ data=user_input,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -172,7 +185,9 @@ async def test_ssdp_unknown_error(
side_effect=Exception,
):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_SSDP},
+ data=discovery_info,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -209,7 +224,9 @@ async def test_full_import_flow_implementation(
"homeassistant.components.directv.async_setup_entry", return_value=True
), patch("homeassistant.components.directv.async_setup", return_value=True):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=user_input,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_IMPORT},
+ data=user_input,
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
@@ -227,7 +244,8 @@ async def test_full_user_flow_implementation(
mock_connection(aioclient_mock)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER},
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_USER},
)
assert result["type"] == RESULT_TYPE_FORM
@@ -238,7 +256,8 @@ async def test_full_user_flow_implementation(
"homeassistant.components.directv.async_setup_entry", return_value=True
), patch("homeassistant.components.directv.async_setup", return_value=True):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=user_input,
+ result["flow_id"],
+ user_input=user_input,
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py
index 7203325fbbe..11aaad707b7 100644
--- a/tests/components/directv/test_media_player.py
+++ b/tests/components/directv/test_media_player.py
@@ -10,6 +10,7 @@ from homeassistant.components.directv.media_player import (
ATTR_MEDIA_RECORDED,
ATTR_MEDIA_START_TIME,
)
+from homeassistant.components.media_player import DEVICE_CLASS_RECEIVER
from homeassistant.components.media_player.const import (
ATTR_INPUT_SOURCE,
ATTR_MEDIA_ALBUM_NAME,
@@ -169,12 +170,15 @@ async def test_unique_id(
entity_registry = await hass.helpers.entity_registry.async_get_registry()
main = entity_registry.async_get(MAIN_ENTITY_ID)
+ assert main.device_class == DEVICE_CLASS_RECEIVER
assert main.unique_id == "028877455858"
client = entity_registry.async_get(CLIENT_ENTITY_ID)
+ assert client.device_class == DEVICE_CLASS_RECEIVER
assert client.unique_id == "2CA17D1CD30X"
unavailable_client = entity_registry.async_get(UNAVAILABLE_ENTITY_ID)
+ assert unavailable_client.device_class == DEVICE_CLASS_RECEIVER
assert unavailable_client.unique_id == "9XXXXXXXXXX9"
diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py
index 7c7c034c6b4..f8a01899bd5 100644
--- a/tests/components/doorbird/test_config_flow.py
+++ b/tests/components/doorbird/test_config_flow.py
@@ -55,10 +55,12 @@ async def test_user_form(hass):
), patch(
"homeassistant.components.doorbird.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.doorbird.async_setup_entry", return_value=True,
+ "homeassistant.components.doorbird.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], VALID_CONFIG,
+ result["flow_id"],
+ VALID_CONFIG,
)
assert result2["type"] == "create_entry"
@@ -98,7 +100,8 @@ async def test_form_import(hass):
), patch("homeassistant.components.logbook.async_setup", return_value=True), patch(
"homeassistant.components.doorbird.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.doorbird.async_setup_entry", return_value=True,
+ "homeassistant.components.doorbird.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -165,7 +168,8 @@ async def test_form_import_with_zeroconf_already_discovered(hass):
), patch("homeassistant.components.logbook.async_setup", return_value=True), patch(
"homeassistant.components.doorbird.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.doorbird.async_setup_entry", return_value=True,
+ "homeassistant.components.doorbird.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -264,7 +268,8 @@ async def test_form_zeroconf_correct_oui(hass):
), patch("homeassistant.components.logbook.async_setup", return_value=True), patch(
"homeassistant.components.doorbird.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.doorbird.async_setup_entry", return_value=True,
+ "homeassistant.components.doorbird.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], VALID_CONFIG
@@ -299,7 +304,8 @@ async def test_form_user_cannot_connect(hass):
return_value=doorbirdapi,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], VALID_CONFIG,
+ result["flow_id"],
+ VALID_CONFIG,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -325,7 +331,8 @@ async def test_form_user_invalid_auth(hass):
return_value=doorbirdapi,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], VALID_CONFIG,
+ result["flow_id"],
+ VALID_CONFIG,
)
assert result2["type"] == "form"
diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py
new file mode 100644
index 00000000000..c35562b4024
--- /dev/null
+++ b/tests/components/dsmr/test_config_flow.py
@@ -0,0 +1,248 @@
+"""Test the DSMR config flow."""
+import asyncio
+from itertools import chain, repeat
+
+from dsmr_parser.clients.protocol import DSMRProtocol
+from dsmr_parser.obis_references import EQUIPMENT_IDENTIFIER, EQUIPMENT_IDENTIFIER_GAS
+from dsmr_parser.objects import CosemObject
+import pytest
+import serial
+
+from homeassistant import config_entries, setup
+from homeassistant.components.dsmr import DOMAIN
+
+from tests.async_mock import DEFAULT, AsyncMock, Mock, patch
+from tests.common import MockConfigEntry
+
+SERIAL_DATA = {"serial_id": "12345678", "serial_id_gas": "123456789"}
+
+
+@pytest.fixture
+def mock_connection_factory(monkeypatch):
+ """Mock the create functions for serial and TCP Asyncio connections."""
+ transport = Mock(spec=asyncio.Transport)
+ protocol = Mock(spec=DSMRProtocol)
+
+ async def connection_factory(*args, **kwargs):
+ """Return mocked out Asyncio classes."""
+ return (transport, protocol)
+
+ connection_factory = Mock(wraps=connection_factory)
+
+ # apply the mock to both connection factories
+ monkeypatch.setattr(
+ "homeassistant.components.dsmr.config_flow.create_dsmr_reader",
+ connection_factory,
+ )
+ monkeypatch.setattr(
+ "homeassistant.components.dsmr.config_flow.create_tcp_dsmr_reader",
+ connection_factory,
+ )
+
+ protocol.telegram = {
+ EQUIPMENT_IDENTIFIER: CosemObject([{"value": "12345678", "unit": ""}]),
+ EQUIPMENT_IDENTIFIER_GAS: CosemObject([{"value": "123456789", "unit": ""}]),
+ }
+
+ async def wait_closed():
+ if isinstance(connection_factory.call_args_list[0][0][2], str):
+ # TCP
+ telegram_callback = connection_factory.call_args_list[0][0][3]
+ else:
+ # Serial
+ telegram_callback = connection_factory.call_args_list[0][0][2]
+
+ telegram_callback(protocol.telegram)
+
+ protocol.wait_closed = wait_closed
+
+ return connection_factory, transport, protocol
+
+
+async def test_import_usb(hass, mock_connection_factory):
+ """Test we can import."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ entry_data = {
+ "port": "/dev/ttyUSB0",
+ "dsmr_version": "2.2",
+ "precision": 4,
+ "reconnect_interval": 30,
+ }
+
+ with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data=entry_data,
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == "/dev/ttyUSB0"
+ assert result["data"] == {**entry_data, **SERIAL_DATA}
+
+
+async def test_import_usb_failed_connection(hass, monkeypatch, mock_connection_factory):
+ """Test we can import."""
+ (connection_factory, transport, protocol) = mock_connection_factory
+
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ entry_data = {
+ "port": "/dev/ttyUSB0",
+ "dsmr_version": "2.2",
+ "precision": 4,
+ "reconnect_interval": 30,
+ }
+
+ # override the mock to have it fail the first time and succeed after
+ first_fail_connection_factory = AsyncMock(
+ return_value=(transport, protocol),
+ side_effect=chain([serial.serialutil.SerialException], repeat(DEFAULT)),
+ )
+
+ monkeypatch.setattr(
+ "homeassistant.components.dsmr.config_flow.create_dsmr_reader",
+ first_fail_connection_factory,
+ )
+
+ with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data=entry_data,
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "cannot_connect"
+
+
+async def test_import_usb_no_data(hass, monkeypatch, mock_connection_factory):
+ """Test we can import."""
+ (connection_factory, transport, protocol) = mock_connection_factory
+
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ entry_data = {
+ "port": "/dev/ttyUSB0",
+ "dsmr_version": "2.2",
+ "precision": 4,
+ "reconnect_interval": 30,
+ }
+
+ # override the mock to have it fail the first time and succeed after
+ wait_closed = AsyncMock(
+ return_value=(transport, protocol),
+ side_effect=chain([asyncio.TimeoutError], repeat(DEFAULT)),
+ )
+
+ protocol.wait_closed = wait_closed
+
+ with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data=entry_data,
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "cannot_communicate"
+
+
+async def test_import_usb_wrong_telegram(hass, mock_connection_factory):
+ """Test we can import."""
+ (connection_factory, transport, protocol) = mock_connection_factory
+
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ entry_data = {
+ "port": "/dev/ttyUSB0",
+ "dsmr_version": "2.2",
+ "precision": 4,
+ "reconnect_interval": 30,
+ }
+
+ protocol.telegram = {}
+
+ with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data=entry_data,
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "cannot_communicate"
+
+
+async def test_import_network(hass, mock_connection_factory):
+ """Test we can import from network."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ entry_data = {
+ "host": "localhost",
+ "port": "1234",
+ "dsmr_version": "2.2",
+ "precision": 4,
+ "reconnect_interval": 30,
+ }
+
+ with patch("homeassistant.components.dsmr.async_setup_entry", return_value=True):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data=entry_data,
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == "localhost:1234"
+ assert result["data"] == {**entry_data, **SERIAL_DATA}
+
+
+async def test_import_update(hass, mock_connection_factory):
+ """Test we can import."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ entry_data = {
+ "port": "/dev/ttyUSB0",
+ "dsmr_version": "2.2",
+ "precision": 4,
+ "reconnect_interval": 30,
+ }
+
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=entry_data,
+ unique_id="/dev/ttyUSB0",
+ )
+ entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.dsmr.async_setup_entry", return_value=True
+ ), patch("homeassistant.components.dsmr.async_unload_entry", return_value=True):
+ await hass.config_entries.async_setup(entry.entry_id)
+
+ await hass.async_block_till_done()
+
+ new_entry_data = {
+ "port": "/dev/ttyUSB0",
+ "dsmr_version": "2.2",
+ "precision": 3,
+ "reconnect_interval": 30,
+ }
+
+ with patch(
+ "homeassistant.components.dsmr.async_setup_entry", return_value=True
+ ), patch("homeassistant.components.dsmr.async_unload_entry", return_value=True):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data=new_entry_data,
+ )
+
+ await hass.async_block_till_done()
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+
+ assert entry.data["precision"] == 3
diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py
index ed660eb4f51..f0ff2f85c57 100644
--- a/tests/components/dsmr/test_sensor.py
+++ b/tests/components/dsmr/test_sensor.py
@@ -12,13 +12,15 @@ from itertools import chain, repeat
import pytest
-from homeassistant.bootstrap import async_setup_component
+from homeassistant.components.dsmr.const import DOMAIN
from homeassistant.components.dsmr.sensor import DerivativeDSMREntity
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import ENERGY_KILO_WATT_HOUR, TIME_HOURS, VOLUME_CUBIC_METERS
+from homeassistant.setup import async_setup_component
import tests.async_mock
-from tests.async_mock import DEFAULT, Mock
-from tests.common import assert_setup_component
+from tests.async_mock import DEFAULT, MagicMock, Mock
+from tests.common import MockConfigEntry, patch
@pytest.fixture
@@ -47,6 +49,44 @@ def mock_connection_factory(monkeypatch):
return connection_factory, transport, protocol
+async def test_setup_platform(hass, mock_connection_factory):
+ """Test setup of platform."""
+ async_add_entities = MagicMock()
+
+ entry_data = {
+ "platform": DOMAIN,
+ "port": "/dev/ttyUSB0",
+ "dsmr_version": "2.2",
+ "precision": 4,
+ "reconnect_interval": 30,
+ }
+
+ serial_data = {"serial_id": "1234", "serial_id_gas": "5678"}
+
+ with patch("homeassistant.components.dsmr.async_setup", return_value=True), patch(
+ "homeassistant.components.dsmr.async_setup_entry", return_value=True
+ ), patch(
+ "homeassistant.components.dsmr.config_flow._validate_dsmr_connection",
+ return_value=serial_data,
+ ):
+ assert await async_setup_component(
+ hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: entry_data}
+ )
+ await hass.async_block_till_done()
+
+ assert not async_add_entities.called
+
+ # Check config entry
+ conf_entries = hass.config_entries.async_entries(DOMAIN)
+
+ assert len(conf_entries) == 1
+
+ entry = conf_entries[0]
+
+ assert entry.state == "loaded"
+ assert entry.data == {**entry_data, **serial_data}
+
+
async def test_default_setup(hass, mock_connection_factory):
"""Test the default setup."""
(connection_factory, transport, protocol) = mock_connection_factory
@@ -58,7 +98,12 @@ async def test_default_setup(hass, mock_connection_factory):
)
from dsmr_parser.objects import CosemObject, MBusObject
- config = {"platform": "dsmr"}
+ entry_data = {
+ "port": "/dev/ttyUSB0",
+ "dsmr_version": "2.2",
+ "precision": 4,
+ "reconnect_interval": 30,
+ }
telegram = {
CURRENT_ELECTRICITY_USAGE: CosemObject(
@@ -73,9 +118,14 @@ async def test_default_setup(hass, mock_connection_factory):
),
}
- with assert_setup_component(1):
- await async_setup_component(hass, "sensor", {"sensor": config})
- await hass.async_block_till_done()
+ mock_entry = MockConfigEntry(
+ domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data
+ )
+
+ mock_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+ await hass.async_block_till_done()
telegram_callback = connection_factory.call_args_list[0][0][2]
@@ -107,6 +157,10 @@ async def test_default_setup(hass, mock_connection_factory):
assert gas_consumption.state == "745.695"
assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS
+ await hass.config_entries.async_unload(mock_entry.entry_id)
+
+ assert mock_entry.state == "not_loaded"
+
async def test_derivative():
"""Test calculation of derivative value."""
@@ -153,12 +207,17 @@ async def test_v4_meter(hass, mock_connection_factory):
(connection_factory, transport, protocol) = mock_connection_factory
from dsmr_parser.obis_references import (
- HOURLY_GAS_METER_READING,
ELECTRICITY_ACTIVE_TARIFF,
+ HOURLY_GAS_METER_READING,
)
from dsmr_parser.objects import CosemObject, MBusObject
- config = {"platform": "dsmr", "dsmr_version": "4"}
+ entry_data = {
+ "port": "/dev/ttyUSB0",
+ "dsmr_version": "4",
+ "precision": 4,
+ "reconnect_interval": 30,
+ }
telegram = {
HOURLY_GAS_METER_READING: MBusObject(
@@ -170,9 +229,14 @@ async def test_v4_meter(hass, mock_connection_factory):
ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]),
}
- with assert_setup_component(1):
- await async_setup_component(hass, "sensor", {"sensor": config})
- await hass.async_block_till_done()
+ mock_entry = MockConfigEntry(
+ domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data
+ )
+
+ mock_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+ await hass.async_block_till_done()
telegram_callback = connection_factory.call_args_list[0][0][2]
@@ -192,18 +256,27 @@ async def test_v4_meter(hass, mock_connection_factory):
assert gas_consumption.state == "745.695"
assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS
+ await hass.config_entries.async_unload(mock_entry.entry_id)
+
+ assert mock_entry.state == "not_loaded"
+
async def test_v5_meter(hass, mock_connection_factory):
"""Test if v5 meter is correctly parsed."""
(connection_factory, transport, protocol) = mock_connection_factory
from dsmr_parser.obis_references import (
- HOURLY_GAS_METER_READING,
ELECTRICITY_ACTIVE_TARIFF,
+ HOURLY_GAS_METER_READING,
)
from dsmr_parser.objects import CosemObject, MBusObject
- config = {"platform": "dsmr", "dsmr_version": "5"}
+ entry_data = {
+ "port": "/dev/ttyUSB0",
+ "dsmr_version": "5",
+ "precision": 4,
+ "reconnect_interval": 30,
+ }
telegram = {
HOURLY_GAS_METER_READING: MBusObject(
@@ -215,9 +288,14 @@ async def test_v5_meter(hass, mock_connection_factory):
ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]),
}
- with assert_setup_component(1):
- await async_setup_component(hass, "sensor", {"sensor": config})
- await hass.async_block_till_done()
+ mock_entry = MockConfigEntry(
+ domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data
+ )
+
+ mock_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+ await hass.async_block_till_done()
telegram_callback = connection_factory.call_args_list[0][0][2]
@@ -237,6 +315,10 @@ async def test_v5_meter(hass, mock_connection_factory):
assert gas_consumption.state == "745.695"
assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS
+ await hass.config_entries.async_unload(mock_entry.entry_id)
+
+ assert mock_entry.state == "not_loaded"
+
async def test_belgian_meter(hass, mock_connection_factory):
"""Test if Belgian meter is correctly parsed."""
@@ -248,7 +330,12 @@ async def test_belgian_meter(hass, mock_connection_factory):
)
from dsmr_parser.objects import CosemObject, MBusObject
- config = {"platform": "dsmr", "dsmr_version": "5B"}
+ entry_data = {
+ "port": "/dev/ttyUSB0",
+ "dsmr_version": "5B",
+ "precision": 4,
+ "reconnect_interval": 30,
+ }
telegram = {
BELGIUM_HOURLY_GAS_METER_READING: MBusObject(
@@ -260,9 +347,14 @@ async def test_belgian_meter(hass, mock_connection_factory):
ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]),
}
- with assert_setup_component(1):
- await async_setup_component(hass, "sensor", {"sensor": config})
- await hass.async_block_till_done()
+ mock_entry = MockConfigEntry(
+ domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data
+ )
+
+ mock_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+ await hass.async_block_till_done()
telegram_callback = connection_factory.call_args_list[0][0][2]
@@ -282,6 +374,10 @@ async def test_belgian_meter(hass, mock_connection_factory):
assert gas_consumption.state == "745.695"
assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS
+ await hass.config_entries.async_unload(mock_entry.entry_id)
+
+ assert mock_entry.state == "not_loaded"
+
async def test_belgian_meter_low(hass, mock_connection_factory):
"""Test if Belgian meter is correctly parsed."""
@@ -290,13 +386,23 @@ async def test_belgian_meter_low(hass, mock_connection_factory):
from dsmr_parser.obis_references import ELECTRICITY_ACTIVE_TARIFF
from dsmr_parser.objects import CosemObject
- config = {"platform": "dsmr", "dsmr_version": "5B"}
+ entry_data = {
+ "port": "/dev/ttyUSB0",
+ "dsmr_version": "5B",
+ "precision": 4,
+ "reconnect_interval": 30,
+ }
telegram = {ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0002", "unit": ""}])}
- with assert_setup_component(1):
- await async_setup_component(hass, "sensor", {"sensor": config})
- await hass.async_block_till_done()
+ mock_entry = MockConfigEntry(
+ domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data
+ )
+
+ mock_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+ await hass.async_block_till_done()
telegram_callback = connection_factory.call_args_list[0][0][2]
@@ -311,26 +417,50 @@ async def test_belgian_meter_low(hass, mock_connection_factory):
assert power_tariff.state == "low"
assert power_tariff.attributes.get("unit_of_measurement") == ""
+ await hass.config_entries.async_unload(mock_entry.entry_id)
+
+ assert mock_entry.state == "not_loaded"
+
async def test_tcp(hass, mock_connection_factory):
"""If proper config provided TCP connection should be made."""
(connection_factory, transport, protocol) = mock_connection_factory
- config = {"platform": "dsmr", "host": "localhost", "port": 1234}
+ entry_data = {
+ "host": "localhost",
+ "port": "1234",
+ "dsmr_version": "2.2",
+ "precision": 4,
+ "reconnect_interval": 30,
+ }
- with assert_setup_component(1):
- await async_setup_component(hass, "sensor", {"sensor": config})
- await hass.async_block_till_done()
+ mock_entry = MockConfigEntry(
+ domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data
+ )
+
+ mock_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+ await hass.async_block_till_done()
assert connection_factory.call_args_list[0][0][0] == "localhost"
assert connection_factory.call_args_list[0][0][1] == "1234"
+ await hass.config_entries.async_unload(mock_entry.entry_id)
+
+ assert mock_entry.state == "not_loaded"
+
async def test_connection_errors_retry(hass, monkeypatch, mock_connection_factory):
"""Connection should be retried on error during setup."""
(connection_factory, transport, protocol) = mock_connection_factory
- config = {"platform": "dsmr", "reconnect_interval": 0}
+ entry_data = {
+ "port": "/dev/ttyUSB0",
+ "dsmr_version": "2.2",
+ "precision": 4,
+ "reconnect_interval": 0,
+ }
# override the mock to have it fail the first time and succeed after
first_fail_connection_factory = tests.async_mock.AsyncMock(
@@ -342,17 +472,35 @@ async def test_connection_errors_retry(hass, monkeypatch, mock_connection_factor
"homeassistant.components.dsmr.sensor.create_dsmr_reader",
first_fail_connection_factory,
)
- await async_setup_component(hass, "sensor", {"sensor": config})
+
+ mock_entry = MockConfigEntry(
+ domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data
+ )
+
+ mock_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(mock_entry.entry_id)
+ await hass.async_block_till_done()
# wait for sleep to resolve
await hass.async_block_till_done()
assert first_fail_connection_factory.call_count >= 2, "connecting not retried"
+ await hass.config_entries.async_unload(mock_entry.entry_id)
+
+ assert mock_entry.state == "not_loaded"
+
async def test_reconnect(hass, monkeypatch, mock_connection_factory):
"""If transport disconnects, the connection should be retried."""
(connection_factory, transport, protocol) = mock_connection_factory
- config = {"platform": "dsmr", "reconnect_interval": 0}
+
+ entry_data = {
+ "port": "/dev/ttyUSB0",
+ "dsmr_version": "2.2",
+ "precision": 4,
+ "reconnect_interval": 0,
+ }
# mock waiting coroutine while connection lasts
closed = asyncio.Event()
@@ -365,7 +513,13 @@ async def test_reconnect(hass, monkeypatch, mock_connection_factory):
protocol.wait_closed = wait_closed
- await async_setup_component(hass, "sensor", {"sensor": config})
+ mock_entry = MockConfigEntry(
+ domain="dsmr", unique_id="/dev/ttyUSB0", data=entry_data
+ )
+
+ mock_entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert connection_factory.call_count == 1
@@ -382,3 +536,7 @@ async def test_reconnect(hass, monkeypatch, mock_connection_factory):
assert connection_factory.call_count >= 2, "connecting not retried"
# setting it so teardown can be successful
closed.set()
+
+ await hass.config_entries.async_unload(mock_entry.entry_id)
+
+ assert mock_entry.state == "not_loaded"
diff --git a/tests/components/dunehd/test_config_flow.py b/tests/components/dunehd/test_config_flow.py
index 2336d2e4a3f..f2d30b2a8dd 100644
--- a/tests/components/dunehd/test_config_flow.py
+++ b/tests/components/dunehd/test_config_flow.py
@@ -39,7 +39,9 @@ async def test_import_cannot_connect(hass):
async def test_import_duplicate_error(hass):
"""Test that errors are shown when duplicates are added during import."""
config_entry = MockConfigEntry(
- domain=DOMAIN, data={CONF_HOST: "dunehd-host"}, title="dunehd-host",
+ domain=DOMAIN,
+ data={CONF_HOST: "dunehd-host"},
+ title="dunehd-host",
)
config_entry.add_to_hass(hass)
@@ -74,7 +76,9 @@ async def test_user_cannot_connect(hass):
async def test_duplicate_error(hass):
"""Test that errors are shown when duplicates are added."""
config_entry = MockConfigEntry(
- domain=DOMAIN, data=CONFIG_HOSTNAME, title="dunehd-host",
+ domain=DOMAIN,
+ data=CONFIG_HOSTNAME,
+ title="dunehd-host",
)
config_entry.add_to_hass(hass)
diff --git a/tests/components/dynalite/test_bridge.py b/tests/components/dynalite/test_bridge.py
index ea73f75a390..8f3210bbcf8 100644
--- a/tests/components/dynalite/test_bridge.py
+++ b/tests/components/dynalite/test_bridge.py
@@ -1,6 +1,21 @@
"""Test Dynalite bridge."""
+
+from dynalite_devices_lib.dynalite_devices import (
+ CONF_AREA as dyn_CONF_AREA,
+ CONF_PRESET as dyn_CONF_PRESET,
+ NOTIFICATION_PACKET,
+ NOTIFICATION_PRESET,
+ DynaliteNotification,
+)
+
from homeassistant.components import dynalite
+from homeassistant.components.dynalite.const import (
+ ATTR_AREA,
+ ATTR_HOST,
+ ATTR_PACKET,
+ ATTR_PRESET,
+)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from tests.async_mock import AsyncMock, Mock, patch
@@ -95,3 +110,41 @@ async def test_register_then_add_devices(hass):
await hass.async_block_till_done()
assert hass.states.get("light.name")
assert hass.states.get("switch.name2")
+
+
+async def test_notifications(hass):
+ """Test that update works."""
+ host = "1.2.3.4"
+ entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host})
+ entry.add_to_hass(hass)
+ with patch(
+ "homeassistant.components.dynalite.bridge.DynaliteDevices"
+ ) as mock_dyn_dev:
+ mock_dyn_dev().async_setup = AsyncMock(return_value=True)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+ notification_func = mock_dyn_dev.mock_calls[1][2]["notification_func"]
+ event_listener1 = Mock()
+ hass.bus.async_listen("dynalite_packet", event_listener1)
+ packet = [5, 4, 3, 2]
+ notification_func(
+ DynaliteNotification(NOTIFICATION_PACKET, {NOTIFICATION_PACKET: packet})
+ )
+ await hass.async_block_till_done()
+ event_listener1.assert_called_once()
+ my_event = event_listener1.mock_calls[0][1][0]
+ assert my_event.data[ATTR_HOST] == host
+ assert my_event.data[ATTR_PACKET] == packet
+ event_listener2 = Mock()
+ hass.bus.async_listen("dynalite_preset", event_listener2)
+ notification_func(
+ DynaliteNotification(
+ NOTIFICATION_PRESET, {dyn_CONF_AREA: 7, dyn_CONF_PRESET: 2}
+ )
+ )
+ await hass.async_block_till_done()
+ event_listener2.assert_called_once()
+ my_event = event_listener2.mock_calls[0][1][0]
+ assert my_event.data[ATTR_HOST] == host
+ assert my_event.data[ATTR_AREA] == 7
+ assert my_event.data[ATTR_PRESET] == 2
diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py
index be646a1854d..faa75aadef8 100644
--- a/tests/components/dynalite/test_init.py
+++ b/tests/components/dynalite/test_init.py
@@ -1,6 +1,9 @@
"""Test Dynalite __init__."""
+import pytest
+from voluptuous import MultipleInvalid
+
import homeassistant.components.dynalite.const as dynalite
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_ROOM
from homeassistant.setup import async_setup_component
@@ -79,6 +82,129 @@ async def test_async_setup(hass):
assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 1
+async def test_service_request_area_preset(hass):
+ """Test requesting and area preset via service call."""
+ with patch(
+ "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
+ return_value=True,
+ ), patch(
+ "dynalite_devices_lib.dynalite.Dynalite.request_area_preset",
+ return_value=True,
+ ) as mock_req_area_pres:
+ assert await async_setup_component(
+ hass,
+ dynalite.DOMAIN,
+ {
+ dynalite.DOMAIN: {
+ dynalite.CONF_BRIDGES: [
+ {CONF_HOST: "1.2.3.4"},
+ {CONF_HOST: "5.6.7.8"},
+ ]
+ }
+ },
+ )
+ await hass.async_block_till_done()
+ assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 2
+ await hass.services.async_call(
+ dynalite.DOMAIN,
+ "request_area_preset",
+ {"host": "1.2.3.4", "area": 2},
+ )
+ await hass.async_block_till_done()
+ mock_req_area_pres.assert_called_once_with(2, 1)
+ mock_req_area_pres.reset_mock()
+ await hass.services.async_call(
+ dynalite.DOMAIN,
+ "request_area_preset",
+ {"area": 3},
+ )
+ await hass.async_block_till_done()
+ assert mock_req_area_pres.mock_calls == [call(3, 1), call(3, 1)]
+ mock_req_area_pres.reset_mock()
+ await hass.services.async_call(
+ dynalite.DOMAIN,
+ "request_area_preset",
+ {"host": "5.6.7.8", "area": 4},
+ )
+ await hass.async_block_till_done()
+ mock_req_area_pres.assert_called_once_with(4, 1)
+ mock_req_area_pres.reset_mock()
+ await hass.services.async_call(
+ dynalite.DOMAIN,
+ "request_area_preset",
+ {"host": "6.5.4.3", "area": 5},
+ )
+ await hass.async_block_till_done()
+ mock_req_area_pres.assert_not_called()
+ mock_req_area_pres.reset_mock()
+ await hass.services.async_call(
+ dynalite.DOMAIN,
+ "request_area_preset",
+ {"host": "1.2.3.4", "area": 6, "channel": 9},
+ )
+ await hass.async_block_till_done()
+ mock_req_area_pres.assert_called_once_with(6, 9)
+ mock_req_area_pres.reset_mock()
+ await hass.services.async_call(
+ dynalite.DOMAIN,
+ "request_area_preset",
+ {"host": "1.2.3.4", "area": 7},
+ )
+ await hass.async_block_till_done()
+ mock_req_area_pres.assert_called_once_with(7, 1)
+
+
+async def test_service_request_channel_level(hass):
+ """Test requesting the level of a channel via service call."""
+ with patch(
+ "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup",
+ return_value=True,
+ ), patch(
+ "dynalite_devices_lib.dynalite.Dynalite.request_channel_level",
+ return_value=True,
+ ) as mock_req_chan_lvl:
+ assert await async_setup_component(
+ hass,
+ dynalite.DOMAIN,
+ {
+ dynalite.DOMAIN: {
+ dynalite.CONF_BRIDGES: [
+ {
+ CONF_HOST: "1.2.3.4",
+ dynalite.CONF_AREA: {"7": {CONF_NAME: "test"}},
+ },
+ {CONF_HOST: "5.6.7.8"},
+ ]
+ }
+ },
+ )
+ await hass.async_block_till_done()
+ assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 2
+ await hass.services.async_call(
+ dynalite.DOMAIN,
+ "request_channel_level",
+ {"host": "1.2.3.4", "area": 2, "channel": 3},
+ )
+ await hass.async_block_till_done()
+ mock_req_chan_lvl.assert_called_once_with(2, 3)
+ mock_req_chan_lvl.reset_mock()
+ with pytest.raises(MultipleInvalid):
+ await hass.services.async_call(
+ dynalite.DOMAIN,
+ "request_channel_level",
+ {"area": 3},
+ )
+ await hass.async_block_till_done()
+ mock_req_chan_lvl.assert_not_called()
+ await hass.services.async_call(
+ dynalite.DOMAIN,
+ "request_channel_level",
+ {"area": 4, "channel": 5},
+ )
+ await hass.async_block_till_done()
+ assert mock_req_chan_lvl.mock_calls == [call(4, 5), call(4, 5)]
+
+
async def test_async_setup_bad_config1(hass):
"""Test a successful with bad config on templates."""
with patch(
diff --git a/tests/components/dyson/test_sensor.py b/tests/components/dyson/test_sensor.py
index d15826863bb..5a6febc98cf 100644
--- a/tests/components/dyson/test_sensor.py
+++ b/tests/components/dyson/test_sensor.py
@@ -8,11 +8,11 @@ from libpurecool.dyson_pure_cool_link import DysonPureCoolLink
from homeassistant.components import dyson as dyson_parent
from homeassistant.components.dyson import sensor as dyson
from homeassistant.const import (
+ PERCENTAGE,
STATE_OFF,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
TIME_HOURS,
- UNIT_PERCENTAGE,
)
from homeassistant.helpers import discovery
from homeassistant.setup import async_setup_component
@@ -166,7 +166,7 @@ class DysonTest(unittest.TestCase):
sensor.entity_id = "sensor.dyson_1"
assert not sensor.should_poll
assert sensor.state is None
- assert sensor.unit_of_measurement == UNIT_PERCENTAGE
+ assert sensor.unit_of_measurement == PERCENTAGE
assert sensor.name == "Device_name Humidity"
assert sensor.entity_id == "sensor.dyson_1"
@@ -177,7 +177,7 @@ class DysonTest(unittest.TestCase):
sensor.entity_id = "sensor.dyson_1"
assert not sensor.should_poll
assert sensor.state == 45
- assert sensor.unit_of_measurement == UNIT_PERCENTAGE
+ assert sensor.unit_of_measurement == PERCENTAGE
assert sensor.name == "Device_name Humidity"
assert sensor.entity_id == "sensor.dyson_1"
@@ -188,7 +188,7 @@ class DysonTest(unittest.TestCase):
sensor.entity_id = "sensor.dyson_1"
assert not sensor.should_poll
assert sensor.state == STATE_OFF
- assert sensor.unit_of_measurement == UNIT_PERCENTAGE
+ assert sensor.unit_of_measurement == PERCENTAGE
assert sensor.name == "Device_name Humidity"
assert sensor.entity_id == "sensor.dyson_1"
diff --git a/tests/components/eafm/__init__.py b/tests/components/eafm/__init__.py
new file mode 100644
index 00000000000..d94537dd966
--- /dev/null
+++ b/tests/components/eafm/__init__.py
@@ -0,0 +1 @@
+"""Tests for eafm."""
diff --git a/tests/components/eafm/conftest.py b/tests/components/eafm/conftest.py
new file mode 100644
index 00000000000..b25c0f4cdba
--- /dev/null
+++ b/tests/components/eafm/conftest.py
@@ -0,0 +1,18 @@
+"""eafm fixtures."""
+
+from asynctest import patch
+import pytest
+
+
+@pytest.fixture()
+def mock_get_stations():
+ """Mock aioeafm.get_stations."""
+ with patch("homeassistant.components.eafm.config_flow.get_stations") as patched:
+ yield patched
+
+
+@pytest.fixture()
+def mock_get_station():
+ """Mock aioeafm.get_station."""
+ with patch("homeassistant.components.eafm.sensor.get_station") as patched:
+ yield patched
diff --git a/tests/components/eafm/test_config_flow.py b/tests/components/eafm/test_config_flow.py
new file mode 100644
index 00000000000..4656e34a34c
--- /dev/null
+++ b/tests/components/eafm/test_config_flow.py
@@ -0,0 +1,59 @@
+"""Tests for eafm config flow."""
+from asynctest import patch
+import pytest
+from voluptuous.error import MultipleInvalid
+
+from homeassistant.components.eafm import const
+
+
+async def test_flow_no_discovered_stations(hass, mock_get_stations):
+ """Test config flow discovers no station."""
+ mock_get_stations.return_value = []
+ result = await hass.config_entries.flow.async_init(
+ const.DOMAIN, context={"source": "user"}
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "no_stations"
+
+
+async def test_flow_invalid_station(hass, mock_get_stations):
+ """Test config flow errors on invalid station."""
+ mock_get_stations.return_value = [
+ {"label": "My station", "stationReference": "L12345"}
+ ]
+
+ result = await hass.config_entries.flow.async_init(
+ const.DOMAIN, context={"source": "user"}
+ )
+ assert result["type"] == "form"
+
+ with pytest.raises(MultipleInvalid):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"station": "My other station"}
+ )
+
+
+async def test_flow_works(hass, mock_get_stations, mock_get_station):
+ """Test config flow discovers no station."""
+ mock_get_stations.return_value = [
+ {"label": "My station", "stationReference": "L12345"}
+ ]
+ mock_get_station.return_value = [
+ {"label": "My station", "stationReference": "L12345"}
+ ]
+
+ result = await hass.config_entries.flow.async_init(
+ const.DOMAIN, context={"source": "user"}
+ )
+ assert result["type"] == "form"
+
+ with patch("homeassistant.components.eafm.async_setup_entry", return_value=True):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"station": "My station"}
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == "My station"
+ assert result["data"] == {
+ "station": "L12345",
+ }
diff --git a/tests/components/eafm/test_sensor.py b/tests/components/eafm/test_sensor.py
new file mode 100644
index 00000000000..6cce7a2bc4b
--- /dev/null
+++ b/tests/components/eafm/test_sensor.py
@@ -0,0 +1,431 @@
+"""Tests for polling measures."""
+import datetime
+
+import aiohttp
+import pytest
+
+from homeassistant import config_entries
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+
+from tests.common import MockConfigEntry, async_fire_time_changed
+
+DUMMY_REQUEST_INFO = aiohttp.client.RequestInfo(
+ url="http://example.com", method="GET", headers={}, real_url="http://example.com"
+)
+
+CONNECTION_EXCEPTIONS = [
+ aiohttp.ClientConnectionError("Mock connection error"),
+ aiohttp.ClientResponseError(DUMMY_REQUEST_INFO, [], message="Mock response error"),
+]
+
+
+async def async_setup_test_fixture(hass, mock_get_station, initial_value):
+ """Create a dummy config entry for testing polling."""
+ mock_get_station.return_value = initial_value
+
+ entry = MockConfigEntry(
+ version=1,
+ domain="eafm",
+ entry_id="VikingRecorder1234",
+ data={"station": "L1234"},
+ title="Viking Recorder",
+ connection_class=config_entries.CONN_CLASS_CLOUD_PUSH,
+ )
+ entry.add_to_hass(hass)
+
+ assert await async_setup_component(hass, "eafm", {})
+ assert entry.state == config_entries.ENTRY_STATE_LOADED
+ await hass.async_block_till_done()
+
+ async def poll(value):
+ mock_get_station.reset_mock(return_value=True, side_effect=True)
+
+ if isinstance(value, Exception):
+ mock_get_station.side_effect = value
+ else:
+ mock_get_station.return_value = value
+
+ next_update = dt_util.utcnow() + datetime.timedelta(60 * 15)
+ async_fire_time_changed(hass, next_update)
+ await hass.async_block_till_done()
+
+ return entry, poll
+
+
+async def test_reading_measures_not_list(hass, mock_get_station):
+ """
+ Test that a measure can be a dict not a list.
+
+ E.g. https://environment.data.gov.uk/flood-monitoring/id/stations/751110
+ """
+ _ = await async_setup_test_fixture(
+ hass,
+ mock_get_station,
+ {
+ "label": "My station",
+ "measures": {
+ "@id": "really-long-unique-id",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Stage",
+ "parameterName": "Water Level",
+ "latestReading": {"value": 5},
+ "stationReference": "L1234",
+ },
+ },
+ )
+
+ state = hass.states.get("sensor.my_station_water_level_stage")
+ assert state.state == "5"
+
+
+async def test_reading_no_unit(hass, mock_get_station):
+ """
+ Test that a sensor functions even if its unit is not known.
+
+ E.g. https://environment.data.gov.uk/flood-monitoring/id/stations/L0410
+ """
+ _ = await async_setup_test_fixture(
+ hass,
+ mock_get_station,
+ {
+ "label": "My station",
+ "measures": [
+ {
+ "@id": "really-long-unique-id",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Stage",
+ "parameterName": "Water Level",
+ "latestReading": {"value": 5},
+ "stationReference": "L1234",
+ }
+ ],
+ },
+ )
+
+ state = hass.states.get("sensor.my_station_water_level_stage")
+ assert state.state == "5"
+
+
+async def test_ignore_invalid_latest_reading(hass, mock_get_station):
+ """
+ Test that a sensor functions even if its unit is not known.
+
+ E.g. https://environment.data.gov.uk/flood-monitoring/id/stations/L0410
+ """
+ _ = await async_setup_test_fixture(
+ hass,
+ mock_get_station,
+ {
+ "label": "My station",
+ "measures": [
+ {
+ "@id": "really-long-unique-id",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Stage",
+ "parameterName": "Water Level",
+ "latestReading": "http://environment.data.gov.uk/flood-monitoring/data/readings/L0410-level-stage-i-15_min----/2017-02-22T10-30-00Z",
+ "stationReference": "L0410",
+ },
+ {
+ "@id": "really-long-unique-id",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Stage",
+ "parameterName": "Other",
+ "latestReading": {"value": 5},
+ "stationReference": "L0411",
+ },
+ ],
+ },
+ )
+
+ state = hass.states.get("sensor.my_station_water_level_stage")
+ assert state is None
+
+ state = hass.states.get("sensor.my_station_other_stage")
+ assert state.state == "5"
+
+
+@pytest.mark.parametrize("exception", CONNECTION_EXCEPTIONS)
+async def test_reading_unavailable(hass, mock_get_station, exception):
+ """Test that a sensor is marked as unavailable if there is a connection error."""
+ _, poll = await async_setup_test_fixture(
+ hass,
+ mock_get_station,
+ {
+ "label": "My station",
+ "measures": [
+ {
+ "@id": "really-long-unique-id",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Stage",
+ "parameterName": "Water Level",
+ "latestReading": {"value": 5},
+ "stationReference": "L1234",
+ "unit": "http://qudt.org/1.1/vocab/unit#Meter",
+ "unitName": "m",
+ }
+ ],
+ },
+ )
+ state = hass.states.get("sensor.my_station_water_level_stage")
+ assert state.state == "5"
+
+ await poll(exception)
+ state = hass.states.get("sensor.my_station_water_level_stage")
+ assert state.state == "unavailable"
+
+
+@pytest.mark.parametrize("exception", CONNECTION_EXCEPTIONS)
+async def test_recover_from_failure(hass, mock_get_station, exception):
+ """Test that a sensor recovers from failures."""
+ _, poll = await async_setup_test_fixture(
+ hass,
+ mock_get_station,
+ {
+ "label": "My station",
+ "measures": [
+ {
+ "@id": "really-long-unique-id",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Stage",
+ "parameterName": "Water Level",
+ "latestReading": {"value": 5},
+ "stationReference": "L1234",
+ "unit": "http://qudt.org/1.1/vocab/unit#Meter",
+ "unitName": "m",
+ }
+ ],
+ },
+ )
+ state = hass.states.get("sensor.my_station_water_level_stage")
+ assert state.state == "5"
+
+ await poll(exception)
+ state = hass.states.get("sensor.my_station_water_level_stage")
+ assert state.state == "unavailable"
+
+ await poll(
+ {
+ "label": "My station",
+ "measures": [
+ {
+ "@id": "really-long-unique-id",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Stage",
+ "parameterName": "Water Level",
+ "latestReading": {"value": 56},
+ "stationReference": "L1234",
+ "unit": "http://qudt.org/1.1/vocab/unit#Meter",
+ "unitName": "m",
+ }
+ ],
+ },
+ )
+ state = hass.states.get("sensor.my_station_water_level_stage")
+ assert state.state == "56"
+
+
+async def test_reading_is_sampled(hass, mock_get_station):
+ """Test that a sensor is added and polled."""
+ await async_setup_test_fixture(
+ hass,
+ mock_get_station,
+ {
+ "label": "My station",
+ "measures": [
+ {
+ "@id": "really-long-unique-id",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Stage",
+ "parameterName": "Water Level",
+ "latestReading": {"value": 5},
+ "stationReference": "L1234",
+ "unit": "http://qudt.org/1.1/vocab/unit#Meter",
+ "unitName": "m",
+ }
+ ],
+ },
+ )
+
+ state = hass.states.get("sensor.my_station_water_level_stage")
+ assert state.state == "5"
+ assert state.attributes["unit_of_measurement"] == "m"
+
+
+async def test_multiple_readings_are_sampled(hass, mock_get_station):
+ """Test that multiple sensors are added and polled."""
+ await async_setup_test_fixture(
+ hass,
+ mock_get_station,
+ {
+ "label": "My station",
+ "measures": [
+ {
+ "@id": "really-long-unique-id",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Stage",
+ "parameterName": "Water Level",
+ "latestReading": {"value": 5},
+ "stationReference": "L1234",
+ "unit": "http://qudt.org/1.1/vocab/unit#Meter",
+ "unitName": "m",
+ },
+ {
+ "@id": "really-long-unique-id-2",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Second Stage",
+ "parameterName": "Water Level",
+ "latestReading": {"value": 4},
+ "stationReference": "L1234",
+ "unit": "http://qudt.org/1.1/vocab/unit#Meter",
+ "unitName": "m",
+ },
+ ],
+ },
+ )
+
+ state = hass.states.get("sensor.my_station_water_level_stage")
+ assert state.state == "5"
+ assert state.attributes["unit_of_measurement"] == "m"
+
+ state = hass.states.get("sensor.my_station_water_level_second_stage")
+ assert state.state == "4"
+ assert state.attributes["unit_of_measurement"] == "m"
+
+
+async def test_ignore_no_latest_reading(hass, mock_get_station):
+ """Test that a measure is ignored if it has no latest reading."""
+ await async_setup_test_fixture(
+ hass,
+ mock_get_station,
+ {
+ "label": "My station",
+ "measures": [
+ {
+ "@id": "really-long-unique-id",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Stage",
+ "parameterName": "Water Level",
+ "latestReading": {"value": 5},
+ "stationReference": "L1234",
+ "unit": "http://qudt.org/1.1/vocab/unit#Meter",
+ "unitName": "m",
+ },
+ {
+ "@id": "really-long-unique-id-2",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Second Stage",
+ "parameterName": "Water Level",
+ "stationReference": "L1234",
+ "unit": "http://qudt.org/1.1/vocab/unit#Meter",
+ "unitName": "m",
+ },
+ ],
+ },
+ )
+
+ state = hass.states.get("sensor.my_station_water_level_stage")
+ assert state.state == "5"
+ assert state.attributes["unit_of_measurement"] == "m"
+
+ state = hass.states.get("sensor.my_station_water_level_second_stage")
+ assert state is None
+
+
+async def test_mark_existing_as_unavailable_if_no_latest(hass, mock_get_station):
+ """Test that a measure is marked as unavailable if it has no latest reading."""
+ _, poll = await async_setup_test_fixture(
+ hass,
+ mock_get_station,
+ {
+ "label": "My station",
+ "measures": [
+ {
+ "@id": "really-long-unique-id",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Stage",
+ "parameterName": "Water Level",
+ "latestReading": {"value": 5},
+ "stationReference": "L1234",
+ "unit": "http://qudt.org/1.1/vocab/unit#Meter",
+ "unitName": "m",
+ }
+ ],
+ },
+ )
+
+ state = hass.states.get("sensor.my_station_water_level_stage")
+ assert state.state == "5"
+ assert state.attributes["unit_of_measurement"] == "m"
+
+ await poll(
+ {
+ "label": "My station",
+ "measures": [
+ {
+ "@id": "really-long-unique-id",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Stage",
+ "parameterName": "Water Level",
+ "stationReference": "L1234",
+ "unit": "http://qudt.org/1.1/vocab/unit#Meter",
+ "unitName": "m",
+ }
+ ],
+ }
+ )
+ state = hass.states.get("sensor.my_station_water_level_stage")
+ assert state.state == "unavailable"
+
+ await poll(
+ {
+ "label": "My station",
+ "measures": [
+ {
+ "@id": "really-long-unique-id",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Stage",
+ "parameterName": "Water Level",
+ "latestReading": {"value": 5},
+ "stationReference": "L1234",
+ "unit": "http://qudt.org/1.1/vocab/unit#Meter",
+ "unitName": "m",
+ }
+ ],
+ }
+ )
+ state = hass.states.get("sensor.my_station_water_level_stage")
+ assert state.state == "5"
+
+
+async def test_unload_entry(hass, mock_get_station):
+ """Test being able to unload an entry."""
+ entry, _ = await async_setup_test_fixture(
+ hass,
+ mock_get_station,
+ {
+ "label": "My station",
+ "measures": [
+ {
+ "@id": "really-long-unique-id",
+ "label": "York Viking Recorder - level-stage-i-15_min----",
+ "qualifier": "Stage",
+ "parameterName": "Water Level",
+ "latestReading": {"value": 5},
+ "stationReference": "L1234",
+ "unit": "http://qudt.org/1.1/vocab/unit#Meter",
+ "unitName": "m",
+ }
+ ],
+ },
+ )
+
+ # And there should be an entity
+ state = hass.states.get("sensor.my_station_water_level_stage")
+ assert state.state == "5"
+
+ assert await entry.async_unload(hass)
+
+ # And the entity should be gone
+ assert not hass.states.get("sensor.my_station_water_level_stage")
diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py
index 7d1d928cef8..1cf01bdb96c 100644
--- a/tests/components/efergy/test_sensor.py
+++ b/tests/components/efergy/test_sensor.py
@@ -35,14 +35,16 @@ def mock_responses(mock):
"""Mock responses for Efergy."""
base_url = "https://engage.efergy.com/mobile_proxy/"
mock.get(
- f"{base_url}getInstant?token={token}", text=load_fixture("efergy_instant.json"),
+ f"{base_url}getInstant?token={token}",
+ text=load_fixture("efergy_instant.json"),
)
mock.get(
f"{base_url}getEnergy?token={token}&offset=300&period=day",
text=load_fixture("efergy_energy.json"),
)
mock.get(
- f"{base_url}getBudget?token={token}", text=load_fixture("efergy_budget.json"),
+ f"{base_url}getBudget?token={token}",
+ text=load_fixture("efergy_budget.json"),
)
mock.get(
f"{base_url}getCost?token={token}&offset=300&period=day",
diff --git a/tests/components/elgato/__init__.py b/tests/components/elgato/__init__.py
index 95791161a1f..5f0f2f5fb14 100644
--- a/tests/components/elgato/__init__.py
+++ b/tests/components/elgato/__init__.py
@@ -9,7 +9,9 @@ from tests.test_util.aiohttp import AiohttpClientMocker
async def init_integration(
- hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, skip_setup: bool = False,
+ hass: HomeAssistant,
+ aioclient_mock: AiohttpClientMocker,
+ skip_setup: bool = False,
) -> MockConfigEntry:
"""Set up the Elgato Key Light integration in Home Assistant."""
diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py
index 7d50aec2e22..bd65811133a 100644
--- a/tests/components/elgato/test_config_flow.py
+++ b/tests/components/elgato/test_config_flow.py
@@ -17,7 +17,8 @@ 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},
+ config_flow.DOMAIN,
+ context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
@@ -179,7 +180,8 @@ async def test_full_user_flow_implementation(
)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": SOURCE_USER},
+ config_flow.DOMAIN,
+ context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
diff --git a/tests/components/elgato/test_light.py b/tests/components/elgato/test_light.py
index 13898dad757..838608c0aac 100644
--- a/tests/components/elgato/test_light.py
+++ b/tests/components/elgato/test_light.py
@@ -51,7 +51,8 @@ async def test_light_change_state(
assert state.state == STATE_ON
with patch(
- "homeassistant.components.elgato.light.Elgato.light", return_value=mock_coro(),
+ "homeassistant.components.elgato.light.Elgato.light",
+ return_value=mock_coro(),
) as mock_light:
await hass.services.async_call(
LIGHT_DOMAIN,
@@ -68,7 +69,8 @@ async def test_light_change_state(
mock_light.assert_called_with(on=True, brightness=100, temperature=100)
with patch(
- "homeassistant.components.elgato.light.Elgato.light", return_value=mock_coro(),
+ "homeassistant.components.elgato.light.Elgato.light",
+ return_value=mock_coro(),
) as mock_light:
await hass.services.async_call(
LIGHT_DOMAIN,
@@ -87,7 +89,8 @@ async def test_light_unavailable(
"""Test error/unavailable handling of an Elgato Key Light."""
await init_integration(hass, aioclient_mock)
with patch(
- "homeassistant.components.elgato.light.Elgato.light", side_effect=ElgatoError,
+ "homeassistant.components.elgato.light.Elgato.light",
+ side_effect=ElgatoError,
):
with patch(
"homeassistant.components.elgato.light.Elgato.state",
diff --git a/tests/components/elkm1/test_config_flow.py b/tests/components/elkm1/test_config_flow.py
index 992483529a5..0a959cf1b97 100644
--- a/tests/components/elkm1/test_config_flow.py
+++ b/tests/components/elkm1/test_config_flow.py
@@ -26,11 +26,13 @@ async def test_form_user_with_secure_elk(hass):
mocked_elk = mock_elk(invalid_auth=False)
with patch(
- "homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
+ "homeassistant.components.elkm1.config_flow.elkm1.Elk",
+ return_value=mocked_elk,
), patch(
"homeassistant.components.elkm1.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.elkm1.async_setup_entry", return_value=True,
+ "homeassistant.components.elkm1.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -71,11 +73,13 @@ async def test_form_user_with_non_secure_elk(hass):
mocked_elk = mock_elk(invalid_auth=False)
with patch(
- "homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
+ "homeassistant.components.elkm1.config_flow.elkm1.Elk",
+ return_value=mocked_elk,
), patch(
"homeassistant.components.elkm1.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.elkm1.async_setup_entry", return_value=True,
+ "homeassistant.components.elkm1.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -114,11 +118,13 @@ async def test_form_user_with_serial_elk(hass):
mocked_elk = mock_elk(invalid_auth=False)
with patch(
- "homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
+ "homeassistant.components.elkm1.config_flow.elkm1.Elk",
+ return_value=mocked_elk,
), patch(
"homeassistant.components.elkm1.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.elkm1.async_setup_entry", return_value=True,
+ "homeassistant.components.elkm1.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -154,7 +160,8 @@ async def test_form_cannot_connect(hass):
mocked_elk = mock_elk(invalid_auth=False)
with patch(
- "homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
+ "homeassistant.components.elkm1.config_flow.elkm1.Elk",
+ return_value=mocked_elk,
), patch(
"homeassistant.components.elkm1.config_flow.async_wait_for_elk_to_sync",
return_value=False,
@@ -184,7 +191,8 @@ async def test_form_invalid_auth(hass):
mocked_elk = mock_elk(invalid_auth=True)
with patch(
- "homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
+ "homeassistant.components.elkm1.config_flow.elkm1.Elk",
+ return_value=mocked_elk,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -208,11 +216,13 @@ async def test_form_import(hass):
mocked_elk = mock_elk(invalid_auth=False)
with patch(
- "homeassistant.components.elkm1.config_flow.elkm1.Elk", return_value=mocked_elk,
+ "homeassistant.components.elkm1.config_flow.elkm1.Elk",
+ return_value=mocked_elk,
), patch(
"homeassistant.components.elkm1.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.elkm1.async_setup_entry", return_value=True,
+ "homeassistant.components.elkm1.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py
index fae391ce5ff..0f61178107d 100644
--- a/tests/components/emulated_hue/test_hue_api.py
+++ b/tests/components/emulated_hue/test_hue_api.py
@@ -96,7 +96,7 @@ def hass_hue(loop, hass):
)
)
- with patch("homeassistant.components.emulated_hue.UPNPResponderThread"):
+ with patch("homeassistant.components.emulated_hue.create_upnp_datagram_endpoint"):
loop.run_until_complete(
setup.async_setup_component(
hass,
@@ -283,7 +283,37 @@ async def test_light_without_brightness_supported(hass_hue, hue_client):
assert light_without_brightness_json["state"][HUE_API_STATE_ON] is True
assert light_without_brightness_json["type"] == "On/Off light"
- # BRI required for alexa compat
+
+async def test_lights_all_dimmable(hass, aiohttp_client):
+ """Test CONF_LIGHTS_ALL_DIMMABLE."""
+ # create a lamp without brightness support
+ hass.states.async_set("light.no_brightness", "on", {})
+ await setup.async_setup_component(
+ hass, http.DOMAIN, {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}}
+ )
+ await hass.async_block_till_done()
+ hue_config = {
+ emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT,
+ emulated_hue.CONF_EXPOSE_BY_DEFAULT: True,
+ emulated_hue.CONF_LIGHTS_ALL_DIMMABLE: True,
+ }
+ with patch("homeassistant.components.emulated_hue.create_upnp_datagram_endpoint"):
+ await setup.async_setup_component(
+ hass,
+ emulated_hue.DOMAIN,
+ {emulated_hue.DOMAIN: hue_config},
+ )
+ await hass.async_block_till_done()
+ config = Config(None, hue_config)
+ config.numbers = ENTITY_IDS_BY_NUMBER
+ web_app = hass.http.app
+ HueOneLightStateView(config).register(web_app, web_app.router)
+ client = await aiohttp_client(web_app)
+ light_without_brightness_json = await perform_get_light_state(
+ client, "light.no_brightness", HTTP_OK
+ )
+ assert light_without_brightness_json["state"][HUE_API_STATE_ON] is True
+ assert light_without_brightness_json["type"] == "Dimmable light"
assert (
light_without_brightness_json["state"][HUE_API_STATE_BRI]
== HUE_API_STATE_BRI_MAX
@@ -474,6 +504,24 @@ async def test_discover_config(hue_client):
assert "linkbutton" in config_json
assert config_json["linkbutton"] is True
+ # Test without username
+ result = await hue_client.get("/api/config")
+
+ assert result.status == 200
+ assert "application/json" in result.headers["content-type"]
+
+ config_json = await result.json()
+ assert "error" not in config_json
+
+ # Test with wrong username username
+ result = await hue_client.get("/api/wronguser/config")
+
+ assert result.status == 200
+ assert "application/json" in result.headers["content-type"]
+
+ config_json = await result.json()
+ assert "error" not in config_json
+
async def test_get_light_state(hass_hue, hue_client):
"""Test the getting of light state."""
@@ -1164,7 +1212,7 @@ async def test_external_ip_blocked(hue_client):
postUrls = ["/api"]
putUrls = ["/api/username/lights/light.ceiling_lights/state"]
with patch(
- "homeassistant.components.http.real_ip.ip_address",
+ "homeassistant.components.emulated_hue.hue_api.ip_address",
return_value=ip_address("45.45.45.45"),
):
for getUrl in getUrls:
@@ -1184,7 +1232,6 @@ async def test_unauthorized_user_blocked(hue_client):
"""Test unauthorized_user blocked."""
getUrls = [
"/api/wronguser",
- "/api/wronguser/config",
]
for getUrl in getUrls:
result = await hue_client.get(getUrl)
diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py
index a6040e8db68..8a5556b1222 100644
--- a/tests/components/emulated_hue/test_upnp.py
+++ b/tests/components/emulated_hue/test_upnp.py
@@ -8,9 +8,9 @@ import requests
from homeassistant import const, setup
from homeassistant.components import emulated_hue
+from homeassistant.components.emulated_hue import upnp
from homeassistant.const import HTTP_OK
-from tests.async_mock import patch
from tests.common import get_test_home_assistant, get_test_instance_port
HTTP_SERVER_PORT = get_test_instance_port()
@@ -20,6 +20,18 @@ BRIDGE_URL_BASE = f"http://127.0.0.1:{BRIDGE_SERVER_PORT}" + "{}"
JSON_HEADERS = {CONTENT_TYPE: const.CONTENT_TYPE_JSON}
+class MockTransport:
+ """Mock asyncio transport."""
+
+ def __init__(self):
+ """Create a place to store the sends."""
+ self.sends = []
+
+ def sendto(self, response, addr):
+ """Mock sendto."""
+ self.sends.append((response, addr))
+
+
class TestEmulatedHue(unittest.TestCase):
"""Test the emulated Hue component."""
@@ -30,16 +42,11 @@ class TestEmulatedHue(unittest.TestCase):
"""Set up the class."""
cls.hass = hass = get_test_home_assistant()
- with patch("homeassistant.components.emulated_hue.UPNPResponderThread"):
- setup.setup_component(
- hass,
- emulated_hue.DOMAIN,
- {
- emulated_hue.DOMAIN: {
- emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT
- }
- },
- )
+ setup.setup_component(
+ hass,
+ emulated_hue.DOMAIN,
+ {emulated_hue.DOMAIN: {emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT}},
+ )
cls.hass.start()
@@ -50,23 +57,24 @@ class TestEmulatedHue(unittest.TestCase):
def test_upnp_discovery_basic(self):
"""Tests the UPnP basic discovery response."""
- with patch("threading.Thread.__init__"):
- upnp_responder_thread = emulated_hue.UPNPResponderThread(
- "0.0.0.0", 80, True, "192.0.2.42", 8080
- )
+ upnp_responder_protocol = upnp.UPNPResponderProtocol(
+ None, None, "192.0.2.42", 8080
+ )
+ mock_transport = MockTransport()
+ upnp_responder_protocol.transport = mock_transport
- """Original request emitted by the Hue Bridge v1 app."""
- request = """M-SEARCH * HTTP/1.1
+ """Original request emitted by the Hue Bridge v1 app."""
+ request = """M-SEARCH * HTTP/1.1
HOST:239.255.255.250:1900
ST:ssdp:all
Man:"ssdp:discover"
MX:3
"""
- encoded_request = request.replace("\n", "\r\n").encode("utf-8")
+ encoded_request = request.replace("\n", "\r\n").encode("utf-8")
- response = upnp_responder_thread._handle_request(encoded_request)
- expected_response = """HTTP/1.1 200 OK
+ upnp_responder_protocol.datagram_received(encoded_request, 1234)
+ expected_response = """HTTP/1.1 200 OK
CACHE-CONTROL: max-age=60
EXT:
LOCATION: http://192.0.2.42:8080/description.xml
@@ -76,27 +84,30 @@ ST: urn:schemas-upnp-org:device:basic:1
USN: uuid:2f402f80-da50-11e1-9b23-001788255acc
"""
- assert expected_response.replace("\n", "\r\n").encode("utf-8") == response
+ expected_send = expected_response.replace("\n", "\r\n").encode("utf-8")
+
+ assert mock_transport.sends == [(expected_send, 1234)]
def test_upnp_discovery_rootdevice(self):
"""Tests the UPnP rootdevice discovery response."""
- with patch("threading.Thread.__init__"):
- upnp_responder_thread = emulated_hue.UPNPResponderThread(
- "0.0.0.0", 80, True, "192.0.2.42", 8080
- )
+ upnp_responder_protocol = upnp.UPNPResponderProtocol(
+ None, None, "192.0.2.42", 8080
+ )
+ mock_transport = MockTransport()
+ upnp_responder_protocol.transport = mock_transport
- """Original request emitted by Busch-Jaeger free@home SysAP."""
- request = """M-SEARCH * HTTP/1.1
+ """Original request emitted by Busch-Jaeger free@home SysAP."""
+ request = """M-SEARCH * HTTP/1.1
HOST: 239.255.255.250:1900
MAN: "ssdp:discover"
MX: 40
ST: upnp:rootdevice
"""
- encoded_request = request.replace("\n", "\r\n").encode("utf-8")
+ encoded_request = request.replace("\n", "\r\n").encode("utf-8")
- response = upnp_responder_thread._handle_request(encoded_request)
- expected_response = """HTTP/1.1 200 OK
+ upnp_responder_protocol.datagram_received(encoded_request, 1234)
+ expected_response = """HTTP/1.1 200 OK
CACHE-CONTROL: max-age=60
EXT:
LOCATION: http://192.0.2.42:8080/description.xml
@@ -106,7 +117,31 @@ ST: upnp:rootdevice
USN: uuid:2f402f80-da50-11e1-9b23-001788255acc::upnp:rootdevice
"""
- assert expected_response.replace("\n", "\r\n").encode("utf-8") == response
+ expected_send = expected_response.replace("\n", "\r\n").encode("utf-8")
+
+ assert mock_transport.sends == [(expected_send, 1234)]
+
+ def test_upnp_no_response(self):
+ """Tests the UPnP does not response on an invalid request."""
+ upnp_responder_protocol = upnp.UPNPResponderProtocol(
+ None, None, "192.0.2.42", 8080
+ )
+ mock_transport = MockTransport()
+ upnp_responder_protocol.transport = mock_transport
+
+ """Original request emitted by the Hue Bridge v1 app."""
+ request = """INVALID * HTTP/1.1
+HOST:239.255.255.250:1900
+ST:ssdp:all
+Man:"ssdp:discover"
+MX:3
+
+"""
+ encoded_request = request.replace("\n", "\r\n").encode("utf-8")
+
+ upnp_responder_protocol.datagram_received(encoded_request, 1234)
+
+ assert mock_transport.sends == []
def test_description_xml(self):
"""Test the description."""
diff --git a/tests/components/emulated_kasa/__init__.py b/tests/components/emulated_kasa/__init__.py
new file mode 100644
index 00000000000..a762eeca16e
--- /dev/null
+++ b/tests/components/emulated_kasa/__init__.py
@@ -0,0 +1 @@
+"""Tests for emulated_kasa."""
diff --git a/tests/components/emulated_kasa/test_init.py b/tests/components/emulated_kasa/test_init.py
new file mode 100644
index 00000000000..10ccb4d68a5
--- /dev/null
+++ b/tests/components/emulated_kasa/test_init.py
@@ -0,0 +1,495 @@
+"""Tests for emulated_kasa library bindings."""
+import math
+
+from homeassistant.components import emulated_kasa
+from homeassistant.components.emulated_kasa.const import (
+ CONF_POWER,
+ CONF_POWER_ENTITY,
+ DOMAIN,
+)
+from homeassistant.components.fan import (
+ ATTR_SPEED,
+ DOMAIN as FAN_DOMAIN,
+ SERVICE_SET_SPEED,
+)
+from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.components.switch import (
+ ATTR_CURRENT_POWER_W,
+ DOMAIN as SWITCH_DOMAIN,
+)
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ ATTR_FRIENDLY_NAME,
+ CONF_ENTITIES,
+ CONF_NAME,
+ SERVICE_TURN_OFF,
+ SERVICE_TURN_ON,
+ STATE_ON,
+)
+from homeassistant.setup import async_setup_component
+
+from tests.async_mock import AsyncMock, Mock, patch
+
+ENTITY_SWITCH = "switch.ac"
+ENTITY_SWITCH_NAME = "A/C"
+ENTITY_SWITCH_POWER = 400.0
+ENTITY_LIGHT = "light.bed_light"
+ENTITY_LIGHT_NAME = "Bed Room Lights"
+ENTITY_FAN = "fan.ceiling_fan"
+ENTITY_FAN_NAME = "Ceiling Fan"
+ENTITY_FAN_SPEED_LOW = 5
+ENTITY_FAN_SPEED_MED = 10
+ENTITY_FAN_SPEED_HIGH = 50
+ENTITY_SENSOR = "sensor.outside_temperature"
+ENTITY_SENSOR_NAME = "Power Sensor"
+
+CONFIG = {
+ DOMAIN: {
+ CONF_ENTITIES: {
+ ENTITY_SWITCH: {
+ CONF_NAME: ENTITY_SWITCH_NAME,
+ CONF_POWER: ENTITY_SWITCH_POWER,
+ },
+ ENTITY_LIGHT: {
+ CONF_NAME: ENTITY_LIGHT_NAME,
+ CONF_POWER_ENTITY: ENTITY_SENSOR,
+ },
+ ENTITY_FAN: {
+ CONF_POWER: "{% if is_state_attr('"
+ + ENTITY_FAN
+ + "','speed', 'low') %} "
+ + str(ENTITY_FAN_SPEED_LOW)
+ + "{% elif is_state_attr('"
+ + ENTITY_FAN
+ + "','speed', 'medium') %} "
+ + str(ENTITY_FAN_SPEED_MED)
+ + "{% elif is_state_attr('"
+ + ENTITY_FAN
+ + "','speed', 'high') %} "
+ + str(ENTITY_FAN_SPEED_HIGH)
+ + "{% endif %}"
+ },
+ }
+ }
+}
+
+CONFIG_SWITCH = {
+ DOMAIN: {
+ CONF_ENTITIES: {
+ ENTITY_SWITCH: {
+ CONF_NAME: ENTITY_SWITCH_NAME,
+ CONF_POWER: ENTITY_SWITCH_POWER,
+ },
+ }
+ }
+}
+
+CONFIG_SWITCH_NO_POWER = {
+ DOMAIN: {
+ CONF_ENTITIES: {
+ ENTITY_SWITCH: {},
+ }
+ }
+}
+
+CONFIG_LIGHT = {
+ DOMAIN: {
+ CONF_ENTITIES: {
+ ENTITY_LIGHT: {
+ CONF_NAME: ENTITY_LIGHT_NAME,
+ CONF_POWER_ENTITY: ENTITY_SENSOR,
+ },
+ }
+ }
+}
+
+CONFIG_FAN = {
+ DOMAIN: {
+ CONF_ENTITIES: {
+ ENTITY_FAN: {
+ CONF_POWER: "{% if is_state_attr('"
+ + ENTITY_FAN
+ + "','speed', 'low') %} "
+ + str(ENTITY_FAN_SPEED_LOW)
+ + "{% elif is_state_attr('"
+ + ENTITY_FAN
+ + "','speed', 'medium') %} "
+ + str(ENTITY_FAN_SPEED_MED)
+ + "{% elif is_state_attr('"
+ + ENTITY_FAN
+ + "','speed', 'high') %} "
+ + str(ENTITY_FAN_SPEED_HIGH)
+ + "{% endif %}"
+ },
+ }
+ }
+}
+
+CONFIG_SENSOR = {
+ DOMAIN: {
+ CONF_ENTITIES: {
+ ENTITY_SENSOR: {CONF_NAME: ENTITY_SENSOR_NAME},
+ }
+ }
+}
+
+
+def nested_value(ndict, *keys):
+ """Return a nested dict value or None if it doesn't exist."""
+ if len(keys) == 0:
+ return ndict
+ key = keys[0]
+ if not isinstance(ndict, dict) or key not in ndict:
+ return None
+ return nested_value(ndict[key], *keys[1:])
+
+
+async def test_setup(hass):
+ """Test that devices are reported correctly."""
+ with patch(
+ "sense_energy.SenseLink",
+ return_value=Mock(start=AsyncMock(), close=AsyncMock()),
+ ):
+ assert await async_setup_component(hass, DOMAIN, CONFIG) is True
+
+
+async def test_float(hass):
+ """Test a configuration using a simple float."""
+ config = CONFIG_SWITCH[DOMAIN][CONF_ENTITIES]
+ assert await async_setup_component(
+ hass,
+ SWITCH_DOMAIN,
+ {SWITCH_DOMAIN: {"platform": "demo"}},
+ )
+ with patch(
+ "sense_energy.SenseLink",
+ return_value=Mock(start=AsyncMock(), close=AsyncMock()),
+ ):
+ assert await async_setup_component(hass, DOMAIN, CONFIG_SWITCH) is True
+ await hass.async_block_till_done()
+ await emulated_kasa.validate_configs(hass, config)
+
+ # Turn switch on
+ await hass.services.async_call(
+ SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SWITCH}, blocking=True
+ )
+
+ switch = hass.states.get(ENTITY_SWITCH)
+ assert switch.state == STATE_ON
+
+ plug_it = emulated_kasa.get_plug_devices(hass, config)
+ plug = next(plug_it).generate_response()
+
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_SWITCH_NAME
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, ENTITY_SWITCH_POWER)
+
+ # Turn off
+ await hass.services.async_call(
+ SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_SWITCH}, blocking=True
+ )
+
+ plug_it = emulated_kasa.get_plug_devices(hass, config)
+ plug = next(plug_it).generate_response()
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_SWITCH_NAME
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, 0)
+
+
+async def test_switch_power(hass):
+ """Test a configuration using a simple float."""
+ config = CONFIG_SWITCH_NO_POWER[DOMAIN][CONF_ENTITIES]
+ assert await async_setup_component(
+ hass,
+ SWITCH_DOMAIN,
+ {SWITCH_DOMAIN: {"platform": "demo"}},
+ )
+ with patch(
+ "sense_energy.SenseLink",
+ return_value=Mock(start=AsyncMock(), close=AsyncMock()),
+ ):
+ assert await async_setup_component(hass, DOMAIN, CONFIG_SWITCH_NO_POWER) is True
+ await hass.async_block_till_done()
+ await emulated_kasa.validate_configs(hass, config)
+
+ # Turn switch on
+ await hass.services.async_call(
+ SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SWITCH}, blocking=True
+ )
+
+ switch = hass.states.get(ENTITY_SWITCH)
+ assert switch.state == STATE_ON
+ power = switch.attributes[ATTR_CURRENT_POWER_W]
+ assert power == 100
+ assert switch.name == "AC"
+
+ plug_it = emulated_kasa.get_plug_devices(hass, config)
+ plug = next(plug_it).generate_response()
+
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == "AC"
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, power)
+
+ hass.states.async_set(
+ ENTITY_SWITCH,
+ STATE_ON,
+ attributes={ATTR_CURRENT_POWER_W: 120, ATTR_FRIENDLY_NAME: "AC"},
+ )
+
+ plug_it = emulated_kasa.get_plug_devices(hass, config)
+ plug = next(plug_it).generate_response()
+
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == "AC"
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, 120)
+
+ # Turn off
+ await hass.services.async_call(
+ SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_SWITCH}, blocking=True
+ )
+
+ plug_it = emulated_kasa.get_plug_devices(hass, config)
+ plug = next(plug_it).generate_response()
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == "AC"
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, 0)
+
+
+async def test_template(hass):
+ """Test a configuration using a complex template."""
+ config = CONFIG_FAN[DOMAIN][CONF_ENTITIES]
+ assert await async_setup_component(
+ hass, FAN_DOMAIN, {FAN_DOMAIN: {"platform": "demo"}}
+ )
+ with patch(
+ "sense_energy.SenseLink",
+ return_value=Mock(start=AsyncMock(), close=AsyncMock()),
+ ):
+ assert await async_setup_component(hass, DOMAIN, CONFIG_FAN) is True
+ await hass.async_block_till_done()
+ await emulated_kasa.validate_configs(hass, config)
+
+ # Turn all devices on to known state
+ await hass.services.async_call(
+ FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_FAN}, blocking=True
+ )
+ await hass.services.async_call(
+ FAN_DOMAIN,
+ SERVICE_SET_SPEED,
+ {ATTR_ENTITY_ID: ENTITY_FAN, ATTR_SPEED: "low"},
+ blocking=True,
+ )
+
+ fan = hass.states.get(ENTITY_FAN)
+ assert fan.state == STATE_ON
+
+ # Fan low:
+ plug_it = emulated_kasa.get_plug_devices(hass, config)
+ plug = next(plug_it).generate_response()
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_FAN_NAME
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, ENTITY_FAN_SPEED_LOW)
+
+ # Fan High:
+ await hass.services.async_call(
+ FAN_DOMAIN,
+ SERVICE_SET_SPEED,
+ {ATTR_ENTITY_ID: ENTITY_FAN, ATTR_SPEED: "high"},
+ blocking=True,
+ )
+ plug_it = emulated_kasa.get_plug_devices(hass, config)
+ plug = next(plug_it).generate_response()
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_FAN_NAME
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, ENTITY_FAN_SPEED_HIGH)
+
+ # Fan off:
+ await hass.services.async_call(
+ FAN_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_FAN}, blocking=True
+ )
+ plug_it = emulated_kasa.get_plug_devices(hass, config)
+ plug = next(plug_it).generate_response()
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_FAN_NAME
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, 0)
+
+
+async def test_sensor(hass):
+ """Test a configuration using a sensor in a template."""
+ config = CONFIG_LIGHT[DOMAIN][CONF_ENTITIES]
+ assert await async_setup_component(
+ hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {"platform": "demo"}}
+ )
+ assert await async_setup_component(
+ hass,
+ SENSOR_DOMAIN,
+ {SENSOR_DOMAIN: {"platform": "demo"}},
+ )
+ with patch(
+ "sense_energy.SenseLink",
+ return_value=Mock(start=AsyncMock(), close=AsyncMock()),
+ ):
+ assert await async_setup_component(hass, DOMAIN, CONFIG_LIGHT) is True
+ await hass.async_block_till_done()
+ await emulated_kasa.validate_configs(hass, config)
+
+ await hass.services.async_call(
+ LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_LIGHT}, blocking=True
+ )
+ hass.states.async_set(ENTITY_SENSOR, 35)
+
+ light = hass.states.get(ENTITY_LIGHT)
+ assert light.state == STATE_ON
+ sensor = hass.states.get(ENTITY_SENSOR)
+ assert sensor.state == "35"
+
+ # light
+ plug_it = emulated_kasa.get_plug_devices(hass, config)
+ plug = next(plug_it).generate_response()
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_LIGHT_NAME
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, 35)
+
+ # change power sensor
+ hass.states.async_set(ENTITY_SENSOR, 40)
+
+ plug_it = emulated_kasa.get_plug_devices(hass, config)
+ plug = next(plug_it).generate_response()
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_LIGHT_NAME
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, 40)
+
+ # report 0 if device is off
+ await hass.services.async_call(
+ LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_LIGHT}, blocking=True
+ )
+
+ plug_it = emulated_kasa.get_plug_devices(hass, config)
+ plug = next(plug_it).generate_response()
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_LIGHT_NAME
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, 0)
+
+
+async def test_sensor_state(hass):
+ """Test a configuration using a sensor in a template."""
+ config = CONFIG_SENSOR[DOMAIN][CONF_ENTITIES]
+ assert await async_setup_component(
+ hass,
+ SENSOR_DOMAIN,
+ {SENSOR_DOMAIN: {"platform": "demo"}},
+ )
+ with patch(
+ "sense_energy.SenseLink",
+ return_value=Mock(start=AsyncMock(), close=AsyncMock()),
+ ):
+ assert await async_setup_component(hass, DOMAIN, CONFIG_SENSOR) is True
+ await hass.async_block_till_done()
+ await emulated_kasa.validate_configs(hass, config)
+
+ hass.states.async_set(ENTITY_SENSOR, 35)
+
+ sensor = hass.states.get(ENTITY_SENSOR)
+ assert sensor.state == "35"
+
+ # sensor
+ plug_it = emulated_kasa.get_plug_devices(hass, config)
+ plug = next(plug_it).generate_response()
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_SENSOR_NAME
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, 35)
+
+ # change power sensor
+ hass.states.async_set(ENTITY_SENSOR, 40)
+
+ plug_it = emulated_kasa.get_plug_devices(hass, config)
+ plug = next(plug_it).generate_response()
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_SENSOR_NAME
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, 40)
+
+ # report 0 if device is off
+ hass.states.async_set(ENTITY_SENSOR, 0)
+
+ plug_it = emulated_kasa.get_plug_devices(hass, config)
+ plug = next(plug_it).generate_response()
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_SENSOR_NAME
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, 0)
+
+
+async def test_multiple_devices(hass):
+ """Test that devices are reported correctly."""
+ config = CONFIG[DOMAIN][CONF_ENTITIES]
+ assert await async_setup_component(
+ hass, SWITCH_DOMAIN, {SWITCH_DOMAIN: {"platform": "demo"}}
+ )
+ assert await async_setup_component(
+ hass, LIGHT_DOMAIN, {LIGHT_DOMAIN: {"platform": "demo"}}
+ )
+ assert await async_setup_component(
+ hass, FAN_DOMAIN, {FAN_DOMAIN: {"platform": "demo"}}
+ )
+ assert await async_setup_component(
+ hass,
+ SENSOR_DOMAIN,
+ {SENSOR_DOMAIN: {"platform": "demo"}},
+ )
+ with patch(
+ "sense_energy.SenseLink",
+ return_value=Mock(start=AsyncMock(), close=AsyncMock()),
+ ):
+ assert await emulated_kasa.async_setup(hass, CONFIG) is True
+ await hass.async_block_till_done()
+ await emulated_kasa.validate_configs(hass, config)
+
+ # Turn all devices on to known state
+ await hass.services.async_call(
+ SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_SWITCH}, blocking=True
+ )
+ await hass.services.async_call(
+ LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_LIGHT}, blocking=True
+ )
+ hass.states.async_set(ENTITY_SENSOR, 35)
+ await hass.services.async_call(
+ FAN_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_FAN}, blocking=True
+ )
+ await hass.services.async_call(
+ FAN_DOMAIN,
+ SERVICE_SET_SPEED,
+ {ATTR_ENTITY_ID: ENTITY_FAN, ATTR_SPEED: "medium"},
+ blocking=True,
+ )
+
+ # All of them should now be on
+ switch = hass.states.get(ENTITY_SWITCH)
+ assert switch.state == STATE_ON
+ light = hass.states.get(ENTITY_LIGHT)
+ assert light.state == STATE_ON
+ sensor = hass.states.get(ENTITY_SENSOR)
+ assert sensor.state == "35"
+ fan = hass.states.get(ENTITY_FAN)
+ assert fan.state == STATE_ON
+
+ plug_it = emulated_kasa.get_plug_devices(hass, config)
+ # switch
+ plug = next(plug_it).generate_response()
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_SWITCH_NAME
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, ENTITY_SWITCH_POWER)
+
+ # light
+ plug = next(plug_it).generate_response()
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_LIGHT_NAME
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, 35)
+
+ # fan
+ plug = next(plug_it).generate_response()
+ assert nested_value(plug, "system", "get_sysinfo", "alias") == ENTITY_FAN_NAME
+ power = nested_value(plug, "emeter", "get_realtime", "power")
+ assert math.isclose(power, ENTITY_FAN_SPEED_MED)
+
+ # No more devices
+ assert next(plug_it, None) is None
diff --git a/tests/components/enocean/test_config_flow.py b/tests/components/enocean/test_config_flow.py
index aee6765272e..b815d48694a 100644
--- a/tests/components/enocean/test_config_flow.py
+++ b/tests/components/enocean/test_config_flow.py
@@ -120,7 +120,8 @@ async def test_manual_flow_with_invalid_path(hass):
USER_PROVIDED_PATH = "/user/provided/path"
with patch(
- DONGLE_VALIDATE_PATH_METHOD, Mock(return_value=False),
+ DONGLE_VALIDATE_PATH_METHOD,
+ Mock(return_value=False),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "manual"}, data={CONF_DEVICE: USER_PROVIDED_PATH}
@@ -149,7 +150,8 @@ async def test_import_flow_with_invalid_path(hass):
DATA_TO_IMPORT = {CONF_DEVICE: "/invalid/path/to/import"}
with patch(
- DONGLE_VALIDATE_PATH_METHOD, Mock(return_value=False),
+ DONGLE_VALIDATE_PATH_METHOD,
+ Mock(return_value=False),
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "import"}, data=DATA_TO_IMPORT
diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py
index 50aaa989028..164709e86a8 100644
--- a/tests/components/esphome/test_config_flow.py
+++ b/tests/components/esphome/test_config_flow.py
@@ -22,11 +22,12 @@ def mock_client():
"""Mock APIClient."""
with patch("homeassistant.components.esphome.config_flow.APIClient") as mock_client:
- def mock_constructor(loop, host, port, password):
+ def mock_constructor(loop, host, port, password, zeroconf_instance=None):
"""Fake the client constructor."""
mock_client.host = host
mock_client.port = port
mock_client.password = password
+ mock_client.zeroconf_instance = zeroconf_instance
return mock_client
mock_client.side_effect = mock_constructor
@@ -49,7 +50,9 @@ def mock_api_connection_error():
async def test_user_connection_works(hass, mock_client):
"""Test we can finish a config flow."""
result = await hass.config_entries.flow.async_init(
- "esphome", context={"source": "user"}, data=None,
+ "esphome",
+ context={"source": "user"},
+ data=None,
)
assert result["type"] == RESULT_TYPE_FORM
diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py
index 1983cb5ab8d..b50ef5f6619 100644
--- a/tests/components/feedreader/test_init.py
+++ b/tests/components/feedreader/test_init.py
@@ -38,11 +38,14 @@ class TestFeedreaderComponent(unittest.TestCase):
def setUp(self):
"""Initialize values for this testcase class."""
self.hass = get_test_home_assistant()
- # Delete any previously stored data
+ self.addCleanup(self.tear_down_cleanup)
+
+ def tear_down_cleanup(self):
+ """Clean up files and stop Home Assistant."""
data_file = self.hass.config.path(f"{feedreader.DOMAIN}.pickle")
if exists(data_file):
remove(data_file)
- self.addCleanup(self.hass.stop)
+ self.hass.stop()
def test_setup_one_feed(self):
"""Test the general setup of this component."""
diff --git a/tests/components/filesize/test_sensor.py b/tests/components/filesize/test_sensor.py
index 6d35ab0d88e..e0633d69d03 100644
--- a/tests/components/filesize/test_sensor.py
+++ b/tests/components/filesize/test_sensor.py
@@ -2,9 +2,13 @@
import os
import unittest
+from homeassistant import config as hass_config
+from homeassistant.components.filesize import DOMAIN
from homeassistant.components.filesize.sensor import CONF_FILE_PATHS
-from homeassistant.setup import setup_component
+from homeassistant.const import SERVICE_RELOAD
+from homeassistant.setup import async_setup_component, setup_component
+from tests.async_mock import patch
from tests.common import get_test_home_assistant
TEST_DIR = os.path.join(os.path.dirname(__file__))
@@ -47,3 +51,47 @@ class TestFileSensor(unittest.TestCase):
state = self.hass.states.get("sensor.mock_file_test_filesize_txt")
assert state.state == "0.0"
assert state.attributes.get("bytes") == 4
+
+
+async def test_reload(hass, tmpdir):
+ """Verify we can reload filter sensors."""
+ testfile = f"{tmpdir}/file"
+ await hass.async_add_executor_job(create_file, testfile)
+ with patch.object(hass.config, "is_allowed_path", return_value=True):
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": "filesize",
+ "file_paths": [testfile],
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+ assert hass.states.get("sensor.file")
+
+ yaml_path = os.path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "filesize/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path), patch.object(
+ hass.config, "is_allowed_path", return_value=True
+ ):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert hass.states.get("sensor.file") is None
+
+
+def _get_fixtures_base_path():
+ return os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py
index f6eae30c653..0aa390223ca 100644
--- a/tests/components/filter/test_sensor.py
+++ b/tests/components/filter/test_sensor.py
@@ -1,8 +1,11 @@
"""The test for the data filter sensor platform."""
from datetime import timedelta
+from os import path
import unittest
+from homeassistant import config as hass_config
from homeassistant.components.filter.sensor import (
+ DOMAIN,
LowPassFilter,
OutlierFilter,
RangeFilter,
@@ -10,8 +13,9 @@ from homeassistant.components.filter.sensor import (
TimeSMAFilter,
TimeThrottleFilter,
)
+from homeassistant.const import SERVICE_RELOAD
import homeassistant.core as ha
-from homeassistant.setup import setup_component
+from homeassistant.setup import async_setup_component, setup_component
import homeassistant.util.dt as dt_util
from tests.async_mock import patch
@@ -307,3 +311,58 @@ class TestFilterSensor(unittest.TestCase):
for state in self.values:
filtered = filt.filter_state(state)
assert 21.5 == filtered.state
+
+
+async def test_reload(hass):
+ """Verify we can reload filter sensors."""
+ await hass.async_add_executor_job(
+ init_recorder_component, hass
+ ) # force in memory db
+
+ hass.states.async_set("sensor.test_monitored", 12345)
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": "filter",
+ "name": "test",
+ "entity_id": "sensor.test_monitored",
+ "filters": [
+ {"filter": "outlier", "window_size": 10, "radius": 4.0},
+ {"filter": "lowpass", "time_constant": 10, "precision": 2},
+ {"filter": "throttle", "window_size": 1},
+ ],
+ }
+ },
+ )
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 2
+
+ assert hass.states.get("sensor.test")
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "filter/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 2
+
+ assert hass.states.get("sensor.test") is None
+ assert hass.states.get("sensor.filtered_realistic_humidity")
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/flick_electric/test_config_flow.py b/tests/components/flick_electric/test_config_flow.py
index 90a6271aa7d..c8fe6298764 100644
--- a/tests/components/flick_electric/test_config_flow.py
+++ b/tests/components/flick_electric/test_config_flow.py
@@ -15,7 +15,9 @@ CONF = {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}
async def _flow_submit(hass):
return await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_USER}, data=CONF,
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data=CONF,
)
@@ -34,10 +36,12 @@ async def test_form(hass):
), patch(
"homeassistant.components.flick_electric.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.flick_electric.async_setup_entry", return_value=True,
+ "homeassistant.components.flick_electric.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], CONF,
+ result["flow_id"],
+ CONF,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
diff --git a/tests/components/flo/__init__.py b/tests/components/flo/__init__.py
new file mode 100644
index 00000000000..a207193f500
--- /dev/null
+++ b/tests/components/flo/__init__.py
@@ -0,0 +1 @@
+"""Tests for the flo integration."""
diff --git a/tests/components/flo/common.py b/tests/components/flo/common.py
new file mode 100644
index 00000000000..d4018aae090
--- /dev/null
+++ b/tests/components/flo/common.py
@@ -0,0 +1,12 @@
+"""Define common test utilities."""
+TEST_ACCOUNT_ID = "aabbccdd"
+TEST_DEVICE_ID = "98765"
+TEST_EMAIL_ADDRESS = "email@address.com"
+TEST_FIRST_NAME = "Tom"
+TEST_LAST_NAME = "Jones"
+TEST_LOCATION_ID = "mmnnoopp"
+TEST_MAC_ADDRESS = "12:34:56:ab:cd:ef"
+TEST_PASSWORD = "password"
+TEST_PHONE_NUMBER = "+1 123-456-7890"
+TEST_TOKEN = "123abc"
+TEST_USER_ID = "12345abcde"
diff --git a/tests/components/flo/conftest.py b/tests/components/flo/conftest.py
new file mode 100644
index 00000000000..3a835cb0547
--- /dev/null
+++ b/tests/components/flo/conftest.py
@@ -0,0 +1,134 @@
+"""Define fixtures available for all tests."""
+import json
+import time
+
+import pytest
+
+from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+
+from .common import TEST_EMAIL_ADDRESS, TEST_PASSWORD, TEST_TOKEN, TEST_USER_ID
+
+from tests.common import MockConfigEntry, load_fixture
+
+
+@pytest.fixture
+def config_entry(hass):
+ """Config entry version 1 fixture."""
+ return MockConfigEntry(
+ domain=FLO_DOMAIN,
+ data={CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD},
+ version=1,
+ )
+
+
+@pytest.fixture
+def aioclient_mock_fixture(aioclient_mock):
+ """Fixture to provide a aioclient mocker."""
+ now = round(time.time())
+ # Mocks the login response for flo.
+ aioclient_mock.post(
+ "https://api.meetflo.com/api/v1/users/auth",
+ text=json.dumps(
+ {
+ "token": TEST_TOKEN,
+ "tokenPayload": {
+ "user": {"user_id": TEST_USER_ID, "email": TEST_EMAIL_ADDRESS},
+ "timestamp": now,
+ },
+ "tokenExpiration": 86400,
+ "timeNow": now,
+ }
+ ),
+ headers={"Content-Type": "application/json"},
+ status=200,
+ )
+ # Mocks the device for flo.
+ aioclient_mock.get(
+ "https://api-gw.meetflo.com/api/v2/devices/98765",
+ text=load_fixture("flo/device_info_response.json"),
+ status=200,
+ headers={"Content-Type": "application/json"},
+ )
+ # Mocks the water consumption for flo.
+ aioclient_mock.get(
+ "https://api-gw.meetflo.com/api/v2/water/consumption",
+ text=load_fixture("flo/water_consumption_info_response.json"),
+ status=200,
+ headers={"Content-Type": "application/json"},
+ )
+ # Mocks the location info for flo.
+ aioclient_mock.get(
+ "https://api-gw.meetflo.com/api/v2/locations/mmnnoopp",
+ text=load_fixture("flo/location_info_expand_devices_response.json"),
+ status=200,
+ headers={"Content-Type": "application/json"},
+ )
+ # Mocks the user info for flo.
+ aioclient_mock.get(
+ "https://api-gw.meetflo.com/api/v2/users/12345abcde",
+ text=load_fixture("flo/user_info_expand_locations_response.json"),
+ status=200,
+ headers={"Content-Type": "application/json"},
+ params={"expand": "locations"},
+ )
+ # Mocks the user info for flo.
+ aioclient_mock.get(
+ "https://api-gw.meetflo.com/api/v2/users/12345abcde",
+ text=load_fixture("flo/user_info_expand_locations_response.json"),
+ status=200,
+ headers={"Content-Type": "application/json"},
+ )
+ # Mocks the valve open call for flo.
+ aioclient_mock.post(
+ "https://api-gw.meetflo.com/api/v2/devices/98765",
+ text=load_fixture("flo/device_info_response.json"),
+ status=200,
+ headers={"Content-Type": "application/json"},
+ json={"valve": {"target": "open"}},
+ )
+ # Mocks the valve close call for flo.
+ aioclient_mock.post(
+ "https://api-gw.meetflo.com/api/v2/devices/98765",
+ text=load_fixture("flo/device_info_response_closed.json"),
+ status=200,
+ headers={"Content-Type": "application/json"},
+ json={"valve": {"target": "closed"}},
+ )
+ # Mocks the health test call for flo.
+ aioclient_mock.post(
+ "https://api-gw.meetflo.com/api/v2/devices/98765/healthTest/run",
+ text=load_fixture("flo/user_info_expand_locations_response.json"),
+ status=200,
+ headers={"Content-Type": "application/json"},
+ )
+ # Mocks the health test call for flo.
+ aioclient_mock.post(
+ "https://api-gw.meetflo.com/api/v2/locations/mmnnoopp/systemMode",
+ text=load_fixture("flo/user_info_expand_locations_response.json"),
+ status=200,
+ headers={"Content-Type": "application/json"},
+ json={"systemMode": {"target": "home"}},
+ )
+ # Mocks the health test call for flo.
+ aioclient_mock.post(
+ "https://api-gw.meetflo.com/api/v2/locations/mmnnoopp/systemMode",
+ text=load_fixture("flo/user_info_expand_locations_response.json"),
+ status=200,
+ headers={"Content-Type": "application/json"},
+ json={"systemMode": {"target": "away"}},
+ )
+ # Mocks the health test call for flo.
+ aioclient_mock.post(
+ "https://api-gw.meetflo.com/api/v2/locations/mmnnoopp/systemMode",
+ text=load_fixture("flo/user_info_expand_locations_response.json"),
+ status=200,
+ headers={"Content-Type": "application/json"},
+ json={
+ "systemMode": {
+ "target": "sleep",
+ "revertMinutes": 120,
+ "revertMode": "home",
+ }
+ },
+ )
diff --git a/tests/components/flo/test_binary_sensor.py b/tests/components/flo/test_binary_sensor.py
new file mode 100644
index 00000000000..64b8f787a85
--- /dev/null
+++ b/tests/components/flo/test_binary_sensor.py
@@ -0,0 +1,30 @@
+"""Test Flo by Moen binary sensor entities."""
+from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN
+from homeassistant.const import (
+ ATTR_FRIENDLY_NAME,
+ CONF_PASSWORD,
+ CONF_USERNAME,
+ STATE_ON,
+)
+from homeassistant.setup import async_setup_component
+
+from .common import TEST_PASSWORD, TEST_USER_ID
+
+
+async def test_binary_sensors(hass, config_entry, aioclient_mock_fixture):
+ """Test Flo by Moen sensors."""
+ config_entry.add_to_hass(hass)
+ assert await async_setup_component(
+ hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD}
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 1
+
+ # we should have 6 entities for the device
+ state = hass.states.get("binary_sensor.pending_system_alerts")
+ assert state.state == STATE_ON
+ assert state.attributes.get("info") == 0
+ assert state.attributes.get("warning") == 2
+ assert state.attributes.get("critical") == 0
+ assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Pending System Alerts"
diff --git a/tests/components/flo/test_config_flow.py b/tests/components/flo/test_config_flow.py
new file mode 100644
index 00000000000..265f2ae2d38
--- /dev/null
+++ b/tests/components/flo/test_config_flow.py
@@ -0,0 +1,68 @@
+"""Test the flo config flow."""
+import json
+import time
+
+from homeassistant import config_entries, setup
+from homeassistant.components.flo.const import DOMAIN
+
+from .common import TEST_EMAIL_ADDRESS, TEST_PASSWORD, TEST_TOKEN, TEST_USER_ID
+
+from tests.async_mock import patch
+
+
+async def test_form(hass, aioclient_mock_fixture):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch(
+ "homeassistant.components.flo.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.flo.async_setup_entry", return_value=True
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"username": TEST_USER_ID, "password": TEST_PASSWORD}
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "Home"
+ assert result2["data"] == {"username": TEST_USER_ID, "password": TEST_PASSWORD}
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_cannot_connect(hass, aioclient_mock):
+ """Test we handle cannot connect error."""
+ now = round(time.time())
+ # Mocks a failed login response for flo.
+ aioclient_mock.post(
+ "https://api.meetflo.com/api/v1/users/auth",
+ json=json.dumps(
+ {
+ "token": TEST_TOKEN,
+ "tokenPayload": {
+ "user": {"user_id": TEST_USER_ID, "email": TEST_EMAIL_ADDRESS},
+ "timestamp": now,
+ },
+ "tokenExpiration": 86400,
+ "timeNow": now,
+ }
+ ),
+ headers={"Content-Type": "application/json"},
+ status=400,
+ )
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"username": "test-username", "password": "test-password"}
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
diff --git a/tests/components/flo/test_device.py b/tests/components/flo/test_device.py
new file mode 100644
index 00000000000..63e81a16fb4
--- /dev/null
+++ b/tests/components/flo/test_device.py
@@ -0,0 +1,58 @@
+"""Define tests for device-related endpoints."""
+from datetime import timedelta
+
+from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN
+from homeassistant.components.flo.device import FloDeviceDataUpdateCoordinator
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.setup import async_setup_component
+from homeassistant.util import dt
+
+from .common import TEST_PASSWORD, TEST_USER_ID
+
+from tests.common import async_fire_time_changed
+
+
+async def test_device(hass, config_entry, aioclient_mock_fixture, aioclient_mock):
+ """Test Flo by Moen device."""
+ config_entry.add_to_hass(hass)
+ assert await async_setup_component(
+ hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD}
+ )
+ await hass.async_block_till_done()
+ assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 1
+
+ device: FloDeviceDataUpdateCoordinator = hass.data[FLO_DOMAIN][
+ config_entry.entry_id
+ ]["devices"][0]
+ assert device.api_client is not None
+ assert device.available
+ assert device.consumption_today == 3.674
+ assert device.current_flow_rate == 0
+ assert device.current_psi == 54.20000076293945
+ assert device.current_system_mode == "home"
+ assert device.target_system_mode == "home"
+ assert device.firmware_version == "6.1.1"
+ assert device.device_type == "flo_device_v2"
+ assert device.id == "98765"
+ assert device.last_heard_from_time == "2020-07-24T12:45:00Z"
+ assert device.location_id == "mmnnoopp"
+ assert device.hass is not None
+ assert device.temperature == 70
+ assert device.mac_address == "111111111111"
+ assert device.model == "flo_device_075_v2"
+ assert device.manufacturer == "Flo by Moen"
+ assert device.device_name == "Flo by Moen flo_device_075_v2"
+ assert device.rssi == -47
+ assert device.pending_info_alerts_count == 0
+ assert device.pending_critical_alerts_count == 0
+ assert device.pending_warning_alerts_count == 2
+ assert device.has_alerts is True
+ assert device.last_known_valve_state == "open"
+ assert device.target_valve_state == "open"
+
+ call_count = aioclient_mock.call_count
+
+ async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=90))
+ await hass.async_block_till_done()
+
+ assert aioclient_mock.call_count == call_count + 2
diff --git a/tests/components/flo/test_init.py b/tests/components/flo/test_init.py
new file mode 100644
index 00000000000..9061477da47
--- /dev/null
+++ b/tests/components/flo/test_init.py
@@ -0,0 +1,18 @@
+"""Test init."""
+from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.setup import async_setup_component
+
+from .common import TEST_PASSWORD, TEST_USER_ID
+
+
+async def test_setup_entry(hass, config_entry, aioclient_mock_fixture):
+ """Test migration of config entry from v1."""
+ config_entry.add_to_hass(hass)
+ assert await async_setup_component(
+ hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD}
+ )
+ await hass.async_block_till_done()
+ assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 1
+
+ assert await hass.config_entries.async_unload(config_entry.entry_id)
diff --git a/tests/components/flo/test_sensor.py b/tests/components/flo/test_sensor.py
new file mode 100644
index 00000000000..309dfc11266
--- /dev/null
+++ b/tests/components/flo/test_sensor.py
@@ -0,0 +1,48 @@
+"""Test Flo by Moen sensor entities."""
+from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN
+from homeassistant.const import ATTR_ENTITY_ID, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.setup import async_setup_component
+
+from .common import TEST_PASSWORD, TEST_USER_ID
+
+
+async def test_sensors(hass, config_entry, aioclient_mock_fixture):
+ """Test Flo by Moen sensors."""
+ config_entry.add_to_hass(hass)
+ assert await async_setup_component(
+ hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD}
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 1
+
+ # we should have 5 entities for the device
+ assert hass.states.get("sensor.current_system_mode").state == "home"
+ assert hass.states.get("sensor.today_s_water_usage").state == "3.7"
+ assert hass.states.get("sensor.water_flow_rate").state == "0"
+ assert hass.states.get("sensor.water_pressure").state == "54.2"
+ assert hass.states.get("sensor.water_temperature").state == "21.1"
+
+
+async def test_manual_update_entity(
+ hass, config_entry, aioclient_mock_fixture, aioclient_mock
+):
+ """Test manual update entity via service homeasasistant/update_entity."""
+ config_entry.add_to_hass(hass)
+ assert await async_setup_component(
+ hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD}
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 1
+
+ await async_setup_component(hass, "homeassistant", {})
+
+ call_count = aioclient_mock.call_count
+ await hass.services.async_call(
+ "homeassistant",
+ "update_entity",
+ {ATTR_ENTITY_ID: ["sensor.current_system_mode"]},
+ blocking=True,
+ )
+ assert aioclient_mock.call_count == call_count + 2
diff --git a/tests/components/flo/test_services.py b/tests/components/flo/test_services.py
new file mode 100644
index 00000000000..270279e4a9d
--- /dev/null
+++ b/tests/components/flo/test_services.py
@@ -0,0 +1,69 @@
+"""Test the services for the Flo by Moen integration."""
+from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN
+from homeassistant.components.flo.switch import (
+ ATTR_REVERT_TO_MODE,
+ ATTR_SLEEP_MINUTES,
+ SERVICE_RUN_HEALTH_TEST,
+ SERVICE_SET_AWAY_MODE,
+ SERVICE_SET_HOME_MODE,
+ SERVICE_SET_SLEEP_MODE,
+ SYSTEM_MODE_HOME,
+)
+from homeassistant.const import ATTR_ENTITY_ID, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.setup import async_setup_component
+
+from .common import TEST_PASSWORD, TEST_USER_ID
+
+SWITCH_ENTITY_ID = "switch.shutoff_valve"
+
+
+async def test_services(hass, config_entry, aioclient_mock_fixture, aioclient_mock):
+ """Test Flo services."""
+ config_entry.add_to_hass(hass)
+ assert await async_setup_component(
+ hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD}
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 1
+ assert aioclient_mock.call_count == 4
+
+ await hass.services.async_call(
+ FLO_DOMAIN,
+ SERVICE_RUN_HEALTH_TEST,
+ {ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ assert aioclient_mock.call_count == 5
+
+ await hass.services.async_call(
+ FLO_DOMAIN,
+ SERVICE_SET_AWAY_MODE,
+ {ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ assert aioclient_mock.call_count == 6
+
+ await hass.services.async_call(
+ FLO_DOMAIN,
+ SERVICE_SET_HOME_MODE,
+ {ATTR_ENTITY_ID: SWITCH_ENTITY_ID},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ assert aioclient_mock.call_count == 7
+
+ await hass.services.async_call(
+ FLO_DOMAIN,
+ SERVICE_SET_SLEEP_MODE,
+ {
+ ATTR_ENTITY_ID: SWITCH_ENTITY_ID,
+ ATTR_REVERT_TO_MODE: SYSTEM_MODE_HOME,
+ ATTR_SLEEP_MINUTES: 120,
+ },
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ assert aioclient_mock.call_count == 8
diff --git a/tests/components/flo/test_switch.py b/tests/components/flo/test_switch.py
new file mode 100644
index 00000000000..25a64433a29
--- /dev/null
+++ b/tests/components/flo/test_switch.py
@@ -0,0 +1,31 @@
+"""Tests for the switch domain for Flo by Moen."""
+from homeassistant.components.flo.const import DOMAIN as FLO_DOMAIN
+from homeassistant.components.switch import DOMAIN
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, STATE_OFF, STATE_ON
+from homeassistant.setup import async_setup_component
+
+from .common import TEST_PASSWORD, TEST_USER_ID
+
+
+async def test_valve_switches(hass, config_entry, aioclient_mock_fixture):
+ """Test Flo by Moen valve switches."""
+ config_entry.add_to_hass(hass)
+ assert await async_setup_component(
+ hass, FLO_DOMAIN, {CONF_USERNAME: TEST_USER_ID, CONF_PASSWORD: TEST_PASSWORD}
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.data[FLO_DOMAIN][config_entry.entry_id]["devices"]) == 1
+
+ entity_id = "switch.shutoff_valve"
+ assert hass.states.get(entity_id).state == STATE_ON
+
+ await hass.services.async_call(
+ DOMAIN, "turn_off", {"entity_id": entity_id}, blocking=True
+ )
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+ await hass.services.async_call(
+ DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True
+ )
+ assert hass.states.get(entity_id).state == STATE_ON
diff --git a/tests/components/flume/test_config_flow.py b/tests/components/flume/test_config_flow.py
index 6f2c8d2ed04..bf95700c2de 100644
--- a/tests/components/flume/test_config_flow.py
+++ b/tests/components/flume/test_config_flow.py
@@ -31,14 +31,16 @@ async def test_form(hass):
mock_flume_device_list = _get_mocked_flume_device_list()
with patch(
- "homeassistant.components.flume.config_flow.FlumeAuth", return_value=True,
+ "homeassistant.components.flume.config_flow.FlumeAuth",
+ return_value=True,
), patch(
"homeassistant.components.flume.config_flow.FlumeDeviceList",
return_value=mock_flume_device_list,
), patch(
"homeassistant.components.flume.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.flume.async_setup_entry", return_value=True,
+ "homeassistant.components.flume.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -69,14 +71,16 @@ async def test_form_import(hass):
mock_flume_device_list = _get_mocked_flume_device_list()
with patch(
- "homeassistant.components.flume.config_flow.FlumeAuth", return_value=True,
+ "homeassistant.components.flume.config_flow.FlumeAuth",
+ return_value=True,
), patch(
"homeassistant.components.flume.config_flow.FlumeDeviceList",
return_value=mock_flume_device_list,
), patch(
"homeassistant.components.flume.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.flume.async_setup_entry", return_value=True,
+ "homeassistant.components.flume.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -109,7 +113,8 @@ async def test_form_invalid_auth(hass):
)
with patch(
- "homeassistant.components.flume.config_flow.FlumeAuth", return_value=True,
+ "homeassistant.components.flume.config_flow.FlumeAuth",
+ return_value=True,
), patch(
"homeassistant.components.flume.config_flow.FlumeDeviceList",
side_effect=Exception,
@@ -134,7 +139,8 @@ async def test_form_cannot_connect(hass):
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
- "homeassistant.components.flume.config_flow.FlumeAuth", return_value=True,
+ "homeassistant.components.flume.config_flow.FlumeAuth",
+ return_value=True,
), patch(
"homeassistant.components.flume.config_flow.FlumeDeviceList",
side_effect=requests.exceptions.ConnectionError(),
diff --git a/tests/components/flunearyou/test_config_flow.py b/tests/components/flunearyou/test_config_flow.py
index a3a0d41e885..d4e650e2b4a 100644
--- a/tests/components/flunearyou/test_config_flow.py
+++ b/tests/components/flunearyou/test_config_flow.py
@@ -31,7 +31,8 @@ async def test_general_error(hass):
conf = {CONF_LATITUDE: "51.528308", CONF_LONGITUDE: "-0.3817765"}
with patch(
- "pyflunearyou.cdc.CdcReport.status_by_coordinates", side_effect=FluNearYouError,
+ "pyflunearyou.cdc.CdcReport.status_by_coordinates",
+ side_effect=FluNearYouError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=conf
diff --git a/tests/components/foobot/test_sensor.py b/tests/components/foobot/test_sensor.py
index d2f55fbda16..9b8a81c96d5 100644
--- a/tests/components/foobot/test_sensor.py
+++ b/tests/components/foobot/test_sensor.py
@@ -13,8 +13,8 @@ from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
HTTP_FORBIDDEN,
HTTP_INTERNAL_SERVER_ERROR,
+ PERCENTAGE,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
from homeassistant.exceptions import PlatformNotReady
from homeassistant.setup import async_setup_component
@@ -44,10 +44,10 @@ async def test_default_setup(hass, aioclient_mock):
metrics = {
"co2": ["1232.0", CONCENTRATION_PARTS_PER_MILLION],
"temperature": ["21.1", TEMP_CELSIUS],
- "humidity": ["49.5", UNIT_PERCENTAGE],
+ "humidity": ["49.5", PERCENTAGE],
"pm2_5": ["144.8", CONCENTRATION_MICROGRAMS_PER_CUBIC_METER],
"voc": ["340.7", CONCENTRATION_PARTS_PER_BILLION],
- "index": ["138.9", UNIT_PERCENTAGE],
+ "index": ["138.9", PERCENTAGE],
}
for name, value in metrics.items():
diff --git a/tests/components/forked_daapd/test_config_flow.py b/tests/components/forked_daapd/test_config_flow.py
index 17b30121aaf..181c963ee4f 100644
--- a/tests/components/forked_daapd/test_config_flow.py
+++ b/tests/components/forked_daapd/test_config_flow.py
@@ -89,7 +89,9 @@ async def test_config_flow(hass, config_entry):
# Also test that creating a new entry with the same host aborts
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=config_entry.data,
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=config_entry.data,
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
diff --git a/tests/components/forked_daapd/test_media_player.py b/tests/components/forked_daapd/test_media_player.py
index 4f5cb57d66c..15755949062 100644
--- a/tests/components/forked_daapd/test_media_player.py
+++ b/tests/components/forked_daapd/test_media_player.py
@@ -528,7 +528,9 @@ async def test_bunch_of_stuff_master(hass, get_request_return_values, mock_api_o
await _service_call(hass, TEST_MASTER_ENTITY_NAME, SERVICE_TOGGLE)
for output in SAMPLE_OUTPUTS_ON:
mock_api_object.change_output.assert_any_call(
- output["id"], selected=output["selected"], volume=output["volume"],
+ output["id"],
+ selected=output["selected"],
+ volume=output["volume"],
)
mock_api_object.set_volume.assert_any_call(volume=0)
mock_api_object.set_volume.assert_any_call(volume=SAMPLE_PLAYER_PAUSED["volume"])
diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py
index 5e6bbe8b2d4..7298ad753d2 100644
--- a/tests/components/frontend/test_init.py
+++ b/tests/components/frontend/test_init.py
@@ -334,14 +334,6 @@ async def test_missing_themes(hass, hass_ws_client):
assert msg["result"]["themes"] == {}
-async def test_extra_urls(mock_http_client_with_urls, mock_onboarded):
- """Test that extra urls are loaded."""
- resp = await mock_http_client_with_urls.get("/lovelace?latest")
- assert resp.status == 200
- text = await resp.text()
- assert text.find('href="https://domain.com/my_extra_url.html"') >= 0
-
-
async def test_get_panels(hass, hass_ws_client, mock_http_client):
"""Test get_panels command."""
events = async_capture_events(hass, EVENT_PANELS_UPDATED)
@@ -484,3 +476,12 @@ async def test_get_version(hass, hass_ws_client):
assert msg["type"] == TYPE_RESULT
assert msg["success"]
assert msg["result"] == {"version": cur_version}
+
+
+async def test_static_paths(hass, mock_http_client):
+ """Test static paths."""
+ resp = await mock_http_client.get(
+ "/.well-known/change-password", allow_redirects=False
+ )
+ assert resp.status == 302
+ assert resp.headers["location"] == "/profile"
diff --git a/tests/components/garmin_connect/test_config_flow.py b/tests/components/garmin_connect/test_config_flow.py
index 366573a276c..93b24269441 100644
--- a/tests/components/garmin_connect/test_config_flow.py
+++ b/tests/components/garmin_connect/test_config_flow.py
@@ -23,7 +23,9 @@ MOCK_CONF = {
@pytest.fixture(name="mock_garmin_connect")
def mock_garmin():
"""Mock Garmin."""
- with patch("homeassistant.components.garmin_connect.config_flow.Garmin",) as garmin:
+ with patch(
+ "homeassistant.components.garmin_connect.config_flow.Garmin",
+ ) as garmin:
garmin.return_value.get_full_name.return_value = MOCK_CONF[CONF_ID]
yield garmin.return_value
diff --git a/tests/components/gdacs/test_sensor.py b/tests/components/gdacs/test_sensor.py
index 9be6742deed..b123021a7e3 100644
--- a/tests/components/gdacs/test_sensor.py
+++ b/tests/components/gdacs/test_sensor.py
@@ -29,10 +29,24 @@ async def test_setup(hass, legacy_patchable_time):
"""Test the general setup of the integration."""
# Set up some mock feed entries for this test.
mock_entry_1 = _generate_mock_feed_entry(
- "1234", "Title 1", 15.5, (38.0, -3.0), attribution="Attribution 1",
+ "1234",
+ "Title 1",
+ 15.5,
+ (38.0, -3.0),
+ attribution="Attribution 1",
+ )
+ mock_entry_2 = _generate_mock_feed_entry(
+ "2345",
+ "Title 2",
+ 20.5,
+ (38.1, -3.1),
+ )
+ mock_entry_3 = _generate_mock_feed_entry(
+ "3456",
+ "Title 3",
+ 25.5,
+ (38.2, -3.2),
)
- mock_entry_2 = _generate_mock_feed_entry("2345", "Title 2", 20.5, (38.1, -3.1),)
- mock_entry_3 = _generate_mock_feed_entry("3456", "Title 3", 25.5, (38.2, -3.2),)
mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (38.3, -3.3))
# Patching 'utcnow' to gain more control over the timed update.
diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py
index fffa5db6be5..7be1670dd4c 100644
--- a/tests/components/generic/test_camera.py
+++ b/tests/components/generic/test_camera.py
@@ -1,8 +1,15 @@
"""The tests for generic camera component."""
import asyncio
+from os import path
+from homeassistant import config as hass_config
+from homeassistant.components.generic import DOMAIN
from homeassistant.components.websocket_api.const import TYPE_RESULT
-from homeassistant.const import HTTP_INTERNAL_SERVER_ERROR, HTTP_NOT_FOUND
+from homeassistant.const import (
+ HTTP_INTERNAL_SERVER_ERROR,
+ HTTP_NOT_FOUND,
+ SERVICE_RELOAD,
+)
from homeassistant.setup import async_setup_component
from tests.async_mock import patch
@@ -292,3 +299,63 @@ async def test_camera_content_type(aioclient_mock, hass, hass_client):
assert resp_2.content_type == "image/jpeg"
body = await resp_2.text()
assert body == svg_image
+
+
+async def test_reloading(aioclient_mock, hass, hass_client):
+ """Test we can cleanly reload."""
+ aioclient_mock.get("http://example.com", text="hello world")
+
+ await async_setup_component(
+ hass,
+ "camera",
+ {
+ "camera": {
+ "name": "config_test",
+ "platform": "generic",
+ "still_image_url": "http://example.com",
+ "username": "user",
+ "password": "pass",
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ client = await hass_client()
+
+ resp = await client.get("/api/camera_proxy/camera.config_test")
+
+ assert resp.status == 200
+ assert aioclient_mock.call_count == 1
+ body = await resp.text()
+ assert body == "hello world"
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "generic/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+ resp = await client.get("/api/camera_proxy/camera.config_test")
+
+ assert resp.status == 404
+
+ resp = await client.get("/api/camera_proxy/camera.reload")
+
+ assert resp.status == 200
+ assert aioclient_mock.call_count == 2
+ body = await resp.text()
+ assert body == "hello world"
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py
index 313ff43ca6a..eaf7c8e5651 100644
--- a/tests/components/generic_thermostat/test_climate.py
+++ b/tests/components/generic_thermostat/test_climate.py
@@ -1,10 +1,12 @@
"""The tests for the generic_thermostat."""
import datetime
+from os import path
import pytest
import pytz
import voluptuous as vol
+from homeassistant import config as hass_config
from homeassistant.components import input_boolean, switch
from homeassistant.components.climate.const import (
ATTR_PRESET_MODE,
@@ -15,8 +17,12 @@ from homeassistant.components.climate.const import (
PRESET_AWAY,
PRESET_NONE,
)
+from homeassistant.components.generic_thermostat import (
+ DOMAIN as GENERIC_THERMOSTAT_DOMAIN,
+)
from homeassistant.const import (
ATTR_TEMPERATURE,
+ SERVICE_RELOAD,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
@@ -1246,3 +1252,46 @@ def _mock_restore_cache(hass, temperature=20, hvac_mode=HVAC_MODE_OFF):
),
),
)
+
+
+async def test_reload(hass):
+ """Test we can reload."""
+
+ assert await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ "climate": {
+ "platform": "generic_thermostat",
+ "name": "test",
+ "heater": "switch.any",
+ "target_sensor": "sensor.any",
+ }
+ },
+ )
+
+ await hass.async_block_till_done()
+ assert len(hass.states.async_all()) == 1
+ assert hass.states.get("climate.test") is not None
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "generic_thermostat/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ GENERIC_THERMOSTAT_DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+ assert hass.states.get("climate.test") is None
+ assert hass.states.get("climate.reload")
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/automation/test_geo_location.py b/tests/components/geo_location/test_trigger.py
similarity index 100%
rename from tests/components/automation/test_geo_location.py
rename to tests/components/geo_location/test_trigger.py
diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py
index 3a9bdf1fe73..dd3132b0117 100644
--- a/tests/components/geofency/test_init.py
+++ b/tests/components/geofency/test_init.py
@@ -150,7 +150,8 @@ async def setup_zones(loop, hass):
async def webhook_id(hass, geofency_client):
"""Initialize the Geofency component and get the webhook_id."""
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}
diff --git a/tests/components/gios/__init__.py b/tests/components/gios/__init__.py
index 98528fda9f9..920461a6ae1 100644
--- a/tests/components/gios/__init__.py
+++ b/tests/components/gios/__init__.py
@@ -1 +1,47 @@
"""Tests for GIOS."""
+import json
+
+from homeassistant.components.gios.const import DOMAIN
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry, load_fixture
+
+STATIONS = [
+ {"id": 123, "stationName": "Test Name 1", "gegrLat": "99.99", "gegrLon": "88.88"},
+ {"id": 321, "stationName": "Test Name 2", "gegrLat": "77.77", "gegrLon": "66.66"},
+]
+
+
+async def init_integration(hass, incomplete_data=False) -> MockConfigEntry:
+ """Set up the GIOS integration in Home Assistant."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ title="Home",
+ unique_id=123,
+ data={"station_id": 123, "name": "Home"},
+ )
+
+ indexes = json.loads(load_fixture("gios/indexes.json"))
+ station = json.loads(load_fixture("gios/station.json"))
+ sensors = json.loads(load_fixture("gios/sensors.json"))
+ if incomplete_data:
+ indexes["stIndexLevel"]["indexLevelName"] = "foo"
+ sensors["PM10"]["values"][0]["value"] = None
+ sensors["PM10"]["values"][1]["value"] = None
+
+ with patch(
+ "homeassistant.components.gios.Gios._get_stations", return_value=STATIONS
+ ), patch(
+ "homeassistant.components.gios.Gios._get_station",
+ return_value=station,
+ ), patch(
+ "homeassistant.components.gios.Gios._get_all_sensors",
+ return_value=sensors,
+ ), patch(
+ "homeassistant.components.gios.Gios._get_indexes", return_value=indexes
+ ):
+ entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ return entry
diff --git a/tests/components/gios/test_air_quality.py b/tests/components/gios/test_air_quality.py
new file mode 100644
index 00000000000..9a5b1be6a20
--- /dev/null
+++ b/tests/components/gios/test_air_quality.py
@@ -0,0 +1,123 @@
+"""Test air_quality of GIOS integration."""
+from datetime import timedelta
+import json
+
+from gios import ApiError
+
+from homeassistant.components.air_quality import (
+ ATTR_AQI,
+ ATTR_CO,
+ ATTR_NO2,
+ ATTR_OZONE,
+ ATTR_PM_2_5,
+ ATTR_PM_10,
+ ATTR_SO2,
+)
+from homeassistant.components.gios.air_quality import ATTRIBUTION
+from homeassistant.components.gios.const import AQI_GOOD
+from homeassistant.const import (
+ ATTR_ATTRIBUTION,
+ ATTR_ICON,
+ ATTR_UNIT_OF_MEASUREMENT,
+ CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+ STATE_UNAVAILABLE,
+)
+from homeassistant.util.dt import utcnow
+
+from tests.async_mock import patch
+from tests.common import async_fire_time_changed, load_fixture
+from tests.components.gios import init_integration
+
+
+async def test_air_quality(hass):
+ """Test states of the air_quality."""
+ await init_integration(hass)
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ state = hass.states.get("air_quality.home")
+ assert state
+ assert state.state == "4"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_AQI) == AQI_GOOD
+ assert state.attributes.get(ATTR_PM_10) == 17
+ assert state.attributes.get(ATTR_PM_2_5) == 4
+ assert state.attributes.get(ATTR_CO) == 252
+ assert state.attributes.get(ATTR_SO2) == 4
+ assert state.attributes.get(ATTR_NO2) == 7
+ assert state.attributes.get(ATTR_OZONE) == 96
+ assert (
+ state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
+ )
+ assert state.attributes.get(ATTR_ICON) == "mdi:emoticon-happy"
+ assert state.attributes.get("station") == "Test Name 1"
+
+ entry = registry.async_get("air_quality.home")
+ assert entry
+ assert entry.unique_id == 123
+
+
+async def test_air_quality_with_incomplete_data(hass):
+ """Test states of the air_quality with incomplete data from measuring station."""
+ await init_integration(hass, incomplete_data=True)
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ state = hass.states.get("air_quality.home")
+ assert state
+ assert state.state == "4"
+ assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION
+ assert state.attributes.get(ATTR_AQI) == "foo"
+ assert state.attributes.get(ATTR_PM_10) is None
+ assert state.attributes.get(ATTR_PM_2_5) == 4
+ assert state.attributes.get(ATTR_CO) == 252
+ assert state.attributes.get(ATTR_SO2) == 4
+ assert state.attributes.get(ATTR_NO2) == 7
+ assert state.attributes.get(ATTR_OZONE) == 96
+ assert (
+ state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
+ )
+ assert state.attributes.get(ATTR_ICON) == "mdi:blur"
+ assert state.attributes.get("station") == "Test Name 1"
+
+ entry = registry.async_get("air_quality.home")
+ assert entry
+ assert entry.unique_id == 123
+
+
+async def test_availability(hass):
+ """Ensure that we mark the entities unavailable correctly when service causes an error."""
+ await init_integration(hass)
+
+ state = hass.states.get("air_quality.home")
+ assert state
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "4"
+
+ future = utcnow() + timedelta(minutes=60)
+ with patch(
+ "homeassistant.components.gios.Gios._get_all_sensors",
+ side_effect=ApiError("Unexpected error"),
+ ):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("air_quality.home")
+ assert state
+ assert state.state == STATE_UNAVAILABLE
+
+ future = utcnow() + timedelta(minutes=120)
+ with patch(
+ "homeassistant.components.gios.Gios._get_all_sensors",
+ return_value=json.loads(load_fixture("gios/sensors.json")),
+ ), patch(
+ "homeassistant.components.gios.Gios._get_indexes",
+ return_value=json.loads(load_fixture("gios/indexes.json")),
+ ):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("air_quality.home")
+ assert state
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "4"
diff --git a/tests/components/gios/test_config_flow.py b/tests/components/gios/test_config_flow.py
index b2f7ceec9e4..24ada20aded 100644
--- a/tests/components/gios/test_config_flow.py
+++ b/tests/components/gios/test_config_flow.py
@@ -1,4 +1,6 @@
"""Define tests for the GIOS config flow."""
+import json
+
from gios import ApiError
from homeassistant import data_entry_flow
@@ -7,28 +9,14 @@ from homeassistant.components.gios.const import CONF_STATION_ID
from homeassistant.const import CONF_NAME
from tests.async_mock import patch
+from tests.common import load_fixture
+from tests.components.gios import STATIONS
CONFIG = {
CONF_NAME: "Foo",
CONF_STATION_ID: 123,
}
-VALID_STATIONS = [
- {"id": 123, "stationName": "Test Name 1", "gegrLat": "99.99", "gegrLon": "88.88"},
- {"id": 321, "stationName": "Test Name 2", "gegrLat": "77.77", "gegrLon": "66.66"},
-]
-
-VALID_STATION = [
- {"id": 3764, "param": {"paramName": "particulate matter PM10", "paramCode": "PM10"}}
-]
-
-VALID_INDEXES = {
- "stIndexLevel": {"id": 1, "indexLevelName": "Good"},
- "pm10IndexLevel": {"id": 0, "indexLevelName": "Very good"},
-}
-
-VALID_SENSOR = {"key": "PM10", "values": [{"value": 11.11}]}
-
async def test_show_form(hass):
"""Test that the form is served with no input."""
@@ -43,7 +31,9 @@ async def test_show_form(hass):
async def test_invalid_station_id(hass):
"""Test that errors are shown when measuring station ID is invalid."""
- with patch("gios.Gios._get_stations", return_value=VALID_STATIONS):
+ with patch(
+ "homeassistant.components.gios.Gios._get_stations", return_value=STATIONS
+ ):
flow = config_flow.GiosFlowHandler()
flow.hass = hass
flow.context = {}
@@ -57,10 +47,13 @@ async def test_invalid_station_id(hass):
async def test_invalid_sensor_data(hass):
"""Test that errors are shown when sensor data is invalid."""
- with patch("gios.Gios._get_stations", return_value=VALID_STATIONS), patch(
- "gios.Gios._get_station", return_value=VALID_STATION
- ), patch("gios.Gios._get_station", return_value=VALID_STATION), patch(
- "gios.Gios._get_sensor", return_value={}
+ with patch(
+ "homeassistant.components.gios.Gios._get_stations", return_value=STATIONS
+ ), patch(
+ "homeassistant.components.gios.Gios._get_station",
+ return_value=json.loads(load_fixture("gios/station.json")),
+ ), patch(
+ "homeassistant.components.gios.Gios._get_sensor", return_value={}
):
flow = config_flow.GiosFlowHandler()
flow.hass = hass
@@ -73,7 +66,9 @@ async def test_invalid_sensor_data(hass):
async def test_cannot_connect(hass):
"""Test that errors are shown when cannot connect to GIOS server."""
- with patch("gios.Gios._async_get", side_effect=ApiError("error")):
+ with patch(
+ "homeassistant.components.gios.Gios._async_get", side_effect=ApiError("error")
+ ):
flow = config_flow.GiosFlowHandler()
flow.hass = hass
flow.context = {}
@@ -85,12 +80,17 @@ async def test_cannot_connect(hass):
async def test_create_entry(hass):
"""Test that the user step works."""
- with patch("gios.Gios._get_stations", return_value=VALID_STATIONS), patch(
- "gios.Gios._get_station", return_value=VALID_STATION
- ), patch("gios.Gios._get_station", return_value=VALID_STATION), patch(
- "gios.Gios._get_sensor", return_value=VALID_SENSOR
+ with patch(
+ "homeassistant.components.gios.Gios._get_stations", return_value=STATIONS
), patch(
- "gios.Gios._get_indexes", return_value=VALID_INDEXES
+ "homeassistant.components.gios.Gios._get_station",
+ return_value=json.loads(load_fixture("gios/station.json")),
+ ), patch(
+ "homeassistant.components.gios.Gios._get_all_sensors",
+ return_value=json.loads(load_fixture("gios/sensors.json")),
+ ), patch(
+ "homeassistant.components.gios.Gios._get_indexes",
+ return_value=json.loads(load_fixture("gios/indexes.json")),
):
flow = config_flow.GiosFlowHandler()
flow.hass = hass
diff --git a/tests/components/gios/test_init.py b/tests/components/gios/test_init.py
new file mode 100644
index 00000000000..0846ddfb4ca
--- /dev/null
+++ b/tests/components/gios/test_init.py
@@ -0,0 +1,54 @@
+"""Test init of GIOS integration."""
+from homeassistant.components.gios.const import DOMAIN
+from homeassistant.config_entries import (
+ ENTRY_STATE_LOADED,
+ ENTRY_STATE_NOT_LOADED,
+ ENTRY_STATE_SETUP_RETRY,
+)
+from homeassistant.const import STATE_UNAVAILABLE
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+from tests.components.gios import init_integration
+
+
+async def test_async_setup_entry(hass):
+ """Test a successful setup entry."""
+ await init_integration(hass)
+
+ state = hass.states.get("air_quality.home")
+ assert state is not None
+ assert state.state != STATE_UNAVAILABLE
+ assert state.state == "4"
+
+
+async def test_config_not_ready(hass):
+ """Test for setup failure if connection to GIOS is missing."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ title="Home",
+ unique_id=123,
+ data={"station_id": 123, "name": "Home"},
+ )
+
+ with patch(
+ "homeassistant.components.gios.Gios._get_stations",
+ side_effect=ConnectionError(),
+ ):
+ entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(entry.entry_id)
+ assert entry.state == ENTRY_STATE_SETUP_RETRY
+
+
+async def test_unload_entry(hass):
+ """Test successful unload of entry."""
+ entry = await init_integration(hass)
+
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+ assert entry.state == ENTRY_STATE_LOADED
+
+ assert await hass.config_entries.async_unload(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.state == ENTRY_STATE_NOT_LOADED
+ assert not hass.data.get(DOMAIN)
diff --git a/tests/components/gogogate2/common.py b/tests/components/gogogate2/common.py
deleted file mode 100644
index d344a31cf4b..00000000000
--- a/tests/components/gogogate2/common.py
+++ /dev/null
@@ -1,162 +0,0 @@
-"""Common test code."""
-from typing import List, NamedTuple, Optional
-from unittest.mock import MagicMock, Mock
-
-from gogogate2_api import GogoGate2Api, InfoResponse
-from gogogate2_api.common import Door, DoorMode, DoorStatus, Network, Outputs, Wifi
-
-from homeassistant.components import persistent_notification
-from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
-from homeassistant.components.gogogate2 import async_unload_entry
-from homeassistant.components.gogogate2.common import (
- GogoGateDataUpdateCoordinator,
- get_data_update_coordinator,
-)
-import homeassistant.components.gogogate2.const as const
-from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN
-from homeassistant.config import async_process_ha_core_config
-from homeassistant.config_entries import SOURCE_USER
-from homeassistant.const import CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_METRIC
-from homeassistant.core import HomeAssistant
-from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM
-from homeassistant.setup import async_setup_component
-
-INFO_RESPONSE = InfoResponse(
- user="user1",
- gogogatename="gogogatename1",
- model="",
- apiversion="",
- remoteaccessenabled=False,
- remoteaccess="abcdefg.my-gogogate.com",
- firmwareversion="",
- apicode="API_CODE",
- door1=Door(
- door_id=1,
- permission=True,
- name="Door1",
- mode=DoorMode.GARAGE,
- status=DoorStatus.OPENED,
- sensor=True,
- sensorid=None,
- camera=False,
- events=2,
- temperature=None,
- ),
- door2=Door(
- door_id=2,
- permission=True,
- name=None,
- mode=DoorMode.GARAGE,
- status=DoorStatus.OPENED,
- sensor=True,
- sensorid=None,
- camera=False,
- events=2,
- temperature=None,
- ),
- door3=Door(
- door_id=3,
- permission=True,
- name="Door3",
- mode=DoorMode.GARAGE,
- status=DoorStatus.OPENED,
- sensor=True,
- sensorid=None,
- camera=False,
- events=2,
- temperature=None,
- ),
- outputs=Outputs(output1=True, output2=False, output3=True),
- network=Network(ip=""),
- wifi=Wifi(SSID="", linkquality="", signal=""),
-)
-
-
-class ComponentData(NamedTuple):
- """Test data for a mocked component."""
-
- api: GogoGate2Api
- data_update_coordinator: GogoGateDataUpdateCoordinator
-
-
-class ComponentFactory:
- """Manages the setup and unloading of the withing component and profiles."""
-
- def __init__(self, hass: HomeAssistant, gogogate_api_mock: Mock) -> None:
- """Initialize the object."""
- self._hass = hass
- self._gogogate_api_mock = gogogate_api_mock
-
- @property
- def api_class_mock(self):
- """Get the api class mock."""
- return self._gogogate_api_mock
-
- async def configure_component(
- self, cover_config: Optional[List[dict]] = None
- ) -> None:
- """Configure the component."""
- hass_config = {
- "homeassistant": {CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC},
- "cover": cover_config or [],
- }
-
- await async_process_ha_core_config(self._hass, hass_config.get("homeassistant"))
- assert await async_setup_component(self._hass, HA_DOMAIN, {})
- assert await async_setup_component(
- self._hass, persistent_notification.DOMAIN, {}
- )
- assert await async_setup_component(self._hass, COVER_DOMAIN, hass_config)
- assert await async_setup_component(self._hass, const.DOMAIN, hass_config)
- await self._hass.async_block_till_done()
-
- async def run_config_flow(
- self, config_data: dict, api_mock: Optional[GogoGate2Api] = None
- ) -> ComponentData:
- """Run a config flow."""
- if api_mock is None:
- api_mock: GogoGate2Api = MagicMock(spec=GogoGate2Api)
- api_mock.info.return_value = INFO_RESPONSE
-
- self._gogogate_api_mock.reset_mocks()
- self._gogogate_api_mock.return_value = api_mock
-
- result = await self._hass.config_entries.flow.async_init(
- const.DOMAIN, context={"source": SOURCE_USER}
- )
- assert result
- assert result["type"] == RESULT_TYPE_FORM
- assert result["step_id"] == "user"
-
- result = await self._hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=config_data,
- )
- assert result
- assert result["type"] == RESULT_TYPE_CREATE_ENTRY
- assert result["data"] == config_data
-
- await self._hass.async_block_till_done()
-
- config_entry = next(
- iter(
- entry
- for entry in self._hass.config_entries.async_entries(const.DOMAIN)
- if entry.unique_id == "abcdefg"
- )
- )
-
- return ComponentData(
- api=api_mock,
- data_update_coordinator=get_data_update_coordinator(
- self._hass, config_entry
- ),
- )
-
- async def unload(self) -> None:
- """Unload all config entries."""
- config_entries = self._hass.config_entries.async_entries(const.DOMAIN)
- for config_entry in config_entries:
- await async_unload_entry(self._hass, config_entry)
-
- await self._hass.async_block_till_done()
- assert not self._hass.states.async_entity_ids("gogogate")
diff --git a/tests/components/gogogate2/conftest.py b/tests/components/gogogate2/conftest.py
deleted file mode 100644
index 31e85c5f14e..00000000000
--- a/tests/components/gogogate2/conftest.py
+++ /dev/null
@@ -1,18 +0,0 @@
-"""Fixtures for tests."""
-
-import pytest
-
-from homeassistant.core import HomeAssistant
-
-from .common import ComponentFactory
-
-from tests.async_mock import patch
-
-
-@pytest.fixture()
-def component_factory(hass: HomeAssistant):
- """Return a factory for initializing the gogogate2 api."""
- with patch(
- "homeassistant.components.gogogate2.common.GogoGate2Api"
- ) as gogogate2_api_mock:
- yield ComponentFactory(hass, gogogate2_api_mock)
diff --git a/tests/components/gogogate2/test_config_flow.py b/tests/components/gogogate2/test_config_flow.py
index e921df406d2..667c0330d80 100644
--- a/tests/components/gogogate2/test_config_flow.py
+++ b/tests/components/gogogate2/test_config_flow.py
@@ -1,64 +1,145 @@
"""Tests for the GogoGate2 component."""
from gogogate2_api import GogoGate2Api
from gogogate2_api.common import ApiError
-from gogogate2_api.const import ApiErrorCode
+from gogogate2_api.const import GogoGate2ApiErrorCode
+from homeassistant import config_entries, setup
+from homeassistant.components.gogogate2.const import (
+ DEVICE_TYPE_GOGOGATE2,
+ DEVICE_TYPE_ISMARTGATE,
+ DOMAIN,
+)
from homeassistant.config_entries import SOURCE_USER
-from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.const import (
+ CONF_DEVICE,
+ CONF_IP_ADDRESS,
+ CONF_PASSWORD,
+ CONF_USERNAME,
+)
from homeassistant.core import HomeAssistant
-from homeassistant.data_entry_flow import RESULT_TYPE_FORM
-
-from .common import ComponentFactory
+from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM
from tests.async_mock import MagicMock, patch
+from tests.common import MockConfigEntry
+
+MOCK_MAC_ADDR = "AA:BB:CC:DD:EE:FF"
+@patch("homeassistant.components.gogogate2.async_setup", return_value=True)
+@patch("homeassistant.components.gogogate2.async_setup_entry", return_value=True)
+@patch("homeassistant.components.gogogate2.common.GogoGate2Api")
async def test_auth_fail(
- hass: HomeAssistant, component_factory: ComponentFactory
+ gogogate2api_mock, async_setup_entry_mock, async_setup_mock, hass: HomeAssistant
) -> None:
"""Test authorization failures."""
- api_mock: GogoGate2Api = MagicMock(spec=GogoGate2Api)
+ api: GogoGate2Api = MagicMock(spec=GogoGate2Api)
+ gogogate2api_mock.return_value = api
- with patch(
- "homeassistant.components.gogogate2.async_setup", return_value=True
- ), patch(
- "homeassistant.components.gogogate2.async_setup_entry", return_value=True,
- ):
- await component_factory.configure_component()
- component_factory.api_class_mock.return_value = api_mock
+ api.reset_mock()
+ api.info.side_effect = ApiError(GogoGate2ApiErrorCode.CREDENTIALS_INCORRECT, "blah")
+ result = await hass.config_entries.flow.async_init(
+ "gogogate2", context={"source": SOURCE_USER}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={
+ CONF_DEVICE: DEVICE_TYPE_GOGOGATE2,
+ CONF_IP_ADDRESS: "127.0.0.2",
+ CONF_USERNAME: "user0",
+ CONF_PASSWORD: "password0",
+ },
+ )
+ assert result
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {
+ "base": "invalid_auth",
+ }
- api_mock.reset_mock()
- api_mock.info.side_effect = ApiError(ApiErrorCode.CREDENTIALS_INCORRECT, "blah")
- result = await hass.config_entries.flow.async_init(
- "gogogate2", context={"source": SOURCE_USER}
- )
- result = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- user_input={
- CONF_IP_ADDRESS: "127.0.0.2",
- CONF_USERNAME: "user0",
- CONF_PASSWORD: "password0",
- },
- )
- assert result
- assert result["type"] == RESULT_TYPE_FORM
- assert result["errors"] == {
- "base": "invalid_auth",
- }
+ api.reset_mock()
+ api.info.side_effect = Exception("Generic connection error.")
+ result = await hass.config_entries.flow.async_init(
+ "gogogate2", context={"source": SOURCE_USER}
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input={
+ CONF_DEVICE: DEVICE_TYPE_GOGOGATE2,
+ CONF_IP_ADDRESS: "127.0.0.2",
+ CONF_USERNAME: "user0",
+ CONF_PASSWORD: "password0",
+ },
+ )
+ assert result
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "cannot_connect"}
- api_mock.reset_mock()
- api_mock.info.side_effect = Exception("Generic connection error.")
- result = await hass.config_entries.flow.async_init(
- "gogogate2", context={"source": SOURCE_USER}
- )
- result = await hass.config_entries.flow.async_configure(
- result["flow_id"],
- user_input={
- CONF_IP_ADDRESS: "127.0.0.2",
- CONF_USERNAME: "user0",
- CONF_PASSWORD: "password0",
- },
- )
- assert result
- assert result["type"] == RESULT_TYPE_FORM
- assert result["errors"] == {"base": "cannot_connect"}
+
+async def test_form_homekit_unique_id_already_setup(hass):
+ """Test that we abort from homekit if gogogate2 is already setup."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_HOMEKIT},
+ data={"host": "1.2.3.4", "properties": {"id": MOCK_MAC_ADDR}},
+ )
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {}
+ flow = next(
+ flow
+ for flow in hass.config_entries.flow.async_progress()
+ if flow["flow_id"] == result["flow_id"]
+ )
+ assert flow["context"]["unique_id"] == MOCK_MAC_ADDR
+
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_IP_ADDRESS: "1.2.3.4", CONF_USERNAME: "mock", CONF_PASSWORD: "mock"},
+ )
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_HOMEKIT},
+ data={"host": "1.2.3.4", "properties": {"id": MOCK_MAC_ADDR}},
+ )
+ assert result["type"] == RESULT_TYPE_ABORT
+
+
+async def test_form_homekit_ip_address_already_setup(hass):
+ """Test that we abort from homekit if gogogate2 is already setup."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_IP_ADDRESS: "1.2.3.4", CONF_USERNAME: "mock", CONF_PASSWORD: "mock"},
+ )
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_HOMEKIT},
+ data={"host": "1.2.3.4", "properties": {"id": MOCK_MAC_ADDR}},
+ )
+ assert result["type"] == RESULT_TYPE_ABORT
+
+
+async def test_form_homekit_ip_address(hass):
+ """Test homekit includes the defaults ip address."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_HOMEKIT},
+ data={"host": "1.2.3.4", "properties": {"id": MOCK_MAC_ADDR}},
+ )
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {}
+
+ data_schema = result["data_schema"]
+ assert data_schema({CONF_USERNAME: "username", CONF_PASSWORD: "password"}) == {
+ CONF_DEVICE: DEVICE_TYPE_ISMARTGATE,
+ CONF_IP_ADDRESS: "1.2.3.4",
+ CONF_PASSWORD: "password",
+ CONF_USERNAME: "username",
+ }
diff --git a/tests/components/gogogate2/test_cover.py b/tests/components/gogogate2/test_cover.py
index 5bc9ed9ebd4..b1ab2284580 100644
--- a/tests/components/gogogate2/test_cover.py
+++ b/tests/components/gogogate2/test_cover.py
@@ -1,65 +1,95 @@
"""Tests for the GogoGate2 component."""
-from gogogate2_api import GogoGate2Api
+from datetime import timedelta
+
+from gogogate2_api import GogoGate2Api, ISmartGateApi
from gogogate2_api.common import (
- ActivateResponse,
ApiError,
- Door,
DoorMode,
DoorStatus,
- InfoResponse,
+ GogoGate2ActivateResponse,
+ GogoGate2Door,
+ GogoGate2InfoResponse,
+ ISmartGateDoor,
+ ISmartGateInfoResponse,
Network,
Outputs,
Wifi,
)
-from homeassistant.components.cover import DOMAIN as COVER_DOMAIN
+from homeassistant.components.cover import (
+ DEVICE_CLASS_GARAGE,
+ DEVICE_CLASS_GATE,
+ DOMAIN as COVER_DOMAIN,
+)
+from homeassistant.components.gogogate2.const import (
+ DEVICE_TYPE_GOGOGATE2,
+ DEVICE_TYPE_ISMARTGATE,
+ DOMAIN,
+)
from homeassistant.components.homeassistant import DOMAIN as HA_DOMAIN
+from homeassistant.config import async_process_ha_core_config
+from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import (
+ ATTR_DEVICE_CLASS,
+ CONF_DEVICE,
CONF_IP_ADDRESS,
CONF_NAME,
CONF_PASSWORD,
CONF_PLATFORM,
+ CONF_UNIT_SYSTEM,
+ CONF_UNIT_SYSTEM_METRIC,
CONF_USERNAME,
STATE_CLOSED,
STATE_OPEN,
STATE_UNAVAILABLE,
+ STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
+from homeassistant.setup import async_setup_component
+from homeassistant.util.dt import utcnow
-from .common import ComponentFactory
-
-from tests.async_mock import MagicMock
+from tests.async_mock import MagicMock, patch
+from tests.common import MockConfigEntry, async_fire_time_changed
-async def test_import_fail(
- hass: HomeAssistant, component_factory: ComponentFactory
-) -> None:
+@patch("homeassistant.components.gogogate2.common.GogoGate2Api")
+async def test_import_fail(gogogate2api_mock, hass: HomeAssistant) -> None:
"""Test the failure to import."""
api = MagicMock(spec=GogoGate2Api)
api.info.side_effect = ApiError(22, "Error")
+ gogogate2api_mock.return_value = api
- component_factory.api_class_mock.return_value = api
-
- await component_factory.configure_component(
- cover_config=[
+ hass_config = {
+ HA_DOMAIN: {CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC},
+ COVER_DOMAIN: [
{
CONF_PLATFORM: "gogogate2",
CONF_NAME: "cover0",
+ CONF_DEVICE: DEVICE_TYPE_GOGOGATE2,
CONF_IP_ADDRESS: "127.0.1.0",
CONF_USERNAME: "user0",
CONF_PASSWORD: "password0",
}
- ]
- )
+ ],
+ }
+
+ await async_process_ha_core_config(hass, hass_config[HA_DOMAIN])
+ assert await async_setup_component(hass, HA_DOMAIN, {})
+ assert await async_setup_component(hass, COVER_DOMAIN, hass_config)
+ await hass.async_block_till_done()
entity_ids = hass.states.async_entity_ids(COVER_DOMAIN)
assert not entity_ids
-async def test_import(hass: HomeAssistant, component_factory: ComponentFactory) -> None:
+@patch("homeassistant.components.gogogate2.common.GogoGate2Api")
+@patch("homeassistant.components.gogogate2.common.ISmartGateApi")
+async def test_import(
+ ismartgateapi_mock, gogogate2api_mock, hass: HomeAssistant
+) -> None:
"""Test importing of file based config."""
api0 = MagicMock(spec=GogoGate2Api)
- api0.info.return_value = InfoResponse(
+ api0.info.return_value = GogoGate2InfoResponse(
user="user1",
gogogatename="gogogatename0",
model="",
@@ -68,10 +98,11 @@ async def test_import(hass: HomeAssistant, component_factory: ComponentFactory)
remoteaccess="abc123.blah.blah",
firmwareversion="",
apicode="",
- door1=Door(
+ door1=GogoGate2Door(
door_id=1,
permission=True,
name="Door1",
+ gate=False,
mode=DoorMode.GARAGE,
status=DoorStatus.OPENED,
sensor=True,
@@ -79,11 +110,13 @@ async def test_import(hass: HomeAssistant, component_factory: ComponentFactory)
camera=False,
events=2,
temperature=None,
+ voltage=40,
),
- door2=Door(
+ door2=GogoGate2Door(
door_id=2,
permission=True,
name=None,
+ gate=True,
mode=DoorMode.GARAGE,
status=DoorStatus.UNDEFINED,
sensor=True,
@@ -91,11 +124,13 @@ async def test_import(hass: HomeAssistant, component_factory: ComponentFactory)
camera=False,
events=0,
temperature=None,
+ voltage=40,
),
- door3=Door(
+ door3=GogoGate2Door(
door_id=3,
permission=True,
name=None,
+ gate=False,
mode=DoorMode.GARAGE,
status=DoorStatus.UNDEFINED,
sensor=True,
@@ -103,26 +138,31 @@ async def test_import(hass: HomeAssistant, component_factory: ComponentFactory)
camera=False,
events=0,
temperature=None,
+ voltage=40,
),
outputs=Outputs(output1=True, output2=False, output3=True),
network=Network(ip=""),
wifi=Wifi(SSID="", linkquality="", signal=""),
)
+ gogogate2api_mock.return_value = api0
- api1 = MagicMock(spec=GogoGate2Api)
- api1.info.return_value = InfoResponse(
+ api1 = MagicMock(spec=ISmartGateApi)
+ api1.info.return_value = ISmartGateInfoResponse(
user="user1",
- gogogatename="gogogatename0",
+ ismartgatename="ismartgatename0",
model="",
apiversion="",
remoteaccessenabled=False,
- remoteaccess="321bca.blah.blah",
+ remoteaccess="abc321.blah.blah",
firmwareversion="",
- apicode="",
- door1=Door(
+ pin=123,
+ lang="en",
+ newfirmware=False,
+ door1=ISmartGateDoor(
door_id=1,
permission=True,
name="Door1",
+ gate=False,
mode=DoorMode.GARAGE,
status=DoorStatus.CLOSED,
sensor=True,
@@ -130,50 +170,57 @@ async def test_import(hass: HomeAssistant, component_factory: ComponentFactory)
camera=False,
events=2,
temperature=None,
+ enabled=True,
+ apicode="apicode0",
+ customimage=False,
+ voltage=40,
),
- door2=Door(
- door_id=2,
+ door2=ISmartGateDoor(
+ door_id=1,
permission=True,
name=None,
+ gate=True,
mode=DoorMode.GARAGE,
- status=DoorStatus.UNDEFINED,
+ status=DoorStatus.CLOSED,
sensor=True,
sensorid=None,
camera=False,
- events=0,
+ events=2,
temperature=None,
+ enabled=True,
+ apicode="apicode0",
+ customimage=False,
+ voltage=40,
),
- door3=Door(
- door_id=3,
+ door3=ISmartGateDoor(
+ door_id=1,
permission=True,
name=None,
+ gate=False,
mode=DoorMode.GARAGE,
- status=DoorStatus.UNDEFINED,
+ status=DoorStatus.CLOSED,
sensor=True,
sensorid=None,
camera=False,
- events=0,
+ events=2,
temperature=None,
+ enabled=True,
+ apicode="apicode0",
+ customimage=False,
+ voltage=40,
),
- outputs=Outputs(output1=True, output2=False, output3=True),
network=Network(ip=""),
wifi=Wifi(SSID="", linkquality="", signal=""),
)
+ ismartgateapi_mock.return_value = api1
- def new_api(ip_address: str, username: str, password: str) -> GogoGate2Api:
- if ip_address == "127.0.1.0":
- return api0
- if ip_address == "127.0.1.1":
- return api1
- raise Exception(f"Untested ip address {ip_address}")
-
- component_factory.api_class_mock.side_effect = new_api
-
- await component_factory.configure_component(
- cover_config=[
+ hass_config = {
+ HA_DOMAIN: {CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC},
+ COVER_DOMAIN: [
{
CONF_PLATFORM: "gogogate2",
CONF_NAME: "cover0",
+ CONF_DEVICE: DEVICE_TYPE_GOGOGATE2,
CONF_IP_ADDRESS: "127.0.1.0",
CONF_USERNAME: "user0",
CONF_PASSWORD: "password0",
@@ -181,327 +228,245 @@ async def test_import(hass: HomeAssistant, component_factory: ComponentFactory)
{
CONF_PLATFORM: "gogogate2",
CONF_NAME: "cover1",
+ CONF_DEVICE: DEVICE_TYPE_ISMARTGATE,
CONF_IP_ADDRESS: "127.0.1.1",
CONF_USERNAME: "user1",
CONF_PASSWORD: "password1",
},
- ]
- )
+ ],
+ }
+
+ await async_process_ha_core_config(hass, hass_config[HA_DOMAIN])
+ assert await async_setup_component(hass, HA_DOMAIN, {})
+ assert await async_setup_component(hass, COVER_DOMAIN, hass_config)
+ await hass.async_block_till_done()
+
entity_ids = hass.states.async_entity_ids(COVER_DOMAIN)
assert entity_ids is not None
assert len(entity_ids) == 2
assert "cover.door1" in entity_ids
assert "cover.door1_2" in entity_ids
- await component_factory.unload()
+@patch("homeassistant.components.gogogate2.common.GogoGate2Api")
+async def test_open_close_update(gogogat2api_mock, hass: HomeAssistant) -> None:
+ """Test open and close and data update."""
-async def test_cover_update(
- hass: HomeAssistant, component_factory: ComponentFactory
-) -> None:
- """Test cover."""
- await component_factory.configure_component()
- component_data = await component_factory.run_config_flow(
- config_data={
- CONF_IP_ADDRESS: "127.0.0.2",
- CONF_USERNAME: "user0",
- CONF_PASSWORD: "password0",
- }
+ def info_response(door_status: DoorStatus) -> GogoGate2InfoResponse:
+ return GogoGate2InfoResponse(
+ user="user1",
+ gogogatename="gogogatename0",
+ model="",
+ apiversion="",
+ remoteaccessenabled=False,
+ remoteaccess="abc123.blah.blah",
+ firmwareversion="",
+ apicode="",
+ door1=GogoGate2Door(
+ door_id=1,
+ permission=True,
+ name="Door1",
+ gate=False,
+ mode=DoorMode.GARAGE,
+ status=door_status,
+ sensor=True,
+ sensorid=None,
+ camera=False,
+ events=2,
+ temperature=None,
+ voltage=40,
+ ),
+ door2=GogoGate2Door(
+ door_id=2,
+ permission=True,
+ name=None,
+ gate=True,
+ mode=DoorMode.GARAGE,
+ status=DoorStatus.UNDEFINED,
+ sensor=True,
+ sensorid=None,
+ camera=False,
+ events=0,
+ temperature=None,
+ voltage=40,
+ ),
+ door3=GogoGate2Door(
+ door_id=3,
+ permission=True,
+ name=None,
+ gate=False,
+ mode=DoorMode.GARAGE,
+ status=DoorStatus.UNDEFINED,
+ sensor=True,
+ sensorid=None,
+ camera=False,
+ events=0,
+ temperature=None,
+ voltage=40,
+ ),
+ outputs=Outputs(output1=True, output2=False, output3=True),
+ network=Network(ip=""),
+ wifi=Wifi(SSID="", linkquality="", signal=""),
+ )
+
+ api = MagicMock(GogoGate2Api)
+ api.activate.return_value = GogoGate2ActivateResponse(result=True)
+ api.info.return_value = info_response(DoorStatus.OPENED)
+ gogogat2api_mock.return_value = api
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ source=SOURCE_USER,
+ data={
+ CONF_IP_ADDRESS: "127.0.0.1",
+ CONF_USERNAME: "admin",
+ CONF_PASSWORD: "password",
+ },
)
+ config_entry.add_to_hass(hass)
- assert hass.states.async_entity_ids(COVER_DOMAIN)
-
- state = hass.states.get("cover.door1")
- assert state
- assert state.state == STATE_OPEN
- assert state.attributes["friendly_name"] == "Door1"
- assert state.attributes["supported_features"] == 3
- assert state.attributes["device_class"] == "garage"
-
- component_data.data_update_coordinator.api.info.return_value = InfoResponse(
- user="user1",
- gogogatename="gogogatename0",
- model="",
- apiversion="",
- remoteaccessenabled=False,
- remoteaccess="abc123.blah.blah",
- firmwareversion="",
- apicode="",
- door1=Door(
- door_id=1,
- permission=True,
- name="Door1",
- mode=DoorMode.GARAGE,
- status=DoorStatus.OPENED,
- sensor=True,
- sensorid=None,
- camera=False,
- events=2,
- temperature=None,
- ),
- door2=Door(
- door_id=2,
- permission=True,
- name=None,
- mode=DoorMode.GARAGE,
- status=DoorStatus.UNDEFINED,
- sensor=True,
- sensorid=None,
- camera=False,
- events=0,
- temperature=None,
- ),
- door3=Door(
- door_id=3,
- permission=True,
- name=None,
- mode=DoorMode.GARAGE,
- status=DoorStatus.UNDEFINED,
- sensor=True,
- sensorid=None,
- camera=False,
- events=0,
- temperature=None,
- ),
- outputs=Outputs(output1=True, output2=False, output3=True),
- network=Network(ip=""),
- wifi=Wifi(SSID="", linkquality="", signal=""),
- )
- await component_data.data_update_coordinator.async_refresh()
- await hass.async_block_till_done()
- state = hass.states.get("cover.door1")
- assert state
- assert state.state == STATE_OPEN
-
- component_data.data_update_coordinator.api.info.return_value = InfoResponse(
- user="user1",
- gogogatename="gogogatename0",
- model="",
- apiversion="",
- remoteaccessenabled=False,
- remoteaccess="abc123.blah.blah",
- firmwareversion="",
- apicode="",
- door1=Door(
- door_id=1,
- permission=True,
- name="Door1",
- mode=DoorMode.GARAGE,
- status=DoorStatus.CLOSED,
- sensor=True,
- sensorid=None,
- camera=False,
- events=2,
- temperature=None,
- ),
- door2=Door(
- door_id=2,
- permission=True,
- name=None,
- mode=DoorMode.GARAGE,
- status=DoorStatus.UNDEFINED,
- sensor=True,
- sensorid=None,
- camera=False,
- events=0,
- temperature=None,
- ),
- door3=Door(
- door_id=3,
- permission=True,
- name=None,
- mode=DoorMode.GARAGE,
- status=DoorStatus.UNDEFINED,
- sensor=True,
- sensorid=None,
- camera=False,
- events=0,
- temperature=None,
- ),
- outputs=Outputs(output1=True, output2=False, output3=True),
- network=Network(ip=""),
- wifi=Wifi(SSID="", linkquality="", signal=""),
- )
- await component_data.data_update_coordinator.async_refresh()
- await hass.async_block_till_done()
- state = hass.states.get("cover.door1")
- assert state
- assert state.state == STATE_CLOSED
-
-
-async def test_open_close(
- hass: HomeAssistant, component_factory: ComponentFactory
-) -> None:
- """Test open and close."""
- closed_door_response = InfoResponse(
- user="user1",
- gogogatename="gogogatename0",
- model="",
- apiversion="",
- remoteaccessenabled=False,
- remoteaccess="abc123.blah.blah",
- firmwareversion="",
- apicode="",
- door1=Door(
- door_id=1,
- permission=True,
- name="Door1",
- mode=DoorMode.GARAGE,
- status=DoorStatus.CLOSED,
- sensor=True,
- sensorid=None,
- camera=False,
- events=2,
- temperature=None,
- ),
- door2=Door(
- door_id=2,
- permission=True,
- name=None,
- mode=DoorMode.GARAGE,
- status=DoorStatus.UNDEFINED,
- sensor=True,
- sensorid=None,
- camera=False,
- events=0,
- temperature=None,
- ),
- door3=Door(
- door_id=3,
- permission=True,
- name=None,
- mode=DoorMode.GARAGE,
- status=DoorStatus.UNDEFINED,
- sensor=True,
- sensorid=None,
- camera=False,
- events=0,
- temperature=None,
- ),
- outputs=Outputs(output1=True, output2=False, output3=True),
- network=Network(ip=""),
- wifi=Wifi(SSID="", linkquality="", signal=""),
- )
-
- await component_factory.configure_component()
assert hass.states.get("cover.door1") is None
-
- component_data = await component_factory.run_config_flow(
- config_data={
- CONF_IP_ADDRESS: "127.0.0.2",
- CONF_USERNAME: "user0",
- CONF_PASSWORD: "password0",
- }
- )
-
- component_data.api.activate.return_value = ActivateResponse(result=True)
-
- assert hass.states.get("cover.door1").state == STATE_OPEN
- await hass.services.async_call(
- COVER_DOMAIN, "close_cover", service_data={"entity_id": "cover.door1"},
- )
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
- component_data.api.close_door.assert_called_with(1)
-
- component_data.data_update_coordinator.api.info.return_value = closed_door_response
- await component_data.data_update_coordinator.async_refresh()
- await hass.async_block_till_done()
- assert hass.states.get("cover.door1").state == STATE_CLOSED
-
- # Assert mid state changed when new status is received.
- await hass.services.async_call(
- COVER_DOMAIN, "open_cover", service_data={"entity_id": "cover.door1"},
- )
- await hass.async_block_till_done()
- component_data.api.open_door.assert_called_with(1)
-
- # Assert the mid state does not change when the same status is returned.
- component_data.data_update_coordinator.api.info.return_value = closed_door_response
- await component_data.data_update_coordinator.async_refresh()
- component_data.data_update_coordinator.api.info.return_value = closed_door_response
- await component_data.data_update_coordinator.async_refresh()
-
- await component_data.data_update_coordinator.async_refresh()
- await hass.services.async_call(
- HA_DOMAIN, "update_entity", service_data={"entity_id": "cover.door1"},
- )
- await hass.async_block_till_done()
- assert hass.states.get("cover.door1").state == STATE_CLOSED
-
-
-async def test_availability(
- hass: HomeAssistant, component_factory: ComponentFactory
-) -> None:
- """Test open and close."""
- closed_door_response = InfoResponse(
- user="user1",
- gogogatename="gogogatename0",
- model="",
- apiversion="",
- remoteaccessenabled=False,
- remoteaccess="abc123.blah.blah",
- firmwareversion="",
- apicode="",
- door1=Door(
- door_id=1,
- permission=True,
- name="Door1",
- mode=DoorMode.GARAGE,
- status=DoorStatus.CLOSED,
- sensor=True,
- sensorid=None,
- camera=False,
- events=2,
- temperature=None,
- ),
- door2=Door(
- door_id=2,
- permission=True,
- name=None,
- mode=DoorMode.GARAGE,
- status=DoorStatus.UNDEFINED,
- sensor=True,
- sensorid=None,
- camera=False,
- events=0,
- temperature=None,
- ),
- door3=Door(
- door_id=3,
- permission=True,
- name=None,
- mode=DoorMode.GARAGE,
- status=DoorStatus.UNDEFINED,
- sensor=True,
- sensorid=None,
- camera=False,
- events=0,
- temperature=None,
- ),
- outputs=Outputs(output1=True, output2=False, output3=True),
- network=Network(ip=""),
- wifi=Wifi(SSID="", linkquality="", signal=""),
- )
-
- await component_factory.configure_component()
- assert hass.states.get("cover.door1") is None
-
- component_data = await component_factory.run_config_flow(
- config_data={
- CONF_IP_ADDRESS: "127.0.0.2",
- CONF_USERNAME: "user0",
- CONF_PASSWORD: "password0",
- }
- )
assert hass.states.get("cover.door1").state == STATE_OPEN
- component_data.api.info.side_effect = Exception("Error")
- await component_data.data_update_coordinator.async_refresh()
+ api.info.return_value = info_response(DoorStatus.CLOSED)
+ await hass.services.async_call(
+ COVER_DOMAIN,
+ "close_cover",
+ service_data={"entity_id": "cover.door1"},
+ )
+ async_fire_time_changed(hass, utcnow() + timedelta(hours=2))
+ await hass.async_block_till_done()
+ assert hass.states.get("cover.door1").state == STATE_CLOSED
+ api.close_door.assert_called_with(1)
+
+ api.info.return_value = info_response(DoorStatus.OPENED)
+ await hass.services.async_call(
+ COVER_DOMAIN,
+ "open_cover",
+ service_data={"entity_id": "cover.door1"},
+ )
+ async_fire_time_changed(hass, utcnow() + timedelta(hours=2))
+ await hass.async_block_till_done()
+ assert hass.states.get("cover.door1").state == STATE_OPEN
+ api.open_door.assert_called_with(1)
+
+ api.info.return_value = info_response(DoorStatus.UNDEFINED)
+ async_fire_time_changed(hass, utcnow() + timedelta(hours=2))
+ await hass.async_block_till_done()
+ assert hass.states.get("cover.door1").state == STATE_UNKNOWN
+
+ assert await hass.config_entries.async_unload(config_entry.entry_id)
+ assert not hass.states.async_entity_ids(DOMAIN)
+
+
+@patch("homeassistant.components.gogogate2.common.ISmartGateApi")
+async def test_availability(ismartgateapi_mock, hass: HomeAssistant) -> None:
+ """Test availability."""
+ closed_door_response = ISmartGateInfoResponse(
+ user="user1",
+ ismartgatename="ismartgatename0",
+ model="",
+ apiversion="",
+ remoteaccessenabled=False,
+ remoteaccess="abc123.blah.blah",
+ firmwareversion="",
+ pin=123,
+ lang="en",
+ newfirmware=False,
+ door1=ISmartGateDoor(
+ door_id=1,
+ permission=True,
+ name="Door1",
+ gate=False,
+ mode=DoorMode.GARAGE,
+ status=DoorStatus.CLOSED,
+ sensor=True,
+ sensorid=None,
+ camera=False,
+ events=2,
+ temperature=None,
+ enabled=True,
+ apicode="apicode0",
+ customimage=False,
+ voltage=40,
+ ),
+ door2=ISmartGateDoor(
+ door_id=2,
+ permission=True,
+ name="Door2",
+ gate=True,
+ mode=DoorMode.GARAGE,
+ status=DoorStatus.CLOSED,
+ sensor=True,
+ sensorid=None,
+ camera=False,
+ events=2,
+ temperature=None,
+ enabled=True,
+ apicode="apicode0",
+ customimage=False,
+ voltage=40,
+ ),
+ door3=ISmartGateDoor(
+ door_id=3,
+ permission=True,
+ name=None,
+ gate=False,
+ mode=DoorMode.GARAGE,
+ status=DoorStatus.UNDEFINED,
+ sensor=True,
+ sensorid=None,
+ camera=False,
+ events=0,
+ temperature=None,
+ enabled=True,
+ apicode="apicode0",
+ customimage=False,
+ voltage=40,
+ ),
+ network=Network(ip=""),
+ wifi=Wifi(SSID="", linkquality="", signal=""),
+ )
+
+ api = MagicMock(ISmartGateApi)
+ api.info.return_value = closed_door_response
+ ismartgateapi_mock.return_value = api
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ source=SOURCE_USER,
+ data={
+ CONF_DEVICE: DEVICE_TYPE_ISMARTGATE,
+ CONF_IP_ADDRESS: "127.0.0.1",
+ CONF_USERNAME: "admin",
+ CONF_PASSWORD: "password",
+ },
+ )
+ config_entry.add_to_hass(hass)
+
+ assert hass.states.get("cover.door1") is None
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+ assert hass.states.get("cover.door1")
+ assert (
+ hass.states.get("cover.door1").attributes[ATTR_DEVICE_CLASS]
+ == DEVICE_CLASS_GARAGE
+ )
+ assert (
+ hass.states.get("cover.door2").attributes[ATTR_DEVICE_CLASS]
+ == DEVICE_CLASS_GATE
+ )
+
+ api.info.side_effect = Exception("Error")
+
+ async_fire_time_changed(hass, utcnow() + timedelta(hours=2))
await hass.async_block_till_done()
assert hass.states.get("cover.door1").state == STATE_UNAVAILABLE
- component_data.api.info.side_effect = None
- component_data.api.info.return_value = closed_door_response
- await component_data.data_update_coordinator.async_refresh()
+ api.info.side_effect = None
+ api.info.return_value = closed_door_response
+ async_fire_time_changed(hass, utcnow() + timedelta(hours=2))
await hass.async_block_till_done()
assert hass.states.get("cover.door1").state == STATE_CLOSED
diff --git a/tests/components/gogogate2/test_init.py b/tests/components/gogogate2/test_init.py
index 8788590407f..af8678300d1 100644
--- a/tests/components/gogogate2/test_init.py
+++ b/tests/components/gogogate2/test_init.py
@@ -1,8 +1,17 @@
"""Tests for the GogoGate2 component."""
+from gogogate2_api import GogoGate2Api
import pytest
-from homeassistant.components.gogogate2 import async_setup_entry
-from homeassistant.components.gogogate2.common import GogoGateDataUpdateCoordinator
+from homeassistant.components.gogogate2 import DEVICE_TYPE_GOGOGATE2, async_setup_entry
+from homeassistant.components.gogogate2.common import DeviceDataUpdateCoordinator
+from homeassistant.components.gogogate2.const import DEVICE_TYPE_ISMARTGATE, DOMAIN
+from homeassistant.config_entries import SOURCE_USER
+from homeassistant.const import (
+ CONF_DEVICE,
+ CONF_IP_ADDRESS,
+ CONF_PASSWORD,
+ CONF_USERNAME,
+)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
@@ -10,11 +19,69 @@ from tests.async_mock import MagicMock, patch
from tests.common import MockConfigEntry
+@patch("homeassistant.components.gogogate2.common.GogoGate2Api")
+async def test_config_update(gogogate2api_mock, hass: HomeAssistant) -> None:
+ """Test config setup where the config is updated."""
+
+ api = MagicMock(GogoGate2Api)
+ api.info.side_effect = Exception("Error")
+ gogogate2api_mock.return_value = api
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ source=SOURCE_USER,
+ data={
+ CONF_IP_ADDRESS: "127.0.0.1",
+ CONF_USERNAME: "admin",
+ CONF_PASSWORD: "password",
+ },
+ )
+ config_entry.add_to_hass(hass)
+
+ assert not await hass.config_entries.async_setup(entry_id=config_entry.entry_id)
+ await hass.async_block_till_done()
+ assert config_entry.data == {
+ CONF_DEVICE: DEVICE_TYPE_GOGOGATE2,
+ CONF_IP_ADDRESS: "127.0.0.1",
+ CONF_USERNAME: "admin",
+ CONF_PASSWORD: "password",
+ }
+
+
+@patch("homeassistant.components.gogogate2.common.ISmartGateApi")
+async def test_config_no_update(ismartgateapi_mock, hass: HomeAssistant) -> None:
+ """Test config setup where the data is not updated."""
+ api = MagicMock(GogoGate2Api)
+ api.info.side_effect = Exception("Error")
+ ismartgateapi_mock.return_value = api
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ source=SOURCE_USER,
+ data={
+ CONF_DEVICE: DEVICE_TYPE_ISMARTGATE,
+ CONF_IP_ADDRESS: "127.0.0.1",
+ CONF_USERNAME: "admin",
+ CONF_PASSWORD: "password",
+ },
+ )
+ config_entry.add_to_hass(hass)
+
+ assert not await hass.config_entries.async_setup(entry_id=config_entry.entry_id)
+ await hass.async_block_till_done()
+ assert config_entry.data == {
+ CONF_DEVICE: DEVICE_TYPE_ISMARTGATE,
+ CONF_IP_ADDRESS: "127.0.0.1",
+ CONF_USERNAME: "admin",
+ CONF_PASSWORD: "password",
+ }
+
+
async def test_auth_fail(hass: HomeAssistant) -> None:
"""Test authorization failures."""
- coordinator_mock: GogoGateDataUpdateCoordinator = MagicMock(
- spec=GogoGateDataUpdateCoordinator
+ coordinator_mock: DeviceDataUpdateCoordinator = MagicMock(
+ spec=DeviceDataUpdateCoordinator
)
coordinator_mock.last_update_success = False
diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py
index f665fa53ed2..f4e26a77f48 100644
--- a/tests/components/google_assistant/__init__.py
+++ b/tests/components/google_assistant/__init__.py
@@ -128,6 +128,7 @@ DEMO_DEVICES = [
"action.devices.traits.OnOff",
"action.devices.traits.Brightness",
"action.devices.traits.ColorSetting",
+ "action.devices.traits.Modes",
],
"type": "action.devices.types.LIGHT",
"willReportState": False,
diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py
index c58f44c06c8..2bd68926da1 100644
--- a/tests/components/google_assistant/test_helpers.py
+++ b/tests/components/google_assistant/test_helpers.py
@@ -27,7 +27,8 @@ async def test_google_entity_sync_serialize_with_local_sdk(hass):
hass.states.async_set("light.ceiling_lights", "off")
hass.config.api = Mock(port=1234, use_ssl=True)
await async_process_ha_core_config(
- hass, {"external_url": "https://hostname:1234"},
+ hass,
+ {"external_url": "https://hostname:1234"},
)
hass.http = Mock(server_port=1234)
@@ -227,7 +228,8 @@ async def test_report_state_all(agents):
@pytest.mark.parametrize(
- "agents, result", [({}, 204), ({"1": 200}, 200), ({"1": 200, "2": 300}, 300)],
+ "agents, result",
+ [({}, 204), ({"1": 200}, 200), ({"1": 200, "2": 300}, 300)],
)
async def test_sync_entities_all(agents, result):
"""Test sync entities ."""
diff --git a/tests/components/google_assistant/test_init.py b/tests/components/google_assistant/test_init.py
index 0df2b032b5a..e663df19d88 100644
--- a/tests/components/google_assistant/test_init.py
+++ b/tests/components/google_assistant/test_init.py
@@ -17,7 +17,9 @@ async def test_request_sync_service(aioclient_mock, hass):
aioclient_mock.post(ga.const.REQUEST_SYNC_BASE_URL, status=200)
await async_setup_component(
- hass, "google_assistant", {"google_assistant": DUMMY_CONFIG},
+ hass,
+ "google_assistant",
+ {"google_assistant": DUMMY_CONFIG},
)
assert aioclient_mock.call_count == 0
diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py
index 6cd99d1fdd1..78d403c2038 100644
--- a/tests/components/google_assistant/test_smart_home.py
+++ b/tests/components/google_assistant/test_smart_home.py
@@ -9,7 +9,7 @@ from homeassistant.components.climate.const import (
)
from homeassistant.components.demo.binary_sensor import DemoBinarySensor
from homeassistant.components.demo.cover import DemoCover
-from homeassistant.components.demo.light import DemoLight
+from homeassistant.components.demo.light import LIGHT_EFFECT_LIST, DemoLight
from homeassistant.components.demo.media_player import AbstractDemoPlayer
from homeassistant.components.demo.switch import DemoSwitch
from homeassistant.components.google_assistant import (
@@ -48,7 +48,14 @@ def registries(hass):
async def test_sync_message(hass):
"""Test a sync message."""
- light = DemoLight(None, "Demo Light", state=False, hs_color=(180, 75))
+ light = DemoLight(
+ None,
+ "Demo Light",
+ state=False,
+ hs_color=(180, 75),
+ effect_list=LIGHT_EFFECT_LIST,
+ effect=LIGHT_EFFECT_LIST[0],
+ )
light.hass = hass
light.entity_id = "light.demo_light"
await light.async_update_ha_state()
@@ -95,10 +102,37 @@ async def test_sync_message(hass):
trait.TRAIT_BRIGHTNESS,
trait.TRAIT_ONOFF,
trait.TRAIT_COLOR_SETTING,
+ trait.TRAIT_MODES,
],
"type": const.TYPE_LIGHT,
"willReportState": False,
"attributes": {
+ "availableModes": [
+ {
+ "name": "effect",
+ "name_values": [
+ {"lang": "en", "name_synonym": ["effect"]}
+ ],
+ "ordered": False,
+ "settings": [
+ {
+ "setting_name": "rainbow",
+ "setting_values": [
+ {
+ "lang": "en",
+ "setting_synonym": ["rainbow"],
+ }
+ ],
+ },
+ {
+ "setting_name": "none",
+ "setting_values": [
+ {"lang": "en", "setting_synonym": ["none"]}
+ ],
+ },
+ ],
+ }
+ ],
"colorModel": "hsv",
"colorTemperatureRange": {
"temperatureMinK": 2000,
@@ -132,7 +166,14 @@ async def test_sync_in_area(hass, registries):
"light", "test", "1235", suggested_object_id="demo_light", device_id=device.id
)
- light = DemoLight(None, "Demo Light", state=False, hs_color=(180, 75))
+ light = DemoLight(
+ None,
+ "Demo Light",
+ state=False,
+ hs_color=(180, 75),
+ effect_list=LIGHT_EFFECT_LIST,
+ effect=LIGHT_EFFECT_LIST[0],
+ )
light.hass = hass
light.entity_id = entity.entity_id
await light.async_update_ha_state()
@@ -162,10 +203,37 @@ async def test_sync_in_area(hass, registries):
trait.TRAIT_BRIGHTNESS,
trait.TRAIT_ONOFF,
trait.TRAIT_COLOR_SETTING,
+ trait.TRAIT_MODES,
],
"type": const.TYPE_LIGHT,
"willReportState": False,
"attributes": {
+ "availableModes": [
+ {
+ "name": "effect",
+ "name_values": [
+ {"lang": "en", "name_synonym": ["effect"]}
+ ],
+ "ordered": False,
+ "settings": [
+ {
+ "setting_name": "rainbow",
+ "setting_values": [
+ {
+ "lang": "en",
+ "setting_synonym": ["rainbow"],
+ }
+ ],
+ },
+ {
+ "setting_name": "none",
+ "setting_values": [
+ {"lang": "en", "setting_synonym": ["none"]}
+ ],
+ },
+ ],
+ }
+ ],
"colorModel": "hsv",
"colorTemperatureRange": {
"temperatureMinK": 2000,
@@ -186,7 +254,14 @@ async def test_sync_in_area(hass, registries):
async def test_query_message(hass):
"""Test a sync message."""
- light = DemoLight(None, "Demo Light", state=False, hs_color=(180, 75))
+ light = DemoLight(
+ None,
+ "Demo Light",
+ state=False,
+ hs_color=(180, 75),
+ effect_list=LIGHT_EFFECT_LIST,
+ effect=LIGHT_EFFECT_LIST[0],
+ )
light.hass = hass
light.entity_id = "light.demo_light"
await light.async_update_ha_state()
@@ -555,7 +630,14 @@ async def test_serialize_input_boolean(hass):
async def test_unavailable_state_does_sync(hass):
"""Test that an unavailable entity does sync over."""
- light = DemoLight(None, "Demo Light", state=False, hs_color=(180, 75))
+ light = DemoLight(
+ None,
+ "Demo Light",
+ state=False,
+ hs_color=(180, 75),
+ effect_list=LIGHT_EFFECT_LIST,
+ effect=LIGHT_EFFECT_LIST[0],
+ )
light.hass = hass
light.entity_id = "light.demo_light"
light._available = False # pylint: disable=protected-access
@@ -584,10 +666,37 @@ async def test_unavailable_state_does_sync(hass):
trait.TRAIT_BRIGHTNESS,
trait.TRAIT_ONOFF,
trait.TRAIT_COLOR_SETTING,
+ trait.TRAIT_MODES,
],
"type": const.TYPE_LIGHT,
"willReportState": False,
"attributes": {
+ "availableModes": [
+ {
+ "name": "effect",
+ "name_values": [
+ {"lang": "en", "name_synonym": ["effect"]}
+ ],
+ "ordered": False,
+ "settings": [
+ {
+ "setting_name": "rainbow",
+ "setting_values": [
+ {
+ "lang": "en",
+ "setting_synonym": ["rainbow"],
+ }
+ ],
+ },
+ {
+ "setting_name": "none",
+ "setting_values": [
+ {"lang": "en", "setting_synonym": ["none"]}
+ ],
+ },
+ ],
+ }
+ ],
"colorModel": "hsv",
"colorTemperatureRange": {
"temperatureMinK": 2000,
@@ -686,7 +795,10 @@ async def test_device_class_binary_sensor(hass, device_class, google_type):
"agentUserId": "test-agent",
"devices": [
{
- "attributes": {"queryOnlyOpenClose": True},
+ "attributes": {
+ "queryOnlyOpenClose": True,
+ "discreteOnlyOpenClose": True,
+ },
"id": "binary_sensor.demo_sensor",
"name": {"name": "Demo Sensor"},
"traits": ["action.devices.traits.OpenClose"],
@@ -744,6 +856,8 @@ async def test_device_class_cover(hass, device_class, google_type):
[
("non_existing_class", "action.devices.types.SETTOP"),
("tv", "action.devices.types.TV"),
+ ("speaker", "action.devices.types.SPEAKER"),
+ ("receiver", "action.devices.types.AUDIO_VIDEO_RECEIVER"),
],
)
async def test_device_media_player(hass, device_class, google_type):
@@ -805,7 +919,8 @@ async def test_query_disconnect(hass):
async def test_trait_execute_adding_query_data(hass):
"""Test a trait execute influencing query data."""
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com"},
+ hass,
+ {"external_url": "https://example.com"},
)
hass.states.async_set(
"camera.office", "idle", {"supported_features": camera.SUPPORT_STREAM}
@@ -1019,3 +1134,118 @@ async def test_reachable_devices(hass):
"requestId": REQ_ID,
"payload": {"devices": [{"verificationId": "light.ceiling_lights"}]},
}
+
+
+async def test_sync_message_recovery(hass, caplog):
+ """Test a sync message recovers from bad entities."""
+ light = DemoLight(
+ None,
+ "Demo Light",
+ state=False,
+ hs_color=(180, 75),
+ )
+ light.hass = hass
+ light.entity_id = "light.demo_light"
+ await light.async_update_ha_state()
+
+ hass.states.async_set(
+ "light.bad_light",
+ "on",
+ {
+ "min_mireds": "badvalue",
+ "supported_features": hass.components.light.SUPPORT_COLOR_TEMP,
+ },
+ )
+
+ result = await sh.async_handle_message(
+ hass,
+ BASIC_CONFIG,
+ "test-agent",
+ {"requestId": REQ_ID, "inputs": [{"intent": "action.devices.SYNC"}]},
+ const.SOURCE_CLOUD,
+ )
+
+ assert result == {
+ "requestId": REQ_ID,
+ "payload": {
+ "agentUserId": "test-agent",
+ "devices": [
+ {
+ "id": "light.demo_light",
+ "name": {"name": "Demo Light"},
+ "attributes": {
+ "colorModel": "hsv",
+ "colorTemperatureRange": {
+ "temperatureMaxK": 6535,
+ "temperatureMinK": 2000,
+ },
+ },
+ "traits": [
+ "action.devices.traits.Brightness",
+ "action.devices.traits.OnOff",
+ "action.devices.traits.ColorSetting",
+ ],
+ "willReportState": False,
+ "type": "action.devices.types.LIGHT",
+ },
+ ],
+ },
+ }
+
+ assert "Error serializing light.bad_light" in caplog.text
+
+
+async def test_query_recover(hass, caplog):
+ """Test that we recover if an entity raises during query."""
+
+ hass.states.async_set(
+ "light.good",
+ "on",
+ {
+ "supported_features": hass.components.light.SUPPORT_BRIGHTNESS,
+ "brightness": 50,
+ },
+ )
+ hass.states.async_set(
+ "light.bad",
+ "on",
+ {
+ "supported_features": hass.components.light.SUPPORT_BRIGHTNESS,
+ "brightness": "shoe",
+ },
+ )
+
+ result = await sh.async_handle_message(
+ hass,
+ BASIC_CONFIG,
+ "test-agent",
+ {
+ "requestId": REQ_ID,
+ "inputs": [
+ {
+ "intent": "action.devices.QUERY",
+ "payload": {
+ "devices": [
+ {"id": "light.good"},
+ {"id": "light.bad"},
+ ]
+ },
+ }
+ ],
+ },
+ const.SOURCE_CLOUD,
+ )
+
+ assert (
+ f"Unexpected error serializing query for {hass.states.get('light.bad')}"
+ in caplog.text
+ )
+ assert result == {
+ "requestId": REQ_ID,
+ "payload": {
+ "devices": {
+ "light.bad": {"online": False},
+ "light.good": {"on": True, "online": True, "brightness": 19},
+ }
+ },
+ }
diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py
index 854e040119d..ac0db986f42 100644
--- a/tests/components/google_assistant/test_trait.py
+++ b/tests/components/google_assistant/test_trait.py
@@ -24,6 +24,7 @@ from homeassistant.components import (
)
from homeassistant.components.climate import const as climate
from homeassistant.components.google_assistant import const, error, helpers, trait
+from homeassistant.components.google_assistant.error import SmartHomeError
from homeassistant.components.humidifier import const as humidifier
from homeassistant.config import async_process_ha_core_config
from homeassistant.const import (
@@ -109,7 +110,8 @@ async def test_brightness_light(hass):
async def test_camera_stream(hass):
"""Test camera stream trait support for camera domain."""
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com"},
+ hass,
+ {"external_url": "https://example.com"},
)
assert helpers.get_google_type(camera.DOMAIN, None) is not None
assert trait.CameraStreamTrait.supported(camera.DOMAIN, camera.SUPPORT_STREAM, None)
@@ -206,6 +208,11 @@ async def test_onoff_switch(hass):
assert trt_off.query_attributes() == {"on": False}
+ trt_assumed = trait.OnOffTrait(
+ hass, State("switch.bla", STATE_OFF, {"assumed_state": True}), BASIC_CONFIG
+ )
+ assert trt_assumed.sync_attributes() == {"commandOnlyOnOff": True}
+
on_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_ON)
await trt_on.execute(trait.COMMAND_ONOFF, BASIC_DATA, {"on": True}, {})
assert len(on_calls) == 1
@@ -514,6 +521,74 @@ async def test_color_light_temperature_light_bad_temp(hass):
assert trt.query_attributes() == {}
+async def test_light_modes(hass):
+ """Test Light Mode trait."""
+ assert helpers.get_google_type(light.DOMAIN, None) is not None
+ assert trait.ModesTrait.supported(light.DOMAIN, light.SUPPORT_EFFECT, None)
+
+ trt = trait.ModesTrait(
+ hass,
+ State(
+ "light.living_room",
+ light.STATE_ON,
+ attributes={
+ light.ATTR_EFFECT_LIST: ["random", "colorloop"],
+ light.ATTR_EFFECT: "random",
+ },
+ ),
+ BASIC_CONFIG,
+ )
+
+ attribs = trt.sync_attributes()
+ assert attribs == {
+ "availableModes": [
+ {
+ "name": "effect",
+ "name_values": [{"name_synonym": ["effect"], "lang": "en"}],
+ "settings": [
+ {
+ "setting_name": "random",
+ "setting_values": [
+ {"setting_synonym": ["random"], "lang": "en"}
+ ],
+ },
+ {
+ "setting_name": "colorloop",
+ "setting_values": [
+ {"setting_synonym": ["colorloop"], "lang": "en"}
+ ],
+ },
+ ],
+ "ordered": False,
+ }
+ ]
+ }
+
+ assert trt.query_attributes() == {
+ "currentModeSettings": {"effect": "random"},
+ "on": True,
+ }
+
+ assert trt.can_execute(
+ trait.COMMAND_MODES,
+ params={"updateModeSettings": {"effect": "colorloop"}},
+ )
+
+ calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON)
+ await trt.execute(
+ trait.COMMAND_MODES,
+ BASIC_DATA,
+ {"updateModeSettings": {"effect": "colorloop"}},
+ {},
+ )
+
+ assert len(calls) == 1
+ assert calls[0].data == {
+ "entity_id": "light.living_room",
+ "effect": "colorloop",
+ }
+
+
async def test_scene_scene(hass):
"""Test Scene trait support for scene domain."""
assert helpers.get_google_type(scene.DOMAIN, None) is not None
@@ -920,7 +995,9 @@ async def test_lock_unlock_unlock(hass):
# Test with 2FA override
with patch.object(
- BASIC_CONFIG, "should_2fa", return_value=False,
+ BASIC_CONFIG,
+ "should_2fa",
+ return_value=False,
):
await trt.execute(trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {"lock": False}, {})
assert len(calls) == 2
@@ -1082,7 +1159,10 @@ async def test_arm_disarm_arm_away(hass):
with pytest.raises(error.SmartHomeError) as err:
await trt.execute(
- trait.COMMAND_ARMDISARM, PIN_DATA, {"arm": True}, {},
+ trait.COMMAND_ARMDISARM,
+ PIN_DATA,
+ {"arm": True},
+ {},
)
@@ -1415,26 +1495,130 @@ async def test_inputselector(hass):
"currentInput": "game",
}
- assert trt.can_execute(trait.COMMAND_INPUT, params={"newInput": "media"},)
+ assert trt.can_execute(
+ trait.COMMAND_INPUT,
+ params={"newInput": "media"},
+ )
calls = async_mock_service(
hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOURCE
)
await trt.execute(
- trait.COMMAND_INPUT, BASIC_DATA, {"newInput": "media"}, {},
+ trait.COMMAND_INPUT,
+ BASIC_DATA,
+ {"newInput": "media"},
+ {},
)
assert len(calls) == 1
assert calls[0].data == {"entity_id": "media_player.living_room", "source": "media"}
+@pytest.mark.parametrize(
+ "sources,source,source_next,source_prev",
+ [
+ (["a"], "a", "a", "a"),
+ (["a", "b"], "a", "b", "b"),
+ (["a", "b", "c"], "a", "b", "c"),
+ ],
+)
+async def test_inputselector_nextprev(hass, sources, source, source_next, source_prev):
+ """Test input selector trait."""
+ trt = trait.InputSelectorTrait(
+ hass,
+ State(
+ "media_player.living_room",
+ media_player.STATE_PLAYING,
+ attributes={
+ media_player.ATTR_INPUT_SOURCE_LIST: sources,
+ media_player.ATTR_INPUT_SOURCE: source,
+ },
+ ),
+ BASIC_CONFIG,
+ )
+
+ assert trt.can_execute("action.devices.commands.NextInput", params={})
+ assert trt.can_execute("action.devices.commands.PreviousInput", params={})
+
+ calls = async_mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOURCE
+ )
+ await trt.execute(
+ "action.devices.commands.NextInput",
+ BASIC_DATA,
+ {},
+ {},
+ )
+ await trt.execute(
+ "action.devices.commands.PreviousInput",
+ BASIC_DATA,
+ {},
+ {},
+ )
+
+ assert len(calls) == 2
+ assert calls[0].data == {
+ "entity_id": "media_player.living_room",
+ "source": source_next,
+ }
+ assert calls[1].data == {
+ "entity_id": "media_player.living_room",
+ "source": source_prev,
+ }
+
+
+@pytest.mark.parametrize(
+ "sources,source", [(None, "a"), (["a", "b"], None), (["a", "b"], "c")]
+)
+async def test_inputselector_nextprev_invalid(hass, sources, source):
+ """Test input selector trait."""
+ trt = trait.InputSelectorTrait(
+ hass,
+ State(
+ "media_player.living_room",
+ media_player.STATE_PLAYING,
+ attributes={
+ media_player.ATTR_INPUT_SOURCE_LIST: sources,
+ media_player.ATTR_INPUT_SOURCE: source,
+ },
+ ),
+ BASIC_CONFIG,
+ )
+
+ with pytest.raises(SmartHomeError):
+ await trt.execute(
+ "action.devices.commands.NextInput",
+ BASIC_DATA,
+ {},
+ {},
+ )
+
+ with pytest.raises(SmartHomeError):
+ await trt.execute(
+ "action.devices.commands.PreviousInput",
+ BASIC_DATA,
+ {},
+ {},
+ )
+
+ with pytest.raises(SmartHomeError):
+ await trt.execute(
+ "action.devices.commands.InvalidCommand",
+ BASIC_DATA,
+ {},
+ {},
+ )
+
+
async def test_modes_input_select(hass):
"""Test Input Select Mode trait."""
assert helpers.get_google_type(input_select.DOMAIN, None) is not None
assert trait.ModesTrait.supported(input_select.DOMAIN, None, None)
trt = trait.ModesTrait(
- hass, State("input_select.bla", "unavailable"), BASIC_CONFIG,
+ hass,
+ State("input_select.bla", "unavailable"),
+ BASIC_CONFIG,
)
assert trt.sync_attributes() == {"availableModes": []}
@@ -1484,14 +1668,18 @@ async def test_modes_input_select(hass):
}
assert trt.can_execute(
- trait.COMMAND_MODES, params={"updateModeSettings": {"option": "xyz"}},
+ trait.COMMAND_MODES,
+ params={"updateModeSettings": {"option": "xyz"}},
)
calls = async_mock_service(
hass, input_select.DOMAIN, input_select.SERVICE_SELECT_OPTION
)
await trt.execute(
- trait.COMMAND_MODES, BASIC_DATA, {"updateModeSettings": {"option": "xyz"}}, {},
+ trait.COMMAND_MODES,
+ BASIC_DATA,
+ {"updateModeSettings": {"option": "xyz"}},
+ {},
)
assert len(calls) == 1
@@ -1562,7 +1750,10 @@ async def test_modes_humidifier(hass):
calls = async_mock_service(hass, humidifier.DOMAIN, humidifier.SERVICE_SET_MODE)
await trt.execute(
- trait.COMMAND_MODES, BASIC_DATA, {"updateModeSettings": {"mode": "away"}}, {},
+ trait.COMMAND_MODES,
+ BASIC_DATA,
+ {"updateModeSettings": {"mode": "away"}},
+ {},
)
assert len(calls) == 1
@@ -1625,7 +1816,8 @@ async def test_sound_modes(hass):
}
assert trt.can_execute(
- trait.COMMAND_MODES, params={"updateModeSettings": {"sound mode": "stereo"}},
+ trait.COMMAND_MODES,
+ params={"updateModeSettings": {"sound mode": "stereo"}},
)
calls = async_mock_service(
@@ -1835,7 +2027,10 @@ async def test_openclose_binary_sensor(hass, device_class):
BASIC_CONFIG,
)
- assert trt.sync_attributes() == {"queryOnlyOpenClose": True}
+ assert trt.sync_attributes() == {
+ "queryOnlyOpenClose": True,
+ "discreteOnlyOpenClose": True,
+ }
assert trt.query_attributes() == {"openPercent": 100}
@@ -1845,7 +2040,10 @@ async def test_openclose_binary_sensor(hass, device_class):
BASIC_CONFIG,
)
- assert trt.sync_attributes() == {"queryOnlyOpenClose": True}
+ assert trt.sync_attributes() == {
+ "queryOnlyOpenClose": True,
+ "discreteOnlyOpenClose": True,
+ }
assert trt.query_attributes() == {"openPercent": 0}
@@ -1855,7 +2053,7 @@ async def test_volume_media_player(hass):
assert helpers.get_google_type(media_player.DOMAIN, None) is not None
assert trait.VolumeTrait.supported(
media_player.DOMAIN,
- media_player.SUPPORT_VOLUME_SET | media_player.SUPPORT_VOLUME_MUTE,
+ media_player.SUPPORT_VOLUME_SET,
None,
)
@@ -1865,16 +2063,21 @@ async def test_volume_media_player(hass):
"media_player.bla",
media_player.STATE_PLAYING,
{
+ ATTR_SUPPORTED_FEATURES: media_player.SUPPORT_VOLUME_SET,
media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.3,
- media_player.ATTR_MEDIA_VOLUME_MUTED: False,
},
),
BASIC_CONFIG,
)
- assert trt.sync_attributes() == {}
+ assert trt.sync_attributes() == {
+ "volumeMaxLevel": 100,
+ "levelStepSize": 10,
+ "volumeCanMuteAndUnmute": False,
+ "commandOnlyVolume": False,
+ }
- assert trt.query_attributes() == {"currentVolume": 30, "isMuted": False}
+ assert trt.query_attributes() == {"currentVolume": 30}
calls = async_mock_service(
hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET
@@ -1886,40 +2089,144 @@ async def test_volume_media_player(hass):
media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.6,
}
+ calls = async_mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET
+ )
+ await trt.execute(
+ trait.COMMAND_VOLUME_RELATIVE, BASIC_DATA, {"relativeSteps": 10}, {}
+ )
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: "media_player.bla",
+ media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.4,
+ }
+
async def test_volume_media_player_relative(hass):
- """Test volume trait support for media player domain."""
+ """Test volume trait support for relative-volume-only media players."""
+ assert trait.VolumeTrait.supported(
+ media_player.DOMAIN,
+ media_player.SUPPORT_VOLUME_STEP,
+ None,
+ )
trt = trait.VolumeTrait(
hass,
State(
"media_player.bla",
media_player.STATE_PLAYING,
{
- media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.3,
+ ATTR_ASSUMED_STATE: True,
+ ATTR_SUPPORTED_FEATURES: media_player.SUPPORT_VOLUME_STEP,
+ },
+ ),
+ BASIC_CONFIG,
+ )
+
+ assert trt.sync_attributes() == {
+ "volumeMaxLevel": 100,
+ "levelStepSize": 10,
+ "volumeCanMuteAndUnmute": False,
+ "commandOnlyVolume": True,
+ }
+
+ assert trt.query_attributes() == {}
+
+ calls = async_mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_UP
+ )
+
+ await trt.execute(
+ trait.COMMAND_VOLUME_RELATIVE,
+ BASIC_DATA,
+ {"relativeSteps": 10},
+ {},
+ )
+ assert len(calls) == 10
+ for call in calls:
+ assert call.data == {
+ ATTR_ENTITY_ID: "media_player.bla",
+ }
+
+ calls = async_mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_DOWN
+ )
+ await trt.execute(
+ trait.COMMAND_VOLUME_RELATIVE,
+ BASIC_DATA,
+ {"relativeSteps": -10},
+ {},
+ )
+ assert len(calls) == 10
+ for call in calls:
+ assert call.data == {
+ ATTR_ENTITY_ID: "media_player.bla",
+ }
+
+ with pytest.raises(SmartHomeError):
+ await trt.execute(trait.COMMAND_SET_VOLUME, BASIC_DATA, {"volumeLevel": 42}, {})
+
+ with pytest.raises(SmartHomeError):
+ await trt.execute(trait.COMMAND_MUTE, BASIC_DATA, {"mute": True}, {})
+
+
+async def test_media_player_mute(hass):
+ """Test volume trait support for muting."""
+ assert trait.VolumeTrait.supported(
+ media_player.DOMAIN,
+ media_player.SUPPORT_VOLUME_STEP | media_player.SUPPORT_VOLUME_MUTE,
+ None,
+ )
+ trt = trait.VolumeTrait(
+ hass,
+ State(
+ "media_player.bla",
+ media_player.STATE_PLAYING,
+ {
+ ATTR_SUPPORTED_FEATURES: (
+ media_player.SUPPORT_VOLUME_STEP | media_player.SUPPORT_VOLUME_MUTE
+ ),
media_player.ATTR_MEDIA_VOLUME_MUTED: False,
},
),
BASIC_CONFIG,
)
- assert trt.sync_attributes() == {}
+ assert trt.sync_attributes() == {
+ "volumeMaxLevel": 100,
+ "levelStepSize": 10,
+ "volumeCanMuteAndUnmute": True,
+ "commandOnlyVolume": False,
+ }
+ assert trt.query_attributes() == {"isMuted": False}
- assert trt.query_attributes() == {"currentVolume": 30, "isMuted": False}
-
- calls = async_mock_service(
- hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET
+ mute_calls = async_mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_MUTE
)
-
await trt.execute(
- trait.COMMAND_VOLUME_RELATIVE,
+ trait.COMMAND_MUTE,
BASIC_DATA,
- {"volumeRelativeLevel": 20, "relativeSteps": 2},
+ {"mute": True},
{},
)
- assert len(calls) == 1
- assert calls[0].data == {
+ assert len(mute_calls) == 1
+ assert mute_calls[0].data == {
ATTR_ENTITY_ID: "media_player.bla",
- media_player.ATTR_MEDIA_VOLUME_LEVEL: 0.5,
+ media_player.ATTR_MEDIA_VOLUME_MUTED: True,
+ }
+
+ unmute_calls = async_mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_MUTE
+ )
+ await trt.execute(
+ trait.COMMAND_MUTE,
+ BASIC_DATA,
+ {"mute": False},
+ {},
+ )
+ assert len(unmute_calls) == 1
+ assert unmute_calls[0].data == {
+ ATTR_ENTITY_ID: "media_player.bla",
+ media_player.ATTR_MEDIA_VOLUME_MUTED: False,
}
diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py
index 18e80647fe7..39dc05303b3 100644
--- a/tests/components/gpslogger/test_init.py
+++ b/tests/components/gpslogger/test_init.py
@@ -64,7 +64,8 @@ async def setup_zones(loop, hass):
async def webhook_id(hass, gpslogger_client):
"""Initialize the GPSLogger component and get the webhook_id."""
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}
diff --git a/tests/components/griddy/test_config_flow.py b/tests/components/griddy/test_config_flow.py
index 79f99d7f8b1..309864dbc11 100644
--- a/tests/components/griddy/test_config_flow.py
+++ b/tests/components/griddy/test_config_flow.py
@@ -22,10 +22,12 @@ async def test_form(hass):
), patch(
"homeassistant.components.griddy.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.griddy.async_setup_entry", return_value=True,
+ "homeassistant.components.griddy.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"loadzone": "LZ_HOUSTON"},
+ result["flow_id"],
+ {"loadzone": "LZ_HOUSTON"},
)
assert result2["type"] == "create_entry"
@@ -47,7 +49,8 @@ async def test_form_cannot_connect(hass):
side_effect=asyncio.TimeoutError,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"loadzone": "LZ_NORTH"},
+ result["flow_id"],
+ {"loadzone": "LZ_NORTH"},
)
assert result2["type"] == "form"
diff --git a/tests/components/group/common.py b/tests/components/group/common.py
index 69de1cfee75..b2c35703e6c 100644
--- a/tests/components/group/common.py
+++ b/tests/components/group/common.py
@@ -31,18 +31,34 @@ def async_reload(hass):
@bind_hass
def set_group(
- hass, object_id, name=None, entity_ids=None, icon=None, add=None,
+ hass,
+ object_id,
+ name=None,
+ entity_ids=None,
+ icon=None,
+ add=None,
):
"""Create/Update a group."""
hass.add_job(
- async_set_group, hass, object_id, name, entity_ids, icon, add,
+ async_set_group,
+ hass,
+ object_id,
+ name,
+ entity_ids,
+ icon,
+ add,
)
@callback
@bind_hass
def async_set_group(
- hass, object_id, name=None, entity_ids=None, icon=None, add=None,
+ hass,
+ object_id,
+ name=None,
+ entity_ids=None,
+ icon=None,
+ add=None,
):
"""Create/Update a group."""
data = {
diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py
index 98460762389..efdbc40ee46 100644
--- a/tests/components/group/test_cover.py
+++ b/tests/components/group/test_cover.py
@@ -78,6 +78,8 @@ async def setup_comp(hass, config_count):
with assert_setup_component(count, DOMAIN):
await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
@pytest.mark.parametrize("config_count", [(CONFIG_ATTRIBUTES, 1)])
diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py
index 3709c4856a2..9684c107bb7 100644
--- a/tests/components/group/test_init.py
+++ b/tests/components/group/test_init.py
@@ -512,3 +512,54 @@ async def test_group_order(hass):
assert hass.states.get("group.group_zero").attributes["order"] == 0
assert hass.states.get("group.group_one").attributes["order"] == 1
assert hass.states.get("group.group_two").attributes["order"] == 2
+
+
+async def test_group_order_with_dynamic_creation(hass):
+ """Test that order gets incremented when creating a new group."""
+ hass.states.async_set("light.bowl", STATE_ON)
+
+ assert await async_setup_component(
+ hass,
+ "group",
+ {
+ "group": {
+ "group_zero": {"entities": "light.Bowl", "icon": "mdi:work"},
+ "group_one": {"entities": "light.Bowl", "icon": "mdi:work"},
+ "group_two": {"entities": "light.Bowl", "icon": "mdi:work"},
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert hass.states.get("group.group_zero").attributes["order"] == 0
+ assert hass.states.get("group.group_one").attributes["order"] == 1
+ assert hass.states.get("group.group_two").attributes["order"] == 2
+
+ await hass.services.async_call(
+ group.DOMAIN,
+ group.SERVICE_SET,
+ {"object_id": "new_group", "name": "New Group", "entities": "light.bowl"},
+ )
+ await hass.async_block_till_done()
+
+ assert hass.states.get("group.new_group").attributes["order"] == 3
+
+ await hass.services.async_call(
+ group.DOMAIN,
+ group.SERVICE_REMOVE,
+ {
+ "object_id": "new_group",
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert not hass.states.get("group.new_group")
+
+ await hass.services.async_call(
+ group.DOMAIN,
+ group.SERVICE_SET,
+ {"object_id": "new_group2", "name": "New Group 2", "entities": "light.bowl"},
+ )
+ await hass.async_block_till_done()
+
+ assert hass.states.get("group.new_group2").attributes["order"] == 4
diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py
index 2a2e21f77c5..a22c56b4bfc 100644
--- a/tests/components/group/test_light.py
+++ b/tests/components/group/test_light.py
@@ -1,5 +1,8 @@
"""The tests for the Group Light platform."""
-from homeassistant.components.group import DOMAIN
+from os import path
+
+from homeassistant import config as hass_config
+from homeassistant.components.group import DOMAIN, SERVICE_RELOAD
import homeassistant.components.group.light as group
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
@@ -29,7 +32,7 @@ from homeassistant.const import (
from homeassistant.setup import async_setup_component
import tests.async_mock
-from tests.async_mock import MagicMock
+from tests.async_mock import MagicMock, patch
async def test_default_state(hass):
@@ -47,6 +50,8 @@ async def test_default_state(hass):
},
)
await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
state = hass.states.get("light.bedroom_group")
assert state is not None
@@ -73,6 +78,9 @@ async def test_state_reporting(hass):
}
},
)
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
hass.states.async_set("light.test1", STATE_ON)
hass.states.async_set("light.test2", STATE_UNAVAILABLE)
@@ -107,6 +115,9 @@ async def test_brightness(hass):
}
},
)
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
hass.states.async_set(
"light.test1", STATE_ON, {ATTR_BRIGHTNESS: 255, ATTR_SUPPORTED_FEATURES: 1}
@@ -147,6 +158,9 @@ async def test_color(hass):
}
},
)
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
hass.states.async_set(
"light.test1", STATE_ON, {ATTR_HS_COLOR: (0, 100), ATTR_SUPPORTED_FEATURES: 16}
@@ -184,6 +198,9 @@ async def test_white_value(hass):
}
},
)
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
hass.states.async_set(
"light.test1", STATE_ON, {ATTR_WHITE_VALUE: 255, ATTR_SUPPORTED_FEATURES: 128}
@@ -219,6 +236,9 @@ async def test_color_temp(hass):
}
},
)
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
hass.states.async_set(
"light.test1", STATE_ON, {"color_temp": 2, ATTR_SUPPORTED_FEATURES: 2}
@@ -262,6 +282,8 @@ async def test_emulated_color_temp_group(hass):
},
)
await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
hass.states.async_set("light.bed_light", STATE_ON, {ATTR_SUPPORTED_FEATURES: 2})
hass.states.async_set(
@@ -306,6 +328,9 @@ async def test_min_max_mireds(hass):
}
},
)
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
hass.states.async_set(
"light.test1",
@@ -350,6 +375,9 @@ async def test_effect_list(hass):
}
},
)
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
hass.states.async_set(
"light.test1",
@@ -402,6 +430,9 @@ async def test_effect(hass):
}
},
)
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
hass.states.async_set(
"light.test1", STATE_ON, {ATTR_EFFECT: "None", ATTR_SUPPORTED_FEATURES: 6}
@@ -447,6 +478,9 @@ async def test_supported_features(hass):
}
},
)
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
hass.states.async_set("light.test1", STATE_ON, {ATTR_SUPPORTED_FEATURES: 0})
await hass.async_block_till_done()
@@ -489,6 +523,8 @@ async def test_service_calls(hass):
},
)
await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
assert hass.states.get("light.light_group").state == STATE_ON
await hass.services.async_call(
@@ -545,13 +581,11 @@ async def test_service_calls(hass):
state = hass.states.get("light.ceiling_lights")
assert state.state == STATE_ON
assert state.attributes[ATTR_BRIGHTNESS] == 128
- assert state.attributes[ATTR_EFFECT] == "Random"
assert state.attributes[ATTR_RGB_COLOR] == (42, 255, 255)
state = hass.states.get("light.kitchen_lights")
assert state.state == STATE_ON
assert state.attributes[ATTR_BRIGHTNESS] == 128
- assert state.attributes[ATTR_EFFECT] == "Random"
assert state.attributes[ATTR_RGB_COLOR] == (42, 255, 255)
@@ -561,6 +595,9 @@ async def test_invalid_service_calls(hass):
await group.async_setup_platform(
hass, {"entities": ["light.test1", "light.test2"]}, add_entities
)
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
assert add_entities.call_count == 1
grouped_light = add_entities.call_args[0][0][0]
@@ -598,3 +635,127 @@ async def test_invalid_service_calls(hass):
mock_call.assert_called_once_with(
LIGHT_DOMAIN, SERVICE_TURN_ON, data, blocking=True, context=None
)
+
+
+async def test_reload(hass):
+ """Test the ability to reload lights."""
+ await async_setup_component(
+ hass,
+ LIGHT_DOMAIN,
+ {
+ LIGHT_DOMAIN: [
+ {"platform": "demo"},
+ {
+ "platform": DOMAIN,
+ "entities": [
+ "light.bed_light",
+ "light.ceiling_lights",
+ "light.kitchen_lights",
+ ],
+ },
+ ]
+ },
+ )
+ await hass.async_block_till_done()
+
+ await hass.async_block_till_done()
+ await hass.async_start()
+
+ await hass.async_block_till_done()
+ assert hass.states.get("light.light_group").state == STATE_ON
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "group/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert hass.states.get("light.light_group") is None
+ assert hass.states.get("light.master_hall_lights_g") is not None
+ assert hass.states.get("light.outside_patio_lights_g") is not None
+
+
+async def test_reload_with_platform_not_setup(hass):
+ """Test the ability to reload lights."""
+ hass.states.async_set("light.bowl", STATE_ON)
+ await async_setup_component(
+ hass,
+ LIGHT_DOMAIN,
+ {
+ LIGHT_DOMAIN: [
+ {"platform": "demo"},
+ ]
+ },
+ )
+ assert await async_setup_component(
+ hass,
+ "group",
+ {
+ "group": {
+ "group_zero": {"entities": "light.Bowl", "icon": "mdi:work"},
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "group/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert hass.states.get("light.light_group") is None
+ assert hass.states.get("light.master_hall_lights_g") is not None
+ assert hass.states.get("light.outside_patio_lights_g") is not None
+
+
+async def test_reload_with_base_integration_platform_not_setup(hass):
+ """Test the ability to reload lights."""
+ assert await async_setup_component(
+ hass,
+ "group",
+ {
+ "group": {
+ "group_zero": {"entities": "light.Bowl", "icon": "mdi:work"},
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "group/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert hass.states.get("light.light_group") is None
+ assert hass.states.get("light.master_hall_lights_g") is not None
+ assert hass.states.get("light.outside_patio_lights_g") is not None
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py
index b120cf2cea4..62b395dcf4d 100644
--- a/tests/components/group/test_notify.py
+++ b/tests/components/group/test_notify.py
@@ -1,11 +1,14 @@
"""The tests for the notify.group platform."""
import asyncio
+from os import path
import unittest
+from homeassistant import config as hass_config
import homeassistant.components.demo.notify as demo
+from homeassistant.components.group import SERVICE_RELOAD
import homeassistant.components.group.notify as group
import homeassistant.components.notify as notify
-from homeassistant.setup import setup_component
+from homeassistant.setup import async_setup_component, setup_component
from tests.async_mock import MagicMock, patch
from tests.common import assert_setup_component, get_test_home_assistant
@@ -90,3 +93,58 @@ class TestNotifyGroup(unittest.TestCase):
"title": "Test notification",
"data": {"hello": "world", "test": "message"},
}
+
+
+async def test_reload_notify(hass):
+ """Verify we can reload the notify service."""
+
+ assert await async_setup_component(
+ hass,
+ "group",
+ {},
+ )
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(
+ hass,
+ notify.DOMAIN,
+ {
+ notify.DOMAIN: [
+ {"name": "demo1", "platform": "demo"},
+ {"name": "demo2", "platform": "demo"},
+ {
+ "name": "group_notify",
+ "platform": "group",
+ "services": [{"service": "demo1"}],
+ },
+ ]
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert hass.services.has_service(notify.DOMAIN, "demo1")
+ assert hass.services.has_service(notify.DOMAIN, "demo2")
+ assert hass.services.has_service(notify.DOMAIN, "group_notify")
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "group/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ "group",
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert hass.services.has_service(notify.DOMAIN, "demo1")
+ assert hass.services.has_service(notify.DOMAIN, "demo2")
+ assert not hass.services.has_service(notify.DOMAIN, "group_notify")
+ assert hass.services.has_service(notify.DOMAIN, "new_group_notify")
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/guardian/test_config_flow.py b/tests/components/guardian/test_config_flow.py
index 91a1a3b83e0..a10bee374f8 100644
--- a/tests/components/guardian/test_config_flow.py
+++ b/tests/components/guardian/test_config_flow.py
@@ -35,7 +35,8 @@ async def test_connect_error(hass):
conf = {CONF_IP_ADDRESS: "192.168.1.100", CONF_PORT: 7777}
with patch(
- "aioguardian.client.Client.connect", side_effect=GuardianError,
+ "aioguardian.client.Client.connect",
+ side_effect=GuardianError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=conf
diff --git a/tests/components/harmony/test_config_flow.py b/tests/components/harmony/test_config_flow.py
index acf1ffd16f1..d6bd4022d9e 100644
--- a/tests/components/harmony/test_config_flow.py
+++ b/tests/components/harmony/test_config_flow.py
@@ -44,14 +44,17 @@ async def test_user_form(hass):
harmonyapi = _get_mock_harmonyapi(connect=True)
with patch(
- "homeassistant.components.harmony.util.HarmonyAPI", return_value=harmonyapi,
+ "homeassistant.components.harmony.util.HarmonyAPI",
+ return_value=harmonyapi,
), patch(
"homeassistant.components.harmony.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.harmony.async_setup_entry", return_value=True,
+ "homeassistant.components.harmony.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"host": "1.2.3.4", "name": "friend"},
+ result["flow_id"],
+ {"host": "1.2.3.4", "name": "friend"},
)
assert result2["type"] == "create_entry"
@@ -68,11 +71,13 @@ async def test_form_import(hass):
harmonyapi = _get_mock_harmonyapi(connect=True)
with patch(
- "homeassistant.components.harmony.util.HarmonyAPI", return_value=harmonyapi,
+ "homeassistant.components.harmony.util.HarmonyAPI",
+ return_value=harmonyapi,
), patch(
"homeassistant.components.harmony.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.harmony.async_setup_entry", return_value=True,
+ "homeassistant.components.harmony.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -110,7 +115,8 @@ async def test_form_ssdp(hass):
harmonyapi = _get_mock_harmonyapi(connect=True)
with patch(
- "homeassistant.components.harmony.util.HarmonyAPI", return_value=harmonyapi,
+ "homeassistant.components.harmony.util.HarmonyAPI",
+ return_value=harmonyapi,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -129,13 +135,18 @@ async def test_form_ssdp(hass):
}
with patch(
- "homeassistant.components.harmony.util.HarmonyAPI", return_value=harmonyapi,
+ "homeassistant.components.harmony.util.HarmonyAPI",
+ return_value=harmonyapi,
), patch(
"homeassistant.components.harmony.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.harmony.async_setup_entry", return_value=True,
+ "homeassistant.components.harmony.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
- result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {},)
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
assert result2["type"] == "create_entry"
assert result2["title"] == "Harmony Hub"
@@ -149,17 +160,22 @@ async def test_form_ssdp_aborts_before_checking_remoteid_if_host_known(hass):
"""Test we abort without connecting if the host is already known."""
await setup.async_setup_component(hass, "persistent_notification", {})
config_entry = MockConfigEntry(
- domain=DOMAIN, data={"host": "2.2.2.2", "name": "any"},
+ domain=DOMAIN,
+ data={"host": "2.2.2.2", "name": "any"},
)
config_entry.add_to_hass(hass)
- config_entry_without_host = MockConfigEntry(domain=DOMAIN, data={"name": "other"},)
+ config_entry_without_host = MockConfigEntry(
+ domain=DOMAIN,
+ data={"name": "other"},
+ )
config_entry_without_host.add_to_hass(hass)
harmonyapi = _get_mock_harmonyapi(connect=True)
with patch(
- "homeassistant.components.harmony.util.HarmonyAPI", return_value=harmonyapi,
+ "homeassistant.components.harmony.util.HarmonyAPI",
+ return_value=harmonyapi,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -179,7 +195,8 @@ async def test_form_cannot_connect(hass):
)
with patch(
- "homeassistant.components.harmony.util.HarmonyAPI", side_effect=CannotConnect,
+ "homeassistant.components.harmony.util.HarmonyAPI",
+ side_effect=CannotConnect,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -208,7 +225,8 @@ async def test_options_flow(hass):
harmony_client = _get_mock_harmonyclient()
with patch(
- "aioharmony.harmonyapi.HarmonyClient", return_value=harmony_client,
+ "aioharmony.harmonyapi.HarmonyClient",
+ return_value=harmony_client,
), patch("homeassistant.components.harmony.remote.HarmonyRemote.write_config_file"):
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py
index 5768d192c8a..f52a825ca29 100644
--- a/tests/components/hassio/conftest.py
+++ b/tests/components/hassio/conftest.py
@@ -35,7 +35,8 @@ def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock):
"homeassistant.components.hassio.HassIO.update_hass_timezone",
return_value={"result": "ok"},
), patch(
- "homeassistant.components.hassio.HassIO.get_info", side_effect=HassioAPIError(),
+ "homeassistant.components.hassio.HassIO.get_info",
+ side_effect=HassioAPIError(),
):
hass.state = CoreState.starting
hass.loop.run_until_complete(async_setup_component(hass, "hassio", {}))
@@ -60,7 +61,8 @@ async def hassio_client_supervisor(hass, aiohttp_client, hassio_stubs):
"""Return an authenticated HTTP client."""
access_token = hass.auth.async_create_access_token(hassio_stubs)
return await aiohttp_client(
- hass.http.app, headers={"Authorization": f"Bearer {access_token}"},
+ hass.http.app,
+ headers={"Authorization": f"Bearer {access_token}"},
)
diff --git a/tests/components/hassio/test_auth.py b/tests/components/hassio/test_auth.py
index e97c5bc66fb..c5ac9df74b7 100644
--- a/tests/components/hassio/test_auth.py
+++ b/tests/components/hassio/test_auth.py
@@ -1,7 +1,6 @@
"""The tests for the hassio component."""
-from homeassistant.const import HTTP_INTERNAL_SERVER_ERROR
-from homeassistant.exceptions import HomeAssistantError
+from homeassistant.auth.providers.homeassistant import InvalidAuth
from tests.async_mock import Mock, patch
@@ -59,7 +58,7 @@ async def test_login_error(hass, hassio_client_supervisor):
with patch(
"homeassistant.auth.providers.homeassistant."
"HassAuthProvider.async_validate_login",
- Mock(side_effect=HomeAssistantError()),
+ Mock(side_effect=InvalidAuth()),
) as mock_login:
resp = await hassio_client_supervisor.post(
"/api/hassio_auth",
@@ -76,7 +75,7 @@ async def test_login_no_data(hass, hassio_client_supervisor):
with patch(
"homeassistant.auth.providers.homeassistant."
"HassAuthProvider.async_validate_login",
- Mock(side_effect=HomeAssistantError()),
+ Mock(side_effect=InvalidAuth()),
) as mock_login:
resp = await hassio_client_supervisor.post("/api/hassio_auth")
@@ -90,7 +89,7 @@ async def test_login_no_username(hass, hassio_client_supervisor):
with patch(
"homeassistant.auth.providers.homeassistant."
"HassAuthProvider.async_validate_login",
- Mock(side_effect=HomeAssistantError()),
+ Mock(side_effect=InvalidAuth()),
) as mock_login:
resp = await hassio_client_supervisor.post(
"/api/hassio_auth", json={"password": "123456", "addon": "samba"}
@@ -125,7 +124,8 @@ async def test_login_success_extra(hass, hassio_client_supervisor):
async def test_password_success(hass, hassio_client_supervisor):
"""Test no auth needed for ."""
with patch(
- "homeassistant.components.hassio.auth.HassIOPasswordReset._change_password",
+ "homeassistant.auth.providers.homeassistant."
+ "HassAuthProvider.async_change_password",
) as mock_change:
resp = await hassio_client_supervisor.post(
"/api/hassio_auth/password_reset",
@@ -139,44 +139,32 @@ async def test_password_success(hass, hassio_client_supervisor):
async def test_password_fails_no_supervisor(hass, hassio_client):
"""Test if only supervisor can access."""
- with patch(
- "homeassistant.auth.providers.homeassistant.Data.async_save",
- ) as mock_save:
- resp = await hassio_client.post(
- "/api/hassio_auth/password_reset",
- json={"username": "test", "password": "123456"},
- )
+ resp = await hassio_client.post(
+ "/api/hassio_auth/password_reset",
+ json={"username": "test", "password": "123456"},
+ )
- # Check we got right response
- assert resp.status == 401
- assert not mock_save.called
+ # Check we got right response
+ assert resp.status == 401
async def test_password_fails_no_auth(hass, hassio_noauth_client):
"""Test if only supervisor can access."""
- with patch(
- "homeassistant.auth.providers.homeassistant.Data.async_save",
- ) as mock_save:
- resp = await hassio_noauth_client.post(
- "/api/hassio_auth/password_reset",
- json={"username": "test", "password": "123456"},
- )
+ resp = await hassio_noauth_client.post(
+ "/api/hassio_auth/password_reset",
+ json={"username": "test", "password": "123456"},
+ )
- # Check we got right response
- assert resp.status == 401
- assert not mock_save.called
+ # Check we got right response
+ assert resp.status == 401
async def test_password_no_user(hass, hassio_client_supervisor):
- """Test no auth needed for ."""
- with patch(
- "homeassistant.auth.providers.homeassistant.Data.async_save",
- ) as mock_save:
- resp = await hassio_client_supervisor.post(
- "/api/hassio_auth/password_reset",
- json={"username": "test", "password": "123456"},
- )
+ """Test changing password for invalid user."""
+ resp = await hassio_client_supervisor.post(
+ "/api/hassio_auth/password_reset",
+ json={"username": "test", "password": "123456"},
+ )
- # Check we got right response
- assert resp.status == HTTP_INTERNAL_SERVER_ERROR
- assert not mock_save.called
+ # Check we got right response
+ assert resp.status == 404
diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py
index 1cfb5ed9f7b..3bb97a6662e 100644
--- a/tests/components/hassio/test_discovery.py
+++ b/tests/components/hassio/test_discovery.py
@@ -61,7 +61,8 @@ async def test_hassio_discovery_startup(hass, aioclient_mock, hassio_client):
async def test_hassio_discovery_startup_done(hass, aioclient_mock, hassio_client):
"""Test startup and discovery with hass discovery."""
aioclient_mock.post(
- "http://127.0.0.1/supervisor/options", json={"result": "ok", "data": {}},
+ "http://127.0.0.1/supervisor/options",
+ json={"result": "ok", "data": {}},
)
aioclient_mock.get(
"http://127.0.0.1/discovery",
diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py
index 34ba638410a..56792295fec 100644
--- a/tests/components/hassio/test_init.py
+++ b/tests/components/hassio/test_init.py
@@ -213,7 +213,8 @@ async def test_fail_setup_without_environ_var(hass):
async def test_warn_when_cannot_connect(hass, caplog):
"""Fail warn when we cannot connect."""
with patch.dict(os.environ, MOCK_ENVIRON), patch(
- "homeassistant.components.hassio.HassIO.is_connected", return_value=None,
+ "homeassistant.components.hassio.HassIO.is_connected",
+ return_value=None,
):
result = await async_setup_component(hass, "hassio", {})
assert result
diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py
index 835f34e5efc..b2d96707d7e 100644
--- a/tests/components/heos/test_media_player.py
+++ b/tests/components/heos/test_media_player.py
@@ -694,7 +694,7 @@ async def test_play_media_playlist(
await setup_platform(hass, config_entry, config)
player = controller.players[1]
playlist = playlists[0]
- # Play without enqueing
+ # Play without enqueuing
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
@@ -708,7 +708,7 @@ async def test_play_media_playlist(
player.add_to_queue.assert_called_once_with(
playlist, const.ADD_QUEUE_REPLACE_AND_PLAY
)
- # Play with enqueing
+ # Play with enqueuing
player.add_to_queue.reset_mock()
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
diff --git a/tests/components/hisense_aehw4a1/test_init.py b/tests/components/hisense_aehw4a1/test_init.py
index 498e8c4c306..bf3c9d2c6a4 100644
--- a/tests/components/hisense_aehw4a1/test_init.py
+++ b/tests/components/hisense_aehw4a1/test_init.py
@@ -78,7 +78,8 @@ async def test_configuring_hisense_w4a1_not_creates_entry_for_device_not_found(h
async def test_configuring_hisense_w4a1_not_creates_entry_for_empty_import(hass):
"""Test that specifying config will not create an entry."""
with patch(
- "homeassistant.components.hisense_aehw4a1.async_setup_entry", return_value=True,
+ "homeassistant.components.hisense_aehw4a1.async_setup_entry",
+ return_value=True,
) as mock_setup:
await async_setup_component(hass, hisense_aehw4a1.DOMAIN, {})
await hass.async_block_till_done()
diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py
index e9544ce9035..c15e4431f87 100644
--- a/tests/components/history/test_init.py
+++ b/tests/components/history/test_init.py
@@ -257,7 +257,8 @@ class TestComponentHistory(unittest.TestCase):
# will happen with encoding a native state
input_state = states["media_player.test"][1]
orig_last_changed = json.dumps(
- process_timestamp(input_state.last_changed), cls=JSONEncoder,
+ process_timestamp(input_state.last_changed),
+ cls=JSONEncoder,
).replace('"', "")
orig_state = input_state.state
states["media_player.test"][1] = {
diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py
index 25cca7615a7..bab6eae4564 100644
--- a/tests/components/history_stats/test_sensor.py
+++ b/tests/components/history_stats/test_sensor.py
@@ -1,16 +1,19 @@
"""The test for the History Statistics sensor platform."""
# pylint: disable=protected-access
from datetime import datetime, timedelta
+from os import path
import unittest
import pytest
import pytz
+from homeassistant import config as hass_config
+from homeassistant.components.history_stats import DOMAIN
from homeassistant.components.history_stats.sensor import HistoryStatsSensor
-from homeassistant.const import STATE_UNKNOWN
+from homeassistant.const import SERVICE_RELOAD, STATE_UNKNOWN
import homeassistant.core as ha
from homeassistant.helpers.template import Template
-from homeassistant.setup import setup_component
+from homeassistant.setup import async_setup_component, setup_component
import homeassistant.util.dt as dt_util
from tests.async_mock import patch
@@ -255,3 +258,58 @@ class TestHistoryStatsSensor(unittest.TestCase):
"""Initialize the recorder."""
init_recorder_component(self.hass)
self.hass.start()
+
+
+async def test_reload(hass):
+ """Verify we can reload history_stats sensors."""
+ await hass.async_add_executor_job(
+ init_recorder_component, hass
+ ) # force in memory db
+
+ hass.state = ha.CoreState.not_running
+ hass.states.async_set("binary_sensor.test_id", "on")
+
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": "history_stats",
+ "entity_id": "binary_sensor.test_id",
+ "name": "test",
+ "state": "on",
+ "start": "{{ as_timestamp(now()) - 3600 }}",
+ "duration": "01:00",
+ },
+ },
+ )
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 2
+
+ assert hass.states.get("sensor.test")
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "history_stats/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 2
+
+ assert hass.states.get("sensor.test") is None
+ assert hass.states.get("sensor.second_test")
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/hlk_sw16/test_config_flow.py b/tests/components/hlk_sw16/test_config_flow.py
index 6f9d5592893..7b7468047be 100644
--- a/tests/components/hlk_sw16/test_config_flow.py
+++ b/tests/components/hlk_sw16/test_config_flow.py
@@ -70,10 +70,12 @@ async def test_form(hass):
), patch(
"homeassistant.components.hlk_sw16.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.hlk_sw16.async_setup_entry", return_value=True,
+ "homeassistant.components.hlk_sw16.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], conf,
+ result["flow_id"],
+ conf,
)
assert result2["type"] == "create_entry"
@@ -98,7 +100,10 @@ async def test_form(hass):
assert result3["type"] == "form"
assert result3["errors"] == {}
- result4 = await hass.config_entries.flow.async_configure(result3["flow_id"], conf,)
+ result4 = await hass.config_entries.flow.async_configure(
+ result3["flow_id"],
+ conf,
+ )
assert result4["type"] == "form"
assert result4["errors"] == {"base": "already_configured"}
@@ -127,10 +132,12 @@ async def test_import(hass):
), patch(
"homeassistant.components.hlk_sw16.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.hlk_sw16.async_setup_entry", return_value=True,
+ "homeassistant.components.hlk_sw16.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], conf,
+ result["flow_id"],
+ conf,
)
assert result2["type"] == "create_entry"
@@ -162,7 +169,8 @@ async def test_form_invalid_data(hass):
return_value=mock_hlk_sw16_connection,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], conf,
+ result["flow_id"],
+ conf,
)
assert result2["type"] == "form"
@@ -186,7 +194,8 @@ async def test_form_cannot_connect(hass):
return_value=None,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], conf,
+ result["flow_id"],
+ conf,
)
assert result2["type"] == "form"
diff --git a/tests/components/home_connect/test_config_flow.py b/tests/components/home_connect/test_config_flow.py
index 57c6fb2af7f..5d65df98e5b 100644
--- a/tests/components/home_connect/test_config_flow.py
+++ b/tests/components/home_connect/test_config_flow.py
@@ -14,7 +14,7 @@ CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
-async def test_full_flow(hass, aiohttp_client, aioclient_mock):
+async def test_full_flow(hass, aiohttp_client, aioclient_mock, current_request):
"""Check full flow."""
assert await setup.async_setup_component(
hass,
diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py
index 3419aea06af..b2ca49712d1 100644
--- a/tests/components/homeassistant/test_init.py
+++ b/tests/components/homeassistant/test_init.py
@@ -266,7 +266,8 @@ async def test_turn_on_to_not_block_for_domains_without_service(hass):
service = hass.services._services["homeassistant"]["turn_on"]
with patch(
- "homeassistant.core.ServiceRegistry.async_call", return_value=None,
+ "homeassistant.core.ServiceRegistry.async_call",
+ return_value=None,
) as mock_call:
await service.func(service_call)
@@ -290,7 +291,8 @@ async def test_entity_update(hass):
await async_setup_component(hass, "homeassistant", {})
with patch(
- "homeassistant.helpers.entity_component.async_update_entity", return_value=None,
+ "homeassistant.helpers.entity_component.async_update_entity",
+ return_value=None,
) as mock_update:
await hass.services.async_call(
"homeassistant",
@@ -373,7 +375,10 @@ async def test_not_allowing_recursion(hass, caplog):
for service in SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE:
await hass.services.async_call(
- ha.DOMAIN, service, {"entity_id": "homeassistant.light"}, blocking=True,
+ ha.DOMAIN,
+ service,
+ {"entity_id": "homeassistant.light"},
+ blocking=True,
)
assert (
f"Called service homeassistant.{service} with invalid entity IDs homeassistant.light"
diff --git a/tests/components/homeassistant/test_scene.py b/tests/components/homeassistant/test_scene.py
index a1f502a3475..8f47d891f9f 100644
--- a/tests/components/homeassistant/test_scene.py
+++ b/tests/components/homeassistant/test_scene.py
@@ -320,3 +320,12 @@ async def test_config(hass):
no_icon = hass.states.get("scene.scene_no_icon")
assert no_icon is not None
assert "icon" not in no_icon.attributes
+
+
+def test_validator():
+ """Test validators."""
+ parsed = ha_scene.STATES_SCHEMA({"light.Test": {"state": "on"}})
+ assert len(parsed) == 1
+ assert "light.test" in parsed
+ assert parsed["light.test"].entity_id == "light.test"
+ assert parsed["light.test"].state == "on"
diff --git a/tests/components/homeassistant/triggers/__init__.py b/tests/components/homeassistant/triggers/__init__.py
new file mode 100644
index 00000000000..f40f513e89b
--- /dev/null
+++ b/tests/components/homeassistant/triggers/__init__.py
@@ -0,0 +1 @@
+"""Test core triggers."""
diff --git a/tests/components/automation/test_event.py b/tests/components/homeassistant/triggers/test_event.py
similarity index 92%
rename from tests/components/automation/test_event.py
rename to tests/components/homeassistant/triggers/test_event.py
index cc9aecfdac4..84b7e725f0a 100644
--- a/tests/components/automation/test_event.py
+++ b/tests/components/homeassistant/triggers/test_event.py
@@ -84,17 +84,27 @@ async def test_if_fires_on_event_with_data(hass, calls):
"trigger": {
"platform": "event",
"event_type": "test_event",
- "event_data": {"some_attr": "some_value"},
+ "event_data": {
+ "some_attr": "some_value",
+ "second_attr": "second_value",
+ },
},
"action": {"service": "test.automation"},
}
},
)
- hass.bus.async_fire("test_event", {"some_attr": "some_value", "another": "value"})
+ hass.bus.async_fire(
+ "test_event",
+ {"some_attr": "some_value", "another": "value", "second_attr": "second_value"},
+ )
await hass.async_block_till_done()
assert len(calls) == 1
+ hass.bus.async_fire("test_event", {"some_attr": "some_value", "another": "value"})
+ await hass.async_block_till_done()
+ assert len(calls) == 1 # No new call
+
async def test_if_fires_on_event_with_empty_data_config(hass, calls):
"""Test the firing of events with empty data config.
diff --git a/tests/components/automation/test_homeassistant.py b/tests/components/homeassistant/triggers/test_homeassistant.py
similarity index 95%
rename from tests/components/automation/test_homeassistant.py
rename to tests/components/homeassistant/triggers/test_homeassistant.py
index d7bdfbeef3e..9272c3620af 100644
--- a/tests/components/automation/test_homeassistant.py
+++ b/tests/components/homeassistant/triggers/test_homeassistant.py
@@ -29,7 +29,8 @@ async def test_if_fires_on_hass_start(hass):
assert len(calls) == 1
with patch(
- "homeassistant.config.async_hass_config_yaml", AsyncMock(return_value=config),
+ "homeassistant.config.async_hass_config_yaml",
+ AsyncMock(return_value=config),
):
await hass.services.async_call(
automation.DOMAIN, automation.SERVICE_RELOAD, blocking=True
diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/homeassistant/triggers/test_numeric_state.py
similarity index 94%
rename from tests/components/automation/test_numeric_state.py
rename to tests/components/homeassistant/triggers/test_numeric_state.py
index e4180ad2e6c..932dde91120 100644
--- a/tests/components/automation/test_numeric_state.py
+++ b/tests/components/homeassistant/triggers/test_numeric_state.py
@@ -5,7 +5,9 @@ import pytest
import voluptuous as vol
import homeassistant.components.automation as automation
-from homeassistant.components.automation import numeric_state
+from homeassistant.components.homeassistant.triggers import (
+ numeric_state as numeric_state_trigger,
+)
from homeassistant.core import Context
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
@@ -776,7 +778,7 @@ async def test_if_fails_setup_bad_for(hass, calls):
},
)
- with patch.object(automation.numeric_state, "_LOGGER") as mock_logger:
+ with patch.object(numeric_state_trigger, "_LOGGER") as mock_logger:
hass.states.async_set("test.entity", 9)
await hass.async_block_till_done()
assert mock_logger.error.called
@@ -1164,7 +1166,7 @@ async def test_invalid_for_template(hass, calls):
},
)
- with patch.object(automation.numeric_state, "_LOGGER") as mock_logger:
+ with patch.object(numeric_state_trigger, "_LOGGER") as mock_logger:
hass.states.async_set("test.entity", 9)
await hass.async_block_till_done()
assert mock_logger.error.called
@@ -1234,6 +1236,64 @@ async def test_if_fires_on_entities_change_overlap_for_template(hass, calls):
def test_below_above():
"""Test above cannot be above below."""
with pytest.raises(vol.Invalid):
- numeric_state.TRIGGER_SCHEMA(
+ numeric_state_trigger.TRIGGER_SCHEMA(
{"platform": "numeric_state", "above": 1200, "below": 1000}
)
+
+
+async def test_attribute_if_fires_on_entity_change_with_both_filters(hass, calls):
+ """Test for firing if both filters are match attribute."""
+ hass.states.async_set("test.entity", "bla", {"test-measurement": 1})
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: {
+ "trigger": {
+ "platform": "numeric_state",
+ "entity_id": "test.entity",
+ "above": 3,
+ "attribute": "test-measurement",
+ },
+ "action": {"service": "test.automation"},
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ hass.states.async_set("test.entity", "bla", {"test-measurement": 4})
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+
+async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop(
+ hass, calls
+):
+ """Test for not firing on entity change with for after stop trigger."""
+ hass.states.async_set("test.entity", "bla", {"test-measurement": 1})
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: {
+ "trigger": {
+ "platform": "numeric_state",
+ "entity_id": "test.entity",
+ "above": 3,
+ "attribute": "test-measurement",
+ "for": 5,
+ },
+ "action": {"service": "test.automation"},
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ hass.states.async_set("test.entity", "bla", {"test-measurement": 4})
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
+ await hass.async_block_till_done()
+ assert len(calls) == 1
diff --git a/tests/components/automation/test_state.py b/tests/components/homeassistant/triggers/test_state.py
similarity index 82%
rename from tests/components/automation/test_state.py
rename to tests/components/homeassistant/triggers/test_state.py
index 9842818efab..ce9ecaba1b0 100644
--- a/tests/components/automation/test_state.py
+++ b/tests/components/homeassistant/triggers/test_state.py
@@ -4,6 +4,7 @@ from datetime import timedelta
import pytest
import homeassistant.components.automation as automation
+from homeassistant.components.homeassistant.triggers import state as state_trigger
from homeassistant.core import Context
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
@@ -327,31 +328,12 @@ async def test_if_fails_setup_bad_for(hass, calls):
},
)
- with patch.object(automation.state, "_LOGGER") as mock_logger:
+ with patch.object(state_trigger, "_LOGGER") as mock_logger:
hass.states.async_set("test.entity", "world")
await hass.async_block_till_done()
assert mock_logger.error.called
-async def test_if_fails_setup_for_without_to(hass, calls):
- """Test for setup failures for missing to."""
- with assert_setup_component(0, automation.DOMAIN):
- assert await async_setup_component(
- hass,
- automation.DOMAIN,
- {
- automation.DOMAIN: {
- "trigger": {
- "platform": "state",
- "entity_id": "test.entity",
- "for": {"seconds": 5},
- },
- "action": {"service": "homeassistant.turn_on"},
- }
- },
- )
-
-
async def test_if_not_fires_on_entity_change_with_for(hass, calls):
"""Test for not firing on entity change with for."""
assert await async_setup_component(
@@ -519,6 +501,43 @@ async def test_if_fires_on_entity_change_with_for(hass, calls):
assert 1 == len(calls)
+async def test_if_fires_on_entity_change_with_for_without_to(hass, calls):
+ """Test for firing on entity change with for."""
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: {
+ "trigger": {
+ "platform": "state",
+ "entity_id": "test.entity",
+ "for": {"seconds": 5},
+ },
+ "action": {"service": "test.automation"},
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ hass.states.async_set("test.entity", "hello")
+ await hass.async_block_till_done()
+
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+ hass.states.async_set("test.entity", "world")
+ await hass.async_block_till_done()
+
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=4))
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+
async def test_if_fires_on_entity_creation_and_removal(hass, calls):
"""Test for firing on entity creation and removal, with to/from constraints."""
# set automations for multiple combinations to/from
@@ -924,6 +943,64 @@ async def test_if_fires_on_change_with_for_template_3(hass, calls):
assert len(calls) == 1
+async def test_if_fires_on_change_from_with_for(hass, calls):
+ """Test for firing on change with from/for."""
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: {
+ "trigger": {
+ "platform": "state",
+ "entity_id": "media_player.foo",
+ "from": "playing",
+ "for": "00:00:30",
+ },
+ "action": {"service": "test.automation"},
+ }
+ },
+ )
+
+ hass.states.async_set("media_player.foo", "playing")
+ await hass.async_block_till_done()
+ hass.states.async_set("media_player.foo", "paused")
+ await hass.async_block_till_done()
+ hass.states.async_set("media_player.foo", "stopped")
+ await hass.async_block_till_done()
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1))
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+
+async def test_if_not_fires_on_change_from_with_for(hass, calls):
+ """Test for firing on change with from/for."""
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: {
+ "trigger": {
+ "platform": "state",
+ "entity_id": "media_player.foo",
+ "from": "playing",
+ "for": "00:00:30",
+ },
+ "action": {"service": "test.automation"},
+ }
+ },
+ )
+
+ hass.states.async_set("media_player.foo", "playing")
+ await hass.async_block_till_done()
+ hass.states.async_set("media_player.foo", "paused")
+ await hass.async_block_till_done()
+ hass.states.async_set("media_player.foo", "playing")
+ await hass.async_block_till_done()
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1))
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+
async def test_invalid_for_template_1(hass, calls):
"""Test for invalid for template."""
assert await async_setup_component(
@@ -942,7 +1019,7 @@ async def test_invalid_for_template_1(hass, calls):
},
)
- with patch.object(automation.state, "_LOGGER") as mock_logger:
+ with patch.object(state_trigger, "_LOGGER") as mock_logger:
hass.states.async_set("test.entity", "world")
await hass.async_block_till_done()
assert mock_logger.error.called
@@ -1006,3 +1083,119 @@ async def test_if_fires_on_entities_change_overlap_for_template(hass, calls):
await hass.async_block_till_done()
assert len(calls) == 2
assert calls[1].data["some"] == "test.entity_2 - 0:00:10"
+
+
+async def test_attribute_if_fires_on_entity_change_with_both_filters(hass, calls):
+ """Test for firing if both filters are match attribute."""
+ hass.states.async_set("test.entity", "bla", {"name": "hello"})
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: {
+ "trigger": {
+ "platform": "state",
+ "entity_id": "test.entity",
+ "from": "hello",
+ "to": "world",
+ "attribute": "name",
+ },
+ "action": {"service": "test.automation"},
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ hass.states.async_set("test.entity", "bla", {"name": "world"})
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+
+async def test_attribute_if_fires_on_entity_where_attr_stays_constant(hass, calls):
+ """Test for firing if attribute stays the same."""
+ hass.states.async_set("test.entity", "bla", {"name": "hello", "other": "old_value"})
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: {
+ "trigger": {
+ "platform": "state",
+ "entity_id": "test.entity",
+ "attribute": "name",
+ },
+ "action": {"service": "test.automation"},
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ # Leave all attributes the same
+ hass.states.async_set("test.entity", "bla", {"name": "hello", "other": "old_value"})
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+ # Change the untracked attribute
+ hass.states.async_set("test.entity", "bla", {"name": "hello", "other": "new_value"})
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+ # Change the tracked attribute
+ hass.states.async_set("test.entity", "bla", {"name": "world", "other": "old_value"})
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+
+async def test_attribute_if_not_fires_on_entities_change_with_for_after_stop(
+ hass, calls
+):
+ """Test for not firing on entity change with for after stop trigger."""
+ hass.states.async_set("test.entity", "bla", {"name": "hello"})
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: {
+ "trigger": {
+ "platform": "state",
+ "entity_id": "test.entity",
+ "from": "hello",
+ "to": "world",
+ "attribute": "name",
+ "for": 5,
+ },
+ "action": {"service": "test.automation"},
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ # Test that the for-check works
+ hass.states.async_set("test.entity", "bla", {"name": "world"})
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2))
+ hass.states.async_set("test.entity", "bla", {"name": "world", "something": "else"})
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ # Now remove state while inside "for"
+ hass.states.async_set("test.entity", "bla", {"name": "hello"})
+ hass.states.async_set("test.entity", "bla", {"name": "world"})
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ hass.states.async_remove("test.entity")
+ await hass.async_block_till_done()
+
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
+ await hass.async_block_till_done()
+ assert len(calls) == 1
diff --git a/tests/components/automation/test_time.py b/tests/components/homeassistant/triggers/test_time.py
similarity index 70%
rename from tests/components/automation/test_time.py
rename to tests/components/homeassistant/triggers/test_time.py
index c8b95985636..d38f82be11c 100644
--- a/tests/components/automation/test_time.py
+++ b/tests/components/homeassistant/triggers/test_time.py
@@ -31,14 +31,14 @@ def setup_comp(hass):
async def test_if_fires_using_at(hass, calls):
"""Test for firing at."""
- now = dt_util.utcnow()
+ now = dt_util.now()
- time_that_will_not_match_right_away = now.replace(
- year=now.year + 1, hour=4, minute=59, second=0
- )
+ trigger_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2)
+ time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1)
with patch(
- "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away
+ "homeassistant.util.dt.utcnow",
+ return_value=dt_util.as_utc(time_that_will_not_match_right_away),
):
assert await async_setup_component(
hass,
@@ -55,29 +55,103 @@ async def test_if_fires_using_at(hass, calls):
}
},
)
+ await hass.async_block_till_done()
- now = dt_util.utcnow()
-
- async_fire_time_changed(
- hass, now.replace(year=now.year + 1, hour=5, minute=0, second=0)
- )
-
+ async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1))
await hass.async_block_till_done()
+
assert len(calls) == 1
assert calls[0].data["some"] == "time - 5"
+@pytest.mark.parametrize(
+ "has_date,has_time", [(True, True), (True, False), (False, True)]
+)
+async def test_if_fires_using_at_input_datetime(hass, calls, has_date, has_time):
+ """Test for firing at input_datetime."""
+ await async_setup_component(
+ hass,
+ "input_datetime",
+ {"input_datetime": {"trigger": {"has_date": has_date, "has_time": has_time}}},
+ )
+
+ now = dt_util.now()
+
+ trigger_dt = now.replace(
+ hour=5 if has_time else 0, minute=0, second=0, microsecond=0
+ ) + timedelta(2)
+
+ await hass.services.async_call(
+ "input_datetime",
+ "set_datetime",
+ {
+ ATTR_ENTITY_ID: "input_datetime.trigger",
+ "datetime": str(trigger_dt.replace(tzinfo=None)),
+ },
+ blocking=True,
+ )
+
+ time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1)
+
+ some_data = "{{ trigger.platform }}-{{ trigger.now.day }}-{{ trigger.now.hour }}"
+ with patch(
+ "homeassistant.util.dt.utcnow",
+ return_value=dt_util.as_utc(time_that_will_not_match_right_away),
+ ):
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: {
+ "trigger": {"platform": "time", "at": "input_datetime.trigger"},
+ "action": {
+ "service": "test.automation",
+ "data_template": {"some": some_data},
+ },
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1))
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data["some"] == f"time-{trigger_dt.day}-{trigger_dt.hour}"
+
+ if has_date:
+ trigger_dt += timedelta(days=1)
+ if has_time:
+ trigger_dt += timedelta(hours=1)
+
+ await hass.services.async_call(
+ "input_datetime",
+ "set_datetime",
+ {
+ ATTR_ENTITY_ID: "input_datetime.trigger",
+ "datetime": str(trigger_dt.replace(tzinfo=None)),
+ },
+ blocking=True,
+ )
+
+ async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1))
+ await hass.async_block_till_done()
+
+ assert len(calls) == 2
+ assert calls[1].data["some"] == f"time-{trigger_dt.day}-{trigger_dt.hour}"
+
+
async def test_if_fires_using_multiple_at(hass, calls):
"""Test for firing at."""
- now = dt_util.utcnow()
+ now = dt_util.now()
- time_that_will_not_match_right_away = now.replace(
- year=now.year + 1, hour=4, minute=59, second=0
- )
+ trigger_dt = now.replace(hour=5, minute=0, second=0, microsecond=0) + timedelta(2)
+ time_that_will_not_match_right_away = trigger_dt - timedelta(minutes=1)
with patch(
- "homeassistant.util.dt.utcnow", return_value=time_that_will_not_match_right_away
+ "homeassistant.util.dt.utcnow",
+ return_value=dt_util.as_utc(time_that_will_not_match_right_away),
):
assert await async_setup_component(
hass,
@@ -94,22 +168,17 @@ async def test_if_fires_using_multiple_at(hass, calls):
}
},
)
+ await hass.async_block_till_done()
- now = dt_util.utcnow()
-
- async_fire_time_changed(
- hass, now.replace(year=now.year + 1, hour=5, minute=0, second=0)
- )
-
+ async_fire_time_changed(hass, trigger_dt + timedelta(seconds=1))
await hass.async_block_till_done()
+
assert len(calls) == 1
assert calls[0].data["some"] == "time - 5"
- async_fire_time_changed(
- hass, now.replace(year=now.year + 1, hour=6, minute=0, second=0)
- )
-
+ async_fire_time_changed(hass, trigger_dt + timedelta(hours=1, seconds=1))
await hass.async_block_till_done()
+
assert len(calls) == 2
assert calls[1].data["some"] == "time - 6"
@@ -143,6 +212,7 @@ async def test_if_not_fires_using_wrong_at(hass, calls):
}
},
)
+ await hass.async_block_till_done()
async_fire_time_changed(
hass, now.replace(year=now.year + 1, hour=1, minute=0, second=5)
@@ -165,6 +235,7 @@ async def test_if_action_before(hass, calls):
}
},
)
+ await hass.async_block_till_done()
before_10 = dt_util.now().replace(hour=8)
after_10 = dt_util.now().replace(hour=14)
@@ -195,6 +266,7 @@ async def test_if_action_after(hass, calls):
}
},
)
+ await hass.async_block_till_done()
before_10 = dt_util.now().replace(hour=8)
after_10 = dt_util.now().replace(hour=14)
@@ -225,6 +297,7 @@ async def test_if_action_one_weekday(hass, calls):
}
},
)
+ await hass.async_block_till_done()
days_past_monday = dt_util.now().weekday()
monday = dt_util.now() - timedelta(days=days_past_monday)
@@ -256,6 +329,7 @@ async def test_if_action_list_weekday(hass, calls):
}
},
)
+ await hass.async_block_till_done()
days_past_monday = dt_util.now().weekday()
monday = dt_util.now() - timedelta(days=days_past_monday)
@@ -285,7 +359,7 @@ async def test_untrack_time_change(hass):
"""Test for removing tracked time changes."""
mock_track_time_change = Mock()
with patch(
- "homeassistant.components.automation.time.async_track_time_change",
+ "homeassistant.components.homeassistant.triggers.time.async_track_time_change",
return_value=mock_track_time_change,
):
assert await async_setup_component(
@@ -302,6 +376,7 @@ async def test_untrack_time_change(hass):
}
},
)
+ await hass.async_block_till_done()
await hass.services.async_call(
automation.DOMAIN,
diff --git a/tests/components/automation/test_time_pattern.py b/tests/components/homeassistant/triggers/test_time_pattern.py
similarity index 93%
rename from tests/components/automation/test_time_pattern.py
rename to tests/components/homeassistant/triggers/test_time_pattern.py
index b5141f088e4..0ef071aadb6 100644
--- a/tests/components/automation/test_time_pattern.py
+++ b/tests/components/homeassistant/triggers/test_time_pattern.py
@@ -1,11 +1,13 @@
"""The tests for the time_pattern automation."""
-from asynctest.mock import patch
import pytest
+import voluptuous as vol
import homeassistant.components.automation as automation
+import homeassistant.components.homeassistant.triggers.time_pattern as time_pattern
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
+from tests.async_mock import patch
from tests.common import async_fire_time_changed, async_mock_service, mock_component
from tests.components.automation import common
@@ -294,3 +296,20 @@ async def test_default_values(hass, calls):
await hass.async_block_till_done()
assert len(calls) == 2
+
+
+async def test_invalid_schemas(hass, calls):
+ """Test invalid schemas."""
+ schemas = (
+ None,
+ {},
+ {"platform": "time_pattern"},
+ {"platform": "time_pattern", "minutes": "/"},
+ {"platform": "time_pattern", "minutes": "*/5"},
+ {"platform": "time_pattern", "minutes": "/90"},
+ {"platform": "time_pattern", "hours": 12, "minutes": 0, "seconds": 100},
+ )
+
+ for value in schemas:
+ with pytest.raises(vol.Invalid):
+ time_pattern.TRIGGER_SCHEMA(value)
diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py
index fd3d546a507..6e4e03f2137 100644
--- a/tests/components/homekit/test_config_flow.py
+++ b/tests/components/homekit/test_config_flow.py
@@ -45,7 +45,8 @@ async def test_user_form(hass):
return_value=12345,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"auto_start": True, "include_domains": ["light"]},
+ result["flow_id"],
+ {"auto_start": True, "include_domains": ["light"]},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -54,9 +55,13 @@ async def test_user_form(hass):
with patch(
"homeassistant.components.homekit.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.homekit.async_setup_entry", return_value=True,
+ "homeassistant.components.homekit.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
- result3 = await hass.config_entries.flow.async_configure(result["flow_id"], {},)
+ result3 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result3["title"][:11] == "HASS Bridge"
@@ -98,7 +103,8 @@ async def test_import(hass):
with patch(
"homeassistant.components.homekit.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.homekit.async_setup_entry", return_value=True,
+ "homeassistant.components.homekit.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -142,14 +148,16 @@ async def test_options_flow_advanced(hass):
assert result["step_id"] == "exclude"
result2 = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={"exclude_entities": ["climate.old"]},
+ result["flow_id"],
+ user_input={"exclude_entities": ["climate.old"]},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "advanced"
with patch("homeassistant.components.homekit.async_setup_entry", return_value=True):
result3 = await hass.config_entries.options.async_configure(
- result2["flow_id"], user_input={"auto_start": True, "safe_mode": True},
+ result2["flow_id"],
+ user_input={"auto_start": True, "safe_mode": True},
)
assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -182,21 +190,24 @@ async def test_options_flow_basic(hass):
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={"include_domains": ["fan", "vacuum", "climate"]},
+ result["flow_id"],
+ user_input={"include_domains": ["fan", "vacuum", "climate"]},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "exclude"
result2 = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={"exclude_entities": ["climate.old"]},
+ result["flow_id"],
+ user_input={"exclude_entities": ["climate.old"]},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["step_id"] == "advanced"
with patch("homeassistant.components.homekit.async_setup_entry", return_value=True):
result3 = await hass.config_entries.options.async_configure(
- result2["flow_id"], user_input={"safe_mode": True},
+ result2["flow_id"],
+ user_input={"safe_mode": True},
)
assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -248,7 +259,8 @@ async def test_options_flow_with_cameras(hass):
assert result2["step_id"] == "cameras"
result3 = await hass.config_entries.options.async_configure(
- result2["flow_id"], user_input={"camera_copy": ["camera.native_h264"]},
+ result2["flow_id"],
+ user_input={"camera_copy": ["camera.native_h264"]},
)
assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -256,7 +268,8 @@ async def test_options_flow_with_cameras(hass):
with patch("homeassistant.components.homekit.async_setup_entry", return_value=True):
result4 = await hass.config_entries.options.async_configure(
- result3["flow_id"], user_input={"safe_mode": True},
+ result3["flow_id"],
+ user_input={"safe_mode": True},
)
assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -297,7 +310,8 @@ async def test_options_flow_with_cameras(hass):
assert result2["step_id"] == "cameras"
result3 = await hass.config_entries.options.async_configure(
- result2["flow_id"], user_input={"camera_copy": []},
+ result2["flow_id"],
+ user_input={"camera_copy": []},
)
assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -305,7 +319,8 @@ async def test_options_flow_with_cameras(hass):
with patch("homeassistant.components.homekit.async_setup_entry", return_value=True):
result4 = await hass.config_entries.options.async_configure(
- result3["flow_id"], user_input={"safe_mode": True},
+ result3["flow_id"],
+ user_input={"safe_mode": True},
)
assert result4["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -356,6 +371,7 @@ async def test_options_flow_blocked_when_from_yaml(hass):
with patch("homeassistant.components.homekit.async_setup_entry", return_value=True):
result2 = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={},
+ result["flow_id"],
+ user_input={},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py
index 85f517f28be..bcbbbf3bcbf 100644
--- a/tests/components/homekit/test_get_accessories.py
+++ b/tests/components/homekit/test_get_accessories.py
@@ -24,9 +24,9 @@ from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
CONF_NAME,
CONF_TYPE,
+ PERCENTAGE,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
- UNIT_PERCENTAGE,
)
from homeassistant.core import State
@@ -186,7 +186,7 @@ def test_type_media_player(type_name, entity_id, state, attrs, config):
"HumiditySensor",
"sensor.humidity",
"20",
- {ATTR_DEVICE_CLASS: "humidity", ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE},
+ {ATTR_DEVICE_CLASS: "humidity", ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE},
),
("LightSensor", "sensor.light", "900", {ATTR_DEVICE_CLASS: "illuminance"}),
("LightSensor", "sensor.light", "900", {ATTR_UNIT_OF_MEASUREMENT: "lm"}),
@@ -271,7 +271,8 @@ def test_type_vacuum(type_name, entity_id, state, attrs):
@pytest.mark.parametrize(
- "type_name, entity_id, state, attrs", [("Camera", "camera.basic", "on", {})],
+ "type_name, entity_id, state, attrs",
+ [("Camera", "camera.basic", "on", {})],
)
def test_type_camera(type_name, entity_id, state, attrs):
"""Test if camera types are associated correctly."""
diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py
index 8e8e9a3506f..757281af1e9 100644
--- a/tests/components/homekit/test_homekit.py
+++ b/tests/components/homekit/test_homekit.py
@@ -4,6 +4,7 @@ from typing import Dict
import pytest
+from homeassistant import config as hass_config
from homeassistant.components import zeroconf
from homeassistant.components.binary_sensor import (
DEVICE_CLASS_BATTERY_CHARGING,
@@ -49,8 +50,9 @@ from homeassistant.const import (
DEVICE_CLASS_HUMIDITY,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
+ PERCENTAGE,
+ SERVICE_RELOAD,
STATE_ON,
- UNIT_PERCENTAGE,
)
from homeassistant.core import State
from homeassistant.helpers import device_registry
@@ -805,6 +807,91 @@ async def test_homekit_finds_linked_batteries(
)
+async def test_homekit_async_get_integration_fails(
+ hass, hk_driver, debounce_patcher, device_reg, entity_reg
+):
+ """Test that we continue if async_get_integration fails."""
+ entry = await async_init_integration(hass)
+
+ homekit = HomeKit(
+ hass,
+ None,
+ None,
+ None,
+ {},
+ {"light.demo": {}},
+ DEFAULT_SAFE_MODE,
+ advertise_ip=None,
+ entry_id=entry.entry_id,
+ )
+ homekit.driver = hk_driver
+ # pylint: disable=protected-access
+ homekit._filter = Mock(return_value=True)
+ homekit.bridge = HomeBridge(hass, hk_driver, "mock_bridge")
+
+ config_entry = MockConfigEntry(domain="test", data={})
+ config_entry.add_to_hass(hass)
+ device_entry = device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ sw_version="0.16.0",
+ model="Powerwall 2",
+ connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
+ )
+
+ binary_charging_sensor = entity_reg.async_get_or_create(
+ "binary_sensor",
+ "invalid_integration_does_not_exist",
+ "battery_charging",
+ device_id=device_entry.id,
+ device_class=DEVICE_CLASS_BATTERY_CHARGING,
+ )
+ battery_sensor = entity_reg.async_get_or_create(
+ "sensor",
+ "invalid_integration_does_not_exist",
+ "battery",
+ device_id=device_entry.id,
+ device_class=DEVICE_CLASS_BATTERY,
+ )
+ light = entity_reg.async_get_or_create(
+ "light", "invalid_integration_does_not_exist", "demo", device_id=device_entry.id
+ )
+
+ hass.states.async_set(
+ binary_charging_sensor.entity_id,
+ STATE_ON,
+ {ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY_CHARGING},
+ )
+ hass.states.async_set(
+ battery_sensor.entity_id, 30, {ATTR_DEVICE_CLASS: DEVICE_CLASS_BATTERY}
+ )
+ hass.states.async_set(light.entity_id, STATE_ON)
+
+ def _mock_get_accessory(*args, **kwargs):
+ return [None, "acc", None]
+
+ with patch.object(homekit.bridge, "add_accessory"), patch(
+ f"{PATH_HOMEKIT}.show_setup_message"
+ ), patch(f"{PATH_HOMEKIT}.get_accessory") as mock_get_acc, patch(
+ "pyhap.accessory_driver.AccessoryDriver.start_service"
+ ):
+ await homekit.async_start()
+ await hass.async_block_till_done()
+
+ mock_get_acc.assert_called_with(
+ hass,
+ hk_driver,
+ ANY,
+ ANY,
+ {
+ "model": "Powerwall 2",
+ "sw_version": "0.16.0",
+ "platform": "invalid_integration_does_not_exist",
+ "linked_battery_charging_sensor": "binary_sensor.invalid_integration_does_not_exist_battery_charging",
+ "linked_battery_sensor": "sensor.invalid_integration_does_not_exist_battery",
+ },
+ )
+
+
async def test_setup_imported(hass):
"""Test async_setup with imported config options."""
legacy_persist_file_path = hass.config.path(HOMEKIT_FILE)
@@ -917,7 +1004,8 @@ async def test_raise_config_entry_not_ready(hass):
entry.add_to_hass(hass)
with patch(
- "homeassistant.components.homekit.port_is_available", return_value=False,
+ "homeassistant.components.homekit.port_is_available",
+ return_value=False,
):
assert not await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -1152,7 +1240,7 @@ async def test_homekit_finds_linked_humidity_sensors(
"42",
{
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
- ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE,
+ ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE,
},
)
hass.states.async_set(humidifier.entity_id, STATE_ON)
@@ -1180,3 +1268,75 @@ async def test_homekit_finds_linked_humidity_sensors(
"linked_humidity_sensor": "sensor.humidifier_humidity_sensor",
},
)
+
+
+async def test_reload(hass):
+ """Test we can reload from yaml."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ source=SOURCE_IMPORT,
+ data={CONF_NAME: "reloadable", CONF_PORT: 12345},
+ options={},
+ )
+ entry.add_to_hass(hass)
+
+ with patch(f"{PATH_HOMEKIT}.HomeKit") as mock_homekit:
+ mock_homekit.return_value = homekit = Mock()
+ type(homekit).async_start = AsyncMock()
+ assert await async_setup_component(
+ hass, "homekit", {"homekit": {CONF_NAME: "reloadable", CONF_PORT: 12345}}
+ )
+ await hass.async_block_till_done()
+
+ mock_homekit.assert_any_call(
+ hass,
+ "reloadable",
+ 12345,
+ None,
+ ANY,
+ {},
+ DEFAULT_SAFE_MODE,
+ None,
+ entry.entry_id,
+ )
+ assert mock_homekit().setup.called is True
+ yaml_path = os.path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "homekit/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path), patch(
+ f"{PATH_HOMEKIT}.HomeKit"
+ ) as mock_homekit2, patch.object(homekit.bridge, "add_accessory"), patch(
+ f"{PATH_HOMEKIT}.show_setup_message"
+ ), patch(
+ f"{PATH_HOMEKIT}.get_accessory"
+ ), patch(
+ "pyhap.accessory_driver.AccessoryDriver.start_service"
+ ):
+ mock_homekit2.return_value = homekit = Mock()
+ type(homekit).async_start = AsyncMock()
+ await hass.services.async_call(
+ "homekit",
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ mock_homekit2.assert_any_call(
+ hass,
+ "reloadable",
+ 45678,
+ None,
+ ANY,
+ {},
+ DEFAULT_SAFE_MODE,
+ None,
+ entry.entry_id,
+ )
+ assert mock_homekit2().setup.called is True
+
+
+def _get_fixtures_base_path():
+ return os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
diff --git a/tests/components/homekit/test_init.py b/tests/components/homekit/test_init.py
index 1fad563445b..72670667fa7 100644
--- a/tests/components/homekit/test_init.py
+++ b/tests/components/homekit/test_init.py
@@ -44,6 +44,7 @@ async def test_humanify_homekit_changed_event(hass, hk_driver):
),
],
entity_attr_cache,
+ {},
)
)
diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py
index 118ce2d9934..6386b1d8e69 100644
--- a/tests/components/homekit/test_type_cameras.py
+++ b/tests/components/homekit/test_type_cameras.py
@@ -142,7 +142,14 @@ async def test_camera_stream_source_configured(hass, run_driver, events):
2,
{CONF_STREAM_SOURCE: "/dev/null", CONF_SUPPORT_AUDIO: True},
)
- not_camera_acc = Switch(hass, run_driver, "Switch", entity_id, 4, {},)
+ not_camera_acc = Switch(
+ hass,
+ run_driver,
+ "Switch",
+ entity_id,
+ 4,
+ {},
+ )
bridge = HomeBridge("hass", run_driver, "Test Bridge")
bridge.add_accessory(acc)
bridge.add_accessory(not_camera_acc)
@@ -247,7 +254,14 @@ async def test_camera_stream_source_configured_with_failing_ffmpeg(
2,
{CONF_STREAM_SOURCE: "/dev/null", CONF_SUPPORT_AUDIO: True},
)
- not_camera_acc = Switch(hass, run_driver, "Switch", entity_id, 4, {},)
+ not_camera_acc = Switch(
+ hass,
+ run_driver,
+ "Switch",
+ entity_id,
+ 4,
+ {},
+ )
bridge = HomeBridge("hass", run_driver, "Test Bridge")
bridge.add_accessory(acc)
bridge.add_accessory(not_camera_acc)
@@ -284,7 +298,14 @@ async def test_camera_stream_source_found(hass, run_driver, events):
hass.states.async_set(entity_id, None)
await hass.async_block_till_done()
- acc = Camera(hass, run_driver, "Camera", entity_id, 2, {},)
+ acc = Camera(
+ hass,
+ run_driver,
+ "Camera",
+ entity_id,
+ 2,
+ {},
+ )
await acc.run_handler()
assert acc.aid == 2
@@ -327,7 +348,14 @@ async def test_camera_stream_source_fails(hass, run_driver, events):
hass.states.async_set(entity_id, None)
await hass.async_block_till_done()
- acc = Camera(hass, run_driver, "Camera", entity_id, 2, {},)
+ acc = Camera(
+ hass,
+ run_driver,
+ "Camera",
+ entity_id,
+ 2,
+ {},
+ )
await acc.run_handler()
assert acc.aid == 2
@@ -355,7 +383,14 @@ async def test_camera_with_no_stream(hass, run_driver, events):
hass.states.async_set(entity_id, None)
await hass.async_block_till_done()
- acc = Camera(hass, run_driver, "Camera", entity_id, 2, {},)
+ acc = Camera(
+ hass,
+ run_driver,
+ "Camera",
+ entity_id,
+ 2,
+ {},
+ )
await acc.run_handler()
assert acc.aid == 2
diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py
index 806aa60ee50..3eed6d05816 100644
--- a/tests/components/homekit/test_type_covers.py
+++ b/tests/components/homekit/test_type_covers.py
@@ -437,7 +437,10 @@ async def test_window_basic_restore(hass, hk_driver, cls, events):
registry = await entity_registry.async_get_registry(hass)
registry.async_get_or_create(
- "cover", "generic", "1234", suggested_object_id="simple",
+ "cover",
+ "generic",
+ "1234",
+ suggested_object_id="simple",
)
registry.async_get_or_create(
"cover",
@@ -472,7 +475,10 @@ async def test_window_restore(hass, hk_driver, cls, events):
registry = await entity_registry.async_get_registry(hass)
registry.async_get_or_create(
- "cover", "generic", "1234", suggested_object_id="simple",
+ "cover",
+ "generic",
+ "1234",
+ suggested_object_id="simple",
)
registry.async_get_or_create(
"cover",
diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py
index 6d5b0f9841b..a25567b7004 100644
--- a/tests/components/homekit/test_type_fans.py
+++ b/tests/components/homekit/test_type_fans.py
@@ -566,7 +566,10 @@ async def test_fan_restore(hass, hk_driver, cls, events):
registry = await entity_registry.async_get_registry(hass)
registry.async_get_or_create(
- "fan", "generic", "1234", suggested_object_id="simple",
+ "fan",
+ "generic",
+ "1234",
+ suggested_object_id="simple",
)
registry.async_get_or_create(
"fan",
diff --git a/tests/components/homekit/test_type_humidifiers.py b/tests/components/homekit/test_type_humidifiers.py
index 34f61a5c0df..51f9621d15a 100644
--- a/tests/components/homekit/test_type_humidifiers.py
+++ b/tests/components/homekit/test_type_humidifiers.py
@@ -32,12 +32,12 @@ from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_UNIT_OF_MEASUREMENT,
DEVICE_CLASS_HUMIDITY,
+ PERCENTAGE,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
- UNIT_PERCENTAGE,
)
from tests.common import async_mock_service
@@ -74,7 +74,9 @@ async def test_humidifier(hass, hk_driver, events):
}
hass.states.async_set(
- entity_id, STATE_ON, {ATTR_HUMIDITY: 47},
+ entity_id,
+ STATE_ON,
+ {ATTR_HUMIDITY: 47},
)
await hass.async_block_till_done()
assert acc.char_target_humidity.value == 47.0
@@ -153,7 +155,9 @@ async def test_dehumidifier(hass, hk_driver, events):
}
hass.states.async_set(
- entity_id, STATE_ON, {ATTR_HUMIDITY: 30},
+ entity_id,
+ STATE_ON,
+ {ATTR_HUMIDITY: 30},
)
await hass.async_block_till_done()
assert acc.char_target_humidity.value == 30.0
@@ -162,7 +166,9 @@ async def test_dehumidifier(hass, hk_driver, events):
assert acc.char_active.value == 1
hass.states.async_set(
- entity_id, STATE_OFF, {ATTR_HUMIDITY: 42},
+ entity_id,
+ STATE_OFF,
+ {ATTR_HUMIDITY: 42},
)
await hass.async_block_till_done()
assert acc.char_target_humidity.value == 42.0
@@ -204,7 +210,9 @@ async def test_hygrostat_power_state(hass, hk_driver, events):
entity_id = "humidifier.test"
hass.states.async_set(
- entity_id, STATE_ON, {ATTR_HUMIDITY: 43},
+ entity_id,
+ STATE_ON,
+ {ATTR_HUMIDITY: 43},
)
await hass.async_block_till_done()
acc = HumidifierDehumidifier(
@@ -220,7 +228,9 @@ async def test_hygrostat_power_state(hass, hk_driver, events):
assert acc.char_active.value == 1
hass.states.async_set(
- entity_id, STATE_OFF, {ATTR_HUMIDITY: 43},
+ entity_id,
+ STATE_OFF,
+ {ATTR_HUMIDITY: 43},
)
await hass.async_block_till_done()
assert acc.char_current_humidifier_dehumidifier.value == 0
@@ -304,7 +314,7 @@ async def test_humidifier_with_linked_humidity_sensor(hass, hk_driver):
"42.0",
{
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
- ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE,
+ ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE,
},
)
await hass.async_block_till_done()
@@ -332,7 +342,7 @@ async def test_humidifier_with_linked_humidity_sensor(hass, hk_driver):
"43.0",
{
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
- ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE,
+ ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE,
},
)
await hass.async_block_till_done()
@@ -344,7 +354,7 @@ async def test_humidifier_with_linked_humidity_sensor(hass, hk_driver):
STATE_UNAVAILABLE,
{
ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY,
- ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE,
+ ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE,
},
)
await hass.async_block_till_done()
@@ -397,9 +407,9 @@ async def test_humidifier_as_dehumidifier(hass, hk_driver, events, caplog):
assert acc.char_target_humidifier_dehumidifier.value == 1
# Set from HomeKit
- char_target_humidifier_dehumidifier_iid = acc.char_target_humidifier_dehumidifier.to_HAP()[
- HAP_REPR_IID
- ]
+ char_target_humidifier_dehumidifier_iid = (
+ acc.char_target_humidifier_dehumidifier.to_HAP()[HAP_REPR_IID]
+ )
hk_driver.set_characteristics(
{
diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py
index 26bb5bfdbad..e82bc5bb15d 100644
--- a/tests/components/homekit/test_type_lights.py
+++ b/tests/components/homekit/test_type_lights.py
@@ -19,10 +19,10 @@ from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_SUPPORTED_FEATURES,
EVENT_HOMEASSISTANT_START,
+ PERCENTAGE,
STATE_OFF,
STATE_ON,
STATE_UNKNOWN,
- UNIT_PERCENTAGE,
)
from homeassistant.core import CoreState
from homeassistant.helpers import entity_registry
@@ -163,8 +163,7 @@ async def test_light_brightness(hass, hk_driver, cls, events):
assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20
assert len(events) == 1
assert (
- events[-1].data[ATTR_VALUE]
- == f"Set state to 1, brightness at 20{UNIT_PERCENTAGE}"
+ events[-1].data[ATTR_VALUE] == f"Set state to 1, brightness at 20{PERCENTAGE}"
)
hk_driver.set_characteristics(
@@ -186,8 +185,7 @@ async def test_light_brightness(hass, hk_driver, cls, events):
assert call_turn_on[1].data[ATTR_BRIGHTNESS_PCT] == 40
assert len(events) == 2
assert (
- events[-1].data[ATTR_VALUE]
- == f"Set state to 1, brightness at 40{UNIT_PERCENTAGE}"
+ events[-1].data[ATTR_VALUE] == f"Set state to 1, brightness at 40{PERCENTAGE}"
)
hk_driver.set_characteristics(
@@ -207,10 +205,7 @@ async def test_light_brightness(hass, hk_driver, cls, events):
assert call_turn_off
assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id
assert len(events) == 3
- assert (
- events[-1].data[ATTR_VALUE]
- == f"Set state to 0, brightness at 0{UNIT_PERCENTAGE}"
- )
+ assert events[-1].data[ATTR_VALUE] == f"Set state to 0, brightness at 0{PERCENTAGE}"
# 0 is a special case for homekit, see "Handle Brightness"
# in update_state
@@ -298,9 +293,25 @@ async def test_light_color_temperature_and_rgb_color(hass, hk_driver, cls, event
)
await hass.async_block_till_done()
acc = cls.light(hass, hk_driver, "Light", entity_id, 2, None)
+ assert acc.char_hue.value == 260
+ assert acc.char_saturation.value == 90
assert not hasattr(acc, "char_color_temperature")
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: 224})
+ await hass.async_block_till_done()
+ await acc.run_handler()
+ await hass.async_block_till_done()
+ assert acc.char_hue.value == 27
+ assert acc.char_saturation.value == 27
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_COLOR_TEMP: 352})
+ await hass.async_block_till_done()
+ await acc.run_handler()
+ await hass.async_block_till_done()
+ assert acc.char_hue.value == 28
+ assert acc.char_saturation.value == 61
+
async def test_light_rgb_color(hass, hk_driver, cls, events):
"""Test light with rgb_color."""
@@ -459,7 +470,7 @@ async def test_light_set_brightness_and_color(hass, hk_driver, cls, events):
assert len(events) == 1
assert (
events[-1].data[ATTR_VALUE]
- == f"Set state to 1, brightness at 20{UNIT_PERCENTAGE}, set color at (145, 75)"
+ == f"Set state to 1, brightness at 20{PERCENTAGE}, set color at (145, 75)"
)
@@ -528,5 +539,5 @@ async def test_light_set_brightness_and_color_temp(hass, hk_driver, cls, events)
assert len(events) == 1
assert (
events[-1].data[ATTR_VALUE]
- == f"Set state to 1, brightness at 20{UNIT_PERCENTAGE}, color temperature at 250"
+ == f"Set state to 1, brightness at 20{PERCENTAGE}, color temperature at 250"
)
diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py
index ec746dec5b9..7ee79352d7b 100644
--- a/tests/components/homekit/test_type_sensors.py
+++ b/tests/components/homekit/test_type_sensors.py
@@ -20,6 +20,7 @@ from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_UNIT_OF_MEASUREMENT,
EVENT_HOMEASSISTANT_START,
+ PERCENTAGE,
STATE_HOME,
STATE_NOT_HOME,
STATE_OFF,
@@ -27,7 +28,6 @@ from homeassistant.const import (
STATE_UNKNOWN,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
- UNIT_PERCENTAGE,
)
from homeassistant.core import CoreState
from homeassistant.helpers import entity_registry
@@ -341,7 +341,7 @@ async def test_sensor_restore(hass, hk_driver, events):
"12345",
suggested_object_id="humidity",
device_class="humidity",
- unit_of_measurement=UNIT_PERCENTAGE,
+ unit_of_measurement=PERCENTAGE,
)
hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {})
await hass.async_block_till_done()
diff --git a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py b/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py
index e9fc9b522ea..fbdf00698f7 100644
--- a/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py
+++ b/tests/components/homekit_controller/specific_devices/test_homeassistant_bridge.py
@@ -28,7 +28,11 @@ async def test_homeassistant_bridge_fan_setup(hass):
assert fan.unique_id == "homekit-fan.living_room_fan-8"
fan_helper = Helper(
- hass, "fan.living_room_fan", pairing, accessories[0], config_entry,
+ hass,
+ "fan.living_room_fan",
+ pairing,
+ accessories[0],
+ config_entry,
)
fan_state = await fan_helper.poll_and_get_state()
diff --git a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py b/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py
index 9e88d9d82e9..a83a31166f4 100644
--- a/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py
+++ b/tests/components/homekit_controller/specific_devices/test_simpleconnect_fan.py
@@ -26,7 +26,11 @@ async def test_simpleconnect_fan_setup(hass):
assert fan.unique_id == "homekit-1234567890abcd-8"
fan_helper = Helper(
- hass, "fan.simpleconnect_fan_06f674", pairing, accessories[0], config_entry,
+ hass,
+ "fan.simpleconnect_fan_06f674",
+ pairing,
+ accessories[0],
+ config_entry,
)
fan_state = await fan_helper.poll_and_get_state()
diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py
index 9bcadb6604e..38156354cda 100644
--- a/tests/components/homekit_controller/test_climate.py
+++ b/tests/components/homekit_controller/test_climate.py
@@ -1,5 +1,11 @@
"""Basic checks for HomeKitclimate."""
-from aiohomekit.model.characteristics import CharacteristicsTypes
+from aiohomekit.model.characteristics import (
+ ActivationStateValues,
+ CharacteristicsTypes,
+ CurrentHeaterCoolerStateValues,
+ SwingModeValues,
+ TargetHeaterCoolerStateValues,
+)
from aiohomekit.model.services import ServicesTypes
from homeassistant.components.climate.const import (
@@ -10,6 +16,7 @@ from homeassistant.components.climate.const import (
HVAC_MODE_OFF,
SERVICE_SET_HUMIDITY,
SERVICE_SET_HVAC_MODE,
+ SERVICE_SET_SWING_MODE,
SERVICE_SET_TEMPERATURE,
)
@@ -22,6 +29,8 @@ TEMPERATURE_CURRENT = ("thermostat", "temperature.current")
HUMIDITY_TARGET = ("thermostat", "relative-humidity.target")
HUMIDITY_CURRENT = ("thermostat", "relative-humidity.current")
+# Test thermostat devices
+
def create_thermostat_service(accessory):
"""Define thermostat characteristics."""
@@ -226,3 +235,277 @@ async def test_hvac_mode_vs_hvac_action(hass, utcnow):
state = await helper.poll_and_get_state()
assert state.state == "heat"
assert state.attributes["hvac_action"] == "heating"
+
+
+TARGET_HEATER_COOLER_STATE = ("heater-cooler", "heater-cooler.state.target")
+CURRENT_HEATER_COOLER_STATE = ("heater-cooler", "heater-cooler.state.current")
+HEATER_COOLER_ACTIVE = ("heater-cooler", "active")
+HEATER_COOLER_TEMPERATURE_CURRENT = ("heater-cooler", "temperature.current")
+TEMPERATURE_COOLING_THRESHOLD = ("heater-cooler", "temperature.cooling-threshold")
+TEMPERATURE_HEATING_THRESHOLD = ("heater-cooler", "temperature.heating-threshold")
+SWING_MODE = ("heater-cooler", "swing-mode")
+
+
+def create_heater_cooler_service(accessory):
+ """Define thermostat characteristics."""
+ service = accessory.add_service(ServicesTypes.HEATER_COOLER)
+
+ char = service.add_char(CharacteristicsTypes.TARGET_HEATER_COOLER_STATE)
+ char.value = 0
+
+ char = service.add_char(CharacteristicsTypes.CURRENT_HEATER_COOLER_STATE)
+ char.value = 0
+
+ char = service.add_char(CharacteristicsTypes.ACTIVE)
+ char.value = 1
+
+ char = service.add_char(CharacteristicsTypes.TEMPERATURE_COOLING_THRESHOLD)
+ char.minValue = 7
+ char.maxValue = 35
+ char.value = 0
+
+ char = service.add_char(CharacteristicsTypes.TEMPERATURE_HEATING_THRESHOLD)
+ char.minValue = 7
+ char.maxValue = 35
+ char.value = 0
+
+ char = service.add_char(CharacteristicsTypes.TEMPERATURE_CURRENT)
+ char.value = 0
+
+ char = service.add_char(CharacteristicsTypes.SWING_MODE)
+ char.value = 0
+
+
+# Test heater-cooler devices
+def create_heater_cooler_service_min_max(accessory):
+ """Define thermostat characteristics."""
+ service = accessory.add_service(ServicesTypes.HEATER_COOLER)
+ char = service.add_char(CharacteristicsTypes.TARGET_HEATER_COOLER_STATE)
+ char.value = 1
+ char.minValue = 1
+ char.maxValue = 2
+
+
+async def test_heater_cooler_respect_supported_op_modes_1(hass, utcnow):
+ """Test that climate respects minValue/maxValue hints."""
+ helper = await setup_test_component(hass, create_heater_cooler_service_min_max)
+ state = await helper.poll_and_get_state()
+ assert state.attributes["hvac_modes"] == ["heat", "cool", "off"]
+
+
+def create_theater_cooler_service_valid_vals(accessory):
+ """Define heater-cooler characteristics."""
+ service = accessory.add_service(ServicesTypes.HEATER_COOLER)
+ char = service.add_char(CharacteristicsTypes.TARGET_HEATER_COOLER_STATE)
+ char.value = 1
+ char.valid_values = [1, 2]
+
+
+async def test_heater_cooler_respect_supported_op_modes_2(hass, utcnow):
+ """Test that climate respects validValue hints."""
+ helper = await setup_test_component(hass, create_theater_cooler_service_valid_vals)
+ state = await helper.poll_and_get_state()
+ assert state.attributes["hvac_modes"] == ["heat", "cool", "off"]
+
+
+async def test_heater_cooler_change_thermostat_state(hass, utcnow):
+ """Test that we can change the operational mode."""
+ helper = await setup_test_component(hass, create_heater_cooler_service)
+
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_HVAC_MODE,
+ {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_HEAT},
+ blocking=True,
+ )
+
+ assert (
+ helper.characteristics[TARGET_HEATER_COOLER_STATE].value
+ == TargetHeaterCoolerStateValues.HEAT
+ )
+
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_HVAC_MODE,
+ {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_COOL},
+ blocking=True,
+ )
+ assert (
+ helper.characteristics[TARGET_HEATER_COOLER_STATE].value
+ == TargetHeaterCoolerStateValues.COOL
+ )
+
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_HVAC_MODE,
+ {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_HEAT_COOL},
+ blocking=True,
+ )
+ assert (
+ helper.characteristics[TARGET_HEATER_COOLER_STATE].value
+ == TargetHeaterCoolerStateValues.AUTOMATIC
+ )
+
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_HVAC_MODE,
+ {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_OFF},
+ blocking=True,
+ )
+ assert (
+ helper.characteristics[HEATER_COOLER_ACTIVE].value
+ == ActivationStateValues.INACTIVE
+ )
+
+
+async def test_heater_cooler_change_thermostat_temperature(hass, utcnow):
+ """Test that we can change the target temperature."""
+ helper = await setup_test_component(hass, create_heater_cooler_service)
+
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_HVAC_MODE,
+ {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_HEAT},
+ blocking=True,
+ )
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_TEMPERATURE,
+ {"entity_id": "climate.testdevice", "temperature": 20},
+ blocking=True,
+ )
+ assert helper.characteristics[TEMPERATURE_HEATING_THRESHOLD].value == 20
+
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_HVAC_MODE,
+ {"entity_id": "climate.testdevice", "hvac_mode": HVAC_MODE_COOL},
+ blocking=True,
+ )
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_TEMPERATURE,
+ {"entity_id": "climate.testdevice", "temperature": 26},
+ blocking=True,
+ )
+ assert helper.characteristics[TEMPERATURE_COOLING_THRESHOLD].value == 26
+
+
+async def test_heater_cooler_read_thermostat_state(hass, utcnow):
+ """Test that we can read the state of a HomeKit thermostat accessory."""
+ helper = await setup_test_component(hass, create_heater_cooler_service)
+
+ # Simulate that heating is on
+ helper.characteristics[HEATER_COOLER_TEMPERATURE_CURRENT].value = 19
+ helper.characteristics[TEMPERATURE_HEATING_THRESHOLD].value = 20
+ helper.characteristics[
+ CURRENT_HEATER_COOLER_STATE
+ ].value = CurrentHeaterCoolerStateValues.HEATING
+ helper.characteristics[
+ TARGET_HEATER_COOLER_STATE
+ ].value = TargetHeaterCoolerStateValues.HEAT
+ helper.characteristics[SWING_MODE].value = SwingModeValues.DISABLED
+
+ state = await helper.poll_and_get_state()
+ assert state.state == HVAC_MODE_HEAT
+ assert state.attributes["current_temperature"] == 19
+ assert state.attributes["min_temp"] == 7
+ assert state.attributes["max_temp"] == 35
+
+ # Simulate that cooling is on
+ helper.characteristics[HEATER_COOLER_TEMPERATURE_CURRENT].value = 21
+ helper.characteristics[TEMPERATURE_COOLING_THRESHOLD].value = 19
+ helper.characteristics[
+ CURRENT_HEATER_COOLER_STATE
+ ].value = CurrentHeaterCoolerStateValues.COOLING
+ helper.characteristics[
+ TARGET_HEATER_COOLER_STATE
+ ].value = TargetHeaterCoolerStateValues.COOL
+ helper.characteristics[SWING_MODE].value = SwingModeValues.DISABLED
+
+ state = await helper.poll_and_get_state()
+ assert state.state == HVAC_MODE_COOL
+ assert state.attributes["current_temperature"] == 21
+
+ # Simulate that we are in auto mode
+ helper.characteristics[HEATER_COOLER_TEMPERATURE_CURRENT].value = 21
+ helper.characteristics[TEMPERATURE_COOLING_THRESHOLD].value = 21
+ helper.characteristics[
+ CURRENT_HEATER_COOLER_STATE
+ ].value = CurrentHeaterCoolerStateValues.COOLING
+ helper.characteristics[
+ TARGET_HEATER_COOLER_STATE
+ ].value = TargetHeaterCoolerStateValues.AUTOMATIC
+ helper.characteristics[SWING_MODE].value = SwingModeValues.DISABLED
+
+ state = await helper.poll_and_get_state()
+ assert state.state == HVAC_MODE_HEAT_COOL
+
+
+async def test_heater_cooler_hvac_mode_vs_hvac_action(hass, utcnow):
+ """Check that we haven't conflated hvac_mode and hvac_action."""
+ helper = await setup_test_component(hass, create_heater_cooler_service)
+
+ # Simulate that current temperature is above target temp
+ # Heating might be on, but hvac_action currently 'off'
+ helper.characteristics[HEATER_COOLER_TEMPERATURE_CURRENT].value = 22
+ helper.characteristics[TEMPERATURE_HEATING_THRESHOLD].value = 21
+ helper.characteristics[
+ CURRENT_HEATER_COOLER_STATE
+ ].value = CurrentHeaterCoolerStateValues.IDLE
+ helper.characteristics[
+ TARGET_HEATER_COOLER_STATE
+ ].value = TargetHeaterCoolerStateValues.HEAT
+ helper.characteristics[SWING_MODE].value = SwingModeValues.DISABLED
+
+ state = await helper.poll_and_get_state()
+ assert state.state == "heat"
+ assert state.attributes["hvac_action"] == "idle"
+
+ # Simulate that current temperature is below target temp
+ # Heating might be on and hvac_action currently 'heat'
+ helper.characteristics[HEATER_COOLER_TEMPERATURE_CURRENT].value = 19
+ helper.characteristics[
+ CURRENT_HEATER_COOLER_STATE
+ ].value = CurrentHeaterCoolerStateValues.HEATING
+
+ state = await helper.poll_and_get_state()
+ assert state.state == "heat"
+ assert state.attributes["hvac_action"] == "heating"
+
+
+async def test_heater_cooler_change_swing_mode(hass, utcnow):
+ """Test that we can change the swing mode."""
+ helper = await setup_test_component(hass, create_heater_cooler_service)
+
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_SWING_MODE,
+ {"entity_id": "climate.testdevice", "swing_mode": "vertical"},
+ blocking=True,
+ )
+ assert helper.characteristics[SWING_MODE].value == SwingModeValues.ENABLED
+
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_SWING_MODE,
+ {"entity_id": "climate.testdevice", "swing_mode": "off"},
+ blocking=True,
+ )
+ assert helper.characteristics[SWING_MODE].value == SwingModeValues.DISABLED
+
+
+async def test_heater_cooler_turn_off(hass, utcnow):
+ """Test that both hvac_action and hvac_mode return "off" when turned off."""
+ helper = await setup_test_component(hass, create_heater_cooler_service)
+ # Simulate that the device is turned off but CURRENT_HEATER_COOLER_STATE still returns HEATING/COOLING
+ helper.characteristics[HEATER_COOLER_ACTIVE].value = ActivationStateValues.INACTIVE
+ helper.characteristics[
+ CURRENT_HEATER_COOLER_STATE
+ ].value = CurrentHeaterCoolerStateValues.HEATING
+ helper.characteristics[
+ TARGET_HEATER_COOLER_STATE
+ ].value = TargetHeaterCoolerStateValues.HEAT
+ state = await helper.poll_and_get_state()
+ assert state.state == "off"
+ assert state.attributes["hvac_action"] == "off"
diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py
index a9aef723164..e5c8e381a5f 100644
--- a/tests/components/homekit_controller/test_config_flow.py
+++ b/tests/components/homekit_controller/test_config_flow.py
@@ -8,14 +8,13 @@ from aiohomekit.model.services import ServicesTypes
import pytest
from homeassistant.components.homekit_controller import config_flow
+from homeassistant.helpers import device_registry
import tests.async_mock
from tests.async_mock import patch
-from tests.common import MockConfigEntry
+from tests.common import MockConfigEntry, mock_device_registry
PAIRING_START_FORM_ERRORS = [
- (aiohomekit.BusyError, "busy_error"),
- (aiohomekit.MaxTriesError, "max_tries_error"),
(KeyError, "pairing_failed"),
]
@@ -24,6 +23,12 @@ PAIRING_START_ABORT_ERRORS = [
(aiohomekit.UnavailableError, "already_paired"),
]
+PAIRING_TRY_LATER_ERRORS = [
+ (aiohomekit.BusyError, "busy_error"),
+ (aiohomekit.MaxTriesError, "max_tries_error"),
+ (IndexError, "protocol_error"),
+]
+
PAIRING_FINISH_FORM_ERRORS = [
(aiohomekit.exceptions.MalformedPinError, "authentication_error"),
(aiohomekit.MaxPeersError, "max_peers_error"),
@@ -229,11 +234,45 @@ async def test_pair_already_paired_1(hass, controller):
assert result["reason"] == "already_paired"
+async def test_id_missing(hass, controller):
+ """Test id is missing."""
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
+
+ # Remove id from device
+ del discovery_info["properties"]["id"]
+
+ # Device is discovered
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "invalid_properties"
+
+
async def test_discovery_ignored_model(hass, controller):
"""Already paired."""
device = setup_mock_accessory(controller)
discovery_info = get_device_discovery_info(device)
- discovery_info["properties"]["md"] = config_flow.HOMEKIT_IGNORE[0]
+
+ config_entry = MockConfigEntry(domain=config_flow.HOMEKIT_BRIDGE_DOMAIN, data={})
+ formatted_mac = device_registry.format_mac("AA:BB:CC:DD:EE:FF")
+
+ dev_reg = mock_device_registry(hass)
+ dev_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={
+ (
+ config_flow.HOMEKIT_BRIDGE_DOMAIN,
+ config_entry.entry_id,
+ config_flow.HOMEKIT_BRIDGE_SERIAL_NUMBER,
+ )
+ },
+ connections={(device_registry.CONNECTION_NETWORK_MAC, formatted_mac)},
+ model=config_flow.HOMEKIT_BRIDGE_MODEL,
+ )
+
+ discovery_info["properties"]["id"] = "AA:BB:CC:DD:EE:FF"
# Device is discovered
result = await hass.config_entries.flow.async_init(
@@ -314,6 +353,41 @@ async def test_pair_abort_errors_on_start(hass, controller, exception, expected)
assert result["reason"] == expected
+@pytest.mark.parametrize("exception,expected", PAIRING_TRY_LATER_ERRORS)
+async def test_pair_try_later_errors_on_start(hass, controller, exception, expected):
+ """Test various pairing errors."""
+
+ device = setup_mock_accessory(controller)
+ discovery_info = get_device_discovery_info(device)
+
+ # Device is discovered
+ result = await hass.config_entries.flow.async_init(
+ "homekit_controller", context={"source": "zeroconf"}, data=discovery_info
+ )
+
+ # User initiates pairing - device refuses to enter pairing mode but may be successful after entering pairing mode or rebooting
+ test_exc = exception("error")
+ with patch.object(device, "start_pairing", side_effect=test_exc):
+ result2 = await hass.config_entries.flow.async_configure(result["flow_id"])
+ assert result2["step_id"] == expected
+ assert result2["type"] == "form"
+
+ # Device is rebooted or placed into pairing mode as they have been instructed
+
+ # We start pairing again
+ result3 = await hass.config_entries.flow.async_configure(
+ result2["flow_id"], user_input={"any": "key"}
+ )
+
+ # .. and successfully complete pair
+ result4 = await hass.config_entries.flow.async_configure(
+ result3["flow_id"], user_input={"pairing_code": "111-22-333"}
+ )
+
+ assert result4["type"] == "create_entry"
+ assert result4["title"] == "Koogeek-LS1-20833F"
+
+
@pytest.mark.parametrize("exception,expected", PAIRING_START_FORM_ERRORS)
async def test_pair_form_errors_on_start(hass, controller, exception, expected):
"""Test various pairing errors."""
@@ -336,7 +410,9 @@ async def test_pair_form_errors_on_start(hass, controller, exception, expected):
# User initiates pairing - device refuses to enter pairing mode
test_exc = exception("error")
with patch.object(device, "start_pairing", side_effect=test_exc):
- result = await hass.config_entries.flow.async_configure(result["flow_id"])
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"pairing_code": "111-22-333"}
+ )
assert result["type"] == "form"
assert result["errors"]["pairing_code"] == expected
@@ -347,6 +423,19 @@ async def test_pair_form_errors_on_start(hass, controller, exception, expected):
"source": "zeroconf",
}
+ # User gets back the form
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ # User re-tries entering pairing code
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"pairing_code": "111-22-333"}
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == "Koogeek-LS1-20833F"
+
@pytest.mark.parametrize("exception,expected", PAIRING_FINISH_ABORT_ERRORS)
async def test_pair_abort_errors_on_finish(hass, controller, exception, expected):
diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py
index fd24f5215da..e9ebce4045b 100644
--- a/tests/components/homekit_controller/test_fan.py
+++ b/tests/components/homekit_controller/test_fan.py
@@ -102,7 +102,10 @@ async def test_turn_off(hass, utcnow):
helper.characteristics[V1_ON].value = 1
await hass.services.async_call(
- "fan", "turn_off", {"entity_id": "fan.testdevice"}, blocking=True,
+ "fan",
+ "turn_off",
+ {"entity_id": "fan.testdevice"},
+ blocking=True,
)
assert helper.characteristics[V1_ON].value == 0
@@ -255,7 +258,10 @@ async def test_v2_turn_off(hass, utcnow):
helper.characteristics[V2_ACTIVE].value = 1
await hass.services.async_call(
- "fan", "turn_off", {"entity_id": "fan.testdevice"}, blocking=True,
+ "fan",
+ "turn_off",
+ {"entity_id": "fan.testdevice"},
+ blocking=True,
)
assert helper.characteristics[V2_ACTIVE].value == 0
diff --git a/tests/components/homematicip_cloud/helper.py b/tests/components/homematicip_cloud/helper.py
index fede095e57d..dbbf0249c0c 100644
--- a/tests/components/homematicip_cloud/helper.py
+++ b/tests/components/homematicip_cloud/helper.py
@@ -13,7 +13,7 @@ from homematicip.home import Home
from homeassistant import config_entries
from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN
-from homeassistant.components.homematicip_cloud.device import (
+from homeassistant.components.homematicip_cloud.generic_entity import (
ATTR_IS_GROUP,
ATTR_MODEL_TYPE,
)
diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py
index 7fe9a7327ea..f15b2b56a95 100644
--- a/tests/components/homematicip_cloud/test_binary_sensor.py
+++ b/tests/components/homematicip_cloud/test_binary_sensor.py
@@ -15,7 +15,7 @@ from homeassistant.components.homematicip_cloud.binary_sensor import (
ATTR_WATER_LEVEL_DETECTED,
ATTR_WINDOW_STATE,
)
-from homeassistant.components.homematicip_cloud.device import (
+from homeassistant.components.homematicip_cloud.generic_entity import (
ATTR_EVENT_DELAY,
ATTR_GROUP_MEMBER_UNREACHABLE,
ATTR_LOW_BATTERY,
@@ -31,7 +31,9 @@ from .helper import async_manipulate_test_data, get_and_check_entity_basics
async def test_manually_configured_platform(hass):
"""Test that we do not set up an access point."""
assert await async_setup_component(
- hass, BINARY_SENSOR_DOMAIN, {BINARY_SENSOR_DOMAIN: {"platform": HMIPC_DOMAIN}},
+ hass,
+ BINARY_SENSOR_DOMAIN,
+ {BINARY_SENSOR_DOMAIN: {"platform": HMIPC_DOMAIN}},
)
assert not hass.data.get(HMIPC_DOMAIN)
@@ -73,6 +75,42 @@ async def test_hmip_acceleration_sensor(hass, default_mock_hap_factory):
assert len(hmip_device.mock_calls) == service_call_counter + 2
+async def test_hmip_tilt_vibration_sensor(hass, default_mock_hap_factory):
+ """Test HomematicipTiltVibrationSensor."""
+ entity_id = "binary_sensor.garage_neigungs_und_erschutterungssensor"
+ entity_name = "Garage Neigungs- und Erschütterungssensor"
+ device_model = "HmIP-STV"
+ mock_hap = await default_mock_hap_factory.async_get_mock_hap(
+ test_devices=[entity_name]
+ )
+
+ ha_state, hmip_device = get_and_check_entity_basics(
+ hass, mock_hap, entity_id, entity_name, device_model
+ )
+
+ assert ha_state.state == STATE_ON
+ assert ha_state.attributes[ATTR_ACCELERATION_SENSOR_MODE] == "FLAT_DECT"
+ assert (
+ ha_state.attributes[ATTR_ACCELERATION_SENSOR_SENSITIVITY] == "SENSOR_RANGE_2G"
+ )
+ assert ha_state.attributes[ATTR_ACCELERATION_SENSOR_TRIGGER_ANGLE] == 45
+ service_call_counter = len(hmip_device.mock_calls)
+
+ await async_manipulate_test_data(
+ hass, hmip_device, "accelerationSensorTriggered", False
+ )
+ ha_state = hass.states.get(entity_id)
+ assert ha_state.state == STATE_OFF
+ assert len(hmip_device.mock_calls) == service_call_counter + 1
+
+ await async_manipulate_test_data(
+ hass, hmip_device, "accelerationSensorTriggered", True
+ )
+ ha_state = hass.states.get(entity_id)
+ assert ha_state.state == STATE_ON
+ assert len(hmip_device.mock_calls) == service_call_counter + 2
+
+
async def test_hmip_contact_interface(hass, default_mock_hap_factory):
"""Test HomematicipContactInterface."""
entity_id = "binary_sensor.kontakt_schnittstelle_unterputz_1_fach"
@@ -110,11 +148,19 @@ async def test_hmip_shutter_contact(hass, default_mock_hap_factory):
)
assert ha_state.state == STATE_ON
+ assert ha_state.attributes[ATTR_WINDOW_STATE] == WindowState.TILTED
+
+ await async_manipulate_test_data(hass, hmip_device, "windowState", WindowState.OPEN)
+ ha_state = hass.states.get(entity_id)
+ assert ha_state.state == STATE_ON
+ assert ha_state.attributes[ATTR_WINDOW_STATE] == WindowState.OPEN
+
await async_manipulate_test_data(
hass, hmip_device, "windowState", WindowState.CLOSED
)
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_OFF
+ assert not ha_state.attributes.get(ATTR_WINDOW_STATE)
await async_manipulate_test_data(hass, hmip_device, "windowState", None)
ha_state = hass.states.get(entity_id)
@@ -245,7 +291,10 @@ async def test_hmip_smoke_detector(hass, default_mock_hap_factory):
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_ON
await async_manipulate_test_data(
- hass, hmip_device, "smokeDetectorAlarmType", None,
+ hass,
+ hmip_device,
+ "smokeDetectorAlarmType",
+ None,
)
ha_state = hass.states.get(entity_id)
assert ha_state.state == STATE_OFF
diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py
index 52ca13aad62..6d5c3fb6060 100644
--- a/tests/components/homematicip_cloud/test_climate.py
+++ b/tests/components/homematicip_cloud/test_climate.py
@@ -382,7 +382,8 @@ async def test_hmip_heating_group_heat_with_radiator(hass, default_mock_hap_fact
entity_name = "Vorzimmer"
device_model = None
mock_hap = await default_mock_hap_factory.async_get_mock_hap(
- test_devices=["Heizkörperthermostat2"], test_groups=[entity_name],
+ test_devices=["Heizkörperthermostat2"],
+ test_groups=[entity_name],
)
ha_state, hmip_device = get_and_check_entity_basics(
hass, mock_hap, entity_id, entity_name, device_model
diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py
index f43e0ddafae..f7999b5f015 100644
--- a/tests/components/homematicip_cloud/test_device.py
+++ b/tests/components/homematicip_cloud/test_device.py
@@ -22,7 +22,7 @@ async def test_hmip_load_all_supported_devices(hass, default_mock_hap_factory):
test_devices=None, test_groups=None
)
- assert len(mock_hap.hmip_device_by_entity_id) == 187
+ assert len(mock_hap.hmip_device_by_entity_id) == 191
async def test_hmip_remove_device(hass, default_mock_hap_factory):
diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py
index ca701622e90..2a4833553d2 100644
--- a/tests/components/homematicip_cloud/test_hap.py
+++ b/tests/components/homematicip_cloud/test_hap.py
@@ -175,9 +175,7 @@ async def test_auth_create_exception(hass, simple_mock_auth):
"homeassistant.components.homematicip_cloud.hap.AsyncAuth",
return_value=simple_mock_auth,
):
- assert await hmip_auth.async_setup()
- await hass.async_block_till_done()
- assert not hmip_auth.auth
+ assert not await hmip_auth.async_setup()
with patch(
"homeassistant.components.homematicip_cloud.hap.AsyncAuth",
diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py
index 5b201da8aa8..37b293a3452 100644
--- a/tests/components/homematicip_cloud/test_init.py
+++ b/tests/components/homematicip_cloud/test_init.py
@@ -125,7 +125,9 @@ async def test_load_entry_fails_due_to_generic_exception(hass, hmip_config_entry
with patch(
"homeassistant.components.homematicip_cloud.hap.AsyncHome.get_current_state",
side_effect=Exception,
- ), patch("homematicip.aio.connection.AsyncConnection.init",):
+ ), patch(
+ "homematicip.aio.connection.AsyncConnection.init",
+ ):
assert await async_setup_component(hass, HMIPC_DOMAIN, {})
assert hass.data[HMIPC_DOMAIN][hmip_config_entry.unique_id]
diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py
index 61de66d916d..fe7283b471e 100644
--- a/tests/components/homematicip_cloud/test_sensor.py
+++ b/tests/components/homematicip_cloud/test_sensor.py
@@ -2,7 +2,7 @@
from homematicip.base.enums import ValveState
from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN
-from homeassistant.components.homematicip_cloud.device import (
+from homeassistant.components.homematicip_cloud.generic_entity import (
ATTR_CONFIG_PENDING,
ATTR_DEVICE_OVERHEATED,
ATTR_DEVICE_OVERLOADED,
@@ -24,10 +24,10 @@ from homeassistant.components.homematicip_cloud.sensor import (
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
+ PERCENTAGE,
POWER_WATT,
SPEED_KILOMETERS_PER_HOUR,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
from homeassistant.setup import async_setup_component
@@ -56,7 +56,7 @@ async def test_hmip_accesspoint_status(hass, default_mock_hap_factory):
)
assert hmip_device
assert ha_state.state == "8.0"
- assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UNIT_PERCENTAGE
+ assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE
await async_manipulate_test_data(hass, hmip_device, "dutyCycle", 17.3)
@@ -78,7 +78,7 @@ async def test_hmip_heating_thermostat(hass, default_mock_hap_factory):
)
assert ha_state.state == "0"
- assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UNIT_PERCENTAGE
+ assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE
await async_manipulate_test_data(hass, hmip_device, "valvePosition", 0.37)
ha_state = hass.states.get(entity_id)
assert ha_state.state == "37"
@@ -112,7 +112,7 @@ async def test_hmip_humidity_sensor(hass, default_mock_hap_factory):
)
assert ha_state.state == "40"
- assert ha_state.attributes["unit_of_measurement"] == UNIT_PERCENTAGE
+ assert ha_state.attributes["unit_of_measurement"] == PERCENTAGE
await async_manipulate_test_data(hass, hmip_device, "humidity", 45)
ha_state = hass.states.get(entity_id)
assert ha_state.state == "45"
diff --git a/tests/components/homematicip_cloud/test_switch.py b/tests/components/homematicip_cloud/test_switch.py
index 59690458881..85bfaaa4ec5 100644
--- a/tests/components/homematicip_cloud/test_switch.py
+++ b/tests/components/homematicip_cloud/test_switch.py
@@ -1,6 +1,6 @@
"""Tests for HomematicIP Cloud switch."""
from homeassistant.components.homematicip_cloud import DOMAIN as HMIPC_DOMAIN
-from homeassistant.components.homematicip_cloud.device import (
+from homeassistant.components.homematicip_cloud.generic_entity import (
ATTR_GROUP_MEMBER_UNREACHABLE,
)
from homeassistant.components.switch import (
diff --git a/tests/components/http/__init__.py b/tests/components/http/__init__.py
index e96f4a7fcf2..238f5c7050a 100644
--- a/tests/components/http/__init__.py
+++ b/tests/components/http/__init__.py
@@ -1,10 +1,6 @@
"""Tests for the HTTP component."""
-from ipaddress import ip_address
-
from aiohttp import web
-from homeassistant.components.http.const import KEY_REAL_IP
-
# Relic from the past. Kept here so we can run negative tests.
HTTP_HEADER_HA_AUTH = "X-HA-access"
@@ -25,7 +21,7 @@ def mock_real_ip(app):
"""Mock Real IP middleware."""
nonlocal ip_to_mock
- request[KEY_REAL_IP] = ip_address(ip_to_mock)
+ request = request.clone(remote=ip_to_mock)
return await handler(request)
diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py
index 9282bf4587b..e3274ddfa7d 100644
--- a/tests/components/http/test_auth.py
+++ b/tests/components/http/test_auth.py
@@ -9,7 +9,7 @@ import pytest
from homeassistant.auth.providers import trusted_networks
from homeassistant.components.http.auth import async_sign_path, setup_auth
from homeassistant.components.http.const import KEY_AUTHENTICATED
-from homeassistant.components.http.real_ip import setup_real_ip
+from homeassistant.components.http.forwarded import async_setup_forwarded
from homeassistant.setup import async_setup_component
from . import HTTP_HEADER_HA_AUTH, mock_real_ip
@@ -54,7 +54,7 @@ def app(hass):
app = web.Application()
app["hass"] = hass
app.router.add_get("/", mock_handler)
- setup_real_ip(app, False, [])
+ async_setup_forwarded(app, [])
return app
diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py
index 702912dd9d0..993ec708c18 100644
--- a/tests/components/http/test_ban.py
+++ b/tests/components/http/test_ban.py
@@ -24,6 +24,7 @@ from homeassistant.setup import async_setup_component
from . import mock_real_ip
from tests.async_mock import Mock, mock_open, patch
+from tests.common import async_mock_service
SUPERVISOR_IP = "1.2.3.4"
BANNED_IPS = ["200.201.202.203", "100.64.0.2"]
@@ -40,6 +41,16 @@ def hassio_env_fixture():
yield
+@pytest.fixture(autouse=True)
+def gethostbyaddr_mock():
+ """Fixture to mock out I/O on getting host by address."""
+ with patch(
+ "homeassistant.components.http.ban.gethostbyaddr",
+ return_value=("example.com", ["0.0.0.0.in-addr.arpa"], ["0.0.0.0"]),
+ ):
+ yield
+
+
async def test_access_from_banned_ip(hass, aiohttp_client):
"""Test accessing to server from banned IP. Both trusted and not."""
app = web.Application()
@@ -125,6 +136,8 @@ async def test_ban_middleware_loaded_by_default(hass):
async def test_ip_bans_file_creation(hass, aiohttp_client):
"""Testing if banned IP file created."""
+ notification_calls = async_mock_service(hass, "persistent_notification", "create")
+
app = web.Application()
app["hass"] = hass
@@ -159,6 +172,12 @@ async def test_ip_bans_file_creation(hass, aiohttp_client):
assert resp.status == HTTP_FORBIDDEN
assert m_open.call_count == 1
+ assert len(notification_calls) == 3
+ assert (
+ "Login attempt or request with invalid authentication from example.com (200.201.202.204) (Python"
+ in notification_calls[0].data["message"]
+ )
+
async def test_failed_login_attempts_counter(hass, aiohttp_client):
"""Testing if failed login attempts counter increased."""
diff --git a/tests/components/http/test_forwarded.py b/tests/components/http/test_forwarded.py
new file mode 100644
index 00000000000..2946c0b383c
--- /dev/null
+++ b/tests/components/http/test_forwarded.py
@@ -0,0 +1,489 @@
+"""Test real forwarded middleware."""
+from ipaddress import ip_network
+
+from aiohttp import web
+from aiohttp.hdrs import X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO
+import pytest
+
+from homeassistant.components.http.forwarded import async_setup_forwarded
+
+
+async def mock_handler(request):
+ """Return the real IP as text."""
+ return web.Response(text=request.remote)
+
+
+async def test_x_forwarded_for_without_trusted_proxy(aiohttp_client, caplog):
+ """Test that we get the IP from the transport."""
+
+ async def handler(request):
+ url = mock_api_client.make_url("/")
+ assert request.host == f"{url.host}:{url.port}"
+ assert request.scheme == "http"
+ assert not request.secure
+ assert request.remote == "127.0.0.1"
+
+ return web.Response()
+
+ app = web.Application()
+ app.router.add_get("/", handler)
+
+ async_setup_forwarded(app, [])
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get("/", headers={X_FORWARDED_FOR: "255.255.255.255"})
+
+ assert resp.status == 200
+ assert (
+ "Received X-Forwarded-For header from untrusted proxy 127.0.0.1, headers not processed"
+ in caplog.text
+ )
+
+
+@pytest.mark.parametrize(
+ "trusted_proxies,x_forwarded_for,remote",
+ [
+ (
+ ["127.0.0.0/24", "1.1.1.1", "10.10.10.0/24"],
+ "10.10.10.10, 1.1.1.1",
+ "10.10.10.10",
+ ),
+ (["127.0.0.0/24", "1.1.1.1"], "123.123.123.123, 2.2.2.2, 1.1.1.1", "2.2.2.2"),
+ (["127.0.0.0/24", "1.1.1.1"], "123.123.123.123,2.2.2.2,1.1.1.1", "2.2.2.2"),
+ (["127.0.0.0/24"], "123.123.123.123, 2.2.2.2, 1.1.1.1", "1.1.1.1"),
+ (["127.0.0.0/24"], "127.0.0.1", "127.0.0.1"),
+ (["127.0.0.1", "1.1.1.1"], "123.123.123.123, 1.1.1.1", "123.123.123.123"),
+ (["127.0.0.1", "1.1.1.1"], "123.123.123.123, 2.2.2.2, 1.1.1.1", "2.2.2.2"),
+ (["127.0.0.1"], "255.255.255.255", "255.255.255.255"),
+ ],
+)
+async def test_x_forwarded_for_with_trusted_proxy(
+ trusted_proxies, x_forwarded_for, remote, aiohttp_client
+):
+ """Test that we get the IP from the forwarded for header."""
+
+ async def handler(request):
+ url = mock_api_client.make_url("/")
+ assert request.host == f"{url.host}:{url.port}"
+ assert request.scheme == "http"
+ assert not request.secure
+ assert request.remote == remote
+
+ return web.Response()
+
+ app = web.Application()
+ app.router.add_get("/", handler)
+ async_setup_forwarded(
+ app, [ip_network(trusted_proxy) for trusted_proxy in trusted_proxies]
+ )
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get("/", headers={X_FORWARDED_FOR: x_forwarded_for})
+
+ assert resp.status == 200
+
+
+async def test_x_forwarded_for_with_untrusted_proxy(aiohttp_client):
+ """Test that we get the IP from transport with untrusted proxy."""
+
+ async def handler(request):
+ url = mock_api_client.make_url("/")
+ assert request.host == f"{url.host}:{url.port}"
+ assert request.scheme == "http"
+ assert not request.secure
+ assert request.remote == "127.0.0.1"
+
+ return web.Response()
+
+ app = web.Application()
+ app.router.add_get("/", handler)
+ async_setup_forwarded(app, [ip_network("1.1.1.1")])
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get("/", headers={X_FORWARDED_FOR: "255.255.255.255"})
+
+ assert resp.status == 200
+
+
+async def test_x_forwarded_for_with_spoofed_header(aiohttp_client):
+ """Test that we get the IP from the transport with a spoofed header."""
+
+ async def handler(request):
+ url = mock_api_client.make_url("/")
+ assert request.host == f"{url.host}:{url.port}"
+ assert request.scheme == "http"
+ assert not request.secure
+ assert request.remote == "255.255.255.255"
+
+ return web.Response()
+
+ app = web.Application()
+ app.router.add_get("/", handler)
+ async_setup_forwarded(app, [ip_network("127.0.0.1")])
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get(
+ "/", headers={X_FORWARDED_FOR: "222.222.222.222, 255.255.255.255"}
+ )
+
+ assert resp.status == 200
+
+
+@pytest.mark.parametrize(
+ "x_forwarded_for",
+ [
+ "This value is invalid",
+ "1.1.1.1, , 1.2.3.4",
+ "1.1.1.1,,1.2.3.4",
+ "1.1.1.1, batman, 1.2.3.4",
+ "192.168.0.0/24",
+ "192.168.0.0/24, 1.1.1.1",
+ ",",
+ "",
+ ],
+)
+async def test_x_forwarded_for_with_malformed_header(
+ x_forwarded_for, aiohttp_client, caplog
+):
+ """Test that we get a HTTP 400 bad request with a malformed header."""
+ app = web.Application()
+ app.router.add_get("/", mock_handler)
+ async_setup_forwarded(app, [ip_network("127.0.0.1")])
+
+ mock_api_client = await aiohttp_client(app)
+
+ resp = await mock_api_client.get("/", headers={X_FORWARDED_FOR: x_forwarded_for})
+
+ assert resp.status == 400
+ assert "Invalid IP address in X-Forwarded-For" in caplog.text
+
+
+async def test_x_forwarded_for_with_multiple_headers(aiohttp_client, caplog):
+ """Test that we get a HTTP 400 bad request with multiple headers."""
+ app = web.Application()
+ app.router.add_get("/", mock_handler)
+ async_setup_forwarded(app, [ip_network("127.0.0.1")])
+
+ mock_api_client = await aiohttp_client(app)
+
+ resp = await mock_api_client.get(
+ "/",
+ headers=[
+ (X_FORWARDED_FOR, "222.222.222.222"),
+ (X_FORWARDED_FOR, "123.123.123.123"),
+ ],
+ )
+
+ assert resp.status == 400
+ assert "Too many headers for X-Forwarded-For" in caplog.text
+
+
+async def test_x_forwarded_proto_without_trusted_proxy(aiohttp_client):
+ """Test that proto header is ignored when untrusted."""
+
+ async def handler(request):
+ url = mock_api_client.make_url("/")
+ assert request.host == f"{url.host}:{url.port}"
+ assert request.scheme == "http"
+ assert not request.secure
+ assert request.remote == "127.0.0.1"
+
+ return web.Response()
+
+ app = web.Application()
+ app.router.add_get("/", handler)
+
+ async_setup_forwarded(app, [])
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get(
+ "/", headers={X_FORWARDED_FOR: "255.255.255.255", X_FORWARDED_PROTO: "https"}
+ )
+
+ assert resp.status == 200
+
+
+@pytest.mark.parametrize(
+ "x_forwarded_for,remote,x_forwarded_proto,secure",
+ [
+ ("10.10.10.10, 127.0.0.1, 127.0.0.2", "10.10.10.10", "https, http, http", True),
+ ("10.10.10.10, 127.0.0.1, 127.0.0.2", "10.10.10.10", "https,http,http", True),
+ ("10.10.10.10, 127.0.0.1, 127.0.0.2", "10.10.10.10", "http", False),
+ (
+ "10.10.10.10, 127.0.0.1, 127.0.0.2",
+ "10.10.10.10",
+ "http, https, https",
+ False,
+ ),
+ ("10.10.10.10, 127.0.0.1, 127.0.0.2", "10.10.10.10", "https", True),
+ (
+ "255.255.255.255, 10.10.10.10, 127.0.0.1",
+ "10.10.10.10",
+ "http, https, http",
+ True,
+ ),
+ (
+ "255.255.255.255, 10.10.10.10, 127.0.0.1",
+ "10.10.10.10",
+ "https, http, https",
+ False,
+ ),
+ ("255.255.255.255, 10.10.10.10, 127.0.0.1", "10.10.10.10", "https", True),
+ ],
+)
+async def test_x_forwarded_proto_with_trusted_proxy(
+ x_forwarded_for, remote, x_forwarded_proto, secure, aiohttp_client
+):
+ """Test that we get the proto header if proxy is trusted."""
+
+ async def handler(request):
+ assert request.remote == remote
+ assert request.scheme == ("https" if secure else "http")
+ assert request.secure == secure
+
+ return web.Response()
+
+ app = web.Application()
+ app.router.add_get("/", handler)
+ async_setup_forwarded(app, [ip_network("127.0.0.0/24")])
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get(
+ "/",
+ headers={
+ X_FORWARDED_FOR: x_forwarded_for,
+ X_FORWARDED_PROTO: x_forwarded_proto,
+ },
+ )
+
+ assert resp.status == 200
+
+
+async def test_x_forwarded_proto_with_trusted_proxy_multiple_for(aiohttp_client):
+ """Test that we get the proto with 1 element in the proto, multiple in the for."""
+
+ async def handler(request):
+ url = mock_api_client.make_url("/")
+ assert request.host == f"{url.host}:{url.port}"
+ assert request.scheme == "https"
+ assert request.secure
+ assert request.remote == "255.255.255.255"
+
+ return web.Response()
+
+ app = web.Application()
+ app.router.add_get("/", handler)
+ async_setup_forwarded(app, [ip_network("127.0.0.0/24")])
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get(
+ "/",
+ headers={
+ X_FORWARDED_FOR: "255.255.255.255, 127.0.0.1, 127.0.0.2",
+ X_FORWARDED_PROTO: "https",
+ },
+ )
+
+ assert resp.status == 200
+
+
+async def test_x_forwarded_proto_not_processed_without_for(aiohttp_client):
+ """Test that proto header isn't processed without a for header."""
+
+ async def handler(request):
+ url = mock_api_client.make_url("/")
+ assert request.host == f"{url.host}:{url.port}"
+ assert request.scheme == "http"
+ assert not request.secure
+ assert request.remote == "127.0.0.1"
+
+ return web.Response()
+
+ app = web.Application()
+ app.router.add_get("/", handler)
+ async_setup_forwarded(app, [ip_network("127.0.0.1")])
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get("/", headers={X_FORWARDED_PROTO: "https"})
+
+ assert resp.status == 200
+
+
+async def test_x_forwarded_proto_with_multiple_headers(aiohttp_client, caplog):
+ """Test that we get a HTTP 400 bad request with multiple headers."""
+ app = web.Application()
+ app.router.add_get("/", mock_handler)
+ async_setup_forwarded(app, [ip_network("127.0.0.1")])
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get(
+ "/",
+ headers=[
+ (X_FORWARDED_FOR, "222.222.222.222"),
+ (X_FORWARDED_PROTO, "https"),
+ (X_FORWARDED_PROTO, "http"),
+ ],
+ )
+
+ assert resp.status == 400
+ assert "Too many headers for X-Forward-Proto" in caplog.text
+
+
+@pytest.mark.parametrize(
+ "x_forwarded_proto",
+ ["", ",", "https, , https", "https, https, "],
+)
+async def test_x_forwarded_proto_empty_element(
+ x_forwarded_proto, aiohttp_client, caplog
+):
+ """Test that we get a HTTP 400 bad request with empty proto."""
+ app = web.Application()
+ app.router.add_get("/", mock_handler)
+ async_setup_forwarded(app, [ip_network("127.0.0.1")])
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get(
+ "/",
+ headers={X_FORWARDED_FOR: "1.1.1.1", X_FORWARDED_PROTO: x_forwarded_proto},
+ )
+
+ assert resp.status == 400
+ assert "Empty item received in X-Forward-Proto header" in caplog.text
+
+
+@pytest.mark.parametrize(
+ "x_forwarded_for,x_forwarded_proto,expected,got",
+ [
+ ("1.1.1.1, 2.2.2.2", "https, https, https", 2, 3),
+ ("1.1.1.1, 2.2.2.2, 3.3.3.3, 4.4.4.4", "https, https, https", 4, 3),
+ ],
+)
+async def test_x_forwarded_proto_incorrect_number_of_elements(
+ x_forwarded_for, x_forwarded_proto, expected, got, aiohttp_client, caplog
+):
+ """Test that we get a HTTP 400 bad request with incorrect number of elements."""
+ app = web.Application()
+ app.router.add_get("/", mock_handler)
+ async_setup_forwarded(app, [ip_network("127.0.0.1")])
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get(
+ "/",
+ headers={
+ X_FORWARDED_FOR: x_forwarded_for,
+ X_FORWARDED_PROTO: x_forwarded_proto,
+ },
+ )
+
+ assert resp.status == 400
+ assert (
+ f"Incorrect number of elements in X-Forward-Proto. Expected 1 or {expected}, got {got}"
+ in caplog.text
+ )
+
+
+async def test_x_forwarded_host_without_trusted_proxy(aiohttp_client):
+ """Test that host header is ignored when untrusted."""
+
+ async def handler(request):
+ url = mock_api_client.make_url("/")
+ assert request.host == f"{url.host}:{url.port}"
+ assert request.scheme == "http"
+ assert not request.secure
+ assert request.remote == "127.0.0.1"
+
+ return web.Response()
+
+ app = web.Application()
+ app.router.add_get("/", handler)
+
+ async_setup_forwarded(app, [])
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get(
+ "/",
+ headers={X_FORWARDED_FOR: "255.255.255.255", X_FORWARDED_HOST: "example.com"},
+ )
+
+ assert resp.status == 200
+
+
+async def test_x_forwarded_host_with_trusted_proxy(aiohttp_client):
+ """Test that we get the host header if proxy is trusted."""
+
+ async def handler(request):
+ assert request.host == "example.com"
+ assert request.scheme == "http"
+ assert not request.secure
+ assert request.remote == "255.255.255.255"
+
+ return web.Response()
+
+ app = web.Application()
+ app.router.add_get("/", handler)
+ async_setup_forwarded(app, [ip_network("127.0.0.1")])
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get(
+ "/",
+ headers={X_FORWARDED_FOR: "255.255.255.255", X_FORWARDED_HOST: "example.com"},
+ )
+
+ assert resp.status == 200
+
+
+async def test_x_forwarded_host_not_processed_without_for(aiohttp_client):
+ """Test that host header isn't processed without a for header."""
+
+ async def handler(request):
+ url = mock_api_client.make_url("/")
+ assert request.host == f"{url.host}:{url.port}"
+ assert request.scheme == "http"
+ assert not request.secure
+ assert request.remote == "127.0.0.1"
+
+ return web.Response()
+
+ app = web.Application()
+ app.router.add_get("/", handler)
+ async_setup_forwarded(app, [ip_network("127.0.0.1")])
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get("/", headers={X_FORWARDED_HOST: "example.com"})
+
+ assert resp.status == 200
+
+
+async def test_x_forwarded_host_with_multiple_headers(aiohttp_client, caplog):
+ """Test that we get a HTTP 400 bad request with multiple headers."""
+ app = web.Application()
+ app.router.add_get("/", mock_handler)
+ async_setup_forwarded(app, [ip_network("127.0.0.1")])
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get(
+ "/",
+ headers=[
+ (X_FORWARDED_FOR, "222.222.222.222"),
+ (X_FORWARDED_HOST, "example.com"),
+ (X_FORWARDED_HOST, "example.spoof"),
+ ],
+ )
+
+ assert resp.status == 400
+ assert "Too many headers for X-Forwarded-Host" in caplog.text
+
+
+async def test_x_forwarded_host_with_empty_header(aiohttp_client, caplog):
+ """Test that we get a HTTP 400 bad request with empty host value."""
+ app = web.Application()
+ app.router.add_get("/", mock_handler)
+ async_setup_forwarded(app, [ip_network("127.0.0.1")])
+
+ mock_api_client = await aiohttp_client(app)
+ resp = await mock_api_client.get(
+ "/", headers={X_FORWARDED_FOR: "222.222.222.222", X_FORWARDED_HOST: ""}
+ )
+
+ assert resp.status == 400
+ assert "Empty value received in X-Forward-Host header" in caplog.text
diff --git a/tests/components/http/test_real_ip.py b/tests/components/http/test_real_ip.py
deleted file mode 100644
index 2cb74df3176..00000000000
--- a/tests/components/http/test_real_ip.py
+++ /dev/null
@@ -1,101 +0,0 @@
-"""Test real IP middleware."""
-from ipaddress import ip_network
-
-from aiohttp import web
-from aiohttp.hdrs import X_FORWARDED_FOR
-
-from homeassistant.components.http.const import KEY_REAL_IP
-from homeassistant.components.http.real_ip import setup_real_ip
-
-
-async def mock_handler(request):
- """Return the real IP as text."""
- return web.Response(text=str(request[KEY_REAL_IP]))
-
-
-async def test_ignore_x_forwarded_for(aiohttp_client):
- """Test that we get the IP from the transport."""
- app = web.Application()
- app.router.add_get("/", mock_handler)
- setup_real_ip(app, False, [])
-
- mock_api_client = await aiohttp_client(app)
-
- resp = await mock_api_client.get("/", headers={X_FORWARDED_FOR: "255.255.255.255"})
- assert resp.status == 200
- text = await resp.text()
- assert text != "255.255.255.255"
-
-
-async def test_use_x_forwarded_for_without_trusted_proxy(aiohttp_client):
- """Test that we get the IP from the transport."""
- app = web.Application()
- app.router.add_get("/", mock_handler)
- setup_real_ip(app, True, [])
-
- mock_api_client = await aiohttp_client(app)
-
- resp = await mock_api_client.get("/", headers={X_FORWARDED_FOR: "255.255.255.255"})
- assert resp.status == 200
- text = await resp.text()
- assert text != "255.255.255.255"
-
-
-async def test_use_x_forwarded_for_with_trusted_proxy(aiohttp_client):
- """Test that we get the IP from the transport."""
- app = web.Application()
- app.router.add_get("/", mock_handler)
- setup_real_ip(app, True, [ip_network("127.0.0.1")])
-
- mock_api_client = await aiohttp_client(app)
-
- resp = await mock_api_client.get("/", headers={X_FORWARDED_FOR: "255.255.255.255"})
- assert resp.status == 200
- text = await resp.text()
- assert text == "255.255.255.255"
-
-
-async def test_use_x_forwarded_for_with_untrusted_proxy(aiohttp_client):
- """Test that we get the IP from the transport."""
- app = web.Application()
- app.router.add_get("/", mock_handler)
- setup_real_ip(app, True, [ip_network("1.1.1.1")])
-
- mock_api_client = await aiohttp_client(app)
-
- resp = await mock_api_client.get("/", headers={X_FORWARDED_FOR: "255.255.255.255"})
- assert resp.status == 200
- text = await resp.text()
- assert text != "255.255.255.255"
-
-
-async def test_use_x_forwarded_for_with_spoofed_header(aiohttp_client):
- """Test that we get the IP from the transport."""
- app = web.Application()
- app.router.add_get("/", mock_handler)
- setup_real_ip(app, True, [ip_network("127.0.0.1")])
-
- mock_api_client = await aiohttp_client(app)
-
- resp = await mock_api_client.get(
- "/", headers={X_FORWARDED_FOR: "222.222.222.222, 255.255.255.255"}
- )
- assert resp.status == 200
- text = await resp.text()
- assert text == "255.255.255.255"
-
-
-async def test_use_x_forwarded_for_with_nonsense_header(aiohttp_client):
- """Test that we get the IP from the transport."""
- app = web.Application()
- app.router.add_get("/", mock_handler)
- setup_real_ip(app, True, [ip_network("127.0.0.1")])
-
- mock_api_client = await aiohttp_client(app)
-
- resp = await mock_api_client.get(
- "/", headers={X_FORWARDED_FOR: "This value is invalid"}
- )
- assert resp.status == 200
- text = await resp.text()
- assert text == "127.0.0.1"
diff --git a/tests/components/http/test_request_context.py b/tests/components/http/test_request_context.py
new file mode 100644
index 00000000000..f511b860dca
--- /dev/null
+++ b/tests/components/http/test_request_context.py
@@ -0,0 +1,33 @@
+"""Test request context middleware."""
+from contextvars import ContextVar
+
+from aiohttp import web
+
+from homeassistant.components.http.request_context import setup_request_context
+
+
+async def test_request_context_middleware(aiohttp_client):
+ """Test that request context is set from middleware."""
+ context = ContextVar("request", default=None)
+ app = web.Application()
+
+ async def mock_handler(request):
+ """Return the real IP as text."""
+ request_context = context.get()
+ assert request_context
+ assert request_context == request
+
+ return web.Response(text="hi!")
+
+ app.router.add_get("/", mock_handler)
+ setup_request_context(app, context)
+ mock_api_client = await aiohttp_client(app)
+
+ resp = await mock_api_client.get("/")
+ assert resp.status == 200
+
+ text = await resp.text()
+ assert text == "hi!"
+
+ # We are outside of the context here, should be None
+ assert context.get() is None
diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py
index 42a6f525319..21bb489ffe7 100644
--- a/tests/components/hue/test_config_flow.py
+++ b/tests/components/hue/test_config_flow.py
@@ -115,7 +115,8 @@ async def test_manual_flow_works(hass, aioclient_mock):
)
with patch(
- "aiohue.Bridge", return_value=bridge,
+ "aiohue.Bridge",
+ return_value=bridge,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": "2.2.2.2"}
@@ -148,7 +149,8 @@ async def test_manual_flow_bridge_exist(hass, aioclient_mock):
).add_to_hass(hass)
with patch(
- "homeassistant.components.hue.config_flow.discover_nupnp", return_value=[],
+ "homeassistant.components.hue.config_flow.discover_nupnp",
+ return_value=[],
):
result = await hass.config_entries.flow.async_init(
const.DOMAIN, context={"source": "user"}
@@ -162,7 +164,8 @@ async def test_manual_flow_bridge_exist(hass, aioclient_mock):
)
with patch(
- "aiohue.Bridge", return_value=bridge,
+ "aiohue.Bridge",
+ return_value=bridge,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": "2.2.2.2"}
@@ -293,7 +296,9 @@ async def test_flow_link_timeout(hass):
async def test_flow_link_unknown_error(hass):
"""Test if a unknown error happened during the linking processes."""
- mock_bridge = get_mock_bridge(mock_create_user=AsyncMock(side_effect=OSError),)
+ mock_bridge = get_mock_bridge(
+ mock_create_user=AsyncMock(side_effect=OSError),
+ )
with patch(
"homeassistant.components.hue.config_flow.discover_nupnp",
return_value=[mock_bridge],
@@ -481,7 +486,9 @@ async def test_bridge_ssdp_already_configured(hass):
async def test_import_with_no_config(hass):
"""Test importing a host without an existing config file."""
result = await hass.config_entries.flow.async_init(
- const.DOMAIN, context={"source": "import"}, data={"host": "0.0.0.0"},
+ const.DOMAIN,
+ context={"source": "import"},
+ data={"host": "0.0.0.0"},
)
assert result["type"] == "form"
@@ -496,12 +503,16 @@ async def test_creating_entry_removes_entries_for_same_host_or_bridge(hass):
all existing entries that either have same IP or same bridge_id.
"""
orig_entry = MockConfigEntry(
- domain="hue", data={"host": "0.0.0.0", "username": "aaaa"}, unique_id="id-1234",
+ domain="hue",
+ data={"host": "0.0.0.0", "username": "aaaa"},
+ unique_id="id-1234",
)
orig_entry.add_to_hass(hass)
MockConfigEntry(
- domain="hue", data={"host": "1.2.3.4", "username": "bbbb"}, unique_id="id-5678",
+ domain="hue",
+ data={"host": "1.2.3.4", "username": "bbbb"},
+ unique_id="id-5678",
).add_to_hass(hass)
assert len(hass.config_entries.async_entries("hue")) == 2
@@ -511,7 +522,8 @@ async def test_creating_entry_removes_entries_for_same_host_or_bridge(hass):
)
with patch(
- "aiohue.Bridge", return_value=bridge,
+ "aiohue.Bridge",
+ return_value=bridge,
):
result = await hass.config_entries.flow.async_init(
"hue", data={"host": "2.2.2.2"}, context={"source": "import"}
@@ -614,7 +626,9 @@ async def test_ssdp_discovery_update_configuration(hass):
async def test_options_flow(hass):
"""Test options config flow."""
entry = MockConfigEntry(
- domain="hue", unique_id="aabbccddeeff", data={"host": "0.0.0.0"},
+ domain="hue",
+ unique_id="aabbccddeeff",
+ data={"host": "0.0.0.0"},
)
entry.add_to_hass(hass)
diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py
index b1dc024998e..70a6c3b8756 100644
--- a/tests/components/hue/test_init.py
+++ b/tests/components/hue/test_init.py
@@ -31,130 +31,6 @@ async def test_setup_with_no_config(hass):
assert hass.data[hue.DOMAIN] == {}
-async def test_setup_defined_hosts_known_auth(hass):
- """Test we don't initiate a config entry if config bridge is known."""
- MockConfigEntry(domain="hue", data={"host": "0.0.0.0"}).add_to_hass(hass)
-
- with patch.object(hue, "async_setup_entry", return_value=True):
- assert (
- await async_setup_component(
- hass,
- hue.DOMAIN,
- {
- hue.DOMAIN: {
- hue.CONF_BRIDGES: [
- {
- hue.CONF_HOST: "0.0.0.0",
- hue.CONF_ALLOW_HUE_GROUPS: False,
- hue.CONF_ALLOW_UNREACHABLE: True,
- },
- {hue.CONF_HOST: "1.1.1.1"},
- ]
- }
- },
- )
- is True
- )
-
- # Flow started for discovered bridge
- assert len(hass.config_entries.flow.async_progress()) == 1
-
- # Config stored for domain.
- assert hass.data[hue.DATA_CONFIGS] == {
- "0.0.0.0": {
- hue.CONF_HOST: "0.0.0.0",
- hue.CONF_ALLOW_HUE_GROUPS: False,
- hue.CONF_ALLOW_UNREACHABLE: True,
- },
- "1.1.1.1": {hue.CONF_HOST: "1.1.1.1"},
- }
-
-
-async def test_setup_defined_hosts_no_known_auth(hass):
- """Test we initiate config entry if config bridge is not known."""
- assert (
- await async_setup_component(
- hass,
- hue.DOMAIN,
- {
- hue.DOMAIN: {
- hue.CONF_BRIDGES: {
- hue.CONF_HOST: "0.0.0.0",
- hue.CONF_ALLOW_HUE_GROUPS: False,
- hue.CONF_ALLOW_UNREACHABLE: True,
- }
- }
- },
- )
- is True
- )
-
- # Flow started for discovered bridge
- assert len(hass.config_entries.flow.async_progress()) == 1
-
- # Config stored for domain.
- assert hass.data[hue.DATA_CONFIGS] == {
- "0.0.0.0": {
- hue.CONF_HOST: "0.0.0.0",
- hue.CONF_ALLOW_HUE_GROUPS: False,
- hue.CONF_ALLOW_UNREACHABLE: True,
- }
- }
-
-
-async def test_config_passed_to_config_entry(hass):
- """Test that configured options for a host are loaded via config entry."""
- entry = MockConfigEntry(domain=hue.DOMAIN, data={"host": "0.0.0.0"})
- entry.add_to_hass(hass)
- mock_registry = Mock()
- with patch.object(hue, "HueBridge") as mock_bridge, patch(
- "homeassistant.helpers.device_registry.async_get_registry",
- return_value=mock_registry,
- ):
- mock_bridge.return_value.async_setup = AsyncMock(return_value=True)
- mock_bridge.return_value.api.config = Mock(
- mac="mock-mac",
- bridgeid="mock-bridgeid",
- modelid="mock-modelid",
- swversion="mock-swversion",
- )
- # Can't set name via kwargs
- mock_bridge.return_value.api.config.name = "mock-name"
- assert (
- await async_setup_component(
- hass,
- hue.DOMAIN,
- {
- hue.DOMAIN: {
- hue.CONF_BRIDGES: {
- hue.CONF_HOST: "0.0.0.0",
- hue.CONF_ALLOW_HUE_GROUPS: False,
- hue.CONF_ALLOW_UNREACHABLE: True,
- }
- }
- },
- )
- is True
- )
-
- assert len(mock_bridge.mock_calls) == 2
- p_hass, p_entry = mock_bridge.mock_calls[0][1]
-
- assert p_hass is hass
- assert p_entry is entry
-
- assert len(mock_registry.mock_calls) == 1
- assert mock_registry.mock_calls[0][2] == {
- "config_entry_id": entry.entry_id,
- "connections": {("mac", "mock-mac")},
- "identifiers": {("hue", "mock-bridgeid")},
- "manufacturer": "Signify",
- "name": "mock-name",
- "model": "mock-modelid",
- "sw_version": "mock-swversion",
- }
-
-
async def test_unload_entry(hass, mock_bridge_setup):
"""Test being able to unload an entry."""
entry = MockConfigEntry(domain=hue.DOMAIN, data={"host": "0.0.0.0"})
@@ -196,7 +72,9 @@ async def test_fixing_unique_id_other_ignored(hass, mock_bridge_setup):
source=config_entries.SOURCE_IGNORE,
).add_to_hass(hass)
entry = MockConfigEntry(
- domain=hue.DOMAIN, data={"host": "0.0.0.0"}, unique_id="invalid-id",
+ domain=hue.DOMAIN,
+ data={"host": "0.0.0.0"},
+ unique_id="invalid-id",
)
entry.add_to_hass(hass)
assert await async_setup_component(hass, hue.DOMAIN, {}) is True
@@ -208,11 +86,15 @@ async def test_fixing_unique_id_other_ignored(hass, mock_bridge_setup):
async def test_fixing_unique_id_other_correct(hass, mock_bridge_setup):
"""Test we remove config entry if another one has correct ID."""
correct_entry = MockConfigEntry(
- domain=hue.DOMAIN, data={"host": "0.0.0.0"}, unique_id="mock-id",
+ domain=hue.DOMAIN,
+ data={"host": "0.0.0.0"},
+ unique_id="mock-id",
)
correct_entry.add_to_hass(hass)
entry = MockConfigEntry(
- domain=hue.DOMAIN, data={"host": "0.0.0.0"}, unique_id="invalid-id",
+ domain=hue.DOMAIN,
+ data={"host": "0.0.0.0"},
+ unique_id="invalid-id",
)
entry.add_to_hass(hass)
assert await async_setup_component(hass, hue.DOMAIN, {}) is True
diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py
index 383d0445a7d..37fb150ddf7 100644
--- a/tests/components/hunterdouglas_powerview/test_config_flow.py
+++ b/tests/components/hunterdouglas_powerview/test_config_flow.py
@@ -43,7 +43,8 @@ async def test_user_form(hass):
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"host": "1.2.3.4"},
+ result["flow_id"],
+ {"host": "1.2.3.4"},
)
assert result2["type"] == "create_entry"
@@ -62,7 +63,8 @@ async def test_user_form(hass):
assert result3["errors"] == {}
result4 = await hass.config_entries.flow.async_configure(
- result3["flow_id"], {"host": "1.2.3.4"},
+ result3["flow_id"],
+ {"host": "1.2.3.4"},
)
assert result4["type"] == "abort"
@@ -175,7 +177,8 @@ async def test_form_cannot_connect(hass):
return_value=mock_powerview_userdata,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"host": "1.2.3.4"},
+ result["flow_id"],
+ {"host": "1.2.3.4"},
)
assert result2["type"] == "form"
@@ -194,7 +197,8 @@ async def test_form_no_data(hass):
return_value=mock_powerview_userdata,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"host": "1.2.3.4"},
+ result["flow_id"],
+ {"host": "1.2.3.4"},
)
assert result2["type"] == "form"
@@ -213,7 +217,8 @@ async def test_form_unknown_exception(hass):
return_value=mock_powerview_userdata,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"host": "1.2.3.4"},
+ result["flow_id"],
+ {"host": "1.2.3.4"},
)
assert result2["type"] == "form"
diff --git a/tests/components/hvv_departures/test_config_flow.py b/tests/components/hvv_departures/test_config_flow.py
index 3f9098abfc8..1e37ff2e021 100644
--- a/tests/components/hvv_departures/test_config_flow.py
+++ b/tests/components/hvv_departures/test_config_flow.py
@@ -33,11 +33,13 @@ async def test_user_flow(hass):
"homeassistant.components.hvv_departures.hub.GTI.init",
return_value=FIXTURE_INIT,
), patch("pygti.gti.GTI.checkName", return_value=FIXTURE_CHECK_NAME,), patch(
- "pygti.gti.GTI.stationInformation", return_value=FIXTURE_STATION_INFORMATION,
+ "pygti.gti.GTI.stationInformation",
+ return_value=FIXTURE_STATION_INFORMATION,
), patch(
"homeassistant.components.hvv_departures.async_setup", return_value=True
), patch(
- "homeassistant.components.hvv_departures.async_setup_entry", return_value=True,
+ "homeassistant.components.hvv_departures.async_setup_entry",
+ return_value=True,
):
# step: user
@@ -56,14 +58,16 @@ async def test_user_flow(hass):
# step: station
result_station = await hass.config_entries.flow.async_configure(
- result_user["flow_id"], {CONF_STATION: "Wartenau"},
+ result_user["flow_id"],
+ {CONF_STATION: "Wartenau"},
)
assert result_station["step_id"] == "station_select"
# step: station_select
result_station_select = await hass.config_entries.flow.async_configure(
- result_user["flow_id"], {CONF_STATION: "Wartenau"},
+ result_user["flow_id"],
+ {CONF_STATION: "Wartenau"},
)
assert result_station_select["type"] == "create_entry"
@@ -92,11 +96,13 @@ async def test_user_flow_no_results(hass):
"homeassistant.components.hvv_departures.hub.GTI.init",
return_value=FIXTURE_INIT,
), patch(
- "pygti.gti.GTI.checkName", return_value={"returnCode": "OK", "results": []},
+ "pygti.gti.GTI.checkName",
+ return_value={"returnCode": "OK", "results": []},
), patch(
"homeassistant.components.hvv_departures.async_setup", return_value=True
), patch(
- "homeassistant.components.hvv_departures.async_setup_entry", return_value=True,
+ "homeassistant.components.hvv_departures.async_setup_entry",
+ return_value=True,
):
# step: user
@@ -115,7 +121,8 @@ async def test_user_flow_no_results(hass):
# step: station
result_station = await hass.config_entries.flow.async_configure(
- result_user["flow_id"], {CONF_STATION: "non_existing_station"},
+ result_user["flow_id"],
+ {CONF_STATION: "non_existing_station"},
)
assert result_station["step_id"] == "station"
@@ -176,9 +183,11 @@ async def test_user_flow_station(hass):
"""Test that config flow handles empty data on step station."""
with patch(
- "homeassistant.components.hvv_departures.hub.GTI.init", return_value=True,
+ "homeassistant.components.hvv_departures.hub.GTI.init",
+ return_value=True,
), patch(
- "pygti.gti.GTI.checkName", return_value={"returnCode": "OK", "results": []},
+ "pygti.gti.GTI.checkName",
+ return_value={"returnCode": "OK", "results": []},
):
# step: user
@@ -197,7 +206,8 @@ async def test_user_flow_station(hass):
# step: station
result_station = await hass.config_entries.flow.async_configure(
- result_user["flow_id"], None,
+ result_user["flow_id"],
+ None,
)
assert result_station["type"] == "form"
assert result_station["step_id"] == "station"
@@ -207,9 +217,11 @@ async def test_user_flow_station_select(hass):
"""Test that config flow handles empty data on step station_select."""
with patch(
- "homeassistant.components.hvv_departures.hub.GTI.init", return_value=True,
+ "homeassistant.components.hvv_departures.hub.GTI.init",
+ return_value=True,
), patch(
- "pygti.gti.GTI.checkName", return_value=FIXTURE_CHECK_NAME,
+ "pygti.gti.GTI.checkName",
+ return_value=FIXTURE_CHECK_NAME,
):
result_user = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -222,12 +234,14 @@ async def test_user_flow_station_select(hass):
)
result_station = await hass.config_entries.flow.async_configure(
- result_user["flow_id"], {CONF_STATION: "Wartenau"},
+ result_user["flow_id"],
+ {CONF_STATION: "Wartenau"},
)
# step: station_select
result_station_select = await hass.config_entries.flow.async_configure(
- result_station["flow_id"], None,
+ result_station["flow_id"],
+ None,
)
assert result_station_select["type"] == "form"
@@ -251,9 +265,11 @@ async def test_options_flow(hass):
config_entry.add_to_hass(hass)
with patch(
- "homeassistant.components.hvv_departures.hub.GTI.init", return_value=True,
+ "homeassistant.components.hvv_departures.hub.GTI.init",
+ return_value=True,
), patch(
- "pygti.gti.GTI.departureList", return_value=FIXTURE_DEPARTURE_LIST,
+ "pygti.gti.GTI.departureList",
+ return_value=FIXTURE_DEPARTURE_LIST,
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
@@ -332,7 +348,8 @@ async def test_options_flow_cannot_connect(hass):
config_entry.add_to_hass(hass)
with patch(
- "pygti.gti.GTI.departureList", side_effect=CannotConnect(),
+ "pygti.gti.GTI.departureList",
+ side_effect=CannotConnect(),
):
assert await hass.config_entries.async_setup(config_entry.entry_id)
diff --git a/tests/components/image/__init__.py b/tests/components/image/__init__.py
new file mode 100644
index 00000000000..8bf90c4f516
--- /dev/null
+++ b/tests/components/image/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Image integration."""
diff --git a/tests/components/image/logo.png b/tests/components/image/logo.png
new file mode 100644
index 00000000000..4fe49f12630
Binary files /dev/null and b/tests/components/image/logo.png differ
diff --git a/tests/components/image/test_init.py b/tests/components/image/test_init.py
new file mode 100644
index 00000000000..277e6f07149
--- /dev/null
+++ b/tests/components/image/test_init.py
@@ -0,0 +1,76 @@
+"""Test that we can upload images."""
+import pathlib
+import tempfile
+
+from aiohttp import ClientSession, ClientWebSocketResponse
+
+from homeassistant.components.websocket_api import const as ws_const
+from homeassistant.setup import async_setup_component
+from homeassistant.util import dt as util_dt
+
+from tests.async_mock import patch
+
+
+async def test_upload_image(hass, hass_client, hass_ws_client):
+ """Test we can upload an image."""
+ now = util_dt.utcnow()
+ test_image = pathlib.Path(__file__).parent / "logo.png"
+
+ with tempfile.TemporaryDirectory() as tempdir, patch.object(
+ hass.config, "path", return_value=tempdir
+ ), patch("homeassistant.util.dt.utcnow", return_value=now):
+ assert await async_setup_component(hass, "image", {})
+ ws_client: ClientWebSocketResponse = await hass_ws_client()
+ client: ClientSession = await hass_client()
+
+ with test_image.open("rb") as fp:
+ res = await client.post("/api/image/upload", data={"file": fp})
+
+ assert res.status == 200
+
+ item = await res.json()
+
+ assert item["content_type"] == "image/png"
+ assert item["filesize"] == 38847
+ assert item["name"] == "logo.png"
+ assert item["uploaded_at"] == now.isoformat()
+
+ tempdir = pathlib.Path(tempdir)
+ item_folder: pathlib.Path = tempdir / item["id"]
+ assert (item_folder / "original").read_bytes() == test_image.read_bytes()
+
+ # fetch non-existing image
+ res = await client.get("/api/image/serve/non-existing/256x256")
+ assert res.status == 404
+
+ # fetch invalid sizes
+ for inv_size in ("256", "256x25A", "100x100", "25Ax256"):
+ res = await client.get(f"/api/image/serve/{item['id']}/{inv_size}")
+ assert res.status == 400
+
+ # fetch resized version
+ res = await client.get(f"/api/image/serve/{item['id']}/256x256")
+ assert res.status == 200
+ assert (item_folder / "256x256").is_file()
+
+ # List item
+ await ws_client.send_json({"id": 6, "type": "image/list"})
+ msg = await ws_client.receive_json()
+
+ assert msg["id"] == 6
+ assert msg["type"] == ws_const.TYPE_RESULT
+ assert msg["success"]
+ assert msg["result"] == [item]
+
+ # Delete item
+ await ws_client.send_json(
+ {"id": 7, "type": "image/delete", "image_id": item["id"]}
+ )
+ msg = await ws_client.receive_json()
+
+ assert msg["id"] == 7
+ assert msg["type"] == ws_const.TYPE_RESULT
+ assert msg["success"]
+
+ # Ensure removed from disk
+ assert not item_folder.is_dir()
diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py
index 72edcd7160a..c006a262e85 100644
--- a/tests/components/image_processing/test_init.py
+++ b/tests/components/image_processing/test_init.py
@@ -69,7 +69,8 @@ class TestImageProcessing:
self.hass.stop()
@patch(
- "homeassistant.components.demo.camera.Path.read_bytes", return_value=b"Test",
+ "homeassistant.components.demo.camera.Path.read_bytes",
+ return_value=b"Test",
)
def test_get_image_from_camera(self, mock_camera_read):
"""Grab an image from camera entity."""
diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py
index 7e2a9f5d9c3..ca4e56ff54d 100644
--- a/tests/components/influxdb/test_init.py
+++ b/tests/components/influxdb/test_init.py
@@ -8,10 +8,10 @@ import homeassistant.components.influxdb as influxdb
from homeassistant.components.influxdb.const import DEFAULT_BUCKET
from homeassistant.const import (
EVENT_STATE_CHANGED,
+ PERCENTAGE,
STATE_OFF,
STATE_ON,
STATE_STANDBY,
- UNIT_PERCENTAGE,
)
from homeassistant.core import split_entity_id
from homeassistant.setup import async_setup_component
@@ -41,7 +41,8 @@ def mock_batch_timeout(hass, monkeypatch):
"""Mock the event bus listener and the batch timeout for tests."""
hass.bus.listen = MagicMock()
monkeypatch.setattr(
- f"{INFLUX_PATH}.InfluxThread.batch_timeout", Mock(return_value=0),
+ f"{INFLUX_PATH}.InfluxThread.batch_timeout",
+ Mock(return_value=0),
)
@@ -238,7 +239,7 @@ async def test_event_listener(
"unit_of_measurement": "foobars",
"longitude": "1.1",
"latitude": "2.2",
- "battery_level": f"99{UNIT_PERCENTAGE}",
+ "battery_level": f"99{PERCENTAGE}",
"temperature": "20c",
"last_seen": "Last seen 23 minutes ago",
"updated_at": datetime.datetime(2017, 1, 1, 0, 0),
@@ -260,7 +261,7 @@ async def test_event_listener(
"fields": {
"longitude": 1.1,
"latitude": 2.2,
- "battery_level_str": f"99{UNIT_PERCENTAGE}",
+ "battery_level_str": f"99{PERCENTAGE}",
"battery_level": 99.0,
"temperature_str": "20c",
"temperature": 20.0,
@@ -868,7 +869,11 @@ async def test_event_listener_default_measurement(
handler_method = await _setup(hass, mock_client, config, get_write_api)
state = MagicMock(
- state=1, domain="fake", entity_id="fake.ok", object_id="ok", attributes={},
+ state=1,
+ domain="fake",
+ entity_id="fake.ok",
+ object_id="ok",
+ attributes={},
)
event = MagicMock(data={"new_state": state}, time_fired=12345)
body = [
diff --git a/tests/components/influxdb/test_sensor.py b/tests/components/influxdb/test_sensor.py
index 150e378e383..633f9e891f8 100644
--- a/tests/components/influxdb/test_sensor.py
+++ b/tests/components/influxdb/test_sensor.py
@@ -356,7 +356,12 @@ async def test_state_matches_first_query_result_for_multiple_return(
@pytest.mark.parametrize(
"mock_client, config_ext, queries, set_query_mock",
[
- (DEFAULT_API_VERSION, BASE_V1_CONFIG, BASE_V1_QUERY, _set_query_mock_v1,),
+ (
+ DEFAULT_API_VERSION,
+ BASE_V1_CONFIG,
+ BASE_V1_QUERY,
+ _set_query_mock_v1,
+ ),
(API_VERSION_2, BASE_V2_CONFIG, BASE_V2_QUERY, _set_query_mock_v2),
],
indirect=["mock_client"],
@@ -577,7 +582,12 @@ async def test_connection_error_at_startup(
indirect=["mock_client"],
)
async def test_data_repository_not_found(
- hass, caplog, mock_client, config_ext, queries, set_query_mock,
+ hass,
+ caplog,
+ mock_client,
+ config_ext,
+ queries,
+ set_query_mock,
):
"""Test sensor is not setup when bucket not available."""
set_query_mock(mock_client)
diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py
index 0eb4d748563..70f0b69d3ef 100644
--- a/tests/components/input_datetime/test_init.py
+++ b/tests/components/input_datetime/test_init.py
@@ -10,6 +10,7 @@ from homeassistant.components.input_datetime import (
ATTR_DATETIME,
ATTR_EDITABLE,
ATTR_TIME,
+ ATTR_TIMESTAMP,
CONF_HAS_DATE,
CONF_HAS_TIME,
CONF_ID,
@@ -25,6 +26,7 @@ from homeassistant.core import Context, CoreState, State
from homeassistant.exceptions import Unauthorized
from homeassistant.helpers import entity_registry
from homeassistant.setup import async_setup_component
+from homeassistant.util import dt as dt_util
from tests.async_mock import patch
from tests.common import mock_restore_cache
@@ -92,6 +94,16 @@ async def async_set_datetime(hass, entity_id, dt_value):
)
+async def async_set_timestamp(hass, entity_id, timestamp):
+ """Set date and / or time of input_datetime."""
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SET_DATETIME,
+ {ATTR_ENTITY_ID: entity_id, ATTR_TIMESTAMP: timestamp},
+ blocking=True,
+ )
+
+
async def test_invalid_configs(hass):
"""Test config."""
invalid_configs = [
@@ -156,6 +168,32 @@ async def test_set_datetime_2(hass):
assert state.attributes["timestamp"] == dt_obj.timestamp()
+async def test_set_datetime_3(hass):
+ """Test set_datetime method using timestamp."""
+ await async_setup_component(
+ hass, DOMAIN, {DOMAIN: {"test_datetime": {"has_time": True, "has_date": True}}}
+ )
+
+ entity_id = "input_datetime.test_datetime"
+
+ dt_obj = datetime.datetime(2017, 9, 7, 19, 46, 30)
+
+ await async_set_timestamp(hass, entity_id, dt_util.as_utc(dt_obj).timestamp())
+
+ state = hass.states.get(entity_id)
+ assert state.state == str(dt_obj)
+ assert state.attributes["has_time"]
+ assert state.attributes["has_date"]
+
+ assert state.attributes["year"] == 2017
+ assert state.attributes["month"] == 9
+ assert state.attributes["day"] == 7
+ assert state.attributes["hour"] == 19
+ assert state.attributes["minute"] == 46
+ assert state.attributes["second"] == 30
+ assert state.attributes["timestamp"] == dt_obj.timestamp()
+
+
async def test_set_datetime_time(hass):
"""Test set_datetime method with only time."""
await async_setup_component(
@@ -199,7 +237,8 @@ async def test_set_invalid(hass):
await hass.services.async_call(
"input_datetime",
"set_datetime",
- {"entity_id": "test_date", "time": time_portion},
+ {"entity_id": entity_id, "time": time_portion},
+ blocking=True,
)
await hass.async_block_till_done()
@@ -229,7 +268,8 @@ async def test_set_invalid_2(hass):
await hass.services.async_call(
"input_datetime",
"set_datetime",
- {"entity_id": "test_date", "time": time_portion, "datetime": dt_obj},
+ {"entity_id": entity_id, "time": time_portion, "datetime": dt_obj},
+ blocking=True,
)
await hass.async_block_till_done()
@@ -358,8 +398,8 @@ async def test_input_datetime_context(hass, hass_admin_user):
"input_datetime",
"set_datetime",
{"entity_id": state.entity_id, "date": "2018-01-02"},
- True,
- Context(user_id=hass_admin_user.id),
+ blocking=True,
+ context=Context(user_id=hass_admin_user.id),
)
state2 = hass.states.get("input_datetime.only_date")
diff --git a/tests/components/insteon/__init__.py b/tests/components/insteon/__init__.py
new file mode 100644
index 00000000000..91010bd1370
--- /dev/null
+++ b/tests/components/insteon/__init__.py
@@ -0,0 +1 @@
+"""Test for the Insteon integration."""
diff --git a/tests/components/insteon/const.py b/tests/components/insteon/const.py
new file mode 100644
index 00000000000..ec59d94ba72
--- /dev/null
+++ b/tests/components/insteon/const.py
@@ -0,0 +1,100 @@
+"""Constants used for Insteon test cases."""
+from homeassistant.components.insteon.const import (
+ CONF_CAT,
+ CONF_DIM_STEPS,
+ CONF_HOUSECODE,
+ CONF_HUB_VERSION,
+ CONF_OVERRIDE,
+ CONF_SUBCAT,
+ CONF_UNITCODE,
+ CONF_X10,
+ X10_PLATFORMS,
+)
+from homeassistant.const import (
+ CONF_ADDRESS,
+ CONF_DEVICE,
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_PLATFORM,
+ CONF_PORT,
+ CONF_USERNAME,
+)
+
+MOCK_HOSTNAME = "1.1.1.1"
+MOCK_DEVICE = "/dev/ttyUSB55"
+MOCK_USERNAME = "test-username"
+MOCK_PASSWORD = "test-password"
+MOCK_PORT = 4567
+
+MOCK_ADDRESS = "1a2b3c"
+MOCK_CAT = 0x02
+MOCK_SUBCAT = 0x1A
+
+MOCK_HOUSECODE = "c"
+MOCK_UNITCODE_1 = 1
+MOCK_UNITCODE_2 = 2
+MOCK_X10_PLATFORM_1 = X10_PLATFORMS[0]
+MOCK_X10_PLATFORM_2 = X10_PLATFORMS[2]
+MOCK_X10_STEPS = 10
+
+MOCK_USER_INPUT_PLM = {
+ CONF_DEVICE: MOCK_DEVICE,
+}
+
+MOCK_USER_INPUT_HUB_V2 = {
+ CONF_HOST: MOCK_HOSTNAME,
+ CONF_USERNAME: MOCK_USERNAME,
+ CONF_PASSWORD: MOCK_PASSWORD,
+ CONF_PORT: MOCK_PORT,
+}
+
+MOCK_USER_INPUT_HUB_V1 = {
+ CONF_HOST: MOCK_HOSTNAME,
+ CONF_PORT: MOCK_PORT,
+}
+
+MOCK_DEVICE_OVERRIDE_CONFIG = {
+ CONF_ADDRESS: MOCK_ADDRESS,
+ CONF_CAT: MOCK_CAT,
+ CONF_SUBCAT: MOCK_SUBCAT,
+}
+
+MOCK_X10_CONFIG_1 = {
+ CONF_HOUSECODE: MOCK_HOUSECODE,
+ CONF_UNITCODE: MOCK_UNITCODE_1,
+ CONF_PLATFORM: MOCK_X10_PLATFORM_1,
+ CONF_DIM_STEPS: MOCK_X10_STEPS,
+}
+
+MOCK_X10_CONFIG_2 = {
+ CONF_HOUSECODE: MOCK_HOUSECODE,
+ CONF_UNITCODE: MOCK_UNITCODE_2,
+ CONF_PLATFORM: MOCK_X10_PLATFORM_2,
+ CONF_DIM_STEPS: MOCK_X10_STEPS,
+}
+
+MOCK_IMPORT_CONFIG_PLM = {CONF_PORT: MOCK_DEVICE}
+
+MOCK_IMPORT_MINIMUM_HUB_V2 = {
+ CONF_HOST: MOCK_HOSTNAME,
+ CONF_USERNAME: MOCK_USERNAME,
+ CONF_PASSWORD: MOCK_PASSWORD,
+}
+MOCK_IMPORT_MINIMUM_HUB_V1 = {CONF_HOST: MOCK_HOSTNAME, CONF_HUB_VERSION: 1}
+MOCK_IMPORT_FULL_CONFIG_PLM = MOCK_IMPORT_CONFIG_PLM.copy()
+MOCK_IMPORT_FULL_CONFIG_PLM[CONF_OVERRIDE] = [MOCK_DEVICE_OVERRIDE_CONFIG]
+MOCK_IMPORT_FULL_CONFIG_PLM[CONF_X10] = [MOCK_X10_CONFIG_1, MOCK_X10_CONFIG_2]
+
+MOCK_IMPORT_FULL_CONFIG_HUB_V2 = MOCK_USER_INPUT_HUB_V2.copy()
+MOCK_IMPORT_FULL_CONFIG_HUB_V2[CONF_HUB_VERSION] = 2
+MOCK_IMPORT_FULL_CONFIG_HUB_V2[CONF_OVERRIDE] = [MOCK_DEVICE_OVERRIDE_CONFIG]
+MOCK_IMPORT_FULL_CONFIG_HUB_V2[CONF_X10] = [MOCK_X10_CONFIG_1, MOCK_X10_CONFIG_2]
+
+MOCK_IMPORT_FULL_CONFIG_HUB_V1 = MOCK_USER_INPUT_HUB_V1.copy()
+MOCK_IMPORT_FULL_CONFIG_HUB_V1[CONF_HUB_VERSION] = 1
+MOCK_IMPORT_FULL_CONFIG_HUB_V1[CONF_OVERRIDE] = [MOCK_DEVICE_OVERRIDE_CONFIG]
+MOCK_IMPORT_FULL_CONFIG_HUB_V1[CONF_X10] = [MOCK_X10_CONFIG_1, MOCK_X10_CONFIG_2]
+
+PATCH_CONNECTION = "homeassistant.components.insteon.config_flow.async_connect"
+PATCH_ASYNC_SETUP = "homeassistant.components.insteon.async_setup"
+PATCH_ASYNC_SETUP_ENTRY = "homeassistant.components.insteon.async_setup_entry"
diff --git a/tests/components/insteon/mock_devices.py b/tests/components/insteon/mock_devices.py
new file mode 100644
index 00000000000..e57d06d210c
--- /dev/null
+++ b/tests/components/insteon/mock_devices.py
@@ -0,0 +1,69 @@
+"""Mock devices object to test Insteon."""
+import logging
+
+from pyinsteon.address import Address
+from pyinsteon.device_types import (
+ GeneralController_MiniRemote_4,
+ Hub,
+ SwitchedLightingControl_SwitchLinc,
+)
+
+from tests.async_mock import AsyncMock, MagicMock
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class MockSwitchLinc(SwitchedLightingControl_SwitchLinc):
+ """Mock SwitchLinc device."""
+
+ @property
+ def operating_flags(self):
+ """Return no operating flags to force properties to be checked."""
+ return {}
+
+
+class MockDevices:
+ """Mock devices class."""
+
+ def __init__(self, connected=True):
+ """Init the MockDevices class."""
+ self._devices = {}
+ self.modem = None
+ self._connected = connected
+ self.async_save = AsyncMock()
+ self.add_x10_device = MagicMock()
+ self.set_id = MagicMock()
+
+ def __getitem__(self, address):
+ """Return a a device from the device address."""
+ return self._devices.get(address)
+
+ def __iter__(self):
+ """Return an iterator of device addresses."""
+ yield from self._devices
+
+ def __len__(self):
+ """Return the number of devices."""
+ return len(self._devices)
+
+ def get(self, address):
+ """Return a device from an address or None if not found."""
+ return self._devices.get(Address(address))
+
+ async def async_load(self, *args, **kwargs):
+ """Load the mock devices."""
+ if self._connected:
+ addr0 = Address("AA.AA.AA")
+ addr1 = Address("11.11.11")
+ addr2 = Address("22.22.22")
+ addr3 = Address("33.33.33")
+ self._devices[addr0] = Hub(addr0)
+ self._devices[addr1] = MockSwitchLinc(addr1, 0x02, 0x00)
+ self._devices[addr2] = GeneralController_MiniRemote_4(addr2, 0x00, 0x00)
+ self._devices[addr3] = SwitchedLightingControl_SwitchLinc(addr3, 0x02, 0x00)
+ for device in [self._devices[addr] for addr in [addr1, addr2, addr3]]:
+ device.async_read_config = AsyncMock()
+ for device in [self._devices[addr] for addr in [addr2, addr3]]:
+ device.async_status = AsyncMock()
+ self._devices[addr1].async_status = AsyncMock(side_effect=AttributeError)
+ self.modem = self._devices[addr0]
diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py
new file mode 100644
index 00000000000..f4a3806a891
--- /dev/null
+++ b/tests/components/insteon/test_config_flow.py
@@ -0,0 +1,605 @@
+"""Test the config flow for the Insteon integration."""
+
+from homeassistant import config_entries, data_entry_flow, setup
+from homeassistant.components.insteon.config_flow import (
+ HUB1,
+ HUB2,
+ MODEM_TYPE,
+ PLM,
+ STEP_ADD_OVERRIDE,
+ STEP_ADD_X10,
+ STEP_CHANGE_HUB_CONFIG,
+ STEP_HUB_V2,
+ STEP_REMOVE_OVERRIDE,
+ STEP_REMOVE_X10,
+)
+from homeassistant.components.insteon.const import (
+ CONF_CAT,
+ CONF_DIM_STEPS,
+ CONF_HOUSECODE,
+ CONF_HUB_VERSION,
+ CONF_OVERRIDE,
+ CONF_SUBCAT,
+ CONF_UNITCODE,
+ CONF_X10,
+ DOMAIN,
+)
+from homeassistant.const import (
+ CONF_ADDRESS,
+ CONF_DEVICE,
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_PLATFORM,
+ CONF_PORT,
+ CONF_USERNAME,
+)
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import (
+ MOCK_HOSTNAME,
+ MOCK_IMPORT_CONFIG_PLM,
+ MOCK_IMPORT_MINIMUM_HUB_V1,
+ MOCK_IMPORT_MINIMUM_HUB_V2,
+ MOCK_PASSWORD,
+ MOCK_USER_INPUT_HUB_V1,
+ MOCK_USER_INPUT_HUB_V2,
+ MOCK_USER_INPUT_PLM,
+ MOCK_USERNAME,
+ PATCH_ASYNC_SETUP,
+ PATCH_ASYNC_SETUP_ENTRY,
+ PATCH_CONNECTION,
+)
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+
+async def mock_successful_connection(*args, **kwargs):
+ """Return a successful connection."""
+ return True
+
+
+async def mock_failed_connection(*args, **kwargs):
+ """Return a failed connection."""
+ raise ConnectionError("Connection failed")
+
+
+async def _init_form(hass, modem_type):
+ """Run the user form."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {}
+
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {MODEM_TYPE: modem_type},
+ )
+ return result2
+
+
+async def _device_form(hass, flow_id, connection, user_input):
+ """Test the PLM, Hub v1 or Hub v2 form."""
+ with patch(PATCH_CONNECTION, new=connection,), patch(
+ PATCH_ASYNC_SETUP, return_value=True
+ ) as mock_setup, patch(
+ PATCH_ASYNC_SETUP_ENTRY,
+ return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_configure(flow_id, user_input)
+ return result, mock_setup, mock_setup_entry
+
+
+async def test_form_select_modem(hass: HomeAssistantType):
+ """Test we get a modem form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await _init_form(hass, HUB2)
+ assert result["step_id"] == STEP_HUB_V2
+ assert result["type"] == "form"
+
+
+async def test_fail_on_existing(hass: HomeAssistantType):
+ """Test we fail if the integration is already configured."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ entry_id="abcde12345",
+ data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2},
+ options={},
+ )
+ config_entry.add_to_hass(hass)
+ assert config_entry.state == config_entries.ENTRY_STATE_NOT_LOADED
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2},
+ context={"source": config_entries.SOURCE_USER},
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "single_instance_allowed"
+
+
+async def test_form_select_plm(hass: HomeAssistantType):
+ """Test we set up the PLM correctly."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await _init_form(hass, PLM)
+
+ result2, mock_setup, mock_setup_entry = await _device_form(
+ hass, result["flow_id"], mock_successful_connection, MOCK_USER_INPUT_PLM
+ )
+ assert result2["type"] == "create_entry"
+ assert result2["data"] == MOCK_USER_INPUT_PLM
+
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_select_hub_v1(hass: HomeAssistantType):
+ """Test we set up the Hub v1 correctly."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await _init_form(hass, HUB1)
+
+ result2, mock_setup, mock_setup_entry = await _device_form(
+ hass, result["flow_id"], mock_successful_connection, MOCK_USER_INPUT_HUB_V1
+ )
+ assert result2["type"] == "create_entry"
+ assert result2["data"] == {
+ **MOCK_USER_INPUT_HUB_V1,
+ CONF_HUB_VERSION: 1,
+ }
+
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_select_hub_v2(hass: HomeAssistantType):
+ """Test we set up the Hub v2 correctly."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await _init_form(hass, HUB2)
+
+ result2, mock_setup, mock_setup_entry = await _device_form(
+ hass, result["flow_id"], mock_successful_connection, MOCK_USER_INPUT_HUB_V2
+ )
+ assert result2["type"] == "create_entry"
+ assert result2["data"] == {
+ **MOCK_USER_INPUT_HUB_V2,
+ CONF_HUB_VERSION: 2,
+ }
+
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_failed_connection_plm(hass: HomeAssistantType):
+ """Test a failed connection with the PLM."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await _init_form(hass, PLM)
+
+ result2, _, _ = await _device_form(
+ hass, result["flow_id"], mock_failed_connection, MOCK_USER_INPUT_PLM
+ )
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def test_failed_connection_hub(hass: HomeAssistantType):
+ """Test a failed connection with a Hub."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await _init_form(hass, HUB2)
+
+ result2, _, _ = await _device_form(
+ hass, result["flow_id"], mock_failed_connection, MOCK_USER_INPUT_HUB_V2
+ )
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def _import_config(hass, config):
+ """Run the import step."""
+ with patch(PATCH_CONNECTION, new=mock_successful_connection,), patch(
+ PATCH_ASYNC_SETUP, return_value=True
+ ), patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True):
+ return await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config
+ )
+
+
+async def test_import_plm(hass: HomeAssistantType):
+ """Test importing a minimum PLM config from yaml."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ result = await _import_config(hass, MOCK_IMPORT_CONFIG_PLM)
+
+ assert result["type"] == "create_entry"
+ assert hass.config_entries.async_entries(DOMAIN)
+ for entry in hass.config_entries.async_entries(DOMAIN):
+ assert entry.data == MOCK_IMPORT_CONFIG_PLM
+
+
+async def _options_init_form(hass, entry_id, step):
+ """Run the init options form."""
+ with patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True):
+ result = await hass.config_entries.options.async_init(entry_id)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ result2 = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ {step: True},
+ )
+ return result2
+
+
+async def test_import_min_hub_v2(hass: HomeAssistantType):
+ """Test importing a minimum Hub v2 config from yaml."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ result = await _import_config(
+ hass, {**MOCK_IMPORT_MINIMUM_HUB_V2, CONF_PORT: 25105, CONF_HUB_VERSION: 2}
+ )
+
+ assert result["type"] == "create_entry"
+ assert hass.config_entries.async_entries(DOMAIN)
+ for entry in hass.config_entries.async_entries(DOMAIN):
+ assert entry.data[CONF_HOST] == MOCK_HOSTNAME
+ assert entry.data[CONF_PORT] == 25105
+ assert entry.data[CONF_USERNAME] == MOCK_USERNAME
+ assert entry.data[CONF_PASSWORD] == MOCK_PASSWORD
+ assert entry.data[CONF_HUB_VERSION] == 2
+
+
+async def test_import_min_hub_v1(hass: HomeAssistantType):
+ """Test importing a minimum Hub v1 config from yaml."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ result = await _import_config(
+ hass, {**MOCK_IMPORT_MINIMUM_HUB_V1, CONF_PORT: 9761, CONF_HUB_VERSION: 1}
+ )
+
+ assert result["type"] == "create_entry"
+ assert hass.config_entries.async_entries(DOMAIN)
+ for entry in hass.config_entries.async_entries(DOMAIN):
+ assert entry.data[CONF_HOST] == MOCK_HOSTNAME
+ assert entry.data[CONF_PORT] == 9761
+ assert entry.data[CONF_HUB_VERSION] == 1
+
+
+async def test_import_existing(hass: HomeAssistantType):
+ """Test we fail on an existing config imported."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ entry_id="abcde12345",
+ data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2},
+ options={},
+ )
+ config_entry.add_to_hass(hass)
+ assert config_entry.state == config_entries.ENTRY_STATE_NOT_LOADED
+
+ result = await _import_config(
+ hass, {**MOCK_IMPORT_MINIMUM_HUB_V2, CONF_PORT: 25105, CONF_HUB_VERSION: 2}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "single_instance_allowed"
+
+
+async def test_import_failed_connection(hass: HomeAssistantType):
+ """Test a failed connection on import."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ with patch(PATCH_CONNECTION, new=mock_failed_connection,), patch(
+ PATCH_ASYNC_SETUP, return_value=True
+ ), patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={**MOCK_IMPORT_MINIMUM_HUB_V2, CONF_PORT: 25105, CONF_HUB_VERSION: 2},
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "cannot_connect"
+
+
+async def _options_form(hass, flow_id, user_input):
+ """Test an options form."""
+
+ with patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True) as mock_setup_entry:
+ result = await hass.config_entries.options.async_configure(flow_id, user_input)
+ return result, mock_setup_entry
+
+
+async def test_options_change_hub_config(hass: HomeAssistantType):
+ """Test changing Hub v2 config."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ entry_id="abcde12345",
+ data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2},
+ options={},
+ )
+
+ config_entry.add_to_hass(hass)
+ result = await _options_init_form(
+ hass, config_entry.entry_id, STEP_CHANGE_HUB_CONFIG
+ )
+
+ user_input = {
+ CONF_HOST: "2.3.4.5",
+ CONF_PORT: 9999,
+ CONF_USERNAME: "new username",
+ CONF_PASSWORD: "new password",
+ }
+ result, _ = await _options_form(hass, result["flow_id"], user_input)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert config_entry.options == {}
+ assert config_entry.data == {**user_input, CONF_HUB_VERSION: 2}
+
+
+async def test_options_add_device_override(hass: HomeAssistantType):
+ """Test adding a device override."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ entry_id="abcde12345",
+ data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2},
+ options={},
+ )
+
+ config_entry.add_to_hass(hass)
+ result = await _options_init_form(hass, config_entry.entry_id, STEP_ADD_OVERRIDE)
+
+ user_input = {
+ CONF_ADDRESS: "1a2b3c",
+ CONF_CAT: "0x04",
+ CONF_SUBCAT: "0xaa",
+ }
+ result, _ = await _options_form(hass, result["flow_id"], user_input)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert len(config_entry.options[CONF_OVERRIDE]) == 1
+ assert config_entry.options[CONF_OVERRIDE][0][CONF_ADDRESS] == "1A.2B.3C"
+ assert config_entry.options[CONF_OVERRIDE][0][CONF_CAT] == 4
+ assert config_entry.options[CONF_OVERRIDE][0][CONF_SUBCAT] == 170
+
+ result2 = await _options_init_form(hass, config_entry.entry_id, STEP_ADD_OVERRIDE)
+
+ user_input = {
+ CONF_ADDRESS: "4d5e6f",
+ CONF_CAT: "05",
+ CONF_SUBCAT: "bb",
+ }
+ await _options_form(hass, result2["flow_id"], user_input)
+
+ assert len(config_entry.options[CONF_OVERRIDE]) == 2
+ assert config_entry.options[CONF_OVERRIDE][1][CONF_ADDRESS] == "4D.5E.6F"
+ assert config_entry.options[CONF_OVERRIDE][1][CONF_CAT] == 5
+ assert config_entry.options[CONF_OVERRIDE][1][CONF_SUBCAT] == 187
+
+
+async def test_options_remove_device_override(hass: HomeAssistantType):
+ """Test removing a device override."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ entry_id="abcde12345",
+ data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2},
+ options={
+ CONF_OVERRIDE: [
+ {CONF_ADDRESS: "1A.2B.3C", CONF_CAT: 6, CONF_SUBCAT: 100},
+ {CONF_ADDRESS: "4D.5E.6F", CONF_CAT: 7, CONF_SUBCAT: 200},
+ ]
+ },
+ )
+
+ config_entry.add_to_hass(hass)
+ result = await _options_init_form(hass, config_entry.entry_id, STEP_REMOVE_OVERRIDE)
+
+ user_input = {CONF_ADDRESS: "1A.2B.3C"}
+ result, _ = await _options_form(hass, result["flow_id"], user_input)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert len(config_entry.options[CONF_OVERRIDE]) == 1
+
+
+async def test_options_remove_device_override_with_x10(hass: HomeAssistantType):
+ """Test removing a device override when an X10 device is configured."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ entry_id="abcde12345",
+ data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2},
+ options={
+ CONF_OVERRIDE: [
+ {CONF_ADDRESS: "1A.2B.3C", CONF_CAT: 6, CONF_SUBCAT: 100},
+ {CONF_ADDRESS: "4D.5E.6F", CONF_CAT: 7, CONF_SUBCAT: 200},
+ ],
+ CONF_X10: [
+ {
+ CONF_HOUSECODE: "d",
+ CONF_UNITCODE: 5,
+ CONF_PLATFORM: "light",
+ CONF_DIM_STEPS: 22,
+ }
+ ],
+ },
+ )
+
+ config_entry.add_to_hass(hass)
+ result = await _options_init_form(hass, config_entry.entry_id, STEP_REMOVE_OVERRIDE)
+
+ user_input = {CONF_ADDRESS: "1A.2B.3C"}
+ result, _ = await _options_form(hass, result["flow_id"], user_input)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert len(config_entry.options[CONF_OVERRIDE]) == 1
+ assert len(config_entry.options[CONF_X10]) == 1
+
+
+async def test_options_add_x10_device(hass: HomeAssistantType):
+ """Test adding an X10 device."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ entry_id="abcde12345",
+ data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2},
+ options={},
+ )
+
+ config_entry.add_to_hass(hass)
+ result = await _options_init_form(hass, config_entry.entry_id, STEP_ADD_X10)
+
+ user_input = {
+ CONF_HOUSECODE: "c",
+ CONF_UNITCODE: 12,
+ CONF_PLATFORM: "light",
+ CONF_DIM_STEPS: 18,
+ }
+ result2, _ = await _options_form(hass, result["flow_id"], user_input)
+
+ assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert len(config_entry.options[CONF_X10]) == 1
+ assert config_entry.options[CONF_X10][0][CONF_HOUSECODE] == "c"
+ assert config_entry.options[CONF_X10][0][CONF_UNITCODE] == 12
+ assert config_entry.options[CONF_X10][0][CONF_PLATFORM] == "light"
+ assert config_entry.options[CONF_X10][0][CONF_DIM_STEPS] == 18
+
+ result = await _options_init_form(hass, config_entry.entry_id, STEP_ADD_X10)
+ user_input = {
+ CONF_HOUSECODE: "d",
+ CONF_UNITCODE: 10,
+ CONF_PLATFORM: "binary_sensor",
+ CONF_DIM_STEPS: 15,
+ }
+ result3, _ = await _options_form(hass, result["flow_id"], user_input)
+
+ assert result3["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert len(config_entry.options[CONF_X10]) == 2
+ assert config_entry.options[CONF_X10][1][CONF_HOUSECODE] == "d"
+ assert config_entry.options[CONF_X10][1][CONF_UNITCODE] == 10
+ assert config_entry.options[CONF_X10][1][CONF_PLATFORM] == "binary_sensor"
+ assert config_entry.options[CONF_X10][1][CONF_DIM_STEPS] == 15
+
+
+async def test_options_remove_x10_device(hass: HomeAssistantType):
+ """Test removing an X10 device."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ entry_id="abcde12345",
+ data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2},
+ options={
+ CONF_X10: [
+ {
+ CONF_HOUSECODE: "C",
+ CONF_UNITCODE: 4,
+ CONF_PLATFORM: "light",
+ CONF_DIM_STEPS: 18,
+ },
+ {
+ CONF_HOUSECODE: "D",
+ CONF_UNITCODE: 10,
+ CONF_PLATFORM: "binary_sensor",
+ CONF_DIM_STEPS: 15,
+ },
+ ]
+ },
+ )
+
+ config_entry.add_to_hass(hass)
+ result = await _options_init_form(hass, config_entry.entry_id, STEP_REMOVE_X10)
+
+ for device in config_entry.options[CONF_X10]:
+ housecode = device[CONF_HOUSECODE].upper()
+ unitcode = device[CONF_UNITCODE]
+ print(f"Housecode: {housecode}, Unitcode: {unitcode}")
+
+ user_input = {CONF_DEVICE: "Housecode: C, Unitcode: 4"}
+ result, _ = await _options_form(hass, result["flow_id"], user_input)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert len(config_entry.options[CONF_X10]) == 1
+
+
+async def test_options_remove_x10_device_with_override(hass: HomeAssistantType):
+ """Test removing an X10 device when a device override is configured."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ entry_id="abcde12345",
+ data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2},
+ options={
+ CONF_X10: [
+ {
+ CONF_HOUSECODE: "C",
+ CONF_UNITCODE: 4,
+ CONF_PLATFORM: "light",
+ CONF_DIM_STEPS: 18,
+ },
+ {
+ CONF_HOUSECODE: "D",
+ CONF_UNITCODE: 10,
+ CONF_PLATFORM: "binary_sensor",
+ CONF_DIM_STEPS: 15,
+ },
+ ],
+ CONF_OVERRIDE: [{CONF_ADDRESS: "1A.2B.3C", CONF_CAT: 1, CONF_SUBCAT: 18}],
+ },
+ )
+
+ config_entry.add_to_hass(hass)
+ result = await _options_init_form(hass, config_entry.entry_id, STEP_REMOVE_X10)
+
+ for device in config_entry.options[CONF_X10]:
+ housecode = device[CONF_HOUSECODE].upper()
+ unitcode = device[CONF_UNITCODE]
+ print(f"Housecode: {housecode}, Unitcode: {unitcode}")
+
+ user_input = {CONF_DEVICE: "Housecode: C, Unitcode: 4"}
+ result, _ = await _options_form(hass, result["flow_id"], user_input)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert len(config_entry.options[CONF_X10]) == 1
+ assert len(config_entry.options[CONF_OVERRIDE]) == 1
+
+
+async def test_options_dup_selection(hass: HomeAssistantType):
+ """Test if a duplicate selection was made in options."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ entry_id="abcde12345",
+ data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2},
+ options={},
+ )
+ config_entry.add_to_hass(hass)
+ result = await hass.config_entries.options.async_init(config_entry.entry_id)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ result2 = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ {STEP_ADD_OVERRIDE: True, STEP_ADD_X10: True},
+ )
+ assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result2["errors"] == {"base": "select_single"}
+
+
+async def test_options_override_bad_data(hass: HomeAssistantType):
+ """Test for bad data in a device override."""
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ entry_id="abcde12345",
+ data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2},
+ options={},
+ )
+
+ config_entry.add_to_hass(hass)
+ result = await _options_init_form(hass, config_entry.entry_id, STEP_ADD_OVERRIDE)
+
+ user_input = {
+ CONF_ADDRESS: "zzzzzz",
+ CONF_CAT: "bad",
+ CONF_SUBCAT: "data",
+ }
+ result, _ = await _options_form(hass, result["flow_id"], user_input)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "input_error"}
diff --git a/tests/components/insteon/test_init.py b/tests/components/insteon/test_init.py
new file mode 100644
index 00000000000..eed1ee76e8c
--- /dev/null
+++ b/tests/components/insteon/test_init.py
@@ -0,0 +1,227 @@
+"""Test the init file for the Insteon component."""
+import asyncio
+import logging
+
+from pyinsteon.address import Address
+
+from homeassistant.components import insteon
+from homeassistant.components.insteon.const import (
+ CONF_CAT,
+ CONF_OVERRIDE,
+ CONF_SUBCAT,
+ CONF_X10,
+ DOMAIN,
+ PORT_HUB_V1,
+ PORT_HUB_V2,
+)
+from homeassistant.const import (
+ CONF_ADDRESS,
+ CONF_DEVICE,
+ CONF_HOST,
+ CONF_PASSWORD,
+ CONF_PORT,
+ CONF_USERNAME,
+ EVENT_HOMEASSISTANT_STOP,
+)
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.setup import async_setup_component
+
+from .const import (
+ MOCK_ADDRESS,
+ MOCK_CAT,
+ MOCK_IMPORT_CONFIG_PLM,
+ MOCK_IMPORT_FULL_CONFIG_HUB_V1,
+ MOCK_IMPORT_FULL_CONFIG_HUB_V2,
+ MOCK_IMPORT_FULL_CONFIG_PLM,
+ MOCK_IMPORT_MINIMUM_HUB_V1,
+ MOCK_IMPORT_MINIMUM_HUB_V2,
+ MOCK_SUBCAT,
+ MOCK_USER_INPUT_PLM,
+ PATCH_CONNECTION,
+)
+from .mock_devices import MockDevices
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def mock_successful_connection(*args, **kwargs):
+ """Return a successful connection."""
+ return True
+
+
+async def mock_failed_connection(*args, **kwargs):
+ """Return a failed connection."""
+ raise ConnectionError("Connection failed")
+
+
+async def test_setup_entry(hass: HomeAssistantType):
+ """Test setting up the entry."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT_PLM)
+ config_entry.add_to_hass(hass)
+
+ with patch.object(
+ insteon, "async_connect", new=mock_successful_connection
+ ), patch.object(insteon, "async_close") as mock_close, patch.object(
+ insteon, "devices", new=MockDevices()
+ ):
+ assert await async_setup_component(
+ hass,
+ insteon.DOMAIN,
+ {},
+ )
+ await hass.async_block_till_done()
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
+ await hass.async_block_till_done()
+ # pylint: disable=no-member
+ assert insteon.devices.async_save.call_count == 1
+ assert mock_close.called
+
+
+async def test_import_plm(hass: HomeAssistantType):
+ """Test setting up the entry from YAML to a PLM."""
+ config = {}
+ config[DOMAIN] = MOCK_IMPORT_CONFIG_PLM
+
+ with patch.object(
+ insteon, "async_connect", new=mock_successful_connection
+ ), patch.object(insteon, "close_insteon_connection"), patch.object(
+ insteon, "devices", new=MockDevices()
+ ), patch(
+ PATCH_CONNECTION, new=mock_successful_connection
+ ):
+ assert await async_setup_component(
+ hass,
+ insteon.DOMAIN,
+ config,
+ )
+ await hass.async_block_till_done()
+ await asyncio.sleep(0.01)
+ assert hass.config_entries.async_entries(DOMAIN)
+ data = hass.config_entries.async_entries(DOMAIN)[0].data
+ assert data[CONF_DEVICE] == MOCK_IMPORT_CONFIG_PLM[CONF_PORT]
+ assert CONF_PORT not in data
+
+
+async def test_import_hub1(hass: HomeAssistantType):
+ """Test setting up the entry from YAML to a hub v1."""
+ config = {}
+ config[DOMAIN] = MOCK_IMPORT_MINIMUM_HUB_V1
+
+ with patch.object(
+ insteon, "async_connect", new=mock_successful_connection
+ ), patch.object(insteon, "close_insteon_connection"), patch.object(
+ insteon, "devices", new=MockDevices()
+ ), patch(
+ PATCH_CONNECTION, new=mock_successful_connection
+ ):
+ assert await async_setup_component(
+ hass,
+ insteon.DOMAIN,
+ config,
+ )
+ await hass.async_block_till_done()
+ await asyncio.sleep(0.01)
+ assert hass.config_entries.async_entries(DOMAIN)
+ data = hass.config_entries.async_entries(DOMAIN)[0].data
+ assert data[CONF_HOST] == MOCK_IMPORT_FULL_CONFIG_HUB_V1[CONF_HOST]
+ assert data[CONF_PORT] == PORT_HUB_V1
+ assert CONF_USERNAME not in data
+ assert CONF_PASSWORD not in data
+
+
+async def test_import_hub2(hass: HomeAssistantType):
+ """Test setting up the entry from YAML to a hub v2."""
+ config = {}
+ config[DOMAIN] = MOCK_IMPORT_MINIMUM_HUB_V2
+
+ with patch.object(
+ insteon, "async_connect", new=mock_successful_connection
+ ), patch.object(insteon, "close_insteon_connection"), patch.object(
+ insteon, "devices", new=MockDevices()
+ ), patch(
+ PATCH_CONNECTION, new=mock_successful_connection
+ ):
+ assert await async_setup_component(
+ hass,
+ insteon.DOMAIN,
+ config,
+ )
+ await hass.async_block_till_done()
+ await asyncio.sleep(0.01)
+ assert hass.config_entries.async_entries(DOMAIN)
+ data = hass.config_entries.async_entries(DOMAIN)[0].data
+ assert data[CONF_HOST] == MOCK_IMPORT_FULL_CONFIG_HUB_V2[CONF_HOST]
+ assert data[CONF_PORT] == PORT_HUB_V2
+ assert data[CONF_USERNAME] == MOCK_IMPORT_MINIMUM_HUB_V2[CONF_USERNAME]
+ assert data[CONF_PASSWORD] == MOCK_IMPORT_MINIMUM_HUB_V2[CONF_PASSWORD]
+
+
+async def test_import_options(hass: HomeAssistantType):
+ """Test setting up the entry from YAML including options."""
+ config = {}
+ config[DOMAIN] = MOCK_IMPORT_FULL_CONFIG_PLM
+
+ with patch.object(
+ insteon, "async_connect", new=mock_successful_connection
+ ), patch.object(insteon, "close_insteon_connection"), patch.object(
+ insteon, "devices", new=MockDevices()
+ ), patch(
+ PATCH_CONNECTION, new=mock_successful_connection
+ ):
+ assert await async_setup_component(
+ hass,
+ insteon.DOMAIN,
+ config,
+ )
+ await hass.async_block_till_done()
+ await asyncio.sleep(0.01) # Need to yield to async processes
+ # pylint: disable=no-member
+ assert insteon.devices.add_x10_device.call_count == 2
+ assert insteon.devices.set_id.call_count == 1
+ options = hass.config_entries.async_entries(DOMAIN)[0].options
+ assert len(options[CONF_OVERRIDE]) == 1
+ assert options[CONF_OVERRIDE][0][CONF_ADDRESS] == str(Address(MOCK_ADDRESS))
+ assert options[CONF_OVERRIDE][0][CONF_CAT] == MOCK_CAT
+ assert options[CONF_OVERRIDE][0][CONF_SUBCAT] == MOCK_SUBCAT
+
+ assert len(options[CONF_X10]) == 2
+ assert options[CONF_X10][0] == MOCK_IMPORT_FULL_CONFIG_PLM[CONF_X10][0]
+ assert options[CONF_X10][1] == MOCK_IMPORT_FULL_CONFIG_PLM[CONF_X10][1]
+
+
+async def test_import_failed_connection(hass: HomeAssistantType):
+ """Test a failed connection in import does not create a config entry."""
+ config = {}
+ config[DOMAIN] = MOCK_IMPORT_CONFIG_PLM
+
+ with patch.object(
+ insteon, "async_connect", new=mock_failed_connection
+ ), patch.object(insteon, "async_close"), patch.object(
+ insteon, "devices", new=MockDevices(connected=False)
+ ):
+ assert await async_setup_component(
+ hass,
+ insteon.DOMAIN,
+ config,
+ )
+ await hass.async_block_till_done()
+ assert not hass.config_entries.async_entries(DOMAIN)
+
+
+async def test_setup_entry_failed_connection(hass: HomeAssistantType, caplog):
+ """Test setting up the entry with a failed connection."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_INPUT_PLM)
+ config_entry.add_to_hass(hass)
+
+ with patch.object(
+ insteon, "async_connect", new=mock_failed_connection
+ ), patch.object(insteon, "devices", new=MockDevices(connected=False)):
+ assert await async_setup_component(
+ hass,
+ insteon.DOMAIN,
+ {},
+ )
+ assert "Could not connect to Insteon modem" in caplog.text
diff --git a/tests/components/ipma/test_config_flow.py b/tests/components/ipma/test_config_flow.py
index b3b8968f451..b45c4e89529 100644
--- a/tests/components/ipma/test_config_flow.py
+++ b/tests/components/ipma/test_config_flow.py
@@ -79,7 +79,9 @@ async def test_flow_entry_created_from_user_input():
with patch(
"homeassistant.components.ipma.config_flow.IpmaFlowHandler._show_config_form"
) as config_form, patch.object(
- flow.hass.config_entries, "async_entries", return_value=[],
+ flow.hass.config_entries,
+ "async_entries",
+ return_value=[],
) as config_entries:
result = await flow.async_step_user(user_input=test_data)
diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py
index c7a8bbda63f..bfe7eefbb4c 100644
--- a/tests/components/ipma/test_weather.py
+++ b/tests/components/ipma/test_weather.py
@@ -5,7 +5,7 @@ from homeassistant.components import weather
from homeassistant.components.weather import (
ATTR_FORECAST,
ATTR_FORECAST_CONDITION,
- ATTR_FORECAST_PRECIPITATION,
+ ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TEMP,
ATTR_FORECAST_TEMP_LOW,
ATTR_FORECAST_TIME,
@@ -192,7 +192,7 @@ async def test_daily_forecast(hass):
assert forecast.get(ATTR_FORECAST_CONDITION) == "rainy"
assert forecast.get(ATTR_FORECAST_TEMP) == 16.2
assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 10.6
- assert forecast.get(ATTR_FORECAST_PRECIPITATION) == "100.0"
+ assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == "100.0"
assert forecast.get(ATTR_FORECAST_WIND_SPEED) == "10"
assert forecast.get(ATTR_FORECAST_WIND_BEARING) == "S"
@@ -216,6 +216,6 @@ async def test_hourly_forecast(hass):
forecast = state.attributes.get(ATTR_FORECAST)[0]
assert forecast.get(ATTR_FORECAST_CONDITION) == "rainy"
assert forecast.get(ATTR_FORECAST_TEMP) == 7.7
- assert forecast.get(ATTR_FORECAST_PRECIPITATION) == "80.0"
+ assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 80.0
assert forecast.get(ATTR_FORECAST_WIND_SPEED) == "32.7"
assert forecast.get(ATTR_FORECAST_WIND_BEARING) == "S"
diff --git a/tests/components/ipp/test_config_flow.py b/tests/components/ipp/test_config_flow.py
index 7133bf3cde7..19ee2f73231 100644
--- a/tests/components/ipp/test_config_flow.py
+++ b/tests/components/ipp/test_config_flow.py
@@ -24,7 +24,8 @@ 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(
- DOMAIN, context={"source": SOURCE_USER},
+ DOMAIN,
+ context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
@@ -39,7 +40,9 @@ async def test_show_zeroconf_form(
discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
)
assert result["step_id"] == "zeroconf_confirm"
@@ -55,7 +58,9 @@ async def test_connection_error(
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=user_input,
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=user_input,
)
assert result["step_id"] == "user"
@@ -71,7 +76,9 @@ async def test_zeroconf_connection_error(
discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -101,7 +108,9 @@ async def test_user_connection_upgrade_required(
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=user_input,
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=user_input,
)
assert result["step_id"] == "user"
@@ -117,7 +126,9 @@ async def test_zeroconf_connection_upgrade_required(
discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -132,7 +143,9 @@ async def test_user_parse_error(
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=user_input,
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=user_input,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -147,7 +160,9 @@ async def test_zeroconf_parse_error(
discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -162,7 +177,9 @@ async def test_user_ipp_error(
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=user_input,
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=user_input,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -177,7 +194,9 @@ async def test_zeroconf_ipp_error(
discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -192,7 +211,9 @@ async def test_user_ipp_version_error(
user_input = {**MOCK_USER_INPUT}
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=user_input,
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=user_input,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -207,7 +228,9 @@ async def test_zeroconf_ipp_version_error(
discovery_info = {**MOCK_ZEROCONF_IPP_SERVICE_INFO}
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -222,7 +245,9 @@ async def test_user_device_exists_abort(
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=user_input,
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=user_input,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -237,7 +262,9 @@ async def test_zeroconf_device_exists_abort(
discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -258,7 +285,9 @@ async def test_zeroconf_with_uuid_device_exists_abort(
},
}
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -276,7 +305,9 @@ async def test_zeroconf_empty_unique_id(
"properties": {**MOCK_ZEROCONF_IPP_SERVICE_INFO["properties"], "UUID": ""},
}
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
)
assert result["type"] == RESULT_TYPE_FORM
@@ -290,7 +321,9 @@ async def test_zeroconf_no_unique_id(
discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
)
assert result["type"] == RESULT_TYPE_FORM
@@ -303,7 +336,8 @@ async def test_full_user_flow_implementation(
mock_connection(aioclient_mock)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER},
+ DOMAIN,
+ context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
@@ -336,7 +370,9 @@ async def test_full_zeroconf_flow_implementation(
discovery_info = MOCK_ZEROCONF_IPP_SERVICE_INFO.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
)
assert result["step_id"] == "zeroconf_confirm"
@@ -370,7 +406,9 @@ async def test_full_zeroconf_tls_flow_implementation(
discovery_info = MOCK_ZEROCONF_IPPS_SERVICE_INFO.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info,
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=discovery_info,
)
assert result["step_id"] == "zeroconf_confirm"
diff --git a/tests/components/ipp/test_sensor.py b/tests/components/ipp/test_sensor.py
index 1abf557c93b..1817a66f630 100644
--- a/tests/components/ipp/test_sensor.py
+++ b/tests/components/ipp/test_sensor.py
@@ -3,7 +3,7 @@ from datetime import datetime
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.const import ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
@@ -43,31 +43,31 @@ async def test_sensors(
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.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is 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.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is 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.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is 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.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is 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.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is PERCENTAGE
assert state.state == "73"
state = hass.states.get("sensor.epson_xp_6000_series_uptime")
diff --git a/tests/components/iqvia/test_config_flow.py b/tests/components/iqvia/test_config_flow.py
index 6b6872d5f67..209f88cf895 100644
--- a/tests/components/iqvia/test_config_flow.py
+++ b/tests/components/iqvia/test_config_flow.py
@@ -47,9 +47,7 @@ async def test_step_user(hass):
"""Test that the user step works (without MFA)."""
conf = {CONF_ZIP_CODE: "12345"}
- with patch(
- "homeassistant.components.simplisafe.async_setup_entry", return_value=True
- ):
+ with patch("homeassistant.components.iqvia.async_setup_entry", return_value=True):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=conf
)
diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py
index 204075ecb8c..4ffd6e4f25d 100644
--- a/tests/components/islamic_prayer_times/test_config_flow.py
+++ b/tests/components/islamic_prayer_times/test_config_flow.py
@@ -72,7 +72,11 @@ async def test_import(hass):
async def test_integration_already_configured(hass):
"""Test integration is already configured."""
- entry = MockConfigEntry(domain=DOMAIN, data={}, options={},)
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={},
+ options={},
+ )
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
islamic_prayer_times.DOMAIN, context={"source": "user"}
diff --git a/tests/components/islamic_prayer_times/test_init.py b/tests/components/islamic_prayer_times/test_init.py
index 984bbbf7c75..fd0de854056 100644
--- a/tests/components/islamic_prayer_times/test_init.py
+++ b/tests/components/islamic_prayer_times/test_init.py
@@ -39,7 +39,10 @@ async def test_setup_with_config(hass, legacy_patchable_time):
async def test_successful_config_entry(hass, legacy_patchable_time):
"""Test that Islamic Prayer Times is configured successfully."""
- entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={},)
+ entry = MockConfigEntry(
+ domain=islamic_prayer_times.DOMAIN,
+ data={},
+ )
entry.add_to_hass(hass)
with patch(
@@ -58,7 +61,10 @@ async def test_successful_config_entry(hass, legacy_patchable_time):
async def test_setup_failed(hass, legacy_patchable_time):
"""Test Islamic Prayer Times failed due to an error."""
- entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={},)
+ entry = MockConfigEntry(
+ domain=islamic_prayer_times.DOMAIN,
+ data={},
+ )
entry.add_to_hass(hass)
# test request error raising ConfigEntryNotReady
@@ -73,7 +79,10 @@ async def test_setup_failed(hass, legacy_patchable_time):
async def test_unload_entry(hass, legacy_patchable_time):
"""Test removing Islamic Prayer Times."""
- entry = MockConfigEntry(domain=islamic_prayer_times.DOMAIN, data={},)
+ entry = MockConfigEntry(
+ domain=islamic_prayer_times.DOMAIN,
+ data={},
+ )
entry.add_to_hass(hass)
with patch(
diff --git a/tests/components/isy994/test_config_flow.py b/tests/components/isy994/test_config_flow.py
index 55311494d30..cbbc7427f9f 100644
--- a/tests/components/isy994/test_config_flow.py
+++ b/tests/components/isy994/test_config_flow.py
@@ -83,13 +83,15 @@ async def test_form(hass: HomeAssistantType):
) as mock_connection_class, patch(
PATCH_ASYNC_SETUP, return_value=True
) as mock_setup, patch(
- PATCH_ASYNC_SETUP_ENTRY, return_value=True,
+ PATCH_ASYNC_SETUP_ENTRY,
+ return_value=True,
) as mock_setup_entry:
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = None
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], MOCK_USER_INPUT,
+ result["flow_id"],
+ MOCK_USER_INPUT,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result2["title"] == f"{MOCK_DEVICE_NAME} ({MOCK_HOSTNAME})"
@@ -126,10 +128,12 @@ async def test_form_invalid_auth(hass: HomeAssistantType):
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(PATCH_CONFIGURATION), patch(
- PATCH_CONNECTION, side_effect=ValueError("PyISY could not connect to the ISY."),
+ PATCH_CONNECTION,
+ side_effect=ValueError("PyISY could not connect to the ISY."),
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], MOCK_USER_INPUT,
+ result["flow_id"],
+ MOCK_USER_INPUT,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -142,10 +146,12 @@ async def test_form_cannot_connect(hass: HomeAssistantType):
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(PATCH_CONFIGURATION), patch(
- PATCH_CONNECTION, side_effect=CannotConnect,
+ PATCH_CONNECTION,
+ side_effect=CannotConnect,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], MOCK_USER_INPUT,
+ result["flow_id"],
+ MOCK_USER_INPUT,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -169,7 +175,8 @@ async def test_form_existing_config_entry(hass: HomeAssistantType):
isy_conn.get_config.return_value = None
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], MOCK_USER_INPUT,
+ result["flow_id"],
+ MOCK_USER_INPUT,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_ABORT
@@ -179,13 +186,16 @@ async def test_import_flow_some_fields(hass: HomeAssistantType) -> None:
with patch(PATCH_CONFIGURATION) as mock_config_class, patch(
PATCH_CONNECTION
) as mock_connection_class, patch(PATCH_ASYNC_SETUP, return_value=True), patch(
- PATCH_ASYNC_SETUP_ENTRY, return_value=True,
+ PATCH_ASYNC_SETUP_ENTRY,
+ return_value=True,
):
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = None
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_IMPORT_BASIC_CONFIG,
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=MOCK_IMPORT_BASIC_CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -200,13 +210,16 @@ async def test_import_flow_with_https(hass: HomeAssistantType) -> None:
with patch(PATCH_CONFIGURATION) as mock_config_class, patch(
PATCH_CONNECTION
) as mock_connection_class, patch(PATCH_ASYNC_SETUP, return_value=True), patch(
- PATCH_ASYNC_SETUP_ENTRY, return_value=True,
+ PATCH_ASYNC_SETUP_ENTRY,
+ return_value=True,
):
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = None
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_IMPORT_WITH_SSL,
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=MOCK_IMPORT_WITH_SSL,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -220,13 +233,16 @@ async def test_import_flow_all_fields(hass: HomeAssistantType) -> None:
with patch(PATCH_CONFIGURATION) as mock_config_class, patch(
PATCH_CONNECTION
) as mock_connection_class, patch(PATCH_ASYNC_SETUP, return_value=True), patch(
- PATCH_ASYNC_SETUP_ENTRY, return_value=True,
+ PATCH_ASYNC_SETUP_ENTRY,
+ return_value=True,
):
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = None
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_IMPORT_FULL_CONFIG,
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=MOCK_IMPORT_FULL_CONFIG,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -284,13 +300,15 @@ async def test_form_ssdp(hass: HomeAssistantType):
) as mock_connection_class, patch(
PATCH_ASYNC_SETUP, return_value=True
) as mock_setup, patch(
- PATCH_ASYNC_SETUP_ENTRY, return_value=True,
+ PATCH_ASYNC_SETUP_ENTRY,
+ return_value=True,
) as mock_setup_entry:
isy_conn = mock_connection_class.return_value
isy_conn.get_config.return_value = None
mock_config_class.return_value = MOCK_VALIDATED_RESPONSE
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], MOCK_USER_INPUT,
+ result["flow_id"],
+ MOCK_USER_INPUT,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
diff --git a/tests/components/izone/test_config_flow.py b/tests/components/izone/test_config_flow.py
index 72b80e75bcf..df5ec18db8a 100644
--- a/tests/components/izone/test_config_flow.py
+++ b/tests/components/izone/test_config_flow.py
@@ -57,7 +57,8 @@ async def test_found(hass, mock_disco):
mock_disco.pi_disco.controllers["blah"] = object()
with patch(
- "homeassistant.components.izone.climate.async_setup_entry", return_value=True,
+ "homeassistant.components.izone.climate.async_setup_entry",
+ return_value=True,
) as mock_setup, patch(
"homeassistant.components.izone.config_flow.async_start_discovery_service"
) as start_disco, patch(
diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py
index 8e18579b197..c5fc2683923 100644
--- a/tests/components/jewish_calendar/__init__.py
+++ b/tests/components/jewish_calendar/__init__.py
@@ -10,6 +10,7 @@ from tests.async_mock import patch
_LatLng = namedtuple("_LatLng", ["lat", "lng"])
+HDATE_DEFAULT_ALTITUDE = 754
NYC_LATLNG = _LatLng(40.7128, -74.0060)
JERUSALEM_LATLNG = _LatLng(31.778, 35.235)
diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py
index b9b980d29c2..8986c2e6f53 100644
--- a/tests/components/jewish_calendar/test_binary_sensor.py
+++ b/tests/components/jewish_calendar/test_binary_sensor.py
@@ -8,7 +8,12 @@ from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
-from . import alter_time, make_jerusalem_test_params, make_nyc_test_params
+from . import (
+ HDATE_DEFAULT_ALTITUDE,
+ alter_time,
+ make_jerusalem_test_params,
+ make_nyc_test_params,
+)
from tests.common import async_fire_time_changed
@@ -79,6 +84,8 @@ async def test_issur_melacha_sensor(
hass.config.latitude = latitude
hass.config.longitude = longitude
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
with alter_time(test_time):
assert await async_setup_component(
hass,
@@ -103,3 +110,21 @@ async def test_issur_melacha_sensor(
hass.states.get("binary_sensor.test_issur_melacha_in_effect").state
== result
)
+ entity = registry.async_get("binary_sensor.test_issur_melacha_in_effect")
+ target_uid = "_".join(
+ map(
+ str,
+ [
+ latitude,
+ longitude,
+ time_zone,
+ HDATE_DEFAULT_ALTITUDE,
+ diaspora,
+ "english",
+ candle_lighting,
+ havdalah,
+ "issur_melacha_in_effect",
+ ],
+ )
+ )
+ assert entity.unique_id == target_uid
diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py
index 60def2e09d2..1c771c71a3a 100644
--- a/tests/components/jewish_calendar/test_sensor.py
+++ b/tests/components/jewish_calendar/test_sensor.py
@@ -7,7 +7,12 @@ from homeassistant.components import jewish_calendar
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
-from . import alter_time, make_jerusalem_test_params, make_nyc_test_params
+from . import (
+ HDATE_DEFAULT_ALTITUDE,
+ alter_time,
+ make_jerusalem_test_params,
+ make_nyc_test_params,
+)
from tests.common import async_fire_time_changed
@@ -506,6 +511,8 @@ async def test_shabbat_times_sensor(
hass.config.latitude = latitude
hass.config.longitude = longitude
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
with alter_time(test_time):
assert await async_setup_component(
hass,
@@ -543,6 +550,26 @@ async def test_shabbat_times_sensor(
result_value
), f"Value for {sensor_type}"
+ entity = registry.async_get(f"sensor.test_{sensor_type}")
+ target_sensor_type = sensor_type.replace("parshat_hashavua", "weekly_portion")
+ target_uid = "_".join(
+ map(
+ str,
+ [
+ latitude,
+ longitude,
+ time_zone,
+ HDATE_DEFAULT_ALTITUDE,
+ diaspora,
+ language,
+ candle_lighting,
+ havdalah,
+ target_sensor_type,
+ ],
+ )
+ )
+ assert entity.unique_id == target_uid
+
OMER_PARAMS = [
(dt(2019, 4, 21, 0), "1"),
diff --git a/tests/components/kodi/__init__.py b/tests/components/kodi/__init__.py
new file mode 100644
index 00000000000..31c9dff14ac
--- /dev/null
+++ b/tests/components/kodi/__init__.py
@@ -0,0 +1,42 @@
+"""Tests for the Kodi integration."""
+from homeassistant.components.kodi.const import CONF_WS_PORT, DOMAIN
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_NAME,
+ CONF_PASSWORD,
+ CONF_PORT,
+ CONF_SSL,
+ CONF_USERNAME,
+)
+
+from .util import MockConnection
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+
+async def init_integration(hass) -> MockConfigEntry:
+ """Set up the Kodi integration in Home Assistant."""
+ entry_data = {
+ CONF_NAME: "name",
+ CONF_HOST: "1.1.1.1",
+ CONF_PORT: 8080,
+ CONF_WS_PORT: 9090,
+ CONF_USERNAME: "user",
+ CONF_PASSWORD: "pass",
+ CONF_SSL: False,
+ }
+ entry = MockConfigEntry(domain=DOMAIN, data=entry_data, title="name")
+ entry.add_to_hass(hass)
+
+ with patch("homeassistant.components.kodi.Kodi.ping", return_value=True), patch(
+ "homeassistant.components.kodi.Kodi.get_application_properties",
+ return_value={"version": {"major": 1, "minor": 1}},
+ ), patch(
+ "homeassistant.components.kodi.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ return entry
diff --git a/tests/components/kodi/test_config_flow.py b/tests/components/kodi/test_config_flow.py
new file mode 100644
index 00000000000..4fd61ede8ba
--- /dev/null
+++ b/tests/components/kodi/test_config_flow.py
@@ -0,0 +1,614 @@
+"""Test the Kodi config flow."""
+import pytest
+
+from homeassistant import config_entries
+from homeassistant.components.kodi.config_flow import (
+ CannotConnectError,
+ InvalidAuthError,
+)
+from homeassistant.components.kodi.const import DEFAULT_TIMEOUT, DOMAIN
+
+from .util import (
+ TEST_CREDENTIALS,
+ TEST_DISCOVERY,
+ TEST_HOST,
+ TEST_IMPORT,
+ TEST_WS_PORT,
+ UUID,
+ MockConnection,
+ MockWSConnection,
+ get_kodi_connection,
+)
+
+from tests.async_mock import AsyncMock, PropertyMock, patch
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture
+async def user_flow(hass):
+ """Return a user-initiated flow after filling in host info."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ return result["flow_id"]
+
+
+async def test_user_flow(hass, user_flow):
+ """Test a successful user initiated flow."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ), patch(
+ "homeassistant.components.kodi.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.kodi.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_configure(user_flow, TEST_HOST)
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == TEST_HOST["host"]
+ assert result["data"] == {
+ **TEST_HOST,
+ **TEST_WS_PORT,
+ "password": None,
+ "username": None,
+ "name": None,
+ "timeout": DEFAULT_TIMEOUT,
+ }
+
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_valid_auth(hass, user_flow):
+ """Test we handle valid auth."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ side_effect=InvalidAuthError,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ result = await hass.config_entries.flow.async_configure(user_flow, TEST_HOST)
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "credentials"
+ assert result["errors"] == {}
+
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ), patch(
+ "homeassistant.components.kodi.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.kodi.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_CREDENTIALS
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == TEST_HOST["host"]
+ assert result["data"] == {
+ **TEST_HOST,
+ **TEST_WS_PORT,
+ **TEST_CREDENTIALS,
+ "name": None,
+ "timeout": DEFAULT_TIMEOUT,
+ }
+
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_valid_ws_port(hass, user_flow):
+ """Test we handle valid websocket port."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ return_value=True,
+ ), patch.object(
+ MockWSConnection,
+ "connect",
+ AsyncMock(side_effect=CannotConnectError),
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ new=get_kodi_connection,
+ ):
+ result = await hass.config_entries.flow.async_configure(user_flow, TEST_HOST)
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "ws_port"
+ assert result["errors"] == {}
+
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ), patch(
+ "homeassistant.components.kodi.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.kodi.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_WS_PORT
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == TEST_HOST["host"]
+ assert result["data"] == {
+ **TEST_HOST,
+ **TEST_WS_PORT,
+ "password": None,
+ "username": None,
+ "name": None,
+ "timeout": DEFAULT_TIMEOUT,
+ }
+
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_invalid_auth(hass, user_flow):
+ """Test we handle invalid auth."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ side_effect=InvalidAuthError,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ result = await hass.config_entries.flow.async_configure(user_flow, TEST_HOST)
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "credentials"
+ assert result["errors"] == {}
+
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ side_effect=InvalidAuthError,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_CREDENTIALS
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "credentials"
+ assert result["errors"] == {"base": "invalid_auth"}
+
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ side_effect=CannotConnectError,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_CREDENTIALS
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "credentials"
+ assert result["errors"] == {"base": "cannot_connect"}
+
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ side_effect=Exception,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_CREDENTIALS
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "credentials"
+ assert result["errors"] == {"base": "unknown"}
+
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ return_value=True,
+ ), patch.object(
+ MockWSConnection,
+ "connect",
+ AsyncMock(side_effect=CannotConnectError),
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ new=get_kodi_connection,
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_CREDENTIALS
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "ws_port"
+ assert result["errors"] == {}
+
+
+async def test_form_cannot_connect_http(hass, user_flow):
+ """Test we handle cannot connect over HTTP error."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ side_effect=CannotConnectError,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ result = await hass.config_entries.flow.async_configure(user_flow, TEST_HOST)
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_exception_http(hass, user_flow):
+ """Test we handle generic exception over HTTP."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ side_effect=Exception,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ result = await hass.config_entries.flow.async_configure(user_flow, TEST_HOST)
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+ assert result["errors"] == {"base": "unknown"}
+
+
+async def test_form_cannot_connect_ws(hass, user_flow):
+ """Test we handle cannot connect over WebSocket error."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ return_value=True,
+ ), patch.object(
+ MockWSConnection,
+ "connect",
+ AsyncMock(side_effect=CannotConnectError),
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ new=get_kodi_connection,
+ ):
+ result = await hass.config_entries.flow.async_configure(user_flow, TEST_HOST)
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "ws_port"
+ assert result["errors"] == {}
+
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ return_value=True,
+ ), patch.object(
+ MockWSConnection, "connected", new_callable=PropertyMock(return_value=False)
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ new=get_kodi_connection,
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_WS_PORT
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "ws_port"
+ assert result["errors"] == {"base": "cannot_connect"}
+
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ side_effect=CannotConnectError,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ new=get_kodi_connection,
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_WS_PORT
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "ws_port"
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_exception_ws(hass, user_flow):
+ """Test we handle generic exception over WebSocket."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ return_value=True,
+ ), patch.object(
+ MockWSConnection,
+ "connect",
+ AsyncMock(side_effect=CannotConnectError),
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ new=get_kodi_connection,
+ ):
+ result = await hass.config_entries.flow.async_configure(user_flow, TEST_HOST)
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "ws_port"
+ assert result["errors"] == {}
+
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ return_value=True,
+ ), patch.object(
+ MockWSConnection, "connect", AsyncMock(side_effect=Exception)
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ new=get_kodi_connection,
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_WS_PORT
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "ws_port"
+ assert result["errors"] == {"base": "unknown"}
+
+
+async def test_discovery(hass):
+ """Test discovery flow works."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "discovery_confirm"
+
+ with patch(
+ "homeassistant.components.kodi.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.kodi.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_configure(
+ flow_id=result["flow_id"], user_input={}
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == "hostname"
+ assert result["data"] == {
+ **TEST_HOST,
+ **TEST_WS_PORT,
+ "password": None,
+ "username": None,
+ "name": "hostname",
+ "timeout": DEFAULT_TIMEOUT,
+ }
+
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_discovery_cannot_connect_http(hass):
+ """Test discovery aborts if cannot connect."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ side_effect=CannotConnectError,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "cannot_connect"
+
+
+async def test_discovery_cannot_connect_ws(hass):
+ """Test discovery aborts if cannot connect to websocket."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ return_value=True,
+ ), patch.object(
+ MockWSConnection,
+ "connect",
+ AsyncMock(side_effect=CannotConnectError),
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ new=get_kodi_connection,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "ws_port"
+ assert result["errors"] == {}
+
+
+async def test_discovery_exception_http(hass, user_flow):
+ """Test we handle generic exception during discovery validation."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ side_effect=Exception,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "unknown"
+
+
+async def test_discovery_invalid_auth(hass):
+ """Test we handle invalid auth during discovery."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ side_effect=InvalidAuthError,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "credentials"
+ assert result["errors"] == {}
+
+
+async def test_discovery_duplicate_data(hass):
+ """Test discovery aborts if same mDNS packet arrives."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY
+ )
+
+ assert result["type"] == "form"
+ assert result["step_id"] == "discovery_confirm"
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_in_progress"
+
+
+async def test_discovery_updates_unique_id(hass):
+ """Test a duplicate discovery id aborts and updates existing entry."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id=UUID,
+ data={"host": "dummy", "port": 11, "namename": "dummy.local."},
+ )
+
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+
+ assert entry.data["host"] == "1.1.1.1"
+ assert entry.data["port"] == 8080
+ assert entry.data["name"] == "hostname"
+
+
+async def test_form_import(hass):
+ """Test we get the form with import source."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ), patch(
+ "homeassistant.components.kodi.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.kodi.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data=TEST_IMPORT,
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == TEST_IMPORT["name"]
+ assert result["data"] == TEST_IMPORT
+
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_import_invalid_auth(hass):
+ """Test we handle invalid auth on import."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ side_effect=InvalidAuthError,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data=TEST_IMPORT,
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "invalid_auth"
+
+
+async def test_form_import_cannot_connect(hass):
+ """Test we handle cannot connect on import."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ side_effect=CannotConnectError,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data=TEST_IMPORT,
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "cannot_connect"
+
+
+async def test_form_import_exception(hass):
+ """Test we handle unknown exception on import."""
+ with patch(
+ "homeassistant.components.kodi.config_flow.Kodi.ping",
+ side_effect=Exception,
+ ), patch(
+ "homeassistant.components.kodi.config_flow.get_kodi_connection",
+ return_value=MockConnection(),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_IMPORT},
+ data=TEST_IMPORT,
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "unknown"
diff --git a/tests/components/kodi/test_device_trigger.py b/tests/components/kodi/test_device_trigger.py
new file mode 100644
index 00000000000..5819f2128c8
--- /dev/null
+++ b/tests/components/kodi/test_device_trigger.py
@@ -0,0 +1,136 @@
+"""The tests for Kodi device triggers."""
+import pytest
+
+import homeassistant.components.automation as automation
+from homeassistant.components.kodi import DOMAIN
+from homeassistant.components.media_player.const import DOMAIN as MP_DOMAIN
+from homeassistant.setup import async_setup_component
+
+from . import init_integration
+
+from tests.common import (
+ MockConfigEntry,
+ assert_lists_same,
+ async_get_device_automations,
+ async_mock_service,
+ mock_device_registry,
+ mock_registry,
+)
+
+
+@pytest.fixture
+def device_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_device_registry(hass)
+
+
+@pytest.fixture
+def entity_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_registry(hass)
+
+
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock service."""
+ return async_mock_service(hass, "test", "automation")
+
+
+@pytest.fixture
+async def kodi_media_player(hass):
+ """Get a kodi media player."""
+ await init_integration(hass)
+ return f"{MP_DOMAIN}.name"
+
+
+async def test_get_triggers(hass, device_reg, entity_reg):
+ """Test we get the expected triggers from a kodi."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={})
+ config_entry.add_to_hass(hass)
+ device_entry = device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={(DOMAIN, "host", 1234)},
+ )
+ entity_reg.async_get_or_create(MP_DOMAIN, DOMAIN, "5678", device_id=device_entry.id)
+ expected_triggers = [
+ {
+ "platform": "device",
+ "domain": DOMAIN,
+ "type": "turn_off",
+ "device_id": device_entry.id,
+ "entity_id": f"{MP_DOMAIN}.kodi_5678",
+ },
+ {
+ "platform": "device",
+ "domain": DOMAIN,
+ "type": "turn_on",
+ "device_id": device_entry.id,
+ "entity_id": f"{MP_DOMAIN}.kodi_5678",
+ },
+ ]
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert_lists_same(triggers, expected_triggers)
+
+
+async def test_if_fires_on_state_change(hass, calls, kodi_media_player):
+ """Test for turn_on and turn_off triggers firing."""
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": kodi_media_player,
+ "type": "turn_on",
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": ("turn_on - {{ trigger.entity_id }}")
+ },
+ },
+ },
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": kodi_media_player,
+ "type": "turn_off",
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": ("turn_off - {{ trigger.entity_id }}")
+ },
+ },
+ },
+ ]
+ },
+ )
+
+ await hass.services.async_call(
+ MP_DOMAIN,
+ "turn_on",
+ {"entity_id": kodi_media_player},
+ blocking=True,
+ )
+
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+ assert calls[0].data["some"] == f"turn_on - {kodi_media_player}"
+
+ await hass.services.async_call(
+ MP_DOMAIN,
+ "turn_off",
+ {"entity_id": kodi_media_player},
+ blocking=True,
+ )
+
+ await hass.async_block_till_done()
+ assert len(calls) == 2
+ assert calls[1].data["some"] == f"turn_off - {kodi_media_player}"
diff --git a/tests/components/kodi/test_init.py b/tests/components/kodi/test_init.py
new file mode 100644
index 00000000000..b272e005012
--- /dev/null
+++ b/tests/components/kodi/test_init.py
@@ -0,0 +1,25 @@
+"""Test the Kodi integration init."""
+from homeassistant.components.kodi.const import DOMAIN
+from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED
+
+from . import init_integration
+
+from tests.async_mock import patch
+
+
+async def test_unload_entry(hass):
+ """Test successful unload of entry."""
+ with patch(
+ "homeassistant.components.kodi.media_player.async_setup_entry",
+ return_value=True,
+ ):
+ entry = await init_integration(hass)
+
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+ assert entry.state == ENTRY_STATE_LOADED
+
+ assert await hass.config_entries.async_unload(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.state == ENTRY_STATE_NOT_LOADED
+ assert not hass.data.get(DOMAIN)
diff --git a/tests/components/kodi/util.py b/tests/components/kodi/util.py
new file mode 100644
index 00000000000..5a47ea88631
--- /dev/null
+++ b/tests/components/kodi/util.py
@@ -0,0 +1,108 @@
+"""Test the Kodi config flow."""
+from homeassistant.components.kodi.const import DEFAULT_SSL
+
+TEST_HOST = {
+ "host": "1.1.1.1",
+ "port": 8080,
+ "ssl": DEFAULT_SSL,
+}
+
+
+TEST_CREDENTIALS = {"username": "username", "password": "password"}
+
+
+TEST_WS_PORT = {"ws_port": 9090}
+
+UUID = "11111111-1111-1111-1111-111111111111"
+TEST_DISCOVERY = {
+ "host": "1.1.1.1",
+ "port": 8080,
+ "hostname": "hostname.local.",
+ "type": "_xbmc-jsonrpc-h._tcp.local.",
+ "name": "hostname._xbmc-jsonrpc-h._tcp.local.",
+ "properties": {"uuid": UUID},
+}
+
+
+TEST_IMPORT = {
+ "name": "name",
+ "host": "1.1.1.1",
+ "port": 8080,
+ "ws_port": 9090,
+ "username": "username",
+ "password": "password",
+ "ssl": True,
+ "timeout": 7,
+}
+
+
+def get_kodi_connection(
+ host, port, ws_port, username, password, ssl=False, timeout=5, session=None
+):
+ """Get Kodi connection."""
+ if ws_port is None:
+ return MockConnection()
+ else:
+ return MockWSConnection()
+
+
+class MockConnection:
+ """A mock kodi connection."""
+
+ def __init__(self, connected=True):
+ """Mock the Kodi connection."""
+ self._connected = connected
+
+ async def connect(self):
+ """Mock connect."""
+ pass
+
+ @property
+ def connected(self):
+ """Mock connected."""
+ return self._connected
+
+ @property
+ def can_subscribe(self):
+ """Mock can_subscribe."""
+ return False
+
+ async def close(self):
+ """Mock close."""
+ pass
+
+ @property
+ def server(self):
+ """Mock server."""
+ return None
+
+
+class MockWSConnection:
+ """A mock kodi websocket connection."""
+
+ def __init__(self, connected=True):
+ """Mock the websocket connection."""
+ self._connected = connected
+
+ async def connect(self):
+ """Mock connect."""
+ pass
+
+ @property
+ def connected(self):
+ """Mock connected."""
+ return self._connected
+
+ @property
+ def can_subscribe(self):
+ """Mock can_subscribe."""
+ return False
+
+ async def close(self):
+ """Mock close."""
+ pass
+
+ @property
+ def server(self):
+ """Mock server."""
+ return None
diff --git a/tests/components/konnected/test_config_flow.py b/tests/components/konnected/test_config_flow.py
index dbca89efe30..dfd4600f1fb 100644
--- a/tests/components/konnected/test_config_flow.py
+++ b/tests/components/konnected/test_config_flow.py
@@ -312,7 +312,7 @@ async def test_ssdp_host_update(hass, mock_panel):
"10": "Binary Sensor",
"3": "Digital Sensor",
"7": "Digital Sensor",
- "11": "Digital Sensor",
+ "11": "Binary Sensor",
"4": "Switchable Output",
"out1": "Switchable Output",
"alarm1": "Switchable Output",
@@ -321,11 +321,11 @@ async def test_ssdp_host_update(hass, mock_panel):
{"zone": "2", "type": "door"},
{"zone": "6", "type": "window", "name": "winder", "inverse": True},
{"zone": "10", "type": "door"},
+ {"zone": "11", "type": "window"},
],
"sensors": [
{"zone": "3", "type": "dht"},
{"zone": "7", "type": "ds18b20", "name": "temper"},
- {"zone": "11", "type": "dht"},
],
"switches": [
{"zone": "4"},
@@ -391,11 +391,11 @@ async def test_import_existing_config(hass, mock_panel):
{"zone": "2", "type": "door"},
{"zone": 6, "type": "window", "name": "winder", "inverse": True},
{"zone": "10", "type": "door"},
+ {"zone": "11", "type": "window"},
],
"sensors": [
{"zone": "3", "type": "dht"},
{"zone": 7, "type": "ds18b20", "name": "temper"},
- {"zone": "11", "type": "dht"},
],
"switches": [
{"zone": "4"},
@@ -447,7 +447,7 @@ async def test_import_existing_config(hass, mock_panel):
"10": "Binary Sensor",
"3": "Digital Sensor",
"7": "Digital Sensor",
- "11": "Digital Sensor",
+ "11": "Binary Sensor",
"4": "Switchable Output",
"8": "Switchable Output",
"out1": "Switchable Output",
@@ -460,11 +460,11 @@ async def test_import_existing_config(hass, mock_panel):
{"zone": "2", "type": "door", "inverse": False},
{"zone": "6", "type": "window", "name": "winder", "inverse": True},
{"zone": "10", "type": "door", "inverse": False},
+ {"zone": "11", "type": "window", "inverse": False},
],
"sensors": [
{"zone": "3", "type": "dht", "poll_interval": 3},
{"zone": "7", "type": "ds18b20", "name": "temper", "poll_interval": 3},
- {"zone": "11", "type": "dht", "poll_interval": 3},
],
"switches": [
{"activation": "high", "zone": "4"},
@@ -788,7 +788,12 @@ async def test_option_flow(hass, mock_panel):
# make sure we enforce url format
result = await hass.config_entries.options.async_configure(
result["flow_id"],
- user_input={"blink": True, "override_api_host": True, "api_host": "badhosturl"},
+ user_input={
+ "discovery": False,
+ "blink": True,
+ "override_api_host": True,
+ "api_host": "badhosturl",
+ },
)
assert result["type"] == "form"
@@ -796,6 +801,7 @@ async def test_option_flow(hass, mock_panel):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
+ "discovery": False,
"blink": True,
"override_api_host": True,
"api_host": "http://overridehost:1111",
@@ -810,6 +816,7 @@ async def test_option_flow(hass, mock_panel):
"6": "Binary Sensor",
"out": "Switchable Output",
},
+ "discovery": False,
"blink": True,
"api_host": "http://overridehost:1111",
"binary_sensors": [
@@ -889,7 +896,7 @@ async def test_option_flow_pro(hass, mock_panel):
"8": "Switchable Output",
"9": "Disabled",
"10": "Binary Sensor",
- "11": "Digital Sensor",
+ "11": "Binary Sensor",
"12": "Disabled",
"out1": "Switchable Output",
"alarm1": "Switchable Output",
@@ -919,6 +926,13 @@ async def test_option_flow_pro(hass, mock_panel):
result["flow_id"], user_input={"type": "door"}
)
assert result["type"] == "form"
+ assert result["step_id"] == "options_binary"
+
+ # zone 11
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={"type": "window"}
+ )
+ assert result["type"] == "form"
assert result["step_id"] == "options_digital"
# zone 3
@@ -933,13 +947,6 @@ async def test_option_flow_pro(hass, mock_panel):
result["flow_id"], user_input={"type": "ds18b20", "name": "temper"}
)
assert result["type"] == "form"
- assert result["step_id"] == "options_digital"
-
- # zone 11
- result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={"type": "dht"}
- )
- assert result["type"] == "form"
assert result["step_id"] == "options_switch"
# zone 4
@@ -978,14 +985,15 @@ async def test_option_flow_pro(hass, mock_panel):
assert result["step_id"] == "options_misc"
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={"blink": True, "override_api_host": False},
+ result["flow_id"],
+ user_input={"discovery": False, "blink": True, "override_api_host": False},
)
assert result["type"] == "create_entry"
assert result["data"] == {
"io": {
"10": "Binary Sensor",
- "11": "Digital Sensor",
+ "11": "Binary Sensor",
"2": "Binary Sensor",
"3": "Digital Sensor",
"4": "Switchable Output",
@@ -995,17 +1003,18 @@ async def test_option_flow_pro(hass, mock_panel):
"alarm1": "Switchable Output",
"out1": "Switchable Output",
},
+ "discovery": False,
"blink": True,
"api_host": "",
"binary_sensors": [
{"zone": "2", "type": "door", "inverse": False},
{"zone": "6", "type": "window", "name": "winder", "inverse": True},
{"zone": "10", "type": "door", "inverse": False},
+ {"zone": "11", "type": "window", "inverse": False},
],
"sensors": [
{"zone": "3", "type": "dht", "poll_interval": 3},
{"zone": "7", "type": "ds18b20", "name": "temper", "poll_interval": 3},
- {"zone": "11", "type": "dht", "poll_interval": 3},
],
"switches": [
{"activation": "high", "zone": "4"},
@@ -1099,7 +1108,8 @@ async def test_option_flow_import(hass, mock_panel):
assert schema["8"] == "Disabled"
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={},
+ result["flow_id"],
+ user_input={},
)
assert result["type"] == "form"
assert result["step_id"] == "options_binary"
@@ -1120,7 +1130,8 @@ async def test_option_flow_import(hass, mock_panel):
assert schema["type"] == "ds18b20"
assert schema["name"] == "temper"
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={"type": "dht"},
+ result["flow_id"],
+ user_input={"type": "dht"},
)
assert result["type"] == "form"
assert result["step_id"] == "options_switch"
@@ -1141,14 +1152,17 @@ async def test_option_flow_import(hass, mock_panel):
schema = result["data_schema"]({})
assert schema["blink"] is True
+ assert schema["discovery"] is True
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={"blink": False, "override_api_host": False},
+ result["flow_id"],
+ user_input={"discovery": True, "blink": False, "override_api_host": False},
)
# verify the updated fields
assert result["type"] == "create_entry"
assert result["data"] == {
"io": {"1": "Binary Sensor", "2": "Digital Sensor", "3": "Switchable Output"},
+ "discovery": True,
"blink": False,
"api_host": "",
"binary_sensors": [
diff --git a/tests/components/konnected/test_init.py b/tests/components/konnected/test_init.py
index 74e3b931f61..c198812a82b 100644
--- a/tests/components/konnected/test_init.py
+++ b/tests/components/konnected/test_init.py
@@ -387,7 +387,8 @@ async def test_config_passed_to_config_entry(hass):
async def test_unload_entry(hass, mock_panel):
"""Test being able to unload an entry."""
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
entry = MockConfigEntry(
domain=konnected.DOMAIN, data={konnected.CONF_ID: "aabbccddeeff"}
@@ -411,12 +412,14 @@ async def test_api(hass, aiohttp_client, mock_panel):
"id": "112233445566",
"model": "Konnected Pro",
"access_token": "abcdefgh",
+ "api_host": "http://192.168.86.32:8123",
"default_options": config_flow.OPTIONS_SCHEMA({config_flow.CONF_IO: {}}),
}
)
device_options = config_flow.OPTIONS_SCHEMA(
{
+ "api_host": "http://192.168.86.32:8123",
"io": {
"1": "Binary Sensor",
"2": "Binary Sensor",
@@ -568,7 +571,8 @@ async def test_api(hass, aiohttp_client, mock_panel):
async def test_state_updates_zone(hass, aiohttp_client, mock_panel):
"""Test callback view."""
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
device_config = config_flow.CONFIG_ENTRY_SCHEMA(
@@ -718,7 +722,8 @@ async def test_state_updates_zone(hass, aiohttp_client, mock_panel):
async def test_state_updates_pin(hass, aiohttp_client, mock_panel):
"""Test callback view."""
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
device_config = config_flow.CONFIG_ENTRY_SCHEMA(
@@ -775,7 +780,11 @@ async def test_state_updates_pin(hass, aiohttp_client, mock_panel):
entry.add_to_hass(hass)
# Add empty data field to ensure we process it correctly (possible if entry is ignored)
- entry = MockConfigEntry(domain="konnected", title="Konnected Alarm Panel", data={},)
+ entry = MockConfigEntry(
+ domain="konnected",
+ title="Konnected Alarm Panel",
+ data={},
+ )
entry.add_to_hass(hass)
assert (
diff --git a/tests/components/konnected/test_panel.py b/tests/components/konnected/test_panel.py
index f167c558d01..21351cb6be3 100644
--- a/tests/components/konnected/test_panel.py
+++ b/tests/components/konnected/test_panel.py
@@ -1,11 +1,14 @@
"""Test Konnected setup process."""
+from datetime import timedelta
+
import pytest
from homeassistant.components.konnected import config_flow, panel
from homeassistant.setup import async_setup_component
+from homeassistant.util import utcnow
from tests.async_mock import patch
-from tests.common import MockConfigEntry
+from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.fixture(name="mock_panel")
@@ -221,9 +224,9 @@ async def test_create_and_setup_pro(hass, mock_panel):
"2": "Binary Sensor",
"6": "Binary Sensor",
"10": "Binary Sensor",
+ "11": "Binary Sensor",
"3": "Digital Sensor",
"7": "Digital Sensor",
- "11": "Digital Sensor",
"4": "Switchable Output",
"8": "Switchable Output",
"out1": "Switchable Output",
@@ -233,11 +236,11 @@ async def test_create_and_setup_pro(hass, mock_panel):
{"zone": "2", "type": "door"},
{"zone": "6", "type": "window", "name": "winder", "inverse": True},
{"zone": "10", "type": "door"},
+ {"zone": "11", "type": "window"},
],
"sensors": [
- {"zone": "3", "type": "dht"},
+ {"zone": "3", "type": "dht", "poll_interval": 5},
{"zone": "7", "type": "ds18b20", "name": "temper"},
- {"zone": "11", "type": "dht", "poll_interval": 5},
],
"switches": [
{"zone": "4"},
@@ -291,17 +294,14 @@ async def test_create_and_setup_pro(hass, mock_panel):
# confirm the settings are sent to the panel
# pylint: disable=no-member
assert mock_panel.put_settings.call_args_list[0][1] == {
- "sensors": [{"zone": "2"}, {"zone": "6"}, {"zone": "10"}],
+ "sensors": [{"zone": "2"}, {"zone": "6"}, {"zone": "10"}, {"zone": "11"}],
"actuators": [
{"trigger": 1, "zone": "4"},
{"trigger": 0, "zone": "8"},
{"trigger": 1, "zone": "out1"},
{"trigger": 1, "zone": "alarm1"},
],
- "dht_sensors": [
- {"poll_interval": 3, "zone": "3"},
- {"poll_interval": 5, "zone": "11"},
- ],
+ "dht_sensors": [{"poll_interval": 5, "zone": "3"}],
"ds18b20_sensors": [{"zone": "7"}],
"auth_token": "11223344556677889900",
"blink": True,
@@ -312,6 +312,12 @@ async def test_create_and_setup_pro(hass, mock_panel):
# confirm the device settings are saved in hass.data
assert device.stored_configuration == {
"binary_sensors": {
+ "11": {
+ "inverse": False,
+ "name": "Konnected 445566 Zone 11",
+ "state": None,
+ "type": "window",
+ },
"10": {
"inverse": False,
"name": "Konnected 445566 Zone 10",
@@ -334,17 +340,11 @@ async def test_create_and_setup_pro(hass, mock_panel):
"sensors": [
{
"name": "Konnected 445566 Sensor 3",
- "poll_interval": 3,
+ "poll_interval": 5,
"type": "dht",
"zone": "3",
},
{"name": "temper", "poll_interval": 3, "type": "ds18b20", "zone": "7"},
- {
- "name": "Konnected 445566 Sensor 11",
- "poll_interval": 5,
- "type": "dht",
- "zone": "11",
- },
],
"switches": [
{
@@ -551,3 +551,118 @@ async def test_default_options(hass, mock_panel):
},
],
}
+
+
+async def test_connect_retry(hass, mock_panel):
+ """Test that we create a Konnected Panel and save the data."""
+ device_config = config_flow.CONFIG_ENTRY_SCHEMA(
+ {
+ "host": "1.2.3.4",
+ "port": 1234,
+ "id": "112233445566",
+ "model": "Konnected Pro",
+ "access_token": "11223344556677889900",
+ "default_options": config_flow.OPTIONS_SCHEMA(
+ {
+ "io": {
+ "1": "Binary Sensor",
+ "2": "Binary Sensor",
+ "3": "Binary Sensor",
+ "4": "Digital Sensor",
+ "5": "Digital Sensor",
+ "6": "Switchable Output",
+ "out": "Switchable Output",
+ },
+ "binary_sensors": [
+ {"zone": "1", "type": "door"},
+ {
+ "zone": "2",
+ "type": "window",
+ "name": "winder",
+ "inverse": True,
+ },
+ {"zone": "3", "type": "door"},
+ ],
+ "sensors": [
+ {"zone": "4", "type": "dht"},
+ {"zone": "5", "type": "ds18b20", "name": "temper"},
+ ],
+ "switches": [
+ {
+ "zone": "out",
+ "name": "switcher",
+ "activation": "low",
+ "momentary": 50,
+ "pause": 100,
+ "repeat": 4,
+ },
+ {"zone": "6"},
+ ],
+ }
+ ),
+ }
+ )
+
+ entry = MockConfigEntry(
+ domain="konnected",
+ title="Konnected Alarm Panel",
+ data=device_config,
+ options={},
+ )
+ entry.add_to_hass(hass)
+
+ # fail first 2 attempts, and succeed the third
+ mock_panel.get_status.side_effect = [
+ mock_panel.ClientError,
+ mock_panel.ClientError,
+ {
+ "hwVersion": "2.3.0",
+ "swVersion": "2.3.1",
+ "heap": 10000,
+ "uptime": 12222,
+ "ip": "192.168.1.90",
+ "port": 9123,
+ "sensors": [],
+ "actuators": [],
+ "dht_sensors": [],
+ "ds18b20_sensors": [],
+ "mac": "11:22:33:44:55:66",
+ "model": "Konnected Pro",
+ "settings": {},
+ },
+ ]
+
+ # setup the integration and inspect panel behavior
+ assert (
+ await async_setup_component(
+ hass,
+ panel.DOMAIN,
+ {
+ panel.DOMAIN: {
+ panel.CONF_ACCESS_TOKEN: "arandomstringvalue",
+ panel.CONF_API_HOST: "http://192.168.1.1:8123",
+ }
+ },
+ )
+ is True
+ )
+
+ # confirm switch is unavailable after initial attempt
+ await hass.async_block_till_done()
+ assert hass.states.get("switch.konnected_445566_actuator_6").state == "unavailable"
+
+ # confirm switch is unavailable after second attempt
+ async_fire_time_changed(hass, utcnow() + timedelta(seconds=11))
+ await hass.async_block_till_done()
+ await hass.helpers.entity_component.async_update_entity(
+ "switch.konnected_445566_actuator_6"
+ )
+ assert hass.states.get("switch.konnected_445566_actuator_6").state == "unavailable"
+
+ # confirm switch is available after third attempt
+ async_fire_time_changed(hass, utcnow() + timedelta(seconds=21))
+ await hass.async_block_till_done()
+ await hass.helpers.entity_component.async_update_entity(
+ "switch.konnected_445566_actuator_6"
+ )
+ assert hass.states.get("switch.konnected_445566_actuator_6").state == "off"
diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py
index 1f3f0c22bd0..aeedde82af5 100644
--- a/tests/components/light/test_device_action.py
+++ b/tests/components/light/test_device_action.py
@@ -107,7 +107,10 @@ async def test_get_action_capabilities(hass, device_reg, entity_reg):
connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
)
entity_reg.async_get_or_create(
- DOMAIN, "test", "5678", device_id=device_entry.id,
+ DOMAIN,
+ "test",
+ "5678",
+ device_id=device_entry.id,
)
actions = await async_get_device_automations(hass, "action", device_entry.id)
diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py
index dd4745ac513..d3c630cd0dc 100644
--- a/tests/components/light/test_device_trigger.py
+++ b/tests/components/light/test_device_trigger.py
@@ -95,6 +95,8 @@ async def test_if_fires_on_state_change(hass, calls):
platform = getattr(hass.components, f"test.{DOMAIN}")
platform.init()
+ await hass.async_block_till_done()
+
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
await hass.async_block_till_done()
@@ -180,6 +182,8 @@ async def test_if_fires_on_state_change_with_for(hass, calls):
platform = getattr(hass.components, f"test.{DOMAIN}")
platform.init()
+ await hass.async_block_till_done()
+
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
await hass.async_block_till_done()
diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py
index 1660ec422f3..a3f24bef3c9 100644
--- a/tests/components/light/test_init.py
+++ b/tests/components/light/test_init.py
@@ -589,28 +589,40 @@ async def test_light_brightness_pct_conversion(hass):
assert state.attributes["brightness"] == 100
await hass.services.async_call(
- "light", "turn_on", {"entity_id": entity.entity_id, "brightness_pct": 1}, True,
+ "light",
+ "turn_on",
+ {"entity_id": entity.entity_id, "brightness_pct": 1},
+ True,
)
_, data = entity.last_call("turn_on")
assert data["brightness"] == 3, data
await hass.services.async_call(
- "light", "turn_on", {"entity_id": entity.entity_id, "brightness_pct": 2}, True,
+ "light",
+ "turn_on",
+ {"entity_id": entity.entity_id, "brightness_pct": 2},
+ True,
)
_, data = entity.last_call("turn_on")
assert data["brightness"] == 5, data
await hass.services.async_call(
- "light", "turn_on", {"entity_id": entity.entity_id, "brightness_pct": 50}, True,
+ "light",
+ "turn_on",
+ {"entity_id": entity.entity_id, "brightness_pct": 50},
+ True,
)
_, data = entity.last_call("turn_on")
assert data["brightness"] == 128, data
await hass.services.async_call(
- "light", "turn_on", {"entity_id": entity.entity_id, "brightness_pct": 99}, True,
+ "light",
+ "turn_on",
+ {"entity_id": entity.entity_id, "brightness_pct": 99},
+ True,
)
_, data = entity.last_call("turn_on")
diff --git a/tests/components/automation/test_litejet.py b/tests/components/litejet/test_trigger.py
similarity index 100%
rename from tests/components/automation/test_litejet.py
rename to tests/components/litejet/test_trigger.py
diff --git a/tests/components/local_ip/test_config_flow.py b/tests/components/local_ip/test_config_flow.py
index f6b3ebf6c8e..e3a9ecd9ef8 100644
--- a/tests/components/local_ip/test_config_flow.py
+++ b/tests/components/local_ip/test_config_flow.py
@@ -23,7 +23,10 @@ async def test_config_flow(hass):
async def test_already_setup(hass):
"""Test we abort if already setup."""
- MockConfigEntry(domain=DOMAIN, data={},).add_to_hass(hass)
+ MockConfigEntry(
+ domain=DOMAIN,
+ data={},
+ ).add_to_hass(hass)
# Should fail, same NAME
result = await hass.config_entries.flow.async_init(
diff --git a/tests/components/locative/test_init.py b/tests/components/locative/test_init.py
index 0f3d17c9b59..2fa68778f7e 100644
--- a/tests/components/locative/test_init.py
+++ b/tests/components/locative/test_init.py
@@ -35,7 +35,8 @@ async def locative_client(loop, hass, hass_client):
async def webhook_id(hass, locative_client):
"""Initialize the Geofency component and get the webhook_id."""
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
result = await hass.config_entries.flow.async_init(
"locative", context={"source": "user"}
diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py
index f264f75e2b0..5e41f0bce89 100644
--- a/tests/components/logbook/test_init.py
+++ b/tests/components/logbook/test_init.py
@@ -15,12 +15,16 @@ from homeassistant.components.automation import EVENT_AUTOMATION_TRIGGERED
from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat
from homeassistant.components.script import EVENT_SCRIPT_STARTED
from homeassistant.const import (
+ ATTR_DOMAIN,
ATTR_ENTITY_ID,
+ ATTR_FRIENDLY_NAME,
ATTR_NAME,
+ ATTR_SERVICE,
CONF_DOMAINS,
CONF_ENTITIES,
CONF_EXCLUDE,
CONF_INCLUDE,
+ EVENT_CALL_SERVICE,
EVENT_HOMEASSISTANT_START,
EVENT_HOMEASSISTANT_STOP,
EVENT_STATE_CHANGED,
@@ -96,7 +100,6 @@ class TestComponentLogbook(unittest.TestCase):
events = list(
logbook._get_events(
self.hass,
- {},
dt_util.utcnow() - timedelta(hours=1),
dt_util.utcnow() + timedelta(hours=1),
)
@@ -152,7 +155,7 @@ class TestComponentLogbook(unittest.TestCase):
eventC = self.create_state_changed_event(pointC, entity_id, 30)
entries = list(
- logbook.humanify(self.hass, (eventA, eventB, eventC), entity_attr_cache)
+ logbook.humanify(self.hass, (eventA, eventB, eventC), entity_attr_cache, {})
)
assert len(entries) == 2
@@ -191,7 +194,7 @@ class TestComponentLogbook(unittest.TestCase):
)
if logbook._keep_event(self.hass, e, entities_filter)
]
- entries = list(logbook.humanify(self.hass, events, entity_attr_cache))
+ entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {}))
assert len(entries) == 2
self.assert_entry(
@@ -229,7 +232,7 @@ class TestComponentLogbook(unittest.TestCase):
)
if logbook._keep_event(self.hass, e, entities_filter)
]
- entries = list(logbook.humanify(self.hass, events, entity_attr_cache))
+ entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {}))
assert len(entries) == 2
self.assert_entry(
@@ -276,7 +279,7 @@ class TestComponentLogbook(unittest.TestCase):
)
if logbook._keep_event(self.hass, e, entities_filter)
]
- entries = list(logbook.humanify(self.hass, events, entity_attr_cache))
+ entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {}))
assert len(entries) == 2
self.assert_entry(
@@ -318,7 +321,7 @@ class TestComponentLogbook(unittest.TestCase):
)
if logbook._keep_event(self.hass, e, entities_filter)
]
- entries = list(logbook.humanify(self.hass, events, entity_attr_cache))
+ entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {}))
assert len(entries) == 2
self.assert_entry(
@@ -364,7 +367,7 @@ class TestComponentLogbook(unittest.TestCase):
)
if logbook._keep_event(self.hass, e, entities_filter)
]
- entries = list(logbook.humanify(self.hass, events, entity_attr_cache))
+ entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {}))
assert len(entries) == 3
self.assert_entry(
@@ -418,7 +421,7 @@ class TestComponentLogbook(unittest.TestCase):
)
if logbook._keep_event(self.hass, e, entities_filter)
]
- entries = list(logbook.humanify(self.hass, events, entity_attr_cache))
+ entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {}))
assert len(entries) == 4
self.assert_entry(
@@ -475,7 +478,7 @@ class TestComponentLogbook(unittest.TestCase):
)
if logbook._keep_event(self.hass, e, entities_filter)
]
- entries = list(logbook.humanify(self.hass, events, entity_attr_cache))
+ entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {}))
assert len(entries) == 5
self.assert_entry(
@@ -549,7 +552,7 @@ class TestComponentLogbook(unittest.TestCase):
)
if logbook._keep_event(self.hass, e, entities_filter)
]
- entries = list(logbook.humanify(self.hass, events, entity_attr_cache))
+ entries = list(logbook.humanify(self.hass, events, entity_attr_cache, {}))
assert len(entries) == 6
self.assert_entry(
@@ -585,6 +588,7 @@ class TestComponentLogbook(unittest.TestCase):
MockLazyEventPartialState(EVENT_HOMEASSISTANT_START),
),
entity_attr_cache,
+ {},
),
)
@@ -607,6 +611,7 @@ class TestComponentLogbook(unittest.TestCase):
self.create_state_changed_event(pointA, entity_id, 10),
),
entity_attr_cache,
+ {},
)
)
@@ -628,21 +633,21 @@ class TestComponentLogbook(unittest.TestCase):
# message for a device state change
eventA = self.create_state_changed_event(pointA, "switch.bla", 10)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "changed to 10"
# message for a switch turned on
eventA = self.create_state_changed_event(pointA, "switch.bla", STATE_ON)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "turned on"
# message for a switch turned off
eventA = self.create_state_changed_event(pointA, "switch.bla", STATE_OFF)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "turned off"
@@ -656,14 +661,14 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "device_tracker.john", STATE_NOT_HOME
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is away"
# message for a device tracker "home" state
eventA = self.create_state_changed_event(pointA, "device_tracker.john", "work")
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is at work"
@@ -675,14 +680,14 @@ class TestComponentLogbook(unittest.TestCase):
# message for a device tracker "not home" state
eventA = self.create_state_changed_event(pointA, "person.john", STATE_NOT_HOME)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is away"
# message for a device tracker "home" state
eventA = self.create_state_changed_event(pointA, "person.john", "work")
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is at work"
@@ -696,7 +701,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "sun.sun", sun.STATE_ABOVE_HORIZON
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "has risen"
@@ -705,7 +710,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "sun.sun", sun.STATE_BELOW_HORIZON
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "has set"
@@ -720,7 +725,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.battery", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is low"
@@ -729,7 +734,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.battery", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is normal"
@@ -744,7 +749,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.connectivity", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is connected"
@@ -753,7 +758,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.connectivity", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is disconnected"
@@ -768,7 +773,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.door", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is opened"
@@ -777,7 +782,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.door", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is closed"
@@ -792,7 +797,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.garage_door", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is opened"
@@ -801,7 +806,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.garage_door", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is closed"
@@ -816,7 +821,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.opening", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is opened"
@@ -825,7 +830,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.opening", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is closed"
@@ -840,7 +845,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.window", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is opened"
@@ -849,7 +854,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.window", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is closed"
@@ -864,7 +869,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.lock", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is unlocked"
@@ -873,7 +878,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.lock", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is locked"
@@ -888,7 +893,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.plug", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is plugged in"
@@ -897,7 +902,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.plug", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is unplugged"
@@ -912,7 +917,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.presence", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is at home"
@@ -921,7 +926,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.presence", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is away"
@@ -936,7 +941,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.safety", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is unsafe"
@@ -945,7 +950,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.safety", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "is safe"
@@ -960,7 +965,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.cold", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "detected cold"
@@ -969,7 +974,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.cold", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "cleared (no cold detected)"
@@ -984,7 +989,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.gas", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "detected gas"
@@ -993,7 +998,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.gas", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "cleared (no gas detected)"
@@ -1008,7 +1013,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.heat", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "detected heat"
@@ -1017,7 +1022,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.heat", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "cleared (no heat detected)"
@@ -1032,7 +1037,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.light", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "detected light"
@@ -1041,7 +1046,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.light", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "cleared (no light detected)"
@@ -1056,7 +1061,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.moisture", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "detected moisture"
@@ -1065,7 +1070,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.moisture", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "cleared (no moisture detected)"
@@ -1080,7 +1085,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.motion", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "detected motion"
@@ -1089,7 +1094,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.motion", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "cleared (no motion detected)"
@@ -1104,7 +1109,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.occupancy", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "detected occupancy"
@@ -1113,7 +1118,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.occupancy", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "cleared (no occupancy detected)"
@@ -1128,7 +1133,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.power", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "detected power"
@@ -1137,7 +1142,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.power", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "cleared (no power detected)"
@@ -1152,7 +1157,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.problem", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "detected problem"
@@ -1161,7 +1166,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.problem", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "cleared (no problem detected)"
@@ -1176,7 +1181,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.smoke", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "detected smoke"
@@ -1185,7 +1190,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.smoke", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "cleared (no smoke detected)"
@@ -1200,7 +1205,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.sound", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "detected sound"
@@ -1209,7 +1214,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.sound", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "cleared (no sound detected)"
@@ -1224,7 +1229,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.vibration", STATE_ON, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "detected vibration"
@@ -1233,7 +1238,7 @@ class TestComponentLogbook(unittest.TestCase):
pointA, "binary_sensor.vibration", STATE_OFF, attributes
)
message = logbook._entry_message_from_event(
- self.hass, eventA.entity_id, eventA.domain, eventA, entity_attr_cache
+ eventA.entity_id, eventA.domain, eventA, entity_attr_cache
)
assert message == "cleared (no vibration detected)"
@@ -1258,6 +1263,7 @@ class TestComponentLogbook(unittest.TestCase):
),
),
entity_attr_cache,
+ {},
)
)
@@ -1652,7 +1658,6 @@ async def test_logbook_entity_filter_with_automations(hass, hass_client):
assert response.status == 200
json_dict = await response.json()
- assert len(json_dict) == 5
assert json_dict[0]["entity_id"] == entity_id_test
assert json_dict[1]["entity_id"] == entity_id_second
assert json_dict[2]["entity_id"] == "automation.mock_automation"
@@ -1837,9 +1842,353 @@ async def test_exclude_attribute_changes(hass, hass_client):
assert response_json[2]["entity_id"] == "light.kitchen"
+async def test_logbook_entity_context_id(hass, hass_client):
+ """Test the logbook view with end_time and entity with automations and scripts."""
+ await hass.async_add_executor_job(init_recorder_component, hass)
+ await async_setup_component(hass, "logbook", {})
+ await async_setup_component(hass, "automation", {})
+ await async_setup_component(hass, "script", {})
+
+ await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done)
+
+ context = ha.Context(
+ id="ac5bd62de45711eaaeb351041eec8dd9",
+ user_id="b400facee45711eaa9308bfd3d19e474",
+ )
+
+ # An Automation
+ automation_entity_id_test = "automation.alarm"
+ hass.bus.async_fire(
+ EVENT_AUTOMATION_TRIGGERED,
+ {ATTR_NAME: "Mock automation", ATTR_ENTITY_ID: automation_entity_id_test},
+ context=context,
+ )
+ hass.bus.async_fire(
+ EVENT_SCRIPT_STARTED,
+ {ATTR_NAME: "Mock script", ATTR_ENTITY_ID: "script.mock_script"},
+ context=context,
+ )
+ hass.states.async_set(
+ automation_entity_id_test,
+ STATE_ON,
+ {ATTR_FRIENDLY_NAME: "Alarm Automation"},
+ context=context,
+ )
+
+ entity_id_test = "alarm_control_panel.area_001"
+ hass.states.async_set(entity_id_test, STATE_OFF, context=context)
+ await hass.async_block_till_done()
+ hass.states.async_set(entity_id_test, STATE_ON, context=context)
+ await hass.async_block_till_done()
+ entity_id_second = "alarm_control_panel.area_002"
+ hass.states.async_set(entity_id_second, STATE_OFF, context=context)
+ await hass.async_block_till_done()
+ hass.states.async_set(entity_id_second, STATE_ON, context=context)
+ await hass.async_block_till_done()
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+
+ await hass.async_add_job(
+ logbook.log_entry,
+ hass,
+ "mock_name",
+ "mock_message",
+ "alarm_control_panel",
+ "alarm_control_panel.area_003",
+ context,
+ )
+ await hass.async_block_till_done()
+
+ await hass.async_add_job(
+ logbook.log_entry,
+ hass,
+ "mock_name",
+ "mock_message",
+ "homeassistant",
+ None,
+ context,
+ )
+ await hass.async_block_till_done()
+
+ # A service call
+ light_turn_off_service_context = ha.Context(
+ id="9c5bd62de45711eaaeb351041eec8dd9",
+ user_id="9400facee45711eaa9308bfd3d19e474",
+ )
+ hass.states.async_set("light.switch", STATE_ON)
+ await hass.async_block_till_done()
+
+ hass.bus.async_fire(
+ EVENT_CALL_SERVICE,
+ {
+ ATTR_DOMAIN: "light",
+ ATTR_SERVICE: "turn_off",
+ ATTR_ENTITY_ID: "light.switch",
+ },
+ context=light_turn_off_service_context,
+ )
+ await hass.async_block_till_done()
+
+ hass.states.async_set(
+ "light.switch", STATE_OFF, context=light_turn_off_service_context
+ )
+ await hass.async_block_till_done()
+
+ await hass.async_add_job(trigger_db_commit, hass)
+ await hass.async_block_till_done()
+ await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done)
+
+ client = await hass_client()
+
+ # Today time 00:00:00
+ start = dt_util.utcnow().date()
+ start_date = datetime(start.year, start.month, start.day)
+
+ # Test today entries with filter by end_time
+ end_time = start + timedelta(hours=24)
+ response = await client.get(
+ f"/api/logbook/{start_date.isoformat()}?end_time={end_time}"
+ )
+ assert response.status == 200
+ json_dict = await response.json()
+
+ assert json_dict[0]["entity_id"] == "automation.alarm"
+ assert "context_entity_id" not in json_dict[0]
+ assert json_dict[0]["context_user_id"] == "b400facee45711eaa9308bfd3d19e474"
+
+ assert json_dict[1]["entity_id"] == "script.mock_script"
+ assert json_dict[1]["context_event_type"] == "automation_triggered"
+ assert json_dict[1]["context_entity_id"] == "automation.alarm"
+ assert json_dict[1]["context_entity_id_name"] == "Alarm Automation"
+ assert json_dict[1]["context_user_id"] == "b400facee45711eaa9308bfd3d19e474"
+
+ assert json_dict[2]["entity_id"] == entity_id_test
+ assert json_dict[2]["context_event_type"] == "automation_triggered"
+ assert json_dict[2]["context_entity_id"] == "automation.alarm"
+ assert json_dict[2]["context_entity_id_name"] == "Alarm Automation"
+ assert json_dict[2]["context_user_id"] == "b400facee45711eaa9308bfd3d19e474"
+
+ assert json_dict[3]["entity_id"] == entity_id_second
+ assert json_dict[3]["context_event_type"] == "automation_triggered"
+ assert json_dict[3]["context_entity_id"] == "automation.alarm"
+ assert json_dict[3]["context_entity_id_name"] == "Alarm Automation"
+ assert json_dict[3]["context_user_id"] == "b400facee45711eaa9308bfd3d19e474"
+
+ assert json_dict[4]["domain"] == "homeassistant"
+
+ assert json_dict[5]["entity_id"] == "alarm_control_panel.area_003"
+ assert json_dict[5]["context_event_type"] == "automation_triggered"
+ assert json_dict[5]["context_entity_id"] == "automation.alarm"
+ assert json_dict[5]["domain"] == "alarm_control_panel"
+ assert json_dict[5]["context_entity_id_name"] == "Alarm Automation"
+ assert json_dict[5]["context_user_id"] == "b400facee45711eaa9308bfd3d19e474"
+
+ assert json_dict[6]["domain"] == "homeassistant"
+ assert json_dict[6]["context_user_id"] == "b400facee45711eaa9308bfd3d19e474"
+
+ assert json_dict[7]["entity_id"] == "light.switch"
+ assert json_dict[7]["context_event_type"] == "call_service"
+ assert json_dict[7]["context_domain"] == "light"
+ assert json_dict[7]["context_service"] == "turn_off"
+ assert json_dict[7]["domain"] == "light"
+ assert json_dict[7]["context_user_id"] == "9400facee45711eaa9308bfd3d19e474"
+
+
+async def test_logbook_context_from_template(hass, hass_client):
+ """Test the logbook view with end_time and entity with automations and scripts."""
+ await hass.async_add_executor_job(init_recorder_component, hass)
+ await async_setup_component(hass, "logbook", {})
+ assert await async_setup_component(
+ hass,
+ "switch",
+ {
+ "switch": {
+ "platform": "template",
+ "switches": {
+ "test_template_switch": {
+ "value_template": "{{ states.switch.test_state.state }}",
+ "turn_on": {
+ "service": "switch.turn_on",
+ "entity_id": "switch.test_state",
+ },
+ "turn_off": {
+ "service": "switch.turn_off",
+ "entity_id": "switch.test_state",
+ },
+ }
+ },
+ }
+ },
+ )
+ await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done)
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ # Entity added (should not be logged)
+ hass.states.async_set("switch.test_state", STATE_ON)
+ await hass.async_block_till_done()
+
+ # First state change (should be logged)
+ hass.states.async_set("switch.test_state", STATE_OFF)
+ await hass.async_block_till_done()
+
+ switch_turn_off_context = ha.Context(
+ id="9c5bd62de45711eaaeb351041eec8dd9",
+ user_id="9400facee45711eaa9308bfd3d19e474",
+ )
+ hass.states.async_set(
+ "switch.test_state", STATE_ON, context=switch_turn_off_context
+ )
+ await hass.async_block_till_done()
+
+ await hass.async_add_executor_job(trigger_db_commit, hass)
+ await hass.async_block_till_done()
+ await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done)
+
+ client = await hass_client()
+
+ # Today time 00:00:00
+ start = dt_util.utcnow().date()
+ start_date = datetime(start.year, start.month, start.day)
+
+ # Test today entries with filter by end_time
+ end_time = start + timedelta(hours=24)
+ response = await client.get(
+ f"/api/logbook/{start_date.isoformat()}?end_time={end_time}"
+ )
+ assert response.status == 200
+ json_dict = await response.json()
+
+ assert json_dict[0]["domain"] == "homeassistant"
+ assert "context_entity_id" not in json_dict[0]
+
+ assert json_dict[1]["entity_id"] == "switch.test_template_switch"
+
+ assert json_dict[2]["entity_id"] == "switch.test_state"
+
+ assert json_dict[3]["entity_id"] == "switch.test_template_switch"
+ assert json_dict[3]["context_entity_id"] == "switch.test_state"
+ assert json_dict[3]["context_entity_id_name"] == "test state"
+
+ assert json_dict[4]["entity_id"] == "switch.test_state"
+ assert json_dict[4]["context_user_id"] == "9400facee45711eaa9308bfd3d19e474"
+
+ assert json_dict[5]["entity_id"] == "switch.test_template_switch"
+ assert json_dict[5]["context_entity_id"] == "switch.test_state"
+ assert json_dict[5]["context_entity_id_name"] == "test state"
+ assert json_dict[5]["context_user_id"] == "9400facee45711eaa9308bfd3d19e474"
+
+
+async def test_logbook_entity_matches_only(hass, hass_client):
+ """Test the logbook view with a single entity and entity_matches_only."""
+ await hass.async_add_executor_job(init_recorder_component, hass)
+ await async_setup_component(hass, "logbook", {})
+ assert await async_setup_component(
+ hass,
+ "switch",
+ {
+ "switch": {
+ "platform": "template",
+ "switches": {
+ "test_template_switch": {
+ "value_template": "{{ states.switch.test_state.state }}",
+ "turn_on": {
+ "service": "switch.turn_on",
+ "entity_id": "switch.test_state",
+ },
+ "turn_off": {
+ "service": "switch.turn_off",
+ "entity_id": "switch.test_state",
+ },
+ }
+ },
+ }
+ },
+ )
+ await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done)
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ # Entity added (should not be logged)
+ hass.states.async_set("switch.test_state", STATE_ON)
+ await hass.async_block_till_done()
+
+ # First state change (should be logged)
+ hass.states.async_set("switch.test_state", STATE_OFF)
+ await hass.async_block_till_done()
+
+ switch_turn_off_context = ha.Context(
+ id="9c5bd62de45711eaaeb351041eec8dd9",
+ user_id="9400facee45711eaa9308bfd3d19e474",
+ )
+ hass.states.async_set(
+ "switch.test_state", STATE_ON, context=switch_turn_off_context
+ )
+ await hass.async_block_till_done()
+
+ await hass.async_add_executor_job(trigger_db_commit, hass)
+ await hass.async_block_till_done()
+ await hass.async_add_executor_job(hass.data[recorder.DATA_INSTANCE].block_till_done)
+
+ client = await hass_client()
+
+ # Today time 00:00:00
+ start = dt_util.utcnow().date()
+ start_date = datetime(start.year, start.month, start.day)
+
+ # Test today entries with filter by end_time
+ end_time = start + timedelta(hours=24)
+ response = await client.get(
+ f"/api/logbook/{start_date.isoformat()}?end_time={end_time}&entity=switch.test_state&entity_matches_only"
+ )
+ assert response.status == 200
+ json_dict = await response.json()
+
+ assert len(json_dict) == 2
+
+ assert json_dict[0]["entity_id"] == "switch.test_state"
+ assert json_dict[0]["message"] == "turned off"
+
+ assert json_dict[1]["entity_id"] == "switch.test_state"
+ assert json_dict[1]["context_user_id"] == "9400facee45711eaa9308bfd3d19e474"
+ assert json_dict[1]["message"] == "turned on"
+
+
+async def test_logbook_invalid_entity(hass, hass_client):
+ """Test the logbook view with requesting an invalid entity."""
+ await hass.async_add_executor_job(init_recorder_component, hass)
+ await async_setup_component(hass, "logbook", {})
+ await hass.async_block_till_done()
+ client = await hass_client()
+
+ # Today time 00:00:00
+ start = dt_util.utcnow().date()
+ start_date = datetime(start.year, start.month, start.day)
+
+ # Test today entries with filter by end_time
+ end_time = start + timedelta(hours=24)
+ response = await client.get(
+ f"/api/logbook/{start_date.isoformat()}?end_time={end_time}&entity=invalid&entity_matches_only"
+ )
+ assert response.status == 500
+
+
class MockLazyEventPartialState(ha.Event):
"""Minimal mock of a Lazy event."""
+ @property
+ def data_entity_id(self):
+ """Lookup entity id."""
+ return self.data.get(ATTR_ENTITY_ID)
+
+ @property
+ def data_domain(self):
+ """Lookup domain."""
+ return self.data.get(ATTR_DOMAIN)
+
@property
def time_fired_minute(self):
"""Minute the event was fired."""
@@ -1850,6 +2199,11 @@ class MockLazyEventPartialState(ha.Event):
"""Context user id of event."""
return self.context.user_id
+ @property
+ def context_id(self):
+ """Context id of event."""
+ return self.context.id
+
@property
def time_fired_isoformat(self):
"""Time event was fired in utc isoformat."""
diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py
index fc9c5fe279d..4ec5f14237c 100644
--- a/tests/components/lutron_caseta/test_config_flow.py
+++ b/tests/components/lutron_caseta/test_config_flow.py
@@ -50,7 +50,8 @@ async def test_bridge_import_flow(hass):
}
with patch(
- "homeassistant.components.lutron_caseta.async_setup_entry", return_value=True,
+ "homeassistant.components.lutron_caseta.async_setup_entry",
+ return_value=True,
) as mock_setup_entry, patch(
"homeassistant.components.lutron_caseta.async_setup", return_value=True
), patch.object(
@@ -143,7 +144,8 @@ async def test_duplicate_bridge_import(hass):
mock_entry.add_to_hass(hass)
with patch(
- "homeassistant.components.lutron_caseta.async_setup_entry", return_value=True,
+ "homeassistant.components.lutron_caseta.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
# Mock entry added, try initializing flow with duplicate host
result = await hass.config_entries.flow.async_init(
diff --git a/tests/components/mailgun/test_init.py b/tests/components/mailgun/test_init.py
index 5d6cec844f2..fd244d87a8f 100644
--- a/tests/components/mailgun/test_init.py
+++ b/tests/components/mailgun/test_init.py
@@ -31,7 +31,8 @@ async def webhook_id_with_api_key(hass):
)
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
result = await hass.config_entries.flow.async_init(
"mailgun", context={"source": "user"}
@@ -50,7 +51,8 @@ async def webhook_id_without_api_key(hass):
await async_setup_component(hass, mailgun.DOMAIN, {})
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
result = await hass.config_entries.flow.async_init(
"mailgun", context={"source": "user"}
diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py
index da221a7effd..ce2092dd607 100644
--- a/tests/components/marytts/test_tts.py
+++ b/tests/components/marytts/test_tts.py
@@ -2,7 +2,6 @@
import asyncio
import os
import shutil
-from urllib.parse import urlencode
from homeassistant.components.media_player.const import (
ATTR_MEDIA_CONTENT_ID,
@@ -11,10 +10,9 @@ from homeassistant.components.media_player.const import (
)
import homeassistant.components.tts as tts
from homeassistant.config import async_process_ha_core_config
-from homeassistant.const import HTTP_INTERNAL_SERVER_ERROR
from homeassistant.setup import setup_component
-from tests.async_mock import Mock, patch
+from tests.async_mock import patch
from tests.common import assert_setup_component, get_test_home_assistant, mock_service
@@ -62,18 +60,15 @@ class TestTTSMaryTTSPlatform:
"""Test service call say."""
calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- conn = Mock()
- response = Mock()
- conn.getresponse.return_value = response
- response.status = 200
- response.read.return_value = b"audio"
-
config = {tts.DOMAIN: {"platform": "marytts"}}
with assert_setup_component(1, tts.DOMAIN):
setup_component(self.hass, tts.DOMAIN, config)
- with patch("http.client.HTTPConnection", return_value=conn):
+ with patch(
+ "homeassistant.components.marytts.tts.MaryTTS.speak",
+ return_value=b"audio",
+ ) as mock_speak:
self.hass.services.call(
tts.DOMAIN,
"marytts_say",
@@ -82,22 +77,18 @@ class TestTTSMaryTTSPlatform:
tts.ATTR_MESSAGE: "HomeAssistant",
},
)
- self.hass.block_till_done()
+ self.hass.block_till_done()
+
+ mock_speak.assert_called_once()
+ mock_speak.assert_called_with("HomeAssistant", {})
assert len(calls) == 1
assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find(".wav") != -1
- conn.request.assert_called_with("POST", "/process", urlencode(self.params))
def test_service_say_with_effect(self):
"""Test service call say with effects."""
calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- conn = Mock()
- response = Mock()
- conn.getresponse.return_value = response
- response.status = 200
- response.read.return_value = b"audio"
-
config = {
tts.DOMAIN: {"platform": "marytts", "effect": {"Volume": "amount:2.0;"}}
}
@@ -105,7 +96,10 @@ class TestTTSMaryTTSPlatform:
with assert_setup_component(1, tts.DOMAIN):
setup_component(self.hass, tts.DOMAIN, config)
- with patch("http.client.HTTPConnection", return_value=conn):
+ with patch(
+ "homeassistant.components.marytts.tts.MaryTTS.speak",
+ return_value=b"audio",
+ ) as mock_speak:
self.hass.services.call(
tts.DOMAIN,
"marytts_say",
@@ -114,33 +108,27 @@ class TestTTSMaryTTSPlatform:
tts.ATTR_MESSAGE: "HomeAssistant",
},
)
- self.hass.block_till_done()
+ self.hass.block_till_done()
+
+ mock_speak.assert_called_once()
+ mock_speak.assert_called_with("HomeAssistant", {"Volume": "amount:2.0;"})
assert len(calls) == 1
assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find(".wav") != -1
- self.params.update(
- {"effect_Volume_selected": "on", "effect_Volume_parameters": "amount:2.0;"}
- )
- conn.request.assert_called_with("POST", "/process", urlencode(self.params))
-
def test_service_say_http_error(self):
"""Test service call say."""
calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
- conn = Mock()
- response = Mock()
- conn.getresponse.return_value = response
- response.status = HTTP_INTERNAL_SERVER_ERROR
- response.reason = "test"
- response.readline.return_value = "content"
-
config = {tts.DOMAIN: {"platform": "marytts"}}
with assert_setup_component(1, tts.DOMAIN):
setup_component(self.hass, tts.DOMAIN, config)
- with patch("http.client.HTTPConnection", return_value=conn):
+ with patch(
+ "homeassistant.components.marytts.tts.MaryTTS.speak",
+ side_effect=Exception(),
+ ) as mock_speak:
self.hass.services.call(
tts.DOMAIN,
"marytts_say",
@@ -149,7 +137,7 @@ class TestTTSMaryTTSPlatform:
tts.ATTR_MESSAGE: "HomeAssistant",
},
)
- self.hass.block_till_done()
+ self.hass.block_till_done()
+ mock_speak.assert_called_once()
assert len(calls) == 0
- conn.request.assert_called_with("POST", "/process", urlencode(self.params))
diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py
index d5eac466093..02012a1f71d 100644
--- a/tests/components/media_player/test_init.py
+++ b/tests/components/media_player/test_init.py
@@ -100,3 +100,60 @@ def test_deprecated_base_class(caplog):
CustomMediaPlayer()
assert "MediaPlayerDevice is deprecated, modify CustomMediaPlayer" in caplog.text
+
+
+async def test_media_browse(hass, hass_ws_client):
+ """Test browsing media."""
+ await async_setup_component(
+ hass, "media_player", {"media_player": {"platform": "demo"}}
+ )
+ await hass.async_block_till_done()
+
+ client = await hass_ws_client(hass)
+
+ with patch(
+ "homeassistant.components.demo.media_player.YOUTUBE_PLAYER_SUPPORT",
+ media_player.SUPPORT_BROWSE_MEDIA,
+ ), patch(
+ "homeassistant.components.media_player.MediaPlayerEntity." "async_browse_media",
+ return_value={"bla": "yo"},
+ ) as mock_browse_media:
+ await client.send_json(
+ {
+ "id": 5,
+ "type": "media_player/browse_media",
+ "entity_id": "media_player.bedroom",
+ "media_content_type": "album",
+ "media_content_id": "abcd",
+ }
+ )
+
+ msg = await client.receive_json()
+
+ assert msg["id"] == 5
+ assert msg["type"] == TYPE_RESULT
+ assert msg["success"]
+ assert msg["result"] == {"bla": "yo"}
+ assert mock_browse_media.mock_calls[0][1] == ("album", "abcd")
+
+ with patch(
+ "homeassistant.components.demo.media_player.YOUTUBE_PLAYER_SUPPORT",
+ media_player.SUPPORT_BROWSE_MEDIA,
+ ), patch(
+ "homeassistant.components.media_player.MediaPlayerEntity." "async_browse_media",
+ return_value={"bla": "yo"},
+ ):
+ await client.send_json(
+ {
+ "id": 6,
+ "type": "media_player/browse_media",
+ "entity_id": "media_player.bedroom",
+ }
+ )
+
+ msg = await client.receive_json()
+
+ assert msg["id"] == 6
+ assert msg["type"] == TYPE_RESULT
+ assert msg["success"]
+ assert msg["result"] == {"bla": "yo"}
diff --git a/tests/components/media_source/__init__.py b/tests/components/media_source/__init__.py
new file mode 100644
index 00000000000..d5e56d9d31d
--- /dev/null
+++ b/tests/components/media_source/__init__.py
@@ -0,0 +1 @@
+"""The tests for Media Source integration."""
diff --git a/tests/components/media_source/test_init.py b/tests/components/media_source/test_init.py
new file mode 100644
index 00000000000..a891fb0d11d
--- /dev/null
+++ b/tests/components/media_source/test_init.py
@@ -0,0 +1,180 @@
+"""Test Media Source initialization."""
+import pytest
+
+from homeassistant.components import media_source
+from homeassistant.components.media_player.const import MEDIA_CLASS_DIRECTORY
+from homeassistant.components.media_player.errors import BrowseError
+from homeassistant.components.media_source import const
+from homeassistant.components.media_source.error import Unresolvable
+from homeassistant.setup import async_setup_component
+
+from tests.async_mock import patch
+
+
+async def test_is_media_source_id():
+ """Test media source validation."""
+ assert media_source.is_media_source_id(const.URI_SCHEME)
+ assert media_source.is_media_source_id(f"{const.URI_SCHEME}domain")
+ assert media_source.is_media_source_id(f"{const.URI_SCHEME}domain/identifier")
+ assert not media_source.is_media_source_id("test")
+
+
+async def test_generate_media_source_id():
+ """Test identifier generation."""
+ tests = [
+ (None, None),
+ (None, ""),
+ ("", ""),
+ ("domain", None),
+ ("domain", ""),
+ ("domain", "identifier"),
+ ]
+
+ for domain, identifier in tests:
+ assert media_source.is_media_source_id(
+ media_source.generate_media_source_id(domain, identifier)
+ )
+
+
+async def test_async_browse_media(hass):
+ """Test browse media."""
+ assert await async_setup_component(hass, const.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ # Test non-media ignored (/media has test.mp3 and not_media.txt)
+ media = await media_source.async_browse_media(hass, "")
+ assert isinstance(media, media_source.models.BrowseMediaSource)
+ assert media.title == "media/"
+ assert len(media.children) == 1
+
+ # Test invalid media content
+ with pytest.raises(ValueError):
+ await media_source.async_browse_media(hass, "invalid")
+
+ # Test base URI returns all domains
+ media = await media_source.async_browse_media(hass, const.URI_SCHEME)
+ assert isinstance(media, media_source.models.BrowseMediaSource)
+ assert len(media.children) == 1
+ assert media.children[0].title == "Local Media"
+
+
+async def test_async_resolve_media(hass):
+ """Test browse media."""
+ assert await async_setup_component(hass, const.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ media = await media_source.async_resolve_media(
+ hass,
+ media_source.generate_media_source_id(const.DOMAIN, "local/test.mp3"),
+ )
+ assert isinstance(media, media_source.models.PlayMedia)
+
+
+async def test_async_unresolve_media(hass):
+ """Test browse media."""
+ assert await async_setup_component(hass, const.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ # Test no media content
+ with pytest.raises(Unresolvable):
+ await media_source.async_resolve_media(hass, "")
+
+
+async def test_websocket_browse_media(hass, hass_ws_client):
+ """Test browse media websocket."""
+ assert await async_setup_component(hass, const.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ client = await hass_ws_client(hass)
+
+ media = media_source.models.BrowseMediaSource(
+ domain=const.DOMAIN,
+ identifier="/media",
+ title="Local Media",
+ media_class=MEDIA_CLASS_DIRECTORY,
+ media_content_type="listing",
+ can_play=False,
+ can_expand=True,
+ )
+
+ with patch(
+ "homeassistant.components.media_source.async_browse_media",
+ return_value=media,
+ ):
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "media_source/browse_media",
+ }
+ )
+
+ msg = await client.receive_json()
+
+ assert msg["success"]
+ assert msg["id"] == 1
+ assert media.as_dict() == msg["result"]
+
+ with patch(
+ "homeassistant.components.media_source.async_browse_media",
+ side_effect=BrowseError("test"),
+ ):
+ await client.send_json(
+ {
+ "id": 2,
+ "type": "media_source/browse_media",
+ "media_content_id": "invalid",
+ }
+ )
+
+ msg = await client.receive_json()
+
+ assert not msg["success"]
+ assert msg["error"]["code"] == "browse_media_failed"
+ assert msg["error"]["message"] == "test"
+
+
+async def test_websocket_resolve_media(hass, hass_ws_client):
+ """Test browse media websocket."""
+ assert await async_setup_component(hass, const.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ client = await hass_ws_client(hass)
+
+ media = media_source.models.PlayMedia("/media/local/test.mp3", "audio/mpeg")
+
+ with patch(
+ "homeassistant.components.media_source.async_resolve_media",
+ return_value=media,
+ ):
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "media_source/resolve_media",
+ "media_content_id": f"{const.URI_SCHEME}{const.DOMAIN}/local/test.mp3",
+ }
+ )
+
+ msg = await client.receive_json()
+
+ assert msg["success"]
+ assert msg["id"] == 1
+ assert msg["result"]["url"].startswith(media.url)
+ assert msg["result"]["mime_type"] == media.mime_type
+
+ with patch(
+ "homeassistant.components.media_source.async_resolve_media",
+ side_effect=media_source.Unresolvable("test"),
+ ):
+ await client.send_json(
+ {
+ "id": 2,
+ "type": "media_source/resolve_media",
+ "media_content_id": "invalid",
+ }
+ )
+
+ msg = await client.receive_json()
+
+ assert not msg["success"]
+ assert msg["error"]["code"] == "resolve_media_failed"
+ assert msg["error"]["message"] == "test"
diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py
new file mode 100644
index 00000000000..e3e2a3f1617
--- /dev/null
+++ b/tests/components/media_source/test_local_source.py
@@ -0,0 +1,99 @@
+"""Test Local Media Source."""
+import pytest
+
+from homeassistant.components import media_source
+from homeassistant.components.media_source import const
+from homeassistant.config import async_process_ha_core_config
+from homeassistant.setup import async_setup_component
+
+
+async def test_async_browse_media(hass):
+ """Test browse media."""
+ local_media = hass.config.path("media")
+ await async_process_ha_core_config(
+ hass, {"media_dirs": {"local": local_media, "recordings": local_media}}
+ )
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, const.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ # Test path not exists
+ with pytest.raises(media_source.BrowseError) as excinfo:
+ await media_source.async_browse_media(
+ hass, f"{const.URI_SCHEME}{const.DOMAIN}/local/test/not/exist"
+ )
+ assert str(excinfo.value) == "Path does not exist."
+
+ # Test browse file
+ with pytest.raises(media_source.BrowseError) as excinfo:
+ await media_source.async_browse_media(
+ hass, f"{const.URI_SCHEME}{const.DOMAIN}/local/test.mp3"
+ )
+ assert str(excinfo.value) == "Path is not a directory."
+
+ # Test invalid base
+ with pytest.raises(media_source.BrowseError) as excinfo:
+ await media_source.async_browse_media(
+ hass, f"{const.URI_SCHEME}{const.DOMAIN}/invalid/base"
+ )
+ assert str(excinfo.value) == "Unknown source directory."
+
+ # Test directory traversal
+ with pytest.raises(media_source.BrowseError) as excinfo:
+ await media_source.async_browse_media(
+ hass, f"{const.URI_SCHEME}{const.DOMAIN}/local/../configuration.yaml"
+ )
+ assert str(excinfo.value) == "Invalid path."
+
+ # Test successful listing
+ media = await media_source.async_browse_media(
+ hass, f"{const.URI_SCHEME}{const.DOMAIN}"
+ )
+ assert media
+
+ media = await media_source.async_browse_media(
+ hass, f"{const.URI_SCHEME}{const.DOMAIN}/local/."
+ )
+ assert media
+
+ media = await media_source.async_browse_media(
+ hass, f"{const.URI_SCHEME}{const.DOMAIN}/recordings/."
+ )
+ assert media
+
+
+async def test_media_view(hass, hass_client):
+ """Test media view."""
+ local_media = hass.config.path("media")
+ await async_process_ha_core_config(
+ hass, {"media_dirs": {"local": local_media, "recordings": local_media}}
+ )
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, const.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ client = await hass_client()
+
+ # Protects against non-existent files
+ resp = await client.get("/media/local/invalid.txt")
+ assert resp.status == 404
+
+ resp = await client.get("/media/recordings/invalid.txt")
+ assert resp.status == 404
+
+ # Protects against non-media files
+ resp = await client.get("/media/local/not_media.txt")
+ assert resp.status == 404
+
+ # Protects against unknown local media sources
+ resp = await client.get("/media/unknown_source/not_media.txt")
+ assert resp.status == 404
+
+ # Fetch available media
+ resp = await client.get("/media/local/test.mp3")
+ assert resp.status == 200
+
+ resp = await client.get("/media/recordings/test.mp3")
+ assert resp.status == 200
diff --git a/tests/components/media_source/test_models.py b/tests/components/media_source/test_models.py
new file mode 100644
index 00000000000..8372382bb7a
--- /dev/null
+++ b/tests/components/media_source/test_models.py
@@ -0,0 +1,73 @@
+"""Test Media Source model methods."""
+from homeassistant.components.media_player.const import (
+ MEDIA_CLASS_DIRECTORY,
+ MEDIA_CLASS_MUSIC,
+ MEDIA_TYPE_MUSIC,
+)
+from homeassistant.components.media_source import const, models
+
+
+async def test_browse_media_as_dict():
+ """Test BrowseMediaSource conversion to media player item dict."""
+ base = models.BrowseMediaSource(
+ domain=const.DOMAIN,
+ identifier="media",
+ media_class=MEDIA_CLASS_DIRECTORY,
+ media_content_type="folder",
+ title="media/",
+ can_play=False,
+ can_expand=True,
+ children_media_class=MEDIA_CLASS_MUSIC,
+ )
+ base.children = [
+ models.BrowseMediaSource(
+ domain=const.DOMAIN,
+ identifier="media/test.mp3",
+ media_class=MEDIA_CLASS_MUSIC,
+ media_content_type=MEDIA_TYPE_MUSIC,
+ title="test.mp3",
+ can_play=True,
+ can_expand=False,
+ )
+ ]
+
+ item = base.as_dict()
+ assert item["title"] == "media/"
+ assert item["media_class"] == MEDIA_CLASS_DIRECTORY
+ assert item["media_content_type"] == "folder"
+ assert item["media_content_id"] == f"{const.URI_SCHEME}{const.DOMAIN}/media"
+ assert not item["can_play"]
+ assert item["can_expand"]
+ assert item["children_media_class"] == MEDIA_CLASS_MUSIC
+ assert len(item["children"]) == 1
+ assert item["children"][0]["title"] == "test.mp3"
+ assert item["children"][0]["media_class"] == MEDIA_CLASS_MUSIC
+
+
+async def test_browse_media_parent_no_children():
+ """Test BrowseMediaSource conversion to media player item dict."""
+ base = models.BrowseMediaSource(
+ domain=const.DOMAIN,
+ identifier="media",
+ media_class=MEDIA_CLASS_DIRECTORY,
+ media_content_type="folder",
+ title="media/",
+ can_play=False,
+ can_expand=True,
+ )
+
+ item = base.as_dict()
+ assert item["title"] == "media/"
+ assert item["media_class"] == MEDIA_CLASS_DIRECTORY
+ assert item["media_content_type"] == "folder"
+ assert item["media_content_id"] == f"{const.URI_SCHEME}{const.DOMAIN}/media"
+ assert not item["can_play"]
+ assert item["can_expand"]
+ assert len(item["children"]) == 0
+ assert item["children_media_class"] is None
+
+
+async def test_media_source_default_name():
+ """Test MediaSource uses domain as default name."""
+ source = models.MediaSource(const.DOMAIN)
+ assert source.name == const.DOMAIN
diff --git a/tests/components/melcloud/test_config_flow.py b/tests/components/melcloud/test_config_flow.py
index e6b36306986..ab3d16a0d6a 100644
--- a/tests/components/melcloud/test_config_flow.py
+++ b/tests/components/melcloud/test_config_flow.py
@@ -16,7 +16,9 @@ from tests.common import MockConfigEntry
@pytest.fixture
def mock_login():
"""Mock pymelcloud login."""
- with patch("pymelcloud.login") as mock:
+ with patch(
+ "homeassistant.components.melcloud.config_flow.pymelcloud.login"
+ ) as mock:
mock.return_value = "test-token"
yield mock
@@ -24,7 +26,9 @@ def mock_login():
@pytest.fixture
def mock_get_devices():
"""Mock pymelcloud get_devices."""
- with patch("pymelcloud.get_devices") as mock:
+ with patch(
+ "homeassistant.components.melcloud.config_flow.pymelcloud.get_devices"
+ ) as mock:
mock.return_value = {
pymelcloud.DEVICE_TYPE_ATA: [],
pymelcloud.DEVICE_TYPE_ATW: [],
diff --git a/tests/components/melissa/test_init.py b/tests/components/melissa/test_init.py
index 7e174a4f8a0..b9e09a5d769 100644
--- a/tests/components/melissa/test_init.py
+++ b/tests/components/melissa/test_init.py
@@ -18,5 +18,6 @@ async def test_setup(hass):
assert melissa.DATA_MELISSA in hass.data
assert isinstance(
- hass.data[melissa.DATA_MELISSA], type(mocked_melissa.return_value),
+ hass.data[melissa.DATA_MELISSA],
+ type(mocked_melissa.return_value),
)
diff --git a/tests/components/met/__init__.py b/tests/components/met/__init__.py
index 658ed2901ec..c238fec4cb7 100644
--- a/tests/components/met/__init__.py
+++ b/tests/components/met/__init__.py
@@ -1 +1,26 @@
"""Tests for Met.no."""
+from homeassistant.components.met.const import DOMAIN
+from homeassistant.const import CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+
+async def init_integration(hass) -> MockConfigEntry:
+ """Set up the Met integration in Home Assistant."""
+ entry_data = {
+ CONF_NAME: "test",
+ CONF_LATITUDE: 0,
+ CONF_LONGITUDE: 0,
+ CONF_ELEVATION: 0,
+ }
+ entry = MockConfigEntry(domain=DOMAIN, data=entry_data)
+ with patch(
+ "homeassistant.components.met.metno.MetWeatherData.fetching_data",
+ return_value=True,
+ ):
+ entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ return entry
diff --git a/tests/components/met/test_config_flow.py b/tests/components/met/test_config_flow.py
index a4c48f38245..4d820c1c7c6 100644
--- a/tests/components/met/test_config_flow.py
+++ b/tests/components/met/test_config_flow.py
@@ -103,3 +103,21 @@ async def test_onboarding_step(hass):
assert result["type"] == "create_entry"
assert result["title"] == HOME_LOCATION_NAME
assert result["data"] == {"track_home": True}
+
+
+async def test_import_step(hass):
+ """Test initializing via import step."""
+ test_data = {
+ "name": "home",
+ CONF_LONGITUDE: None,
+ CONF_LATITUDE: None,
+ CONF_ELEVATION: 0,
+ "track_home": True,
+ }
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "import"}, data=test_data
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["title"] == "home"
+ assert result["data"] == test_data
diff --git a/tests/components/met/test_init.py b/tests/components/met/test_init.py
new file mode 100644
index 00000000000..a3323f01565
--- /dev/null
+++ b/tests/components/met/test_init.py
@@ -0,0 +1,19 @@
+"""Test the Met integration init."""
+from homeassistant.components.met.const import DOMAIN
+from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED
+
+from . import init_integration
+
+
+async def test_unload_entry(hass):
+ """Test successful unload of entry."""
+ entry = await init_integration(hass)
+
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+ assert entry.state == ENTRY_STATE_LOADED
+
+ assert await hass.config_entries.async_unload(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.state == ENTRY_STATE_NOT_LOADED
+ assert not hass.data.get(DOMAIN)
diff --git a/tests/components/met/test_weather.py b/tests/components/met/test_weather.py
index 4577146c270..242352c2498 100644
--- a/tests/components/met/test_weather.py
+++ b/tests/components/met/test_weather.py
@@ -1,18 +1,32 @@
"""Test Met weather entity."""
+from homeassistant.components.met import DOMAIN
+from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN
+
async def test_tracking_home(hass, mock_weather):
"""Test we track home."""
await hass.config_entries.flow.async_init("met", context={"source": "onboarding"})
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids("weather")) == 1
- assert len(mock_weather.mock_calls) == 3
+ assert len(mock_weather.mock_calls) == 4
+
+ # Test the hourly sensor is disabled by default
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ state = hass.states.get("weather.test_home_hourly")
+ assert state is None
+
+ entry = registry.async_get("weather.test_home_hourly")
+ assert entry
+ assert entry.disabled
+ assert entry.disabled_by == "integration"
# Test we track config
await hass.config.async_update(latitude=10, longitude=20)
await hass.async_block_till_done()
- assert len(mock_weather.mock_calls) == 6
+ assert len(mock_weather.mock_calls) == 8
entry = hass.config_entries.async_entries()[0]
await hass.config_entries.async_remove(entry.entry_id)
@@ -21,20 +35,31 @@ async def test_tracking_home(hass, mock_weather):
async def test_not_tracking_home(hass, mock_weather):
"""Test when we not track home."""
+
+ # Pre-create registry entry for disabled by default hourly weather
+ registry = await hass.helpers.entity_registry.async_get_registry()
+ registry.async_get_or_create(
+ WEATHER_DOMAIN,
+ DOMAIN,
+ "10-20-hourly",
+ suggested_object_id="somewhere_hourly",
+ disabled_by=None,
+ )
+
await hass.config_entries.flow.async_init(
"met",
context={"source": "user"},
data={"name": "Somewhere", "latitude": 10, "longitude": 20, "elevation": 0},
)
await hass.async_block_till_done()
- assert len(hass.states.async_entity_ids("weather")) == 1
- assert len(mock_weather.mock_calls) == 3
+ assert len(hass.states.async_entity_ids("weather")) == 2
+ assert len(mock_weather.mock_calls) == 4
# Test we do not track config
await hass.config.async_update(latitude=10, longitude=20)
await hass.async_block_till_done()
- assert len(mock_weather.mock_calls) == 3
+ assert len(mock_weather.mock_calls) == 4
entry = hass.config_entries.async_entries()[0]
await hass.config_entries.async_remove(entry.entry_id)
diff --git a/tests/components/meteo_france/test_config_flow.py b/tests/components/meteo_france/test_config_flow.py
index 650a88df84e..3aa70aa48b3 100644
--- a/tests/components/meteo_france/test_config_flow.py
+++ b/tests/components/meteo_france/test_config_flow.py
@@ -84,9 +84,11 @@ def mock_controller_client_single():
def mock_setup():
"""Prevent setup."""
with patch(
- "homeassistant.components.meteo_france.async_setup", return_value=True,
+ "homeassistant.components.meteo_france.async_setup",
+ return_value=True,
), patch(
- "homeassistant.components.meteo_france.async_setup_entry", return_value=True,
+ "homeassistant.components.meteo_france.async_setup_entry",
+ return_value=True,
):
yield
@@ -123,7 +125,9 @@ async def test_user(hass, client_single):
# test with all provided with search returning only 1 place
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL},
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_CITY: CITY_1_POSTAL},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["result"].unique_id == f"{CITY_1_LAT}, {CITY_1_LON}"
@@ -137,7 +141,9 @@ async def test_user_list(hass, client_multiple):
# test with all provided with search returning more than 1 place
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_2_NAME},
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_CITY: CITY_2_NAME},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "cities"
@@ -157,7 +163,9 @@ async def test_import(hass, client_multiple):
"""Test import step."""
# import with all
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_CITY: CITY_2_NAME},
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={CONF_CITY: CITY_2_NAME},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["result"].unique_id == f"{CITY_2_LAT}, {CITY_2_LON}"
@@ -169,7 +177,9 @@ async def test_import(hass, client_multiple):
async def test_search_failed(hass, client_empty):
"""Test error displayed if no result in search."""
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL},
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_CITY: CITY_1_POSTAL},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -186,14 +196,18 @@ async def test_abort_if_already_setup(hass, client_single):
# Should fail, same CITY same postal code (import)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_IMPORT}, data={CONF_CITY: CITY_1_POSTAL},
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data={CONF_CITY: CITY_1_POSTAL},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
# Should fail, same CITY same postal code (flow)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data={CONF_CITY: CITY_1_POSTAL},
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data={CONF_CITY: CITY_1_POSTAL},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
@@ -216,7 +230,8 @@ async def test_options_flow(hass: HomeAssistantType):
# Default
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={},
+ result["flow_id"],
+ user_input={},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options[CONF_MODE] == FORECAST_MODE_DAILY
@@ -224,7 +239,8 @@ async def test_options_flow(hass: HomeAssistantType):
# Manual
result = await hass.config_entries.options.async_init(config_entry.entry_id)
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={CONF_MODE: FORECAST_MODE_HOURLY},
+ result["flow_id"],
+ user_input={CONF_MODE: FORECAST_MODE_HOURLY},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options[CONF_MODE] == FORECAST_MODE_HOURLY
diff --git a/tests/components/metoffice/__init__.py b/tests/components/metoffice/__init__.py
index fdefc3d4786..e3611eb7973 100644
--- a/tests/components/metoffice/__init__.py
+++ b/tests/components/metoffice/__init__.py
@@ -1 +1,12 @@
"""Tests for the metoffice component."""
+
+import datetime
+
+
+class NewDateTime(datetime.datetime):
+ """Patch time to a specific point."""
+
+ @classmethod
+ def now(cls, *args, **kwargs): # pylint: disable=signature-differs
+ """Overload datetime.datetime.now."""
+ return cls(2020, 4, 25, 12, tzinfo=datetime.timezone.utc)
diff --git a/tests/components/metoffice/test_config_flow.py b/tests/components/metoffice/test_config_flow.py
index 6916e949b1c..5987d44ac27 100644
--- a/tests/components/metoffice/test_config_flow.py
+++ b/tests/components/metoffice/test_config_flow.py
@@ -36,7 +36,8 @@ async def test_form(hass, requests_mock):
with patch(
"homeassistant.components.metoffice.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.metoffice.async_setup_entry", return_value=True,
+ "homeassistant.components.metoffice.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {"api_key": TEST_API_KEY}
@@ -67,7 +68,8 @@ async def test_form_already_configured(hass, requests_mock):
requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites)
requests_mock.get(
- "/public/data/val/wxfcs/all/json/354107?res=3hourly", text="",
+ "/public/data/val/wxfcs/all/json/354107?res=3hourly",
+ text="",
)
MockConfigEntry(
@@ -98,7 +100,8 @@ async def test_form_cannot_connect(hass, requests_mock):
)
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"api_key": TEST_API_KEY},
+ result["flow_id"],
+ {"api_key": TEST_API_KEY},
)
assert result2["type"] == "form"
@@ -115,7 +118,8 @@ async def test_form_unknown_error(hass, mock_simple_manager_fail):
)
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"api_key": TEST_API_KEY},
+ result["flow_id"],
+ {"api_key": TEST_API_KEY},
)
assert result2["type"] == "form"
diff --git a/tests/components/metoffice/test_sensor.py b/tests/components/metoffice/test_sensor.py
index 5d6f2787861..43dbf3f75e0 100644
--- a/tests/components/metoffice/test_sensor.py
+++ b/tests/components/metoffice/test_sensor.py
@@ -1,9 +1,9 @@
"""The tests for the Met Office sensor component."""
-from datetime import datetime, timezone
import json
from homeassistant.components.metoffice.const import ATTRIBUTION, DOMAIN
+from . import NewDateTime
from .const import (
DATETIME_FORMAT,
KINGSLYNN_SENSOR_RESULTS,
@@ -15,13 +15,13 @@ from .const import (
WAVERTREE_SENSOR_RESULTS,
)
-from tests.async_mock import Mock, patch
+from tests.async_mock import patch
from tests.common import MockConfigEntry, load_fixture
@patch(
"datapoint.Forecast.datetime.datetime",
- Mock(now=Mock(return_value=datetime(2020, 4, 25, 12, tzinfo=timezone.utc))),
+ NewDateTime,
)
async def test_one_sensor_site_running(hass, requests_mock, legacy_patchable_time):
"""Test the Met Office sensor platform."""
@@ -32,10 +32,14 @@ async def test_one_sensor_site_running(hass, requests_mock, legacy_patchable_tim
requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites)
requests_mock.get(
- "/public/data/val/wxfcs/all/json/354107?res=3hourly", text=wavertree_hourly,
+ "/public/data/val/wxfcs/all/json/354107?res=3hourly",
+ text=wavertree_hourly,
)
- entry = MockConfigEntry(domain=DOMAIN, data=METOFFICE_CONFIG_WAVERTREE,)
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=METOFFICE_CONFIG_WAVERTREE,
+ )
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -59,7 +63,7 @@ async def test_one_sensor_site_running(hass, requests_mock, legacy_patchable_tim
@patch(
"datapoint.Forecast.datetime.datetime",
- Mock(now=Mock(return_value=datetime(2020, 4, 25, 12, tzinfo=timezone.utc))),
+ NewDateTime,
)
async def test_two_sensor_sites_running(hass, requests_mock, legacy_patchable_time):
"""Test we handle two sets of sensors running for two different sites."""
@@ -78,10 +82,16 @@ async def test_two_sensor_sites_running(hass, requests_mock, legacy_patchable_ti
"/public/data/val/wxfcs/all/json/322380?res=3hourly", text=kingslynn_hourly
)
- entry = MockConfigEntry(domain=DOMAIN, data=METOFFICE_CONFIG_WAVERTREE,)
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=METOFFICE_CONFIG_WAVERTREE,
+ )
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
- entry2 = MockConfigEntry(domain=DOMAIN, data=METOFFICE_CONFIG_KINGSLYNN,)
+ entry2 = MockConfigEntry(
+ domain=DOMAIN,
+ data=METOFFICE_CONFIG_KINGSLYNN,
+ )
entry2.add_to_hass(hass)
await hass.config_entries.async_setup(entry2.entry_id)
await hass.async_block_till_done()
diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py
index 05cec7ef46e..f1530021fcf 100644
--- a/tests/components/metoffice/test_weather.py
+++ b/tests/components/metoffice/test_weather.py
@@ -1,24 +1,25 @@
"""The tests for the Met Office sensor component."""
-from datetime import datetime, timedelta, timezone
+from datetime import timedelta
import json
from homeassistant.components.metoffice.const import DOMAIN
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.util import utcnow
+from . import NewDateTime
from .const import (
METOFFICE_CONFIG_KINGSLYNN,
METOFFICE_CONFIG_WAVERTREE,
WAVERTREE_SENSOR_RESULTS,
)
-from tests.async_mock import Mock, patch
+from tests.async_mock import patch
from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture
@patch(
"datapoint.Forecast.datetime.datetime",
- Mock(now=Mock(return_value=datetime(2020, 4, 25, 12, tzinfo=timezone.utc))),
+ NewDateTime,
)
async def test_site_cannot_connect(hass, requests_mock, legacy_patchable_time):
"""Test we handle cannot connect error."""
@@ -26,7 +27,10 @@ async def test_site_cannot_connect(hass, requests_mock, legacy_patchable_time):
requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text="")
requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=3hourly", text="")
- entry = MockConfigEntry(domain=DOMAIN, data=METOFFICE_CONFIG_WAVERTREE,)
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=METOFFICE_CONFIG_WAVERTREE,
+ )
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -40,7 +44,7 @@ async def test_site_cannot_connect(hass, requests_mock, legacy_patchable_time):
@patch(
"datapoint.Forecast.datetime.datetime",
- Mock(now=Mock(return_value=datetime(2020, 4, 25, 12, tzinfo=timezone.utc))),
+ NewDateTime,
)
async def test_site_cannot_update(hass, requests_mock, legacy_patchable_time):
"""Test we handle cannot connect error."""
@@ -55,7 +59,10 @@ async def test_site_cannot_update(hass, requests_mock, legacy_patchable_time):
"/public/data/val/wxfcs/all/json/354107?res=3hourly", text=wavertree_hourly
)
- entry = MockConfigEntry(domain=DOMAIN, data=METOFFICE_CONFIG_WAVERTREE,)
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=METOFFICE_CONFIG_WAVERTREE,
+ )
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -75,7 +82,7 @@ async def test_site_cannot_update(hass, requests_mock, legacy_patchable_time):
@patch(
"datapoint.Forecast.datetime.datetime",
- Mock(now=Mock(return_value=datetime(2020, 4, 25, 12, tzinfo=timezone.utc))),
+ NewDateTime,
)
async def test_one_weather_site_running(hass, requests_mock, legacy_patchable_time):
"""Test the Met Office weather platform."""
@@ -87,10 +94,14 @@ async def test_one_weather_site_running(hass, requests_mock, legacy_patchable_ti
requests_mock.get("/public/data/val/wxfcs/all/json/sitelist/", text=all_sites)
requests_mock.get(
- "/public/data/val/wxfcs/all/json/354107?res=3hourly", text=wavertree_hourly,
+ "/public/data/val/wxfcs/all/json/354107?res=3hourly",
+ text=wavertree_hourly,
)
- entry = MockConfigEntry(domain=DOMAIN, data=METOFFICE_CONFIG_WAVERTREE,)
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=METOFFICE_CONFIG_WAVERTREE,
+ )
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -109,7 +120,7 @@ async def test_one_weather_site_running(hass, requests_mock, legacy_patchable_ti
@patch(
"datapoint.Forecast.datetime.datetime",
- Mock(now=Mock(return_value=datetime(2020, 4, 25, 12, tzinfo=timezone.utc))),
+ NewDateTime,
)
async def test_two_weather_sites_running(hass, requests_mock, legacy_patchable_time):
"""Test we handle two different weather sites both running."""
@@ -128,10 +139,16 @@ async def test_two_weather_sites_running(hass, requests_mock, legacy_patchable_t
"/public/data/val/wxfcs/all/json/322380?res=3hourly", text=kingslynn_hourly
)
- entry = MockConfigEntry(domain=DOMAIN, data=METOFFICE_CONFIG_WAVERTREE,)
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=METOFFICE_CONFIG_WAVERTREE,
+ )
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
- entry2 = MockConfigEntry(domain=DOMAIN, data=METOFFICE_CONFIG_KINGSLYNN,)
+ entry2 = MockConfigEntry(
+ domain=DOMAIN,
+ data=METOFFICE_CONFIG_KINGSLYNN,
+ )
entry2.add_to_hass(hass)
await hass.config_entries.async_setup(entry2.entry_id)
await hass.async_block_till_done()
diff --git a/tests/components/mikrotik/test_init.py b/tests/components/mikrotik/test_init.py
index b3dca7269eb..30df96e89a0 100644
--- a/tests/components/mikrotik/test_init.py
+++ b/tests/components/mikrotik/test_init.py
@@ -16,7 +16,10 @@ async def test_setup_with_no_config(hass):
async def test_successful_config_entry(hass):
"""Test config entry successful setup."""
- entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA,)
+ entry = MockConfigEntry(
+ domain=mikrotik.DOMAIN,
+ data=MOCK_DATA,
+ )
entry.add_to_hass(hass)
mock_registry = Mock()
@@ -50,7 +53,10 @@ async def test_successful_config_entry(hass):
async def test_hub_fail_setup(hass):
"""Test that a failed setup will not store the hub."""
- entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA,)
+ entry = MockConfigEntry(
+ domain=mikrotik.DOMAIN,
+ data=MOCK_DATA,
+ )
entry.add_to_hass(hass)
with patch.object(mikrotik, "MikrotikHub") as mock_hub:
@@ -62,11 +68,15 @@ async def test_hub_fail_setup(hass):
async def test_unload_entry(hass):
"""Test being able to unload an entry."""
- entry = MockConfigEntry(domain=mikrotik.DOMAIN, data=MOCK_DATA,)
+ entry = MockConfigEntry(
+ domain=mikrotik.DOMAIN,
+ data=MOCK_DATA,
+ )
entry.add_to_hass(hass)
with patch.object(mikrotik, "MikrotikHub") as mock_hub, patch(
- "homeassistant.helpers.device_registry.async_get_registry", return_value=Mock(),
+ "homeassistant.helpers.device_registry.async_get_registry",
+ return_value=Mock(),
):
mock_hub.return_value.async_setup = AsyncMock(return_value=True)
mock_hub.return_value.serial_num = "12345678"
diff --git a/tests/components/mill/test_config_flow.py b/tests/components/mill/test_config_flow.py
index 54b8dbc9b59..0bd059a1786 100644
--- a/tests/components/mill/test_config_flow.py
+++ b/tests/components/mill/test_config_flow.py
@@ -51,7 +51,9 @@ async def test_flow_entry_already_exists(hass):
}
first_entry = MockConfigEntry(
- domain="mill", data=test_data, unique_id=test_data[CONF_USERNAME],
+ domain="mill",
+ data=test_data,
+ unique_id=test_data[CONF_USERNAME],
)
first_entry.add_to_hass(hass)
@@ -73,7 +75,9 @@ async def test_connection_error(hass):
}
first_entry = MockConfigEntry(
- domain="mill", data=test_data, unique_id=test_data[CONF_USERNAME],
+ domain="mill",
+ data=test_data,
+ unique_id=test_data[CONF_USERNAME],
)
first_entry.add_to_hass(hass)
diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py
index bece386a89e..66de0d47b53 100644
--- a/tests/components/min_max/test_sensor.py
+++ b/tests/components/min_max/test_sensor.py
@@ -1,17 +1,22 @@
"""The test for the min/max sensor platform."""
+from os import path
import statistics
import unittest
+from homeassistant import config as hass_config
+from homeassistant.components.min_max import DOMAIN
from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
+ PERCENTAGE,
+ SERVICE_RELOAD,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
- UNIT_PERCENTAGE,
)
-from homeassistant.setup import setup_component
+from homeassistant.setup import async_setup_component, setup_component
+from tests.async_mock import patch
from tests.common import get_test_home_assistant
@@ -296,7 +301,7 @@ class TestMinMaxSensor(unittest.TestCase):
assert state.attributes.get("unit_of_measurement") == "ERR"
self.hass.states.set(
- entity_ids[2], self.values[2], {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}
+ entity_ids[2], self.values[2], {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE}
)
self.hass.block_till_done()
@@ -332,3 +337,50 @@ class TestMinMaxSensor(unittest.TestCase):
assert self.max == state.attributes.get("max_value")
assert self.mean == state.attributes.get("mean")
assert self.median == state.attributes.get("median")
+
+
+async def test_reload(hass):
+ """Verify we can reload filter sensors."""
+ hass.states.async_set("sensor.test_1", 12345)
+ hass.states.async_set("sensor.test_2", 45678)
+
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": "min_max",
+ "name": "test",
+ "type": "mean",
+ "entity_ids": ["sensor.test_1", "sensor.test_2"],
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 3
+
+ assert hass.states.get("sensor.test")
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "min_max/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 3
+
+ assert hass.states.get("sensor.test") is None
+ assert hass.states.get("sensor.second_test")
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py
index 17ec9080e86..2f8ae5ff0bf 100644
--- a/tests/components/minecraft_server/test_config_flow.py
+++ b/tests/components/minecraft_server/test_config_flow.py
@@ -104,7 +104,8 @@ async def test_invalid_ip(hass: HomeAssistantType) -> None:
async def test_same_host(hass: HomeAssistantType) -> None:
"""Test abort in case of same host name."""
with patch(
- "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,
+ "aiodns.DNSResolver.query",
+ side_effect=aiodns.error.DNSError,
):
with patch(
"mcstatus.server.MinecraftServer.status",
@@ -132,7 +133,8 @@ async def test_same_host(hass: HomeAssistantType) -> None:
async def test_port_too_small(hass: HomeAssistantType) -> None:
"""Test error in case of a too small port."""
with patch(
- "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,
+ "aiodns.DNSResolver.query",
+ side_effect=aiodns.error.DNSError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_PORT_TOO_SMALL
@@ -145,7 +147,8 @@ async def test_port_too_small(hass: HomeAssistantType) -> None:
async def test_port_too_large(hass: HomeAssistantType) -> None:
"""Test error in case of a too large port."""
with patch(
- "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,
+ "aiodns.DNSResolver.query",
+ side_effect=aiodns.error.DNSError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_PORT_TOO_LARGE
@@ -158,7 +161,8 @@ async def test_port_too_large(hass: HomeAssistantType) -> None:
async def test_connection_failed(hass: HomeAssistantType) -> None:
"""Test error in case of a failed connection."""
with patch(
- "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,
+ "aiodns.DNSResolver.query",
+ side_effect=aiodns.error.DNSError,
):
with patch("mcstatus.server.MinecraftServer.status", side_effect=OSError):
result = await hass.config_entries.flow.async_init(
@@ -172,7 +176,8 @@ async def test_connection_failed(hass: HomeAssistantType) -> None:
async def test_connection_succeeded_with_srv_record(hass: HomeAssistantType) -> None:
"""Test config entry in case of a successful connection with a SRV record."""
with patch(
- "aiodns.DNSResolver.query", return_value=SRV_RECORDS,
+ "aiodns.DNSResolver.query",
+ return_value=SRV_RECORDS,
):
with patch(
"mcstatus.server.MinecraftServer.status",
@@ -191,7 +196,8 @@ async def test_connection_succeeded_with_srv_record(hass: HomeAssistantType) ->
async def test_connection_succeeded_with_host(hass: HomeAssistantType) -> None:
"""Test config entry in case of a successful connection with a host name."""
with patch(
- "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,
+ "aiodns.DNSResolver.query",
+ side_effect=aiodns.error.DNSError,
):
with patch(
"mcstatus.server.MinecraftServer.status",
@@ -211,7 +217,8 @@ async def test_connection_succeeded_with_ip4(hass: HomeAssistantType) -> None:
"""Test config entry in case of a successful connection with an IPv4 address."""
with patch("getmac.get_mac_address", return_value="01:23:45:67:89:ab"):
with patch(
- "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,
+ "aiodns.DNSResolver.query",
+ side_effect=aiodns.error.DNSError,
):
with patch(
"mcstatus.server.MinecraftServer.status",
@@ -231,7 +238,8 @@ async def test_connection_succeeded_with_ip6(hass: HomeAssistantType) -> None:
"""Test config entry in case of a successful connection with an IPv6 address."""
with patch("getmac.get_mac_address", return_value="01:23:45:67:89:ab"):
with patch(
- "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError,
+ "aiodns.DNSResolver.query",
+ side_effect=aiodns.error.DNSError,
):
with patch(
"mcstatus.server.MinecraftServer.status",
diff --git a/tests/components/mobile_app/test_entity.py b/tests/components/mobile_app/test_entity.py
index fd5baf50beb..1ec97c8c4e2 100644
--- a/tests/components/mobile_app/test_entity.py
+++ b/tests/components/mobile_app/test_entity.py
@@ -2,7 +2,7 @@
import logging
-from homeassistant.const import STATE_UNKNOWN, UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE, STATE_UNKNOWN
from homeassistant.helpers import device_registry
_LOGGER = logging.getLogger(__name__)
@@ -25,7 +25,7 @@ async def test_sensor(hass, create_registrations, webhook_client):
"state": 100,
"type": "sensor",
"unique_id": "battery_state",
- "unit_of_measurement": UNIT_PERCENTAGE,
+ "unit_of_measurement": PERCENTAGE,
},
},
)
@@ -41,7 +41,7 @@ async def test_sensor(hass, create_registrations, webhook_client):
assert entity.attributes["device_class"] == "battery"
assert entity.attributes["icon"] == "mdi:battery"
- assert entity.attributes["unit_of_measurement"] == UNIT_PERCENTAGE
+ assert entity.attributes["unit_of_measurement"] == PERCENTAGE
assert entity.attributes["foo"] == "bar"
assert entity.domain == "sensor"
assert entity.name == "Test 1 Battery State"
@@ -110,7 +110,7 @@ async def test_sensor_id_no_dupes(hass, create_registrations, webhook_client, ca
"state": 100,
"type": "sensor",
"unique_id": "battery_state",
- "unit_of_measurement": UNIT_PERCENTAGE,
+ "unit_of_measurement": PERCENTAGE,
},
}
@@ -122,14 +122,14 @@ async def test_sensor_id_no_dupes(hass, create_registrations, webhook_client, ca
assert reg_json == {"success": True}
await hass.async_block_till_done()
- assert "Re-register existing sensor" not in caplog.text
+ assert "Re-register" not in caplog.text
entity = hass.states.get("sensor.test_1_battery_state")
assert entity is not None
assert entity.attributes["device_class"] == "battery"
assert entity.attributes["icon"] == "mdi:battery"
- assert entity.attributes["unit_of_measurement"] == UNIT_PERCENTAGE
+ assert entity.attributes["unit_of_measurement"] == PERCENTAGE
assert entity.attributes["foo"] == "bar"
assert entity.domain == "sensor"
assert entity.name == "Test 1 Battery State"
@@ -143,14 +143,14 @@ async def test_sensor_id_no_dupes(hass, create_registrations, webhook_client, ca
assert dupe_reg_json == {"success": True}
await hass.async_block_till_done()
- assert "Re-register existing sensor" in caplog.text
+ assert "Re-register" in caplog.text
entity = hass.states.get("sensor.test_1_battery_state")
assert entity is not None
assert entity.attributes["device_class"] == "battery"
assert entity.attributes["icon"] == "mdi:battery"
- assert entity.attributes["unit_of_measurement"] == UNIT_PERCENTAGE
+ assert entity.attributes["unit_of_measurement"] == PERCENTAGE
assert entity.attributes["foo"] == "bar"
assert entity.domain == "sensor"
assert entity.name == "Test 1 Battery State"
diff --git a/tests/components/mobile_app/test_http_api.py b/tests/components/mobile_app/test_http_api.py
index 6cb08580005..c5b363c25b0 100644
--- a/tests/components/mobile_app/test_http_api.py
+++ b/tests/components/mobile_app/test_http_api.py
@@ -59,8 +59,8 @@ async def test_registration(hass, hass_client, hass_admin_user):
async def test_registration_encryption(hass, hass_client):
"""Test that registrations happen."""
try:
- from nacl.secret import SecretBox
from nacl.encoding import Base64Encoder
+ from nacl.secret import SecretBox
except (ImportError, OSError):
pytest.skip("libnacl/libsodium is not installed")
return
diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py
index 860f3d9f81f..5041b2453d9 100644
--- a/tests/components/mobile_app/test_notify.py
+++ b/tests/components/mobile_app/test_notify.py
@@ -61,6 +61,48 @@ async def setup_push_receiver(hass, aioclient_mock):
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
await hass.async_block_till_done()
+ loaded_late_entry = MockConfigEntry(
+ connection_class="cloud_push",
+ data={
+ "app_data": {"push_token": "PUSH_TOKEN2", "push_url": f"{push_url}2"},
+ "app_id": "io.homeassistant.mobile_app",
+ "app_name": "mobile_app tests",
+ "app_version": "1.0",
+ "device_id": "4d5e6f2",
+ "device_name": "Loaded Late",
+ "manufacturer": "Home Assistant",
+ "model": "mobile_app",
+ "os_name": "Linux",
+ "os_version": "5.0.6",
+ "secret": "123abc2",
+ "supports_encryption": False,
+ "user_id": "1a2b3c2",
+ "webhook_id": "webhook_id_2",
+ },
+ domain=DOMAIN,
+ source="registration",
+ title="mobile_app 2 test entry",
+ version=1,
+ )
+ loaded_late_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(loaded_late_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert hass.services.has_service("notify", "mobile_app_loaded_late")
+
+ assert await hass.config_entries.async_remove(loaded_late_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert hass.services.has_service("notify", "mobile_app_test")
+ assert not hass.services.has_service("notify", "mobile_app_loaded_late")
+
+ loaded_late_entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(loaded_late_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert hass.services.has_service("notify", "mobile_app_test")
+ assert hass.services.has_service("notify", "mobile_app_loaded_late")
+
async def test_notify_works(hass, aioclient_mock, setup_push_receiver):
"""Test notify works."""
diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py
index bd38bca535b..dec919317f5 100644
--- a/tests/components/mobile_app/test_webhook.py
+++ b/tests/components/mobile_app/test_webhook.py
@@ -22,8 +22,8 @@ _LOGGER = logging.getLogger(__name__)
def encrypt_payload(secret_key, payload):
"""Return a encrypted payload given a key and dictionary of data."""
try:
- from nacl.secret import SecretBox
from nacl.encoding import Base64Encoder
+ from nacl.secret import SecretBox
except (ImportError, OSError):
pytest.skip("libnacl/libsodium is not installed")
return
@@ -45,8 +45,8 @@ def encrypt_payload(secret_key, payload):
def decrypt_payload(secret_key, encrypted_data):
"""Return a decrypted payload given a key and a string of encrypted data."""
try:
- from nacl.secret import SecretBox
from nacl.encoding import Base64Encoder
+ from nacl.secret import SecretBox
except (ImportError, OSError):
pytest.skip("libnacl/libsodium is not installed")
return
@@ -143,7 +143,9 @@ async def test_webhook_update_registration(webhook_client, authed_api_client):
async def test_webhook_handle_get_zones(hass, create_registrations, webhook_client):
"""Test that we can get zones properly."""
await async_setup_component(
- hass, ZONE_DOMAIN, {ZONE_DOMAIN: {}},
+ hass,
+ ZONE_DOMAIN,
+ {ZONE_DOMAIN: {}},
)
resp = await webhook_client.post(
@@ -266,7 +268,8 @@ async def test_webhook_enable_encryption(hass, webhook_client, create_registrati
webhook_id = create_registrations[1]["webhook_id"]
enable_enc_resp = await webhook_client.post(
- f"/api/webhook/{webhook_id}", json={"type": "enable_encryption"},
+ f"/api/webhook/{webhook_id}",
+ json={"type": "enable_encryption"},
)
assert enable_enc_resp.status == 200
@@ -278,7 +281,8 @@ async def test_webhook_enable_encryption(hass, webhook_client, create_registrati
key = enable_enc_json["secret"]
enc_required_resp = await webhook_client.post(
- f"/api/webhook/{webhook_id}", json=RENDER_TEMPLATE,
+ f"/api/webhook/{webhook_id}",
+ json=RENDER_TEMPLATE,
)
assert enc_required_resp.status == 400
diff --git a/tests/components/mold_indicator/test_sensor.py b/tests/components/mold_indicator/test_sensor.py
index 423c728ff72..a67873fd4e4 100644
--- a/tests/components/mold_indicator/test_sensor.py
+++ b/tests/components/mold_indicator/test_sensor.py
@@ -8,9 +8,9 @@ from homeassistant.components.mold_indicator.sensor import (
import homeassistant.components.sensor as sensor
from homeassistant.const import (
ATTR_UNIT_OF_MEASUREMENT,
+ PERCENTAGE,
STATE_UNKNOWN,
TEMP_CELSIUS,
- UNIT_PERCENTAGE,
)
from homeassistant.setup import setup_component
@@ -30,7 +30,7 @@ class TestSensorMoldIndicator(unittest.TestCase):
"test.outdoortemp", "10", {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}
)
self.hass.states.set(
- "test.indoorhumidity", "50", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}
+ "test.indoorhumidity", "50", {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE}
)
self.addCleanup(self.tear_down_cleanup)
@@ -56,7 +56,7 @@ class TestSensorMoldIndicator(unittest.TestCase):
self.hass.block_till_done()
moldind = self.hass.states.get("sensor.mold_indicator")
assert moldind
- assert UNIT_PERCENTAGE == moldind.attributes.get("unit_of_measurement")
+ assert PERCENTAGE == moldind.attributes.get("unit_of_measurement")
def test_invalidcalib(self):
"""Test invalid sensor values."""
@@ -67,7 +67,7 @@ class TestSensorMoldIndicator(unittest.TestCase):
"test.outdoortemp", "10", {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}
)
self.hass.states.set(
- "test.indoorhumidity", "0", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}
+ "test.indoorhumidity", "0", {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE}
)
assert setup_component(
@@ -101,7 +101,7 @@ class TestSensorMoldIndicator(unittest.TestCase):
"test.outdoortemp", "10", {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}
)
self.hass.states.set(
- "test.indoorhumidity", "-1", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}
+ "test.indoorhumidity", "-1", {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE}
)
assert setup_component(
@@ -128,7 +128,7 @@ class TestSensorMoldIndicator(unittest.TestCase):
assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None
self.hass.states.set(
- "test.indoorhumidity", "A", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}
+ "test.indoorhumidity", "A", {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE}
)
self.hass.block_till_done()
moldind = self.hass.states.get("sensor.mold_indicator")
@@ -232,7 +232,7 @@ class TestSensorMoldIndicator(unittest.TestCase):
self.hass.states.set(
"test.indoorhumidity",
STATE_UNKNOWN,
- {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE},
+ {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE},
)
self.hass.block_till_done()
moldind = self.hass.states.get("sensor.mold_indicator")
@@ -242,7 +242,7 @@ class TestSensorMoldIndicator(unittest.TestCase):
assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None
self.hass.states.set(
- "test.indoorhumidity", "20", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}
+ "test.indoorhumidity", "20", {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE}
)
self.hass.block_till_done()
moldind = self.hass.states.get("sensor.mold_indicator")
@@ -289,7 +289,7 @@ class TestSensorMoldIndicator(unittest.TestCase):
assert self.hass.states.get("sensor.mold_indicator").state == "57"
self.hass.states.set(
- "test.indoorhumidity", "20", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}
+ "test.indoorhumidity", "20", {ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE}
)
self.hass.block_till_done()
assert self.hass.states.get("sensor.mold_indicator").state == "23"
diff --git a/tests/components/monoprice/test_config_flow.py b/tests/components/monoprice/test_config_flow.py
index 8c6e2a3916c..e2aae6eddaa 100644
--- a/tests/components/monoprice/test_config_flow.py
+++ b/tests/components/monoprice/test_config_flow.py
@@ -37,7 +37,8 @@ async def test_form(hass):
), patch(
"homeassistant.components.monoprice.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.monoprice.async_setup_entry", return_value=True,
+ "homeassistant.components.monoprice.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], CONFIG
@@ -96,15 +97,16 @@ async def test_options_flow(hass):
config_entry = MockConfigEntry(
domain=DOMAIN,
- # unique_id="abcde12345",
data=conf,
- # options={CONF_SHOW_ON_MAP: True},
)
config_entry.add_to_hass(hass)
with patch(
"homeassistant.components.monoprice.async_setup_entry", return_value=True
):
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
result = await hass.config_entries.options.async_init(config_entry.entry_id)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
diff --git a/tests/components/monoprice/test_media_player.py b/tests/components/monoprice/test_media_player.py
index ccd70c628e2..41a33fd095b 100644
--- a/tests/components/monoprice/test_media_player.py
+++ b/tests/components/monoprice/test_media_player.py
@@ -97,7 +97,8 @@ async def test_cannot_connect(hass):
"""Test connection error."""
with patch(
- "homeassistant.components.monoprice.get_monoprice", side_effect=SerialException,
+ "homeassistant.components.monoprice.get_monoprice",
+ side_effect=SerialException,
):
config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
config_entry.add_to_hass(hass)
@@ -108,7 +109,8 @@ async def test_cannot_connect(hass):
async def _setup_monoprice(hass, monoprice):
with patch(
- "homeassistant.components.monoprice.get_monoprice", new=lambda *a: monoprice,
+ "homeassistant.components.monoprice.get_monoprice",
+ new=lambda *a: monoprice,
):
config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG)
config_entry.add_to_hass(hass)
@@ -118,7 +120,8 @@ async def _setup_monoprice(hass, monoprice):
async def _setup_monoprice_with_options(hass, monoprice):
with patch(
- "homeassistant.components.monoprice.get_monoprice", new=lambda *a: monoprice,
+ "homeassistant.components.monoprice.get_monoprice",
+ new=lambda *a: monoprice,
):
config_entry = MockConfigEntry(
domain=DOMAIN, data=MOCK_CONFIG, options=MOCK_OPTIONS
@@ -130,7 +133,8 @@ async def _setup_monoprice_with_options(hass, monoprice):
async def _setup_monoprice_not_first_run(hass, monoprice):
with patch(
- "homeassistant.components.monoprice.get_monoprice", new=lambda *a: monoprice,
+ "homeassistant.components.monoprice.get_monoprice",
+ new=lambda *a: monoprice,
):
data = {**MOCK_CONFIG, CONF_NOT_FIRST_RUN: True}
config_entry = MockConfigEntry(domain=DOMAIN, data=data)
diff --git a/tests/components/moon/test_sensor.py b/tests/components/moon/test_sensor.py
index 59210c63b90..234a8a1e34d 100644
--- a/tests/components/moon/test_sensor.py
+++ b/tests/components/moon/test_sensor.py
@@ -54,6 +54,9 @@ async def test_moon_day2(hass):
async def async_update_entity(hass, entity_id):
"""Run an update action for an entity."""
await hass.services.async_call(
- HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_ENTITY_ID: entity_id}, blocking=True,
+ HA_DOMAIN,
+ SERVICE_UPDATE_ENTITY,
+ {ATTR_ENTITY_ID: entity_id},
+ blocking=True,
)
await hass.async_block_till_done()
diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py
index 734e1fd552f..7eb890903fd 100644
--- a/tests/components/mqtt/test_alarm_control_panel.py
+++ b/tests/components/mqtt/test_alarm_control_panel.py
@@ -105,7 +105,9 @@ async def test_fail_setup_without_command_topic(hass, mqtt_mock):
async def test_update_state_via_state_topic(hass, mqtt_mock):
"""Test updating with via state topic."""
assert await async_setup_component(
- hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG,
+ hass,
+ alarm_control_panel.DOMAIN,
+ DEFAULT_CONFIG,
)
await hass.async_block_till_done()
@@ -131,7 +133,9 @@ async def test_update_state_via_state_topic(hass, mqtt_mock):
async def test_ignore_update_state_if_unknown_via_state_topic(hass, mqtt_mock):
"""Test ignoring updates via state topic."""
assert await async_setup_component(
- hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG,
+ hass,
+ alarm_control_panel.DOMAIN,
+ DEFAULT_CONFIG,
)
await hass.async_block_till_done()
@@ -146,7 +150,9 @@ async def test_ignore_update_state_if_unknown_via_state_topic(hass, mqtt_mock):
async def test_arm_home_publishes_mqtt(hass, mqtt_mock):
"""Test publishing of MQTT messages while armed."""
assert await async_setup_component(
- hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG,
+ hass,
+ alarm_control_panel.DOMAIN,
+ DEFAULT_CONFIG,
)
await hass.async_block_till_done()
@@ -162,7 +168,9 @@ async def test_arm_home_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt
When code_arm_required = True
"""
assert await async_setup_component(
- hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE,
+ hass,
+ alarm_control_panel.DOMAIN,
+ DEFAULT_CONFIG_CODE,
)
call_count = mqtt_mock.async_publish.call_count
@@ -177,7 +185,11 @@ async def test_arm_home_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
"""
config = copy.deepcopy(DEFAULT_CONFIG_CODE)
config[alarm_control_panel.DOMAIN]["code_arm_required"] = False
- assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config,)
+ assert await async_setup_component(
+ hass,
+ alarm_control_panel.DOMAIN,
+ config,
+ )
await hass.async_block_till_done()
await common.async_alarm_arm_home(hass)
@@ -189,7 +201,9 @@ async def test_arm_home_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
async def test_arm_away_publishes_mqtt(hass, mqtt_mock):
"""Test publishing of MQTT messages while armed."""
assert await async_setup_component(
- hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG,
+ hass,
+ alarm_control_panel.DOMAIN,
+ DEFAULT_CONFIG,
)
await hass.async_block_till_done()
@@ -205,7 +219,9 @@ async def test_arm_away_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt
When code_arm_required = True
"""
assert await async_setup_component(
- hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE,
+ hass,
+ alarm_control_panel.DOMAIN,
+ DEFAULT_CONFIG_CODE,
)
call_count = mqtt_mock.async_publish.call_count
@@ -220,7 +236,11 @@ async def test_arm_away_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
"""
config = copy.deepcopy(DEFAULT_CONFIG_CODE)
config[alarm_control_panel.DOMAIN]["code_arm_required"] = False
- assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config,)
+ assert await async_setup_component(
+ hass,
+ alarm_control_panel.DOMAIN,
+ config,
+ )
await hass.async_block_till_done()
await common.async_alarm_arm_away(hass)
@@ -232,7 +252,9 @@ async def test_arm_away_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
async def test_arm_night_publishes_mqtt(hass, mqtt_mock):
"""Test publishing of MQTT messages while armed."""
assert await async_setup_component(
- hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG,
+ hass,
+ alarm_control_panel.DOMAIN,
+ DEFAULT_CONFIG,
)
await hass.async_block_till_done()
@@ -248,7 +270,9 @@ async def test_arm_night_not_publishes_mqtt_with_invalid_code_when_req(hass, mqt
When code_arm_required = True
"""
assert await async_setup_component(
- hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE,
+ hass,
+ alarm_control_panel.DOMAIN,
+ DEFAULT_CONFIG_CODE,
)
call_count = mqtt_mock.async_publish.call_count
@@ -263,7 +287,11 @@ async def test_arm_night_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
"""
config = copy.deepcopy(DEFAULT_CONFIG_CODE)
config[alarm_control_panel.DOMAIN]["code_arm_required"] = False
- assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config,)
+ assert await async_setup_component(
+ hass,
+ alarm_control_panel.DOMAIN,
+ config,
+ )
await hass.async_block_till_done()
await common.async_alarm_arm_night(hass)
@@ -352,7 +380,9 @@ async def test_arm_custom_bypass_publishes_mqtt_when_code_not_req(hass, mqtt_moc
async def test_disarm_publishes_mqtt(hass, mqtt_mock):
"""Test publishing of MQTT messages while disarmed."""
assert await async_setup_component(
- hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG,
+ hass,
+ alarm_control_panel.DOMAIN,
+ DEFAULT_CONFIG,
)
await hass.async_block_till_done()
@@ -370,7 +400,11 @@ async def test_disarm_publishes_mqtt_with_template(hass, mqtt_mock):
config[alarm_control_panel.DOMAIN]["command_template"] = (
'{"action":"{{ action }}",' '"code":"{{ code }}"}'
)
- assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config,)
+ assert await async_setup_component(
+ hass,
+ alarm_control_panel.DOMAIN,
+ config,
+ )
await hass.async_block_till_done()
await common.async_alarm_disarm(hass, 1234)
@@ -387,7 +421,11 @@ async def test_disarm_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
config = copy.deepcopy(DEFAULT_CONFIG_CODE)
config[alarm_control_panel.DOMAIN]["code"] = "1234"
config[alarm_control_panel.DOMAIN]["code_disarm_required"] = False
- assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config,)
+ assert await async_setup_component(
+ hass,
+ alarm_control_panel.DOMAIN,
+ config,
+ )
await hass.async_block_till_done()
await common.async_alarm_disarm(hass)
@@ -400,7 +438,9 @@ async def test_disarm_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt_m
When code_disarm_required = True
"""
assert await async_setup_component(
- hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE,
+ hass,
+ alarm_control_panel.DOMAIN,
+ DEFAULT_CONFIG_CODE,
)
call_count = mqtt_mock.async_publish.call_count
@@ -563,17 +603,71 @@ async def test_discovery_removal_alarm(hass, mqtt_mock, caplog):
)
-async def test_discovery_update_alarm(hass, mqtt_mock, caplog):
+async def test_discovery_update_alarm_topic_and_template(hass, mqtt_mock, caplog):
"""Test update of discovered alarm_control_panel."""
config1 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN])
config2 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN])
config1["name"] = "Beer"
config2["name"] = "Milk"
+ config1["state_topic"] = "alarm/state1"
+ config2["state_topic"] = "alarm/state2"
+ config1["value_template"] = "{{ value_json.state1.state }}"
+ config2["value_template"] = "{{ value_json.state2.state }}"
+
+ state_data1 = [
+ ([("alarm/state1", '{"state1":{"state":"armed_away"}}')], "armed_away", None),
+ ]
+ state_data2 = [
+ ([("alarm/state1", '{"state1":{"state":"triggered"}}')], "armed_away", None),
+ ([("alarm/state1", '{"state2":{"state":"triggered"}}')], "armed_away", None),
+ ([("alarm/state2", '{"state1":{"state":"triggered"}}')], "armed_away", None),
+ ([("alarm/state2", '{"state2":{"state":"triggered"}}')], "triggered", None),
+ ]
data1 = json.dumps(config1)
data2 = json.dumps(config2)
await help_test_discovery_update(
- hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data1, data2
+ hass,
+ mqtt_mock,
+ caplog,
+ alarm_control_panel.DOMAIN,
+ data1,
+ data2,
+ state_data1=state_data1,
+ state_data2=state_data2,
+ )
+
+
+async def test_discovery_update_alarm_template(hass, mqtt_mock, caplog):
+ """Test update of discovered alarm_control_panel."""
+ config1 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN])
+ config2 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN])
+ config1["name"] = "Beer"
+ config2["name"] = "Milk"
+ config1["state_topic"] = "alarm/state1"
+ config2["state_topic"] = "alarm/state1"
+ config1["value_template"] = "{{ value_json.state1.state }}"
+ config2["value_template"] = "{{ value_json.state2.state }}"
+
+ state_data1 = [
+ ([("alarm/state1", '{"state1":{"state":"armed_away"}}')], "armed_away", None),
+ ]
+ state_data2 = [
+ ([("alarm/state1", '{"state1":{"state":"triggered"}}')], "armed_away", None),
+ ([("alarm/state1", '{"state2":{"state":"triggered"}}')], "triggered", None),
+ ]
+
+ data1 = json.dumps(config1)
+ data2 = json.dumps(config2)
+ await help_test_discovery_update(
+ hass,
+ mqtt_mock,
+ caplog,
+ alarm_control_panel.DOMAIN,
+ data1,
+ data2,
+ state_data1=state_data1,
+ state_data2=state_data2,
)
diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py
index c739f4378d1..d2375278c4d 100644
--- a/tests/components/mqtt/test_binary_sensor.py
+++ b/tests/components/mqtt/test_binary_sensor.py
@@ -580,17 +580,75 @@ async def test_discovery_removal_binary_sensor(hass, mqtt_mock, caplog):
)
-async def test_discovery_update_binary_sensor(hass, mqtt_mock, caplog):
+async def test_discovery_update_binary_sensor_topic_template(hass, mqtt_mock, caplog):
"""Test update of discovered binary_sensor."""
config1 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN])
config2 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN])
config1["name"] = "Beer"
config2["name"] = "Milk"
+ config1["state_topic"] = "sensor/state1"
+ config2["state_topic"] = "sensor/state2"
+ config1["value_template"] = "{{ value_json.state1.state }}"
+ config2["value_template"] = "{{ value_json.state2.state }}"
+
+ state_data1 = [
+ ([("sensor/state1", '{"state1":{"state":"ON"}}')], "on", None),
+ ]
+ state_data2 = [
+ ([("sensor/state2", '{"state2":{"state":"OFF"}}')], "off", None),
+ ([("sensor/state2", '{"state2":{"state":"ON"}}')], "on", None),
+ ([("sensor/state1", '{"state1":{"state":"OFF"}}')], "on", None),
+ ([("sensor/state1", '{"state2":{"state":"OFF"}}')], "on", None),
+ ([("sensor/state2", '{"state1":{"state":"OFF"}}')], "on", None),
+ ([("sensor/state2", '{"state2":{"state":"OFF"}}')], "off", None),
+ ]
data1 = json.dumps(config1)
data2 = json.dumps(config2)
await help_test_discovery_update(
- hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data1, data2
+ hass,
+ mqtt_mock,
+ caplog,
+ binary_sensor.DOMAIN,
+ data1,
+ data2,
+ state_data1=state_data1,
+ state_data2=state_data2,
+ )
+
+
+async def test_discovery_update_binary_sensor_template(hass, mqtt_mock, caplog):
+ """Test update of discovered binary_sensor."""
+ config1 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN])
+ config2 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN])
+ config1["name"] = "Beer"
+ config2["name"] = "Milk"
+ config1["state_topic"] = "sensor/state1"
+ config2["state_topic"] = "sensor/state1"
+ config1["value_template"] = "{{ value_json.state1.state }}"
+ config2["value_template"] = "{{ value_json.state2.state }}"
+
+ state_data1 = [
+ ([("sensor/state1", '{"state1":{"state":"ON"}}')], "on", None),
+ ]
+ state_data2 = [
+ ([("sensor/state1", '{"state2":{"state":"OFF"}}')], "off", None),
+ ([("sensor/state1", '{"state2":{"state":"ON"}}')], "on", None),
+ ([("sensor/state1", '{"state1":{"state":"OFF"}}')], "on", None),
+ ([("sensor/state1", '{"state2":{"state":"OFF"}}')], "off", None),
+ ]
+
+ data1 = json.dumps(config1)
+ data2 = json.dumps(config2)
+ await help_test_discovery_update(
+ hass,
+ mqtt_mock,
+ caplog,
+ binary_sensor.DOMAIN,
+ data1,
+ data2,
+ state_data1=state_data1,
+ state_data2=state_data2,
)
diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py
index 89bfde22d87..27ee060ebfa 100644
--- a/tests/components/mqtt/test_common.py
+++ b/tests/components/mqtt/test_common.py
@@ -74,7 +74,11 @@ async def help_test_default_availability_payload(
# Add availability settings to config
config = copy.deepcopy(config)
config[domain]["availability_topic"] = "availability-topic"
- assert await async_setup_component(hass, domain, config,)
+ assert await async_setup_component(
+ hass,
+ domain,
+ config,
+ )
await hass.async_block_till_done()
state = hass.states.get(f"{domain}.test")
@@ -123,7 +127,11 @@ async def help_test_default_availability_list_payload(
{"topic": "availability-topic1"},
{"topic": "availability-topic2"},
]
- assert await async_setup_component(hass, domain, config,)
+ assert await async_setup_component(
+ hass,
+ domain,
+ config,
+ )
await hass.async_block_till_done()
state = hass.states.get(f"{domain}.test")
@@ -185,7 +193,11 @@ async def help_test_default_availability_list_single(
{"topic": "availability-topic1"},
]
config[domain]["availability_topic"] = "availability-topic"
- assert await async_setup_component(hass, domain, config,)
+ assert await async_setup_component(
+ hass,
+ domain,
+ config,
+ )
await hass.async_block_till_done()
state = hass.states.get(f"{domain}.test")
@@ -214,7 +226,11 @@ async def help_test_custom_availability_payload(
config[domain]["availability_topic"] = "availability-topic"
config[domain]["payload_available"] = "good"
config[domain]["payload_not_available"] = "nogood"
- assert await async_setup_component(hass, domain, config,)
+ assert await async_setup_component(
+ hass,
+ domain,
+ config,
+ )
await hass.async_block_till_done()
state = hass.states.get(f"{domain}.test")
@@ -336,7 +352,11 @@ async def help_test_setting_attribute_via_mqtt_json_message(
# Add JSON attributes settings to config
config = copy.deepcopy(config)
config[domain]["json_attributes_topic"] = "attr-topic"
- assert await async_setup_component(hass, domain, config,)
+ assert await async_setup_component(
+ hass,
+ domain,
+ config,
+ )
await hass.async_block_till_done()
async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }')
@@ -354,7 +374,11 @@ async def help_test_setting_attribute_with_template(hass, mqtt_mock, domain, con
config = copy.deepcopy(config)
config[domain]["json_attributes_topic"] = "attr-topic"
config[domain]["json_attributes_template"] = "{{ value_json['Timer1'] | tojson }}"
- assert await async_setup_component(hass, domain, config,)
+ assert await async_setup_component(
+ hass,
+ domain,
+ config,
+ )
await hass.async_block_till_done()
async_fire_mqtt_message(
@@ -376,7 +400,11 @@ async def help_test_update_with_json_attrs_not_dict(
# Add JSON attributes settings to config
config = copy.deepcopy(config)
config[domain]["json_attributes_topic"] = "attr-topic"
- assert await async_setup_component(hass, domain, config,)
+ assert await async_setup_component(
+ hass,
+ domain,
+ config,
+ )
await hass.async_block_till_done()
async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]')
@@ -396,7 +424,11 @@ async def help_test_update_with_json_attrs_bad_JSON(
# Add JSON attributes settings to config
config = copy.deepcopy(config)
config[domain]["json_attributes_topic"] = "attr-topic"
- assert await async_setup_component(hass, domain, config,)
+ assert await async_setup_component(
+ hass,
+ domain,
+ config,
+ )
await hass.async_block_till_done()
async_fire_mqtt_message(hass, "attr-topic", "This is not JSON")
@@ -471,7 +503,16 @@ async def help_test_discovery_removal(hass, mqtt_mock, caplog, domain, data):
assert state is None
-async def help_test_discovery_update(hass, mqtt_mock, caplog, domain, data1, data2):
+async def help_test_discovery_update(
+ hass,
+ mqtt_mock,
+ caplog,
+ domain,
+ discovery_data1,
+ discovery_data2,
+ state_data1=None,
+ state_data2=None,
+):
"""Test update of discovered component.
This is a test helper for the MqttDiscoveryUpdate mixin.
@@ -479,20 +520,42 @@ async def help_test_discovery_update(hass, mqtt_mock, caplog, domain, data1, dat
entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0]
await async_start(hass, "homeassistant", entry)
- async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1)
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", discovery_data1)
await hass.async_block_till_done()
state = hass.states.get(f"{domain}.beer")
assert state is not None
assert state.name == "Beer"
- async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data2)
+ if state_data1:
+ for (mqtt_messages, expected_state, attributes) in state_data1:
+ for (topic, data) in mqtt_messages:
+ async_fire_mqtt_message(hass, topic, data)
+ state = hass.states.get(f"{domain}.beer")
+ if expected_state:
+ assert state.state == expected_state
+ if attributes:
+ for (attr, value) in attributes:
+ assert state.attributes.get(attr) == value
+
+ async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", discovery_data2)
await hass.async_block_till_done()
state = hass.states.get(f"{domain}.beer")
assert state is not None
assert state.name == "Milk"
+ if state_data2:
+ for (mqtt_messages, expected_state, attributes) in state_data2:
+ for (topic, data) in mqtt_messages:
+ async_fire_mqtt_message(hass, topic, data)
+ state = hass.states.get(f"{domain}.beer")
+ if expected_state:
+ assert state.state == expected_state
+ if attributes:
+ for (attr, value) in attributes:
+ assert state.attributes.get(attr) == value
+
state = hass.states.get(f"{domain}.milk")
assert state is None
@@ -670,7 +733,11 @@ async def help_test_entity_id_update_subscriptions(
topics = ["avty-topic", "test-topic"]
assert len(topics) > 0
registry = mock_registry(hass, {})
- assert await async_setup_component(hass, domain, config,)
+ assert await async_setup_component(
+ hass,
+ domain,
+ config,
+ )
await hass.async_block_till_done()
state = hass.states.get(f"{domain}.test")
diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py
index 5fbb772f949..ef6e7a7656c 100644
--- a/tests/components/mqtt/test_config_flow.py
+++ b/tests/components/mqtt/test_config_flow.py
@@ -505,7 +505,8 @@ async def test_options_bad_birth_message_fails(hass, mock_try_connection):
assert result["step_id"] == "options"
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={"birth_topic": "ha_state/online/#"},
+ result["flow_id"],
+ user_input={"birth_topic": "ha_state/online/#"},
)
assert result["type"] == "form"
assert result["errors"]["base"] == "bad_birth"
@@ -540,7 +541,8 @@ async def test_options_bad_will_message_fails(hass, mock_try_connection):
assert result["step_id"] == "options"
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={"will_topic": "ha_state/offline/#"},
+ result["flow_id"],
+ user_input={"will_topic": "ha_state/offline/#"},
)
assert result["type"] == "form"
assert result["errors"]["base"] == "bad_will"
diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py
index f9036bcfa0f..d1529e63fc7 100644
--- a/tests/components/mqtt/test_cover.py
+++ b/tests/components/mqtt/test_cover.py
@@ -1397,6 +1397,7 @@ async def test_tilt_position_altered_range(hass, mqtt_mock):
async def test_find_percentage_in_range_defaults(hass, mqtt_mock):
"""Test find percentage in range with default range."""
mqtt_cover = MqttCover(
+ hass,
{
"name": "cover.test",
"state_topic": "state-topic",
@@ -1440,6 +1441,7 @@ async def test_find_percentage_in_range_defaults(hass, mqtt_mock):
async def test_find_percentage_in_range_altered(hass, mqtt_mock):
"""Test find percentage in range with altered range."""
mqtt_cover = MqttCover(
+ hass,
{
"name": "cover.test",
"state_topic": "state-topic",
@@ -1483,6 +1485,7 @@ async def test_find_percentage_in_range_altered(hass, mqtt_mock):
async def test_find_percentage_in_range_defaults_inverted(hass, mqtt_mock):
"""Test find percentage in range with default range but inverted."""
mqtt_cover = MqttCover(
+ hass,
{
"name": "cover.test",
"state_topic": "state-topic",
@@ -1526,6 +1529,7 @@ async def test_find_percentage_in_range_defaults_inverted(hass, mqtt_mock):
async def test_find_percentage_in_range_altered_inverted(hass, mqtt_mock):
"""Test find percentage in range with altered range and inverted."""
mqtt_cover = MqttCover(
+ hass,
{
"name": "cover.test",
"state_topic": "state-topic",
@@ -1569,6 +1573,7 @@ async def test_find_percentage_in_range_altered_inverted(hass, mqtt_mock):
async def test_find_in_range_defaults(hass, mqtt_mock):
"""Test find in range with default range."""
mqtt_cover = MqttCover(
+ hass,
{
"name": "cover.test",
"state_topic": "state-topic",
@@ -1612,6 +1617,7 @@ async def test_find_in_range_defaults(hass, mqtt_mock):
async def test_find_in_range_altered(hass, mqtt_mock):
"""Test find in range with altered range."""
mqtt_cover = MqttCover(
+ hass,
{
"name": "cover.test",
"state_topic": "state-topic",
@@ -1655,6 +1661,7 @@ async def test_find_in_range_altered(hass, mqtt_mock):
async def test_find_in_range_defaults_inverted(hass, mqtt_mock):
"""Test find in range with default range but inverted."""
mqtt_cover = MqttCover(
+ hass,
{
"name": "cover.test",
"state_topic": "state-topic",
@@ -1698,6 +1705,7 @@ async def test_find_in_range_defaults_inverted(hass, mqtt_mock):
async def test_find_in_range_altered_inverted(hass, mqtt_mock):
"""Test find in range with altered range and inverted."""
mqtt_cover = MqttCover(
+ hass,
{
"name": "cover.test",
"state_topic": "state-topic",
diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py
index c1388aeb1c1..59687aaff62 100644
--- a/tests/components/mqtt/test_discovery.py
+++ b/tests/components/mqtt/test_discovery.py
@@ -419,6 +419,8 @@ async def test_missing_discover_abbreviations(hass, mqtt_mock, caplog):
missing = []
regex = re.compile(r"(CONF_[a-zA-Z\d_]*) *= *[\'\"]([a-zA-Z\d_]*)[\'\"]")
for fil in Path(mqtt.__file__).parent.rglob("*.py"):
+ if fil.name == "trigger.py":
+ continue
with open(fil) as file:
matches = re.findall(regex, file.read())
for match in matches:
diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py
index 15d92b9a311..a4d1261daf8 100644
--- a/tests/components/mqtt/test_init.py
+++ b/tests/components/mqtt/test_init.py
@@ -1,4 +1,5 @@
"""The tests for the MQTT component."""
+import asyncio
from datetime import datetime, timedelta
import json
import ssl
@@ -361,7 +362,6 @@ async def test_subscribe_deprecated_async(hass, mqtt_mock):
"""Test the subscription of a topic using deprecated callback signature."""
calls = []
- @callback
async def record_calls(topic, payload, qos):
"""Record calls."""
calls.append((topic, payload, qos))
@@ -575,29 +575,37 @@ async def test_subscribe_special_characters(hass, mqtt_mock, calls, record_calls
assert calls[0][0].payload == payload
-async def test_retained_message_on_subscribe_received(
- hass, mqtt_client_mock, mqtt_mock
-):
- """Test every subscriber receives retained message on subscribe."""
+async def test_subscribe_same_topic(hass, mqtt_client_mock, mqtt_mock):
+ """
+ Test subscring to same topic twice and simulate retained messages.
- def side_effect(*args):
- async_fire_mqtt_message(hass, "test/state", "online")
- return 0, 0
-
- mqtt_client_mock.subscribe.side_effect = side_effect
+ When subscribing to the same topic again, SUBSCRIBE must be sent to the broker again
+ for it to resend any retained messages.
+ """
# Fake that the client is connected
mqtt_mock().connected = True
calls_a = MagicMock()
await mqtt.async_subscribe(hass, "test/state", calls_a)
+ async_fire_mqtt_message(
+ hass, "test/state", "online"
+ ) # Simulate a (retained) message
await hass.async_block_till_done()
assert calls_a.called
+ mqtt_client_mock.subscribe.assert_called()
+ calls_a.reset_mock()
+ mqtt_client_mock.reset_mock()
calls_b = MagicMock()
await mqtt.async_subscribe(hass, "test/state", calls_b)
+ async_fire_mqtt_message(
+ hass, "test/state", "online"
+ ) # Simulate a (retained) message
await hass.async_block_till_done()
+ assert calls_a.called
assert calls_b.called
+ mqtt_client_mock.subscribe.assert_called()
async def test_not_calling_unsubscribe_with_active_subscribers(
@@ -639,13 +647,6 @@ async def test_restore_all_active_subscriptions_on_reconnect(
# Fake that the client is connected
mqtt_mock().connected = True
- mqtt_client_mock.subscribe.side_effect = (
- (0, 1),
- (0, 2),
- (0, 3),
- (0, 4),
- )
-
unsub = await mqtt.async_subscribe(hass, "test/state", None, qos=2)
await mqtt.async_subscribe(hass, "test/state", None)
await mqtt.async_subscribe(hass, "test/state", None, qos=1)
@@ -757,32 +758,49 @@ async def test_setup_without_tls_config_uses_tlsv1_under_python36(hass):
)
async def test_custom_birth_message(hass, mqtt_client_mock, mqtt_mock):
"""Test sending birth message."""
- calls = []
- mqtt_client_mock.publish.side_effect = lambda *args: calls.append(args)
- mqtt_mock._mqtt_on_connect(None, None, 0, 0)
- await hass.async_block_till_done()
- assert calls[-1] == ("birth", "birth", 0, False)
+ birth = asyncio.Event()
+
+ async def wait_birth(topic, payload, qos):
+ """Handle birth message."""
+ birth.set()
+
+ with patch("homeassistant.components.mqtt.DISCOVERY_COOLDOWN", 0.1):
+ await mqtt.async_subscribe(hass, "birth", wait_birth)
+ mqtt_mock._mqtt_on_connect(None, None, 0, 0)
+ await hass.async_block_till_done()
+ await birth.wait()
+ mqtt_client_mock.publish.assert_called_with("birth", "birth", 0, False)
async def test_default_birth_message(hass, mqtt_client_mock, mqtt_mock):
"""Test sending birth message."""
- calls = []
- mqtt_client_mock.publish.side_effect = lambda *args: calls.append(args)
- mqtt_mock._mqtt_on_connect(None, None, 0, 0)
- await hass.async_block_till_done()
- assert calls[-1] == ("homeassistant/status", "online", 0, False)
+ birth = asyncio.Event()
+
+ async def wait_birth(topic, payload, qos):
+ """Handle birth message."""
+ birth.set()
+
+ with patch("homeassistant.components.mqtt.DISCOVERY_COOLDOWN", 0.1):
+ await mqtt.async_subscribe(hass, "homeassistant/status", wait_birth)
+ mqtt_mock._mqtt_on_connect(None, None, 0, 0)
+ await hass.async_block_till_done()
+ await birth.wait()
+ mqtt_client_mock.publish.assert_called_with(
+ "homeassistant/status", "online", 0, False
+ )
@pytest.mark.parametrize(
- "mqtt_config", [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: {}}],
+ "mqtt_config",
+ [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: {}}],
)
async def test_no_birth_message(hass, mqtt_client_mock, mqtt_mock):
- """Test sending birth message."""
- calls = []
- mqtt_client_mock.publish.side_effect = lambda *args: calls.append(args)
- mqtt_mock._mqtt_on_connect(None, None, 0, 0)
- await hass.async_block_till_done()
- assert not calls
+ """Test disabling birth message."""
+ with patch("homeassistant.components.mqtt.DISCOVERY_COOLDOWN", 0.1):
+ mqtt_mock._mqtt_on_connect(None, None, 0, 0)
+ await hass.async_block_till_done()
+ await asyncio.sleep(0.2)
+ mqtt_client_mock.publish.assert_not_called()
@pytest.mark.parametrize(
@@ -812,7 +830,8 @@ async def test_default_will_message(hass, mqtt_client_mock, mqtt_mock):
@pytest.mark.parametrize(
- "mqtt_config", [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_WILL_MESSAGE: {}}],
+ "mqtt_config",
+ [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_WILL_MESSAGE: {}}],
)
async def test_no_will_message(hass, mqtt_client_mock, mqtt_mock):
"""Test will message."""
@@ -820,7 +839,8 @@ async def test_no_will_message(hass, mqtt_client_mock, mqtt_mock):
@pytest.mark.parametrize(
- "mqtt_config", [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: {}}],
+ "mqtt_config",
+ [{mqtt.CONF_BROKER: "mock-broker", mqtt.CONF_BIRTH_MESSAGE: {}}],
)
async def test_mqtt_subscribes_topics_on_connect(hass, mqtt_client_mock, mqtt_mock):
"""Test subscription to topic on connect."""
diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py
index 75d3e694838..5481b8b2565 100644
--- a/tests/components/mqtt/test_light.py
+++ b/tests/components/mqtt/test_light.py
@@ -153,11 +153,15 @@ light:
payload_off: "off"
"""
+import json
+from os import path
+
import pytest
+from homeassistant import config as hass_config
from homeassistant.components import light, mqtt
from homeassistant.components.mqtt.discovery import async_start
-from homeassistant.const import ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON
+from homeassistant.const import ATTR_ASSUMED_STATE, SERVICE_RELOAD, STATE_OFF, STATE_ON
import homeassistant.core as ha
from homeassistant.setup import async_setup_component
@@ -645,6 +649,35 @@ async def test_controlling_state_via_topic_with_templates(hass, mqtt_mock):
assert state.attributes.get("xy_color") == (0.14, 0.131)
+async def test_controlling_state_via_topic_with_value_template(hass, mqtt_mock):
+ """Test the setting of the state with undocumented value_template."""
+ config = {
+ light.DOMAIN: {
+ "platform": "mqtt",
+ "name": "test",
+ "state_topic": "test_light_rgb/status",
+ "command_topic": "test_light_rgb/set",
+ "value_template": "{{ value_json.hello }}",
+ }
+ }
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+ await hass.async_block_till_done()
+
+ state = hass.states.get("light.test")
+ assert state.state == STATE_OFF
+
+ async_fire_mqtt_message(hass, "test_light_rgb/status", '{"hello": "ON"}')
+
+ state = hass.states.get("light.test")
+ assert state.state == STATE_ON
+
+ async_fire_mqtt_message(hass, "test_light_rgb/status", '{"hello": "OFF"}')
+
+ state = hass.states.get("light.test")
+ assert state.state == STATE_OFF
+
+
async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock):
"""Test the sending of command in optimistic mode."""
config = {
@@ -716,9 +749,8 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock):
hass, "light.test", brightness=50, xy_color=[0.123, 0.123]
)
await common.async_turn_on(hass, "light.test", brightness=50, hs_color=[359, 78])
- await common.async_turn_on(
- hass, "light.test", rgb_color=[255, 128, 0], white_value=80
- )
+ await common.async_turn_on(hass, "light.test", rgb_color=[255, 128, 0])
+ await common.async_turn_on(hass, "light.test", white_value=80, color_temp=125)
mqtt_mock.async_publish.assert_has_calls(
[
@@ -728,6 +760,7 @@ async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock):
call("test_light_rgb/hs/set", "359.0,78.0", 2, False),
call("test_light_rgb/white_value/set", 80, 2, False),
call("test_light_rgb/xy/set", "0.14,0.131", 2, False),
+ call("test_light_rgb/color_temp/set", 125, 2, False),
],
any_order=True,
)
@@ -1434,20 +1467,471 @@ async def test_discovery_deprecated(hass, mqtt_mock, caplog):
assert state.name == "Beer"
-async def test_discovery_update_light(hass, mqtt_mock, caplog):
+async def test_discovery_update_light_topic_and_template(hass, mqtt_mock, caplog):
"""Test update of discovered light."""
- data1 = (
- '{ "name": "Beer",'
- ' "state_topic": "test_topic",'
- ' "command_topic": "test_topic" }'
+ data1 = json.dumps(
+ {
+ "name": "Beer",
+ "state_topic": "test_light_rgb/state1",
+ "command_topic": "test_light_rgb/set",
+ "brightness_command_topic": "test_light_rgb/state1",
+ "rgb_command_topic": "test_light_rgb/rgb/set",
+ "color_temp_command_topic": "test_light_rgb/state1",
+ "effect_command_topic": "test_light_rgb/effect/set",
+ "hs_command_topic": "test_light_rgb/hs/set",
+ "white_value_command_topic": "test_light_rgb/white_value/set",
+ "xy_command_topic": "test_light_rgb/xy/set",
+ "brightness_state_topic": "test_light_rgb/state1",
+ "color_temp_state_topic": "test_light_rgb/state1",
+ "effect_state_topic": "test_light_rgb/state1",
+ "hs_state_topic": "test_light_rgb/state1",
+ "rgb_state_topic": "test_light_rgb/state1",
+ "white_value_state_topic": "test_light_rgb/state1",
+ "xy_state_topic": "test_light_rgb/state1",
+ "state_value_template": "{{ value_json.state1.state }}",
+ "brightness_value_template": "{{ value_json.state1.brightness }}",
+ "color_temp_value_template": "{{ value_json.state1.ct }}",
+ "effect_value_template": "{{ value_json.state1.fx }}",
+ "hs_value_template": "{{ value_json.state1.hs }}",
+ "rgb_value_template": "{{ value_json.state1.rgb }}",
+ "white_value_template": "{{ value_json.state1.white }}",
+ "xy_value_template": "{{ value_json.state1.xy }}",
+ }
)
- data2 = (
- '{ "name": "Milk",'
- ' "state_topic": "test_topic",'
- ' "command_topic": "test_topic" }'
+
+ data2 = json.dumps(
+ {
+ "name": "Milk",
+ "state_topic": "test_light_rgb/state2",
+ "command_topic": "test_light_rgb/set",
+ "brightness_command_topic": "test_light_rgb/state2",
+ "rgb_command_topic": "test_light_rgb/rgb/set",
+ "color_temp_command_topic": "test_light_rgb/state2",
+ "effect_command_topic": "test_light_rgb/effect/set",
+ "hs_command_topic": "test_light_rgb/hs/set",
+ "white_value_command_topic": "test_light_rgb/white_value/set",
+ "xy_command_topic": "test_light_rgb/xy/set",
+ "brightness_state_topic": "test_light_rgb/state2",
+ "color_temp_state_topic": "test_light_rgb/state2",
+ "effect_state_topic": "test_light_rgb/state2",
+ "hs_state_topic": "test_light_rgb/state2",
+ "rgb_state_topic": "test_light_rgb/state2",
+ "white_value_state_topic": "test_light_rgb/state2",
+ "xy_state_topic": "test_light_rgb/state2",
+ "state_value_template": "{{ value_json.state2.state }}",
+ "brightness_value_template": "{{ value_json.state2.brightness }}",
+ "color_temp_value_template": "{{ value_json.state2.ct }}",
+ "effect_value_template": "{{ value_json.state2.fx }}",
+ "hs_value_template": "{{ value_json.state2.hs }}",
+ "rgb_value_template": "{{ value_json.state2.rgb }}",
+ "white_value_template": "{{ value_json.state2.white }}",
+ "xy_value_template": "{{ value_json.state2.xy }}",
+ }
)
+ state_data1 = [
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"state":"ON", "brightness":100, "ct":123, "fx":"cycle"}}',
+ )
+ ],
+ "on",
+ [("brightness", 100), ("color_temp", 123), ("effect", "cycle")],
+ ),
+ (
+ [("test_light_rgb/state1", '{"state1":{"state":"OFF"}}')],
+ "off",
+ None,
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"state":"ON", "hs":"1,2"}}',
+ )
+ ],
+ "on",
+ [("hs_color", (1, 2))],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"rgb":"255,127,63"}}',
+ )
+ ],
+ "on",
+ [("rgb_color", (255, 127, 63))],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"white":50, "xy":"0.3, 0.4"}}',
+ )
+ ],
+ "on",
+ [("white_value", 50), ("xy_color", (0.3, 0.401))],
+ ),
+ ]
+ state_data2 = [
+ (
+ [
+ (
+ "test_light_rgb/state2",
+ '{"state2":{"state":"ON", "brightness":50, "ct":200, "fx":"loop"}}',
+ )
+ ],
+ "on",
+ [("brightness", 50), ("color_temp", 200), ("effect", "loop")],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"state":"ON", "brightness":100, "ct":123, "fx":"cycle"}}',
+ ),
+ (
+ "test_light_rgb/state1",
+ '{"state2":{"state":"ON", "brightness":100, "ct":123, "fx":"cycle"}}',
+ ),
+ (
+ "test_light_rgb/state2",
+ '{"state1":{"state":"ON", "brightness":100, "ct":123, "fx":"cycle"}}',
+ ),
+ ],
+ "on",
+ [("brightness", 50), ("color_temp", 200), ("effect", "loop")],
+ ),
+ (
+ [("test_light_rgb/state1", '{"state1":{"state":"OFF"}}')],
+ "on",
+ None,
+ ),
+ (
+ [("test_light_rgb/state1", '{"state2":{"state":"OFF"}}')],
+ "on",
+ None,
+ ),
+ (
+ [("test_light_rgb/state2", '{"state1":{"state":"OFF"}}')],
+ "on",
+ None,
+ ),
+ (
+ [("test_light_rgb/state2", '{"state2":{"state":"OFF"}}')],
+ "off",
+ None,
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state2",
+ '{"state2":{"state":"ON", "hs":"1.2,2.2"}}',
+ )
+ ],
+ "on",
+ [("hs_color", (1.2, 2.2))],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"state":"ON", "hs":"1,2"}}',
+ ),
+ (
+ "test_light_rgb/state1",
+ '{"state2":{"state":"ON", "hs":"1,2"}}',
+ ),
+ (
+ "test_light_rgb/state2",
+ '{"state1":{"state":"ON", "hs":"1,2"}}',
+ ),
+ ],
+ "on",
+ [("hs_color", (1.2, 2.2))],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state2",
+ '{"state2":{"rgb":"63,127,255"}}',
+ )
+ ],
+ "on",
+ [("rgb_color", (63, 127, 255))],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"rgb":"255,127,63"}}',
+ ),
+ (
+ "test_light_rgb/state1",
+ '{"state2":{"rgb":"255,127,63"}}',
+ ),
+ (
+ "test_light_rgb/state2",
+ '{"state1":{"rgb":"255,127,63"}}',
+ ),
+ ],
+ "on",
+ [("rgb_color", (63, 127, 255))],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state2",
+ '{"state2":{"white":75, "xy":"0.4, 0.3"}}',
+ )
+ ],
+ "on",
+ [("white_value", 75), ("xy_color", (0.4, 0.3))],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"white":50, "xy":"0.3, 0.4"}}',
+ ),
+ (
+ "test_light_rgb/state1",
+ '{"state2":{"white":50, "xy":"0.3, 0.4"}}',
+ ),
+ (
+ "test_light_rgb/state2",
+ '{"state1":{"white":50, "xy":"0.3, 0.4"}}',
+ ),
+ ],
+ "on",
+ [("white_value", 75), ("xy_color", (0.4, 0.3))],
+ ),
+ ]
+
await help_test_discovery_update(
- hass, mqtt_mock, caplog, light.DOMAIN, data1, data2
+ hass,
+ mqtt_mock,
+ caplog,
+ light.DOMAIN,
+ data1,
+ data2,
+ state_data1=state_data1,
+ state_data2=state_data2,
+ )
+
+
+async def test_discovery_update_light_template(hass, mqtt_mock, caplog):
+ """Test update of discovered light."""
+ data1 = json.dumps(
+ {
+ "name": "Beer",
+ "state_topic": "test_light_rgb/state1",
+ "command_topic": "test_light_rgb/set",
+ "brightness_command_topic": "test_light_rgb/state1",
+ "rgb_command_topic": "test_light_rgb/rgb/set",
+ "color_temp_command_topic": "test_light_rgb/state1",
+ "effect_command_topic": "test_light_rgb/effect/set",
+ "hs_command_topic": "test_light_rgb/hs/set",
+ "white_value_command_topic": "test_light_rgb/white_value/set",
+ "xy_command_topic": "test_light_rgb/xy/set",
+ "brightness_state_topic": "test_light_rgb/state1",
+ "color_temp_state_topic": "test_light_rgb/state1",
+ "effect_state_topic": "test_light_rgb/state1",
+ "hs_state_topic": "test_light_rgb/state1",
+ "rgb_state_topic": "test_light_rgb/state1",
+ "white_value_state_topic": "test_light_rgb/state1",
+ "xy_state_topic": "test_light_rgb/state1",
+ "state_value_template": "{{ value_json.state1.state }}",
+ "brightness_value_template": "{{ value_json.state1.brightness }}",
+ "color_temp_value_template": "{{ value_json.state1.ct }}",
+ "effect_value_template": "{{ value_json.state1.fx }}",
+ "hs_value_template": "{{ value_json.state1.hs }}",
+ "rgb_value_template": "{{ value_json.state1.rgb }}",
+ "white_value_template": "{{ value_json.state1.white }}",
+ "xy_value_template": "{{ value_json.state1.xy }}",
+ }
+ )
+
+ data2 = json.dumps(
+ {
+ "name": "Milk",
+ "state_topic": "test_light_rgb/state1",
+ "command_topic": "test_light_rgb/set",
+ "brightness_command_topic": "test_light_rgb/state1",
+ "rgb_command_topic": "test_light_rgb/rgb/set",
+ "color_temp_command_topic": "test_light_rgb/state1",
+ "effect_command_topic": "test_light_rgb/effect/set",
+ "hs_command_topic": "test_light_rgb/hs/set",
+ "white_value_command_topic": "test_light_rgb/white_value/set",
+ "xy_command_topic": "test_light_rgb/xy/set",
+ "brightness_state_topic": "test_light_rgb/state1",
+ "color_temp_state_topic": "test_light_rgb/state1",
+ "effect_state_topic": "test_light_rgb/state1",
+ "hs_state_topic": "test_light_rgb/state1",
+ "rgb_state_topic": "test_light_rgb/state1",
+ "white_value_state_topic": "test_light_rgb/state1",
+ "xy_state_topic": "test_light_rgb/state1",
+ "state_value_template": "{{ value_json.state2.state }}",
+ "brightness_value_template": "{{ value_json.state2.brightness }}",
+ "color_temp_value_template": "{{ value_json.state2.ct }}",
+ "effect_value_template": "{{ value_json.state2.fx }}",
+ "hs_value_template": "{{ value_json.state2.hs }}",
+ "rgb_value_template": "{{ value_json.state2.rgb }}",
+ "white_value_template": "{{ value_json.state2.white }}",
+ "xy_value_template": "{{ value_json.state2.xy }}",
+ }
+ )
+ state_data1 = [
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"state":"ON", "brightness":100, "ct":123, "fx":"cycle"}}',
+ )
+ ],
+ "on",
+ [("brightness", 100), ("color_temp", 123), ("effect", "cycle")],
+ ),
+ (
+ [("test_light_rgb/state1", '{"state1":{"state":"OFF"}}')],
+ "off",
+ None,
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"state":"ON", "hs":"1,2"}}',
+ )
+ ],
+ "on",
+ [("hs_color", (1, 2))],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"rgb":"255,127,63"}}',
+ )
+ ],
+ "on",
+ [("rgb_color", (255, 127, 63))],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"white":50, "xy":"0.3, 0.4"}}',
+ )
+ ],
+ "on",
+ [("white_value", 50), ("xy_color", (0.3, 0.401))],
+ ),
+ ]
+ state_data2 = [
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state2":{"state":"ON", "brightness":50, "ct":200, "fx":"loop"}}',
+ )
+ ],
+ "on",
+ [("brightness", 50), ("color_temp", 200), ("effect", "loop")],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"state":"ON", "brightness":100, "ct":123, "fx":"cycle"}}',
+ ),
+ ],
+ "on",
+ [("brightness", 50), ("color_temp", 200), ("effect", "loop")],
+ ),
+ (
+ [("test_light_rgb/state1", '{"state1":{"state":"OFF"}}')],
+ "on",
+ None,
+ ),
+ (
+ [("test_light_rgb/state1", '{"state2":{"state":"OFF"}}')],
+ "off",
+ None,
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state2":{"state":"ON", "hs":"1.2,2.2"}}',
+ )
+ ],
+ "on",
+ [("hs_color", (1.2, 2.2))],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"state":"ON", "hs":"1,2"}}',
+ )
+ ],
+ "on",
+ [("hs_color", (1.2, 2.2))],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state2":{"rgb":"63,127,255"}}',
+ )
+ ],
+ "on",
+ [("rgb_color", (63, 127, 255))],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"rgb":"255,127,63"}}',
+ )
+ ],
+ "on",
+ [("rgb_color", (63, 127, 255))],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state2":{"white":75, "xy":"0.4, 0.3"}}',
+ )
+ ],
+ "on",
+ [("white_value", 75), ("xy_color", (0.4, 0.3))],
+ ),
+ (
+ [
+ (
+ "test_light_rgb/state1",
+ '{"state1":{"white":50, "xy":"0.3, 0.4"}}',
+ )
+ ],
+ "on",
+ [("white_value", 75), ("xy_color", (0.4, 0.3))],
+ ),
+ ]
+
+ await help_test_discovery_update(
+ hass,
+ mqtt_mock,
+ caplog,
+ light.DOMAIN,
+ data1,
+ data2,
+ state_data1=state_data1,
+ state_data2=state_data2,
)
@@ -1547,3 +2031,43 @@ async def test_max_mireds(hass, mqtt_mock):
state = hass.states.get("light.test")
assert state.attributes.get("min_mireds") == 153
assert state.attributes.get("max_mireds") == 370
+
+
+async def test_reloadable(hass, mqtt_mock):
+ """Test reloading an mqtt light."""
+ config = {
+ light.DOMAIN: {
+ "platform": "mqtt",
+ "name": "test",
+ "command_topic": "test/set",
+ }
+ }
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+ await hass.async_block_till_done()
+
+ assert hass.states.get("light.test")
+ assert len(hass.states.async_all()) == 1
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "mqtt/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ "mqtt",
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+ assert hass.states.get("light.test") is None
+ assert hass.states.get("light.reload")
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py
index 54292aeeb7b..9a3d9abacc9 100644
--- a/tests/components/mqtt/test_light_json.py
+++ b/tests/components/mqtt/test_light_json.py
@@ -1006,7 +1006,9 @@ async def test_invalid_values(hass, mqtt_mock):
# Bad HS color values
async_fire_mqtt_message(
- hass, "test_light_rgb", '{"state":"ON",' '"color":{"h":"bad","s":"val"}}',
+ hass,
+ "test_light_rgb",
+ '{"state":"ON",' '"color":{"h":"bad","s":"val"}}',
)
# Color should not have changed
@@ -1028,7 +1030,9 @@ async def test_invalid_values(hass, mqtt_mock):
# Bad XY color values
async_fire_mqtt_message(
- hass, "test_light_rgb", '{"state":"ON",' '"color":{"x":"bad","y":"val"}}',
+ hass,
+ "test_light_rgb",
+ '{"state":"ON",' '"color":{"x":"bad","y":"val"}}',
)
# Color should not have changed
diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py
index 0d31b9f33f2..77fd8c561b2 100644
--- a/tests/components/mqtt/test_sensor.py
+++ b/tests/components/mqtt/test_sensor.py
@@ -1,4 +1,5 @@
"""The tests for the MQTT sensor platform."""
+import copy
from datetime import datetime, timedelta
import json
@@ -430,12 +431,71 @@ async def test_discovery_removal_sensor(hass, mqtt_mock, caplog):
await help_test_discovery_removal(hass, mqtt_mock, caplog, sensor.DOMAIN, data)
-async def test_discovery_update_sensor(hass, mqtt_mock, caplog):
+async def test_discovery_update_sensor_topic_template(hass, mqtt_mock, caplog):
"""Test update of discovered sensor."""
- data1 = '{ "name": "Beer", "state_topic": "test_topic" }'
- data2 = '{ "name": "Milk", "state_topic": "test_topic" }'
+ config = {"name": "test", "state_topic": "test_topic"}
+ config1 = copy.deepcopy(config)
+ config2 = copy.deepcopy(config)
+ config1["name"] = "Beer"
+ config2["name"] = "Milk"
+ config1["state_topic"] = "sensor/state1"
+ config2["state_topic"] = "sensor/state2"
+ config1["value_template"] = "{{ value_json.state | int }}"
+ config2["value_template"] = "{{ value_json.state | int * 2 }}"
+
+ state_data1 = [
+ ([("sensor/state1", '{"state":100}')], "100", None),
+ ]
+ state_data2 = [
+ ([("sensor/state1", '{"state":1000}')], "100", None),
+ ([("sensor/state1", '{"state":1000}')], "100", None),
+ ([("sensor/state2", '{"state":100}')], "200", None),
+ ]
+
+ data1 = json.dumps(config1)
+ data2 = json.dumps(config2)
await help_test_discovery_update(
- hass, mqtt_mock, caplog, sensor.DOMAIN, data1, data2
+ hass,
+ mqtt_mock,
+ caplog,
+ sensor.DOMAIN,
+ data1,
+ data2,
+ state_data1=state_data1,
+ state_data2=state_data2,
+ )
+
+
+async def test_discovery_update_sensor_template(hass, mqtt_mock, caplog):
+ """Test update of discovered sensor."""
+ config = {"name": "test", "state_topic": "test_topic"}
+ config1 = copy.deepcopy(config)
+ config2 = copy.deepcopy(config)
+ config1["name"] = "Beer"
+ config2["name"] = "Milk"
+ config1["state_topic"] = "sensor/state1"
+ config2["state_topic"] = "sensor/state1"
+ config1["value_template"] = "{{ value_json.state | int }}"
+ config2["value_template"] = "{{ value_json.state | int * 2 }}"
+
+ state_data1 = [
+ ([("sensor/state1", '{"state":100}')], "100", None),
+ ]
+ state_data2 = [
+ ([("sensor/state1", '{"state":100}')], "200", None),
+ ]
+
+ data1 = json.dumps(config1)
+ data2 = json.dumps(config2)
+ await help_test_discovery_update(
+ hass,
+ mqtt_mock,
+ caplog,
+ sensor.DOMAIN,
+ data1,
+ data2,
+ state_data1=state_data1,
+ state_data2=state_data2,
)
diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py
index a6edb8d6f14..4d9c6dc2c77 100644
--- a/tests/components/mqtt/test_switch.py
+++ b/tests/components/mqtt/test_switch.py
@@ -1,4 +1,7 @@
"""The tests for the MQTT switch platform."""
+import copy
+import json
+
import pytest
from homeassistant.components import switch
@@ -304,20 +307,75 @@ async def test_discovery_removal_switch(hass, mqtt_mock, caplog):
await help_test_discovery_removal(hass, mqtt_mock, caplog, switch.DOMAIN, data)
-async def test_discovery_update_switch(hass, mqtt_mock, caplog):
+async def test_discovery_update_switch_topic_template(hass, mqtt_mock, caplog):
"""Test update of discovered switch."""
- data1 = (
- '{ "name": "Beer",'
- ' "state_topic": "test_topic",'
- ' "command_topic": "test_topic" }'
- )
- data2 = (
- '{ "name": "Milk",'
- ' "state_topic": "test_topic",'
- ' "command_topic": "test_topic" }'
- )
+ config1 = copy.deepcopy(DEFAULT_CONFIG[switch.DOMAIN])
+ config2 = copy.deepcopy(DEFAULT_CONFIG[switch.DOMAIN])
+ config1["name"] = "Beer"
+ config2["name"] = "Milk"
+ config1["state_topic"] = "switch/state1"
+ config2["state_topic"] = "switch/state2"
+ config1["value_template"] = "{{ value_json.state1.state }}"
+ config2["value_template"] = "{{ value_json.state2.state }}"
+
+ state_data1 = [
+ ([("switch/state1", '{"state1":{"state":"ON"}}')], "on", None),
+ ]
+ state_data2 = [
+ ([("switch/state2", '{"state2":{"state":"OFF"}}')], "off", None),
+ ([("switch/state2", '{"state2":{"state":"ON"}}')], "on", None),
+ ([("switch/state1", '{"state1":{"state":"OFF"}}')], "on", None),
+ ([("switch/state1", '{"state2":{"state":"OFF"}}')], "on", None),
+ ([("switch/state2", '{"state1":{"state":"OFF"}}')], "on", None),
+ ([("switch/state2", '{"state2":{"state":"OFF"}}')], "off", None),
+ ]
+
+ data1 = json.dumps(config1)
+ data2 = json.dumps(config2)
await help_test_discovery_update(
- hass, mqtt_mock, caplog, switch.DOMAIN, data1, data2
+ hass,
+ mqtt_mock,
+ caplog,
+ switch.DOMAIN,
+ data1,
+ data2,
+ state_data1=state_data1,
+ state_data2=state_data2,
+ )
+
+
+async def test_discovery_update_switch_template(hass, mqtt_mock, caplog):
+ """Test update of discovered switch."""
+ config1 = copy.deepcopy(DEFAULT_CONFIG[switch.DOMAIN])
+ config2 = copy.deepcopy(DEFAULT_CONFIG[switch.DOMAIN])
+ config1["name"] = "Beer"
+ config2["name"] = "Milk"
+ config1["state_topic"] = "switch/state1"
+ config2["state_topic"] = "switch/state1"
+ config1["value_template"] = "{{ value_json.state1.state }}"
+ config2["value_template"] = "{{ value_json.state2.state }}"
+
+ state_data1 = [
+ ([("switch/state1", '{"state1":{"state":"ON"}}')], "on", None),
+ ]
+ state_data2 = [
+ ([("switch/state1", '{"state2":{"state":"OFF"}}')], "off", None),
+ ([("switch/state1", '{"state2":{"state":"ON"}}')], "on", None),
+ ([("switch/state1", '{"state1":{"state":"OFF"}}')], "on", None),
+ ([("switch/state1", '{"state2":{"state":"OFF"}}')], "off", None),
+ ]
+
+ data1 = json.dumps(config1)
+ data2 = json.dumps(config2)
+ await help_test_discovery_update(
+ hass,
+ mqtt_mock,
+ caplog,
+ switch.DOMAIN,
+ data1,
+ data2,
+ state_data1=state_data1,
+ state_data2=state_data2,
)
diff --git a/tests/components/automation/test_mqtt.py b/tests/components/mqtt/test_trigger.py
similarity index 100%
rename from tests/components/automation/test_mqtt.py
rename to tests/components/mqtt/test_trigger.py
diff --git a/tests/components/mqtt_eventstream/test_init.py b/tests/components/mqtt_eventstream/test_init.py
index ecdedf904d4..87139a0f3ee 100644
--- a/tests/components/mqtt_eventstream/test_init.py
+++ b/tests/components/mqtt_eventstream/test_init.py
@@ -70,7 +70,8 @@ async def test_state_changed_event_sends_message(hass, mqtt_mock):
e_id = "fake.entity"
pub_topic = "bar"
with patch(
- ("homeassistant.core.dt_util.utcnow"), return_value=now,
+ ("homeassistant.core.dt_util.utcnow"),
+ return_value=now,
):
# Add the eventstream component for publishing events
assert await add_eventstream(hass, pub_topic=pub_topic)
diff --git a/tests/components/myq/test_config_flow.py b/tests/components/myq/test_config_flow.py
index ed022df0dd7..4d1bf7db683 100644
--- a/tests/components/myq/test_config_flow.py
+++ b/tests/components/myq/test_config_flow.py
@@ -19,11 +19,13 @@ async def test_form_user(hass):
assert result["errors"] == {}
with patch(
- "homeassistant.components.myq.config_flow.pymyq.login", return_value=True,
+ "homeassistant.components.myq.config_flow.pymyq.login",
+ return_value=True,
), patch(
"homeassistant.components.myq.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.myq.async_setup_entry", return_value=True,
+ "homeassistant.components.myq.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -46,11 +48,13 @@ async def test_import(hass):
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
- "homeassistant.components.myq.config_flow.pymyq.login", return_value=True,
+ "homeassistant.components.myq.config_flow.pymyq.login",
+ return_value=True,
), patch(
"homeassistant.components.myq.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.myq.async_setup_entry", return_value=True,
+ "homeassistant.components.myq.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -95,7 +99,8 @@ async def test_form_cannot_connect(hass):
)
with patch(
- "homeassistant.components.myq.config_flow.pymyq.login", side_effect=MyQError,
+ "homeassistant.components.myq.config_flow.pymyq.login",
+ side_effect=MyQError,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
diff --git a/tests/components/myq/util.py b/tests/components/myq/util.py
index 61e49a98b83..61c19325d57 100644
--- a/tests/components/myq/util.py
+++ b/tests/components/myq/util.py
@@ -11,7 +11,8 @@ from tests.common import MockConfigEntry, load_fixture
async def async_init_integration(
- hass: HomeAssistant, skip_setup: bool = False,
+ hass: HomeAssistant,
+ skip_setup: bool = False,
) -> MockConfigEntry:
"""Set up the myq integration in Home Assistant."""
diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py
index c6091e4d5e1..8cee7a8c750 100644
--- a/tests/components/netatmo/test_config_flow.py
+++ b/tests/components/netatmo/test_config_flow.py
@@ -39,10 +39,10 @@ async def test_abort_if_existing_entry(hass):
data={"host": "0.0.0.0", "properties": {"id": "aa:bb:cc:dd:ee:ff"}},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
- assert result["reason"] == "single_instance_allowed"
+ assert result["reason"] == "already_configured"
-async def test_full_flow(hass, aiohttp_client, aioclient_mock):
+async def test_full_flow(hass, aiohttp_client, aioclient_mock, current_request):
"""Check full flow."""
assert await setup.async_setup_component(
hass,
@@ -127,7 +127,10 @@ async def test_option_flow(hass):
}
config_entry = MockConfigEntry(
- domain=DOMAIN, unique_id=DOMAIN, data=VALID_CONFIG, options={},
+ domain=DOMAIN,
+ unique_id=DOMAIN,
+ data=VALID_CONFIG,
+ options={},
)
config_entry.add_to_hass(hass)
@@ -182,7 +185,10 @@ async def test_option_flow_wrong_coordinates(hass):
}
config_entry = MockConfigEntry(
- domain=DOMAIN, unique_id=DOMAIN, data=VALID_CONFIG, options={},
+ domain=DOMAIN,
+ unique_id=DOMAIN,
+ data=VALID_CONFIG,
+ options={},
)
config_entry.add_to_hass(hass)
diff --git a/tests/components/netatmo/test_media_source.py b/tests/components/netatmo/test_media_source.py
new file mode 100644
index 00000000000..1773c0d83da
--- /dev/null
+++ b/tests/components/netatmo/test_media_source.py
@@ -0,0 +1,130 @@
+"""Test Local Media Source."""
+import pytest
+
+from homeassistant.components import media_source
+from homeassistant.components.media_source import const
+from homeassistant.components.media_source.models import PlayMedia
+from homeassistant.components.netatmo import DATA_CAMERAS, DATA_EVENTS, DOMAIN
+from homeassistant.setup import async_setup_component
+
+
+async def test_async_browse_media(hass):
+ """Test browse media."""
+ assert await async_setup_component(hass, DOMAIN, {})
+
+ # Prepare cached Netatmo event date
+ hass.data[DOMAIN] = {}
+ hass.data[DOMAIN][DATA_EVENTS] = {
+ "12:34:56:78:90:ab": {
+ 1599152672: {
+ "id": "12345",
+ "type": "person",
+ "time": 1599152672,
+ "camera_id": "12:34:56:78:90:ab",
+ "snapshot": {
+ "url": "https://netatmocameraimage",
+ },
+ "video_id": "98765",
+ "video_status": "available",
+ "message": "Paulus seen",
+ "media_url": "http:///files/high/index.m3u8",
+ },
+ 1599152673: {
+ "id": "12346",
+ "type": "person",
+ "time": 1599152673,
+ "camera_id": "12:34:56:78:90:ab",
+ "snapshot": {
+ "url": "https://netatmocameraimage",
+ },
+ "message": "Tobias seen",
+ },
+ 1599152674: {
+ "id": "12347",
+ "type": "outdoor",
+ "time": 1599152674,
+ "camera_id": "12:34:56:78:90:ac",
+ "snapshot": {
+ "url": "https://netatmocameraimage",
+ },
+ "video_id": "98766",
+ "video_status": "available",
+ "event_list": [
+ {
+ "type": "vehicle",
+ "time": 1599152674,
+ "id": "12347-0",
+ "offset": 0,
+ "message": "Vehicle detected",
+ "snapshot": {
+ "url": "https://netatmocameraimage",
+ },
+ },
+ {
+ "type": "human",
+ "time": 1599152674,
+ "id": "12347-1",
+ "offset": 8,
+ "message": "Person detected",
+ "snapshot": {
+ "url": "https://netatmocameraimage",
+ },
+ },
+ ],
+ "media_url": "http:///files/high/index.m3u8",
+ },
+ }
+ }
+
+ hass.data[DOMAIN][DATA_CAMERAS] = {
+ "12:34:56:78:90:ab": "MyCamera",
+ "12:34:56:78:90:ac": "MyOutdoorCamera",
+ }
+
+ assert await async_setup_component(hass, const.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ # Test camera not exists
+ with pytest.raises(media_source.BrowseError) as excinfo:
+ await media_source.async_browse_media(
+ hass, f"{const.URI_SCHEME}{DOMAIN}/events/98:76:54:32:10:ff"
+ )
+ assert str(excinfo.value) == "Camera does not exist."
+
+ # Test browse event
+ with pytest.raises(media_source.BrowseError) as excinfo:
+ await media_source.async_browse_media(
+ hass, f"{const.URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab/12345"
+ )
+ assert str(excinfo.value) == "Event does not exist."
+
+ # Test invalid base
+ with pytest.raises(media_source.BrowseError) as excinfo:
+ await media_source.async_browse_media(
+ hass, f"{const.URI_SCHEME}{DOMAIN}/invalid/base"
+ )
+ assert str(excinfo.value) == "Unknown source directory."
+
+ # Test successful listing
+ media = await media_source.async_browse_media(
+ hass, f"{const.URI_SCHEME}{DOMAIN}/events/"
+ )
+
+ # Test successful events listing
+ media = await media_source.async_browse_media(
+ hass, f"{const.URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab"
+ )
+
+ # Test successful event listing
+ media = await media_source.async_browse_media(
+ hass, f"{const.URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab/1599152672"
+ )
+ assert media
+
+ # Test successful event resolve
+ media = await media_source.async_resolve_media(
+ hass, f"{const.URI_SCHEME}{DOMAIN}/events/12:34:56:78:90:ab/1599152672"
+ )
+ assert media == PlayMedia(
+ url="http:///files/high/index.m3u8", mime_type="application/x-mpegURL"
+ )
diff --git a/tests/components/nexia/test_config_flow.py b/tests/components/nexia/test_config_flow.py
index 0dce512cff4..944eb612038 100644
--- a/tests/components/nexia/test_config_flow.py
+++ b/tests/components/nexia/test_config_flow.py
@@ -1,5 +1,5 @@
"""Test the nexia config flow."""
-from requests.exceptions import ConnectTimeout
+from requests.exceptions import ConnectTimeout, HTTPError
from homeassistant import config_entries, setup
from homeassistant.components.nexia.const import DOMAIN
@@ -26,10 +26,12 @@ async def test_form(hass):
), patch(
"homeassistant.components.nexia.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.nexia.async_setup_entry", return_value=True,
+ "homeassistant.components.nexia.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_USERNAME: "username", CONF_PASSWORD: "password"},
+ result["flow_id"],
+ {CONF_USERNAME: "username", CONF_PASSWORD: "password"},
)
assert result2["type"] == "create_entry"
@@ -51,7 +53,8 @@ async def test_form_invalid_auth(hass):
with patch("homeassistant.components.nexia.config_flow.NexiaHome.login"):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_USERNAME: "username", CONF_PASSWORD: "password"},
+ result["flow_id"],
+ {CONF_USERNAME: "username", CONF_PASSWORD: "password"},
)
assert result2["type"] == "form"
@@ -69,13 +72,75 @@ async def test_form_cannot_connect(hass):
side_effect=ConnectTimeout,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_USERNAME: "username", CONF_PASSWORD: "password"},
+ result["flow_id"],
+ {CONF_USERNAME: "username", CONF_PASSWORD: "password"},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}
+async def test_form_invalid_auth_http_401(hass):
+ """Test we handle invalid auth error from http 401."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ response_mock = MagicMock()
+ type(response_mock).status_code = 401
+ with patch(
+ "homeassistant.components.nexia.config_flow.NexiaHome.login",
+ side_effect=HTTPError(response=response_mock),
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_USERNAME: "username", CONF_PASSWORD: "password"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "invalid_auth"}
+
+
+async def test_form_cannot_connect_not_found(hass):
+ """Test we handle cannot connect from an http not found error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ response_mock = MagicMock()
+ type(response_mock).status_code = 404
+ with patch(
+ "homeassistant.components.nexia.config_flow.NexiaHome.login",
+ side_effect=HTTPError(response=response_mock),
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_USERNAME: "username", CONF_PASSWORD: "password"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_broad_exception(hass):
+ """Test we handle invalid auth error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.nexia.config_flow.NexiaHome.login",
+ side_effect=ValueError,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_USERNAME: "username", CONF_PASSWORD: "password"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "unknown"}
+
+
async def test_form_import(hass):
"""Test we get the form with import source."""
await setup.async_setup_component(hass, "persistent_notification", {})
@@ -89,7 +154,8 @@ async def test_form_import(hass):
), patch(
"homeassistant.components.nexia.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.nexia.async_setup_entry", return_value=True,
+ "homeassistant.components.nexia.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
diff --git a/tests/components/nexia/test_sensor.py b/tests/components/nexia/test_sensor.py
index a0fa387cd43..289947fbdf6 100644
--- a/tests/components/nexia/test_sensor.py
+++ b/tests/components/nexia/test_sensor.py
@@ -1,6 +1,6 @@
"""The sensor tests for the nexia platform."""
-from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE, TEMP_CELSIUS
from .util import async_init_integration
@@ -69,7 +69,7 @@ async def test_create_sensors(hass):
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"friendly_name": "Master Suite Current Compressor Speed",
- "unit_of_measurement": UNIT_PERCENTAGE,
+ "unit_of_measurement": PERCENTAGE,
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
@@ -99,7 +99,7 @@ async def test_create_sensors(hass):
"attribution": "Data provided by mynexia.com",
"device_class": "humidity",
"friendly_name": "Master Suite Relative Humidity",
- "unit_of_measurement": UNIT_PERCENTAGE,
+ "unit_of_measurement": PERCENTAGE,
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
@@ -113,7 +113,7 @@ async def test_create_sensors(hass):
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"friendly_name": "Master Suite Requested Compressor Speed",
- "unit_of_measurement": UNIT_PERCENTAGE,
+ "unit_of_measurement": PERCENTAGE,
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
diff --git a/tests/components/nexia/test_util.py b/tests/components/nexia/test_util.py
new file mode 100644
index 00000000000..0820c7caf0c
--- /dev/null
+++ b/tests/components/nexia/test_util.py
@@ -0,0 +1,20 @@
+"""The sensor tests for the nexia platform."""
+
+
+from homeassistant.components.nexia import util
+from homeassistant.const import HTTP_FORBIDDEN, HTTP_NOT_FOUND, HTTP_UNAUTHORIZED
+
+
+async def test_is_invalid_auth_code():
+ """Test for invalid auth."""
+
+ assert util.is_invalid_auth_code(HTTP_UNAUTHORIZED) is True
+ assert util.is_invalid_auth_code(HTTP_FORBIDDEN) is True
+ assert util.is_invalid_auth_code(HTTP_NOT_FOUND) is False
+
+
+async def test_percent_conv():
+ """Test percentage conversion."""
+
+ assert util.percent_conv(0.12) == 12.0
+ assert util.percent_conv(0.123) == 12.3
diff --git a/tests/components/nexia/util.py b/tests/components/nexia/util.py
index 2da56d50f37..7d34f0894e0 100644
--- a/tests/components/nexia/util.py
+++ b/tests/components/nexia/util.py
@@ -13,7 +13,8 @@ from tests.common import MockConfigEntry, load_fixture
async def async_init_integration(
- hass: HomeAssistant, skip_setup: bool = False,
+ hass: HomeAssistant,
+ skip_setup: bool = False,
) -> MockConfigEntry:
"""Set up the nexia integration in Home Assistant."""
diff --git a/tests/components/nightscout/__init__.py b/tests/components/nightscout/__init__.py
new file mode 100644
index 00000000000..52064d1a92b
--- /dev/null
+++ b/tests/components/nightscout/__init__.py
@@ -0,0 +1,83 @@
+"""Tests for the Nightscout integration."""
+import json
+
+from aiohttp import ClientConnectionError
+from py_nightscout.models import SGV, ServerStatus
+
+from homeassistant.components.nightscout.const import DOMAIN
+from homeassistant.const import CONF_URL
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+GLUCOSE_READINGS = [
+ SGV.new_from_json_dict(
+ json.loads(
+ '{"_id":"5f2b01f5c3d0ac7c4090e223","device":"xDrip-LimiTTer","date":1596654066533,"dateString":"2020-08-05T19:01:06.533Z","sgv":169,"delta":-5.257,"direction":"FortyFiveDown","type":"sgv","filtered":182823.5157,"unfiltered":182823.5157,"rssi":100,"noise":1,"sysTime":"2020-08-05T19:01:06.533Z","utcOffset":-180}'
+ )
+ )
+]
+SERVER_STATUS = ServerStatus.new_from_json_dict(
+ json.loads(
+ '{"status":"ok","name":"nightscout","version":"13.0.1","serverTime":"2020-08-05T18:14:02.032Z","serverTimeEpoch":1596651242032,"apiEnabled":true,"careportalEnabled":true,"boluscalcEnabled":true,"settings":{},"extendedSettings":{},"authorized":null}'
+ )
+)
+
+
+async def init_integration(hass) -> MockConfigEntry:
+ """Set up the Nightscout integration in Home Assistant."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_URL: "https://some.url:1234"},
+ )
+ with patch(
+ "homeassistant.components.nightscout.NightscoutAPI.get_sgvs",
+ return_value=GLUCOSE_READINGS,
+ ), patch(
+ "homeassistant.components.nightscout.NightscoutAPI.get_server_status",
+ return_value=SERVER_STATUS,
+ ):
+ entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ return entry
+
+
+async def init_integration_unavailable(hass) -> MockConfigEntry:
+ """Set up the Nightscout integration in Home Assistant."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_URL: "https://some.url:1234"},
+ )
+ with patch(
+ "homeassistant.components.nightscout.NightscoutAPI.get_sgvs",
+ side_effect=ClientConnectionError(),
+ ), patch(
+ "homeassistant.components.nightscout.NightscoutAPI.get_server_status",
+ return_value=SERVER_STATUS,
+ ):
+ entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ return entry
+
+
+async def init_integration_empty_response(hass) -> MockConfigEntry:
+ """Set up the Nightscout integration in Home Assistant."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_URL: "https://some.url:1234"},
+ )
+ with patch(
+ "homeassistant.components.nightscout.NightscoutAPI.get_sgvs", return_value=[]
+ ), patch(
+ "homeassistant.components.nightscout.NightscoutAPI.get_server_status",
+ return_value=SERVER_STATUS,
+ ):
+ entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ return entry
diff --git a/tests/components/nightscout/test_config_flow.py b/tests/components/nightscout/test_config_flow.py
new file mode 100644
index 00000000000..a5f3315fbb1
--- /dev/null
+++ b/tests/components/nightscout/test_config_flow.py
@@ -0,0 +1,114 @@
+"""Test the Nightscout config flow."""
+from aiohttp import ClientConnectionError
+
+from homeassistant import config_entries, data_entry_flow, setup
+from homeassistant.components.nightscout.const import DOMAIN
+from homeassistant.components.nightscout.utils import hash_from_url
+from homeassistant.const import CONF_URL
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+from tests.components.nightscout import GLUCOSE_READINGS, SERVER_STATUS
+
+CONFIG = {CONF_URL: "https://some.url:1234"}
+
+
+async def test_form(hass):
+ """Test we get the user initiated form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {}
+
+ with _patch_glucose_readings(), _patch_server_status(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ CONFIG,
+ )
+
+ assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result2["title"] == SERVER_STATUS.name # pylint: disable=maybe-no-member
+ assert result2["data"] == CONFIG
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_user_form_cannot_connect(hass):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.nightscout.NightscoutAPI.get_server_status",
+ side_effect=ClientConnectionError(),
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_URL: "https://some.url:1234"},
+ )
+
+ assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def test_user_form_unexpected_exception(hass):
+ """Test we handle unexpected exception."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.nightscout.NightscoutAPI.get_server_status",
+ side_effect=Exception(),
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_URL: "https://some.url:1234"},
+ )
+
+ assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result2["errors"] == {"base": "unknown"}
+
+
+async def test_user_form_duplicate(hass):
+ """Test duplicate entries."""
+ with _patch_glucose_readings(), _patch_server_status():
+ unique_id = hash_from_url(CONFIG[CONF_URL])
+ entry = MockConfigEntry(domain=DOMAIN, unique_id=unique_id)
+ await hass.config_entries.async_add(entry)
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data=CONFIG,
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+def _patch_async_setup():
+ return patch("homeassistant.components.nightscout.async_setup", return_value=True)
+
+
+def _patch_async_setup_entry():
+ return patch(
+ "homeassistant.components.nightscout.async_setup_entry",
+ return_value=True,
+ )
+
+
+def _patch_glucose_readings():
+ return patch(
+ "homeassistant.components.nightscout.NightscoutAPI.get_sgvs",
+ return_value=GLUCOSE_READINGS,
+ )
+
+
+def _patch_server_status():
+ return patch(
+ "homeassistant.components.nightscout.NightscoutAPI.get_server_status",
+ return_value=SERVER_STATUS,
+ )
diff --git a/tests/components/nightscout/test_init.py b/tests/components/nightscout/test_init.py
new file mode 100644
index 00000000000..d81559ceba7
--- /dev/null
+++ b/tests/components/nightscout/test_init.py
@@ -0,0 +1,44 @@
+"""Test the Nightscout config flow."""
+from aiohttp import ClientError
+
+from homeassistant.components.nightscout.const import DOMAIN
+from homeassistant.config_entries import (
+ ENTRY_STATE_LOADED,
+ ENTRY_STATE_NOT_LOADED,
+ ENTRY_STATE_SETUP_RETRY,
+)
+from homeassistant.const import CONF_URL
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+from tests.components.nightscout import init_integration
+
+
+async def test_unload_entry(hass):
+ """Test successful unload of entry."""
+ entry = await init_integration(hass)
+
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+ assert entry.state == ENTRY_STATE_LOADED
+
+ assert await hass.config_entries.async_unload(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.state == ENTRY_STATE_NOT_LOADED
+ assert not hass.data.get(DOMAIN)
+
+
+async def test_async_setup_raises_entry_not_ready(hass):
+ """Test that it throws ConfigEntryNotReady when exception occurs during setup."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_URL: "https://some.url:1234"},
+ )
+ config_entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.nightscout.NightscoutAPI.get_server_status",
+ side_effect=ClientError(),
+ ):
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ assert config_entry.state == ENTRY_STATE_SETUP_RETRY
diff --git a/tests/components/nightscout/test_sensor.py b/tests/components/nightscout/test_sensor.py
new file mode 100644
index 00000000000..3df98a2595a
--- /dev/null
+++ b/tests/components/nightscout/test_sensor.py
@@ -0,0 +1,58 @@
+"""The sensor tests for the Nightscout platform."""
+
+from homeassistant.components.nightscout.const import (
+ ATTR_DATE,
+ ATTR_DELTA,
+ ATTR_DEVICE,
+ ATTR_DIRECTION,
+)
+from homeassistant.const import ATTR_ICON, STATE_UNAVAILABLE
+
+from tests.components.nightscout import (
+ GLUCOSE_READINGS,
+ init_integration,
+ init_integration_empty_response,
+ init_integration_unavailable,
+)
+
+
+async def test_sensor_state(hass):
+ """Test sensor state data."""
+ await init_integration(hass)
+
+ test_glucose_sensor = hass.states.get("sensor.blood_sugar")
+ assert test_glucose_sensor.state == str(
+ GLUCOSE_READINGS[0].sgv # pylint: disable=maybe-no-member
+ )
+
+
+async def test_sensor_error(hass):
+ """Test sensor state data."""
+ await init_integration_unavailable(hass)
+
+ test_glucose_sensor = hass.states.get("sensor.blood_sugar")
+ assert test_glucose_sensor.state == STATE_UNAVAILABLE
+
+
+async def test_sensor_empty_response(hass):
+ """Test sensor state data."""
+ await init_integration_empty_response(hass)
+
+ test_glucose_sensor = hass.states.get("sensor.blood_sugar")
+ assert test_glucose_sensor.state == STATE_UNAVAILABLE
+
+
+async def test_sensor_attributes(hass):
+ """Test sensor attributes."""
+ await init_integration(hass)
+
+ test_glucose_sensor = hass.states.get("sensor.blood_sugar")
+ reading = GLUCOSE_READINGS[0]
+ assert reading is not None
+
+ attr = test_glucose_sensor.attributes
+ assert attr[ATTR_DATE] == reading.date # pylint: disable=maybe-no-member
+ assert attr[ATTR_DELTA] == reading.delta # pylint: disable=maybe-no-member
+ assert attr[ATTR_DEVICE] == reading.device # pylint: disable=maybe-no-member
+ assert attr[ATTR_DIRECTION] == reading.direction # pylint: disable=maybe-no-member
+ assert attr[ATTR_ICON] == "mdi:arrow-bottom-right"
diff --git a/tests/components/nuheat/test_climate.py b/tests/components/nuheat/test_climate.py
index b407461fa89..51e80e6b3c1 100644
--- a/tests/components/nuheat/test_climate.py
+++ b/tests/components/nuheat/test_climate.py
@@ -20,7 +20,8 @@ async def test_climate_thermostat_run(hass):
mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat)
with patch(
- "homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat,
+ "homeassistant.components.nuheat.nuheat.NuHeat",
+ return_value=mock_nuheat,
):
assert await async_setup_component(hass, DOMAIN, _mock_get_config())
await hass.async_block_till_done()
@@ -50,7 +51,8 @@ async def test_climate_thermostat_schedule_hold_unavailable(hass):
mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat)
with patch(
- "homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat,
+ "homeassistant.components.nuheat.nuheat.NuHeat",
+ return_value=mock_nuheat,
):
assert await async_setup_component(hass, DOMAIN, _mock_get_config())
await hass.async_block_till_done()
@@ -77,7 +79,8 @@ async def test_climate_thermostat_schedule_hold_available(hass):
mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat)
with patch(
- "homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat,
+ "homeassistant.components.nuheat.nuheat.NuHeat",
+ return_value=mock_nuheat,
):
assert await async_setup_component(hass, DOMAIN, _mock_get_config())
await hass.async_block_till_done()
@@ -108,7 +111,8 @@ async def test_climate_thermostat_schedule_temporary_hold(hass):
mock_nuheat = _get_mock_nuheat(get_thermostat=mock_thermostat)
with patch(
- "homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat,
+ "homeassistant.components.nuheat.nuheat.NuHeat",
+ return_value=mock_nuheat,
):
assert await async_setup_component(hass, DOMAIN, _mock_get_config())
await hass.async_block_till_done()
diff --git a/tests/components/nuheat/test_init.py b/tests/components/nuheat/test_init.py
index 4a7a8673230..8cd08f2edd5 100644
--- a/tests/components/nuheat/test_init.py
+++ b/tests/components/nuheat/test_init.py
@@ -17,7 +17,8 @@ async def test_init_success(hass):
mock_nuheat = _get_mock_nuheat()
with patch(
- "homeassistant.components.nuheat.nuheat.NuHeat", return_value=mock_nuheat,
+ "homeassistant.components.nuheat.nuheat.NuHeat",
+ return_value=mock_nuheat,
):
assert await async_setup_component(hass, DOMAIN, VALID_CONFIG)
await hass.async_block_till_done()
diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py
index d86c0d8eb88..8d50d77c31d 100644
--- a/tests/components/nut/test_config_flow.py
+++ b/tests/components/nut/test_config_flow.py
@@ -33,7 +33,8 @@ async def test_form_zeroconf(hass):
)
with patch(
- "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut,
+ "homeassistant.components.nut.PyNUTClient",
+ return_value=mock_pynut,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -44,11 +45,13 @@ async def test_form_zeroconf(hass):
assert result2["type"] == "form"
with patch(
- "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut,
+ "homeassistant.components.nut.PyNUTClient",
+ return_value=mock_pynut,
), patch(
"homeassistant.components.nut.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.nut.async_setup_entry", return_value=True,
+ "homeassistant.components.nut.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
@@ -84,7 +87,8 @@ async def test_form_user_one_ups(hass):
)
with patch(
- "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut,
+ "homeassistant.components.nut.PyNUTClient",
+ return_value=mock_pynut,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -100,11 +104,13 @@ async def test_form_user_one_ups(hass):
assert result2["type"] == "form"
with patch(
- "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut,
+ "homeassistant.components.nut.PyNUTClient",
+ return_value=mock_pynut,
), patch(
"homeassistant.components.nut.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.nut.async_setup_entry", return_value=True,
+ "homeassistant.components.nut.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
@@ -148,7 +154,8 @@ async def test_form_user_multiple_ups(hass):
)
with patch(
- "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut,
+ "homeassistant.components.nut.PyNUTClient",
+ return_value=mock_pynut,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -164,24 +171,29 @@ async def test_form_user_multiple_ups(hass):
assert result2["type"] == "form"
with patch(
- "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut,
+ "homeassistant.components.nut.PyNUTClient",
+ return_value=mock_pynut,
):
result3 = await hass.config_entries.flow.async_configure(
- result2["flow_id"], {"alias": "ups2"},
+ result2["flow_id"],
+ {"alias": "ups2"},
)
assert result3["step_id"] == "resources"
assert result3["type"] == "form"
with patch(
- "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut,
+ "homeassistant.components.nut.PyNUTClient",
+ return_value=mock_pynut,
), patch(
"homeassistant.components.nut.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.nut.async_setup_entry", return_value=True,
+ "homeassistant.components.nut.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result4 = await hass.config_entries.flow.async_configure(
- result3["flow_id"], {"resources": ["battery.voltage"]},
+ result3["flow_id"],
+ {"resources": ["battery.voltage"]},
)
assert result4["type"] == "create_entry"
@@ -209,11 +221,13 @@ async def test_form_import(hass):
)
with patch(
- "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut,
+ "homeassistant.components.nut.PyNUTClient",
+ return_value=mock_pynut,
), patch(
"homeassistant.components.nut.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.nut.async_setup_entry", return_value=True,
+ "homeassistant.components.nut.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -280,7 +294,8 @@ async def test_form_cannot_connect(hass):
mock_pynut = _get_mock_pynutclient()
with patch(
- "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut,
+ "homeassistant.components.nut.PyNUTClient",
+ return_value=mock_pynut,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -312,7 +327,8 @@ async def test_options_flow(hass):
)
with patch(
- "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut,
+ "homeassistant.components.nut.PyNUTClient",
+ return_value=mock_pynut,
), patch("homeassistant.components.nut.async_setup_entry", return_value=True):
result = await hass.config_entries.options.async_init(config_entry.entry_id)
@@ -330,7 +346,8 @@ async def test_options_flow(hass):
}
with patch(
- "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut,
+ "homeassistant.components.nut.PyNUTClient",
+ return_value=mock_pynut,
), patch("homeassistant.components.nut.async_setup_entry", return_value=True):
result2 = await hass.config_entries.options.async_init(config_entry.entry_id)
diff --git a/tests/components/nut/test_sensor.py b/tests/components/nut/test_sensor.py
index 0c6887288b9..42fdc09e2b1 100644
--- a/tests/components/nut/test_sensor.py
+++ b/tests/components/nut/test_sensor.py
@@ -1,6 +1,6 @@
"""The sensor tests for the nut platform."""
-from homeassistant.const import UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE
from .util import async_init_integration
@@ -21,7 +21,7 @@ async def test_pr3000rt2u(hass):
"device_class": "battery",
"friendly_name": "Ups1 Battery Charge",
"state": "Online",
- "unit_of_measurement": UNIT_PERCENTAGE,
+ "unit_of_measurement": PERCENTAGE,
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
@@ -47,7 +47,7 @@ async def test_cp1350c(hass):
"device_class": "battery",
"friendly_name": "Ups1 Battery Charge",
"state": "Online",
- "unit_of_measurement": UNIT_PERCENTAGE,
+ "unit_of_measurement": PERCENTAGE,
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
@@ -152,7 +152,7 @@ async def test_cp1500pfclcd(hass):
"device_class": "battery",
"friendly_name": "Ups1 Battery Charge",
"state": "Online",
- "unit_of_measurement": UNIT_PERCENTAGE,
+ "unit_of_measurement": PERCENTAGE,
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
@@ -177,7 +177,7 @@ async def test_dl650elcd(hass):
"device_class": "battery",
"friendly_name": "Ups1 Battery Charge",
"state": "Online",
- "unit_of_measurement": UNIT_PERCENTAGE,
+ "unit_of_measurement": PERCENTAGE,
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
@@ -202,7 +202,7 @@ async def test_blazer_usb(hass):
"device_class": "battery",
"friendly_name": "Ups1 Battery Charge",
"state": "Online",
- "unit_of_measurement": UNIT_PERCENTAGE,
+ "unit_of_measurement": PERCENTAGE,
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
diff --git a/tests/components/nut/util.py b/tests/components/nut/util.py
index 5622438d70b..772667b6b50 100644
--- a/tests/components/nut/util.py
+++ b/tests/components/nut/util.py
@@ -28,7 +28,8 @@ async def async_init_integration(
mock_pynut = _get_mock_pynutclient(list_ups={"ups1": "UPS 1"}, list_vars=list_vars)
with patch(
- "homeassistant.components.nut.PyNUTClient", return_value=mock_pynut,
+ "homeassistant.components.nut.PyNUTClient",
+ return_value=mock_pynut,
):
entry = MockConfigEntry(
domain=DOMAIN,
diff --git a/tests/components/nws/const.py b/tests/components/nws/const.py
index 8b23f9cc850..ae2f826294f 100644
--- a/tests/components/nws/const.py
+++ b/tests/components/nws/const.py
@@ -1,8 +1,9 @@
"""Helpers for interacting with pynws."""
from homeassistant.components.nws.const import CONF_STATION
-from homeassistant.components.nws.weather import ATTR_FORECAST_PRECIP_PROB
from homeassistant.components.weather import (
+ ATTR_CONDITION_LIGHTNING_RAINY,
ATTR_FORECAST_CONDITION,
+ ATTR_FORECAST_PRECIPITATION_PROBABILITY,
ATTR_FORECAST_TEMP,
ATTR_FORECAST_TIME,
ATTR_FORECAST_WIND_BEARING,
@@ -101,23 +102,23 @@ DEFAULT_FORECAST = [
]
EXPECTED_FORECAST_IMPERIAL = {
- ATTR_FORECAST_CONDITION: "lightning-rainy",
+ ATTR_FORECAST_CONDITION: ATTR_CONDITION_LIGHTNING_RAINY,
ATTR_FORECAST_TIME: "2019-08-12T20:00:00-04:00",
ATTR_FORECAST_TEMP: 10,
ATTR_FORECAST_WIND_SPEED: 10,
ATTR_FORECAST_WIND_BEARING: 180,
- ATTR_FORECAST_PRECIP_PROB: 90,
+ ATTR_FORECAST_PRECIPITATION_PROBABILITY: 90,
}
EXPECTED_FORECAST_METRIC = {
- ATTR_FORECAST_CONDITION: "lightning-rainy",
+ ATTR_FORECAST_CONDITION: ATTR_CONDITION_LIGHTNING_RAINY,
ATTR_FORECAST_TIME: "2019-08-12T20:00:00-04:00",
ATTR_FORECAST_TEMP: round(convert_temperature(10, TEMP_FAHRENHEIT, TEMP_CELSIUS)),
ATTR_FORECAST_WIND_SPEED: round(
convert_distance(10, LENGTH_MILES, LENGTH_KILOMETERS)
),
ATTR_FORECAST_WIND_BEARING: 180,
- ATTR_FORECAST_PRECIP_PROB: 90,
+ ATTR_FORECAST_PRECIPITATION_PROBABILITY: 90,
}
NONE_FORECAST = [{key: None for key in DEFAULT_FORECAST[0]}]
diff --git a/tests/components/nws/test_config_flow.py b/tests/components/nws/test_config_flow.py
index bca852fa379..72e02f32b9d 100644
--- a/tests/components/nws/test_config_flow.py
+++ b/tests/components/nws/test_config_flow.py
@@ -22,7 +22,8 @@ async def test_form(hass, mock_simple_nws_config):
with patch(
"homeassistant.components.nws.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.nws.async_setup_entry", return_value=True,
+ "homeassistant.components.nws.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {"api_key": "test"}
@@ -51,7 +52,8 @@ async def test_form_cannot_connect(hass, mock_simple_nws_config):
)
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"api_key": "test"},
+ result["flow_id"],
+ {"api_key": "test"},
)
assert result2["type"] == "form"
@@ -68,7 +70,8 @@ async def test_form_unknown_error(hass, mock_simple_nws_config):
)
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"api_key": "test"},
+ result["flow_id"],
+ {"api_key": "test"},
)
assert result2["type"] == "form"
@@ -84,10 +87,12 @@ async def test_form_already_configured(hass, mock_simple_nws_config):
with patch(
"homeassistant.components.nws.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.nws.async_setup_entry", return_value=True,
+ "homeassistant.components.nws.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"api_key": "test"},
+ result["flow_id"],
+ {"api_key": "test"},
)
assert result2["type"] == "create_entry"
@@ -102,10 +107,12 @@ async def test_form_already_configured(hass, mock_simple_nws_config):
with patch(
"homeassistant.components.nws.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.nws.async_setup_entry", return_value=True,
+ "homeassistant.components.nws.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"api_key": "test"},
+ result["flow_id"],
+ {"api_key": "test"},
)
assert result2["type"] == "abort"
assert result2["reason"] == "already_configured"
diff --git a/tests/components/nws/test_init.py b/tests/components/nws/test_init.py
index 68c1e8c8130..9d4acd7dde1 100644
--- a/tests/components/nws/test_init.py
+++ b/tests/components/nws/test_init.py
@@ -8,7 +8,10 @@ from tests.components.nws.const import NWS_CONFIG
async def test_unload_entry(hass, mock_simple_nws):
"""Test that nws setup with config yaml."""
- entry = MockConfigEntry(domain=DOMAIN, data=NWS_CONFIG,)
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=NWS_CONFIG,
+ )
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py
index 1486015d80e..2604c6f39ac 100644
--- a/tests/components/nws/test_weather.py
+++ b/tests/components/nws/test_weather.py
@@ -5,7 +5,8 @@ import aiohttp
import pytest
from homeassistant.components import nws
-from homeassistant.components.weather import ATTR_FORECAST
+from homeassistant.components.weather import ATTR_CONDITION_SUNNY, ATTR_FORECAST
+from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM
@@ -35,7 +36,10 @@ async def test_imperial_metric(
):
"""Test with imperial and metric units."""
hass.config.units = units
- entry = MockConfigEntry(domain=nws.DOMAIN, data=NWS_CONFIG,)
+ entry = MockConfigEntry(
+ domain=nws.DOMAIN,
+ data=NWS_CONFIG,
+ )
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -43,7 +47,7 @@ async def test_imperial_metric(
state = hass.states.get("weather.abc_hourly")
assert state
- assert state.state == "sunny"
+ assert state.state == ATTR_CONDITION_SUNNY
data = state.attributes
for key, value in result_observation.items():
@@ -56,7 +60,7 @@ async def test_imperial_metric(
state = hass.states.get("weather.abc_daynight")
assert state
- assert state.state == "sunny"
+ assert state.state == ATTR_CONDITION_SUNNY
data = state.attributes
for key, value in result_observation.items():
@@ -73,13 +77,16 @@ async def test_none_values(hass, mock_simple_nws):
instance.observation = NONE_OBSERVATION
instance.forecast = NONE_FORECAST
- entry = MockConfigEntry(domain=nws.DOMAIN, data=NWS_CONFIG,)
+ entry = MockConfigEntry(
+ domain=nws.DOMAIN,
+ data=NWS_CONFIG,
+ )
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("weather.abc_daynight")
- assert state.state == "unknown"
+ assert state.state == STATE_UNKNOWN
data = state.attributes
for key in EXPECTED_OBSERVATION_IMPERIAL:
assert data.get(key) is None
@@ -95,14 +102,17 @@ async def test_none(hass, mock_simple_nws):
instance.observation = None
instance.forecast = None
- entry = MockConfigEntry(domain=nws.DOMAIN, data=NWS_CONFIG,)
+ entry = MockConfigEntry(
+ domain=nws.DOMAIN,
+ data=NWS_CONFIG,
+ )
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("weather.abc_daynight")
assert state
- assert state.state == "unknown"
+ assert state.state == STATE_UNKNOWN
data = state.attributes
for key in EXPECTED_OBSERVATION_IMPERIAL:
@@ -118,7 +128,10 @@ async def test_error_station(hass, mock_simple_nws):
instance = mock_simple_nws.return_value
instance.set_station.side_effect = aiohttp.ClientError
- entry = MockConfigEntry(domain=nws.DOMAIN, data=NWS_CONFIG,)
+ entry = MockConfigEntry(
+ domain=nws.DOMAIN,
+ data=NWS_CONFIG,
+ )
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -133,7 +146,10 @@ async def test_entity_refresh(hass, mock_simple_nws):
await async_setup_component(hass, "homeassistant", {})
- entry = MockConfigEntry(domain=nws.DOMAIN, data=NWS_CONFIG,)
+ entry = MockConfigEntry(
+ domain=nws.DOMAIN,
+ data=NWS_CONFIG,
+ )
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -171,7 +187,10 @@ async def test_error_observation(hass, mock_simple_nws):
# first update fails
instance.update_observation.side_effect = aiohttp.ClientError
- entry = MockConfigEntry(domain=nws.DOMAIN, data=NWS_CONFIG,)
+ entry = MockConfigEntry(
+ domain=nws.DOMAIN,
+ data=NWS_CONFIG,
+ )
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -180,11 +199,11 @@ async def test_error_observation(hass, mock_simple_nws):
state = hass.states.get("weather.abc_daynight")
assert state
- assert state.state == "unavailable"
+ assert state.state == STATE_UNAVAILABLE
state = hass.states.get("weather.abc_hourly")
assert state
- assert state.state == "unavailable"
+ assert state.state == STATE_UNAVAILABLE
# second update happens faster and succeeds
instance.update_observation.side_effect = None
@@ -195,7 +214,7 @@ async def test_error_observation(hass, mock_simple_nws):
state = hass.states.get("weather.abc_daynight")
assert state
- assert state.state == "sunny"
+ assert state.state == ATTR_CONDITION_SUNNY
state = hass.states.get("weather.abc_hourly")
assert state
@@ -211,11 +230,11 @@ async def test_error_observation(hass, mock_simple_nws):
state = hass.states.get("weather.abc_daynight")
assert state
- assert state.state == "sunny"
+ assert state.state == ATTR_CONDITION_SUNNY
state = hass.states.get("weather.abc_hourly")
assert state
- assert state.state == "sunny"
+ assert state.state == ATTR_CONDITION_SUNNY
# after 20 minutes data caching expires, data is no longer shown
increment_time(timedelta(minutes=10))
@@ -223,11 +242,11 @@ async def test_error_observation(hass, mock_simple_nws):
state = hass.states.get("weather.abc_daynight")
assert state
- assert state.state == "unavailable"
+ assert state.state == STATE_UNAVAILABLE
state = hass.states.get("weather.abc_hourly")
assert state
- assert state.state == "unavailable"
+ assert state.state == STATE_UNAVAILABLE
async def test_error_forecast(hass, mock_simple_nws):
@@ -235,7 +254,10 @@ async def test_error_forecast(hass, mock_simple_nws):
instance = mock_simple_nws.return_value
instance.update_forecast.side_effect = aiohttp.ClientError
- entry = MockConfigEntry(domain=nws.DOMAIN, data=NWS_CONFIG,)
+ entry = MockConfigEntry(
+ domain=nws.DOMAIN,
+ data=NWS_CONFIG,
+ )
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
@@ -244,7 +266,7 @@ async def test_error_forecast(hass, mock_simple_nws):
state = hass.states.get("weather.abc_daynight")
assert state
- assert state.state == "unavailable"
+ assert state.state == STATE_UNAVAILABLE
instance.update_forecast.side_effect = None
@@ -255,7 +277,7 @@ async def test_error_forecast(hass, mock_simple_nws):
state = hass.states.get("weather.abc_daynight")
assert state
- assert state.state == "sunny"
+ assert state.state == ATTR_CONDITION_SUNNY
async def test_error_forecast_hourly(hass, mock_simple_nws):
@@ -263,14 +285,17 @@ async def test_error_forecast_hourly(hass, mock_simple_nws):
instance = mock_simple_nws.return_value
instance.update_forecast_hourly.side_effect = aiohttp.ClientError
- entry = MockConfigEntry(domain=nws.DOMAIN, data=NWS_CONFIG,)
+ entry = MockConfigEntry(
+ domain=nws.DOMAIN,
+ data=NWS_CONFIG,
+ )
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("weather.abc_hourly")
assert state
- assert state.state == "unavailable"
+ assert state.state == STATE_UNAVAILABLE
instance.update_forecast_hourly.assert_called_once()
@@ -283,4 +308,4 @@ async def test_error_forecast_hourly(hass, mock_simple_nws):
state = hass.states.get("weather.abc_hourly")
assert state
- assert state.state == "sunny"
+ assert state.state == ATTR_CONDITION_SUNNY
diff --git a/tests/components/nzbget/__init__.py b/tests/components/nzbget/__init__.py
new file mode 100644
index 00000000000..8a36b299d87
--- /dev/null
+++ b/tests/components/nzbget/__init__.py
@@ -0,0 +1,119 @@
+"""Tests for the NZBGet integration."""
+from datetime import timedelta
+
+from homeassistant.components.nzbget.const import DOMAIN
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_NAME,
+ CONF_PASSWORD,
+ CONF_PORT,
+ CONF_SCAN_INTERVAL,
+ CONF_SSL,
+ CONF_USERNAME,
+ CONF_VERIFY_SSL,
+)
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+ENTRY_CONFIG = {
+ CONF_HOST: "10.10.10.30",
+ CONF_NAME: "NZBGetTest",
+ CONF_PASSWORD: "",
+ CONF_PORT: 6789,
+ CONF_SSL: False,
+ CONF_USERNAME: "",
+ CONF_VERIFY_SSL: False,
+}
+
+USER_INPUT = {
+ CONF_HOST: "10.10.10.30",
+ CONF_NAME: "NZBGet",
+ CONF_PASSWORD: "",
+ CONF_PORT: 6789,
+ CONF_SSL: False,
+ CONF_USERNAME: "",
+}
+
+YAML_CONFIG = {
+ CONF_HOST: "10.10.10.30",
+ CONF_NAME: "GetNZBsTest",
+ CONF_PASSWORD: "",
+ CONF_PORT: 6789,
+ CONF_SCAN_INTERVAL: timedelta(seconds=5),
+ CONF_SSL: False,
+ CONF_USERNAME: "",
+}
+
+MOCK_VERSION = "21.0"
+
+MOCK_STATUS = {
+ "ArticleCacheMB": 64,
+ "AverageDownloadRate": 1250000,
+ "DownloadPaused": 4,
+ "DownloadRate": 2500000,
+ "DownloadedSizeMB": 256,
+ "FreeDiskSpaceMB": 1024,
+ "PostJobCount": 2,
+ "PostPaused": 4,
+ "RemainingSizeMB": 512,
+ "UpTimeSec": 600,
+}
+
+MOCK_HISTORY = [
+ {"Name": "Downloaded Item XYZ", "Category": "", "Status": "SUCCESS"},
+ {"Name": "Failed Item ABC", "Category": "", "Status": "FAILURE"},
+]
+
+
+async def init_integration(
+ hass,
+ *,
+ status: dict = MOCK_STATUS,
+ history: dict = MOCK_HISTORY,
+ version: str = MOCK_VERSION,
+) -> MockConfigEntry:
+ """Set up the NZBGet integration in Home Assistant."""
+ entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG)
+ entry.add_to_hass(hass)
+
+ with _patch_version(version), _patch_status(status), _patch_history(history):
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ return entry
+
+
+def _patch_async_setup(return_value=True):
+ return patch(
+ "homeassistant.components.nzbget.async_setup",
+ return_value=return_value,
+ )
+
+
+def _patch_async_setup_entry(return_value=True):
+ return patch(
+ "homeassistant.components.nzbget.async_setup_entry",
+ return_value=return_value,
+ )
+
+
+def _patch_history(return_value=MOCK_HISTORY):
+ return patch(
+ "homeassistant.components.nzbget.coordinator.NZBGetAPI.history",
+ return_value=return_value,
+ )
+
+
+def _patch_status(return_value=MOCK_STATUS):
+ return patch(
+ "homeassistant.components.nzbget.coordinator.NZBGetAPI.status",
+ return_value=return_value,
+ )
+
+
+def _patch_version(return_value=MOCK_VERSION):
+ return patch(
+ "homeassistant.components.nzbget.coordinator.NZBGetAPI.version",
+ return_value=return_value,
+ )
diff --git a/tests/components/nzbget/test_config_flow.py b/tests/components/nzbget/test_config_flow.py
new file mode 100644
index 00000000000..362ba25ff67
--- /dev/null
+++ b/tests/components/nzbget/test_config_flow.py
@@ -0,0 +1,156 @@
+"""Test the NZBGet config flow."""
+from pynzbgetapi import NZBGetAPIException
+
+from homeassistant.components.nzbget.const import DOMAIN
+from homeassistant.config_entries import SOURCE_USER
+from homeassistant.const import CONF_SCAN_INTERVAL, CONF_VERIFY_SSL
+from homeassistant.data_entry_flow import (
+ RESULT_TYPE_ABORT,
+ RESULT_TYPE_CREATE_ENTRY,
+ RESULT_TYPE_FORM,
+)
+from homeassistant.setup import async_setup_component
+
+from . import (
+ ENTRY_CONFIG,
+ USER_INPUT,
+ _patch_async_setup,
+ _patch_async_setup_entry,
+ _patch_history,
+ _patch_status,
+ _patch_version,
+)
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+
+async def test_user_form(hass):
+ """Test we get the user initiated form."""
+ await async_setup_component(hass, "persistent_notification", {})
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {}
+
+ with _patch_version(), _patch_status(), _patch_history(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry:
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ USER_INPUT,
+ )
+ await hass.async_block_till_done()
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "10.10.10.30"
+ assert result["data"] == {**USER_INPUT, CONF_VERIFY_SSL: False}
+
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_user_form_show_advanced_options(hass):
+ """Test we get the user initiated form with advanced options shown."""
+ await async_setup_component(hass, "persistent_notification", {})
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER, "show_advanced_options": True}
+ )
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {}
+
+ user_input_advanced = {
+ **USER_INPUT,
+ CONF_VERIFY_SSL: True,
+ }
+
+ with _patch_version(), _patch_status(), _patch_history(), _patch_async_setup() as mock_setup, _patch_async_setup_entry() as mock_setup_entry:
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ user_input_advanced,
+ )
+ await hass.async_block_till_done()
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "10.10.10.30"
+ assert result["data"] == {**USER_INPUT, CONF_VERIFY_SSL: True}
+
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_user_form_cannot_connect(hass):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.nzbget.coordinator.NZBGetAPI.version",
+ side_effect=NZBGetAPIException(),
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ USER_INPUT,
+ )
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["errors"] == {"base": "cannot_connect"}
+
+
+async def test_user_form_unexpected_exception(hass):
+ """Test we handle unexpected exception."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.nzbget.coordinator.NZBGetAPI.version",
+ side_effect=Exception(),
+ ):
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ USER_INPUT,
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "unknown"
+
+
+async def test_user_form_single_instance_allowed(hass):
+ """Test that configuring more than one instance is rejected."""
+ entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG)
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=USER_INPUT,
+ )
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "single_instance_allowed"
+
+
+async def test_options_flow(hass):
+ """Test updating options."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=ENTRY_CONFIG,
+ options={CONF_SCAN_INTERVAL: 5},
+ )
+ entry.add_to_hass(hass)
+
+ assert entry.options[CONF_SCAN_INTERVAL] == 5
+
+ result = await hass.config_entries.options.async_init(entry.entry_id)
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={CONF_SCAN_INTERVAL: 15},
+ )
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result["data"][CONF_SCAN_INTERVAL] == 15
diff --git a/tests/components/nzbget/test_init.py b/tests/components/nzbget/test_init.py
new file mode 100644
index 00000000000..62532c56699
--- /dev/null
+++ b/tests/components/nzbget/test_init.py
@@ -0,0 +1,66 @@
+"""Test the NZBGet config flow."""
+from pynzbgetapi import NZBGetAPIException
+
+from homeassistant.components.nzbget.const import DOMAIN
+from homeassistant.config_entries import (
+ ENTRY_STATE_LOADED,
+ ENTRY_STATE_NOT_LOADED,
+ ENTRY_STATE_SETUP_RETRY,
+)
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
+from homeassistant.setup import async_setup_component
+
+from . import (
+ ENTRY_CONFIG,
+ YAML_CONFIG,
+ _patch_async_setup_entry,
+ _patch_history,
+ _patch_status,
+ _patch_version,
+ init_integration,
+)
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+
+async def test_import_from_yaml(hass) -> None:
+ """Test import from YAML."""
+ with _patch_version(), _patch_status(), _patch_history(), _patch_async_setup_entry():
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: YAML_CONFIG})
+ await hass.async_block_till_done()
+
+ entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(entries) == 1
+
+ assert entries[0].data[CONF_NAME] == "GetNZBsTest"
+ assert entries[0].data[CONF_HOST] == "10.10.10.30"
+ assert entries[0].data[CONF_PORT] == 6789
+
+
+async def test_unload_entry(hass):
+ """Test successful unload of entry."""
+ entry = await init_integration(hass)
+
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+ assert entry.state == ENTRY_STATE_LOADED
+
+ assert await hass.config_entries.async_unload(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert entry.state == ENTRY_STATE_NOT_LOADED
+ assert not hass.data.get(DOMAIN)
+
+
+async def test_async_setup_raises_entry_not_ready(hass):
+ """Test that it throws ConfigEntryNotReady when exception occurs during setup."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_CONFIG)
+ config_entry.add_to_hass(hass)
+
+ with _patch_version(), patch(
+ "homeassistant.components.nzbget.coordinator.NZBGetAPI.status",
+ side_effect=NZBGetAPIException(),
+ ):
+ await hass.config_entries.async_setup(config_entry.entry_id)
+
+ assert config_entry.state == ENTRY_STATE_SETUP_RETRY
diff --git a/tests/components/nzbget/test_sensor.py b/tests/components/nzbget/test_sensor.py
new file mode 100644
index 00000000000..43803384740
--- /dev/null
+++ b/tests/components/nzbget/test_sensor.py
@@ -0,0 +1,54 @@
+"""Test the NZBGet sensors."""
+from datetime import timedelta
+
+from homeassistant.const import (
+ ATTR_UNIT_OF_MEASUREMENT,
+ DATA_MEGABYTES,
+ DATA_RATE_MEGABYTES_PER_SECOND,
+ DEVICE_CLASS_TIMESTAMP,
+)
+from homeassistant.util import dt as dt_util
+
+from . import init_integration
+
+from tests.async_mock import patch
+
+
+async def test_sensors(hass) -> None:
+ """Test the creation and values of the sensors."""
+ now = dt_util.utcnow().replace(microsecond=0)
+ with patch("homeassistant.util.dt.utcnow", return_value=now):
+ entry = await init_integration(hass)
+
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ uptime = now - timedelta(seconds=600)
+
+ sensors = {
+ "article_cache": ("ArticleCacheMB", "64", DATA_MEGABYTES, None),
+ "average_speed": (
+ "AverageDownloadRate",
+ "1.19",
+ DATA_RATE_MEGABYTES_PER_SECOND,
+ None,
+ ),
+ "download_paused": ("DownloadPaused", "4", None, None),
+ "speed": ("DownloadRate", "2.38", DATA_RATE_MEGABYTES_PER_SECOND, None),
+ "size": ("DownloadedSizeMB", "256", DATA_MEGABYTES, None),
+ "disk_free": ("FreeDiskSpaceMB", "1024", DATA_MEGABYTES, None),
+ "post_processing_jobs": ("PostJobCount", "2", "Jobs", None),
+ "post_processing_paused": ("PostPaused", "4", None, None),
+ "queue_size": ("RemainingSizeMB", "512", DATA_MEGABYTES, None),
+ "uptime": ("UpTimeSec", uptime.isoformat(), None, DEVICE_CLASS_TIMESTAMP),
+ }
+
+ for (sensor_id, data) in sensors.items():
+ entity_entry = registry.async_get(f"sensor.nzbgettest_{sensor_id}")
+ assert entity_entry
+ assert entity_entry.device_class == data[3]
+ assert entity_entry.unique_id == f"{entry.entry_id}_{data[0]}"
+
+ state = hass.states.get(f"sensor.nzbgettest_{sensor_id}")
+ assert state
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == data[2]
+ assert state.state == data[1]
diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py
index 5c41349bbbe..864968f848d 100644
--- a/tests/components/onvif/test_config_flow.py
+++ b/tests/components/onvif/test_config_flow.py
@@ -130,7 +130,12 @@ def setup_mock_device(mock_device):
async def setup_onvif_integration(
- hass, config=None, options=None, unique_id=MAC, entry_id="1", source="user",
+ hass,
+ config=None,
+ options=None,
+ unique_id=MAC,
+ entry_id="1",
+ source="user",
):
"""Create an ONVIF config entry."""
if not config:
@@ -353,7 +358,8 @@ async def test_flow_manual_entry(hass):
setup_mock_device(mock_device)
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={},
+ result["flow_id"],
+ user_input={},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
diff --git a/tests/components/openhardwaremonitor/test_sensor.py b/tests/components/openhardwaremonitor/test_sensor.py
index 6c8776f740d..3cf5ba84a25 100644
--- a/tests/components/openhardwaremonitor/test_sensor.py
+++ b/tests/components/openhardwaremonitor/test_sensor.py
@@ -41,8 +41,15 @@ class TestOpenHardwareMonitorSetup(unittest.TestCase):
assert len(entities) == 38
state = self.hass.states.get(
- "sensor.test_pc_intel_core_i7_7700_clocks_bus_speed"
+ "sensor.test_pc_intel_core_i7_7700_temperatures_cpu_core_1"
)
assert state is not None
- assert state.state == "100"
+ assert state.state == "31.0"
+
+ state = self.hass.states.get(
+ "sensor.test_pc_intel_core_i7_7700_temperatures_cpu_core_2"
+ )
+
+ assert state is not None
+ assert state.state == "30.0"
diff --git a/tests/components/opentherm_gw/test_config_flow.py b/tests/components/opentherm_gw/test_config_flow.py
index 003d2ad7170..a696bc47590 100644
--- a/tests/components/opentherm_gw/test_config_flow.py
+++ b/tests/components/opentherm_gw/test_config_flow.py
@@ -26,11 +26,14 @@ async def test_form_user(hass):
assert result["errors"] == {}
with patch(
- "homeassistant.components.opentherm_gw.async_setup", return_value=True,
+ "homeassistant.components.opentherm_gw.async_setup",
+ return_value=True,
) as mock_setup, patch(
- "homeassistant.components.opentherm_gw.async_setup_entry", return_value=True,
+ "homeassistant.components.opentherm_gw.async_setup_entry",
+ return_value=True,
) as mock_setup_entry, patch(
- "pyotgw.pyotgw.connect", return_value={OTGW_ABOUT: "OpenTherm Gateway 4.2.5"},
+ "pyotgw.pyotgw.connect",
+ return_value={OTGW_ABOUT: "OpenTherm Gateway 4.2.5"},
) as mock_pyotgw_connect, patch(
"pyotgw.pyotgw.disconnect", return_value=None
) as mock_pyotgw_disconnect:
@@ -56,11 +59,14 @@ async def test_form_import(hass):
"""Test import from existing config."""
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
- "homeassistant.components.opentherm_gw.async_setup", return_value=True,
+ "homeassistant.components.opentherm_gw.async_setup",
+ return_value=True,
) as mock_setup, patch(
- "homeassistant.components.opentherm_gw.async_setup_entry", return_value=True,
+ "homeassistant.components.opentherm_gw.async_setup_entry",
+ return_value=True,
) as mock_setup_entry, patch(
- "pyotgw.pyotgw.connect", return_value={OTGW_ABOUT: "OpenTherm Gateway 4.2.5"},
+ "pyotgw.pyotgw.connect",
+ return_value={OTGW_ABOUT: "OpenTherm Gateway 4.2.5"},
) as mock_pyotgw_connect, patch(
"pyotgw.pyotgw.disconnect", return_value=None
) as mock_pyotgw_disconnect:
@@ -96,11 +102,14 @@ async def test_form_duplicate_entries(hass):
)
with patch(
- "homeassistant.components.opentherm_gw.async_setup", return_value=True,
+ "homeassistant.components.opentherm_gw.async_setup",
+ return_value=True,
) as mock_setup, patch(
- "homeassistant.components.opentherm_gw.async_setup_entry", return_value=True,
+ "homeassistant.components.opentherm_gw.async_setup_entry",
+ return_value=True,
) as mock_setup_entry, patch(
- "pyotgw.pyotgw.connect", return_value={OTGW_ABOUT: "OpenTherm Gateway 4.2.5"},
+ "pyotgw.pyotgw.connect",
+ return_value={OTGW_ABOUT: "OpenTherm Gateway 4.2.5"},
) as mock_pyotgw_connect, patch(
"pyotgw.pyotgw.disconnect", return_value=None
) as mock_pyotgw_disconnect:
diff --git a/tests/components/openuv/test_config_flow.py b/tests/components/openuv/test_config_flow.py
index 06eca531d2d..9c07322acca 100644
--- a/tests/components/openuv/test_config_flow.py
+++ b/tests/components/openuv/test_config_flow.py
@@ -20,9 +20,11 @@ from tests.common import MockConfigEntry
def mock_setup():
"""Prevent setup."""
with patch(
- "homeassistant.components.openuv.async_setup", return_value=True,
+ "homeassistant.components.openuv.async_setup",
+ return_value=True,
), patch(
- "homeassistant.components.openuv.async_setup_entry", return_value=True,
+ "homeassistant.components.openuv.async_setup_entry",
+ return_value=True,
):
yield
@@ -58,7 +60,8 @@ async def test_invalid_api_key(hass):
}
with patch(
- "pyopenuv.client.Client.uv_index", side_effect=InvalidApiKeyError,
+ "pyopenuv.client.Client.uv_index",
+ side_effect=InvalidApiKeyError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=conf
diff --git a/tests/components/openweathermap/__init__.py b/tests/components/openweathermap/__init__.py
new file mode 100644
index 00000000000..e718962766f
--- /dev/null
+++ b/tests/components/openweathermap/__init__.py
@@ -0,0 +1 @@
+"""Tests for the OpenWeatherMap integration."""
diff --git a/tests/components/openweathermap/test_config_flow.py b/tests/components/openweathermap/test_config_flow.py
new file mode 100644
index 00000000000..672e7358803
--- /dev/null
+++ b/tests/components/openweathermap/test_config_flow.py
@@ -0,0 +1,232 @@
+"""Define tests for the OpenWeatherMap config flow."""
+from asynctest import MagicMock, patch
+from pyowm.exceptions.api_call_error import APICallError
+from pyowm.exceptions.api_response_error import UnauthorizedError
+
+from homeassistant import data_entry_flow
+from homeassistant.components.openweathermap.const import (
+ CONF_LANGUAGE,
+ DEFAULT_FORECAST_MODE,
+ DEFAULT_LANGUAGE,
+ DOMAIN,
+)
+from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
+from homeassistant.const import (
+ CONF_API_KEY,
+ CONF_LATITUDE,
+ CONF_LONGITUDE,
+ CONF_MODE,
+ CONF_NAME,
+)
+
+from tests.common import MockConfigEntry
+
+CONFIG = {
+ CONF_NAME: "openweathermap",
+ CONF_API_KEY: "foo",
+ CONF_LATITUDE: 50,
+ CONF_LONGITUDE: 40,
+ CONF_MODE: DEFAULT_FORECAST_MODE,
+ CONF_LANGUAGE: DEFAULT_LANGUAGE,
+}
+
+VALID_YAML_CONFIG = {CONF_API_KEY: "foo"}
+
+
+async def test_form(hass):
+ """Test that the form is served with valid input."""
+ mocked_owm = _create_mocked_owm(True)
+
+ with patch(
+ "pyowm.weatherapi25.owm25.OWM25",
+ return_value=mocked_owm,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == SOURCE_USER
+ assert result["errors"] == {}
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
+ )
+
+ await hass.async_block_till_done()
+
+ conf_entries = hass.config_entries.async_entries(DOMAIN)
+ entry = conf_entries[0]
+ assert entry.state == "loaded"
+
+ await hass.config_entries.async_unload(conf_entries[0].entry_id)
+ await hass.async_block_till_done()
+ assert entry.state == "not_loaded"
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == CONFIG[CONF_NAME]
+ assert result["data"][CONF_LATITUDE] == CONFIG[CONF_LATITUDE]
+ assert result["data"][CONF_LONGITUDE] == CONFIG[CONF_LONGITUDE]
+ assert result["data"][CONF_API_KEY] == CONFIG[CONF_API_KEY]
+
+
+async def test_form_import(hass):
+ """Test we can import yaml config."""
+ mocked_owm = _create_mocked_owm(True)
+
+ with patch("pyowm.weatherapi25.owm25.OWM25", return_value=mocked_owm), patch(
+ "homeassistant.components.openweathermap.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.openweathermap.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_IMPORT},
+ data=VALID_YAML_CONFIG.copy(),
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["data"][CONF_LATITUDE] == hass.config.latitude
+ assert result["data"][CONF_LONGITUDE] == hass.config.longitude
+ assert result["data"][CONF_API_KEY] == VALID_YAML_CONFIG[CONF_API_KEY]
+
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_options(hass):
+ """Test that the options form."""
+ mocked_owm = _create_mocked_owm(True)
+
+ with patch(
+ "pyowm.weatherapi25.owm25.OWM25",
+ return_value=mocked_owm,
+ ):
+ config_entry = MockConfigEntry(
+ domain=DOMAIN, unique_id="openweathermap_unique_id", data=CONFIG
+ )
+ config_entry.add_to_hass(hass)
+
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert config_entry.state == "loaded"
+
+ result = await hass.config_entries.options.async_init(config_entry.entry_id)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={CONF_MODE: "daily"}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert config_entry.options == {
+ CONF_MODE: "daily",
+ CONF_LANGUAGE: DEFAULT_LANGUAGE,
+ }
+
+ await hass.async_block_till_done()
+
+ assert config_entry.state == "loaded"
+
+ result = await hass.config_entries.options.async_init(config_entry.entry_id)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={CONF_MODE: "freedaily"}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert config_entry.options == {
+ CONF_MODE: "freedaily",
+ CONF_LANGUAGE: DEFAULT_LANGUAGE,
+ }
+
+ await hass.async_block_till_done()
+
+ assert config_entry.state == "loaded"
+
+
+async def test_form_invalid_api_key(hass):
+ """Test that the form is served with no input."""
+ mocked_owm = _create_mocked_owm(True)
+
+ with patch(
+ "pyowm.weatherapi25.owm25.OWM25",
+ return_value=mocked_owm,
+ side_effect=UnauthorizedError(""),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
+ )
+
+ assert result["errors"] == {"base": "auth"}
+
+
+async def test_form_api_call_error(hass):
+ """Test setting up with api call error."""
+ mocked_owm = _create_mocked_owm(True)
+
+ with patch(
+ "pyowm.weatherapi25.owm25.OWM25",
+ return_value=mocked_owm,
+ side_effect=APICallError(""),
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
+ )
+
+ assert result["errors"] == {"base": "connection"}
+
+
+async def test_form_api_offline(hass):
+ """Test setting up with api call error."""
+ mocked_owm = _create_mocked_owm(False)
+
+ with patch(
+ "homeassistant.components.openweathermap.config_flow.OWM",
+ return_value=mocked_owm,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=CONFIG
+ )
+
+ assert result["errors"] == {"base": "auth"}
+
+
+def _create_mocked_owm(is_api_online: bool):
+ mocked_owm = MagicMock()
+ mocked_owm.is_API_online.return_value = is_api_online
+
+ weather = MagicMock()
+ weather.get_temperature.return_value.get.return_value = 10
+ weather.get_pressure.return_value.get.return_value = 10
+ weather.get_humidity.return_value = 10
+ weather.get_wind.return_value.get.return_value = 0
+ weather.get_clouds.return_value = "clouds"
+ weather.get_rain.return_value = []
+ weather.get_snow.return_value = 3
+ weather.get_detailed_status.return_value = "status"
+ weather.get_weather_code.return_value = 803
+
+ mocked_owm.weather_at_coords.return_value.get_weather.return_value = weather
+
+ one_day_forecast = MagicMock()
+ one_day_forecast.get_reference_time.return_value = 10
+ one_day_forecast.get_temperature.return_value.get.return_value = 10
+ one_day_forecast.get_rain.return_value.get.return_value = 0
+ one_day_forecast.get_snow.return_value.get.return_value = 0
+ one_day_forecast.get_wind.return_value.get.return_value = 0
+ one_day_forecast.get_weather_code.return_value = 803
+
+ mocked_owm.three_hours_forecast_at_coords.return_value.get_forecast.return_value.get_weathers.return_value = [
+ one_day_forecast
+ ]
+
+ return mocked_owm
diff --git a/tests/components/ovo_energy/test_config_flow.py b/tests/components/ovo_energy/test_config_flow.py
index 73b2610cc7a..b89a9729c35 100644
--- a/tests/components/ovo_energy/test_config_flow.py
+++ b/tests/components/ovo_energy/test_config_flow.py
@@ -35,7 +35,8 @@ async def test_authorization_error(hass: HomeAssistant) -> None:
return_value=False,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], FIXTURE_USER_INPUT,
+ result["flow_id"],
+ FIXTURE_USER_INPUT,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -57,7 +58,8 @@ async def test_connection_error(hass: HomeAssistant) -> None:
side_effect=aiohttp.ClientError,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], FIXTURE_USER_INPUT,
+ result["flow_id"],
+ FIXTURE_USER_INPUT,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -79,7 +81,8 @@ async def test_full_flow_implementation(hass: HomeAssistant) -> None:
return_value=True,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], FIXTURE_USER_INPUT,
+ result["flow_id"],
+ FIXTURE_USER_INPUT,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
diff --git a/tests/components/owntracks/test_config_flow.py b/tests/components/owntracks/test_config_flow.py
index 3e6101e1989..396870c2975 100644
--- a/tests/components/owntracks/test_config_flow.py
+++ b/tests/components/owntracks/test_config_flow.py
@@ -50,7 +50,8 @@ def mock_not_supports_encryption():
async def init_config_flow(hass):
"""Init a configuration flow."""
await async_process_ha_core_config(
- hass, {"external_url": BASE_URL},
+ hass,
+ {"external_url": BASE_URL},
)
flow = config_flow.OwnTracksFlow()
flow.hass = hass
@@ -90,7 +91,8 @@ async def test_import(hass, webhook_id, secret):
async def test_import_setup(hass):
"""Test that we automatically create a config flow."""
await async_process_ha_core_config(
- hass, {"external_url": "http://example.com"},
+ hass,
+ {"external_url": "http://example.com"},
)
assert not hass.config_entries.async_entries(DOMAIN)
@@ -132,7 +134,8 @@ async def test_user_not_supports_encryption(hass, not_supports_encryption):
async def test_unload(hass):
"""Test unloading a config flow."""
await async_process_ha_core_config(
- hass, {"external_url": "http://example.com"},
+ hass,
+ {"external_url": "http://example.com"},
)
with patch(
diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py
index 9f47c63aa85..9f3694637f3 100644
--- a/tests/components/owntracks/test_device_tracker.py
+++ b/tests/components/owntracks/test_device_tracker.py
@@ -1318,12 +1318,12 @@ def generate_ciphers(secret):
# PyNaCl ciphertext generation will fail if the module
# cannot be imported. However, the test for decryption
# also relies on this library and won't be run without it.
- import pickle
import base64
+ import pickle
try:
- from nacl.secret import SecretBox
from nacl.encoding import Base64Encoder
+ from nacl.secret import SecretBox
keylen = SecretBox.KEY_SIZE
key = secret.encode("utf-8")
@@ -1369,8 +1369,8 @@ def mock_cipher():
def mock_decrypt(ciphertext, key):
"""Decrypt/unpickle."""
- import pickle
import base64
+ import pickle
(mkey, plaintext) = pickle.loads(base64.b64decode(ciphertext))
if key != mkey:
diff --git a/tests/components/ozw/conftest.py b/tests/components/ozw/conftest.py
index 8ad19aca46c..3e30b60129b 100644
--- a/tests/components/ozw/conftest.py
+++ b/tests/components/ozw/conftest.py
@@ -87,6 +87,12 @@ def lock_data_fixture():
return load_fixture("ozw/lock_network_dump.csv")
+@pytest.fixture(name="string_sensor_data", scope="session")
+def string_sensor_fixture():
+ """Load string sensor MQTT data and return it."""
+ return load_fixture("ozw/sensor_string_value_network_dump.csv")
+
+
@pytest.fixture(name="sent_messages")
def sent_messages_fixture():
"""Fixture to capture sent messages."""
diff --git a/tests/components/ozw/test_config_flow.py b/tests/components/ozw/test_config_flow.py
index bfe3f922402..3446bbfc7de 100644
--- a/tests/components/ozw/test_config_flow.py
+++ b/tests/components/ozw/test_config_flow.py
@@ -20,7 +20,8 @@ async def test_user_create_entry(hass):
with patch(
"homeassistant.components.ozw.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.ozw.async_setup_entry", return_value=True,
+ "homeassistant.components.ozw.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
diff --git a/tests/components/ozw/test_light.py b/tests/components/ozw/test_light.py
index f1055be90d3..498bf4d0cd9 100644
--- a/tests/components/ozw/test_light.py
+++ b/tests/components/ozw/test_light.py
@@ -377,11 +377,11 @@ async def test_pure_rgb_dimmer_light(
msg = sent_messages[-2]
assert msg["topic"] == "OpenZWave/1/command/setvalue/"
- assert msg["payload"] == {"Value": "#ff4cff0000", "ValueIDKey": 122470423}
+ assert msg["payload"] == {"Value": "#ff4cff00", "ValueIDKey": 122470423}
# Feedback on state
light_pure_rgb_msg.decode()
- light_pure_rgb_msg.payload["Value"] = "#ff4cff0000"
+ light_pure_rgb_msg.payload["Value"] = "#ff4cff00"
light_pure_rgb_msg.encode()
receive_message(light_pure_rgb_msg)
await hass.async_block_till_done()
@@ -500,14 +500,14 @@ async def test_no_cw_light(
assert len(sent_messages) == 2
msg = sent_messages[-2]
assert msg["topic"] == "OpenZWave/1/command/setvalue/"
- assert msg["payload"] == {"Value": "#000000be00", "ValueIDKey": 659341335}
+ assert msg["payload"] == {"Value": "#000000be", "ValueIDKey": 659341335}
# Feedback on state
light_msg.decode()
light_msg.payload["Value"] = byte_to_zwave_brightness(255)
light_msg.encode()
light_rgb_msg.decode()
- light_rgb_msg.payload["Value"] = "#000000be00"
+ light_rgb_msg.payload["Value"] = "#000000be"
light_rgb_msg.encode()
receive_message(light_msg)
receive_message(light_rgb_msg)
diff --git a/tests/components/ozw/test_sensor.py b/tests/components/ozw/test_sensor.py
index 4cc0077cdea..91de895648e 100644
--- a/tests/components/ozw/test_sensor.py
+++ b/tests/components/ozw/test_sensor.py
@@ -74,3 +74,24 @@ async def test_sensor_enabled(hass, generic_data, sensor_msg):
assert state is not None
assert state.state == "0"
assert state.attributes["label"] == "Clear"
+
+
+async def test_string_sensor(hass, string_sensor_data):
+ """Test so the returned type is a string sensor."""
+
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ entry = registry.async_get_or_create(
+ SENSOR_DOMAIN,
+ DOMAIN,
+ "1-49-73464969749610519",
+ suggested_object_id="id_150_z_wave_module_user_code",
+ disabled_by=None,
+ )
+
+ await setup_ozw(hass, fixture=string_sensor_data)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entry.entity_id)
+ assert state is not None
+ assert state.state == "asdfgh"
diff --git a/tests/components/ozw/test_services.py b/tests/components/ozw/test_services.py
index 9ea4e4f496b..1460e69b9c9 100644
--- a/tests/components/ozw/test_services.py
+++ b/tests/components/ozw/test_services.py
@@ -2,61 +2,61 @@
from .common import setup_ozw
-async def test_services(hass, lock_data, sent_messages, lock_msg, caplog):
+async def test_services(hass, light_data, sent_messages, light_msg, caplog):
"""Test services on lock."""
- await setup_ozw(hass, fixture=lock_data)
+ await setup_ozw(hass, fixture=light_data)
# Test set_config_parameter list by label
await hass.services.async_call(
"ozw",
"set_config_parameter",
- {"node_id": 10, "parameter": 1, "value": "Disabled"},
+ {"node_id": 39, "parameter": 1, "value": "Disable"},
blocking=True,
)
assert len(sent_messages) == 1
msg = sent_messages[0]
assert msg["topic"] == "OpenZWave/1/command/setvalue/"
- assert msg["payload"] == {"Value": 0, "ValueIDKey": 281475154706452}
+ assert msg["payload"] == {"Value": 0, "ValueIDKey": 281475641245716}
# Test set_config_parameter list by index int
await hass.services.async_call(
"ozw",
"set_config_parameter",
- {"node_id": 10, "parameter": 1, "value": 0},
+ {"node_id": 39, "parameter": 1, "value": 1},
blocking=True,
)
assert len(sent_messages) == 2
msg = sent_messages[1]
assert msg["topic"] == "OpenZWave/1/command/setvalue/"
- assert msg["payload"] == {"Value": 0, "ValueIDKey": 281475154706452}
+ assert msg["payload"] == {"Value": 1, "ValueIDKey": 281475641245716}
# Test set_config_parameter int
await hass.services.async_call(
"ozw",
"set_config_parameter",
- {"node_id": 10, "parameter": 6, "value": 0},
+ {"node_id": 39, "parameter": 3, "value": 55},
blocking=True,
)
assert len(sent_messages) == 3
msg = sent_messages[2]
assert msg["topic"] == "OpenZWave/1/command/setvalue/"
- assert msg["payload"] == {"Value": 0, "ValueIDKey": 1688850038259731}
+ assert msg["payload"] == {"Value": 55, "ValueIDKey": 844425594667027}
# Test set_config_parameter invalid list int
await hass.services.async_call(
"ozw",
"set_config_parameter",
- {"node_id": 10, "parameter": 1, "value": 12},
+ {"node_id": 39, "parameter": 1, "value": 12},
blocking=True,
)
assert len(sent_messages) == 3
- assert "Value 12 out of range for parameter 1" in caplog.text
+ assert "Invalid value 12 for parameter 1" in caplog.text
# Test set_config_parameter invalid list string
await hass.services.async_call(
"ozw",
"set_config_parameter",
- {"node_id": 10, "parameter": 1, "value": "Blah"},
+ {"node_id": 39, "parameter": 1, "value": "Blah"},
blocking=True,
)
assert len(sent_messages) == 3
@@ -66,8 +66,32 @@ async def test_services(hass, lock_data, sent_messages, lock_msg, caplog):
await hass.services.async_call(
"ozw",
"set_config_parameter",
- {"node_id": 10, "parameter": 6, "value": 2147483657},
+ {"node_id": 39, "parameter": 3, "value": 2147483657},
blocking=True,
)
assert len(sent_messages) == 3
- assert "Value 12 out of range for parameter 1" in caplog.text
+ assert "Value 2147483657 out of range for parameter 3" in caplog.text
+
+ # Test set_config_parameter short
+ await hass.services.async_call(
+ "ozw",
+ "set_config_parameter",
+ {"node_id": 39, "parameter": 81, "value": 3000},
+ blocking=True,
+ )
+ assert len(sent_messages) == 4
+ msg = sent_messages[3]
+ assert msg["topic"] == "OpenZWave/1/command/setvalue/"
+ assert msg["payload"] == {"Value": 3000, "ValueIDKey": 22799473778098198}
+
+ # Test set_config_parameter byte
+ await hass.services.async_call(
+ "ozw",
+ "set_config_parameter",
+ {"node_id": 39, "parameter": 16, "value": 20},
+ blocking=True,
+ )
+ assert len(sent_messages) == 5
+ msg = sent_messages[4]
+ assert msg["topic"] == "OpenZWave/1/command/setvalue/"
+ assert msg["payload"] == {"Value": 20, "ValueIDKey": 4503600291905553}
diff --git a/tests/components/ozw/test_websocket_api.py b/tests/components/ozw/test_websocket_api.py
index 13ba6f2152c..353615c1812 100644
--- a/tests/components/ozw/test_websocket_api.py
+++ b/tests/components/ozw/test_websocket_api.py
@@ -1,8 +1,29 @@
"""Test OpenZWave Websocket API."""
-from homeassistant.components.ozw.websocket_api import ID, NODE_ID, OZW_INSTANCE, TYPE
+from homeassistant.components.ozw.websocket_api import (
+ ATTR_IS_AWAKE,
+ ATTR_IS_BEAMING,
+ ATTR_IS_FAILED,
+ ATTR_IS_FLIRS,
+ ATTR_IS_ROUTING,
+ ATTR_IS_SECURITYV1,
+ ATTR_IS_ZWAVE_PLUS,
+ ATTR_NEIGHBORS,
+ ATTR_NODE_BASIC_STRING,
+ ATTR_NODE_BAUD_RATE,
+ ATTR_NODE_GENERIC_STRING,
+ ATTR_NODE_QUERY_STAGE,
+ ATTR_NODE_SPECIFIC_STRING,
+ ID,
+ NODE_ID,
+ OZW_INSTANCE,
+ TYPE,
+)
+from homeassistant.components.websocket_api.const import ERR_NOT_FOUND
-from .common import setup_ozw
+from .common import MQTTMessage, setup_ozw
+
+from tests.async_mock import patch
async def test_websocket_api(hass, generic_data, hass_ws_client):
@@ -10,12 +31,21 @@ async def test_websocket_api(hass, generic_data, hass_ws_client):
await setup_ozw(hass, fixture=generic_data)
client = await hass_ws_client(hass)
+ # Test instance list
+ await client.send_json({ID: 4, TYPE: "ozw/get_instances"})
+ msg = await client.receive_json()
+ assert len(msg["result"]) == 1
+ result = msg["result"][0]
+ assert result[OZW_INSTANCE] == 1
+ assert result["Status"] == "driverAllNodesQueried"
+ assert result["OpenZWave_Version"] == "1.6.1008"
+
# Test network status
await client.send_json({ID: 5, TYPE: "ozw/network_status"})
msg = await client.receive_json()
result = msg["result"]
- assert result["state"] == "driverAllNodesQueried"
+ assert result["Status"] == "driverAllNodesQueried"
assert result[OZW_INSTANCE] == 1
# Test node status
@@ -25,22 +55,27 @@ async def test_websocket_api(hass, generic_data, hass_ws_client):
assert result[OZW_INSTANCE] == 1
assert result[NODE_ID] == 32
- assert result["node_query_stage"] == "Complete"
- assert result["is_zwave_plus"]
- assert result["is_awake"]
- assert not result["is_failed"]
- assert result["node_baud_rate"] == 100000
- assert result["is_beaming"]
- assert not result["is_flirs"]
- assert result["is_routing"]
- assert not result["is_securityv1"]
- assert result["node_basic_string"] == "Routing Slave"
- assert result["node_generic_string"] == "Binary Switch"
- assert result["node_specific_string"] == "Binary Power Switch"
- assert result["neighbors"] == [1, 33, 36, 37, 39]
+ assert result[ATTR_NODE_QUERY_STAGE] == "Complete"
+ assert result[ATTR_IS_ZWAVE_PLUS]
+ assert result[ATTR_IS_AWAKE]
+ assert not result[ATTR_IS_FAILED]
+ assert result[ATTR_NODE_BAUD_RATE] == 100000
+ assert result[ATTR_IS_BEAMING]
+ assert not result[ATTR_IS_FLIRS]
+ assert result[ATTR_IS_ROUTING]
+ assert not result[ATTR_IS_SECURITYV1]
+ assert result[ATTR_NODE_BASIC_STRING] == "Routing Slave"
+ assert result[ATTR_NODE_GENERIC_STRING] == "Binary Switch"
+ assert result[ATTR_NODE_SPECIFIC_STRING] == "Binary Power Switch"
+ assert result[ATTR_NEIGHBORS] == [1, 33, 36, 37, 39]
+
+ await client.send_json({ID: 7, TYPE: "ozw/node_status", NODE_ID: 999})
+ msg = await client.receive_json()
+ result = msg["error"]
+ assert result["code"] == ERR_NOT_FOUND
# Test node statistics
- await client.send_json({ID: 7, TYPE: "ozw/node_statistics", NODE_ID: 39})
+ await client.send_json({ID: 8, TYPE: "ozw/node_statistics", NODE_ID: 39})
msg = await client.receive_json()
result = msg["result"]
@@ -56,3 +91,96 @@ async def test_websocket_api(hass, generic_data, hass_ws_client):
assert result["received_packets"] == 3594
assert result["received_dup_packets"] == 12
assert result["received_unsolicited"] == 3546
+
+ # Test node metadata
+ await client.send_json({ID: 9, TYPE: "ozw/node_metadata", NODE_ID: 39})
+ msg = await client.receive_json()
+ result = msg["result"]
+ assert result["metadata"]["ProductPic"] == "images/aeotec/zwa002.png"
+
+ await client.send_json({ID: 10, TYPE: "ozw/node_metadata", NODE_ID: 999})
+ msg = await client.receive_json()
+ result = msg["error"]
+ assert result["code"] == ERR_NOT_FOUND
+
+ # Test network statistics
+ await client.send_json({ID: 11, TYPE: "ozw/network_statistics"})
+ msg = await client.receive_json()
+ result = msg["result"]
+ assert result["readCnt"] == 92220
+ assert result[OZW_INSTANCE] == 1
+ assert result["node_count"] == 5
+
+ # Test get nodes
+ await client.send_json({ID: 12, TYPE: "ozw/get_nodes"})
+ msg = await client.receive_json()
+ result = msg["result"]
+ assert len(result) == 5
+ assert result[2][ATTR_IS_AWAKE]
+ assert not result[1][ATTR_IS_FAILED]
+
+
+async def test_refresh_node(hass, generic_data, sent_messages, hass_ws_client):
+ """Test the ozw refresh node api."""
+ receive_message = await setup_ozw(hass, fixture=generic_data)
+ client = await hass_ws_client(hass)
+
+ # Send the refresh_node_info command
+ await client.send_json({ID: 9, TYPE: "ozw/refresh_node_info", NODE_ID: 39})
+ msg = await client.receive_json()
+
+ assert len(sent_messages) == 1
+ assert msg["success"]
+
+ # Receive a mock status update from OZW
+ message = MQTTMessage(
+ topic="OpenZWave/1/node/39/",
+ payload={"NodeID": 39, "NodeQueryStage": "initializing"},
+ )
+ message.encode()
+ receive_message(message)
+
+ # Verify we got expected data on the websocket
+ msg = await client.receive_json()
+ result = msg["event"]
+ assert result["type"] == "node_updated"
+ assert result["node_query_stage"] == "initializing"
+
+ # Send another mock status update from OZW
+ message = MQTTMessage(
+ topic="OpenZWave/1/node/39/",
+ payload={"NodeID": 39, "NodeQueryStage": "versions"},
+ )
+ message.encode()
+ receive_message(message)
+
+ # Send a mock status update for a different node
+ message = MQTTMessage(
+ topic="OpenZWave/1/node/35/",
+ payload={"NodeID": 35, "NodeQueryStage": "fake_shouldnt_be_received"},
+ )
+ message.encode()
+ receive_message(message)
+
+ # Verify we received the message for node 39 but not for node 35
+ msg = await client.receive_json()
+ result = msg["event"]
+ assert result["type"] == "node_updated"
+ assert result["node_query_stage"] == "versions"
+
+
+async def test_refresh_node_unsubscribe(hass, generic_data, hass_ws_client):
+ """Test unsubscribing the ozw refresh node api."""
+ await setup_ozw(hass, fixture=generic_data)
+ client = await hass_ws_client(hass)
+
+ with patch("openzwavemqtt.OZWOptions.listen") as mock_listen:
+ # Send the refresh_node_info command
+ await client.send_json({ID: 9, TYPE: "ozw/refresh_node_info", NODE_ID: 39})
+ await client.receive_json()
+
+ # Send the unsubscribe command
+ await client.send_json({ID: 10, TYPE: "unsubscribe_events", "subscription": 9})
+ await client.receive_json()
+
+ assert mock_listen.return_value.called
diff --git a/tests/components/panasonic_viera/test_config_flow.py b/tests/components/panasonic_viera/test_config_flow.py
index 0e7731dbdc0..5359811c52c 100644
--- a/tests/components/panasonic_viera/test_config_flow.py
+++ b/tests/components/panasonic_viera/test_config_flow.py
@@ -27,7 +27,8 @@ def panasonic_viera_setup_fixture():
with patch(
"homeassistant.components.panasonic_viera.async_setup", return_value=True
), patch(
- "homeassistant.components.panasonic_viera.async_setup_entry", return_value=True,
+ "homeassistant.components.panasonic_viera.async_setup_entry",
+ return_value=True,
):
yield
@@ -80,7 +81,8 @@ async def test_flow_non_encrypted(hass):
return_value=mock_remote,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME},
+ result["flow_id"],
+ {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME},
)
assert result["type"] == "create_entry"
@@ -108,7 +110,8 @@ async def test_flow_not_connected_error(hass):
side_effect=TimeoutError,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME},
+ result["flow_id"],
+ {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME},
)
assert result["type"] == "form"
@@ -131,7 +134,8 @@ async def test_flow_unknown_abort(hass):
side_effect=Exception,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME},
+ result["flow_id"],
+ {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME},
)
assert result["type"] == "abort"
@@ -149,7 +153,9 @@ async def test_flow_encrypted_valid_pin_code(hass):
assert result["step_id"] == "user"
mock_remote = get_mock_remote(
- encrypted=True, app_id="test-app-id", encryption_key="test-encryption-key",
+ encrypted=True,
+ app_id="test-app-id",
+ encryption_key="test-encryption-key",
)
with patch(
@@ -157,14 +163,16 @@ async def test_flow_encrypted_valid_pin_code(hass):
return_value=mock_remote,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME},
+ result["flow_id"],
+ {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME},
)
assert result["type"] == "form"
assert result["step_id"] == "pairing"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_PIN: "1234"},
+ result["flow_id"],
+ {CONF_PIN: "1234"},
)
assert result["type"] == "create_entry"
@@ -196,7 +204,8 @@ async def test_flow_encrypted_invalid_pin_code_error(hass):
return_value=mock_remote,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME},
+ result["flow_id"],
+ {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME},
)
assert result["type"] == "form"
@@ -207,7 +216,8 @@ async def test_flow_encrypted_invalid_pin_code_error(hass):
return_value=mock_remote,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_PIN: "0000"},
+ result["flow_id"],
+ {CONF_PIN: "0000"},
)
assert result["type"] == "form"
@@ -232,14 +242,16 @@ async def test_flow_encrypted_not_connected_abort(hass):
return_value=mock_remote,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME},
+ result["flow_id"],
+ {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME},
)
assert result["type"] == "form"
assert result["step_id"] == "pairing"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_PIN: "0000"},
+ result["flow_id"],
+ {CONF_PIN: "0000"},
)
assert result["type"] == "abort"
@@ -263,14 +275,16 @@ async def test_flow_encrypted_unknown_abort(hass):
return_value=mock_remote,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME},
+ result["flow_id"],
+ {CONF_HOST: "1.2.3.4", CONF_NAME: DEFAULT_NAME},
)
assert result["type"] == "form"
assert result["step_id"] == "pairing"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_PIN: "0000"},
+ result["flow_id"],
+ {CONF_PIN: "0000"},
)
assert result["type"] == "abort"
@@ -355,7 +369,9 @@ async def test_imported_flow_encrypted_valid_pin_code(hass):
"""Test imported flow with encryption and valid PIN code."""
mock_remote = get_mock_remote(
- encrypted=True, app_id="test-app-id", encryption_key="test-encryption-key",
+ encrypted=True,
+ app_id="test-app-id",
+ encryption_key="test-encryption-key",
)
with patch(
@@ -377,7 +393,8 @@ async def test_imported_flow_encrypted_valid_pin_code(hass):
assert result["step_id"] == "pairing"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_PIN: "1234"},
+ result["flow_id"],
+ {CONF_PIN: "1234"},
)
assert result["type"] == "create_entry"
@@ -420,7 +437,8 @@ async def test_imported_flow_encrypted_invalid_pin_code_error(hass):
return_value=mock_remote,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_PIN: "0000"},
+ result["flow_id"],
+ {CONF_PIN: "0000"},
)
assert result["type"] == "form"
@@ -452,7 +470,8 @@ async def test_imported_flow_encrypted_not_connected_abort(hass):
assert result["step_id"] == "pairing"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_PIN: "0000"},
+ result["flow_id"],
+ {CONF_PIN: "0000"},
)
assert result["type"] == "abort"
@@ -483,7 +502,8 @@ async def test_imported_flow_encrypted_unknown_abort(hass):
assert result["step_id"] == "pairing"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_PIN: "0000"},
+ result["flow_id"],
+ {CONF_PIN: "0000"},
)
assert result["type"] == "abort"
diff --git a/tests/components/panasonic_viera/test_init.py b/tests/components/panasonic_viera/test_init.py
index 327ab5829c1..a4a1ca94fe5 100644
--- a/tests/components/panasonic_viera/test_init.py
+++ b/tests/components/panasonic_viera/test_init.py
@@ -52,7 +52,8 @@ async def test_setup_entry_encrypted(hass):
mock_remote = get_mock_remote()
with patch(
- "homeassistant.components.panasonic_viera.Remote", return_value=mock_remote,
+ "homeassistant.components.panasonic_viera.Remote",
+ return_value=mock_remote,
):
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
@@ -66,7 +67,9 @@ async def test_setup_entry_encrypted(hass):
async def test_setup_entry_unencrypted(hass):
"""Test setup with unencrypted config entry."""
mock_entry = MockConfigEntry(
- domain=DOMAIN, unique_id=MOCK_CONFIG_DATA[CONF_HOST], data=MOCK_CONFIG_DATA,
+ domain=DOMAIN,
+ unique_id=MOCK_CONFIG_DATA[CONF_HOST],
+ data=MOCK_CONFIG_DATA,
)
mock_entry.add_to_hass(hass)
@@ -74,7 +77,8 @@ async def test_setup_entry_unencrypted(hass):
mock_remote = get_mock_remote()
with patch(
- "homeassistant.components.panasonic_viera.Remote", return_value=mock_remote,
+ "homeassistant.components.panasonic_viera.Remote",
+ return_value=mock_remote,
):
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
@@ -88,7 +92,11 @@ async def test_setup_entry_unencrypted(hass):
async def test_setup_config_flow_initiated(hass):
"""Test if config flow is initiated in setup."""
assert (
- await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_HOST: "0.0.0.0"}},)
+ await async_setup_component(
+ hass,
+ DOMAIN,
+ {DOMAIN: {CONF_HOST: "0.0.0.0"}},
+ )
is True
)
@@ -106,7 +114,8 @@ async def test_setup_unload_entry(hass):
mock_remote = get_mock_remote()
with patch(
- "homeassistant.components.panasonic_viera.Remote", return_value=mock_remote,
+ "homeassistant.components.panasonic_viera.Remote",
+ return_value=mock_remote,
):
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
diff --git a/tests/components/panel_custom/test_init.py b/tests/components/panel_custom/test_init.py
index caa55749c50..ddcb4079ef7 100644
--- a/tests/components/panel_custom/test_init.py
+++ b/tests/components/panel_custom/test_init.py
@@ -30,52 +30,6 @@ async def test_webcomponent_custom_path_not_found(hass):
assert "nice_url" not in panels
-async def test_webcomponent_custom_path(hass, caplog):
- """Test if a web component is found in config panels dir."""
- filename = "mock.file"
-
- config = {
- "panel_custom": [
- {
- "name": "todo-mvc",
- "webcomponent_path": filename,
- "sidebar_title": "Sidebar Title",
- "sidebar_icon": "mdi:iconicon",
- "url_path": "nice_url",
- "config": {"hello": "world"},
- },
- {"name": "todo-mvc"},
- ]
- }
-
- with patch("os.path.isfile", Mock(return_value=True)):
- with patch("os.access", Mock(return_value=True)):
- result = await setup.async_setup_component(hass, "panel_custom", config)
- assert result
-
- panels = hass.data.get(frontend.DATA_PANELS, [])
-
- assert panels
- assert "nice_url" in panels
-
- panel = panels["nice_url"]
-
- assert panel.config == {
- "hello": "world",
- "_panel_custom": {
- "html_url": "/api/panel_custom/todo-mvc",
- "name": "todo-mvc",
- "embed_iframe": False,
- "trust_external": False,
- },
- }
- assert panel.frontend_url_path == "nice_url"
- assert panel.sidebar_icon == "mdi:iconicon"
- assert panel.sidebar_title == "Sidebar Title"
-
- assert "Got HTML panel with duplicate name todo-mvc. Not registering" in caplog.text
-
-
async def test_js_webcomponent(hass):
"""Test if a web component is found in config panels dir."""
config = {
@@ -188,31 +142,6 @@ async def test_latest_and_es5_build(hass):
assert panel.frontend_url_path == "nice_url"
-async def test_url_option_conflict(hass):
- """Test config with multiple url options."""
- to_try = [
- {
- "panel_custom": {
- "name": "todo-mvc",
- "webcomponent_path": "/local/bla.html",
- "js_url": "/local/bla.js",
- }
- },
- {
- "panel_custom": {
- "name": "todo-mvc",
- "webcomponent_path": "/local/bla.html",
- "module_url": "/local/bla.js",
- "js_url": "/local/bla.js",
- }
- },
- ]
-
- for config in to_try:
- result = await setup.async_setup_component(hass, "panel_custom", config)
- assert not result
-
-
async def test_url_path_conflict(hass):
"""Test config with overlapping url path."""
assert await setup.async_setup_component(
diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py
index 887f0d94fef..64aa583e2f5 100644
--- a/tests/components/person/test_init.py
+++ b/tests/components/person/test_init.py
@@ -11,6 +11,7 @@ from homeassistant.components.device_tracker import (
)
from homeassistant.components.person import ATTR_SOURCE, ATTR_USER_ID, DOMAIN
from homeassistant.const import (
+ ATTR_ENTITY_PICTURE,
ATTR_GPS_ACCURACY,
ATTR_ID,
ATTR_LATITUDE,
@@ -24,7 +25,7 @@ from homeassistant.helpers import collection, entity_registry
from homeassistant.setup import async_setup_component
from tests.async_mock import patch
-from tests.common import assert_setup_component, mock_component, mock_restore_cache
+from tests.common import mock_component, mock_restore_cache
DEVICE_TRACKER = "device_tracker.test_tracker"
DEVICE_TRACKER_2 = "device_tracker.test_tracker_2"
@@ -67,8 +68,7 @@ def storage_setup(hass, hass_storage, hass_admin_user):
async def test_minimal_setup(hass):
"""Test minimal config with only name."""
config = {DOMAIN: {"id": "1234", "name": "test person"}}
- with assert_setup_component(1):
- assert await async_setup_component(hass, DOMAIN, config)
+ assert await async_setup_component(hass, DOMAIN, config)
state = hass.states.get("person.test_person")
assert state.state == STATE_UNKNOWN
@@ -76,6 +76,7 @@ async def test_minimal_setup(hass):
assert state.attributes.get(ATTR_LONGITUDE) is None
assert state.attributes.get(ATTR_SOURCE) is None
assert state.attributes.get(ATTR_USER_ID) is None
+ assert state.attributes.get(ATTR_ENTITY_PICTURE) is None
async def test_setup_no_id(hass):
@@ -94,8 +95,7 @@ async def test_setup_user_id(hass, hass_admin_user):
"""Test config with user id."""
user_id = hass_admin_user.id
config = {DOMAIN: {"id": "1234", "name": "test person", "user_id": user_id}}
- with assert_setup_component(1):
- assert await async_setup_component(hass, DOMAIN, config)
+ assert await async_setup_component(hass, DOMAIN, config)
state = hass.states.get("person.test_person")
assert state.state == STATE_UNKNOWN
@@ -115,8 +115,7 @@ async def test_valid_invalid_user_ids(hass, hass_admin_user):
{"id": "5678", "name": "test bad user", "user_id": "bad_user_id"},
]
}
- with assert_setup_component(2):
- assert await async_setup_component(hass, DOMAIN, config)
+ assert await async_setup_component(hass, DOMAIN, config)
state = hass.states.get("person.test_valid_user")
assert state.state == STATE_UNKNOWN
@@ -141,8 +140,7 @@ async def test_setup_tracker(hass, hass_admin_user):
"device_trackers": DEVICE_TRACKER,
}
}
- with assert_setup_component(1):
- assert await async_setup_component(hass, DOMAIN, config)
+ assert await async_setup_component(hass, DOMAIN, config)
state = hass.states.get("person.tracked_person")
assert state.state == STATE_UNKNOWN
@@ -198,8 +196,7 @@ async def test_setup_two_trackers(hass, hass_admin_user):
"device_trackers": [DEVICE_TRACKER, DEVICE_TRACKER_2],
}
}
- with assert_setup_component(1):
- assert await async_setup_component(hass, DOMAIN, config)
+ assert await async_setup_component(hass, DOMAIN, config)
state = hass.states.get("person.tracked_person")
assert state.state == STATE_UNKNOWN
@@ -285,8 +282,7 @@ async def test_ignore_unavailable_states(hass, hass_admin_user):
"device_trackers": [DEVICE_TRACKER, DEVICE_TRACKER_2],
}
}
- with assert_setup_component(1):
- assert await async_setup_component(hass, DOMAIN, config)
+ assert await async_setup_component(hass, DOMAIN, config)
state = hass.states.get("person.tracked_person")
assert state.state == STATE_UNKNOWN
@@ -337,10 +333,10 @@ async def test_restore_home_state(hass, hass_admin_user):
"name": "tracked person",
"user_id": user_id,
"device_trackers": DEVICE_TRACKER,
+ "picture": "/bla",
}
}
- with assert_setup_component(1):
- assert await async_setup_component(hass, DOMAIN, config)
+ assert await async_setup_component(hass, DOMAIN, config)
state = hass.states.get("person.tracked_person")
assert state.state == "home"
@@ -350,6 +346,7 @@ async def test_restore_home_state(hass, hass_admin_user):
# When restoring state the entity_id of the person will be used as source.
assert state.attributes.get(ATTR_SOURCE) == "person.tracked_person"
assert state.attributes.get(ATTR_USER_ID) == user_id
+ assert state.attributes.get(ATTR_ENTITY_PICTURE) == "/bla"
async def test_duplicate_ids(hass, hass_admin_user):
@@ -360,8 +357,7 @@ async def test_duplicate_ids(hass, hass_admin_user):
{"id": "1234", "name": "test user 2"},
]
}
- with assert_setup_component(2):
- assert await async_setup_component(hass, DOMAIN, config)
+ assert await async_setup_component(hass, DOMAIN, config)
assert len(hass.states.async_entity_ids("person")) == 1
assert hass.states.get("person.test_user_1") is not None
@@ -371,8 +367,7 @@ async def test_duplicate_ids(hass, hass_admin_user):
async def test_create_person_during_run(hass):
"""Test that person is updated if created while hass is running."""
config = {DOMAIN: {}}
- with assert_setup_component(0):
- assert await async_setup_component(hass, DOMAIN, config)
+ assert await async_setup_component(hass, DOMAIN, config)
hass.states.async_set(DEVICE_TRACKER, "home")
await hass.async_block_till_done()
@@ -465,6 +460,7 @@ async def test_ws_create(hass, hass_ws_client, storage_setup, hass_read_only_use
"name": "Hello",
"device_trackers": [DEVICE_TRACKER],
"user_id": hass_read_only_user.id,
+ "picture": "/bla",
}
)
resp = await client.receive_json()
@@ -529,6 +525,7 @@ async def test_ws_update(hass, hass_ws_client, storage_setup):
"name": "Updated Name",
"device_trackers": [DEVICE_TRACKER_2],
"user_id": None,
+ "picture": "/bla",
}
)
resp = await client.receive_json()
@@ -542,6 +539,7 @@ async def test_ws_update(hass, hass_ws_client, storage_setup):
assert persons[0]["name"] == "Updated Name"
assert persons[0]["device_trackers"] == [DEVICE_TRACKER_2]
assert persons[0]["user_id"] is None
+ assert persons[0]["picture"] == "/bla"
state = hass.states.get("person.tracked_person")
assert state.name == "Updated Name"
diff --git a/tests/components/pi_hole/test_config_flow.py b/tests/components/pi_hole/test_config_flow.py
index 07a9e08313a..714f211d4f8 100644
--- a/tests/components/pi_hole/test_config_flow.py
+++ b/tests/components/pi_hole/test_config_flow.py
@@ -30,7 +30,8 @@ def _flow_next(hass, flow_id):
def _patch_setup():
return patch(
- "homeassistant.components.pi_hole.async_setup_entry", return_value=True,
+ "homeassistant.components.pi_hole.async_setup_entry",
+ return_value=True,
)
@@ -70,7 +71,8 @@ async def test_flow_user(hass):
mocked_hole = _create_mocked_hole()
with _patch_config_flow_hole(mocked_hole), _patch_setup():
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER},
+ DOMAIN,
+ context={"source": SOURCE_USER},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
@@ -78,7 +80,8 @@ async def test_flow_user(hass):
_flow_next(hass, result["flow_id"])
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=CONF_CONFIG_FLOW,
+ result["flow_id"],
+ user_input=CONF_CONFIG_FLOW,
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == NAME
@@ -86,7 +89,9 @@ async def test_flow_user(hass):
# duplicated server
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=CONF_CONFIG_FLOW,
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ data=CONF_CONFIG_FLOW,
)
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
diff --git a/tests/components/ping/__init__.py b/tests/components/ping/__init__.py
new file mode 100644
index 00000000000..3d695fea171
--- /dev/null
+++ b/tests/components/ping/__init__.py
@@ -0,0 +1 @@
+"""Tests for ping component."""
diff --git a/tests/components/ping/test_binary_sensor.py b/tests/components/ping/test_binary_sensor.py
new file mode 100644
index 00000000000..ae6362939a7
--- /dev/null
+++ b/tests/components/ping/test_binary_sensor.py
@@ -0,0 +1,53 @@
+"""The test for the ping binary_sensor platform."""
+from os import path
+
+from homeassistant import config as hass_config, setup
+from homeassistant.components.ping import DOMAIN
+from homeassistant.const import SERVICE_RELOAD
+
+from tests.async_mock import patch
+
+
+async def test_reload(hass):
+ """Verify we can reload trend sensors."""
+
+ await setup.async_setup_component(
+ hass,
+ "binary_sensor",
+ {
+ "binary_sensor": {
+ "platform": "ping",
+ "name": "test",
+ "host": "127.0.0.1",
+ "count": 1,
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+ assert hass.states.get("binary_sensor.test")
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "ping/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+ assert hass.states.get("binary_sensor.test") is None
+ assert hass.states.get("binary_sensor.test2")
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/plant/test_init.py b/tests/components/plant/test_init.py
index 991eb1d385d..866702488dc 100644
--- a/tests/components/plant/test_init.py
+++ b/tests/components/plant/test_init.py
@@ -71,7 +71,8 @@ async def test_low_battery(hass):
sensor.hass = hass
assert sensor.state_attributes["problem"] == "none"
sensor.state_changed(
- "sensor.mqtt_plant_battery", State("sensor.mqtt_plant_battery", 10),
+ "sensor.mqtt_plant_battery",
+ State("sensor.mqtt_plant_battery", 10),
)
assert sensor.state == "problem"
assert sensor.state_attributes["problem"] == "battery low"
diff --git a/tests/components/plex/mock_classes.py b/tests/components/plex/mock_classes.py
index dd8e9a93ab8..e16d5cdc13b 100644
--- a/tests/components/plex/mock_classes.py
+++ b/tests/components/plex/mock_classes.py
@@ -1,4 +1,9 @@
"""Mock classes used in tests."""
+from functools import lru_cache
+
+from aiohttp.helpers import reify
+from plexapi.exceptions import NotFound
+
from homeassistant.components.plex.const import (
CONF_SERVER,
CONF_SERVER_IDENTIFIER,
@@ -121,6 +126,8 @@ class MockPlexServer:
self.set_clients(num_users)
self.set_sessions(num_users, session_type)
+ self._cache = {}
+
def set_clients(self, num_clients):
"""Set up mock PlexClients for this PlexServer."""
self._clients = [MockPlexClient(self._baseurl, x) for x in range(num_clients)]
@@ -166,18 +173,32 @@ class MockPlexServer:
"""Mock version of PlexServer."""
return "1.0"
- @property
+ @reify
def library(self):
"""Mock library object of PlexServer."""
- return MockPlexLibrary()
+ return MockPlexLibrary(self)
def playlist(self, playlist):
"""Mock the playlist lookup method."""
return MockPlexMediaItem(playlist, mediatype="playlist")
+ @lru_cache()
+ def playlists(self):
+ """Mock the playlists lookup method with a lazy init."""
+ return [
+ MockPlexPlaylist(
+ self.library.section("Movies").all()
+ + self.library.section("TV Shows").all()
+ ),
+ MockPlexPlaylist(self.library.section("Music").all()),
+ ]
+
def fetchItem(self, item):
"""Mock the fetchItem method."""
- return MockPlexMediaItem("Item Name")
+ for section in self.library.sections():
+ result = section.fetchItem(item)
+ if result:
+ return result
class MockPlexClient:
@@ -247,7 +268,7 @@ class MockPlexSession:
self.TYPE = mediatype
self.usernames = [list(MOCK_USERS)[index]]
self.players = [player]
- self._section = MockPlexLibrarySection()
+ self._section = MockPlexLibrarySection("Movies")
@property
def duration(self):
@@ -302,56 +323,190 @@ class MockPlexSession:
class MockPlexLibrary:
"""Mock a Plex Library instance."""
- def __init__(self):
+ def __init__(self, plex_server):
"""Initialize the object."""
+ self._plex_server = plex_server
+ self._sections = {}
- def section(self, library_name):
+ for kind in ["Movies", "Music", "TV Shows", "Photos"]:
+ self._sections[kind] = MockPlexLibrarySection(kind)
+
+ def section(self, title):
"""Mock the LibrarySection lookup."""
- return MockPlexLibrarySection(library_name)
+ section = self._sections.get(title)
+ if section:
+ return section
+ raise NotFound
+
+ def sections(self):
+ """Return all available sections."""
+ return self._sections.values()
+
+ def sectionByID(self, section_id):
+ """Mock the sectionByID lookup."""
+ return [x for x in self.sections() if x.key == section_id][0]
+
+ def onDeck(self):
+ """Mock an empty On Deck folder."""
+ return []
+
+ def recentlyAdded(self):
+ """Mock an empty Recently Added folder."""
+ return []
class MockPlexLibrarySection:
"""Mock a Plex LibrarySection instance."""
- def __init__(self, library="Movies"):
+ def __init__(self, library):
"""Initialize the object."""
self.title = library
+ if library == "Music":
+ self._item = MockPlexArtist("Artist")
+ elif library == "TV Shows":
+ self._item = MockPlexShow("TV Show")
+ else:
+ self._item = MockPlexMediaItem(library[:-1])
+
def get(self, query):
"""Mock the get lookup method."""
+ if self._item.title == query:
+ return self._item
+ raise NotFound
+
+ def all(self):
+ """Mock the all method."""
+ return [self._item]
+
+ def fetchItem(self, ratingKey):
+ """Return a specific item."""
+ for item in self.all():
+ if item.ratingKey == ratingKey:
+ return item
+ if item._children:
+ for child in item._children:
+ if child.ratingKey == ratingKey:
+ return child
+
+ def onDeck(self):
+ """Mock an empty On Deck folder."""
+ return []
+
+ def recentlyAdded(self):
+ """Mock an empty Recently Added folder."""
+ return self.all()
+
+ @property
+ def type(self):
+ """Mock the library type."""
+ if self.title == "Movies":
+ return "movie"
if self.title == "Music":
- return MockPlexArtist(query)
- return MockPlexMediaItem(query)
+ return "artist"
+ if self.title == "TV Shows":
+ return "show"
+ if self.title == "Photos":
+ return "photo"
+
+ @property
+ def TYPE(self):
+ """Return the library type."""
+ return self.type
+
+ @property
+ def key(self):
+ """Mock the key identifier property."""
+ return str(id(self.title))
+
+ def search(self, **kwargs):
+ """Mock the LibrarySection search method."""
+ if kwargs.get("libtype") == "movie":
+ return self.all()
+
+ def update(self):
+ """Mock the update call."""
+ pass
class MockPlexMediaItem:
"""Mock a Plex Media instance."""
- def __init__(self, title, mediatype="video"):
+ def __init__(self, title, mediatype="video", year=2020):
"""Initialize the object."""
self.title = str(title)
self.type = mediatype
+ self.thumbUrl = "http://1.2.3.4/thumb.png"
+ self.year = year
+ self._children = []
- def album(self, album):
- """Mock the album lookup method."""
- return MockPlexMediaItem(album, mediatype="album")
+ def __iter__(self):
+ """Provide iterator."""
+ yield from self._children
- def track(self, track):
- """Mock the track lookup method."""
- return MockPlexMediaTrack()
+ @property
+ def ratingKey(self):
+ """Mock the ratingKey property."""
+ return id(self.title)
- def tracks(self):
- """Mock the tracks lookup method."""
- for index in range(1, 10):
- yield MockPlexMediaTrack(index)
- def episode(self, episode):
- """Mock the episode lookup method."""
- return MockPlexMediaItem(episode, mediatype="episode")
+class MockPlexPlaylist(MockPlexMediaItem):
+ """Mock a Plex Playlist instance."""
+
+ def __init__(self, items):
+ """Initialize the object."""
+ super().__init__(f"Playlist ({len(items)} Items)", "playlist")
+ for item in items:
+ self._children.append(item)
+
+
+class MockPlexShow(MockPlexMediaItem):
+ """Mock a Plex Show instance."""
+
+ def __init__(self, show):
+ """Initialize the object."""
+ super().__init__(show, "show")
+ for index in range(1, 5):
+ self._children.append(MockPlexSeason(index))
def season(self, season):
"""Mock the season lookup method."""
- return MockPlexMediaItem(season, mediatype="season")
+ return [x for x in self._children if x.title == f"Season {season}"][0]
+
+
+class MockPlexSeason(MockPlexMediaItem):
+ """Mock a Plex Season instance."""
+
+ def __init__(self, season):
+ """Initialize the object."""
+ super().__init__(f"Season {season}", "season")
+ for index in range(1, 10):
+ self._children.append(MockPlexMediaItem(f"Episode {index}", "episode"))
+
+ def episode(self, episode):
+ """Mock the episode lookup method."""
+ return self._children[episode - 1]
+
+
+class MockPlexAlbum(MockPlexMediaItem):
+ """Mock a Plex Album instance."""
+
+ def __init__(self, album):
+ """Initialize the object."""
+ super().__init__(album, "album")
+ for index in range(1, 10):
+ self._children.append(MockPlexMediaTrack(index))
+
+ def track(self, track):
+ """Mock the track lookup method."""
+ try:
+ return [x for x in self._children if x.title == track][0]
+ except IndexError:
+ raise NotFound
+
+ def tracks(self):
+ """Mock the tracks lookup method."""
+ return self._children
class MockPlexArtist(MockPlexMediaItem):
@@ -359,12 +514,16 @@ class MockPlexArtist(MockPlexMediaItem):
def __init__(self, artist):
"""Initialize the object."""
- super().__init__(artist)
- self.type = "artist"
+ super().__init__(artist, "artist")
+ self._album = MockPlexAlbum("Album")
+
+ def album(self, album):
+ """Mock the album lookup method."""
+ return self._album
def get(self, track):
"""Mock the track lookup method."""
- return MockPlexMediaTrack()
+ return self._album.track(track)
class MockPlexMediaTrack(MockPlexMediaItem):
diff --git a/tests/components/plex/test_browse_media.py b/tests/components/plex/test_browse_media.py
new file mode 100644
index 00000000000..9cf6d7a7332
--- /dev/null
+++ b/tests/components/plex/test_browse_media.py
@@ -0,0 +1,217 @@
+"""Tests for Plex media browser."""
+from homeassistant.components.media_player.const import (
+ ATTR_MEDIA_CONTENT_ID,
+ ATTR_MEDIA_CONTENT_TYPE,
+)
+from homeassistant.components.plex.const import CONF_SERVER_IDENTIFIER, DOMAIN
+from homeassistant.components.plex.media_browser import SPECIAL_METHODS
+from homeassistant.components.websocket_api.const import ERR_UNKNOWN_ERROR, TYPE_RESULT
+
+from .const import DEFAULT_DATA, DEFAULT_OPTIONS
+from .helpers import trigger_plex_update
+from .mock_classes import MockPlexAccount, MockPlexServer
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+
+async def test_browse_media(hass, hass_ws_client):
+ """Test getting Plex clients from plex.tv."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=DEFAULT_DATA,
+ options=DEFAULT_OPTIONS,
+ unique_id=DEFAULT_DATA["server_id"],
+ )
+
+ mock_plex_server = MockPlexServer(config_entry=entry)
+ mock_plex_account = MockPlexAccount()
+
+ with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
+ "plexapi.myplex.MyPlexAccount", return_value=mock_plex_account
+ ), patch(
+ "homeassistant.components.plex.PlexWebsocket", autospec=True
+ ) as mock_websocket:
+ entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ websocket_client = await hass_ws_client(hass)
+
+ trigger_plex_update(mock_websocket)
+ await hass.async_block_till_done()
+
+ media_players = hass.states.async_entity_ids("media_player")
+ msg_id = 1
+
+ # Browse base of non-existent Plex server
+ await websocket_client.send_json(
+ {
+ "id": msg_id,
+ "type": "media_player/browse_media",
+ "entity_id": media_players[0],
+ ATTR_MEDIA_CONTENT_TYPE: "server",
+ ATTR_MEDIA_CONTENT_ID: "this server does not exist",
+ }
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == msg_id
+ assert msg["type"] == TYPE_RESULT
+ assert not msg["success"]
+ assert msg["error"]["code"] == ERR_UNKNOWN_ERROR
+
+ # Browse base of Plex server
+ msg_id += 1
+ await websocket_client.send_json(
+ {
+ "id": msg_id,
+ "type": "media_player/browse_media",
+ "entity_id": media_players[0],
+ }
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == msg_id
+ assert msg["type"] == TYPE_RESULT
+ assert msg["success"]
+ result = msg["result"]
+ assert result[ATTR_MEDIA_CONTENT_TYPE] == "server"
+ assert result[ATTR_MEDIA_CONTENT_ID] == DEFAULT_DATA[CONF_SERVER_IDENTIFIER]
+ assert len(result["children"]) == len(mock_plex_server.library.sections()) + len(
+ SPECIAL_METHODS
+ )
+
+ tvshows = next(iter(x for x in result["children"] if x["title"] == "TV Shows"))
+ playlists = next(iter(x for x in result["children"] if x["title"] == "Playlists"))
+ special_keys = list(SPECIAL_METHODS.keys())
+
+ # Browse into a special folder (server)
+ msg_id += 1
+ await websocket_client.send_json(
+ {
+ "id": msg_id,
+ "type": "media_player/browse_media",
+ "entity_id": media_players[0],
+ ATTR_MEDIA_CONTENT_TYPE: "server",
+ ATTR_MEDIA_CONTENT_ID: f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[0]}",
+ }
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == msg_id
+ assert msg["type"] == TYPE_RESULT
+ assert msg["success"]
+ result = msg["result"]
+ assert result[ATTR_MEDIA_CONTENT_TYPE] == "server"
+ assert (
+ result[ATTR_MEDIA_CONTENT_ID]
+ == f"{DEFAULT_DATA[CONF_SERVER_IDENTIFIER]}:{special_keys[0]}"
+ )
+ assert len(result["children"]) == len(mock_plex_server.library.onDeck())
+
+ # Browse into a special folder (library)
+ msg_id += 1
+ library_section_id = next(iter(mock_plex_server.library.sections())).key
+ await websocket_client.send_json(
+ {
+ "id": msg_id,
+ "type": "media_player/browse_media",
+ "entity_id": media_players[0],
+ ATTR_MEDIA_CONTENT_TYPE: "library",
+ ATTR_MEDIA_CONTENT_ID: f"{library_section_id}:{special_keys[1]}",
+ }
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == msg_id
+ assert msg["type"] == TYPE_RESULT
+ assert msg["success"]
+ result = msg["result"]
+ assert result[ATTR_MEDIA_CONTENT_TYPE] == "library"
+ assert result[ATTR_MEDIA_CONTENT_ID] == f"{library_section_id}:{special_keys[1]}"
+ assert len(result["children"]) == len(
+ mock_plex_server.library.sectionByID(library_section_id).recentlyAdded()
+ )
+
+ # Browse into a Plex TV show library
+ msg_id += 1
+ await websocket_client.send_json(
+ {
+ "id": msg_id,
+ "type": "media_player/browse_media",
+ "entity_id": media_players[0],
+ ATTR_MEDIA_CONTENT_TYPE: tvshows[ATTR_MEDIA_CONTENT_TYPE],
+ ATTR_MEDIA_CONTENT_ID: str(tvshows[ATTR_MEDIA_CONTENT_ID]),
+ }
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == msg_id
+ assert msg["type"] == TYPE_RESULT
+ assert msg["success"]
+ result = msg["result"]
+ assert result[ATTR_MEDIA_CONTENT_TYPE] == "library"
+ result_id = result[ATTR_MEDIA_CONTENT_ID]
+ assert len(result["children"]) == len(
+ mock_plex_server.library.sectionByID(result_id).all()
+ ) + len(SPECIAL_METHODS)
+
+ # Browse into a Plex TV show
+ msg_id += 1
+ await websocket_client.send_json(
+ {
+ "id": msg_id,
+ "type": "media_player/browse_media",
+ "entity_id": media_players[0],
+ ATTR_MEDIA_CONTENT_TYPE: result["children"][-1][ATTR_MEDIA_CONTENT_TYPE],
+ ATTR_MEDIA_CONTENT_ID: str(result["children"][-1][ATTR_MEDIA_CONTENT_ID]),
+ }
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == msg_id
+ assert msg["type"] == TYPE_RESULT
+ assert msg["success"]
+ result = msg["result"]
+ assert result[ATTR_MEDIA_CONTENT_TYPE] == "show"
+ result_id = int(result[ATTR_MEDIA_CONTENT_ID])
+ assert result["title"] == mock_plex_server.fetchItem(result_id).title
+
+ # Browse into a non-existent TV season
+ msg_id += 1
+ await websocket_client.send_json(
+ {
+ "id": msg_id,
+ "type": "media_player/browse_media",
+ "entity_id": media_players[0],
+ ATTR_MEDIA_CONTENT_TYPE: result["children"][0][ATTR_MEDIA_CONTENT_TYPE],
+ ATTR_MEDIA_CONTENT_ID: str(99999999999999),
+ }
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == msg_id
+ assert msg["type"] == TYPE_RESULT
+ assert not msg["success"]
+ assert msg["error"]["code"] == ERR_UNKNOWN_ERROR
+
+ # Browse Plex playlists
+ msg_id += 1
+ await websocket_client.send_json(
+ {
+ "id": msg_id,
+ "type": "media_player/browse_media",
+ "entity_id": media_players[0],
+ ATTR_MEDIA_CONTENT_TYPE: playlists[ATTR_MEDIA_CONTENT_TYPE],
+ ATTR_MEDIA_CONTENT_ID: str(playlists[ATTR_MEDIA_CONTENT_ID]),
+ }
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == msg_id
+ assert msg["type"] == TYPE_RESULT
+ assert msg["success"]
+ result = msg["result"]
+ assert result[ATTR_MEDIA_CONTENT_TYPE] == "playlists"
+ result_id = result[ATTR_MEDIA_CONTENT_ID]
diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py
index 92124b7b39c..1bd2ce82863 100644
--- a/tests/components/plex/test_config_flow.py
+++ b/tests/components/plex/test_config_flow.py
@@ -45,7 +45,8 @@ from tests.common import MockConfigEntry
async def test_bad_credentials(hass):
"""Test when provided credentials are rejected."""
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
result = await hass.config_entries.flow.async_init(
@@ -79,7 +80,8 @@ async def test_bad_hostname(hass):
mock_plex_account = MockPlexAccount()
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
result = await hass.config_entries.flow.async_init(
@@ -115,7 +117,8 @@ async def test_bad_hostname(hass):
async def test_unknown_exception(hass):
"""Test when an unknown exception is encountered."""
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
result = await hass.config_entries.flow.async_init(
@@ -144,7 +147,8 @@ async def test_no_servers_found(hass):
"""Test when no servers are on an account."""
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
result = await hass.config_entries.flow.async_init(
@@ -178,7 +182,8 @@ async def test_single_available_server(hass):
mock_plex_server = MockPlexServer()
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
result = await hass.config_entries.flow.async_init(
@@ -217,7 +222,8 @@ async def test_multiple_servers_with_selection(hass):
mock_plex_server = MockPlexServer()
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
result = await hass.config_entries.flow.async_init(
@@ -246,7 +252,8 @@ async def test_multiple_servers_with_selection(hass):
assert result["step_id"] == "select_server"
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={CONF_SERVER: MOCK_SERVERS[0][CONF_SERVER]},
+ result["flow_id"],
+ user_input={CONF_SERVER: MOCK_SERVERS[0][CONF_SERVER]},
)
assert result["type"] == "create_entry"
assert result["title"] == mock_plex_server.friendlyName
@@ -264,7 +271,8 @@ async def test_adding_last_unconfigured_server(hass):
mock_plex_server = MockPlexServer()
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
MockConfigEntry(
@@ -311,7 +319,8 @@ async def test_all_available_servers_configured(hass):
"""Test when all available servers are already configured."""
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
MockConfigEntry(
@@ -503,7 +512,8 @@ async def test_external_timed_out(hass):
"""Test when external flow times out."""
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
result = await hass.config_entries.flow.async_init(
@@ -532,7 +542,8 @@ async def test_callback_view(hass, aiohttp_client):
"""Test callback view."""
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
result = await hass.config_entries.flow.async_init(
@@ -559,7 +570,8 @@ async def test_callback_view(hass, aiohttp_client):
async def test_manual_config(hass):
"""Test creating via manual configuration."""
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
class WrongCertValidaitionException(requests.exceptions.SSLError):
@@ -638,7 +650,8 @@ async def test_manual_config(hass):
assert result["errors"]["base"] == "host_or_token"
with patch(
- "plexapi.server.PlexServer", side_effect=requests.exceptions.SSLError,
+ "plexapi.server.PlexServer",
+ side_effect=requests.exceptions.SSLError,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MANUAL_SERVER
@@ -649,7 +662,8 @@ async def test_manual_config(hass):
assert result["errors"]["base"] == "ssl_error"
with patch(
- "plexapi.server.PlexServer", side_effect=WrongCertValidaitionException,
+ "plexapi.server.PlexServer",
+ side_effect=WrongCertValidaitionException,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=MANUAL_SERVER
diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py
index fa60f4dd7d2..666a819e8ca 100644
--- a/tests/components/plex/test_init.py
+++ b/tests/components/plex/test_init.py
@@ -30,7 +30,10 @@ async def test_set_config_entry_unique_id(hass):
mock_plex_server = MockPlexServer()
entry = MockConfigEntry(
- domain=const.DOMAIN, data=DEFAULT_DATA, options=DEFAULT_OPTIONS, unique_id=None,
+ domain=const.DOMAIN,
+ data=DEFAULT_DATA,
+ options=DEFAULT_OPTIONS,
+ unique_id=None,
)
with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
diff --git a/tests/components/plex/test_server.py b/tests/components/plex/test_server.py
index 7da20846599..b3623681f8a 100644
--- a/tests/components/plex/test_server.py
+++ b/tests/components/plex/test_server.py
@@ -1,7 +1,7 @@
"""Tests for Plex server."""
import copy
-from plexapi.exceptions import NotFound
+from plexapi.exceptions import BadRequest, NotFound
from requests.exceptions import RequestException
from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
@@ -9,6 +9,7 @@ from homeassistant.components.media_player.const import (
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
MEDIA_TYPE_EPISODE,
+ MEDIA_TYPE_MOVIE,
MEDIA_TYPE_MUSIC,
MEDIA_TYPE_PLAYLIST,
MEDIA_TYPE_VIDEO,
@@ -28,11 +29,14 @@ from .const import DEFAULT_DATA, DEFAULT_OPTIONS
from .helpers import trigger_plex_update
from .mock_classes import (
MockPlexAccount,
+ MockPlexAlbum,
MockPlexArtist,
MockPlexLibrary,
MockPlexLibrarySection,
MockPlexMediaItem,
+ MockPlexSeason,
MockPlexServer,
+ MockPlexShow,
)
from tests.async_mock import patch
@@ -298,7 +302,7 @@ async def test_media_lookups(hass):
with patch.object(MockPlexLibrary, "section", side_effect=NotFound):
assert (
loaded_server.lookup_media(
- MEDIA_TYPE_EPISODE, library_name="Not a Library", show_name="A TV Show"
+ MEDIA_TYPE_EPISODE, library_name="Not a Library", show_name="TV Show"
)
is None
)
@@ -316,37 +320,37 @@ async def test_media_lookups(hass):
is None
)
assert loaded_server.lookup_media(
- MEDIA_TYPE_EPISODE, library_name="TV Shows", show_name="A TV Show"
+ MEDIA_TYPE_EPISODE, library_name="TV Shows", show_name="TV Show"
)
assert loaded_server.lookup_media(
MEDIA_TYPE_EPISODE,
library_name="TV Shows",
- show_name="A TV Show",
+ show_name="TV Show",
season_number=2,
)
assert loaded_server.lookup_media(
MEDIA_TYPE_EPISODE,
library_name="TV Shows",
- show_name="A TV Show",
+ show_name="TV Show",
season_number=2,
episode_number=3,
)
- with patch.object(MockPlexMediaItem, "season", side_effect=NotFound):
+ with patch.object(MockPlexShow, "season", side_effect=NotFound):
assert (
loaded_server.lookup_media(
MEDIA_TYPE_EPISODE,
library_name="TV Shows",
- show_name="A TV Show",
+ show_name="TV Show",
season_number=2,
)
is None
)
- with patch.object(MockPlexMediaItem, "episode", side_effect=NotFound):
+ with patch.object(MockPlexSeason, "episode", side_effect=NotFound):
assert (
loaded_server.lookup_media(
MEDIA_TYPE_EPISODE,
library_name="TV Shows",
- show_name="A TV Show",
+ show_name="TV Show",
season_number=2,
episode_number=1,
)
@@ -356,24 +360,24 @@ async def test_media_lookups(hass):
# Music searches
assert (
loaded_server.lookup_media(
- MEDIA_TYPE_MUSIC, library_name="Music", album_name="An Album"
+ MEDIA_TYPE_MUSIC, library_name="Music", album_name="Album"
)
is None
)
assert loaded_server.lookup_media(
- MEDIA_TYPE_MUSIC, library_name="Music", artist_name="An Artist"
+ MEDIA_TYPE_MUSIC, library_name="Music", artist_name="Artist"
)
assert loaded_server.lookup_media(
MEDIA_TYPE_MUSIC,
library_name="Music",
- artist_name="An Artist",
- track_name="A Track",
+ artist_name="Artist",
+ track_name="Track 3",
)
assert loaded_server.lookup_media(
MEDIA_TYPE_MUSIC,
library_name="Music",
- artist_name="An Artist",
- album_name="An Album",
+ artist_name="Artist",
+ album_name="Album",
)
with patch.object(MockPlexLibrarySection, "get", side_effect=NotFound):
assert (
@@ -381,7 +385,7 @@ async def test_media_lookups(hass):
MEDIA_TYPE_MUSIC,
library_name="Music",
artist_name="Not an Artist",
- album_name="An Album",
+ album_name="Album",
)
is None
)
@@ -390,18 +394,18 @@ async def test_media_lookups(hass):
loaded_server.lookup_media(
MEDIA_TYPE_MUSIC,
library_name="Music",
- artist_name="An Artist",
+ artist_name="Artist",
album_name="Not an Album",
)
is None
)
- with patch.object(MockPlexMediaItem, "track", side_effect=NotFound):
+ with patch.object(MockPlexAlbum, "track", side_effect=NotFound):
assert (
loaded_server.lookup_media(
MEDIA_TYPE_MUSIC,
library_name="Music",
- artist_name="An Artist",
- album_name="An Album",
+ artist_name="Artist",
+ album_name=" Album",
track_name="Not a Track",
)
is None
@@ -411,7 +415,7 @@ async def test_media_lookups(hass):
loaded_server.lookup_media(
MEDIA_TYPE_MUSIC,
library_name="Music",
- artist_name="An Artist",
+ artist_name="Artist",
track_name="Not a Track",
)
is None
@@ -419,16 +423,16 @@ async def test_media_lookups(hass):
assert loaded_server.lookup_media(
MEDIA_TYPE_MUSIC,
library_name="Music",
- artist_name="An Artist",
- album_name="An Album",
+ artist_name="Artist",
+ album_name="Album",
track_number=3,
)
assert (
loaded_server.lookup_media(
MEDIA_TYPE_MUSIC,
library_name="Music",
- artist_name="An Artist",
- album_name="An Album",
+ artist_name="Artist",
+ album_name="Album",
track_number=30,
)
is None
@@ -436,9 +440,9 @@ async def test_media_lookups(hass):
assert loaded_server.lookup_media(
MEDIA_TYPE_MUSIC,
library_name="Music",
- artist_name="An Artist",
- album_name="An Album",
- track_name="A Track",
+ artist_name="Artist",
+ album_name="Album",
+ track_name="Track 3",
)
# Playlist searches
@@ -452,11 +456,11 @@ async def test_media_lookups(hass):
is None
)
- # Movie searches
- assert loaded_server.lookup_media(MEDIA_TYPE_VIDEO, video_name="A Movie") is None
+ # Legacy Movie searches
+ assert loaded_server.lookup_media(MEDIA_TYPE_VIDEO, video_name="Movie") is None
assert loaded_server.lookup_media(MEDIA_TYPE_VIDEO, library_name="Movies") is None
assert loaded_server.lookup_media(
- MEDIA_TYPE_VIDEO, library_name="Movies", video_name="A Movie"
+ MEDIA_TYPE_VIDEO, library_name="Movies", video_name="Movie"
)
with patch.object(MockPlexLibrarySection, "get", side_effect=NotFound):
assert (
@@ -465,3 +469,47 @@ async def test_media_lookups(hass):
)
is None
)
+
+ # Movie searches
+ assert loaded_server.lookup_media(MEDIA_TYPE_MOVIE, title="Movie") is None
+ assert loaded_server.lookup_media(MEDIA_TYPE_MOVIE, library_name="Movies") is None
+ assert loaded_server.lookup_media(
+ MEDIA_TYPE_MOVIE, library_name="Movies", title="Movie"
+ )
+ with patch.object(MockPlexLibrarySection, "search", side_effect=BadRequest):
+ assert (
+ loaded_server.lookup_media(
+ MEDIA_TYPE_MOVIE, library_name="Movies", title="Not a Movie"
+ )
+ is None
+ )
+ with patch.object(MockPlexLibrarySection, "search", return_value=[]):
+ assert (
+ loaded_server.lookup_media(
+ MEDIA_TYPE_MOVIE, library_name="Movies", title="Not a Movie"
+ )
+ is None
+ )
+
+ similar_movies = []
+ for title in "Duplicate Movie", "Duplicate Movie 2":
+ similar_movies.append(MockPlexMediaItem(title))
+ with patch.object(
+ loaded_server.library.section("Movies"), "search", return_value=similar_movies
+ ):
+ found_media = loaded_server.lookup_media(
+ MEDIA_TYPE_MOVIE, library_name="Movies", title="Duplicate Movie"
+ )
+ assert found_media.title == "Duplicate Movie"
+
+ duplicate_movies = []
+ for title in "Duplicate Movie - Original", "Duplicate Movie - Remake":
+ duplicate_movies.append(MockPlexMediaItem(title))
+ with patch.object(
+ loaded_server.library.section("Movies"), "search", return_value=duplicate_movies
+ ):
+ assert (
+ loaded_server.lookup_media(
+ MEDIA_TYPE_MOVIE, library_name="Movies", title="Duplicate Movie"
+ )
+ ) is None
diff --git a/tests/components/plex/test_services.py b/tests/components/plex/test_services.py
new file mode 100644
index 00000000000..078ba3b97e9
--- /dev/null
+++ b/tests/components/plex/test_services.py
@@ -0,0 +1,128 @@
+"""Tests for various Plex services."""
+from homeassistant.components.plex.const import (
+ CONF_SERVER,
+ CONF_SERVER_IDENTIFIER,
+ DOMAIN,
+ PLEX_SERVER_CONFIG,
+ SERVICE_REFRESH_LIBRARY,
+ SERVICE_SCAN_CLIENTS,
+)
+from homeassistant.const import (
+ CONF_HOST,
+ CONF_PORT,
+ CONF_TOKEN,
+ CONF_URL,
+ CONF_VERIFY_SSL,
+)
+
+from .const import DEFAULT_DATA, DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN
+from .mock_classes import MockPlexAccount, MockPlexLibrarySection, MockPlexServer
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+
+async def test_refresh_library(hass):
+ """Test refresh_library service call."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=DEFAULT_DATA,
+ options=DEFAULT_OPTIONS,
+ unique_id=DEFAULT_DATA["server_id"],
+ )
+
+ mock_plex_server = MockPlexServer(config_entry=entry)
+
+ with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
+ "plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()
+ ), patch("homeassistant.components.plex.PlexWebsocket", autospec=True):
+ entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ # Test with non-existent server
+ with patch.object(MockPlexLibrarySection, "update") as mock_update:
+ assert await hass.services.async_call(
+ DOMAIN,
+ SERVICE_REFRESH_LIBRARY,
+ {"server_name": "Not a Server", "library_name": "Movies"},
+ True,
+ )
+ assert not mock_update.called
+
+ # Test with non-existent library
+ with patch.object(MockPlexLibrarySection, "update") as mock_update:
+ assert await hass.services.async_call(
+ DOMAIN,
+ SERVICE_REFRESH_LIBRARY,
+ {"library_name": "Not a Library"},
+ True,
+ )
+ assert not mock_update.called
+
+ # Test with valid library
+ with patch.object(MockPlexLibrarySection, "update") as mock_update:
+ assert await hass.services.async_call(
+ DOMAIN,
+ SERVICE_REFRESH_LIBRARY,
+ {"library_name": "Movies"},
+ True,
+ )
+ assert mock_update.called
+
+ # Add a second configured server
+ entry_2 = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ CONF_SERVER: MOCK_SERVERS[1][CONF_SERVER],
+ PLEX_SERVER_CONFIG: {
+ CONF_TOKEN: MOCK_TOKEN,
+ CONF_URL: f"https://{MOCK_SERVERS[1][CONF_HOST]}:{MOCK_SERVERS[1][CONF_PORT]}",
+ CONF_VERIFY_SSL: True,
+ },
+ CONF_SERVER_IDENTIFIER: MOCK_SERVERS[1][CONF_SERVER_IDENTIFIER],
+ },
+ )
+
+ mock_plex_server_2 = MockPlexServer(config_entry=entry_2)
+ with patch("plexapi.server.PlexServer", return_value=mock_plex_server_2), patch(
+ "plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()
+ ), patch("homeassistant.components.plex.PlexWebsocket", autospec=True):
+ entry_2.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry_2.entry_id)
+ await hass.async_block_till_done()
+
+ # Test multiple servers available but none specified
+ with patch.object(MockPlexLibrarySection, "update") as mock_update:
+ assert await hass.services.async_call(
+ DOMAIN,
+ SERVICE_REFRESH_LIBRARY,
+ {"library_name": "Movies"},
+ True,
+ )
+ assert not mock_update.called
+
+
+async def test_scan_clients(hass):
+ """Test scan_for_clients service call."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data=DEFAULT_DATA,
+ options=DEFAULT_OPTIONS,
+ unique_id=DEFAULT_DATA["server_id"],
+ )
+
+ mock_plex_server = MockPlexServer(config_entry=entry)
+
+ with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch(
+ "plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()
+ ), patch("homeassistant.components.plex.PlexWebsocket", autospec=True):
+ entry.add_to_hass(hass)
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert await hass.services.async_call(
+ DOMAIN,
+ SERVICE_SCAN_CLIENTS,
+ blocking=True,
+ )
diff --git a/tests/components/plugwise/common.py b/tests/components/plugwise/common.py
new file mode 100644
index 00000000000..eb227322aa8
--- /dev/null
+++ b/tests/components/plugwise/common.py
@@ -0,0 +1,26 @@
+"""Common initialisation for the Plugwise integration."""
+
+from homeassistant.components.plugwise import DOMAIN
+from homeassistant.core import HomeAssistant
+
+from tests.common import MockConfigEntry
+from tests.test_util.aiohttp import AiohttpClientMocker
+
+
+async def async_init_integration(
+ hass: HomeAssistant,
+ aioclient_mock: AiohttpClientMocker,
+ skip_setup: bool = False,
+):
+ """Initialize the Smile integration."""
+
+ entry = MockConfigEntry(
+ domain=DOMAIN, data={"host": "1.1.1.1", "password": "test-password"}
+ )
+ 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/plugwise/conftest.py b/tests/components/plugwise/conftest.py
new file mode 100644
index 00000000000..11e077c8a24
--- /dev/null
+++ b/tests/components/plugwise/conftest.py
@@ -0,0 +1,170 @@
+"""Setup mocks for the Plugwise integration tests."""
+
+from functools import partial
+import re
+
+from Plugwise_Smile.Smile import Smile
+import jsonpickle
+import pytest
+
+from tests.async_mock import AsyncMock, patch
+from tests.common import load_fixture
+from tests.test_util.aiohttp import AiohttpClientMocker
+
+
+def _read_json(environment, call):
+ """Undecode the json data."""
+ fixture = load_fixture(f"plugwise/{environment}/{call}.json")
+ return jsonpickle.decode(fixture)
+
+
+@pytest.fixture(name="mock_smile")
+def mock_smile():
+ """Create a Mock Smile for testing exceptions."""
+ with patch(
+ "homeassistant.components.plugwise.config_flow.Smile",
+ ) as smile_mock:
+ smile_mock.InvalidAuthentication = Smile.InvalidAuthentication
+ smile_mock.ConnectionFailedError = Smile.ConnectionFailedError
+ smile_mock.return_value.connect.return_value = True
+ yield smile_mock.return_value
+
+
+@pytest.fixture(name="mock_smile_unauth")
+def mock_smile_unauth(aioclient_mock: AiohttpClientMocker) -> None:
+ """Mock the Plugwise Smile unauthorized for Home Assistant."""
+ aioclient_mock.get(re.compile(".*"), status=401)
+ aioclient_mock.put(re.compile(".*"), status=401)
+
+
+@pytest.fixture(name="mock_smile_error")
+def mock_smile_error(aioclient_mock: AiohttpClientMocker) -> None:
+ """Mock the Plugwise Smile server failure for Home Assistant."""
+ aioclient_mock.get(re.compile(".*"), status=500)
+ aioclient_mock.put(re.compile(".*"), status=500)
+
+
+@pytest.fixture(name="mock_smile_notconnect")
+def mock_smile_notconnect():
+ """Mock the Plugwise Smile general connection failure for Home Assistant."""
+ with patch("homeassistant.components.plugwise.Smile") as smile_mock:
+ smile_mock.InvalidAuthentication = Smile.InvalidAuthentication
+ smile_mock.ConnectionFailedError = Smile.ConnectionFailedError
+ smile_mock.PlugwiseError = Smile.PlugwiseError
+ smile_mock.return_value.connect.side_effect = AsyncMock(return_value=False)
+ yield smile_mock.return_value
+
+
+def _get_device_data(chosen_env, device_id):
+ """Mock return data for specific devices."""
+ return _read_json(chosen_env, "get_device_data/" + device_id)
+
+
+@pytest.fixture(name="mock_smile_adam")
+def mock_smile_adam():
+ """Create a Mock Adam environment for testing exceptions."""
+ chosen_env = "adam_multiple_devices_per_zone"
+ with patch("homeassistant.components.plugwise.Smile") as smile_mock:
+ smile_mock.InvalidAuthentication = Smile.InvalidAuthentication
+ smile_mock.ConnectionFailedError = Smile.ConnectionFailedError
+ smile_mock.XMLDataMissingError = Smile.XMLDataMissingError
+
+ smile_mock.return_value.gateway_id = "fe799307f1624099878210aa0b9f1475"
+ smile_mock.return_value.heater_id = "90986d591dcd426cae3ec3e8111ff730"
+ smile_mock.return_value.smile_version = "3.0.15"
+ smile_mock.return_value.smile_type = "thermostat"
+ smile_mock.return_value.smile_hostname = "smile98765"
+
+ smile_mock.return_value.connect.side_effect = AsyncMock(return_value=True)
+ smile_mock.return_value.full_update_device.side_effect = AsyncMock(
+ return_value=True
+ )
+ smile_mock.return_value.set_schedule_state.side_effect = AsyncMock(
+ return_value=True
+ )
+ smile_mock.return_value.set_preset.side_effect = AsyncMock(return_value=True)
+ smile_mock.return_value.set_temperature.side_effect = AsyncMock(
+ return_value=True
+ )
+ smile_mock.return_value.set_relay_state.side_effect = AsyncMock(
+ return_value=True
+ )
+
+ smile_mock.return_value.get_all_devices.return_value = _read_json(
+ chosen_env, "get_all_devices"
+ )
+ smile_mock.return_value.get_device_data.side_effect = partial(
+ _get_device_data, chosen_env
+ )
+
+ yield smile_mock.return_value
+
+
+@pytest.fixture(name="mock_smile_anna")
+def mock_smile_anna():
+ """Create a Mock Anna environment for testing exceptions."""
+ chosen_env = "anna_heatpump"
+ with patch("homeassistant.components.plugwise.Smile") as smile_mock:
+ smile_mock.InvalidAuthentication = Smile.InvalidAuthentication
+ smile_mock.ConnectionFailedError = Smile.ConnectionFailedError
+ smile_mock.XMLDataMissingError = Smile.XMLDataMissingError
+
+ smile_mock.return_value.gateway_id = "015ae9ea3f964e668e490fa39da3870b"
+ smile_mock.return_value.heater_id = "1cbf783bb11e4a7c8a6843dee3a86927"
+ smile_mock.return_value.smile_version = "4.0.15"
+ smile_mock.return_value.smile_type = "thermostat"
+ smile_mock.return_value.smile_hostname = "smile98765"
+
+ smile_mock.return_value.connect.side_effect = AsyncMock(return_value=True)
+ smile_mock.return_value.full_update_device.side_effect = AsyncMock(
+ return_value=True
+ )
+ smile_mock.return_value.set_schedule_state.side_effect = AsyncMock(
+ return_value=True
+ )
+ smile_mock.return_value.set_preset.side_effect = AsyncMock(return_value=True)
+ smile_mock.return_value.set_temperature.side_effect = AsyncMock(
+ return_value=True
+ )
+ smile_mock.return_value.set_relay_state.side_effect = AsyncMock(
+ return_value=True
+ )
+
+ smile_mock.return_value.get_all_devices.return_value = _read_json(
+ chosen_env, "get_all_devices"
+ )
+ smile_mock.return_value.get_device_data.side_effect = partial(
+ _get_device_data, chosen_env
+ )
+
+ yield smile_mock.return_value
+
+
+@pytest.fixture(name="mock_smile_p1")
+def mock_smile_p1():
+ """Create a Mock P1 DSMR environment for testing exceptions."""
+ chosen_env = "p1v3_full_option"
+ with patch("homeassistant.components.plugwise.Smile") as smile_mock:
+ smile_mock.InvalidAuthentication = Smile.InvalidAuthentication
+ smile_mock.ConnectionFailedError = Smile.ConnectionFailedError
+ smile_mock.XMLDataMissingError = Smile.XMLDataMissingError
+
+ smile_mock.return_value.gateway_id = "e950c7d5e1ee407a858e2a8b5016c8b3"
+ smile_mock.return_value.heater_id = None
+ smile_mock.return_value.smile_version = "3.3.9"
+ smile_mock.return_value.smile_type = "power"
+ smile_mock.return_value.smile_hostname = "smile98765"
+
+ smile_mock.return_value.connect.side_effect = AsyncMock(return_value=True)
+ smile_mock.return_value.full_update_device.side_effect = AsyncMock(
+ return_value=True
+ )
+
+ smile_mock.return_value.get_all_devices.return_value = _read_json(
+ chosen_env, "get_all_devices"
+ )
+ smile_mock.return_value.get_device_data.side_effect = partial(
+ _get_device_data, chosen_env
+ )
+
+ yield smile_mock.return_value
diff --git a/tests/components/plugwise/test_binary_sensor.py b/tests/components/plugwise/test_binary_sensor.py
new file mode 100644
index 00000000000..b2221194d8e
--- /dev/null
+++ b/tests/components/plugwise/test_binary_sensor.py
@@ -0,0 +1,37 @@
+"""Tests for the Plugwise binary_sensor integration."""
+
+from homeassistant.config_entries import ENTRY_STATE_LOADED
+from homeassistant.const import STATE_OFF, STATE_ON
+
+from tests.components.plugwise.common import async_init_integration
+
+
+async def test_anna_climate_binary_sensor_entities(hass, mock_smile_anna):
+ """Test creation of climate related binary_sensor entities."""
+ entry = await async_init_integration(hass, mock_smile_anna)
+ assert entry.state == ENTRY_STATE_LOADED
+
+ state = hass.states.get("binary_sensor.auxiliary_slave_boiler_state")
+ assert str(state.state) == STATE_OFF
+
+ state = hass.states.get("binary_sensor.auxiliary_dhw_state")
+ assert str(state.state) == STATE_OFF
+
+
+async def test_anna_climate_binary_sensor_change(hass, mock_smile_anna):
+ """Test change of climate related binary_sensor entities."""
+ entry = await async_init_integration(hass, mock_smile_anna)
+ assert entry.state == ENTRY_STATE_LOADED
+
+ hass.states.async_set("binary_sensor.auxiliary_dhw_state", STATE_ON, {})
+ await hass.async_block_till_done()
+
+ state = hass.states.get("binary_sensor.auxiliary_dhw_state")
+ assert str(state.state) == STATE_ON
+
+ await hass.helpers.entity_component.async_update_entity(
+ "binary_sensor.auxiliary_dhw_state"
+ )
+
+ state = hass.states.get("binary_sensor.auxiliary_dhw_state")
+ assert str(state.state) == STATE_OFF
diff --git a/tests/components/plugwise/test_climate.py b/tests/components/plugwise/test_climate.py
new file mode 100644
index 00000000000..02375b49709
--- /dev/null
+++ b/tests/components/plugwise/test_climate.py
@@ -0,0 +1,161 @@
+"""Tests for the Plugwise Climate integration."""
+
+from homeassistant.config_entries import ENTRY_STATE_LOADED
+
+from tests.components.plugwise.common import async_init_integration
+
+
+async def test_adam_climate_entity_attributes(hass, mock_smile_adam):
+ """Test creation of adam climate device environment."""
+ entry = await async_init_integration(hass, mock_smile_adam)
+ assert entry.state == ENTRY_STATE_LOADED
+
+ state = hass.states.get("climate.zone_lisa_wk")
+ attrs = state.attributes
+
+ assert attrs["hvac_modes"] is None
+
+ assert "preset_modes" in attrs
+ assert "no_frost" in attrs["preset_modes"]
+ assert "home" in attrs["preset_modes"]
+
+ assert attrs["current_temperature"] == 20.9
+ assert attrs["temperature"] == 21.5
+
+ assert attrs["preset_mode"] == "home"
+
+ assert attrs["supported_features"] == 17
+
+ state = hass.states.get("climate.zone_thermostat_jessie")
+ attrs = state.attributes
+
+ assert attrs["hvac_modes"] is None
+
+ assert "preset_modes" in attrs
+ assert "no_frost" in attrs["preset_modes"]
+ assert "home" in attrs["preset_modes"]
+
+ assert attrs["current_temperature"] == 17.2
+ assert attrs["temperature"] == 15.0
+
+ assert attrs["preset_mode"] == "asleep"
+
+
+async def test_adam_climate_entity_climate_changes(hass, mock_smile_adam):
+ """Test handling of user requests in adam climate device environment."""
+ entry = await async_init_integration(hass, mock_smile_adam)
+ assert entry.state == ENTRY_STATE_LOADED
+
+ await hass.services.async_call(
+ "climate",
+ "set_temperature",
+ {"entity_id": "climate.zone_lisa_wk", "temperature": 25},
+ blocking=True,
+ )
+ state = hass.states.get("climate.zone_lisa_wk")
+ attrs = state.attributes
+
+ assert attrs["temperature"] == 25.0
+
+ await hass.services.async_call(
+ "climate",
+ "set_preset_mode",
+ {"entity_id": "climate.zone_lisa_wk", "preset_mode": "away"},
+ blocking=True,
+ )
+ state = hass.states.get("climate.zone_lisa_wk")
+ attrs = state.attributes
+
+ assert attrs["preset_mode"] == "away"
+
+ assert attrs["supported_features"] == 17
+
+ await hass.services.async_call(
+ "climate",
+ "set_temperature",
+ {"entity_id": "climate.zone_thermostat_jessie", "temperature": 25},
+ blocking=True,
+ )
+
+ state = hass.states.get("climate.zone_thermostat_jessie")
+ attrs = state.attributes
+
+ assert attrs["temperature"] == 25.0
+
+ await hass.services.async_call(
+ "climate",
+ "set_preset_mode",
+ {"entity_id": "climate.zone_thermostat_jessie", "preset_mode": "home"},
+ blocking=True,
+ )
+ state = hass.states.get("climate.zone_thermostat_jessie")
+ attrs = state.attributes
+
+ assert attrs["preset_mode"] == "home"
+
+
+async def test_anna_climate_entity_attributes(hass, mock_smile_anna):
+ """Test creation of anna climate device environment."""
+ entry = await async_init_integration(hass, mock_smile_anna)
+ assert entry.state == ENTRY_STATE_LOADED
+
+ state = hass.states.get("climate.anna")
+ attrs = state.attributes
+
+ assert "hvac_modes" in attrs
+ assert "heat_cool" in attrs["hvac_modes"]
+
+ assert "preset_modes" in attrs
+ assert "no_frost" in attrs["preset_modes"]
+ assert "home" in attrs["preset_modes"]
+
+ assert attrs["current_temperature"] == 23.3
+ assert attrs["temperature"] == 21.0
+
+ assert state.state == "auto"
+ assert attrs["hvac_action"] == "idle"
+ assert attrs["preset_mode"] == "home"
+
+ assert attrs["supported_features"] == 17
+
+
+async def test_anna_climate_entity_climate_changes(hass, mock_smile_anna):
+ """Test handling of user requests in anna climate device environment."""
+ entry = await async_init_integration(hass, mock_smile_anna)
+ assert entry.state == ENTRY_STATE_LOADED
+
+ await hass.services.async_call(
+ "climate",
+ "set_temperature",
+ {"entity_id": "climate.anna", "temperature": 25},
+ blocking=True,
+ )
+
+ state = hass.states.get("climate.anna")
+ attrs = state.attributes
+
+ assert attrs["temperature"] == 25.0
+
+ await hass.services.async_call(
+ "climate",
+ "set_preset_mode",
+ {"entity_id": "climate.anna", "preset_mode": "away"},
+ blocking=True,
+ )
+
+ state = hass.states.get("climate.anna")
+ attrs = state.attributes
+
+ assert attrs["preset_mode"] == "away"
+
+ await hass.services.async_call(
+ "climate",
+ "set_hvac_mode",
+ {"entity_id": "climate.anna", "hvac_mode": "heat_cool"},
+ blocking=True,
+ )
+
+ state = hass.states.get("climate.anna")
+ attrs = state.attributes
+
+ assert state.state == "heat_cool"
diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py
index feb695aae81..e0f4993df55 100644
--- a/tests/components/plugwise/test_config_flow.py
+++ b/tests/components/plugwise/test_config_flow.py
@@ -2,16 +2,35 @@
from Plugwise_Smile.Smile import Smile
import pytest
-from homeassistant import config_entries, setup
-from homeassistant.components.plugwise.const import DOMAIN
+from homeassistant import config_entries, data_entry_flow, setup
+from homeassistant.components.plugwise.const import DEFAULT_SCAN_INTERVAL, DOMAIN
+from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_SCAN_INTERVAL
-from tests.async_mock import patch
+from tests.async_mock import MagicMock, patch
+from tests.common import MockConfigEntry
+
+TEST_HOST = "1.1.1.1"
+TEST_HOSTNAME = "smileabcdef"
+TEST_PASSWORD = "test_password"
+TEST_DISCOVERY = {
+ "host": TEST_HOST,
+ "hostname": f"{TEST_HOSTNAME}.local.",
+ "server": f"{TEST_HOSTNAME}.local.",
+ "properties": {
+ "product": "smile",
+ "version": "1.2.3",
+ "hostname": f"{TEST_HOSTNAME}.local.",
+ },
+}
@pytest.fixture(name="mock_smile")
def mock_smile():
"""Create a Mock Smile for testing exceptions."""
- with patch("homeassistant.components.plugwise.config_flow.Smile",) as smile_mock:
+ with patch(
+ "homeassistant.components.plugwise.config_flow.Smile",
+ ) as smile_mock:
smile_mock.PlugwiseError = Smile.PlugwiseError
smile_mock.InvalidAuthentication = Smile.InvalidAuthentication
smile_mock.ConnectionFailedError = Smile.ConnectionFailedError
@@ -23,33 +42,104 @@ async def test_form(hass):
"""Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_USER}
+ DOMAIN, context={"source": SOURCE_USER}
)
- assert result["type"] == "form"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {}
with patch(
"homeassistant.components.plugwise.config_flow.Smile.connect",
return_value=True,
), patch(
- "homeassistant.components.plugwise.async_setup", return_value=True,
+ "homeassistant.components.plugwise.async_setup",
+ return_value=True,
) as mock_setup, patch(
- "homeassistant.components.plugwise.async_setup_entry", return_value=True,
+ "homeassistant.components.plugwise.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"host": "1.1.1.1", "password": "test-password"},
+ result["flow_id"],
+ {"host": TEST_HOST, "password": TEST_PASSWORD},
)
- assert result2["type"] == "create_entry"
- assert result2["data"] == {
- "host": "1.1.1.1",
- "password": "test-password",
- }
await hass.async_block_till_done()
+
+ assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result2["data"] == {
+ "host": TEST_HOST,
+ "password": TEST_PASSWORD,
+ }
+
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
+async def test_zeroconf_form(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=TEST_DISCOVERY,
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {}
+
+ with patch(
+ "homeassistant.components.plugwise.config_flow.Smile.connect",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.plugwise.async_setup",
+ return_value=True,
+ ) as mock_setup, patch(
+ "homeassistant.components.plugwise.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"password": TEST_PASSWORD},
+ )
+
+ await hass.async_block_till_done()
+
+ assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result2["data"] == {
+ "host": TEST_HOST,
+ "password": TEST_PASSWORD,
+ }
+
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+ result3 = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data=TEST_DISCOVERY,
+ )
+ assert result3["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result3["errors"] == {}
+
+ with patch(
+ "homeassistant.components.plugwise.config_flow.Smile.connect",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.plugwise.async_setup",
+ return_value=True,
+ ) as mock_setup, patch(
+ "homeassistant.components.plugwise.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result4 = await hass.config_entries.flow.async_configure(
+ result3["flow_id"],
+ {"password": TEST_PASSWORD},
+ )
+
+ await hass.async_block_till_done()
+
+ assert result4["type"] == "abort"
+ assert result4["reason"] == "already_configured"
+
+
async def test_form_invalid_auth(hass, mock_smile):
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
@@ -60,10 +150,11 @@ async def test_form_invalid_auth(hass, mock_smile):
mock_smile.gateway_id = "0a636a4fc1704ab4a24e4f7e37fb187a"
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"host": "1.1.1.1", "password": "test-password"},
+ result["flow_id"],
+ {"host": TEST_HOST, "password": TEST_PASSWORD},
)
- assert result2["type"] == "form"
+ assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["errors"] == {"base": "invalid_auth"}
@@ -77,8 +168,92 @@ async def test_form_cannot_connect(hass, mock_smile):
mock_smile.gateway_id = "0a636a4fc1704ab4a24e4f7e37fb187a"
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"host": "1.1.1.1", "password": "test-password"},
+ result["flow_id"],
+ {"host": TEST_HOST, "password": TEST_PASSWORD},
)
- assert result2["type"] == "form"
+ assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_other_problem(hass, mock_smile):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ mock_smile.connect.side_effect = TimeoutError
+ mock_smile.gateway_id = "0a636a4fc1704ab4a24e4f7e37fb187a"
+
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": TEST_HOST, "password": TEST_PASSWORD},
+ )
+
+ assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result2["errors"] == {"base": "unknown"}
+
+
+async def test_options_flow_power(hass, mock_smile) -> None:
+ """Test config flow options DSMR environments."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ title=CONF_NAME,
+ data={CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD},
+ options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL},
+ )
+
+ hass.data[DOMAIN] = {entry.entry_id: {"api": MagicMock(smile_type="power")}}
+ entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.plugwise.async_setup_entry", return_value=True
+ ):
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ result = await hass.config_entries.options.async_init(entry.entry_id)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={CONF_SCAN_INTERVAL: 10}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["data"] == {
+ CONF_SCAN_INTERVAL: 10,
+ }
+
+
+async def test_options_flow_thermo(hass, mock_smile) -> None:
+ """Test config flow options for thermostatic environments."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ title=CONF_NAME,
+ data={CONF_HOST: TEST_HOST, CONF_PASSWORD: TEST_PASSWORD},
+ options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL},
+ )
+
+ hass.data[DOMAIN] = {entry.entry_id: {"api": MagicMock(smile_type="thermostat")}}
+ entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.plugwise.async_setup_entry", return_value=True
+ ):
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ result = await hass.config_entries.options.async_init(entry.entry_id)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"], user_input={CONF_SCAN_INTERVAL: 60}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["data"] == {
+ CONF_SCAN_INTERVAL: 60,
+ }
diff --git a/tests/components/plugwise/test_init.py b/tests/components/plugwise/test_init.py
new file mode 100644
index 00000000000..713cd4930d7
--- /dev/null
+++ b/tests/components/plugwise/test_init.py
@@ -0,0 +1,45 @@
+"""Tests for the Plugwise Climate integration."""
+
+import asyncio
+
+from Plugwise_Smile.Smile import Smile
+
+from homeassistant.config_entries import (
+ ENTRY_STATE_SETUP_ERROR,
+ ENTRY_STATE_SETUP_RETRY,
+)
+
+from tests.components.plugwise.common import async_init_integration
+
+
+async def test_smile_unauthorized(hass, mock_smile_unauth):
+ """Test failing unauthorization by Smile."""
+ entry = await async_init_integration(hass, mock_smile_unauth)
+ assert entry.state == ENTRY_STATE_SETUP_ERROR
+
+
+async def test_smile_error(hass, mock_smile_error):
+ """Test server error handling by Smile."""
+ entry = await async_init_integration(hass, mock_smile_error)
+ assert entry.state == ENTRY_STATE_SETUP_RETRY
+
+
+async def test_smile_notconnect(hass, mock_smile_notconnect):
+ """Connection failure error handling by Smile."""
+ mock_smile_notconnect.connect.return_value = False
+ entry = await async_init_integration(hass, mock_smile_notconnect)
+ assert entry.state == ENTRY_STATE_SETUP_RETRY
+
+
+async def test_smile_timeout(hass, mock_smile_notconnect):
+ """Timeout error handling by Smile."""
+ mock_smile_notconnect.connect.side_effect = asyncio.TimeoutError
+ entry = await async_init_integration(hass, mock_smile_notconnect)
+ assert entry.state == ENTRY_STATE_SETUP_RETRY
+
+
+async def test_smile_adam_xmlerror(hass, mock_smile_adam):
+ """Detect malformed XML by Smile in Adam environment."""
+ mock_smile_adam.full_update_device.side_effect = Smile.XMLDataMissingError
+ entry = await async_init_integration(hass, mock_smile_adam)
+ assert entry.state == ENTRY_STATE_SETUP_RETRY
diff --git a/tests/components/plugwise/test_sensor.py b/tests/components/plugwise/test_sensor.py
new file mode 100644
index 00000000000..bc586120517
--- /dev/null
+++ b/tests/components/plugwise/test_sensor.py
@@ -0,0 +1,66 @@
+"""Tests for the Plugwise Sensor integration."""
+
+from homeassistant.config_entries import ENTRY_STATE_LOADED
+
+from tests.components.plugwise.common import async_init_integration
+
+
+async def test_adam_climate_sensor_entities(hass, mock_smile_adam):
+ """Test creation of climate related sensor entities."""
+ entry = await async_init_integration(hass, mock_smile_adam)
+ assert entry.state == ENTRY_STATE_LOADED
+
+ state = hass.states.get("sensor.adam_outdoor_temperature")
+ assert float(state.state) == 7.81
+
+ state = hass.states.get("sensor.cv_pomp_electricity_consumed")
+ assert float(state.state) == 35.6
+
+ state = hass.states.get("sensor.auxiliary_water_temperature")
+ assert float(state.state) == 70.0
+
+ state = hass.states.get("sensor.cv_pomp_electricity_consumed_interval")
+ assert float(state.state) == 7.37
+
+ await hass.helpers.entity_component.async_update_entity(
+ "sensor.zone_lisa_wk_battery"
+ )
+
+ state = hass.states.get("sensor.zone_lisa_wk_battery")
+ assert float(state.state) == 34
+
+
+async def test_anna_climate_sensor_entities(hass, mock_smile_anna):
+ """Test creation of climate related sensor entities."""
+ entry = await async_init_integration(hass, mock_smile_anna)
+ assert entry.state == ENTRY_STATE_LOADED
+
+ state = hass.states.get("sensor.auxiliary_outdoor_temperature")
+ assert float(state.state) == 18.0
+
+ state = hass.states.get("sensor.auxiliary_water_temperature")
+ assert float(state.state) == 29.1
+
+ state = hass.states.get("sensor.anna_illuminance")
+ assert float(state.state) == 86.0
+
+
+async def test_p1_dsmr_sensor_entities(hass, mock_smile_p1):
+ """Test creation of power related sensor entities."""
+ entry = await async_init_integration(hass, mock_smile_p1)
+ assert entry.state == ENTRY_STATE_LOADED
+
+ state = hass.states.get("sensor.p1_net_electricity_point")
+ assert float(state.state) == -2761.0
+
+ state = hass.states.get("sensor.p1_electricity_consumed_off_peak_cumulative")
+ assert int(state.state) == 551
+
+ state = hass.states.get("sensor.p1_electricity_produced_peak_point")
+ assert float(state.state) == 2761.0
+
+ state = hass.states.get("sensor.p1_electricity_consumed_peak_cumulative")
+ assert int(state.state) == 442
+
+ state = hass.states.get("sensor.p1_gas_consumed_cumulative")
+ assert float(state.state) == 584.9
diff --git a/tests/components/plugwise/test_switch.py b/tests/components/plugwise/test_switch.py
new file mode 100644
index 00000000000..a58ebf83caa
--- /dev/null
+++ b/tests/components/plugwise/test_switch.py
@@ -0,0 +1,50 @@
+"""Tests for the Plugwise switch integration."""
+
+from homeassistant.config_entries import ENTRY_STATE_LOADED
+
+from tests.components.plugwise.common import async_init_integration
+
+
+async def test_adam_climate_switch_entities(hass, mock_smile_adam):
+ """Test creation of climate related switch entities."""
+ entry = await async_init_integration(hass, mock_smile_adam)
+ assert entry.state == ENTRY_STATE_LOADED
+
+ state = hass.states.get("switch.cv_pomp")
+ assert str(state.state) == "on"
+
+ state = hass.states.get("switch.fibaro_hc2")
+ assert str(state.state) == "on"
+
+
+async def test_adam_climate_switch_changes(hass, mock_smile_adam):
+ """Test changing of climate related switch entities."""
+ entry = await async_init_integration(hass, mock_smile_adam)
+ assert entry.state == ENTRY_STATE_LOADED
+
+ await hass.services.async_call(
+ "switch",
+ "turn_off",
+ {"entity_id": "switch.cv_pomp"},
+ blocking=True,
+ )
+ state = hass.states.get("switch.cv_pomp")
+ assert str(state.state) == "off"
+
+ await hass.services.async_call(
+ "switch",
+ "toggle",
+ {"entity_id": "switch.fibaro_hc2"},
+ blocking=True,
+ )
+ state = hass.states.get("switch.fibaro_hc2")
+ assert str(state.state) == "off"
+
+ await hass.services.async_call(
+ "switch",
+ "toggle",
+ {"entity_id": "switch.fibaro_hc2"},
+ blocking=True,
+ )
+ state = hass.states.get("switch.fibaro_hc2")
+ assert str(state.state) == "on"
diff --git a/tests/components/plum_lightpad/test_config_flow.py b/tests/components/plum_lightpad/test_config_flow.py
index 9fc32f9872c..7f6196ef9b7 100644
--- a/tests/components/plum_lightpad/test_config_flow.py
+++ b/tests/components/plum_lightpad/test_config_flow.py
@@ -23,7 +23,8 @@ async def test_form(hass):
), patch(
"homeassistant.components.plum_lightpad.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.plum_lightpad.async_setup_entry", return_value=True,
+ "homeassistant.components.plum_lightpad.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -99,7 +100,8 @@ async def test_import(hass):
), patch(
"homeassistant.components.plum_lightpad.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.plum_lightpad.async_setup_entry", return_value=True,
+ "homeassistant.components.plum_lightpad.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
diff --git a/tests/components/plum_lightpad/test_init.py b/tests/components/plum_lightpad/test_init.py
index b92dbaf1aa5..139ab786d1d 100644
--- a/tests/components/plum_lightpad/test_init.py
+++ b/tests/components/plum_lightpad/test_init.py
@@ -23,7 +23,8 @@ async def test_async_setup_imports_from_config(hass: HomeAssistant):
with patch(
"homeassistant.components.plum_lightpad.utils.Plum.loadCloudData"
) as mock_loadCloudData, patch(
- "homeassistant.components.plum_lightpad.async_setup_entry", return_value=True,
+ "homeassistant.components.plum_lightpad.async_setup_entry",
+ return_value=True,
) as mock_async_setup_entry:
result = await async_setup_component(
hass,
diff --git a/tests/components/poolsense/test_config_flow.py b/tests/components/poolsense/test_config_flow.py
index b7499208121..c969ed9c416 100644
--- a/tests/components/poolsense/test_config_flow.py
+++ b/tests/components/poolsense/test_config_flow.py
@@ -20,7 +20,8 @@ async def test_show_form(hass):
async def test_invalid_credentials(hass):
"""Test we handle invalid credentials."""
with patch(
- "poolsense.PoolSense.test_poolsense_credentials", return_value=False,
+ "poolsense.PoolSense.test_poolsense_credentials",
+ return_value=False,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
diff --git a/tests/components/powerwall/test_binary_sensor.py b/tests/components/powerwall/test_binary_sensor.py
index 1cdda033c12..caf7519f598 100644
--- a/tests/components/powerwall/test_binary_sensor.py
+++ b/tests/components/powerwall/test_binary_sensor.py
@@ -18,7 +18,8 @@ async def test_sensors(hass):
"homeassistant.components.powerwall.config_flow.Powerwall",
return_value=mock_powerwall,
), patch(
- "homeassistant.components.powerwall.Powerwall", return_value=mock_powerwall,
+ "homeassistant.components.powerwall.Powerwall",
+ return_value=mock_powerwall,
):
assert await async_setup_component(hass, DOMAIN, _mock_get_config())
await hass.async_block_till_done()
diff --git a/tests/components/powerwall/test_config_flow.py b/tests/components/powerwall/test_config_flow.py
index eaf53f0beef..9f72f1900f0 100644
--- a/tests/components/powerwall/test_config_flow.py
+++ b/tests/components/powerwall/test_config_flow.py
@@ -28,10 +28,12 @@ async def test_form_source_user(hass):
), patch(
"homeassistant.components.powerwall.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.powerwall.async_setup_entry", return_value=True,
+ "homeassistant.components.powerwall.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_IP_ADDRESS: "1.2.3.4"},
+ result["flow_id"],
+ {CONF_IP_ADDRESS: "1.2.3.4"},
)
assert result2["type"] == "create_entry"
@@ -53,7 +55,8 @@ async def test_form_source_import(hass):
), patch(
"homeassistant.components.powerwall.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.powerwall.async_setup_entry", return_value=True,
+ "homeassistant.components.powerwall.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -82,7 +85,8 @@ async def test_form_cannot_connect(hass):
return_value=mock_powerwall,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_IP_ADDRESS: "1.2.3.4"},
+ result["flow_id"],
+ {CONF_IP_ADDRESS: "1.2.3.4"},
)
assert result2["type"] == "form"
@@ -102,7 +106,8 @@ async def test_form_wrong_version(hass):
return_value=mock_powerwall,
):
result3 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_IP_ADDRESS: "1.2.3.4"},
+ result["flow_id"],
+ {CONF_IP_ADDRESS: "1.2.3.4"},
)
assert result3["type"] == "form"
diff --git a/tests/components/powerwall/test_sensor.py b/tests/components/powerwall/test_sensor.py
index af2835ea679..0be7c12c5a8 100644
--- a/tests/components/powerwall/test_sensor.py
+++ b/tests/components/powerwall/test_sensor.py
@@ -1,7 +1,7 @@
"""The sensor tests for the powerwall platform."""
from homeassistant.components.powerwall.const import DOMAIN
-from homeassistant.const import UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE
from homeassistant.setup import async_setup_component
from .mocks import _mock_get_config, _mock_powerwall_with_fixtures
@@ -25,7 +25,8 @@ async def test_sensors(hass):
device_registry = await hass.helpers.device_registry.async_get_registry()
reg_device = device_registry.async_get_device(
- identifiers={("powerwall", "TG0123456789AB_TG9876543210BA")}, connections=set(),
+ identifiers={("powerwall", "TG0123456789AB_TG9876543210BA")},
+ connections=set(),
)
assert reg_device.model == "PowerWall 2 (GW1)"
assert reg_device.sw_version == "1.45.1"
@@ -103,7 +104,7 @@ async def test_sensors(hass):
state = hass.states.get("sensor.powerwall_charge")
assert state.state == "47"
expected_attributes = {
- "unit_of_measurement": UNIT_PERCENTAGE,
+ "unit_of_measurement": PERCENTAGE,
"friendly_name": "Powerwall Charge",
"device_class": "battery",
}
diff --git a/tests/components/progettihwsw/__init__.py b/tests/components/progettihwsw/__init__.py
new file mode 100644
index 00000000000..5049834cc10
--- /dev/null
+++ b/tests/components/progettihwsw/__init__.py
@@ -0,0 +1 @@
+"""Tests for the ProgettiHWSW Automation integration."""
diff --git a/tests/components/progettihwsw/test_config_flow.py b/tests/components/progettihwsw/test_config_flow.py
new file mode 100644
index 00000000000..7a0dbd692c0
--- /dev/null
+++ b/tests/components/progettihwsw/test_config_flow.py
@@ -0,0 +1,135 @@
+"""Test the ProgettiHWSW Automation config flow."""
+from homeassistant import config_entries, setup
+from homeassistant.components.progettihwsw.const import DOMAIN
+from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.data_entry_flow import (
+ RESULT_TYPE_ABORT,
+ RESULT_TYPE_CREATE_ENTRY,
+ RESULT_TYPE_FORM,
+)
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+mock_value_step_user = {
+ "title": "1R & 1IN Board",
+ "relay_count": 1,
+ "input_count": 1,
+ "is_old": False,
+}
+
+
+async def test_form(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["step_id"] == "user"
+ assert result["errors"] == {}
+
+ mock_value_step_rm = {
+ "relay_1": "bistable", # Mocking a single relay board instance.
+ }
+
+ with patch(
+ "homeassistant.components.progettihwsw.config_flow.ProgettiHWSWAPI.check_board",
+ return_value=mock_value_step_user,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "", CONF_PORT: 80},
+ )
+
+ assert result2["type"] == RESULT_TYPE_FORM
+ assert result2["step_id"] == "relay_modes"
+ assert result2["errors"] == {}
+
+ with patch(
+ "homeassistant.components.progettihwsw.async_setup",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.progettihwsw.async_setup_entry",
+ return_value=True,
+ ):
+ result3 = await hass.config_entries.flow.async_configure(
+ result2["flow_id"],
+ mock_value_step_rm,
+ )
+
+ assert result3["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result3["data"]
+ assert result3["data"]["title"] == "1R & 1IN Board"
+ assert result3["data"]["is_old"] is False
+ assert result3["data"]["relay_count"] == result3["data"]["input_count"] == 1
+
+
+async def test_form_cannot_connect(hass):
+ """Test we handle unexisting board."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ assert result["step_id"] == "user"
+
+ with patch(
+ "homeassistant.components.progettihwsw.config_flow.ProgettiHWSWAPI.check_board",
+ return_value=False,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "", CONF_PORT: 80},
+ )
+
+ assert result2["type"] == RESULT_TYPE_FORM
+ assert result2["step_id"] == "user"
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_existing_entry_exception(hass):
+ """Test we handle existing board."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ assert result["step_id"] == "user"
+
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ CONF_HOST: "",
+ CONF_PORT: 80,
+ },
+ )
+ entry.add_to_hass(hass)
+
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "", CONF_PORT: 80},
+ )
+
+ assert result2["type"] == RESULT_TYPE_ABORT
+ assert result2["reason"] == "already_configured"
+
+
+async def test_form_user_exception(hass):
+ """Test we handle unknown exception."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ assert result["step_id"] == "user"
+
+ with patch(
+ "homeassistant.components.progettihwsw.config_flow.validate_input",
+ side_effect=Exception,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {CONF_HOST: "", CONF_PORT: 80},
+ )
+
+ assert result2["type"] == RESULT_TYPE_FORM
+ assert result2["step_id"] == "user"
+ assert result2["errors"] == {"base": "unknown"}
diff --git a/tests/components/ps4/test_config_flow.py b/tests/components/ps4/test_config_flow.py
index c5e0623de5b..d7754cf1c8f 100644
--- a/tests/components/ps4/test_config_flow.py
+++ b/tests/components/ps4/test_config_flow.py
@@ -88,7 +88,8 @@ def location_info_fixture():
def ps4_setup_fixture():
"""Patch ps4 setup entry."""
with patch(
- "homeassistant.components.ps4.async_setup_entry", return_value=True,
+ "homeassistant.components.ps4.async_setup_entry",
+ return_value=True,
):
yield
@@ -329,7 +330,9 @@ async def test_0_pin(hass):
"""Test Pin with leading '0' is passed correctly."""
with patch("pyps4_2ndscreen.Helper.get_creds", return_value=MOCK_CREDS):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": "creds"}, data={},
+ DOMAIN,
+ context={"source": "creds"},
+ data={},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "mode"
diff --git a/tests/components/push/test_camera.py b/tests/components/push/test_camera.py
index 74d975fc57c..644db2b9dd5 100644
--- a/tests/components/push/test_camera.py
+++ b/tests/components/push/test_camera.py
@@ -12,7 +12,8 @@ from tests.common import async_fire_time_changed
async def test_bad_posting(hass, aiohttp_client):
"""Test that posting to wrong api endpoint fails."""
await async_process_ha_core_config(
- hass, {"external_url": "http://example.com"},
+ hass,
+ {"external_url": "http://example.com"},
)
await async_setup_component(
@@ -42,7 +43,8 @@ async def test_bad_posting(hass, aiohttp_client):
async def test_posting_url(hass, aiohttp_client):
"""Test that posting to api endpoint works."""
await async_process_ha_core_config(
- hass, {"external_url": "http://example.com"},
+ hass,
+ {"external_url": "http://example.com"},
)
await async_setup_component(
diff --git a/tests/components/qwikswitch/test_init.py b/tests/components/qwikswitch/test_init.py
index 6075174fa98..5a68a6e6e7b 100644
--- a/tests/components/qwikswitch/test_init.py
+++ b/tests/components/qwikswitch/test_init.py
@@ -270,7 +270,9 @@ async def test_button(hass, aioclient_mock, qs_devices):
button_pressed = Mock()
hass.bus.async_listen_once("qwikswitch.button.@a00002", button_pressed)
- listen_mock.queue_response(json={"id": "@a00002", "cmd": "TOGGLE"},)
+ listen_mock.queue_response(
+ json={"id": "@a00002", "cmd": "TOGGLE"},
+ )
await asyncio.sleep(0.01)
await hass.async_block_till_done()
button_pressed.assert_called_once()
diff --git a/tests/components/rachio/test_config_flow.py b/tests/components/rachio/test_config_flow.py
index 7cc3f272e7a..b107bd1514f 100644
--- a/tests/components/rachio/test_config_flow.py
+++ b/tests/components/rachio/test_config_flow.py
@@ -39,7 +39,8 @@ async def test_form(hass):
), patch(
"homeassistant.components.rachio.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.rachio.async_setup_entry", return_value=True,
+ "homeassistant.components.rachio.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -76,7 +77,8 @@ async def test_form_invalid_auth(hass):
"homeassistant.components.rachio.config_flow.Rachio", return_value=rachio_mock
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_API_KEY: "api_key"},
+ result["flow_id"],
+ {CONF_API_KEY: "api_key"},
)
assert result2["type"] == "form"
@@ -97,7 +99,8 @@ async def test_form_cannot_connect(hass):
"homeassistant.components.rachio.config_flow.Rachio", return_value=rachio_mock
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_API_KEY: "api_key"},
+ result["flow_id"],
+ {CONF_API_KEY: "api_key"},
)
assert result2["type"] == "form"
diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py
index 7b27bdf2f39..be4a5fd20fe 100644
--- a/tests/components/rainmachine/test_config_flow.py
+++ b/tests/components/rainmachine/test_config_flow.py
@@ -50,7 +50,8 @@ async def test_invalid_password(hass):
flow.context = {"source": SOURCE_USER}
with patch(
- "regenmaschine.client.Client.load_local", side_effect=RainMachineError,
+ "regenmaschine.client.Client.load_local",
+ side_effect=RainMachineError,
):
result = await flow.async_step_user(user_input=conf)
assert result["errors"] == {CONF_PASSWORD: "invalid_credentials"}
@@ -83,7 +84,8 @@ async def test_step_import(hass):
flow.context = {"source": SOURCE_USER}
with patch(
- "regenmaschine.client.Client.load_local", return_value=True,
+ "regenmaschine.client.Client.load_local",
+ return_value=True,
):
result = await flow.async_step_import(import_config=conf)
@@ -114,7 +116,8 @@ async def test_step_user(hass):
flow.context = {"source": SOURCE_USER}
with patch(
- "regenmaschine.client.Client.load_local", return_value=True,
+ "regenmaschine.client.Client.load_local",
+ return_value=True,
):
result = await flow.async_step_user(user_input=conf)
diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py
index 07bfcb0bf4f..4c25771398c 100644
--- a/tests/components/recorder/common.py
+++ b/tests/components/recorder/common.py
@@ -13,6 +13,7 @@ def wait_recording_done(hass):
trigger_db_commit(hass)
hass.block_till_done()
hass.data[recorder.DATA_INSTANCE].block_till_done()
+ hass.block_till_done()
def trigger_db_commit(hass):
diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py
index a4f70fc09a6..6116b383341 100644
--- a/tests/components/recorder/test_init.py
+++ b/tests/components/recorder/test_init.py
@@ -304,6 +304,7 @@ def test_recorder_setup_failure():
db_retry_wait=3,
entity_filter=CONFIG_SCHEMA({DOMAIN: {}}),
exclude_t=[],
+ db_integrity_check=False,
)
rec.start()
rec.join()
diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py
index 93fb6e51621..a93e3537905 100644
--- a/tests/components/recorder/test_purge.py
+++ b/tests/components/recorder/test_purge.py
@@ -10,6 +10,8 @@ from homeassistant.components.recorder.purge import purge_old_data
from homeassistant.components.recorder.util import session_scope
from homeassistant.util import dt as dt_util
+from .common import wait_recording_done
+
from tests.async_mock import patch
from tests.common import get_test_home_assistant, init_recorder_component
@@ -37,6 +39,7 @@ class TestRecorderPurge(unittest.TestCase):
self.hass.block_till_done()
self.hass.data[DATA_INSTANCE].block_till_done()
+ wait_recording_done(self.hass)
with recorder.session_scope(hass=self.hass) as session:
for event_id in range(6):
@@ -72,6 +75,7 @@ class TestRecorderPurge(unittest.TestCase):
self.hass.block_till_done()
self.hass.data[DATA_INSTANCE].block_till_done()
+ wait_recording_done(self.hass)
with recorder.session_scope(hass=self.hass) as session:
for event_id in range(6):
@@ -103,6 +107,7 @@ class TestRecorderPurge(unittest.TestCase):
self.hass.block_till_done()
self.hass.data[DATA_INSTANCE].block_till_done()
+ wait_recording_done(self.hass)
with recorder.session_scope(hass=self.hass) as session:
for rec_id in range(6):
@@ -183,6 +188,7 @@ class TestRecorderPurge(unittest.TestCase):
assert recorder_runs.count() == 7
self.hass.data[DATA_INSTANCE].block_till_done()
+ wait_recording_done(self.hass)
# run purge method - no service data, use defaults
self.hass.services.call("recorder", "purge")
@@ -190,6 +196,7 @@ class TestRecorderPurge(unittest.TestCase):
# Small wait for recorder thread
self.hass.data[DATA_INSTANCE].block_till_done()
+ wait_recording_done(self.hass)
# only purged old events
assert states.count() == 4
@@ -201,6 +208,7 @@ class TestRecorderPurge(unittest.TestCase):
# Small wait for recorder thread
self.hass.data[DATA_INSTANCE].block_till_done()
+ wait_recording_done(self.hass)
# we should only have 2 states left after purging
assert states.count() == 2
@@ -223,6 +231,7 @@ class TestRecorderPurge(unittest.TestCase):
self.hass.services.call("recorder", "purge", service_data=service_data)
self.hass.block_till_done()
self.hass.data[DATA_INSTANCE].block_till_done()
+ wait_recording_done(self.hass)
assert (
mock_logger.debug.mock_calls[5][1][0]
== "Vacuuming SQL DB to free space"
diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py
index 56f1e069a61..23ab7ff929d 100644
--- a/tests/components/recorder/test_util.py
+++ b/tests/components/recorder/test_util.py
@@ -1,10 +1,15 @@
"""Test util methods."""
+from datetime import timedelta
import os
+import sqlite3
import pytest
from homeassistant.components.recorder import util
from homeassistant.components.recorder.const import DATA_INSTANCE, SQLITE_URL_PREFIX
+from homeassistant.util import dt as dt_util
+
+from .common import wait_recording_done
from tests.async_mock import MagicMock, patch
from tests.common import get_test_home_assistant, init_recorder_component
@@ -50,7 +55,7 @@ def test_recorder_bad_execute(hass_recorder):
hass_recorder()
def to_native(validate_entity_id=True):
- """Rasie exception."""
+ """Raise exception."""
raise SQLAlchemyError()
mck1 = MagicMock()
@@ -64,25 +69,145 @@ def test_recorder_bad_execute(hass_recorder):
assert e_mock.call_count == 2
-def test_validate_or_move_away_sqlite_database(hass, tmpdir, caplog):
- """Ensure a malformed sqlite database is moved away."""
+def test_validate_or_move_away_sqlite_database_with_integrity_check(
+ hass, tmpdir, caplog
+):
+ """Ensure a malformed sqlite database is moved away.
+
+ A quick_check is run here
+ """
+
+ db_integrity_check = True
test_dir = tmpdir.mkdir("test_validate_or_move_away_sqlite_database")
test_db_file = f"{test_dir}/broken.db"
dburl = f"{SQLITE_URL_PREFIX}{test_db_file}"
- util.validate_sqlite_database(test_db_file) is True
+ util.validate_sqlite_database(test_db_file, db_integrity_check) is True
assert os.path.exists(test_db_file) is True
- assert util.validate_or_move_away_sqlite_database(dburl) is True
+ assert (
+ util.validate_or_move_away_sqlite_database(dburl, db_integrity_check) is False
+ )
_corrupt_db_file(test_db_file)
- assert util.validate_or_move_away_sqlite_database(dburl) is False
+ assert util.validate_sqlite_database(dburl, db_integrity_check) is False
+
+ assert (
+ util.validate_or_move_away_sqlite_database(dburl, db_integrity_check) is False
+ )
assert "corrupt or malformed" in caplog.text
- assert util.validate_or_move_away_sqlite_database(dburl) is True
+ assert util.validate_sqlite_database(dburl, db_integrity_check) is False
+
+ assert util.validate_or_move_away_sqlite_database(dburl, db_integrity_check) is True
+
+
+def test_validate_or_move_away_sqlite_database_without_integrity_check(
+ hass, tmpdir, caplog
+):
+ """Ensure a malformed sqlite database is moved away.
+
+ The quick_check is skipped, but we can still find
+ corruption if the whole database is unreadable
+ """
+
+ db_integrity_check = False
+
+ test_dir = tmpdir.mkdir("test_validate_or_move_away_sqlite_database")
+ test_db_file = f"{test_dir}/broken.db"
+ dburl = f"{SQLITE_URL_PREFIX}{test_db_file}"
+
+ util.validate_sqlite_database(test_db_file, db_integrity_check) is True
+
+ assert os.path.exists(test_db_file) is True
+ assert (
+ util.validate_or_move_away_sqlite_database(dburl, db_integrity_check) is False
+ )
+
+ _corrupt_db_file(test_db_file)
+
+ assert util.validate_sqlite_database(dburl, db_integrity_check) is False
+
+ assert (
+ util.validate_or_move_away_sqlite_database(dburl, db_integrity_check) is False
+ )
+
+ assert "corrupt or malformed" in caplog.text
+
+ assert util.validate_sqlite_database(dburl, db_integrity_check) is False
+
+ assert util.validate_or_move_away_sqlite_database(dburl, db_integrity_check) is True
+
+
+def test_last_run_was_recently_clean(hass_recorder):
+ """Test we can check if the last recorder run was recently clean."""
+ hass = hass_recorder()
+
+ cursor = hass.data[DATA_INSTANCE].engine.raw_connection().cursor()
+
+ assert util.last_run_was_recently_clean(cursor) is False
+
+ hass.data[DATA_INSTANCE]._close_run()
+ wait_recording_done(hass)
+
+ assert util.last_run_was_recently_clean(cursor) is True
+
+ thirty_min_future_time = dt_util.utcnow() + timedelta(minutes=30)
+
+ with patch(
+ "homeassistant.components.recorder.dt_util.utcnow",
+ return_value=thirty_min_future_time,
+ ):
+ assert util.last_run_was_recently_clean(cursor) is False
+
+
+def test_basic_sanity_check(hass_recorder):
+ """Test the basic sanity checks with a missing table."""
+ hass = hass_recorder()
+
+ cursor = hass.data[DATA_INSTANCE].engine.raw_connection().cursor()
+
+ assert util.basic_sanity_check(cursor) is True
+
+ cursor.execute("DROP TABLE states;")
+
+ with pytest.raises(sqlite3.DatabaseError):
+ util.basic_sanity_check(cursor)
+
+
+def test_combined_checks(hass_recorder):
+ """Run Checks on the open database."""
+ hass = hass_recorder()
+
+ db_integrity_check = False
+
+ cursor = hass.data[DATA_INSTANCE].engine.raw_connection().cursor()
+
+ assert (
+ util.run_checks_on_open_db("fake_db_path", cursor, db_integrity_check) is None
+ )
+
+ # We are patching recorder.util here in order
+ # to avoid creating the full database on disk
+ with patch("homeassistant.components.recorder.util.last_run_was_recently_clean"):
+ assert (
+ util.run_checks_on_open_db("fake_db_path", cursor, db_integrity_check)
+ is None
+ )
+
+ with patch(
+ "homeassistant.components.recorder.util.last_run_was_recently_clean",
+ side_effect=sqlite3.DatabaseError,
+ ), pytest.raises(sqlite3.DatabaseError):
+ util.run_checks_on_open_db("fake_db_path", cursor, db_integrity_check)
+
+ cursor.execute("DROP TABLE events;")
+
+ with pytest.raises(sqlite3.DatabaseError):
+ util.run_checks_on_open_db("fake_db_path", cursor, db_integrity_check)
def _corrupt_db_file(test_db_file):
diff --git a/tests/components/remote/test_device_action.py b/tests/components/remote/test_device_action.py
new file mode 100644
index 00000000000..89d0f0de6bf
--- /dev/null
+++ b/tests/components/remote/test_device_action.py
@@ -0,0 +1,143 @@
+"""The test for remote device automation."""
+import pytest
+
+import homeassistant.components.automation as automation
+from homeassistant.components.device_automation import (
+ _async_get_device_automations as async_get_device_automations,
+)
+from homeassistant.components.remote import DOMAIN
+from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON
+from homeassistant.helpers import device_registry
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ MockConfigEntry,
+ async_mock_service,
+ mock_device_registry,
+ mock_registry,
+)
+
+
+@pytest.fixture
+def device_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_device_registry(hass)
+
+
+@pytest.fixture
+def entity_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_registry(hass)
+
+
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock service."""
+ return async_mock_service(hass, "test", "automation")
+
+
+async def test_get_actions(hass, device_reg, entity_reg):
+ """Test we get the expected actions from a remote."""
+ config_entry = MockConfigEntry(domain="test", data={})
+ config_entry.add_to_hass(hass)
+ device_entry = device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
+ )
+ entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id)
+ expected_actions = [
+ {
+ "domain": DOMAIN,
+ "type": "turn_off",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ {
+ "domain": DOMAIN,
+ "type": "turn_on",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ {
+ "domain": DOMAIN,
+ "type": "toggle",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ ]
+ actions = await async_get_device_automations(hass, "action", device_entry.id)
+ assert actions == expected_actions
+
+
+async def test_action(hass, calls):
+ """Test for turn_on and turn_off actions."""
+ platform = getattr(hass.components, f"test.{DOMAIN}")
+
+ platform.init()
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+ await hass.async_block_till_done()
+
+ ent1, ent2, ent3 = platform.ENTITIES
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {"platform": "event", "event_type": "test_event1"},
+ "action": {
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": ent1.entity_id,
+ "type": "turn_off",
+ },
+ },
+ {
+ "trigger": {"platform": "event", "event_type": "test_event2"},
+ "action": {
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": ent1.entity_id,
+ "type": "turn_on",
+ },
+ },
+ {
+ "trigger": {"platform": "event", "event_type": "test_event3"},
+ "action": {
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": ent1.entity_id,
+ "type": "toggle",
+ },
+ },
+ ]
+ },
+ )
+ await hass.async_block_till_done()
+ assert hass.states.get(ent1.entity_id).state == STATE_ON
+ assert len(calls) == 0
+
+ hass.bus.async_fire("test_event1")
+ await hass.async_block_till_done()
+ assert hass.states.get(ent1.entity_id).state == STATE_OFF
+
+ hass.bus.async_fire("test_event1")
+ await hass.async_block_till_done()
+ assert hass.states.get(ent1.entity_id).state == STATE_OFF
+
+ hass.bus.async_fire("test_event2")
+ await hass.async_block_till_done()
+ assert hass.states.get(ent1.entity_id).state == STATE_ON
+
+ hass.bus.async_fire("test_event2")
+ await hass.async_block_till_done()
+ assert hass.states.get(ent1.entity_id).state == STATE_ON
+
+ hass.bus.async_fire("test_event3")
+ await hass.async_block_till_done()
+ assert hass.states.get(ent1.entity_id).state == STATE_OFF
+
+ hass.bus.async_fire("test_event3")
+ await hass.async_block_till_done()
+ assert hass.states.get(ent1.entity_id).state == STATE_ON
diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py
new file mode 100644
index 00000000000..2f01b4ab55f
--- /dev/null
+++ b/tests/components/remote/test_device_condition.py
@@ -0,0 +1,235 @@
+"""The test for remote device automation."""
+from datetime import timedelta
+
+import pytest
+
+import homeassistant.components.automation as automation
+from homeassistant.components.remote import DOMAIN
+from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON
+from homeassistant.helpers import device_registry
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+
+from tests.async_mock import patch
+from tests.common import (
+ MockConfigEntry,
+ async_get_device_automation_capabilities,
+ async_get_device_automations,
+ async_mock_service,
+ mock_device_registry,
+ mock_registry,
+)
+
+
+@pytest.fixture
+def device_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_device_registry(hass)
+
+
+@pytest.fixture
+def entity_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_registry(hass)
+
+
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock service."""
+ return async_mock_service(hass, "test", "automation")
+
+
+async def test_get_conditions(hass, device_reg, entity_reg):
+ """Test we get the expected conditions from a remote."""
+ config_entry = MockConfigEntry(domain="test", data={})
+ config_entry.add_to_hass(hass)
+ device_entry = device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
+ )
+ entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id)
+ expected_conditions = [
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "type": "is_off",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "type": "is_on",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ ]
+ conditions = await async_get_device_automations(hass, "condition", device_entry.id)
+ assert conditions == expected_conditions
+
+
+async def test_get_condition_capabilities(hass, device_reg, entity_reg):
+ """Test we get the expected capabilities from a remote condition."""
+ config_entry = MockConfigEntry(domain="test", data={})
+ config_entry.add_to_hass(hass)
+ device_entry = device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
+ )
+ entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id)
+ expected_capabilities = {
+ "extra_fields": [
+ {"name": "for", "optional": True, "type": "positive_time_period_dict"}
+ ]
+ }
+ conditions = await async_get_device_automations(hass, "condition", device_entry.id)
+ for condition in conditions:
+ capabilities = await async_get_device_automation_capabilities(
+ hass, "condition", condition
+ )
+ assert capabilities == expected_capabilities
+
+
+async def test_if_state(hass, calls):
+ """Test for turn_on and turn_off conditions."""
+ platform = getattr(hass.components, f"test.{DOMAIN}")
+
+ platform.init()
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+ await hass.async_block_till_done()
+
+ ent1, ent2, ent3 = platform.ENTITIES
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {"platform": "event", "event_type": "test_event1"},
+ "condition": [
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": ent1.entity_id,
+ "type": "is_on",
+ }
+ ],
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": "is_on {{ trigger.%s }}"
+ % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ },
+ },
+ },
+ {
+ "trigger": {"platform": "event", "event_type": "test_event2"},
+ "condition": [
+ {
+ "condition": "device",
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": ent1.entity_id,
+ "type": "is_off",
+ }
+ ],
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": "is_off {{ trigger.%s }}"
+ % "}} - {{ trigger.".join(("platform", "event.event_type"))
+ },
+ },
+ },
+ ]
+ },
+ )
+ await hass.async_block_till_done()
+ assert hass.states.get(ent1.entity_id).state == STATE_ON
+ assert len(calls) == 0
+
+ hass.bus.async_fire("test_event1")
+ hass.bus.async_fire("test_event2")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+ assert calls[0].data["some"] == "is_on event - test_event1"
+
+ hass.states.async_set(ent1.entity_id, STATE_OFF)
+ hass.bus.async_fire("test_event1")
+ hass.bus.async_fire("test_event2")
+ await hass.async_block_till_done()
+ assert len(calls) == 2
+ assert calls[1].data["some"] == "is_off event - test_event2"
+
+
+async def test_if_fires_on_for_condition(hass, calls):
+ """Test for firing if condition is on with delay."""
+ point1 = dt_util.utcnow()
+ point2 = point1 + timedelta(seconds=10)
+ point3 = point2 + timedelta(seconds=10)
+
+ platform = getattr(hass.components, f"test.{DOMAIN}")
+
+ platform.init()
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+ await hass.async_block_till_done()
+
+ ent1, ent2, ent3 = platform.ENTITIES
+
+ with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow:
+ mock_utcnow.return_value = point1
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {"platform": "event", "event_type": "test_event1"},
+ "condition": {
+ "condition": "device",
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": ent1.entity_id,
+ "type": "is_off",
+ "for": {"seconds": 5},
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": "is_off {{ trigger.%s }}"
+ % "}} - {{ trigger.".join(
+ ("platform", "event.event_type")
+ )
+ },
+ },
+ }
+ ]
+ },
+ )
+ await hass.async_block_till_done()
+ assert hass.states.get(ent1.entity_id).state == STATE_ON
+ assert len(calls) == 0
+
+ hass.bus.async_fire("test_event1")
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+ # Time travel 10 secs into the future
+ mock_utcnow.return_value = point2
+ hass.bus.async_fire("test_event1")
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+ hass.states.async_set(ent1.entity_id, STATE_OFF)
+ hass.bus.async_fire("test_event1")
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+ # Time travel 20 secs into the future
+ mock_utcnow.return_value = point3
+ hass.bus.async_fire("test_event1")
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+ assert calls[0].data["some"] == "is_off event - test_event1"
diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py
new file mode 100644
index 00000000000..73feb3ea08f
--- /dev/null
+++ b/tests/components/remote/test_device_trigger.py
@@ -0,0 +1,234 @@
+"""The test for remote device automation."""
+from datetime import timedelta
+
+import pytest
+
+import homeassistant.components.automation as automation
+from homeassistant.components.remote import DOMAIN
+from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON
+from homeassistant.helpers import device_registry
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+
+from tests.common import (
+ MockConfigEntry,
+ async_fire_time_changed,
+ async_get_device_automation_capabilities,
+ async_get_device_automations,
+ async_mock_service,
+ mock_device_registry,
+ mock_registry,
+)
+
+
+@pytest.fixture
+def device_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_device_registry(hass)
+
+
+@pytest.fixture
+def entity_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_registry(hass)
+
+
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock service."""
+ return async_mock_service(hass, "test", "automation")
+
+
+async def test_get_triggers(hass, device_reg, entity_reg):
+ """Test we get the expected triggers from a remote."""
+ config_entry = MockConfigEntry(domain="test", data={})
+ config_entry.add_to_hass(hass)
+ device_entry = device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
+ )
+ entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id)
+ expected_triggers = [
+ {
+ "platform": "device",
+ "domain": DOMAIN,
+ "type": "turned_off",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ {
+ "platform": "device",
+ "domain": DOMAIN,
+ "type": "turned_on",
+ "device_id": device_entry.id,
+ "entity_id": f"{DOMAIN}.test_5678",
+ },
+ ]
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ assert triggers == expected_triggers
+
+
+async def test_get_trigger_capabilities(hass, device_reg, entity_reg):
+ """Test we get the expected capabilities from a remote trigger."""
+ config_entry = MockConfigEntry(domain="test", data={})
+ config_entry.add_to_hass(hass)
+ device_entry = device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")},
+ )
+ entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id)
+ expected_capabilities = {
+ "extra_fields": [
+ {"name": "for", "optional": True, "type": "positive_time_period_dict"}
+ ]
+ }
+ triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
+ for trigger in triggers:
+ capabilities = await async_get_device_automation_capabilities(
+ hass, "trigger", trigger
+ )
+ assert capabilities == expected_capabilities
+
+
+async def test_if_fires_on_state_change(hass, calls):
+ """Test for turn_on and turn_off triggers firing."""
+ platform = getattr(hass.components, f"test.{DOMAIN}")
+
+ platform.init()
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+ await hass.async_block_till_done()
+
+ ent1, ent2, ent3 = platform.ENTITIES
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": ent1.entity_id,
+ "type": "turned_on",
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": "turn_on {{ trigger.%s }}"
+ % "}} - {{ trigger.".join(
+ (
+ "platform",
+ "entity_id",
+ "from_state.state",
+ "to_state.state",
+ "for",
+ )
+ )
+ },
+ },
+ },
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": ent1.entity_id,
+ "type": "turned_off",
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": "turn_off {{ trigger.%s }}"
+ % "}} - {{ trigger.".join(
+ (
+ "platform",
+ "entity_id",
+ "from_state.state",
+ "to_state.state",
+ "for",
+ )
+ )
+ },
+ },
+ },
+ ]
+ },
+ )
+ await hass.async_block_till_done()
+ assert hass.states.get(ent1.entity_id).state == STATE_ON
+ assert len(calls) == 0
+
+ hass.states.async_set(ent1.entity_id, STATE_OFF)
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+ assert calls[0].data["some"] == "turn_off device - {} - on - off - None".format(
+ ent1.entity_id
+ )
+
+ hass.states.async_set(ent1.entity_id, STATE_ON)
+ await hass.async_block_till_done()
+ assert len(calls) == 2
+ assert calls[1].data["some"] == "turn_on device - {} - off - on - None".format(
+ ent1.entity_id
+ )
+
+
+async def test_if_fires_on_state_change_with_for(hass, calls):
+ """Test for triggers firing with delay."""
+ platform = getattr(hass.components, f"test.{DOMAIN}")
+
+ platform.init()
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}})
+ await hass.async_block_till_done()
+
+ ent1, ent2, ent3 = platform.ENTITIES
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "platform": "device",
+ "domain": DOMAIN,
+ "device_id": "",
+ "entity_id": ent1.entity_id,
+ "type": "turned_off",
+ "for": {"seconds": 5},
+ },
+ "action": {
+ "service": "test.automation",
+ "data_template": {
+ "some": "turn_off {{ trigger.%s }}"
+ % "}} - {{ trigger.".join(
+ (
+ "platform",
+ "entity_id",
+ "from_state.state",
+ "to_state.state",
+ "for",
+ )
+ )
+ },
+ },
+ }
+ ]
+ },
+ )
+ await hass.async_block_till_done()
+ assert hass.states.get(ent1.entity_id).state == STATE_ON
+ assert len(calls) == 0
+
+ hass.states.async_set(ent1.entity_id, STATE_OFF)
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+ await hass.async_block_till_done()
+ assert calls[0].data["some"] == "turn_off device - {} - on - off - 0:00:05".format(
+ ent1.entity_id
+ )
diff --git a/tests/components/rest/test_notify.py b/tests/components/rest/test_notify.py
new file mode 100644
index 00000000000..49f3876b97c
--- /dev/null
+++ b/tests/components/rest/test_notify.py
@@ -0,0 +1,52 @@
+"""The tests for the rest.notify platform."""
+from os import path
+
+from homeassistant import config as hass_config
+import homeassistant.components.notify as notify
+from homeassistant.components.rest import DOMAIN
+from homeassistant.const import SERVICE_RELOAD
+from homeassistant.setup import async_setup_component
+
+from tests.async_mock import patch
+
+
+async def test_reload_notify(hass):
+ """Verify we can reload the notify service."""
+
+ assert await async_setup_component(
+ hass,
+ notify.DOMAIN,
+ {
+ notify.DOMAIN: [
+ {
+ "name": DOMAIN,
+ "platform": DOMAIN,
+ "resource": "http://127.0.0.1/off",
+ },
+ ]
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert hass.services.has_service(notify.DOMAIN, DOMAIN)
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "rest/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert not hass.services.has_service(notify.DOMAIN, DOMAIN)
+ assert hass.services.has_service(notify.DOMAIN, "rest_reloaded")
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py
index 90a8b8d361e..4351239064a 100644
--- a/tests/components/rest/test_sensor.py
+++ b/tests/components/rest/test_sensor.py
@@ -1,4 +1,5 @@
"""The tests for the REST sensor platform."""
+from os import path
import unittest
import pytest
@@ -8,12 +9,13 @@ from requests.exceptions import RequestException, Timeout
from requests.structures import CaseInsensitiveDict
import requests_mock
+from homeassistant import config as hass_config
import homeassistant.components.rest.sensor as rest
import homeassistant.components.sensor as sensor
-from homeassistant.const import DATA_MEGABYTES
+from homeassistant.const import DATA_MEGABYTES, SERVICE_RELOAD
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.config_validation import template
-from homeassistant.setup import setup_component
+from homeassistant.setup import async_setup_component, setup_component
from tests.async_mock import Mock, patch
from tests.common import assert_setup_component, get_test_home_assistant
@@ -629,7 +631,8 @@ class TestRestSensor(unittest.TestCase):
value_template.hass = self.hass
self.rest.update = Mock(
- "rest.RestData.update", side_effect=self.update_side_effect(None, None),
+ "rest.RestData.update",
+ side_effect=self.update_side_effect(None, None),
)
self.sensor = rest.RestSensor(
self.hass,
@@ -677,3 +680,52 @@ class TestRestData(unittest.TestCase):
"""Test update when a request exception occurs."""
self.rest.update()
assert self.rest.data is None
+
+
+async def test_reload(hass, requests_mock):
+ """Verify we can reload reset sensors."""
+
+ requests_mock.get("http://localhost", text="test data")
+
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": "rest",
+ "method": "GET",
+ "name": "mockrest",
+ "resource": "http://localhost",
+ }
+ },
+ )
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+ assert hass.states.get("sensor.mockrest")
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "rest/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ "rest",
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+ assert hass.states.get("sensor.mockreset") is None
+ assert hass.states.get("sensor.rollout")
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py
index bba6ac3dc89..065645fffd1 100644
--- a/tests/components/rest/test_switch.py
+++ b/tests/components/rest/test_switch.py
@@ -4,7 +4,17 @@ import asyncio
import aiohttp
import homeassistant.components.rest.switch as rest
-from homeassistant.const import HTTP_INTERNAL_SERVER_ERROR
+from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
+from homeassistant.const import (
+ CONF_HEADERS,
+ CONF_NAME,
+ CONF_PLATFORM,
+ CONF_RESOURCE,
+ CONTENT_TYPE_JSON,
+ HTTP_INTERNAL_SERVER_ERROR,
+ HTTP_NOT_FOUND,
+ HTTP_OK,
+)
from homeassistant.helpers.template import Template
from homeassistant.setup import setup_component
@@ -25,7 +35,7 @@ class TestRestSwitchSetup:
def test_setup_missing_config(self):
"""Test setup with configuration missing required entries."""
assert not asyncio.run_coroutine_threadsafe(
- rest.async_setup_platform(self.hass, {"platform": "rest"}, None),
+ rest.async_setup_platform(self.hass, {CONF_PLATFORM: rest.DOMAIN}, None),
self.hass.loop,
).result()
@@ -33,7 +43,9 @@ class TestRestSwitchSetup:
"""Test setup with resource missing schema."""
assert not asyncio.run_coroutine_threadsafe(
rest.async_setup_platform(
- self.hass, {"platform": "rest", "resource": "localhost"}, None
+ self.hass,
+ {CONF_PLATFORM: rest.DOMAIN, CONF_RESOURCE: "localhost"},
+ None,
),
self.hass.loop,
).result()
@@ -43,7 +55,9 @@ class TestRestSwitchSetup:
aioclient_mock.get("http://localhost", exc=aiohttp.ClientError)
assert not asyncio.run_coroutine_threadsafe(
rest.async_setup_platform(
- self.hass, {"platform": "rest", "resource": "http://localhost"}, None
+ self.hass,
+ {CONF_PLATFORM: rest.DOMAIN, CONF_RESOURCE: "http://localhost"},
+ None,
),
self.hass.loop,
).result()
@@ -53,41 +67,70 @@ class TestRestSwitchSetup:
aioclient_mock.get("http://localhost", exc=asyncio.TimeoutError())
assert not asyncio.run_coroutine_threadsafe(
rest.async_setup_platform(
- self.hass, {"platform": "rest", "resource": "http://localhost"}, None
+ self.hass,
+ {CONF_PLATFORM: rest.DOMAIN, CONF_RESOURCE: "http://localhost"},
+ None,
),
self.hass.loop,
).result()
def test_setup_minimum(self, aioclient_mock):
"""Test setup with minimum configuration."""
- aioclient_mock.get("http://localhost", status=200)
- with assert_setup_component(1, "switch"):
+ aioclient_mock.get("http://localhost", status=HTTP_OK)
+ with assert_setup_component(1, SWITCH_DOMAIN):
assert setup_component(
self.hass,
- "switch",
- {"switch": {"platform": "rest", "resource": "http://localhost"}},
+ SWITCH_DOMAIN,
+ {
+ SWITCH_DOMAIN: {
+ CONF_PLATFORM: rest.DOMAIN,
+ CONF_RESOURCE: "http://localhost",
+ }
+ },
)
assert aioclient_mock.call_count == 1
def test_setup(self, aioclient_mock):
"""Test setup with valid configuration."""
- aioclient_mock.get("http://localhost", status=200)
+ aioclient_mock.get("http://localhost", status=HTTP_OK)
assert setup_component(
self.hass,
- "switch",
+ SWITCH_DOMAIN,
{
- "switch": {
- "platform": "rest",
- "name": "foo",
- "resource": "http://localhost",
- "headers": {"Content-type": "application/json"},
- "body_on": "custom on text",
- "body_off": "custom off text",
+ SWITCH_DOMAIN: {
+ CONF_PLATFORM: rest.DOMAIN,
+ CONF_NAME: "foo",
+ CONF_RESOURCE: "http://localhost",
+ CONF_HEADERS: {"Content-type": CONTENT_TYPE_JSON},
+ rest.CONF_BODY_ON: "custom on text",
+ rest.CONF_BODY_OFF: "custom off text",
}
},
)
assert aioclient_mock.call_count == 1
- assert_setup_component(1, "switch")
+ assert_setup_component(1, SWITCH_DOMAIN)
+
+ def test_setup_with_state_resource(self, aioclient_mock):
+ """Test setup with valid configuration."""
+ aioclient_mock.get("http://localhost", status=HTTP_NOT_FOUND)
+ aioclient_mock.get("http://localhost/state", status=HTTP_OK)
+ assert setup_component(
+ self.hass,
+ SWITCH_DOMAIN,
+ {
+ SWITCH_DOMAIN: {
+ CONF_PLATFORM: rest.DOMAIN,
+ CONF_NAME: "foo",
+ CONF_RESOURCE: "http://localhost",
+ rest.CONF_STATE_RESOURCE: "http://localhost/state",
+ CONF_HEADERS: {"Content-type": "application/json"},
+ rest.CONF_BODY_ON: "custom on text",
+ rest.CONF_BODY_OFF: "custom off text",
+ }
+ },
+ )
+ assert aioclient_mock.call_count == 1
+ assert_setup_component(1, SWITCH_DOMAIN)
class TestRestSwitch:
@@ -99,6 +142,7 @@ class TestRestSwitch:
self.name = "foo"
self.method = "post"
self.resource = "http://localhost/"
+ self.state_resource = self.resource
self.headers = {"Content-type": "application/json"}
self.auth = None
self.body_on = Template("on", self.hass)
@@ -106,6 +150,7 @@ class TestRestSwitch:
self.switch = rest.RestSwitch(
self.name,
self.resource,
+ self.state_resource,
self.method,
self.headers,
self.auth,
@@ -131,7 +176,7 @@ class TestRestSwitch:
def test_turn_on_success(self, aioclient_mock):
"""Test turn_on."""
- aioclient_mock.post(self.resource, status=200)
+ aioclient_mock.post(self.resource, status=HTTP_OK)
asyncio.run_coroutine_threadsafe(
self.switch.async_turn_on(), self.hass.loop
).result()
@@ -160,7 +205,7 @@ class TestRestSwitch:
def test_turn_off_success(self, aioclient_mock):
"""Test turn_off."""
- aioclient_mock.post(self.resource, status=200)
+ aioclient_mock.post(self.resource, status=HTTP_OK)
asyncio.run_coroutine_threadsafe(
self.switch.async_turn_off(), self.hass.loop
).result()
diff --git a/tests/components/rflink/test_init.py b/tests/components/rflink/test_init.py
index fdc60ca7262..2ae3724ee66 100644
--- a/tests/components/rflink/test_init.py
+++ b/tests/components/rflink/test_init.py
@@ -337,6 +337,7 @@ async def test_race_condition(hass, monkeypatch):
async def test_not_connected(hass, monkeypatch):
"""Test Error when sending commands to a disconnected device."""
import pytest
+
from homeassistant.core import HomeAssistantError
test_device = RflinkCommand("DUMMY_DEVICE")
diff --git a/tests/components/rflink/test_sensor.py b/tests/components/rflink/test_sensor.py
index 77f911f0ed0..1468037b70d 100644
--- a/tests/components/rflink/test_sensor.py
+++ b/tests/components/rflink/test_sensor.py
@@ -12,7 +12,7 @@ from homeassistant.components.rflink import (
EVENT_KEY_SENSOR,
TMP_ENTITY,
)
-from homeassistant.const import STATE_UNKNOWN, TEMP_CELSIUS, UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE, STATE_UNKNOWN, TEMP_CELSIUS
from tests.components.rflink.test_init import mock_rflink
@@ -151,7 +151,7 @@ async def test_aliases(hass, monkeypatch):
"id": "test_alias_02_0",
"sensor": "humidity",
"value": 65,
- "unit": UNIT_PERCENTAGE,
+ "unit": PERCENTAGE,
}
)
await hass.async_block_till_done()
@@ -160,7 +160,7 @@ async def test_aliases(hass, monkeypatch):
updated_sensor = hass.states.get("sensor.test_02")
assert updated_sensor
assert updated_sensor.state == "65"
- assert updated_sensor.attributes["unit_of_measurement"] == UNIT_PERCENTAGE
+ assert updated_sensor.attributes["unit_of_measurement"] == PERCENTAGE
async def test_race_condition(hass, monkeypatch):
diff --git a/tests/components/rfxtrx/conftest.py b/tests/components/rfxtrx/conftest.py
index 94d440312b4..1eb39f00691 100644
--- a/tests/components/rfxtrx/conftest.py
+++ b/tests/components/rfxtrx/conftest.py
@@ -21,7 +21,8 @@ async def rfxtrx_fixture(hass):
async def _signal_event(packet_id):
event = rfxtrx.get_rfx_object(packet_id)
await hass.async_add_executor_job(
- rfx.event_callback, event,
+ rfx.event_callback,
+ event,
)
await hass.async_block_till_done()
@@ -38,7 +39,9 @@ async def rfxtrx_automatic_fixture(hass, rfxtrx):
"""Fixture that starts up with automatic additions."""
assert await async_setup_component(
- hass, "rfxtrx", {"rfxtrx": {"device": "abcd", "automatic_add": True}},
+ hass,
+ "rfxtrx",
+ {"rfxtrx": {"device": "abcd", "automatic_add": True}},
)
await hass.async_block_till_done()
await hass.async_start()
diff --git a/tests/components/rfxtrx/test_binary_sensor.py b/tests/components/rfxtrx/test_binary_sensor.py
index 11efcfb6510..ee757192aaf 100644
--- a/tests/components/rfxtrx/test_binary_sensor.py
+++ b/tests/components/rfxtrx/test_binary_sensor.py
@@ -69,6 +69,35 @@ async def test_one_pt2262(hass, rfxtrx):
assert state.state == "off"
+async def test_pt2262_unconfigured(hass, rfxtrx):
+ """Test with discovery for PT2262."""
+ assert await async_setup_component(
+ hass,
+ "rfxtrx",
+ {
+ "rfxtrx": {
+ "device": "abcd",
+ "devices": {
+ "0913000022670e013970": {},
+ "09130000226707013970": {},
+ },
+ }
+ },
+ )
+ await hass.async_block_till_done()
+ await hass.async_start()
+
+ state = hass.states.get("binary_sensor.pt2262_22670e")
+ assert state
+ assert state.state == "off" # probably aught to be unknown
+ assert state.attributes.get("friendly_name") == "PT2262 22670e"
+
+ state = hass.states.get("binary_sensor.pt2262_226707")
+ assert state
+ assert state.state == "off" # probably aught to be unknown
+ assert state.attributes.get("friendly_name") == "PT2262 226707"
+
+
@pytest.mark.parametrize(
"state,event",
[["on", "0b1100cd0213c7f230010f71"], ["off", "0b1100cd0213c7f230000f71"]],
@@ -262,3 +291,35 @@ async def test_light(hass, rfxtrx_automatic):
await rfxtrx.signal(EVENT_LIGHT_DETECTOR_DARK)
assert hass.states.get(entity_id).state == "off"
+
+
+async def test_pt2262_duplicate_id(hass, rfxtrx):
+ """Test with 1 sensor."""
+ assert await async_setup_component(
+ hass,
+ "rfxtrx",
+ {
+ "rfxtrx": {
+ "device": "abcd",
+ "devices": {
+ "0913000022670e013970": {
+ "data_bits": 4,
+ "command_on": 0xE,
+ "command_off": 0x7,
+ },
+ "09130000226707013970": {
+ "data_bits": 4,
+ "command_on": 0xE,
+ "command_off": 0x7,
+ },
+ },
+ }
+ },
+ )
+ await hass.async_block_till_done()
+ await hass.async_start()
+
+ state = hass.states.get("binary_sensor.pt2262_22670e")
+ assert state
+ assert state.state == "off" # probably aught to be unknown
+ assert state.attributes.get("friendly_name") == "PT2262 22670e"
diff --git a/tests/components/rfxtrx/test_cover.py b/tests/components/rfxtrx/test_cover.py
index ce4838cebf1..05ce26ebc10 100644
--- a/tests/components/rfxtrx/test_cover.py
+++ b/tests/components/rfxtrx/test_cover.py
@@ -114,3 +114,26 @@ async def test_discover_covers(hass, rfxtrx_automatic):
state = hass.states.get("cover.lightwaverf_siemens_f394ab_2")
assert state
assert state.state == "open"
+
+
+async def test_duplicate_cover(hass, rfxtrx):
+ """Test with 2 duplicate covers."""
+ assert await async_setup_component(
+ hass,
+ "rfxtrx",
+ {
+ "rfxtrx": {
+ "device": "abcd",
+ "devices": {
+ "0b1400cd0213c7f20d010f51": {},
+ "0b1400cd0213c7f20d010f50": {},
+ },
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get("cover.lightwaverf_siemens_0213c7_242")
+ assert state
+ assert state.state == "closed"
+ assert state.attributes.get("friendly_name") == "LightwaveRF, Siemens 0213c7:242"
diff --git a/tests/components/rfxtrx/test_sensor.py b/tests/components/rfxtrx/test_sensor.py
index d87cf1a71e2..18239550a85 100644
--- a/tests/components/rfxtrx/test_sensor.py
+++ b/tests/components/rfxtrx/test_sensor.py
@@ -2,7 +2,7 @@
import pytest
from homeassistant.components.rfxtrx.const import ATTR_EVENT
-from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE, TEMP_CELSIUS
from homeassistant.core import State
from homeassistant.setup import async_setup_component
@@ -81,7 +81,7 @@ async def test_one_sensor_no_datatype(hass, rfxtrx):
assert state
assert state.state == "unknown"
assert state.attributes.get("friendly_name") == f"{base_name} Humidity"
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
+ assert state.attributes.get("unit_of_measurement") == PERCENTAGE
state = hass.states.get(f"{base_id}_humidity_status")
assert state
@@ -99,7 +99,7 @@ async def test_one_sensor_no_datatype(hass, rfxtrx):
assert state
assert state.state == "unknown"
assert state.attributes.get("friendly_name") == f"{base_name} Battery numeric"
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
+ assert state.attributes.get("unit_of_measurement") == PERCENTAGE
async def test_several_sensors(hass, rfxtrx):
@@ -145,7 +145,7 @@ async def test_several_sensors(hass, rfxtrx):
state.attributes.get("friendly_name")
== "WT260,WT260H,WT440H,WT450,WT450H 06:01 Humidity"
)
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
+ assert state.attributes.get("unit_of_measurement") == PERCENTAGE
async def test_discover_sensor(hass, rfxtrx_automatic):
@@ -159,7 +159,7 @@ async def test_discover_sensor(hass, rfxtrx_automatic):
state = hass.states.get(f"{base_id}_humidity")
assert state
assert state.state == "27"
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
+ assert state.attributes.get("unit_of_measurement") == PERCENTAGE
state = hass.states.get(f"{base_id}_humidity_status")
assert state
@@ -179,7 +179,7 @@ async def test_discover_sensor(hass, rfxtrx_automatic):
state = hass.states.get(f"{base_id}_battery_numeric")
assert state
assert state.state == "90"
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
+ assert state.attributes.get("unit_of_measurement") == PERCENTAGE
# 2
await rfxtrx.signal("0a52080405020095240279")
@@ -188,7 +188,7 @@ async def test_discover_sensor(hass, rfxtrx_automatic):
assert state
assert state.state == "36"
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
+ assert state.attributes.get("unit_of_measurement") == PERCENTAGE
state = hass.states.get(f"{base_id}_humidity_status")
assert state
@@ -208,7 +208,7 @@ async def test_discover_sensor(hass, rfxtrx_automatic):
state = hass.states.get(f"{base_id}_battery_numeric")
assert state
assert state.state == "90"
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
+ assert state.attributes.get("unit_of_measurement") == PERCENTAGE
# 1 Update
await rfxtrx.signal("0a52085e070100b31b0279")
@@ -217,7 +217,7 @@ async def test_discover_sensor(hass, rfxtrx_automatic):
state = hass.states.get(f"{base_id}_humidity")
assert state
assert state.state == "27"
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
+ assert state.attributes.get("unit_of_measurement") == PERCENTAGE
state = hass.states.get(f"{base_id}_humidity_status")
assert state
@@ -237,7 +237,7 @@ async def test_discover_sensor(hass, rfxtrx_automatic):
state = hass.states.get(f"{base_id}_battery_numeric")
assert state
assert state.state == "90"
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
+ assert state.attributes.get("unit_of_measurement") == PERCENTAGE
assert len(hass.states.async_all()) == 10
diff --git a/tests/components/rfxtrx/test_switch.py b/tests/components/rfxtrx/test_switch.py
index 3256f303708..1fed6a65562 100644
--- a/tests/components/rfxtrx/test_switch.py
+++ b/tests/components/rfxtrx/test_switch.py
@@ -3,6 +3,7 @@ from unittest.mock import call
import pytest
+from homeassistant.components.rfxtrx import DOMAIN
from homeassistant.core import State
from homeassistant.setup import async_setup_component
@@ -151,3 +152,24 @@ async def test_discover_rfy_sun_switch(hass, rfxtrx_automatic):
state = hass.states.get("switch.rfy_030101_1")
assert state
assert state.state == "on"
+
+
+async def test_unknown_event_code(hass, rfxtrx):
+ """Test with 3 switches."""
+ assert await async_setup_component(
+ hass,
+ "rfxtrx",
+ {
+ "rfxtrx": {
+ "device": "abcd",
+ "devices": {"1234567890": {}},
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ conf_entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(conf_entries) == 1
+
+ entry = conf_entries[0]
+ assert entry.state == "loaded"
diff --git a/tests/components/ring/test_config_flow.py b/tests/components/ring/test_config_flow.py
index 57723d1ede7..1be0a05fc5e 100644
--- a/tests/components/ring/test_config_flow.py
+++ b/tests/components/ring/test_config_flow.py
@@ -23,7 +23,8 @@ async def test_form(hass):
), patch(
"homeassistant.components.ring.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.ring.async_setup_entry", return_value=True,
+ "homeassistant.components.ring.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
diff --git a/tests/components/risco/__init__.py b/tests/components/risco/__init__.py
new file mode 100644
index 00000000000..1a84a8d2399
--- /dev/null
+++ b/tests/components/risco/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Risco integration."""
diff --git a/tests/components/risco/test_alarm_control_panel.py b/tests/components/risco/test_alarm_control_panel.py
new file mode 100644
index 00000000000..bf7c971df54
--- /dev/null
+++ b/tests/components/risco/test_alarm_control_panel.py
@@ -0,0 +1,390 @@
+"""Tests for the Risco alarm control panel device."""
+import pytest
+
+from homeassistant.components.alarm_control_panel import DOMAIN as ALARM_DOMAIN
+from homeassistant.components.alarm_control_panel.const import (
+ SUPPORT_ALARM_ARM_AWAY,
+ SUPPORT_ALARM_ARM_CUSTOM_BYPASS,
+ SUPPORT_ALARM_ARM_HOME,
+ SUPPORT_ALARM_ARM_NIGHT,
+)
+from homeassistant.components.risco import CannotConnectError, UnauthorizedError
+from homeassistant.components.risco.const import DOMAIN
+from homeassistant.const import (
+ SERVICE_ALARM_ARM_AWAY,
+ SERVICE_ALARM_ARM_CUSTOM_BYPASS,
+ SERVICE_ALARM_ARM_HOME,
+ SERVICE_ALARM_ARM_NIGHT,
+ SERVICE_ALARM_DISARM,
+ STATE_ALARM_ARMED_AWAY,
+ STATE_ALARM_ARMED_CUSTOM_BYPASS,
+ STATE_ALARM_ARMED_HOME,
+ STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_ARMING,
+ STATE_ALARM_DISARMED,
+ STATE_ALARM_TRIGGERED,
+ STATE_UNKNOWN,
+)
+from homeassistant.helpers.entity_component import async_update_entity
+
+from .util import TEST_CONFIG, TEST_SITE_UUID, setup_risco
+
+from tests.async_mock import MagicMock, PropertyMock, patch
+from tests.common import MockConfigEntry
+
+FIRST_ENTITY_ID = "alarm_control_panel.risco_test_site_name_partition_0"
+SECOND_ENTITY_ID = "alarm_control_panel.risco_test_site_name_partition_1"
+
+CODES_REQUIRED_OPTIONS = {"code_arm_required": True, "code_disarm_required": True}
+TEST_RISCO_TO_HA = {
+ "arm": STATE_ALARM_ARMED_AWAY,
+ "partial_arm": STATE_ALARM_ARMED_HOME,
+ "A": STATE_ALARM_ARMED_HOME,
+ "B": STATE_ALARM_ARMED_HOME,
+ "C": STATE_ALARM_ARMED_NIGHT,
+ "D": STATE_ALARM_ARMED_NIGHT,
+}
+TEST_FULL_RISCO_TO_HA = {
+ **TEST_RISCO_TO_HA,
+ "D": STATE_ALARM_ARMED_CUSTOM_BYPASS,
+}
+TEST_HA_TO_RISCO = {
+ STATE_ALARM_ARMED_AWAY: "arm",
+ STATE_ALARM_ARMED_HOME: "partial_arm",
+ STATE_ALARM_ARMED_NIGHT: "C",
+}
+TEST_FULL_HA_TO_RISCO = {
+ **TEST_HA_TO_RISCO,
+ STATE_ALARM_ARMED_CUSTOM_BYPASS: "D",
+}
+CUSTOM_MAPPING_OPTIONS = {
+ "risco_states_to_ha": TEST_RISCO_TO_HA,
+ "ha_states_to_risco": TEST_HA_TO_RISCO,
+}
+
+FULL_CUSTOM_MAPPING = {
+ "risco_states_to_ha": TEST_FULL_RISCO_TO_HA,
+ "ha_states_to_risco": TEST_FULL_HA_TO_RISCO,
+}
+
+EXPECTED_FEATURES = (
+ SUPPORT_ALARM_ARM_AWAY | SUPPORT_ALARM_ARM_HOME | SUPPORT_ALARM_ARM_NIGHT
+)
+
+
+def _partition_mock():
+ return MagicMock(
+ triggered=False,
+ arming=False,
+ armed=False,
+ disarmed=False,
+ partially_armed=False,
+ )
+
+
+@pytest.fixture
+def two_part_alarm():
+ """Fixture to mock alarm with two partitions."""
+ partition_mocks = {0: _partition_mock(), 1: _partition_mock()}
+ alarm_mock = MagicMock()
+ with patch.object(
+ partition_mocks[0], "id", new_callable=PropertyMock(return_value=0)
+ ), patch.object(
+ partition_mocks[1], "id", new_callable=PropertyMock(return_value=1)
+ ), patch.object(
+ alarm_mock,
+ "partitions",
+ new_callable=PropertyMock(return_value=partition_mocks),
+ ), patch(
+ "homeassistant.components.risco.RiscoAPI.get_state",
+ return_value=alarm_mock,
+ ):
+ yield alarm_mock
+
+
+async def test_cannot_connect(hass):
+ """Test connection error."""
+
+ with patch(
+ "homeassistant.components.risco.RiscoAPI.login",
+ side_effect=CannotConnectError,
+ ):
+ config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG)
+ config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+ registry = await hass.helpers.entity_registry.async_get_registry()
+ assert not registry.async_is_registered(FIRST_ENTITY_ID)
+ assert not registry.async_is_registered(SECOND_ENTITY_ID)
+
+
+async def test_unauthorized(hass):
+ """Test unauthorized error."""
+
+ with patch(
+ "homeassistant.components.risco.RiscoAPI.login",
+ side_effect=UnauthorizedError,
+ ):
+ config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG)
+ config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+ registry = await hass.helpers.entity_registry.async_get_registry()
+ assert not registry.async_is_registered(FIRST_ENTITY_ID)
+ assert not registry.async_is_registered(SECOND_ENTITY_ID)
+
+
+async def test_setup(hass, two_part_alarm):
+ """Test entity setup."""
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ assert not registry.async_is_registered(FIRST_ENTITY_ID)
+ assert not registry.async_is_registered(SECOND_ENTITY_ID)
+
+ await setup_risco(hass)
+
+ assert registry.async_is_registered(FIRST_ENTITY_ID)
+ assert registry.async_is_registered(SECOND_ENTITY_ID)
+
+ registry = await hass.helpers.device_registry.async_get_registry()
+ device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_0")}, {})
+ assert device is not None
+ assert device.manufacturer == "Risco"
+
+ device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_1")}, {})
+ assert device is not None
+ assert device.manufacturer == "Risco"
+
+
+async def _check_state(hass, alarm, property, state, entity_id, partition_id):
+ with patch.object(alarm.partitions[partition_id], property, return_value=True):
+ await async_update_entity(hass, entity_id)
+ await hass.async_block_till_done()
+
+ assert hass.states.get(entity_id).state == state
+
+
+async def test_states(hass, two_part_alarm):
+ """Test the various alarm states."""
+ await setup_risco(hass, CUSTOM_MAPPING_OPTIONS)
+
+ assert hass.states.get(FIRST_ENTITY_ID).state == STATE_UNKNOWN
+ for partition_id, entity_id in {0: FIRST_ENTITY_ID, 1: SECOND_ENTITY_ID}.items():
+ await _check_state(
+ hass,
+ two_part_alarm,
+ "triggered",
+ STATE_ALARM_TRIGGERED,
+ entity_id,
+ partition_id,
+ )
+ await _check_state(
+ hass, two_part_alarm, "arming", STATE_ALARM_ARMING, entity_id, partition_id
+ )
+ await _check_state(
+ hass,
+ two_part_alarm,
+ "armed",
+ STATE_ALARM_ARMED_AWAY,
+ entity_id,
+ partition_id,
+ )
+ await _check_state(
+ hass,
+ two_part_alarm,
+ "partially_armed",
+ STATE_ALARM_ARMED_HOME,
+ entity_id,
+ partition_id,
+ )
+ await _check_state(
+ hass,
+ two_part_alarm,
+ "disarmed",
+ STATE_ALARM_DISARMED,
+ entity_id,
+ partition_id,
+ )
+
+ groups = {"A": False, "B": False, "C": True, "D": False}
+ with patch.object(
+ two_part_alarm.partitions[partition_id],
+ "groups",
+ new_callable=PropertyMock(return_value=groups),
+ ):
+ await _check_state(
+ hass,
+ two_part_alarm,
+ "partially_armed",
+ STATE_ALARM_ARMED_NIGHT,
+ entity_id,
+ partition_id,
+ )
+
+
+async def _test_service_call(
+ hass, service, method, entity_id, partition_id, *args, **kwargs
+):
+ with patch(f"homeassistant.components.risco.RiscoAPI.{method}") as set_mock:
+ await _call_alarm_service(hass, service, entity_id, **kwargs)
+ set_mock.assert_awaited_once_with(partition_id, *args)
+
+
+async def _test_no_service_call(
+ hass, service, method, entity_id, partition_id, **kwargs
+):
+ with patch(f"homeassistant.components.risco.RiscoAPI.{method}") as set_mock:
+ await _call_alarm_service(hass, service, entity_id, **kwargs)
+ set_mock.assert_not_awaited()
+
+
+async def _call_alarm_service(hass, service, entity_id, **kwargs):
+ data = {"entity_id": entity_id, **kwargs}
+
+ await hass.services.async_call(
+ ALARM_DOMAIN, service, service_data=data, blocking=True
+ )
+
+
+async def test_sets_custom_mapping(hass, two_part_alarm):
+ """Test settings the various modes when mapping some states."""
+ await setup_risco(hass, CUSTOM_MAPPING_OPTIONS)
+
+ registry = await hass.helpers.entity_registry.async_get_registry()
+ entity = registry.async_get(FIRST_ENTITY_ID)
+ assert entity.supported_features == EXPECTED_FEATURES
+
+ await _test_service_call(hass, SERVICE_ALARM_DISARM, "disarm", FIRST_ENTITY_ID, 0)
+ await _test_service_call(hass, SERVICE_ALARM_DISARM, "disarm", SECOND_ENTITY_ID, 1)
+ await _test_service_call(hass, SERVICE_ALARM_ARM_AWAY, "arm", FIRST_ENTITY_ID, 0)
+ await _test_service_call(hass, SERVICE_ALARM_ARM_AWAY, "arm", SECOND_ENTITY_ID, 1)
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_HOME, "partial_arm", FIRST_ENTITY_ID, 0
+ )
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_HOME, "partial_arm", SECOND_ENTITY_ID, 1
+ )
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", FIRST_ENTITY_ID, 0, "C"
+ )
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", SECOND_ENTITY_ID, 1, "C"
+ )
+
+
+async def test_sets_full_custom_mapping(hass, two_part_alarm):
+ """Test settings the various modes when mapping all states."""
+ await setup_risco(hass, FULL_CUSTOM_MAPPING)
+
+ registry = await hass.helpers.entity_registry.async_get_registry()
+ entity = registry.async_get(FIRST_ENTITY_ID)
+ assert (
+ entity.supported_features == EXPECTED_FEATURES | SUPPORT_ALARM_ARM_CUSTOM_BYPASS
+ )
+
+ await _test_service_call(hass, SERVICE_ALARM_DISARM, "disarm", FIRST_ENTITY_ID, 0)
+ await _test_service_call(hass, SERVICE_ALARM_DISARM, "disarm", SECOND_ENTITY_ID, 1)
+ await _test_service_call(hass, SERVICE_ALARM_ARM_AWAY, "arm", FIRST_ENTITY_ID, 0)
+ await _test_service_call(hass, SERVICE_ALARM_ARM_AWAY, "arm", SECOND_ENTITY_ID, 1)
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_HOME, "partial_arm", FIRST_ENTITY_ID, 0
+ )
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_HOME, "partial_arm", SECOND_ENTITY_ID, 1
+ )
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", FIRST_ENTITY_ID, 0, "C"
+ )
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", SECOND_ENTITY_ID, 1, "C"
+ )
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "group_arm", FIRST_ENTITY_ID, 0, "D"
+ )
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "group_arm", SECOND_ENTITY_ID, 1, "D"
+ )
+
+
+async def test_sets_with_correct_code(hass, two_part_alarm):
+ """Test settings the various modes when code is required."""
+ await setup_risco(hass, {**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS})
+
+ code = {"code": 1234}
+ await _test_service_call(
+ hass, SERVICE_ALARM_DISARM, "disarm", FIRST_ENTITY_ID, 0, **code
+ )
+ await _test_service_call(
+ hass, SERVICE_ALARM_DISARM, "disarm", SECOND_ENTITY_ID, 1, **code
+ )
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_AWAY, "arm", FIRST_ENTITY_ID, 0, **code
+ )
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_AWAY, "arm", SECOND_ENTITY_ID, 1, **code
+ )
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_HOME, "partial_arm", FIRST_ENTITY_ID, 0, **code
+ )
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_HOME, "partial_arm", SECOND_ENTITY_ID, 1, **code
+ )
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", FIRST_ENTITY_ID, 0, "C", **code
+ )
+ await _test_service_call(
+ hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", SECOND_ENTITY_ID, 1, "C", **code
+ )
+ await _test_no_service_call(
+ hass, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "partial_arm", FIRST_ENTITY_ID, 0, **code
+ )
+ await _test_no_service_call(
+ hass,
+ SERVICE_ALARM_ARM_CUSTOM_BYPASS,
+ "partial_arm",
+ SECOND_ENTITY_ID,
+ 1,
+ **code,
+ )
+
+
+async def test_sets_with_incorrect_code(hass, two_part_alarm):
+ """Test settings the various modes when code is required and incorrect."""
+ await setup_risco(hass, {**CUSTOM_MAPPING_OPTIONS, **CODES_REQUIRED_OPTIONS})
+
+ code = {"code": 4321}
+ await _test_no_service_call(
+ hass, SERVICE_ALARM_DISARM, "disarm", FIRST_ENTITY_ID, 0, **code
+ )
+ await _test_no_service_call(
+ hass, SERVICE_ALARM_DISARM, "disarm", SECOND_ENTITY_ID, 1, **code
+ )
+ await _test_no_service_call(
+ hass, SERVICE_ALARM_ARM_AWAY, "arm", FIRST_ENTITY_ID, 0, **code
+ )
+ await _test_no_service_call(
+ hass, SERVICE_ALARM_ARM_AWAY, "arm", SECOND_ENTITY_ID, 1, **code
+ )
+ await _test_no_service_call(
+ hass, SERVICE_ALARM_ARM_HOME, "partial_arm", FIRST_ENTITY_ID, 0, **code
+ )
+ await _test_no_service_call(
+ hass, SERVICE_ALARM_ARM_HOME, "partial_arm", SECOND_ENTITY_ID, 1, **code
+ )
+ await _test_no_service_call(
+ hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", FIRST_ENTITY_ID, 0, **code
+ )
+ await _test_no_service_call(
+ hass, SERVICE_ALARM_ARM_NIGHT, "group_arm", SECOND_ENTITY_ID, 1, **code
+ )
+ await _test_no_service_call(
+ hass, SERVICE_ALARM_ARM_CUSTOM_BYPASS, "partial_arm", FIRST_ENTITY_ID, 0, **code
+ )
+ await _test_no_service_call(
+ hass,
+ SERVICE_ALARM_ARM_CUSTOM_BYPASS,
+ "partial_arm",
+ SECOND_ENTITY_ID,
+ 1,
+ **code,
+ )
diff --git a/tests/components/risco/test_binary_sensor.py b/tests/components/risco/test_binary_sensor.py
new file mode 100644
index 00000000000..9aa56f64e51
--- /dev/null
+++ b/tests/components/risco/test_binary_sensor.py
@@ -0,0 +1,158 @@
+"""Tests for the Risco binary sensors."""
+import pytest
+
+from homeassistant.components.risco import CannotConnectError, UnauthorizedError
+from homeassistant.components.risco.const import DOMAIN
+from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.helpers.entity_component import async_update_entity
+
+from .util import TEST_CONFIG, TEST_SITE_UUID, setup_risco
+
+from tests.async_mock import MagicMock, PropertyMock, patch
+from tests.common import MockConfigEntry
+
+FIRST_ENTITY_ID = "binary_sensor.zone_0"
+SECOND_ENTITY_ID = "binary_sensor.zone_1"
+
+
+def _zone_mock():
+ return MagicMock(
+ triggered=False,
+ bypassed=False,
+ )
+
+
+@pytest.fixture
+def two_zone_alarm():
+ """Fixture to mock alarm with two zones."""
+ zone_mocks = {0: _zone_mock(), 1: _zone_mock()}
+ alarm_mock = MagicMock()
+ with patch.object(
+ zone_mocks[0], "id", new_callable=PropertyMock(return_value=0)
+ ), patch.object(
+ zone_mocks[0], "name", new_callable=PropertyMock(return_value="Zone 0")
+ ), patch.object(
+ zone_mocks[1], "id", new_callable=PropertyMock(return_value=1)
+ ), patch.object(
+ zone_mocks[1], "name", new_callable=PropertyMock(return_value="Zone 1")
+ ), patch.object(
+ alarm_mock,
+ "zones",
+ new_callable=PropertyMock(return_value=zone_mocks),
+ ), patch(
+ "homeassistant.components.risco.RiscoAPI.get_state",
+ return_value=alarm_mock,
+ ):
+ yield alarm_mock
+
+
+async def test_cannot_connect(hass):
+ """Test connection error."""
+
+ with patch(
+ "homeassistant.components.risco.RiscoAPI.login",
+ side_effect=CannotConnectError,
+ ):
+ config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG)
+ config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+ registry = await hass.helpers.entity_registry.async_get_registry()
+ assert not registry.async_is_registered(FIRST_ENTITY_ID)
+ assert not registry.async_is_registered(SECOND_ENTITY_ID)
+
+
+async def test_unauthorized(hass):
+ """Test unauthorized error."""
+
+ with patch(
+ "homeassistant.components.risco.RiscoAPI.login",
+ side_effect=UnauthorizedError,
+ ):
+ config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG)
+ config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+ registry = await hass.helpers.entity_registry.async_get_registry()
+ assert not registry.async_is_registered(FIRST_ENTITY_ID)
+ assert not registry.async_is_registered(SECOND_ENTITY_ID)
+
+
+async def test_setup(hass, two_zone_alarm):
+ """Test entity setup."""
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ assert not registry.async_is_registered(FIRST_ENTITY_ID)
+ assert not registry.async_is_registered(SECOND_ENTITY_ID)
+
+ await setup_risco(hass)
+
+ assert registry.async_is_registered(FIRST_ENTITY_ID)
+ assert registry.async_is_registered(SECOND_ENTITY_ID)
+
+ registry = await hass.helpers.device_registry.async_get_registry()
+ device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_zone_0")}, {})
+ assert device is not None
+ assert device.manufacturer == "Risco"
+
+ device = registry.async_get_device({(DOMAIN, TEST_SITE_UUID + "_zone_1")}, {})
+ assert device is not None
+ assert device.manufacturer == "Risco"
+
+
+async def _check_state(hass, alarm, triggered, bypassed, entity_id, zone_id):
+ with patch.object(
+ alarm.zones[zone_id],
+ "triggered",
+ new_callable=PropertyMock(return_value=triggered),
+ ), patch.object(
+ alarm.zones[zone_id],
+ "bypassed",
+ new_callable=PropertyMock(return_value=bypassed),
+ ):
+ await async_update_entity(hass, entity_id)
+ await hass.async_block_till_done()
+
+ expected_triggered = STATE_ON if triggered else STATE_OFF
+ assert hass.states.get(entity_id).state == expected_triggered
+ assert hass.states.get(entity_id).attributes["bypassed"] == bypassed
+
+
+async def test_states(hass, two_zone_alarm):
+ """Test the various alarm states."""
+ await setup_risco(hass)
+
+ await _check_state(hass, two_zone_alarm, True, True, FIRST_ENTITY_ID, 0)
+ await _check_state(hass, two_zone_alarm, True, False, FIRST_ENTITY_ID, 0)
+ await _check_state(hass, two_zone_alarm, False, True, FIRST_ENTITY_ID, 0)
+ await _check_state(hass, two_zone_alarm, False, False, FIRST_ENTITY_ID, 0)
+ await _check_state(hass, two_zone_alarm, True, True, SECOND_ENTITY_ID, 1)
+ await _check_state(hass, two_zone_alarm, True, False, SECOND_ENTITY_ID, 1)
+ await _check_state(hass, two_zone_alarm, False, True, SECOND_ENTITY_ID, 1)
+ await _check_state(hass, two_zone_alarm, False, False, SECOND_ENTITY_ID, 1)
+
+
+async def test_bypass(hass, two_zone_alarm):
+ """Test bypassing a zone."""
+ await setup_risco(hass)
+ with patch("homeassistant.components.risco.RiscoAPI.bypass_zone") as mock:
+ data = {"entity_id": FIRST_ENTITY_ID}
+
+ await hass.services.async_call(
+ DOMAIN, "bypass_zone", service_data=data, blocking=True
+ )
+
+ mock.assert_awaited_once_with(0, True)
+
+
+async def test_unbypass(hass, two_zone_alarm):
+ """Test unbypassing a zone."""
+ await setup_risco(hass)
+ with patch("homeassistant.components.risco.RiscoAPI.bypass_zone") as mock:
+ data = {"entity_id": FIRST_ENTITY_ID}
+
+ await hass.services.async_call(
+ DOMAIN, "unbypass_zone", service_data=data, blocking=True
+ )
+
+ mock.assert_awaited_once_with(0, False)
diff --git a/tests/components/risco/test_config_flow.py b/tests/components/risco/test_config_flow.py
new file mode 100644
index 00000000000..47fd0927cb1
--- /dev/null
+++ b/tests/components/risco/test_config_flow.py
@@ -0,0 +1,235 @@
+"""Test the Risco config flow."""
+import pytest
+import voluptuous as vol
+
+from homeassistant import config_entries, data_entry_flow
+from homeassistant.components.risco.config_flow import (
+ CannotConnectError,
+ UnauthorizedError,
+)
+from homeassistant.components.risco.const import DOMAIN
+
+from tests.async_mock import PropertyMock, patch
+from tests.common import MockConfigEntry
+
+TEST_SITE_NAME = "test-site-name"
+TEST_DATA = {
+ "username": "test-username",
+ "password": "test-password",
+ "pin": "1234",
+}
+
+TEST_RISCO_TO_HA = {
+ "arm": "armed_away",
+ "partial_arm": "armed_home",
+ "A": "armed_home",
+ "B": "armed_home",
+ "C": "armed_night",
+ "D": "armed_night",
+}
+
+TEST_HA_TO_RISCO = {
+ "armed_away": "arm",
+ "armed_home": "partial_arm",
+ "armed_night": "C",
+}
+
+TEST_OPTIONS = {
+ "scan_interval": 10,
+ "code_arm_required": True,
+ "code_disarm_required": True,
+}
+
+
+async def test_form(hass):
+ """Test we get the form."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch(
+ "homeassistant.components.risco.config_flow.RiscoAPI.login",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.risco.config_flow.RiscoAPI.site_name",
+ new_callable=PropertyMock(return_value=TEST_SITE_NAME),
+ ), patch(
+ "homeassistant.components.risco.config_flow.RiscoAPI.close"
+ ) as mock_close, patch(
+ "homeassistant.components.risco.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.risco.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_DATA
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == TEST_SITE_NAME
+ assert result2["data"] == TEST_DATA
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+ mock_close.assert_awaited_once()
+
+
+async def test_form_invalid_auth(hass):
+ """Test we handle invalid auth."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.risco.config_flow.RiscoAPI.login",
+ side_effect=UnauthorizedError,
+ ), patch("homeassistant.components.risco.config_flow.RiscoAPI.close") as mock_close:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_DATA
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "invalid_auth"}
+ mock_close.assert_awaited_once()
+
+
+async def test_form_cannot_connect(hass):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.risco.config_flow.RiscoAPI.login",
+ side_effect=CannotConnectError,
+ ), patch("homeassistant.components.risco.config_flow.RiscoAPI.close") as mock_close:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_DATA
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
+ mock_close.assert_awaited_once()
+
+
+async def test_form_exception(hass):
+ """Test we handle unknown exception."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.risco.config_flow.RiscoAPI.login",
+ side_effect=Exception,
+ ), patch("homeassistant.components.risco.config_flow.RiscoAPI.close") as mock_close:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_DATA
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "unknown"}
+ mock_close.assert_awaited_once()
+
+
+async def test_form_already_exists(hass):
+ """Test that a flow with an existing username aborts."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id=TEST_DATA["username"],
+ data=TEST_DATA,
+ )
+
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_DATA
+ )
+
+ assert result2["type"] == "abort"
+ assert result2["reason"] == "already_configured"
+
+
+async def test_options_flow(hass):
+ """Test options flow."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id=TEST_DATA["username"],
+ data=TEST_DATA,
+ )
+
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.options.async_init(entry.entry_id)
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input=TEST_OPTIONS,
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "risco_to_ha"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input=TEST_RISCO_TO_HA,
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "ha_to_risco"
+
+ with patch("homeassistant.components.risco.async_setup_entry", return_value=True):
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input=TEST_HA_TO_RISCO,
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert entry.options == {
+ **TEST_OPTIONS,
+ "risco_states_to_ha": TEST_RISCO_TO_HA,
+ "ha_states_to_risco": TEST_HA_TO_RISCO,
+ }
+
+
+async def test_ha_to_risco_schema(hass):
+ """Test that the schema for the ha-to-risco mapping step is generated properly."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id=TEST_DATA["username"],
+ data=TEST_DATA,
+ )
+
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.options.async_init(entry.entry_id)
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input=TEST_OPTIONS,
+ )
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input=TEST_RISCO_TO_HA,
+ )
+
+ # Test an HA state that isn't used
+ with pytest.raises(vol.error.MultipleInvalid):
+ await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={**TEST_HA_TO_RISCO, "armed_custom_bypass": "D"},
+ )
+
+ # Test a combo that can't be selected
+ with pytest.raises(vol.error.MultipleInvalid):
+ await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={**TEST_HA_TO_RISCO, "armed_night": "A"},
+ )
diff --git a/tests/components/risco/test_sensor.py b/tests/components/risco/test_sensor.py
new file mode 100644
index 00000000000..cc76c0b970b
--- /dev/null
+++ b/tests/components/risco/test_sensor.py
@@ -0,0 +1,207 @@
+"""Tests for the Risco event sensors."""
+import pytest
+
+from homeassistant.components.risco import (
+ LAST_EVENT_TIMESTAMP_KEY,
+ CannotConnectError,
+ UnauthorizedError,
+)
+from homeassistant.components.risco.const import DOMAIN, EVENTS_COORDINATOR
+
+from .util import TEST_CONFIG, setup_risco
+
+from tests.async_mock import MagicMock, patch
+from tests.common import MockConfigEntry
+
+ENTITY_IDS = {
+ "Alarm": "sensor.risco_test_site_name_alarm_events",
+ "Status": "sensor.risco_test_site_name_status_events",
+ "Trouble": "sensor.risco_test_site_name_trouble_events",
+ "Other": "sensor.risco_test_site_name_other_events",
+}
+
+TEST_EVENTS = [
+ MagicMock(
+ time="2020-09-02T10:00:00Z",
+ category_id=4,
+ category_name="System Status",
+ type_id=16,
+ type_name="disarmed",
+ name="'user' disarmed 'partition'",
+ text="",
+ partition_id=0,
+ zone_id=None,
+ user_id=3,
+ group=None,
+ priority=2,
+ raw={},
+ ),
+ MagicMock(
+ time="2020-09-02T09:00:00Z",
+ category_id=7,
+ category_name="Troubles",
+ type_id=36,
+ type_name="service needed",
+ name="Device Fault",
+ text="Service is needed.",
+ partition_id=None,
+ zone_id=None,
+ user_id=None,
+ group=None,
+ priority=1,
+ raw={},
+ ),
+ MagicMock(
+ time="2020-09-02T08:00:00Z",
+ category_id=2,
+ category_name="Alarms",
+ type_id=3,
+ type_name="triggered",
+ name="Alarm is on",
+ text="Yes it is.",
+ partition_id=0,
+ zone_id=12,
+ user_id=None,
+ group=None,
+ priority=0,
+ raw={},
+ ),
+ MagicMock(
+ time="2020-09-02T07:00:00Z",
+ category_id=4,
+ category_name="System Status",
+ type_id=119,
+ type_name="group arm",
+ name="You armed a group",
+ text="",
+ partition_id=0,
+ zone_id=None,
+ user_id=1,
+ group="C",
+ priority=2,
+ raw={},
+ ),
+ MagicMock(
+ time="2020-09-02T06:00:00Z",
+ category_id=8,
+ category_name="Made up",
+ type_id=200,
+ type_name="also made up",
+ name="really made up",
+ text="",
+ partition_id=2,
+ zone_id=None,
+ user_id=1,
+ group=None,
+ priority=2,
+ raw={},
+ ),
+]
+
+CATEGORIES_TO_EVENTS = {
+ "Alarm": 2,
+ "Status": 0,
+ "Trouble": 1,
+ "Other": 4,
+}
+
+
+@pytest.fixture
+def emptry_alarm():
+ """Fixture to mock an empty alarm."""
+ with patch(
+ "homeassistant.components.risco.RiscoAPI.get_state",
+ return_value=MagicMock(paritions={}, zones={}),
+ ):
+ yield
+
+
+async def test_cannot_connect(hass):
+ """Test connection error."""
+
+ with patch(
+ "homeassistant.components.risco.RiscoAPI.login",
+ side_effect=CannotConnectError,
+ ):
+ config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG)
+ config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ registry = await hass.helpers.entity_registry.async_get_registry()
+ for id in ENTITY_IDS.values():
+ assert not registry.async_is_registered(id)
+
+
+async def test_unauthorized(hass):
+ """Test unauthorized error."""
+
+ with patch(
+ "homeassistant.components.risco.RiscoAPI.login",
+ side_effect=UnauthorizedError,
+ ):
+ config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG)
+ config_entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ registry = await hass.helpers.entity_registry.async_get_registry()
+ for id in ENTITY_IDS.values():
+ assert not registry.async_is_registered(id)
+
+
+def _check_state(hass, category, entity_id):
+ event = TEST_EVENTS[CATEGORIES_TO_EVENTS[category]]
+ assert hass.states.get(entity_id).state == event.time
+ assert hass.states.get(entity_id).attributes["category_id"] == event.category_id
+ assert hass.states.get(entity_id).attributes["category_name"] == event.category_name
+ assert hass.states.get(entity_id).attributes["type_id"] == event.type_id
+ assert hass.states.get(entity_id).attributes["type_name"] == event.type_name
+ assert hass.states.get(entity_id).attributes["name"] == event.name
+ assert hass.states.get(entity_id).attributes["text"] == event.text
+ assert hass.states.get(entity_id).attributes["partition_id"] == event.partition_id
+ assert hass.states.get(entity_id).attributes["zone_id"] == event.zone_id
+ assert hass.states.get(entity_id).attributes["user_id"] == event.user_id
+ assert hass.states.get(entity_id).attributes["group"] == event.group
+ assert hass.states.get(entity_id).attributes["priority"] == event.priority
+ assert hass.states.get(entity_id).attributes["raw"] == event.raw
+
+
+async def test_setup(hass, emptry_alarm):
+ """Test entity setup."""
+ registry = await hass.helpers.entity_registry.async_get_registry()
+
+ for id in ENTITY_IDS.values():
+ assert not registry.async_is_registered(id)
+
+ with patch(
+ "homeassistant.components.risco.RiscoAPI.get_events",
+ return_value=TEST_EVENTS,
+ ), patch(
+ "homeassistant.components.risco.Store.async_save",
+ ) as save_mock:
+ entry = await setup_risco(hass)
+ await hass.async_block_till_done()
+ save_mock.assert_awaited_once_with(
+ {LAST_EVENT_TIMESTAMP_KEY: TEST_EVENTS[0].time}
+ )
+
+ for id in ENTITY_IDS.values():
+ assert registry.async_is_registered(id)
+
+ for category, entity_id in ENTITY_IDS.items():
+ _check_state(hass, category, entity_id)
+
+ coordinator = hass.data[DOMAIN][entry.entry_id][EVENTS_COORDINATOR]
+ with patch(
+ "homeassistant.components.risco.RiscoAPI.get_events", return_value=[]
+ ) as events_mock, patch(
+ "homeassistant.components.risco.Store.async_load",
+ return_value={LAST_EVENT_TIMESTAMP_KEY: TEST_EVENTS[0].time},
+ ):
+ await coordinator.async_refresh()
+ await hass.async_block_till_done()
+ events_mock.assert_awaited_once_with(TEST_EVENTS[0].time, 10)
+
+ for category, entity_id in ENTITY_IDS.items():
+ _check_state(hass, category, entity_id)
diff --git a/tests/components/risco/util.py b/tests/components/risco/util.py
new file mode 100644
index 00000000000..a60be70e861
--- /dev/null
+++ b/tests/components/risco/util.py
@@ -0,0 +1,37 @@
+"""Utilities for Risco tests."""
+from homeassistant.components.risco.const import DOMAIN
+from homeassistant.const import CONF_PASSWORD, CONF_PIN, CONF_USERNAME
+
+from tests.async_mock import PropertyMock, patch
+from tests.common import MockConfigEntry
+
+TEST_CONFIG = {
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
+ CONF_PIN: "1234",
+}
+TEST_SITE_UUID = "test-site-uuid"
+TEST_SITE_NAME = "test-site-name"
+
+
+async def setup_risco(hass, options={}):
+ """Set up a Risco integration for testing."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data=TEST_CONFIG, options=options)
+ config_entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.risco.RiscoAPI.login",
+ return_value=True,
+ ), patch(
+ "homeassistant.components.risco.RiscoAPI.site_uuid",
+ new_callable=PropertyMock(return_value=TEST_SITE_UUID),
+ ), patch(
+ "homeassistant.components.risco.RiscoAPI.site_name",
+ new_callable=PropertyMock(return_value=TEST_SITE_NAME),
+ ), patch(
+ "homeassistant.components.risco.RiscoAPI.close"
+ ):
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ return config_entry
diff --git a/tests/components/rmvtransport/test_sensor.py b/tests/components/rmvtransport/test_sensor.py
index b576f385173..935368f03b1 100644
--- a/tests/components/rmvtransport/test_sensor.py
+++ b/tests/components/rmvtransport/test_sensor.py
@@ -160,7 +160,8 @@ def get_no_departures_mock():
async def test_rmvtransport_min_config(hass):
"""Test minimal rmvtransport configuration."""
with patch(
- "RMVtransport.RMVtransport.get_departures", return_value=get_departures_mock(),
+ "RMVtransport.RMVtransport.get_departures",
+ return_value=get_departures_mock(),
):
assert await async_setup_component(hass, "sensor", VALID_CONFIG_MINIMAL) is True
await hass.async_block_till_done()
@@ -180,7 +181,8 @@ async def test_rmvtransport_min_config(hass):
async def test_rmvtransport_name_config(hass):
"""Test custom name configuration."""
with patch(
- "RMVtransport.RMVtransport.get_departures", return_value=get_departures_mock(),
+ "RMVtransport.RMVtransport.get_departures",
+ return_value=get_departures_mock(),
):
assert await async_setup_component(hass, "sensor", VALID_CONFIG_NAME)
await hass.async_block_till_done()
@@ -192,7 +194,8 @@ async def test_rmvtransport_name_config(hass):
async def test_rmvtransport_misc_config(hass):
"""Test misc configuration."""
with patch(
- "RMVtransport.RMVtransport.get_departures", return_value=get_departures_mock(),
+ "RMVtransport.RMVtransport.get_departures",
+ return_value=get_departures_mock(),
):
assert await async_setup_component(hass, "sensor", VALID_CONFIG_MISC)
await hass.async_block_till_done()
@@ -205,7 +208,8 @@ async def test_rmvtransport_misc_config(hass):
async def test_rmvtransport_dest_config(hass):
"""Test destination configuration."""
with patch(
- "RMVtransport.RMVtransport.get_departures", return_value=get_departures_mock(),
+ "RMVtransport.RMVtransport.get_departures",
+ return_value=get_departures_mock(),
):
assert await async_setup_component(hass, "sensor", VALID_CONFIG_DEST)
await hass.async_block_till_done()
diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py
index a73e1b7d5aa..f2da007b5e0 100644
--- a/tests/components/roku/__init__.py
+++ b/tests/components/roku/__init__.py
@@ -97,11 +97,13 @@ def mock_connection(
)
aioclient_mock.post(
- re.compile(f"{roku_url}/keypress/.*"), text="OK",
+ re.compile(f"{roku_url}/keypress/.*"),
+ text="OK",
)
aioclient_mock.post(
- re.compile(f"{roku_url}/launch/.*"), text="OK",
+ re.compile(f"{roku_url}/launch/.*"),
+ text="OK",
)
aioclient_mock.post(f"{roku_url}/search", text="OK")
diff --git a/tests/components/roku/test_config_flow.py b/tests/components/roku/test_config_flow.py
index 403e25e46c6..58acc7a6bdf 100644
--- a/tests/components/roku/test_config_flow.py
+++ b/tests/components/roku/test_config_flow.py
@@ -70,7 +70,8 @@ async def test_form(
with patch(
"homeassistant.components.roku.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.roku.async_setup_entry", return_value=True,
+ "homeassistant.components.roku.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"], user_input=user_input
@@ -113,7 +114,8 @@ async def test_form_unknown_error(hass: HomeAssistantType) -> None:
user_input = {CONF_HOST: HOST}
with patch(
- "homeassistant.components.roku.config_flow.Roku.update", side_effect=Exception,
+ "homeassistant.components.roku.config_flow.Roku.update",
+ side_effect=Exception,
) as mock_validate_input:
result = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"], user_input=user_input
@@ -136,7 +138,8 @@ async def test_import(
with patch(
"homeassistant.components.roku.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.roku.async_setup_entry", return_value=True,
+ "homeassistant.components.roku.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=user_input
@@ -161,7 +164,9 @@ async def test_ssdp_cannot_connect(
discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_SSDP},
+ data=discovery_info,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -174,10 +179,13 @@ async def test_ssdp_unknown_error(
"""Test we abort SSDP flow on unknown error."""
discovery_info = MOCK_SSDP_DISCOVERY_INFO.copy()
with patch(
- "homeassistant.components.roku.config_flow.Roku.update", side_effect=Exception,
+ "homeassistant.components.roku.config_flow.Roku.update",
+ side_effect=Exception,
):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_SSDP},
+ data=discovery_info,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -202,7 +210,8 @@ async def test_ssdp_discovery(
with patch(
"homeassistant.components.roku.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.roku.async_setup_entry", return_value=True,
+ "homeassistant.components.roku.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_configure(
flow_id=result["flow_id"], user_input={}
diff --git a/tests/components/roku/test_init.py b/tests/components/roku/test_init.py
index 3a627db72a5..b929a48ee25 100644
--- a/tests/components/roku/test_init.py
+++ b/tests/components/roku/test_init.py
@@ -29,7 +29,8 @@ async def test_unload_config_entry(
"homeassistant.components.roku.media_player.async_setup_entry",
return_value=True,
), patch(
- "homeassistant.components.roku.remote.async_setup_entry", return_value=True,
+ "homeassistant.components.roku.remote.async_setup_entry",
+ return_value=True,
):
entry = await setup_integration(hass, aioclient_mock)
diff --git a/tests/components/roku/test_media_player.py b/tests/components/roku/test_media_player.py
index a05cde5a596..e9d5091d664 100644
--- a/tests/components/roku/test_media_player.py
+++ b/tests/components/roku/test_media_player.py
@@ -3,6 +3,7 @@ from datetime import timedelta
from rokuecp import RokuError
+from homeassistant.components.media_player import DEVICE_CLASS_RECEIVER, DEVICE_CLASS_TV
from homeassistant.components.media_player.const import (
ATTR_APP_ID,
ATTR_APP_NAME,
@@ -10,13 +11,21 @@ from homeassistant.components.media_player.const import (
ATTR_MEDIA_CHANNEL,
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
+ ATTR_MEDIA_DURATION,
+ ATTR_MEDIA_POSITION,
ATTR_MEDIA_TITLE,
ATTR_MEDIA_VOLUME_MUTED,
DOMAIN as MP_DOMAIN,
+ MEDIA_CLASS_APP,
+ MEDIA_CLASS_CHANNEL,
+ MEDIA_CLASS_DIRECTORY,
MEDIA_TYPE_APP,
+ MEDIA_TYPE_APPS,
MEDIA_TYPE_CHANNEL,
+ MEDIA_TYPE_CHANNELS,
SERVICE_PLAY_MEDIA,
SERVICE_SELECT_SOURCE,
+ SUPPORT_BROWSE_MEDIA,
SUPPORT_NEXT_TRACK,
SUPPORT_PAUSE,
SUPPORT_PLAY,
@@ -29,6 +38,7 @@ from homeassistant.components.media_player.const import (
SUPPORT_VOLUME_STEP,
)
from homeassistant.components.roku.const import ATTR_KEYWORD, DOMAIN, SERVICE_SEARCH
+from homeassistant.components.websocket_api.const import TYPE_RESULT
from homeassistant.const import (
ATTR_ENTITY_ID,
SERVICE_MEDIA_NEXT_TRACK,
@@ -43,6 +53,7 @@ from homeassistant.const import (
SERVICE_VOLUME_UP,
STATE_HOME,
STATE_IDLE,
+ STATE_ON,
STATE_PAUSED,
STATE_PLAYING,
STATE_STANDBY,
@@ -74,6 +85,7 @@ async def test_setup(
assert hass.states.get(MAIN_ENTITY_ID)
assert main
+ assert main.device_class == DEVICE_CLASS_RECEIVER
assert main.unique_id == UPNP_SERIAL
@@ -105,6 +117,7 @@ async def test_tv_setup(
assert hass.states.get(TV_ENTITY_ID)
assert tv
+ assert tv.device_class == DEVICE_CLASS_TV
assert tv.unique_id == TV_SERIAL
@@ -152,6 +165,7 @@ async def test_supported_features(
| SUPPORT_PLAY_MEDIA
| SUPPORT_TURN_ON
| SUPPORT_TURN_OFF
+ | SUPPORT_BROWSE_MEDIA
== state.attributes.get("supported_features")
)
@@ -181,6 +195,7 @@ async def test_tv_supported_features(
| SUPPORT_PLAY_MEDIA
| SUPPORT_TURN_ON
| SUPPORT_TURN_OFF
+ | SUPPORT_BROWSE_MEDIA
== state.attributes.get("supported_features")
)
@@ -207,7 +222,7 @@ async def test_attributes_app(
await setup_integration(hass, aioclient_mock, app="netflix")
state = hass.states.get(MAIN_ENTITY_ID)
- assert state.state == STATE_PLAYING
+ assert state.state == STATE_ON
assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == MEDIA_TYPE_APP
assert state.attributes.get(ATTR_APP_ID) == "12"
@@ -215,6 +230,23 @@ async def test_attributes_app(
assert state.attributes.get(ATTR_INPUT_SOURCE) == "Netflix"
+async def test_attributes_app_media_playing(
+ hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
+) -> None:
+ """Test attributes for app with playing media."""
+ await setup_integration(hass, aioclient_mock, app="pluto", media_state="play")
+
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state.state == STATE_PLAYING
+
+ assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == MEDIA_TYPE_APP
+ assert state.attributes.get(ATTR_MEDIA_DURATION) == 6496
+ assert state.attributes.get(ATTR_MEDIA_POSITION) == 38
+ assert state.attributes.get(ATTR_APP_ID) == "74519"
+ assert state.attributes.get(ATTR_APP_NAME) == "Pluto TV - It's Free TV"
+ assert state.attributes.get(ATTR_INPUT_SOURCE) == "Pluto TV - It's Free TV"
+
+
async def test_attributes_app_media_paused(
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
) -> None:
@@ -225,6 +257,8 @@ async def test_attributes_app_media_paused(
assert state.state == STATE_PAUSED
assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == MEDIA_TYPE_APP
+ assert state.attributes.get(ATTR_MEDIA_DURATION) == 6496
+ assert state.attributes.get(ATTR_MEDIA_POSITION) == 313
assert state.attributes.get(ATTR_APP_ID) == "74519"
assert state.attributes.get(ATTR_APP_NAME) == "Pluto TV - It's Free TV"
assert state.attributes.get(ATTR_INPUT_SOURCE) == "Pluto TV - It's Free TV"
@@ -259,7 +293,7 @@ async def test_tv_attributes(
)
state = hass.states.get(TV_ENTITY_ID)
- assert state.state == STATE_PLAYING
+ assert state.state == STATE_ON
assert state.attributes.get(ATTR_APP_ID) == "tvinput.dtv"
assert state.attributes.get(ATTR_APP_NAME) == "Antenna TV"
@@ -339,6 +373,20 @@ async def test_services(
remote_mock.assert_called_once_with("reverse")
+ with patch("homeassistant.components.roku.Roku.launch") as launch_mock:
+ await hass.services.async_call(
+ MP_DOMAIN,
+ SERVICE_PLAY_MEDIA,
+ {
+ ATTR_ENTITY_ID: MAIN_ENTITY_ID,
+ ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_APP,
+ ATTR_MEDIA_CONTENT_ID: "11",
+ },
+ blocking=True,
+ )
+
+ launch_mock.assert_called_once_with("11")
+
with patch("homeassistant.components.roku.Roku.remote") as remote_mock:
await hass.services.async_call(
MP_DOMAIN,
@@ -425,6 +473,134 @@ async def test_tv_services(
tune_mock.assert_called_once_with("55")
+async def test_media_browse(hass, aioclient_mock, hass_ws_client):
+ """Test browsing media."""
+ await setup_integration(
+ hass,
+ aioclient_mock,
+ device="rokutv",
+ app="tvinput-dtv",
+ host=TV_HOST,
+ unique_id=TV_SERIAL,
+ )
+
+ client = await hass_ws_client(hass)
+
+ await client.send_json(
+ {
+ "id": 1,
+ "type": "media_player/browse_media",
+ "entity_id": TV_ENTITY_ID,
+ }
+ )
+
+ msg = await client.receive_json()
+
+ assert msg["id"] == 1
+ assert msg["type"] == TYPE_RESULT
+ assert msg["success"]
+
+ assert msg["result"]
+ assert msg["result"]["title"] == "Media Library"
+ assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY
+ assert msg["result"]["media_content_type"] == "library"
+ assert msg["result"]["can_expand"]
+ assert not msg["result"]["can_play"]
+ assert len(msg["result"]["children"]) == 2
+
+ # test apps
+ await client.send_json(
+ {
+ "id": 2,
+ "type": "media_player/browse_media",
+ "entity_id": TV_ENTITY_ID,
+ "media_content_type": MEDIA_TYPE_APPS,
+ "media_content_id": "apps",
+ }
+ )
+
+ msg = await client.receive_json()
+
+ assert msg["id"] == 2
+ assert msg["type"] == TYPE_RESULT
+ assert msg["success"]
+
+ assert msg["result"]
+ assert msg["result"]["title"] == "Apps"
+ assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY
+ assert msg["result"]["media_content_type"] == MEDIA_TYPE_APPS
+ assert msg["result"]["can_expand"]
+ assert not msg["result"]["can_play"]
+ assert len(msg["result"]["children"]) == 11
+ assert msg["result"]["children_media_class"] == MEDIA_CLASS_APP
+
+ assert msg["result"]["children"][0]["title"] == "Satellite TV"
+ assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_APP
+ assert msg["result"]["children"][0]["media_content_id"] == "tvinput.hdmi2"
+ assert (
+ msg["result"]["children"][0]["thumbnail"]
+ == "http://192.168.1.161:8060/query/icon/tvinput.hdmi2"
+ )
+ assert msg["result"]["children"][0]["can_play"]
+
+ assert msg["result"]["children"][3]["title"] == "Roku Channel Store"
+ assert msg["result"]["children"][3]["media_content_type"] == MEDIA_TYPE_APP
+ assert msg["result"]["children"][3]["media_content_id"] == "11"
+ assert (
+ msg["result"]["children"][3]["thumbnail"]
+ == "http://192.168.1.161:8060/query/icon/11"
+ )
+ assert msg["result"]["children"][3]["can_play"]
+
+ # test channels
+ await client.send_json(
+ {
+ "id": 3,
+ "type": "media_player/browse_media",
+ "entity_id": TV_ENTITY_ID,
+ "media_content_type": MEDIA_TYPE_CHANNELS,
+ "media_content_id": "channels",
+ }
+ )
+
+ msg = await client.receive_json()
+
+ assert msg["id"] == 3
+ assert msg["type"] == TYPE_RESULT
+ assert msg["success"]
+
+ assert msg["result"]
+ assert msg["result"]["title"] == "Channels"
+ assert msg["result"]["media_class"] == MEDIA_CLASS_DIRECTORY
+ assert msg["result"]["media_content_type"] == MEDIA_TYPE_CHANNELS
+ assert msg["result"]["can_expand"]
+ assert not msg["result"]["can_play"]
+ assert len(msg["result"]["children"]) == 2
+ assert msg["result"]["children_media_class"] == MEDIA_CLASS_CHANNEL
+
+ assert msg["result"]["children"][0]["title"] == "WhatsOn"
+ assert msg["result"]["children"][0]["media_content_type"] == MEDIA_TYPE_CHANNEL
+ assert msg["result"]["children"][0]["media_content_id"] == "1.1"
+ assert msg["result"]["children"][0]["can_play"]
+
+ # test invalid media type
+ await client.send_json(
+ {
+ "id": 4,
+ "type": "media_player/browse_media",
+ "entity_id": TV_ENTITY_ID,
+ "media_content_type": "invalid",
+ "media_content_id": "invalid",
+ }
+ )
+
+ msg = await client.receive_json()
+
+ assert msg["id"] == 4
+ assert msg["type"] == TYPE_RESULT
+ assert not msg["success"]
+
+
async def test_integration_services(
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
) -> None:
diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py
index 197ad56f415..b2ad3a74235 100644
--- a/tests/components/roomba/test_config_flow.py
+++ b/tests/components/roomba/test_config_flow.py
@@ -55,10 +55,12 @@ async def test_form(hass):
), patch(
"homeassistant.components.roomba.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.roomba.async_setup_entry", return_value=True,
+ "homeassistant.components.roomba.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], VALID_CONFIG,
+ result["flow_id"],
+ VALID_CONFIG,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -94,7 +96,8 @@ async def test_form_cannot_connect(hass):
return_value=mocked_roomba,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], VALID_CONFIG,
+ result["flow_id"],
+ VALID_CONFIG,
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM
@@ -115,7 +118,8 @@ async def test_form_import(hass):
), patch(
"homeassistant.components.roomba.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.roomba.async_setup_entry", return_value=True,
+ "homeassistant.components.roomba.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
diff --git a/tests/components/roon/__init__.py b/tests/components/roon/__init__.py
new file mode 100644
index 00000000000..4babd3f177e
--- /dev/null
+++ b/tests/components/roon/__init__.py
@@ -0,0 +1 @@
+"""Tests for the roon integration."""
diff --git a/tests/components/roon/test_config_flow.py b/tests/components/roon/test_config_flow.py
new file mode 100644
index 00000000000..aae655fb9c5
--- /dev/null
+++ b/tests/components/roon/test_config_flow.py
@@ -0,0 +1,165 @@
+"""Test the roon config flow."""
+from homeassistant import config_entries, setup
+from homeassistant.components.roon.const import DOMAIN
+from homeassistant.const import CONF_HOST
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+
+class RoonApiMock:
+ """Mock to handle returning tokens for testing the RoonApi."""
+
+ def __init__(self, token):
+ """Initialize."""
+ self._token = token
+
+ @property
+ def token(self):
+ """Return the auth token from the api."""
+ return self._token
+
+ def stop(self): # pylint: disable=no-self-use
+ """Close down the api."""
+ return
+
+
+async def test_form_and_auth(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch("homeassistant.components.roon.config_flow.TIMEOUT", 0,), patch(
+ "homeassistant.components.roon.const.AUTHENTICATE_TIMEOUT",
+ 0,
+ ), patch(
+ "homeassistant.components.roon.config_flow.RoonApi",
+ return_value=RoonApiMock("good_token"),
+ ), patch(
+ "homeassistant.components.roon.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.roon.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": "1.1.1.1"}
+ )
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "Roon Labs Music Player"
+ assert result2["data"] == {"host": "1.1.1.1", "api_key": "good_token"}
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_no_token(hass):
+ """Test we handle no token being returned (timeout or not authorized)."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ with patch("homeassistant.components.roon.config_flow.TIMEOUT", 0,), patch(
+ "homeassistant.components.roon.const.AUTHENTICATE_TIMEOUT",
+ 0,
+ ), patch(
+ "homeassistant.components.roon.config_flow.RoonApi",
+ return_value=RoonApiMock(None),
+ ):
+ await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": "1.1.1.1"}
+ )
+
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "invalid_auth"}
+
+
+async def test_form_unknown_exception(hass):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.roon.config_flow.RoonApi",
+ side_effect=Exception,
+ ):
+ await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": "1.1.1.1"}
+ )
+
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "unknown"}
+
+
+async def test_form_host_already_exists(hass):
+ """Test we add the host if the config exists and it isn't a duplicate."""
+
+ MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "existing_host"}).add_to_hass(hass)
+
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch("homeassistant.components.roon.config_flow.TIMEOUT", 0,), patch(
+ "homeassistant.components.roon.const.AUTHENTICATE_TIMEOUT",
+ 0,
+ ), patch(
+ "homeassistant.components.roon.config_flow.RoonApi",
+ return_value=RoonApiMock("good_token"),
+ ), patch(
+ "homeassistant.components.roon.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.roon.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": "1.1.1.1"}
+ )
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "Roon Labs Music Player"
+ assert result2["data"] == {"host": "1.1.1.1", "api_key": "good_token"}
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 2
+
+
+async def test_form_duplicate_host(hass):
+ """Test we don't add the host if it's a duplicate."""
+
+ MockConfigEntry(domain=DOMAIN, data={CONF_HOST: "existing_host"}).add_to_hass(hass)
+
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": "existing_host"}
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "duplicate_entry"}
diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py
index 2673ee56559..0e83255c6e3 100644
--- a/tests/components/samsungtv/test_config_flow.py
+++ b/tests/components/samsungtv/test_config_flow.py
@@ -180,7 +180,8 @@ async def test_user_legacy_not_supported(hass):
async def test_user_websocket_not_supported(hass):
"""Test starting a flow by user for not supported device."""
with patch(
- "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ "homeassistant.components.samsungtv.bridge.Remote",
+ side_effect=OSError("Boom"),
), patch(
"homeassistant.components.samsungtv.bridge.SamsungTVWS",
side_effect=WebSocketProtocolException("Boom"),
@@ -198,7 +199,8 @@ async def test_user_websocket_not_supported(hass):
async def test_user_not_successful(hass):
"""Test starting a flow by user but no connection found."""
with patch(
- "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ "homeassistant.components.samsungtv.bridge.Remote",
+ side_effect=OSError("Boom"),
), patch(
"homeassistant.components.samsungtv.bridge.SamsungTVWS",
side_effect=OSError("Boom"),
@@ -215,7 +217,8 @@ async def test_user_not_successful(hass):
async def test_user_not_successful_2(hass):
"""Test starting a flow by user but no connection found."""
with patch(
- "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ "homeassistant.components.samsungtv.bridge.Remote",
+ side_effect=OSError("Boom"),
), patch(
"homeassistant.components.samsungtv.bridge.SamsungTVWS",
side_effect=ConnectionFailure("Boom"),
@@ -339,7 +342,8 @@ async def test_ssdp_legacy_not_supported(hass):
async def test_ssdp_websocket_not_supported(hass):
"""Test starting a flow from discovery for not supported device."""
with patch(
- "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ "homeassistant.components.samsungtv.bridge.Remote",
+ side_effect=OSError("Boom"),
), patch(
"homeassistant.components.samsungtv.bridge.SamsungTVWS",
side_effect=WebSocketProtocolException("Boom"),
@@ -364,7 +368,8 @@ async def test_ssdp_websocket_not_supported(hass):
async def test_ssdp_not_successful(hass):
"""Test starting a flow from discovery but no device found."""
with patch(
- "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ "homeassistant.components.samsungtv.bridge.Remote",
+ side_effect=OSError("Boom"),
), patch(
"homeassistant.components.samsungtv.bridge.SamsungTVWS",
side_effect=OSError("Boom"),
@@ -390,7 +395,8 @@ async def test_ssdp_not_successful(hass):
async def test_ssdp_not_successful_2(hass):
"""Test starting a flow from discovery but no device found."""
with patch(
- "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ "homeassistant.components.samsungtv.bridge.Remote",
+ side_effect=OSError("Boom"),
), patch(
"homeassistant.components.samsungtv.bridge.SamsungTVWS",
side_effect=ConnectionFailure("Boom"),
@@ -460,7 +466,8 @@ async def test_ssdp_already_configured(hass, remote):
async def test_autodetect_websocket(hass, remote, remotews):
"""Test for send key with autodetection of protocol."""
with patch(
- "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ "homeassistant.components.samsungtv.bridge.Remote",
+ side_effect=OSError("Boom"),
), patch("homeassistant.components.samsungtv.bridge.SamsungTVWS") as remotews:
enter = Mock()
type(enter).token = PropertyMock(return_value="123456789")
@@ -482,7 +489,8 @@ async def test_autodetect_websocket(hass, remote, remotews):
async def test_autodetect_websocket_ssl(hass, remote, remotews):
"""Test for send key with autodetection of protocol."""
with patch(
- "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ "homeassistant.components.samsungtv.bridge.Remote",
+ side_effect=OSError("Boom"),
), patch(
"homeassistant.components.samsungtv.bridge.SamsungTVWS",
side_effect=[WebSocketProtocolException("Boom"), DEFAULT_MOCK],
@@ -552,7 +560,8 @@ async def test_autodetect_legacy(hass, remote):
async def test_autodetect_none(hass, remote, remotews):
"""Test for send key with autodetection of protocol."""
with patch(
- "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"),
+ "homeassistant.components.samsungtv.bridge.Remote",
+ side_effect=OSError("Boom"),
) as remote, patch(
"homeassistant.components.samsungtv.bridge.SamsungTVWS",
side_effect=OSError("Boom"),
diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py
index 840d5ac3f4e..cc9a0f37ec8 100644
--- a/tests/components/samsungtv/test_media_player.py
+++ b/tests/components/samsungtv/test_media_player.py
@@ -203,7 +203,9 @@ async def test_setup_websocket_2(hass, mock_now):
entity_id = f"{DOMAIN}.fake"
entry = MockConfigEntry(
- domain=SAMSUNGTV_DOMAIN, data=MOCK_ENTRY_WS, unique_id=entity_id,
+ domain=SAMSUNGTV_DOMAIN,
+ data=MOCK_ENTRY_WS,
+ unique_id=entity_id,
)
entry.add_to_hass(hass)
diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py
index ec1b7ecb6e0..152c74d8fe9 100644
--- a/tests/components/script/test_init.py
+++ b/tests/components/script/test_init.py
@@ -17,13 +17,14 @@ from homeassistant.const import (
)
from homeassistant.core import Context, callback, split_entity_id
from homeassistant.exceptions import ServiceNotFound
+from homeassistant.helpers import template
from homeassistant.helpers.event import async_track_state_change
from homeassistant.helpers.service import async_get_all_descriptions
from homeassistant.loader import bind_hass
from homeassistant.setup import async_setup_component, setup_component
from tests.async_mock import Mock, patch
-from tests.common import get_test_home_assistant
+from tests.common import async_mock_service, get_test_home_assistant
from tests.components.logbook.test_init import MockLazyEventPartialState
ENTITY_ID = "script.test"
@@ -185,7 +186,7 @@ invalid_configs = [
@pytest.mark.parametrize("value", invalid_configs)
async def test_setup_with_invalid_configs(hass, value):
"""Test setup with invalid configs."""
- assert not await async_setup_component(
+ assert await async_setup_component(
hass, "script", {"script": value}
), f"Script loaded with wrong config {value}"
@@ -418,7 +419,12 @@ async def test_extraction_functions(hass):
"service": "test.script",
"data": {"entity_id": "light.in_first"},
},
- {"domain": "light", "device_id": "device-in-both"},
+ {
+ "entity_id": "light.device_in_both",
+ "domain": "light",
+ "type": "turn_on",
+ "device_id": "device-in-both",
+ },
]
},
"test2": {
@@ -433,8 +439,18 @@ async def test_extraction_functions(hass):
"state": "100",
},
{"scene": "scene.hello"},
- {"domain": "light", "device_id": "device-in-both"},
- {"domain": "light", "device_id": "device-in-last"},
+ {
+ "entity_id": "light.device_in_both",
+ "domain": "light",
+ "type": "turn_on",
+ "device_id": "device-in-both",
+ },
+ {
+ "entity_id": "light.device_in_last",
+ "domain": "light",
+ "type": "turn_on",
+ "device_id": "device-in-last",
+ },
],
},
}
@@ -501,6 +517,7 @@ async def test_logbook_humanify_script_started_event(hass):
),
],
entity_attr_cache,
+ {},
)
)
@@ -599,3 +616,93 @@ async def test_concurrent_script(hass, concurrently):
assert not script.is_on(hass, "script.script1")
assert not script.is_on(hass, "script.script2")
+
+
+async def test_script_variables(hass, caplog):
+ """Test defining scripts."""
+ assert await async_setup_component(
+ hass,
+ "script",
+ {
+ "script": {
+ "script1": {
+ "variables": {
+ "test_var": "from_config",
+ "templated_config_var": "{{ var_from_service | default('config-default') }}",
+ },
+ "sequence": [
+ {
+ "service": "test.script",
+ "data": {
+ "value": "{{ test_var }}",
+ "templated_config_var": "{{ templated_config_var }}",
+ },
+ },
+ ],
+ },
+ "script2": {
+ "variables": {
+ "test_var": "from_config",
+ },
+ "sequence": [
+ {
+ "service": "test.script",
+ "data": {
+ "value": "{{ test_var }}",
+ },
+ },
+ ],
+ },
+ "script3": {
+ "variables": {
+ "test_var": "{{ break + 1 }}",
+ },
+ "sequence": [
+ {
+ "service": "test.script",
+ "data": {
+ "value": "{{ test_var }}",
+ },
+ },
+ ],
+ },
+ }
+ },
+ )
+
+ mock_calls = async_mock_service(hass, "test", "script")
+
+ await hass.services.async_call(
+ "script", "script1", {"var_from_service": "hello"}, blocking=True
+ )
+
+ assert len(mock_calls) == 1
+ assert mock_calls[0].data["value"] == "from_config"
+ assert mock_calls[0].data["templated_config_var"] == "hello"
+
+ await hass.services.async_call(
+ "script", "script1", {"test_var": "from_service"}, blocking=True
+ )
+
+ assert len(mock_calls) == 2
+ assert mock_calls[1].data["value"] == "from_service"
+ assert mock_calls[1].data["templated_config_var"] == "config-default"
+
+ # Call script with vars but no templates in it
+ await hass.services.async_call(
+ "script", "script2", {"test_var": "from_service"}, blocking=True
+ )
+
+ assert len(mock_calls) == 3
+ assert mock_calls[2].data["value"] == "from_service"
+
+ assert "Error rendering variables" not in caplog.text
+ with pytest.raises(template.TemplateError):
+ await hass.services.async_call("script", "script3", blocking=True)
+ assert "Error rendering variables" in caplog.text
+ assert len(mock_calls) == 3
+
+ await hass.services.async_call("script", "script3", {"break": 0}, blocking=True)
+
+ assert len(mock_calls) == 4
+ assert mock_calls[3].data["value"] == "1"
diff --git a/tests/components/sense/test_config_flow.py b/tests/components/sense/test_config_flow.py
index 5a38090b5c9..4cda15f7303 100644
--- a/tests/components/sense/test_config_flow.py
+++ b/tests/components/sense/test_config_flow.py
@@ -19,7 +19,8 @@ async def test_form(hass):
with patch("sense_energy.ASyncSenseable.authenticate", return_value=True,), patch(
"homeassistant.components.sense.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.sense.async_setup_entry", return_value=True,
+ "homeassistant.components.sense.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py
index f92a28d358b..0c0c9c6c22b 100644
--- a/tests/components/sensor/test_device_condition.py
+++ b/tests/components/sensor/test_device_condition.py
@@ -4,7 +4,7 @@ import pytest
import homeassistant.components.automation as automation
from homeassistant.components.sensor import DOMAIN
from homeassistant.components.sensor.device_condition import ENTITY_CONDITIONS
-from homeassistant.const import CONF_PLATFORM, STATE_UNKNOWN, UNIT_PERCENTAGE
+from homeassistant.const import CONF_PLATFORM, PERCENTAGE, STATE_UNKNOWN
from homeassistant.helpers import device_registry
from homeassistant.setup import async_setup_component
@@ -99,13 +99,13 @@ async def test_get_condition_capabilities(hass, device_reg, entity_reg):
expected_capabilities = {
"extra_fields": [
{
- "description": {"suffix": UNIT_PERCENTAGE},
+ "description": {"suffix": PERCENTAGE},
"name": "above",
"optional": True,
"type": "float",
},
{
- "description": {"suffix": UNIT_PERCENTAGE},
+ "description": {"suffix": PERCENTAGE},
"name": "below",
"optional": True,
"type": "float",
diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py
index 3f44e9e5e32..dda57de0d9d 100644
--- a/tests/components/sensor/test_device_trigger.py
+++ b/tests/components/sensor/test_device_trigger.py
@@ -6,7 +6,7 @@ import pytest
import homeassistant.components.automation as automation
from homeassistant.components.sensor import DOMAIN
from homeassistant.components.sensor.device_trigger import ENTITY_TRIGGERS
-from homeassistant.const import CONF_PLATFORM, STATE_UNKNOWN, UNIT_PERCENTAGE
+from homeassistant.const import CONF_PLATFORM, PERCENTAGE, STATE_UNKNOWN
from homeassistant.helpers import device_registry
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
@@ -76,7 +76,7 @@ async def test_get_triggers(hass, device_reg, entity_reg):
if device_class != "none"
]
triggers = await async_get_device_automations(hass, "trigger", device_entry.id)
- assert len(triggers) == 8
+ assert len(triggers) == 12
assert triggers == expected_triggers
@@ -104,13 +104,13 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg):
expected_capabilities = {
"extra_fields": [
{
- "description": {"suffix": UNIT_PERCENTAGE},
+ "description": {"suffix": PERCENTAGE},
"name": "above",
"optional": True,
"type": "float",
},
{
- "description": {"suffix": UNIT_PERCENTAGE},
+ "description": {"suffix": PERCENTAGE},
"name": "below",
"optional": True,
"type": "float",
diff --git a/tests/components/sentry/test_config_flow.py b/tests/components/sentry/test_config_flow.py
index 25353751f91..9ae572123b5 100644
--- a/tests/components/sentry/test_config_flow.py
+++ b/tests/components/sentry/test_config_flow.py
@@ -1,31 +1,45 @@
"""Test the sentry config flow."""
+import logging
+
from sentry_sdk.utils import BadDsn
-from homeassistant import config_entries, setup
-from homeassistant.components.sentry.const import DOMAIN
+from homeassistant.components.sentry.const import (
+ CONF_ENVIRONMENT,
+ CONF_EVENT_CUSTOM_COMPONENTS,
+ CONF_EVENT_HANDLED,
+ CONF_EVENT_THIRD_PARTY_PACKAGES,
+ CONF_LOGGING_EVENT_LEVEL,
+ CONF_LOGGING_LEVEL,
+ CONF_TRACING,
+ CONF_TRACING_SAMPLE_RATE,
+ DOMAIN,
+)
+from homeassistant.config_entries import SOURCE_USER
+from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_FORM
+from homeassistant.setup import async_setup_component
from tests.async_mock import patch
+from tests.common import MockConfigEntry
-async def test_form(hass):
+async def test_full_user_flow_implementation(hass):
"""Test we get the form."""
- await setup.async_setup_component(hass, "persistent_notification", {})
+ await async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_USER}
+ DOMAIN, context={"source": SOURCE_USER}
)
- assert result["type"] == "form"
+ assert result["type"] == RESULT_TYPE_FORM
assert result["errors"] == {}
- with patch(
- "homeassistant.components.sentry.config_flow.validate_input",
- return_value={"title": "Sentry"},
- ), patch(
+ with patch("homeassistant.components.sentry.config_flow.Dsn"), patch(
"homeassistant.components.sentry.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.sentry.async_setup_entry", return_value=True,
+ "homeassistant.components.sentry.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"dsn": "http://public@sentry.local/1"},
+ result["flow_id"],
+ {"dsn": "http://public@sentry.local/1"},
)
assert result2["type"] == "create_entry"
@@ -34,23 +48,99 @@ async def test_form(hass):
"dsn": "http://public@sentry.local/1",
}
await hass.async_block_till_done()
+
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
-async def test_form_bad_dsn(hass):
+async def test_integration_already_exists(hass):
+ """Test we only allow a single config flow."""
+ MockConfigEntry(domain=DOMAIN).add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "single_instance_allowed"
+
+
+async def test_user_flow_bad_dsn(hass):
"""Test we handle bad dsn error."""
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_USER}
+ DOMAIN, context={"source": SOURCE_USER}
)
with patch(
- "homeassistant.components.sentry.config_flow.validate_input",
+ "homeassistant.components.sentry.config_flow.Dsn",
side_effect=BadDsn,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"dsn": "foo"},
+ result["flow_id"],
+ {"dsn": "foo"},
)
- assert result2["type"] == "form"
+ assert result2["type"] == RESULT_TYPE_FORM
assert result2["errors"] == {"base": "bad_dsn"}
+
+
+async def test_user_flow_unkown_exception(hass):
+ """Test we handle any unknown exception error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}
+ )
+
+ with patch(
+ "homeassistant.components.sentry.config_flow.Dsn",
+ side_effect=Exception,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"dsn": "foo"},
+ )
+
+ assert result2["type"] == RESULT_TYPE_FORM
+ assert result2["errors"] == {"base": "unknown"}
+
+
+async def test_options_flow(hass):
+ """Test options config flow."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={"dsn": "http://public@sentry.local/1"},
+ )
+ entry.add_to_hass(hass)
+
+ with patch("homeassistant.components.sentry.async_setup_entry", return_value=True):
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ result = await hass.config_entries.options.async_init(entry.entry_id)
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={
+ CONF_ENVIRONMENT: "Test",
+ CONF_EVENT_CUSTOM_COMPONENTS: True,
+ CONF_EVENT_HANDLED: True,
+ CONF_EVENT_THIRD_PARTY_PACKAGES: True,
+ CONF_LOGGING_EVENT_LEVEL: logging.DEBUG,
+ CONF_LOGGING_LEVEL: logging.DEBUG,
+ CONF_TRACING: True,
+ CONF_TRACING_SAMPLE_RATE: 0.5,
+ },
+ )
+
+ assert result["type"] == "create_entry"
+ assert result["data"] == {
+ CONF_ENVIRONMENT: "Test",
+ CONF_EVENT_CUSTOM_COMPONENTS: True,
+ CONF_EVENT_HANDLED: True,
+ CONF_EVENT_THIRD_PARTY_PACKAGES: True,
+ CONF_LOGGING_EVENT_LEVEL: logging.DEBUG,
+ CONF_LOGGING_LEVEL: logging.DEBUG,
+ CONF_TRACING: True,
+ CONF_TRACING_SAMPLE_RATE: 0.5,
+ }
diff --git a/tests/components/sentry/test_init.py b/tests/components/sentry/test_init.py
new file mode 100644
index 00000000000..95bda9738b8
--- /dev/null
+++ b/tests/components/sentry/test_init.py
@@ -0,0 +1,331 @@
+"""Tests for Sentry integration."""
+import logging
+
+import pytest
+
+from homeassistant.components.sentry import get_channel, process_before_send
+from homeassistant.components.sentry.const import (
+ CONF_DSN,
+ CONF_ENVIRONMENT,
+ CONF_EVENT_CUSTOM_COMPONENTS,
+ CONF_EVENT_HANDLED,
+ CONF_EVENT_THIRD_PARTY_PACKAGES,
+ CONF_TRACING,
+ CONF_TRACING_SAMPLE_RATE,
+ DOMAIN,
+)
+from homeassistant.const import __version__ as current_version
+from homeassistant.core import HomeAssistant
+
+from tests.async_mock import MagicMock, Mock, patch
+from tests.common import MockConfigEntry
+
+
+async def test_setup_entry(hass: HomeAssistant) -> None:
+ """Test integration setup from entry."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_DSN: "http://public@example.com/1", CONF_ENVIRONMENT: "production"},
+ )
+ entry.add_to_hass(hass)
+
+ with patch(
+ "homeassistant.components.sentry.AioHttpIntegration"
+ ) as sentry_aiohttp_mock, patch(
+ "homeassistant.components.sentry.SqlalchemyIntegration"
+ ) as sentry_sqlalchemy_mock, patch(
+ "homeassistant.components.sentry.LoggingIntegration"
+ ) as sentry_logging_mock, patch(
+ "homeassistant.components.sentry.sentry_sdk"
+ ) as sentry_mock:
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ # Test CONF_ENVIRONMENT is migrated to entry options
+ assert CONF_ENVIRONMENT not in entry.data
+ assert CONF_ENVIRONMENT in entry.options
+ assert entry.options[CONF_ENVIRONMENT] == "production"
+
+ assert sentry_logging_mock.call_count == 1
+ assert sentry_logging_mock.called_once_with(
+ level=logging.WARNING, event_level=logging.WARNING
+ )
+
+ assert sentry_aiohttp_mock.call_count == 1
+ assert sentry_sqlalchemy_mock.call_count == 1
+ assert sentry_mock.init.call_count == 1
+
+ call_args = sentry_mock.init.call_args[1]
+ assert set(call_args) == {
+ "dsn",
+ "environment",
+ "integrations",
+ "release",
+ "before_send",
+ }
+ assert call_args["dsn"] == "http://public@example.com/1"
+ assert call_args["environment"] == "production"
+ assert call_args["integrations"] == [
+ sentry_logging_mock.return_value,
+ sentry_aiohttp_mock.return_value,
+ sentry_sqlalchemy_mock.return_value,
+ ]
+ assert call_args["release"] == current_version
+ assert call_args["before_send"]
+
+
+async def test_setup_entry_with_tracing(hass: HomeAssistant) -> None:
+ """Test integration setup from entry with tracing enabled."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={CONF_DSN: "http://public@example.com/1"},
+ options={CONF_TRACING: True, CONF_TRACING_SAMPLE_RATE: 0.5},
+ )
+ entry.add_to_hass(hass)
+
+ with patch("homeassistant.components.sentry.AioHttpIntegration"), patch(
+ "homeassistant.components.sentry.SqlalchemyIntegration"
+ ), patch("homeassistant.components.sentry.LoggingIntegration"), patch(
+ "homeassistant.components.sentry.sentry_sdk"
+ ) as sentry_mock:
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ call_args = sentry_mock.init.call_args[1]
+ assert set(call_args) == {
+ "dsn",
+ "environment",
+ "integrations",
+ "release",
+ "before_send",
+ "traces_sample_rate",
+ }
+ assert call_args["traces_sample_rate"] == 0.5
+
+
+@pytest.mark.parametrize(
+ "version,channel",
+ [
+ ("0.115.0.dev20200815", "nightly"),
+ ("0.115.0", "stable"),
+ ("0.115.0b4", "beta"),
+ ("0.115.0dev0", "dev"),
+ ],
+)
+async def test_get_channel(version, channel) -> None:
+ """Test if channel detection works from Home Assistant version number."""
+ assert get_channel(version) == channel
+
+
+async def test_process_before_send(hass: HomeAssistant):
+ """Test regular use of the Sentry process before sending function."""
+ hass.config.components.add("puppies")
+ hass.config.components.add("a_integration")
+
+ # These should not show up in the result.
+ hass.config.components.add("puppies.light")
+ hass.config.components.add("auth")
+
+ result = process_before_send(
+ hass,
+ options={},
+ channel="test",
+ huuid="12345",
+ system_info={"installation_type": "pytest"},
+ custom_components=["ironing_robot", "fridge_opener"],
+ event={},
+ hint={},
+ )
+
+ assert result
+ assert result["tags"]
+ assert result["contexts"]
+ assert result["contexts"]
+
+ ha_context = result["contexts"]["Home Assistant"]
+ assert ha_context["channel"] == "test"
+ assert ha_context["custom_components"] == "fridge_opener\nironing_robot"
+ assert ha_context["integrations"] == "a_integration\npuppies"
+
+ tags = result["tags"]
+ assert tags["channel"] == "test"
+ assert tags["uuid"] == "12345"
+ assert tags["installation_type"] == "pytest"
+
+ user = result["user"]
+ assert user["id"] == "12345"
+
+
+async def test_event_with_platform_context(hass: HomeAssistant):
+ """Test extraction of platform context information during Sentry events."""
+
+ current_platform_mock = Mock()
+ current_platform_mock.get().platform_name = "hue"
+ current_platform_mock.get().domain = "light"
+
+ with patch(
+ "homeassistant.components.sentry.entity_platform.current_platform",
+ new=current_platform_mock,
+ ):
+ result = process_before_send(
+ hass,
+ options={},
+ channel="test",
+ huuid="12345",
+ system_info={"installation_type": "pytest"},
+ custom_components=["ironing_robot"],
+ event={},
+ hint={},
+ )
+
+ assert result
+ assert result["tags"]["integration"] == "hue"
+ assert result["tags"]["platform"] == "light"
+ assert result["tags"]["custom_component"] == "no"
+
+ current_platform_mock.get().platform_name = "ironing_robot"
+ current_platform_mock.get().domain = "switch"
+
+ with patch(
+ "homeassistant.components.sentry.entity_platform.current_platform",
+ new=current_platform_mock,
+ ):
+ result = process_before_send(
+ hass,
+ options={CONF_EVENT_CUSTOM_COMPONENTS: True},
+ channel="test",
+ huuid="12345",
+ system_info={"installation_type": "pytest"},
+ custom_components=["ironing_robot"],
+ event={},
+ hint={},
+ )
+
+ assert result
+ assert result["tags"]["integration"] == "ironing_robot"
+ assert result["tags"]["platform"] == "switch"
+ assert result["tags"]["custom_component"] == "yes"
+
+
+@pytest.mark.parametrize(
+ "logger,tags",
+ [
+ ("adguard", {"package": "adguard"}),
+ (
+ "homeassistant.components.hue.coordinator",
+ {"integration": "hue", "custom_component": "no"},
+ ),
+ (
+ "homeassistant.components.hue.light",
+ {"integration": "hue", "platform": "light", "custom_component": "no"},
+ ),
+ (
+ "homeassistant.components.ironing_robot.switch",
+ {
+ "integration": "ironing_robot",
+ "platform": "switch",
+ "custom_component": "yes",
+ },
+ ),
+ (
+ "homeassistant.components.ironing_robot",
+ {"integration": "ironing_robot", "custom_component": "yes"},
+ ),
+ ("homeassistant.helpers.network", {"helpers": "network"}),
+ ("tuyapi.test", {"package": "tuyapi"}),
+ ],
+)
+async def test_logger_event_extraction(hass: HomeAssistant, logger, tags):
+ """Test extraction of information from Sentry logger events."""
+
+ result = process_before_send(
+ hass,
+ options={
+ CONF_EVENT_CUSTOM_COMPONENTS: True,
+ CONF_EVENT_THIRD_PARTY_PACKAGES: True,
+ },
+ channel="test",
+ huuid="12345",
+ system_info={"installation_type": "pytest"},
+ custom_components=["ironing_robot"],
+ event={"logger": logger},
+ hint={},
+ )
+
+ assert result
+ assert result["tags"] == {
+ "channel": "test",
+ "uuid": "12345",
+ "installation_type": "pytest",
+ **tags,
+ }
+
+
+@pytest.mark.parametrize(
+ "logger,options,event",
+ [
+ ("adguard", {CONF_EVENT_THIRD_PARTY_PACKAGES: True}, True),
+ ("adguard", {CONF_EVENT_THIRD_PARTY_PACKAGES: False}, False),
+ (
+ "homeassistant.components.ironing_robot.switch",
+ {CONF_EVENT_CUSTOM_COMPONENTS: True},
+ True,
+ ),
+ (
+ "homeassistant.components.ironing_robot.switch",
+ {CONF_EVENT_CUSTOM_COMPONENTS: False},
+ False,
+ ),
+ ],
+)
+async def test_filter_log_events(hass: HomeAssistant, logger, options, event):
+ """Test filtering of events based on configuration options."""
+ result = process_before_send(
+ hass,
+ options=options,
+ channel="test",
+ huuid="12345",
+ system_info={"installation_type": "pytest"},
+ custom_components=["ironing_robot"],
+ event={"logger": logger},
+ hint={},
+ )
+
+ if event:
+ assert result
+ else:
+ assert result is None
+
+
+@pytest.mark.parametrize(
+ "handled,options,event",
+ [
+ ("yes", {CONF_EVENT_HANDLED: True}, True),
+ ("yes", {CONF_EVENT_HANDLED: False}, False),
+ ("no", {CONF_EVENT_HANDLED: False}, True),
+ ("no", {CONF_EVENT_HANDLED: True}, True),
+ ],
+)
+async def test_filter_handled_events(hass: HomeAssistant, handled, options, event):
+ """Tests filtering of handled events based on configuration options."""
+
+ event_mock = MagicMock()
+ event_mock.__iter__ = ["tags"]
+ event_mock.__contains__ = lambda _, val: val == "tags"
+ event_mock.tags = {"handled": handled}
+
+ result = process_before_send(
+ hass,
+ options=options,
+ channel="test",
+ huuid="12345",
+ system_info={"installation_type": "pytest"},
+ custom_components=[],
+ event=event_mock,
+ hint={},
+ )
+
+ if event:
+ assert result
+ else:
+ assert result is None
diff --git a/tests/components/sharkiq/__init__.py b/tests/components/sharkiq/__init__.py
new file mode 100644
index 00000000000..d6f1072e9a8
--- /dev/null
+++ b/tests/components/sharkiq/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Shark IQ integration."""
diff --git a/tests/components/sharkiq/const.py b/tests/components/sharkiq/const.py
new file mode 100644
index 00000000000..305d12ddfa7
--- /dev/null
+++ b/tests/components/sharkiq/const.py
@@ -0,0 +1,74 @@
+"""Constants used in shark iq tests."""
+
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+
+# Dummy device dict of the form returned by AylaApi.list_devices()
+SHARK_DEVICE_DICT = {
+ "product_name": "Sharknado",
+ "model": "AY001MRT1",
+ "dsn": "AC000Wxxxxxxxxx",
+ "oem_model": "RV1000A",
+ "sw_version": "devd 1.7 2020-05-13 11:50:36",
+ "template_id": 99999,
+ "mac": "ffffffffffff",
+ "unique_hardware_id": None,
+ "lan_ip": "192.168.0.123",
+ "connected_at": "2020-07-31T08:03:05Z",
+ "key": 26517570,
+ "lan_enabled": False,
+ "has_properties": True,
+ "product_class": None,
+ "connection_status": "Online",
+ "lat": "99.9999",
+ "lng": "-99.9999",
+ "locality": "99999",
+ "device_type": "Wifi",
+}
+
+# Dummy response for get_metadata
+SHARK_METADATA_DICT = [
+ {
+ "datum": {
+ "created_at": "2019-12-02T02:13:12Z",
+ "from_template": False,
+ "key": "sharkDeviceMobileData",
+ "updated_at": "2019-12-02T02:13:12Z",
+ "value": '{"vacModelNumber":"RV1001AE","vacSerialNumber":"S26xxxxxxxxx"}',
+ "dsn": "AC000Wxxxxxxxxx",
+ }
+ }
+]
+
+# Dummy shark.properties_full for testing. NB: this only includes those properties in the tests
+SHARK_PROPERTIES_DICT = {
+ "Battery_Capacity": {"base_type": "integer", "read_only": True, "value": 50},
+ "Charging_Status": {"base_type": "boolean", "read_only": True, "value": 0},
+ "CleanComplete": {"base_type": "boolean", "read_only": True, "value": 0},
+ "Cleaning_Statistics": {"base_type": "file", "read_only": True, "value": None},
+ "DockedStatus": {"base_type": "boolean", "read_only": True, "value": 0},
+ "Error_Code": {"base_type": "integer", "read_only": True, "value": 7},
+ "Evacuating": {"base_type": "boolean", "read_only": True, "value": 1},
+ "Find_Device": {"base_type": "boolean", "read_only": False, "value": 0},
+ "LowLightMission": {"base_type": "boolean", "read_only": True, "value": 0},
+ "Nav_Module_FW_Version": {
+ "base_type": "string",
+ "read_only": True,
+ "value": "V3.4.11-20191015",
+ },
+ "Operating_Mode": {"base_type": "integer", "read_only": False, "value": 2},
+ "Power_Mode": {"base_type": "integer", "read_only": False, "value": 1},
+ "RSSI": {"base_type": "integer", "read_only": True, "value": -46},
+ "Recharge_Resume": {"base_type": "boolean", "read_only": False, "value": 1},
+ "Recharging_To_Resume": {"base_type": "boolean", "read_only": True, "value": 0},
+ "Robot_Firmware_Version": {
+ "base_type": "string",
+ "read_only": True,
+ "value": "Dummy Firmware 1.0",
+ },
+}
+
+TEST_USERNAME = "test-username"
+TEST_PASSWORD = "test-password"
+UNIQUE_ID = "foo@bar.com"
+CONFIG = {CONF_USERNAME: TEST_USERNAME, CONF_PASSWORD: TEST_PASSWORD}
+ENTRY_ID = "0123456789abcdef0123456789abcdef"
diff --git a/tests/components/sharkiq/test_config_flow.py b/tests/components/sharkiq/test_config_flow.py
new file mode 100644
index 00000000000..3183f6fdee2
--- /dev/null
+++ b/tests/components/sharkiq/test_config_flow.py
@@ -0,0 +1,113 @@
+"""Test the Shark IQ config flow."""
+import aiohttp
+import pytest
+from sharkiqpy import AylaApi, SharkIqAuthError
+
+from homeassistant import config_entries, setup
+from homeassistant.components.sharkiq.const import DOMAIN
+from homeassistant.core import HomeAssistant
+
+from .const import CONFIG, TEST_PASSWORD, TEST_USERNAME, UNIQUE_ID
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+
+async def test_form(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch("sharkiqpy.AylaApi.async_sign_in", return_value=True), patch(
+ "homeassistant.components.sharkiq.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.sharkiq.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ CONFIG,
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == f"{TEST_USERNAME:s}"
+ assert result2["data"] == {
+ "username": TEST_USERNAME,
+ "password": TEST_PASSWORD,
+ }
+ await hass.async_block_till_done()
+ mock_setup.assert_called_once()
+ mock_setup_entry.assert_called_once()
+
+
+@pytest.mark.parametrize(
+ "exc,base_error",
+ [
+ (SharkIqAuthError, "invalid_auth"),
+ (aiohttp.ClientError, "cannot_connect"),
+ (TypeError, "unknown"),
+ ],
+)
+async def test_form_error(hass: HomeAssistant, exc: Exception, base_error: str):
+ """Test form errors."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch.object(AylaApi, "async_sign_in", side_effect=exc):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ CONFIG,
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"].get("base") == base_error
+
+
+async def test_reauth_success(hass: HomeAssistant):
+ """Test reauth flow."""
+ with patch("sharkiqpy.AylaApi.async_sign_in", return_value=True):
+ mock_config = MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG)
+ mock_config.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "reauth", "unique_id": UNIQUE_ID}, data=CONFIG
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "reauth_successful"
+
+
+@pytest.mark.parametrize(
+ "side_effect,result_type,msg_field,msg",
+ [
+ (SharkIqAuthError, "form", "errors", "invalid_auth"),
+ (aiohttp.ClientError, "abort", "reason", "cannot_connect"),
+ (TypeError, "abort", "reason", "unknown"),
+ ],
+)
+async def test_reauth(
+ hass: HomeAssistant,
+ side_effect: Exception,
+ result_type: str,
+ msg_field: str,
+ msg: str,
+):
+ """Test reauth failures."""
+ with patch("sharkiqpy.AylaApi.async_sign_in", side_effect=side_effect):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": "reauth", "unique_id": UNIQUE_ID},
+ data=CONFIG,
+ )
+
+ msg_value = result[msg_field]
+ if msg_field == "errors":
+ msg_value = msg_value.get("base")
+
+ assert result["type"] == result_type
+ assert msg_value == msg
diff --git a/tests/components/sharkiq/test_vacuum.py b/tests/components/sharkiq/test_vacuum.py
new file mode 100644
index 00000000000..3b40011b8e6
--- /dev/null
+++ b/tests/components/sharkiq/test_vacuum.py
@@ -0,0 +1,242 @@
+"""Test the Shark IQ vacuum entity."""
+from copy import deepcopy
+import enum
+from typing import Any, Iterable, List, Optional
+
+import pytest
+from sharkiqpy import AylaApi, SharkIqAuthError, SharkIqVacuum
+
+from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY
+from homeassistant.components.sharkiq import DOMAIN
+from homeassistant.components.sharkiq.vacuum import (
+ ATTR_ERROR_CODE,
+ ATTR_ERROR_MSG,
+ ATTR_LOW_LIGHT,
+ ATTR_RECHARGE_RESUME,
+ FAN_SPEEDS_MAP,
+)
+from homeassistant.components.vacuum import (
+ ATTR_BATTERY_LEVEL,
+ ATTR_FAN_SPEED,
+ ATTR_FAN_SPEED_LIST,
+ SERVICE_LOCATE,
+ SERVICE_PAUSE,
+ SERVICE_RETURN_TO_BASE,
+ SERVICE_SET_FAN_SPEED,
+ SERVICE_START,
+ SERVICE_STOP,
+ STATE_CLEANING,
+ STATE_IDLE,
+ STATE_PAUSED,
+ STATE_RETURNING,
+ SUPPORT_BATTERY,
+ SUPPORT_FAN_SPEED,
+ SUPPORT_LOCATE,
+ SUPPORT_PAUSE,
+ SUPPORT_RETURN_HOME,
+ SUPPORT_START,
+ SUPPORT_STATE,
+ SUPPORT_STATUS,
+ SUPPORT_STOP,
+)
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ ATTR_SUPPORTED_FEATURES,
+ STATE_UNAVAILABLE,
+)
+from homeassistant.core import HomeAssistant
+from homeassistant.setup import async_setup_component
+
+from .const import (
+ CONFIG,
+ ENTRY_ID,
+ SHARK_DEVICE_DICT,
+ SHARK_METADATA_DICT,
+ SHARK_PROPERTIES_DICT,
+ TEST_USERNAME,
+)
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+VAC_ENTITY_ID = f"vacuum.{SHARK_DEVICE_DICT['product_name'].lower()}"
+EXPECTED_FEATURES = (
+ SUPPORT_BATTERY
+ | SUPPORT_FAN_SPEED
+ | SUPPORT_PAUSE
+ | SUPPORT_RETURN_HOME
+ | SUPPORT_START
+ | SUPPORT_STATE
+ | SUPPORT_STATUS
+ | SUPPORT_STOP
+ | SUPPORT_LOCATE
+)
+
+
+class MockAyla(AylaApi):
+ """Mocked AylaApi that doesn't do anything."""
+
+ async def async_sign_in(self):
+ """Instead of signing in, just return."""
+
+ async def async_list_devices(self) -> List[dict]:
+ """Return the device list."""
+ return [SHARK_DEVICE_DICT]
+
+ async def async_get_devices(self, update: bool = True) -> List[SharkIqVacuum]:
+ """Get the list of devices."""
+ shark = MockShark(self, SHARK_DEVICE_DICT)
+ shark.properties_full = deepcopy(SHARK_PROPERTIES_DICT)
+ shark._update_metadata(SHARK_METADATA_DICT) # pylint: disable=protected-access
+ return [shark]
+
+ async def async_request(self, http_method: str, url: str, **kwargs):
+ """Don't make an HTTP request."""
+
+
+class MockShark(SharkIqVacuum):
+ """Mocked SharkIqVacuum that won't hit the API."""
+
+ async def async_update(self, property_list: Optional[Iterable[str]] = None):
+ """Don't do anything."""
+
+ def set_property_value(self, property_name, value):
+ """Set a property locally without hitting the API."""
+ if isinstance(property_name, enum.Enum):
+ property_name = property_name.value
+ if isinstance(value, enum.Enum):
+ value = value.value
+ self.properties_full[property_name]["value"] = value
+
+ async def async_set_property_value(self, property_name, value):
+ """Set a property locally without hitting the API."""
+ self.set_property_value(property_name, value)
+
+
+@pytest.fixture(autouse=True)
+@patch("sharkiqpy.ayla_api.AylaApi", MockAyla)
+async def setup_integration(hass):
+ """Build the mock integration."""
+ entry = MockConfigEntry(
+ domain=DOMAIN, unique_id=TEST_USERNAME, data=CONFIG, entry_id=ENTRY_ID
+ )
+ entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+
+async def test_simple_properties(hass: HomeAssistant):
+ """Test that simple properties work as intended."""
+ state = hass.states.get(VAC_ENTITY_ID)
+ registry = await hass.helpers.entity_registry.async_get_registry()
+ entity = registry.async_get(VAC_ENTITY_ID)
+
+ assert entity
+ assert state
+ assert state.state == STATE_CLEANING
+ assert entity.unique_id == "AC000Wxxxxxxxxx"
+
+
+@pytest.mark.parametrize(
+ "attribute,target_value",
+ [
+ (ATTR_SUPPORTED_FEATURES, EXPECTED_FEATURES),
+ (ATTR_BATTERY_LEVEL, 50),
+ (ATTR_FAN_SPEED, "Eco"),
+ (ATTR_FAN_SPEED_LIST, list(FAN_SPEEDS_MAP)),
+ (ATTR_ERROR_CODE, 7),
+ (ATTR_ERROR_MSG, "Cliff sensor is blocked"),
+ (ATTR_LOW_LIGHT, False),
+ (ATTR_RECHARGE_RESUME, True),
+ ],
+)
+async def test_initial_attributes(
+ hass: HomeAssistant, attribute: str, target_value: Any
+):
+ """Test initial config attributes."""
+ state = hass.states.get(VAC_ENTITY_ID)
+ assert state.attributes.get(attribute) == target_value
+
+
+@pytest.mark.parametrize(
+ "service,target_state",
+ [
+ (SERVICE_STOP, STATE_IDLE),
+ (SERVICE_PAUSE, STATE_PAUSED),
+ (SERVICE_RETURN_TO_BASE, STATE_RETURNING),
+ (SERVICE_START, STATE_CLEANING),
+ ],
+)
+async def test_cleaning_states(hass: HomeAssistant, service: str, target_state: str):
+ """Test cleaning states."""
+ service_data = {ATTR_ENTITY_ID: VAC_ENTITY_ID}
+ await hass.services.async_call("vacuum", service, service_data, blocking=True)
+ state = hass.states.get(VAC_ENTITY_ID)
+ assert state.state == target_state
+
+
+@pytest.mark.parametrize("fan_speed", list(FAN_SPEEDS_MAP))
+async def test_fan_speed(hass: HomeAssistant, fan_speed: str) -> None:
+ """Test setting fan speeds."""
+ service_data = {ATTR_ENTITY_ID: VAC_ENTITY_ID, ATTR_FAN_SPEED: fan_speed}
+ await hass.services.async_call(
+ "vacuum", SERVICE_SET_FAN_SPEED, service_data, blocking=True
+ )
+ state = hass.states.get(VAC_ENTITY_ID)
+ assert state.attributes.get(ATTR_FAN_SPEED) == fan_speed
+
+
+@pytest.mark.parametrize(
+ "device_property,target_value",
+ [
+ ("manufacturer", "Shark"),
+ ("model", "RV1001AE"),
+ ("name", "Sharknado"),
+ ("sw_version", "Dummy Firmware 1.0"),
+ ],
+)
+async def test_device_properties(
+ hass: HomeAssistant, device_property: str, target_value: str
+):
+ """Test device properties."""
+ registry = await hass.helpers.device_registry.async_get_registry()
+ device = registry.async_get_device({(DOMAIN, "AC000Wxxxxxxxxx")}, [])
+ assert getattr(device, device_property) == target_value
+
+
+async def test_locate(hass):
+ """Test that the locate command works."""
+ with patch.object(SharkIqVacuum, "async_find_device") as mock_locate:
+ data = {ATTR_ENTITY_ID: VAC_ENTITY_ID}
+ await hass.services.async_call("vacuum", SERVICE_LOCATE, data, blocking=True)
+ mock_locate.assert_called_once()
+
+
+@pytest.mark.parametrize(
+ "side_effect,success",
+ [
+ (None, True),
+ (SharkIqAuthError, False),
+ (RuntimeError, False),
+ ],
+)
+async def test_coordinator_updates(
+ hass: HomeAssistant, side_effect: Optional[Exception], success: bool
+) -> None:
+ """Test the update coordinator update functions."""
+ coordinator = hass.data[DOMAIN][ENTRY_ID]
+
+ await async_setup_component(hass, "homeassistant", {})
+
+ with patch.object(
+ MockShark, "async_update", side_effect=side_effect
+ ) as mock_update:
+ data = {ATTR_ENTITY_ID: [VAC_ENTITY_ID]}
+ await hass.services.async_call(
+ "homeassistant", SERVICE_UPDATE_ENTITY, data, blocking=True
+ )
+ assert coordinator.last_update_success == success
+ mock_update.assert_called_once()
+
+ state = hass.states.get(VAC_ENTITY_ID)
+ assert (state.state == STATE_UNAVAILABLE) != success
diff --git a/tests/components/shell_command/test_init.py b/tests/components/shell_command/test_init.py
index 7019d22fac8..1f8e442e93d 100644
--- a/tests/components/shell_command/test_init.py
+++ b/tests/components/shell_command/test_init.py
@@ -12,11 +12,10 @@ from tests.async_mock import Mock, patch
from tests.common import get_test_home_assistant
-def mock_process_creator(error: bool = False) -> asyncio.coroutine:
+def mock_process_creator(error: bool = False):
"""Mock a coroutine that creates a process when yielded."""
- @asyncio.coroutine
- def communicate() -> Tuple[bytes, bytes]:
+ async def communicate() -> Tuple[bytes, bytes]:
"""Mock a coroutine that runs a process when yielded.
Returns a tuple of (stdout, stderr).
diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py
new file mode 100644
index 00000000000..3c502c81deb
--- /dev/null
+++ b/tests/components/shelly/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Shelly integration."""
diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py
new file mode 100644
index 00000000000..93192e89df3
--- /dev/null
+++ b/tests/components/shelly/test_config_flow.py
@@ -0,0 +1,387 @@
+"""Test the Shelly config flow."""
+import asyncio
+
+import aiohttp
+import pytest
+
+from homeassistant import config_entries, setup
+from homeassistant.components.shelly.const import DOMAIN
+
+from tests.async_mock import AsyncMock, Mock, patch
+from tests.common import MockConfigEntry
+
+
+async def test_form(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch(
+ "aioshelly.get_info",
+ return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False},
+ ), patch(
+ "aioshelly.Device.create",
+ return_value=Mock(
+ shutdown=AsyncMock(),
+ settings={"name": "Test name", "device": {"mac": "test-mac"}},
+ ),
+ ), patch(
+ "homeassistant.components.shelly.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.shelly.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": "1.1.1.1"},
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "Test name"
+ assert result2["data"] == {
+ "host": "1.1.1.1",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_auth(hass):
+ """Test manual configuration if auth is required."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch(
+ "aioshelly.get_info",
+ return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True},
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": "1.1.1.1"},
+ )
+
+ assert result2["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch(
+ "aioshelly.Device.create",
+ return_value=Mock(
+ shutdown=AsyncMock(),
+ settings={"name": "Test name", "device": {"mac": "test-mac"}},
+ ),
+ ), patch(
+ "homeassistant.components.shelly.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.shelly.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result3 = await hass.config_entries.flow.async_configure(
+ result2["flow_id"],
+ {"username": "test username", "password": "test password"},
+ )
+
+ assert result3["type"] == "create_entry"
+ assert result3["title"] == "Test name"
+ assert result3["data"] == {
+ "host": "1.1.1.1",
+ "username": "test username",
+ "password": "test password",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+@pytest.mark.parametrize(
+ "error", [(asyncio.TimeoutError, "cannot_connect"), (ValueError, "unknown")]
+)
+async def test_form_errors_get_info(hass, error):
+ """Test we handle errors."""
+ exc, base_error = error
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "aioshelly.get_info",
+ side_effect=exc,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": "1.1.1.1"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": base_error}
+
+
+@pytest.mark.parametrize(
+ "error", [(asyncio.TimeoutError, "cannot_connect"), (ValueError, "unknown")]
+)
+async def test_form_errors_test_connection(hass, error):
+ """Test we handle errors."""
+ exc, base_error = error
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "aioshelly.get_info", return_value={"mac": "test-mac", "auth": False}
+ ), patch(
+ "aioshelly.Device.create",
+ side_effect=exc,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": "1.1.1.1"},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": base_error}
+
+
+async def test_form_already_configured(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ entry = MockConfigEntry(
+ domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0"}
+ )
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "aioshelly.get_info",
+ return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False},
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": "1.1.1.1"},
+ )
+
+ assert result2["type"] == "abort"
+ assert result2["reason"] == "already_configured"
+
+ # Test config entry got updated with latest IP
+ assert entry.data["host"] == "1.1.1.1"
+
+
+@pytest.mark.parametrize(
+ "error",
+ [
+ (aiohttp.ClientResponseError(Mock(), (), status=400), "cannot_connect"),
+ (aiohttp.ClientResponseError(Mock(), (), status=401), "invalid_auth"),
+ (asyncio.TimeoutError, "cannot_connect"),
+ (ValueError, "unknown"),
+ ],
+)
+async def test_form_auth_errors_test_connection(hass, error):
+ """Test we handle errors in authenticated devices."""
+ exc, base_error = error
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch("aioshelly.get_info", return_value={"mac": "test-mac", "auth": True}):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"host": "1.1.1.1"},
+ )
+
+ with patch(
+ "aioshelly.Device.create",
+ side_effect=exc,
+ ):
+ result3 = await hass.config_entries.flow.async_configure(
+ result2["flow_id"],
+ {"username": "test username", "password": "test password"},
+ )
+ assert result3["type"] == "form"
+ assert result3["errors"] == {"base": base_error}
+
+
+async def test_zeroconf(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ with patch(
+ "aioshelly.get_info",
+ return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False},
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ data={"host": "1.1.1.1", "name": "shelly1pm-12345"},
+ context={"source": config_entries.SOURCE_ZEROCONF},
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch(
+ "aioshelly.Device.create",
+ return_value=Mock(
+ shutdown=AsyncMock(),
+ settings={"name": "Test name", "device": {"mac": "test-mac"}},
+ ),
+ ), patch(
+ "homeassistant.components.shelly.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.shelly.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == "Test name"
+ assert result2["data"] == {
+ "host": "1.1.1.1",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+@pytest.mark.parametrize(
+ "error", [(asyncio.TimeoutError, "cannot_connect"), (ValueError, "unknown")]
+)
+async def test_zeroconf_confirm_error(hass, error):
+ """Test we get the form."""
+ exc, base_error = error
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ with patch(
+ "aioshelly.get_info",
+ return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False},
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ data={"host": "1.1.1.1", "name": "shelly1pm-12345"},
+ context={"source": config_entries.SOURCE_ZEROCONF},
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch(
+ "aioshelly.Device.create",
+ side_effect=exc,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": base_error}
+
+
+async def test_zeroconf_already_configured(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ entry = MockConfigEntry(
+ domain="shelly", unique_id="test-mac", data={"host": "0.0.0.0"}
+ )
+ entry.add_to_hass(hass)
+
+ with patch(
+ "aioshelly.get_info",
+ return_value={"mac": "test-mac", "type": "SHSW-1", "auth": False},
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ data={"host": "1.1.1.1", "name": "shelly1pm-12345"},
+ context={"source": config_entries.SOURCE_ZEROCONF},
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+
+ # Test config entry got updated with latest IP
+ assert entry.data["host"] == "1.1.1.1"
+
+
+async def test_zeroconf_cannot_connect(hass):
+ """Test we get the form."""
+ with patch(
+ "aioshelly.get_info",
+ side_effect=asyncio.TimeoutError,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ data={"host": "1.1.1.1", "name": "shelly1pm-12345"},
+ context={"source": config_entries.SOURCE_ZEROCONF},
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "cannot_connect"
+
+
+async def test_zeroconf_require_auth(hass):
+ """Test zeroconf if auth is required."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+
+ with patch(
+ "aioshelly.get_info",
+ return_value={"mac": "test-mac", "type": "SHSW-1", "auth": True},
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ data={"host": "1.1.1.1", "name": "shelly1pm-12345"},
+ context={"source": config_entries.SOURCE_ZEROCONF},
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
+ assert result2["type"] == "form"
+ assert result2["errors"] == {}
+
+ with patch(
+ "aioshelly.Device.create",
+ return_value=Mock(
+ shutdown=AsyncMock(),
+ settings={"name": "Test name", "device": {"mac": "test-mac"}},
+ ),
+ ), patch(
+ "homeassistant.components.shelly.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.shelly.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result3 = await hass.config_entries.flow.async_configure(
+ result2["flow_id"],
+ {"username": "test username", "password": "test password"},
+ )
+
+ assert result3["type"] == "create_entry"
+ assert result3["title"] == "Test name"
+ assert result3["data"] == {
+ "host": "1.1.1.1",
+ "username": "test username",
+ "password": "test password",
+ }
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_zeroconf_not_shelly(hass):
+ """Test we filter out non-shelly devices."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ data={"host": "1.1.1.1", "name": "notshelly"},
+ context={"source": config_entries.SOURCE_ZEROCONF},
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "not_shelly"
diff --git a/tests/components/signal_messenger/test_notify.py b/tests/components/signal_messenger/test_notify.py
index b4239dacfab..407068e6f3e 100644
--- a/tests/components/signal_messenger/test_notify.py
+++ b/tests/components/signal_messenger/test_notify.py
@@ -53,7 +53,9 @@ class TestSignalMesssenger(unittest.TestCase):
"""Test send message."""
message = "Testing Signal Messenger platform :)"
mock.register_uri(
- "POST", "http://127.0.0.1:8080/v2/send", status_code=201,
+ "POST",
+ "http://127.0.0.1:8080/v2/send",
+ status_code=201,
)
mock.register_uri(
"GET",
@@ -74,7 +76,9 @@ class TestSignalMesssenger(unittest.TestCase):
"""Test send message."""
message = "Testing Signal Messenger platform with attachment :)"
mock.register_uri(
- "POST", "http://127.0.0.1:8080/v2/send", status_code=201,
+ "POST",
+ "http://127.0.0.1:8080/v2/send",
+ status_code=201,
)
mock.register_uri(
"GET",
@@ -102,7 +106,9 @@ class TestSignalMesssenger(unittest.TestCase):
"""Test send message."""
message = "Testing Signal Messenger platform :)"
mock.register_uri(
- "POST", "http://127.0.0.1:8080/v2/send", status_code=201,
+ "POST",
+ "http://127.0.0.1:8080/v2/send",
+ status_code=201,
)
mock.register_uri(
"GET",
diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py
index d94c431aa14..86cd0fc384c 100644
--- a/tests/components/simplisafe/test_config_flow.py
+++ b/tests/components/simplisafe/test_config_flow.py
@@ -48,7 +48,8 @@ async def test_invalid_credentials(hass):
conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}
with patch(
- "simplipy.API.login_via_credentials", side_effect=InvalidCredentialsError,
+ "simplipy.API.login_via_credentials",
+ side_effect=InvalidCredentialsError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=conf
@@ -61,7 +62,10 @@ async def test_options_flow(hass):
conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}
config_entry = MockConfigEntry(
- domain=DOMAIN, unique_id="abcde12345", data=conf, options={CONF_CODE: "1234"},
+ domain=DOMAIN,
+ unique_id="abcde12345",
+ data=conf,
+ options={CONF_CODE: "1234"},
)
config_entry.add_to_hass(hass)
@@ -217,7 +221,8 @@ async def test_unknown_error(hass):
conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"}
with patch(
- "simplipy.API.login_via_credentials", side_effect=SimplipyError,
+ "simplipy.API.login_via_credentials",
+ side_effect=SimplipyError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=conf
diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py
index 265cfde69bb..2200dd66490 100644
--- a/tests/components/smappee/test_config_flow.py
+++ b/tests/components/smappee/test_config_flow.py
@@ -1,16 +1,339 @@
-"""Test the Smappee config flow."""
-from homeassistant import config_entries, setup
-from homeassistant.components.smappee.const import AUTHORIZE_URL, DOMAIN, TOKEN_URL
+"""Test the Smappee component config flow module."""
+from homeassistant import data_entry_flow, setup
+from homeassistant.components.smappee.const import (
+ CONF_HOSTNAME,
+ CONF_SERIALNUMBER,
+ DOMAIN,
+ ENV_CLOUD,
+ ENV_LOCAL,
+ TOKEN_URL,
+)
+from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF
from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET
from homeassistant.helpers import config_entry_oauth2_flow
from tests.async_mock import patch
+from tests.common import MockConfigEntry
CLIENT_ID = "1234"
CLIENT_SECRET = "5678"
-async def test_full_flow(hass, aiohttp_client, aioclient_mock):
+async def test_show_user_form(hass):
+ """Test that the user set up form is served."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ )
+
+ assert result["step_id"] == "environment"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+
+
+async def test_show_user_host_form(hass):
+ """Test that the host form is served after choosing the local option."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ )
+ assert result["step_id"] == "environment"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"environment": ENV_LOCAL}
+ )
+
+ assert result["step_id"] == ENV_LOCAL
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+
+
+async def test_show_zeroconf_connection_error_form(hass):
+ """Test that the zeroconf confirmation form is served."""
+ with patch("pysmappee.api.SmappeeLocalApi.logon", return_value=None):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data={
+ "host": "1.2.3.4",
+ "port": 22,
+ CONF_HOSTNAME: "Smappee1006000212.local.",
+ "type": "_ssh._tcp.local.",
+ "name": "Smappee1006000212._ssh._tcp.local.",
+ "properties": {"_raw": {}},
+ },
+ )
+
+ assert result["description_placeholders"] == {CONF_SERIALNUMBER: "1006000212"}
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "zeroconf_confirm"
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": "1.2.3.4"}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "connection_error"
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 0
+
+
+async def test_connection_error(hass):
+ """Test we show user form on Smappee connection error."""
+ with patch("pysmappee.api.SmappeeLocalApi.logon", return_value=None):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ )
+ assert result["step_id"] == "environment"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"environment": ENV_LOCAL}
+ )
+ assert result["step_id"] == ENV_LOCAL
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": "1.2.3.4"}
+ )
+ assert result["reason"] == "connection_error"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+
+
+async def test_zeroconf_wrong_mdns(hass):
+ """Test we abort if unsupported mDNS name is discovered."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data={
+ "host": "1.2.3.4",
+ "port": 22,
+ CONF_HOSTNAME: "example.local.",
+ "type": "_ssh._tcp.local.",
+ "name": "example._ssh._tcp.local.",
+ "properties": {"_raw": {}},
+ },
+ )
+
+ assert result["reason"] == "invalid_mdns"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+
+
+async def test_full_user_wrong_mdns(hass):
+ """Test we abort user flow if unsupported mDNS name got resolved."""
+ with patch("pysmappee.api.SmappeeLocalApi.logon", return_value={}), patch(
+ "pysmappee.api.SmappeeLocalApi.load_advanced_config",
+ return_value=[{"key": "mdnsHostName", "value": "Smappee5010000001"}],
+ ), patch(
+ "pysmappee.api.SmappeeLocalApi.load_command_control_config", return_value=[]
+ ), patch(
+ "pysmappee.api.SmappeeLocalApi.load_instantaneous",
+ return_value=[{"key": "phase0ActivePower", "value": 0}],
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ )
+ assert result["step_id"] == "environment"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"environment": ENV_LOCAL}
+ )
+ assert result["step_id"] == ENV_LOCAL
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": "1.2.3.4"}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "invalid_mdns"
+
+
+async def test_user_device_exists_abort(hass):
+ """Test we abort user flow if Smappee device already configured."""
+ with patch("pysmappee.api.SmappeeLocalApi.logon", return_value={}), patch(
+ "pysmappee.api.SmappeeLocalApi.load_advanced_config",
+ return_value=[{"key": "mdnsHostName", "value": "Smappee1006000212"}],
+ ), patch(
+ "pysmappee.api.SmappeeLocalApi.load_command_control_config", return_value=[]
+ ), patch(
+ "pysmappee.api.SmappeeLocalApi.load_instantaneous",
+ return_value=[{"key": "phase0ActivePower", "value": 0}],
+ ):
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={"host": "1.2.3.4"},
+ unique_id="1006000212",
+ source=SOURCE_USER,
+ )
+ config_entry.add_to_hass(hass)
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ )
+ assert result["step_id"] == "environment"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"environment": ENV_LOCAL}
+ )
+ assert result["step_id"] == ENV_LOCAL
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": "1.2.3.4"}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+
+async def test_zeroconf_device_exists_abort(hass):
+ """Test we abort zeroconf flow if Smappee device already configured."""
+ with patch("pysmappee.api.SmappeeLocalApi.logon", return_value={}), patch(
+ "pysmappee.api.SmappeeLocalApi.load_advanced_config",
+ return_value=[{"key": "mdnsHostName", "value": "Smappee1006000212"}],
+ ), patch(
+ "pysmappee.api.SmappeeLocalApi.load_command_control_config", return_value=[]
+ ), patch(
+ "pysmappee.api.SmappeeLocalApi.load_instantaneous",
+ return_value=[{"key": "phase0ActivePower", "value": 0}],
+ ):
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={"host": "1.2.3.4"},
+ unique_id="1006000212",
+ source=SOURCE_USER,
+ )
+ config_entry.add_to_hass(hass)
+
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data={
+ "host": "1.2.3.4",
+ "port": 22,
+ CONF_HOSTNAME: "Smappee1006000212.local.",
+ "type": "_ssh._tcp.local.",
+ "name": "Smappee1006000212._ssh._tcp.local.",
+ "properties": {"_raw": {}},
+ },
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+
+async def test_cloud_device_exists_abort(hass):
+ """Test we abort cloud flow if Smappee Cloud device already configured."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id="smappeeCloud",
+ source=SOURCE_USER,
+ )
+ config_entry.add_to_hass(hass)
+
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured_device"
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+
+async def test_zeroconf_abort_if_cloud_device_exists(hass):
+ """Test we abort zeroconf flow if Smappee Cloud device already configured."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id="smappeeCloud",
+ source=SOURCE_USER,
+ )
+ config_entry.add_to_hass(hass)
+
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data={
+ "host": "1.2.3.4",
+ "port": 22,
+ CONF_HOSTNAME: "Smappee1006000212.local.",
+ "type": "_ssh._tcp.local.",
+ "name": "Smappee1006000212._ssh._tcp.local.",
+ "properties": {"_raw": {}},
+ },
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured_device"
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+
+async def test_zeroconf_confirm_abort_if_cloud_device_exists(hass):
+ """Test we abort zeroconf confirm flow if Smappee Cloud device already configured."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data={
+ "host": "1.2.3.4",
+ "port": 22,
+ CONF_HOSTNAME: "Smappee1006000212.local.",
+ "type": "_ssh._tcp.local.",
+ "name": "Smappee1006000212._ssh._tcp.local.",
+ "properties": {"_raw": {}},
+ },
+ )
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id="smappeeCloud",
+ source=SOURCE_USER,
+ )
+ config_entry.add_to_hass(hass)
+
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured_device"
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+
+async def test_abort_cloud_flow_if_local_device_exists(hass):
+ """Test we abort the cloud flow if a Smappee local device already configured."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={"host": "1.2.3.4"},
+ unique_id="1006000212",
+ source=SOURCE_USER,
+ )
+ config_entry.add_to_hass(hass)
+
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"environment": ENV_CLOUD}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured_local_device"
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+
+async def test_full_user_flow(hass, aiohttp_client, aioclient_mock, current_request):
"""Check full flow."""
assert await setup.async_setup_component(
hass,
@@ -22,16 +345,14 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock):
)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_USER}
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ )
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"environment": ENV_CLOUD}
)
state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]})
- assert result["url"] == (
- f"{AUTHORIZE_URL['PRODUCTION']}?response_type=code&client_id={CLIENT_ID}"
- "&redirect_uri=https://example.com/auth/external/callback"
- f"&state={state}"
- )
-
client = await aiohttp_client(hass.http.app)
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
@@ -54,3 +375,83 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock):
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1
+
+
+async def test_full_zeroconf_flow(hass):
+ """Test the full zeroconf flow."""
+ with patch("pysmappee.api.SmappeeLocalApi.logon", return_value={}), patch(
+ "pysmappee.api.SmappeeLocalApi.load_advanced_config",
+ return_value=[{"key": "mdnsHostName", "value": "Smappee1006000212"}],
+ ), patch(
+ "pysmappee.api.SmappeeLocalApi.load_command_control_config", return_value=[]
+ ), patch(
+ "pysmappee.api.SmappeeLocalApi.load_instantaneous",
+ return_value=[{"key": "phase0ActivePower", "value": 0}],
+ ), patch(
+ "homeassistant.components.smappee.async_setup_entry", return_value=True
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_ZEROCONF},
+ data={
+ "host": "1.2.3.4",
+ "port": 22,
+ CONF_HOSTNAME: "Smappee1006000212.local.",
+ "type": "_ssh._tcp.local.",
+ "name": "Smappee1006000212._ssh._tcp.local.",
+ "properties": {"_raw": {}},
+ },
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "zeroconf_confirm"
+ assert result["description_placeholders"] == {CONF_SERIALNUMBER: "1006000212"}
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": "1.2.3.4"}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "smappee1006000212"
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+ entry = hass.config_entries.async_entries(DOMAIN)[0]
+ assert entry.unique_id == "1006000212"
+
+
+async def test_full_user_local_flow(hass):
+ """Test the full zeroconf flow."""
+ with patch("pysmappee.api.SmappeeLocalApi.logon", return_value={}), patch(
+ "pysmappee.api.SmappeeLocalApi.load_advanced_config",
+ return_value=[{"key": "mdnsHostName", "value": "Smappee1006000212"}],
+ ), patch(
+ "pysmappee.api.SmappeeLocalApi.load_command_control_config", return_value=[]
+ ), patch(
+ "pysmappee.api.SmappeeLocalApi.load_instantaneous",
+ return_value=[{"key": "phase0ActivePower", "value": 0}],
+ ), patch(
+ "homeassistant.components.smappee.async_setup_entry", return_value=True
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": SOURCE_USER},
+ )
+ assert result["step_id"] == "environment"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["description_placeholders"] is None
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {"environment": ENV_LOCAL},
+ )
+ assert result["step_id"] == ENV_LOCAL
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {"host": "1.2.3.4"}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == "smappee1006000212"
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+ entry = hass.config_entries.async_entries(DOMAIN)[0]
+ assert entry.unique_id == "1006000212"
diff --git a/tests/components/smappee/test_init.py b/tests/components/smappee/test_init.py
new file mode 100644
index 00000000000..9a81441e8b3
--- /dev/null
+++ b/tests/components/smappee/test_init.py
@@ -0,0 +1,32 @@
+"""Tests for the Smappee component init module."""
+from homeassistant.components.smappee.const import DOMAIN
+from homeassistant.config_entries import SOURCE_ZEROCONF
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+
+async def test_unload_config_entry(hass):
+ """Test unload config entry flow."""
+ with patch("pysmappee.api.SmappeeLocalApi.logon", return_value={}), patch(
+ "pysmappee.api.SmappeeLocalApi.load_advanced_config",
+ return_value=[{"key": "mdnsHostName", "value": "Smappee1006000212"}],
+ ), patch(
+ "pysmappee.api.SmappeeLocalApi.load_command_control_config", return_value=[]
+ ), patch(
+ "pysmappee.api.SmappeeLocalApi.load_instantaneous",
+ return_value=[{"key": "phase0ActivePower", "value": 0}],
+ ):
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={"host": "1.2.3.4"},
+ unique_id="smappee1006000212",
+ source=SOURCE_ZEROCONF,
+ )
+ config_entry.add_to_hass(hass)
+ assert len(hass.config_entries.async_entries(DOMAIN)) == 1
+
+ entry = hass.config_entries.async_entries(DOMAIN)[0]
+ await hass.config_entries.async_unload(entry.entry_id)
+ await hass.async_block_till_done()
+ assert not hass.data.get(DOMAIN)
diff --git a/tests/components/smart_meter_texas/__init__.py b/tests/components/smart_meter_texas/__init__.py
new file mode 100644
index 00000000000..5ad08df7e46
--- /dev/null
+++ b/tests/components/smart_meter_texas/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Smart Meter Texas integration."""
diff --git a/tests/components/smart_meter_texas/conftest.py b/tests/components/smart_meter_texas/conftest.py
new file mode 100644
index 00000000000..8b089e5d31b
--- /dev/null
+++ b/tests/components/smart_meter_texas/conftest.py
@@ -0,0 +1,103 @@
+"""Test configuration and mocks for Smart Meter Texas."""
+import asyncio
+import json
+from pathlib import Path
+
+import pytest
+from smart_meter_texas.const import (
+ AUTH_ENDPOINT,
+ BASE_ENDPOINT,
+ BASE_URL,
+ LATEST_OD_READ_ENDPOINT,
+ METER_ENDPOINT,
+ OD_READ_ENDPOINT,
+)
+
+from homeassistant.components.homeassistant import (
+ DOMAIN as HA_DOMAIN,
+ SERVICE_UPDATE_ENTITY,
+)
+from homeassistant.components.smart_meter_texas.const import DOMAIN
+from homeassistant.const import ATTR_ENTITY_ID
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockConfigEntry, load_fixture
+
+TEST_ENTITY_ID = "sensor.electric_meter_123456789"
+
+
+def load_smt_fixture(name):
+ """Return a dict of the json fixture."""
+ json_fixture = load_fixture(Path() / DOMAIN / f"{name}.json")
+ return json.loads(json_fixture)
+
+
+async def setup_integration(hass, config_entry, aioclient_mock, **kwargs):
+ """Initialize the Smart Meter Texas integration for testing."""
+ mock_connection(aioclient_mock, **kwargs)
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+
+async def refresh_data(hass, config_entry, aioclient_mock):
+ """Request a DataUpdateCoordinator refresh."""
+ mock_connection(aioclient_mock)
+ await async_setup_component(hass, HA_DOMAIN, {})
+ await hass.services.async_call(
+ HA_DOMAIN,
+ SERVICE_UPDATE_ENTITY,
+ {ATTR_ENTITY_ID: TEST_ENTITY_ID},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+
+def mock_connection(
+ aioclient_mock, auth_fail=False, auth_timeout=False, bad_reading=False
+):
+ """Mock all calls to the API."""
+ aioclient_mock.get(BASE_URL)
+
+ auth_endpoint = f"{BASE_ENDPOINT}{AUTH_ENDPOINT}"
+ if not auth_fail and not auth_timeout:
+ aioclient_mock.post(
+ auth_endpoint,
+ json={"token": "token123"},
+ )
+ elif auth_fail:
+ aioclient_mock.post(
+ auth_endpoint,
+ status=400,
+ json={"errormessage": "ERR-USR-INVALIDPASSWORDERROR"},
+ )
+ else: # auth_timeout
+ aioclient_mock.post(auth_endpoint, exc=asyncio.TimeoutError)
+
+ aioclient_mock.post(
+ f"{BASE_ENDPOINT}{METER_ENDPOINT}",
+ json=load_smt_fixture("meter"),
+ )
+ aioclient_mock.post(f"{BASE_ENDPOINT}{OD_READ_ENDPOINT}", json={"data": None})
+ if not bad_reading:
+ aioclient_mock.post(
+ f"{BASE_ENDPOINT}{LATEST_OD_READ_ENDPOINT}",
+ json=load_smt_fixture("latestodrread"),
+ )
+ else:
+ aioclient_mock.post(
+ f"{BASE_ENDPOINT}{LATEST_OD_READ_ENDPOINT}",
+ json={},
+ )
+
+
+@pytest.fixture(name="config_entry")
+def mock_config_entry(hass):
+ """Return a mock config entry."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id="user123",
+ data={"username": "user123", "password": "password123"},
+ )
+ config_entry.add_to_hass(hass)
+
+ return config_entry
diff --git a/tests/components/smart_meter_texas/test_config_flow.py b/tests/components/smart_meter_texas/test_config_flow.py
new file mode 100644
index 00000000000..729cb0a90b2
--- /dev/null
+++ b/tests/components/smart_meter_texas/test_config_flow.py
@@ -0,0 +1,126 @@
+"""Test the Smart Meter Texas config flow."""
+import asyncio
+
+from aiohttp import ClientError
+import pytest
+from smart_meter_texas.exceptions import (
+ SmartMeterTexasAPIError,
+ SmartMeterTexasAuthError,
+)
+
+from homeassistant import config_entries, setup
+from homeassistant.components.smart_meter_texas.const import DOMAIN
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+TEST_LOGIN = {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"}
+
+
+async def test_form(hass):
+ """Test we get the form."""
+ await setup.async_setup_component(hass, "persistent_notification", {})
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["errors"] == {}
+
+ with patch("smart_meter_texas.Client.authenticate", return_value=True), patch(
+ "homeassistant.components.smart_meter_texas.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.smart_meter_texas.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_LOGIN
+ )
+
+ assert result2["type"] == "create_entry"
+ assert result2["title"] == TEST_LOGIN[CONF_USERNAME]
+ assert result2["data"] == TEST_LOGIN
+ await hass.async_block_till_done()
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+
+
+async def test_form_invalid_auth(hass):
+ """Test we handle invalid auth."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "smart_meter_texas.Client.authenticate",
+ side_effect=SmartMeterTexasAuthError,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ TEST_LOGIN,
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "invalid_auth"}
+
+
+@pytest.mark.parametrize(
+ "side_effect", [asyncio.TimeoutError, ClientError, SmartMeterTexasAPIError]
+)
+async def test_form_cannot_connect(hass, side_effect):
+ """Test we handle cannot connect error."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "smart_meter_texas.Client.authenticate",
+ side_effect=side_effect,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], TEST_LOGIN
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_unknown_exception(hass):
+ """Test base exception is handled."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with patch(
+ "smart_meter_texas.Client.authenticate",
+ side_effect=Exception,
+ ):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ TEST_LOGIN,
+ )
+
+ assert result2["type"] == "form"
+ assert result2["errors"] == {"base": "unknown"}
+
+
+async def test_form_duplicate_account(hass):
+ """Test that a duplicate account cannot be configured."""
+ MockConfigEntry(
+ domain=DOMAIN,
+ unique_id="user123",
+ data={"username": "user123", "password": "password123"},
+ ).add_to_hass(hass)
+
+ with patch(
+ "smart_meter_texas.Client.authenticate",
+ return_value=True,
+ ):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data={"username": "user123", "password": "password123"},
+ )
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
diff --git a/tests/components/smart_meter_texas/test_init.py b/tests/components/smart_meter_texas/test_init.py
new file mode 100644
index 00000000000..1ccb15714b1
--- /dev/null
+++ b/tests/components/smart_meter_texas/test_init.py
@@ -0,0 +1,71 @@
+"""Test the Smart Meter Texas module."""
+from homeassistant.components.homeassistant import (
+ DOMAIN as HA_DOMAIN,
+ SERVICE_UPDATE_ENTITY,
+)
+from homeassistant.components.smart_meter_texas.const import DOMAIN
+from homeassistant.config_entries import (
+ ENTRY_STATE_LOADED,
+ ENTRY_STATE_NOT_LOADED,
+ ENTRY_STATE_SETUP_ERROR,
+ ENTRY_STATE_SETUP_RETRY,
+)
+from homeassistant.const import ATTR_ENTITY_ID
+from homeassistant.setup import async_setup_component
+
+from .conftest import TEST_ENTITY_ID, setup_integration
+
+from tests.async_mock import patch
+
+
+async def test_setup_with_no_config(hass):
+ """Test that no config is successful."""
+ assert await async_setup_component(hass, DOMAIN, {}) is True
+ await hass.async_block_till_done()
+
+ # Assert no flows were started.
+ assert len(hass.config_entries.flow.async_progress()) == 0
+
+
+async def test_auth_failure(hass, config_entry, aioclient_mock):
+ """Test if user's username or password is not accepted."""
+ await setup_integration(hass, config_entry, aioclient_mock, auth_fail=True)
+
+ assert config_entry.state == ENTRY_STATE_SETUP_ERROR
+
+
+async def test_api_timeout(hass, config_entry, aioclient_mock):
+ """Test that a timeout results in ConfigEntryNotReady."""
+ await setup_integration(hass, config_entry, aioclient_mock, auth_timeout=True)
+
+ assert config_entry.state == ENTRY_STATE_SETUP_RETRY
+
+
+async def test_update_failure(hass, config_entry, aioclient_mock):
+ """Test that the coordinator handles a bad response."""
+ await setup_integration(hass, config_entry, aioclient_mock, bad_reading=True)
+ await async_setup_component(hass, HA_DOMAIN, {})
+ with patch("smart_meter_texas.Meter.read_meter") as updater:
+ await hass.services.async_call(
+ HA_DOMAIN,
+ SERVICE_UPDATE_ENTITY,
+ {ATTR_ENTITY_ID: TEST_ENTITY_ID},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ updater.assert_called_once()
+
+
+async def test_unload_config_entry(hass, config_entry, aioclient_mock):
+ """Test entry unloading."""
+ await setup_integration(hass, config_entry, aioclient_mock)
+
+ config_entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(config_entries) == 1
+ assert config_entries[0] is config_entry
+ assert config_entry.state == ENTRY_STATE_LOADED
+
+ await hass.config_entries.async_unload(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert config_entry.state == ENTRY_STATE_NOT_LOADED
diff --git a/tests/components/smart_meter_texas/test_sensor.py b/tests/components/smart_meter_texas/test_sensor.py
new file mode 100644
index 00000000000..104da011d90
--- /dev/null
+++ b/tests/components/smart_meter_texas/test_sensor.py
@@ -0,0 +1,61 @@
+"""Test the Smart Meter Texas sensor entity."""
+from homeassistant.components.homeassistant import (
+ DOMAIN as HA_DOMAIN,
+ SERVICE_UPDATE_ENTITY,
+)
+from homeassistant.components.smart_meter_texas.const import (
+ ELECTRIC_METER,
+ ESIID,
+ METER_NUMBER,
+)
+from homeassistant.const import ATTR_ENTITY_ID, CONF_ADDRESS
+from homeassistant.setup import async_setup_component
+
+from .conftest import TEST_ENTITY_ID, refresh_data, setup_integration
+
+from tests.async_mock import patch
+
+
+async def test_sensor(hass, config_entry, aioclient_mock):
+ """Test that the sensor is setup."""
+ await setup_integration(hass, config_entry, aioclient_mock)
+ await refresh_data(hass, config_entry, aioclient_mock)
+ meter = hass.states.get(TEST_ENTITY_ID)
+
+ assert meter
+ assert meter.state == "9751.212"
+
+
+async def test_name(hass, config_entry, aioclient_mock):
+ """Test sensor name property."""
+ await setup_integration(hass, config_entry, aioclient_mock)
+ await refresh_data(hass, config_entry, aioclient_mock)
+ meter = hass.states.get(TEST_ENTITY_ID)
+
+ assert meter.name == f"{ELECTRIC_METER} 123456789"
+
+
+async def test_attributes(hass, config_entry, aioclient_mock):
+ """Test meter attributes."""
+ await setup_integration(hass, config_entry, aioclient_mock)
+ await refresh_data(hass, config_entry, aioclient_mock)
+ meter = hass.states.get(TEST_ENTITY_ID)
+
+ assert meter.attributes[METER_NUMBER] == "123456789"
+ assert meter.attributes[ESIID] == "12345678901234567"
+ assert meter.attributes[CONF_ADDRESS] == "123 MAIN ST"
+
+
+async def test_generic_entity_update_service(hass, config_entry, aioclient_mock):
+ """Test generic update entity service homeasasistant/update_entity."""
+ await setup_integration(hass, config_entry, aioclient_mock)
+ await async_setup_component(hass, HA_DOMAIN, {})
+ with patch("smart_meter_texas.Meter.read_meter") as updater:
+ await hass.services.async_call(
+ HA_DOMAIN,
+ SERVICE_UPDATE_ENTITY,
+ {ATTR_ENTITY_ID: TEST_ENTITY_ID},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+ updater.assert_called_once()
diff --git a/tests/components/smarthab/test_config_flow.py b/tests/components/smarthab/test_config_flow.py
index 8e6d87a53cc..6303fd44def 100644
--- a/tests/components/smarthab/test_config_flow.py
+++ b/tests/components/smarthab/test_config_flow.py
@@ -84,7 +84,8 @@ async def test_form_unknown_error(hass):
)
with patch(
- "pysmarthab.SmartHab.async_login", side_effect=Exception,
+ "pysmarthab.SmartHab.async_login",
+ side_effect=Exception,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py
index 643c084720a..ecab269c1a2 100644
--- a/tests/components/smartthings/conftest.py
+++ b/tests/components/smartthings/conftest.py
@@ -78,7 +78,8 @@ async def setup_component(hass, config_file, hass_storage):
"""Load the SmartThing component."""
hass_storage[STORAGE_KEY] = {"data": config_file, "version": STORAGE_VERSION}
await async_process_ha_core_config(
- hass, {"external_url": "https://test.local"},
+ hass,
+ {"external_url": "https://test.local"},
)
await async_setup_component(hass, "smartthings", {})
diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py
index 91bba9ab405..b776959ea5b 100644
--- a/tests/components/smartthings/test_config_flow.py
+++ b/tests/components/smartthings/test_config_flow.py
@@ -100,7 +100,10 @@ async def test_entry_created(hass, app, app_oauth_client, location, smartthings_
assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret
assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id
assert result["title"] == location.name
- entry = next((entry for entry in hass.config_entries.async_entries(DOMAIN)), None,)
+ entry = next(
+ (entry for entry in hass.config_entries.async_entries(DOMAIN)),
+ None,
+ )
assert entry.unique_id == smartapp.format_unique_id(
app.app_id, location.location_id
)
@@ -168,7 +171,10 @@ async def test_entry_created_from_update_event(
assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret
assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id
assert result["title"] == location.name
- entry = next((entry for entry in hass.config_entries.async_entries(DOMAIN)), None,)
+ entry = next(
+ (entry for entry in hass.config_entries.async_entries(DOMAIN)),
+ None,
+ )
assert entry.unique_id == smartapp.format_unique_id(
app.app_id, location.location_id
)
@@ -236,7 +242,10 @@ async def test_entry_created_existing_app_new_oauth_client(
assert result["data"][CONF_CLIENT_SECRET] == app_oauth_client.client_secret
assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id
assert result["title"] == location.name
- entry = next((entry for entry in hass.config_entries.async_entries(DOMAIN)), None,)
+ entry = next(
+ (entry for entry in hass.config_entries.async_entries(DOMAIN)),
+ None,
+ )
assert entry.unique_id == smartapp.format_unique_id(
app.app_id, location.location_id
)
@@ -409,7 +418,8 @@ async def test_entry_created_with_cloudhook(
assert result["data"][CONF_CLIENT_ID] == app_oauth_client.client_id
assert result["title"] == location.name
entry = next(
- (entry for entry in hass.config_entries.async_entries(DOMAIN)), None,
+ (entry for entry in hass.config_entries.async_entries(DOMAIN)),
+ None,
)
assert entry.unique_id == smartapp.format_unique_id(
app.app_id, location.location_id
@@ -420,7 +430,8 @@ async def test_invalid_webhook_aborts(hass):
"""Test flow aborts if webhook is invalid."""
# Webhook confirmation shown
await async_process_ha_core_config(
- hass, {"external_url": "http://example.local:8123"},
+ hass,
+ {"external_url": "http://example.local:8123"},
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}
diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py
index 014b9a6673c..931e895cb65 100644
--- a/tests/components/smartthings/test_init.py
+++ b/tests/components/smartthings/test_init.py
@@ -118,7 +118,8 @@ async def test_base_url_no_longer_https_does_not_load(
):
"""Test base_url no longer valid creates a new flow."""
await async_process_ha_core_config(
- hass, {"external_url": "http://example.local:8123"},
+ hass,
+ {"external_url": "http://example.local:8123"},
)
config_entry.add_to_hass(hass)
smartthings_mock.app.return_value = app
diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py
index 6d2b73d787d..b9669d0c8ed 100644
--- a/tests/components/smartthings/test_sensor.py
+++ b/tests/components/smartthings/test_sensor.py
@@ -12,8 +12,8 @@ from homeassistant.components.smartthings.const import DOMAIN, SIGNAL_SMARTTHING
from homeassistant.const import (
ATTR_FRIENDLY_NAME,
ATTR_UNIT_OF_MEASUREMENT,
+ PERCENTAGE,
STATE_UNKNOWN,
- UNIT_PERCENTAGE,
)
from homeassistant.helpers.dispatcher import async_dispatcher_send
@@ -38,7 +38,7 @@ async def test_entity_state(hass, device_factory):
await setup_platform(hass, SENSOR_DOMAIN, devices=[device])
state = hass.states.get("sensor.sensor_1_battery")
assert state.state == "100"
- assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UNIT_PERCENTAGE
+ assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE
assert state.attributes[ATTR_FRIENDLY_NAME] == f"{device.label} Battery"
diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py
index 2e0ad01e552..050dc663487 100644
--- a/tests/components/smhi/test_weather.py
+++ b/tests/components/smhi/test_weather.py
@@ -199,7 +199,9 @@ async def test_refresh_weather_forecast_exception() -> None:
with patch.object(
hass.helpers.event, "async_call_later"
) as call_later, patch.object(
- weather, "get_weather_forecast", side_effect=SmhiForecastException(),
+ weather,
+ "get_weather_forecast",
+ side_effect=SmhiForecastException(),
):
await weather.async_update()
assert len(call_later.mock_calls) == 1
diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py
index d99ee82d4ef..6320614c059 100644
--- a/tests/components/smtp/test_notify.py
+++ b/tests/components/smtp/test_notify.py
@@ -1,8 +1,14 @@
"""The tests for the notify smtp platform."""
+from os import path
import re
import unittest
+from homeassistant import config as hass_config
+import homeassistant.components.notify as notify
+from homeassistant.components.smtp import DOMAIN
from homeassistant.components.smtp.notify import MailNotificationService
+from homeassistant.const import SERVICE_RELOAD
+from homeassistant.setup import async_setup_component
from tests.async_mock import patch
from tests.common import get_test_home_assistant
@@ -85,3 +91,51 @@ class TestNotifySmtp(unittest.TestCase):
"Test msg", data={"html": html, "images": ["test.jpg"]}
)
assert "Content-Type: multipart/related" in msg
+
+
+async def test_reload_notify(hass):
+ """Verify we can reload the notify service."""
+
+ with patch(
+ "homeassistant.components.smtp.notify.MailNotificationService.connection_is_valid"
+ ):
+ assert await async_setup_component(
+ hass,
+ notify.DOMAIN,
+ {
+ notify.DOMAIN: [
+ {
+ "name": DOMAIN,
+ "platform": DOMAIN,
+ "recipient": "test@example.com",
+ "sender": "test@example.com",
+ },
+ ]
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert hass.services.has_service(notify.DOMAIN, DOMAIN)
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "smtp/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path), patch(
+ "homeassistant.components.smtp.notify.MailNotificationService.connection_is_valid"
+ ):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert not hass.services.has_service(notify.DOMAIN, DOMAIN)
+ assert hass.services.has_service(notify.DOMAIN, "smtp_reloaded")
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/solarlog/test_config_flow.py b/tests/components/solarlog/test_config_flow.py
index 1474b8e13e7..9cf0e2932ec 100644
--- a/tests/components/solarlog/test_config_flow.py
+++ b/tests/components/solarlog/test_config_flow.py
@@ -28,7 +28,8 @@ async def test_form(hass):
), patch(
"homeassistant.components.solarlog.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.solarlog.async_setup_entry", return_value=True,
+ "homeassistant.components.solarlog.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"], {"host": HOST, "name": NAME}
diff --git a/tests/components/somfy/test_config_flow.py b/tests/components/somfy/test_config_flow.py
index f5cf97bacff..b1df938b3b1 100644
--- a/tests/components/somfy/test_config_flow.py
+++ b/tests/components/somfy/test_config_flow.py
@@ -52,7 +52,7 @@ async def test_abort_if_existing_entry(hass):
assert result["reason"] == "already_setup"
-async def test_full_flow(hass, aiohttp_client, aioclient_mock):
+async def test_full_flow(hass, aiohttp_client, aioclient_mock, current_request):
"""Check full flow."""
assert await setup.async_setup_component(
hass,
diff --git a/tests/components/sonarr/__init__.py b/tests/components/sonarr/__init__.py
index c7f31c67742..b076f39d0d2 100644
--- a/tests/components/sonarr/__init__.py
+++ b/tests/components/sonarr/__init__.py
@@ -18,6 +18,7 @@ from homeassistant.const import (
)
from homeassistant.helpers.typing import HomeAssistantType
+from tests.async_mock import patch
from tests.common import MockConfigEntry, load_fixture
from tests.test_util.aiohttp import AiohttpClientMocker
@@ -54,19 +55,28 @@ def mock_connection(
"""Mock Sonarr connection."""
if error:
mock_connection_error(
- aioclient_mock, host=host, port=port, base_path=base_path,
+ aioclient_mock,
+ host=host,
+ port=port,
+ base_path=base_path,
)
return
if invalid_auth:
mock_connection_invalid_auth(
- aioclient_mock, host=host, port=port, base_path=base_path,
+ aioclient_mock,
+ host=host,
+ port=port,
+ base_path=base_path,
)
return
if server_error:
mock_connection_server_error(
- aioclient_mock, host=host, port=port, base_path=base_path,
+ aioclient_mock,
+ host=host,
+ port=port,
+ base_path=base_path,
)
return
@@ -196,6 +206,10 @@ async def setup_integration(
CONF_UPCOMING_DAYS: DEFAULT_UPCOMING_DAYS,
CONF_WANTED_MAX_ITEMS: DEFAULT_WANTED_MAX_ITEMS,
},
+ options={
+ CONF_UPCOMING_DAYS: DEFAULT_UPCOMING_DAYS,
+ CONF_WANTED_MAX_ITEMS: DEFAULT_WANTED_MAX_ITEMS,
+ },
)
entry.add_to_hass(hass)
@@ -215,3 +229,18 @@ async def setup_integration(
await hass.async_block_till_done()
return entry
+
+
+def _patch_async_setup(return_value=True):
+ """Patch the async setup of sonarr."""
+ return patch(
+ "homeassistant.components.sonarr.async_setup", return_value=return_value
+ )
+
+
+def _patch_async_setup_entry(return_value=True):
+ """Patch the async entry setup of sonarr."""
+ return patch(
+ "homeassistant.components.sonarr.async_setup_entry",
+ return_value=return_value,
+ )
diff --git a/tests/components/sonarr/test_config_flow.py b/tests/components/sonarr/test_config_flow.py
index 23ebd7c8e98..15bd13b580d 100644
--- a/tests/components/sonarr/test_config_flow.py
+++ b/tests/components/sonarr/test_config_flow.py
@@ -19,6 +19,8 @@ from tests.async_mock import patch
from tests.components.sonarr import (
HOST,
MOCK_USER_INPUT,
+ _patch_async_setup,
+ _patch_async_setup_entry,
mock_connection,
mock_connection_error,
mock_connection_invalid_auth,
@@ -27,34 +29,11 @@ from tests.components.sonarr import (
from tests.test_util.aiohttp import AiohttpClientMocker
-async def test_options(hass, aioclient_mock: AiohttpClientMocker):
- """Test updating options."""
- entry = await setup_integration(hass, aioclient_mock)
- assert entry.options[CONF_UPCOMING_DAYS] == DEFAULT_UPCOMING_DAYS
- assert entry.options[CONF_WANTED_MAX_ITEMS] == DEFAULT_WANTED_MAX_ITEMS
-
- result = await hass.config_entries.options.async_init(entry.entry_id)
-
- assert result["type"] == RESULT_TYPE_FORM
- assert result["step_id"] == "init"
-
- with patch(
- "homeassistant.components.sonarr.async_setup_entry", return_value=True
- ), patch("homeassistant.components.sonarr.async_setup", return_value=True):
- result = await hass.config_entries.options.async_configure(
- result["flow_id"],
- user_input={CONF_UPCOMING_DAYS: 2, CONF_WANTED_MAX_ITEMS: 100},
- )
-
- assert result["type"] == RESULT_TYPE_CREATE_ENTRY
- assert result["data"][CONF_UPCOMING_DAYS] == 2
- assert result["data"][CONF_WANTED_MAX_ITEMS] == 100
-
-
async def test_show_user_form(hass: HomeAssistantType) -> None:
"""Test that the user set up form is served."""
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER},
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_USER},
)
assert result["step_id"] == "user"
@@ -69,7 +48,9 @@ async def test_cannot_connect(
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_USER},
+ data=user_input,
)
assert result["type"] == RESULT_TYPE_FORM
@@ -85,7 +66,9 @@ async def test_invalid_auth(
user_input = MOCK_USER_INPUT.copy()
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_USER},
+ data=user_input,
)
assert result["type"] == RESULT_TYPE_FORM
@@ -103,7 +86,9 @@ async def test_unknown_error(
side_effect=Exception,
):
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=user_input,
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_USER},
+ data=user_input,
)
assert result["type"] == RESULT_TYPE_ABORT
@@ -118,9 +103,12 @@ async def test_full_import_flow_implementation(
user_input = MOCK_USER_INPUT.copy()
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=user_input,
- )
+ with _patch_async_setup(), _patch_async_setup_entry():
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_IMPORT},
+ data=user_input,
+ )
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == HOST
@@ -128,10 +116,6 @@ async def test_full_import_flow_implementation(
assert result["data"]
assert result["data"][CONF_HOST] == HOST
- assert result["result"]
- assert result["result"].options[CONF_UPCOMING_DAYS] == DEFAULT_UPCOMING_DAYS
- assert result["result"].options[CONF_WANTED_MAX_ITEMS] == DEFAULT_WANTED_MAX_ITEMS
-
async def test_full_user_flow_implementation(
hass: HomeAssistantType, aioclient_mock: AiohttpClientMocker
@@ -140,18 +124,19 @@ async def test_full_user_flow_implementation(
mock_connection(aioclient_mock)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER},
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_USER},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
user_input = MOCK_USER_INPUT.copy()
- with patch(
- "homeassistant.components.sonarr.async_setup_entry", return_value=True
- ), patch("homeassistant.components.sonarr.async_setup", return_value=True):
+
+ with _patch_async_setup(), _patch_async_setup_entry():
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=user_input,
+ result["flow_id"],
+ user_input=user_input,
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
@@ -179,11 +164,10 @@ async def test_full_user_flow_advanced_options(
CONF_VERIFY_SSL: True,
}
- with patch(
- "homeassistant.components.sonarr.async_setup_entry", return_value=True
- ), patch("homeassistant.components.sonarr.async_setup", return_value=True):
+ with _patch_async_setup(), _patch_async_setup_entry():
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=user_input,
+ result["flow_id"],
+ user_input=user_input,
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
@@ -192,3 +176,25 @@ async def test_full_user_flow_advanced_options(
assert result["data"]
assert result["data"][CONF_HOST] == HOST
assert result["data"][CONF_VERIFY_SSL]
+
+
+async def test_options_flow(hass, aioclient_mock: AiohttpClientMocker):
+ """Test updating options."""
+ entry = await setup_integration(hass, aioclient_mock, skip_entry_setup=True)
+ assert entry.options[CONF_UPCOMING_DAYS] == DEFAULT_UPCOMING_DAYS
+ assert entry.options[CONF_WANTED_MAX_ITEMS] == DEFAULT_WANTED_MAX_ITEMS
+
+ result = await hass.config_entries.options.async_init(entry.entry_id)
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["step_id"] == "init"
+
+ with _patch_async_setup(), _patch_async_setup_entry():
+ result = await hass.config_entries.options.async_configure(
+ result["flow_id"],
+ user_input={CONF_UPCOMING_DAYS: 2, CONF_WANTED_MAX_ITEMS: 100},
+ )
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result["data"][CONF_UPCOMING_DAYS] == 2
+ assert result["data"][CONF_WANTED_MAX_ITEMS] == 100
diff --git a/tests/components/sonarr/test_init.py b/tests/components/sonarr/test_init.py
index 852befcb31c..e9f01290461 100644
--- a/tests/components/sonarr/test_init.py
+++ b/tests/components/sonarr/test_init.py
@@ -7,6 +7,7 @@ from homeassistant.config_entries import (
)
from homeassistant.core import HomeAssistant
+from tests.async_mock import patch
from tests.components.sonarr import setup_integration
from tests.test_util.aiohttp import AiohttpClientMocker
@@ -23,7 +24,11 @@ async def test_unload_config_entry(
hass: HomeAssistant, aioclient_mock: AiohttpClientMocker
) -> None:
"""Test the configuration entry unloading."""
- entry = await setup_integration(hass, aioclient_mock)
+ with patch(
+ "homeassistant.components.sonarr.sensor.async_setup_entry",
+ return_value=True,
+ ):
+ entry = await setup_integration(hass, aioclient_mock)
assert hass.data[DOMAIN]
assert entry.entry_id in hass.data[DOMAIN]
diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py
index 8fbca025404..93c8d7d4d9e 100644
--- a/tests/components/sonarr/test_sensor.py
+++ b/tests/components/sonarr/test_sensor.py
@@ -4,11 +4,8 @@ from datetime import timedelta
import pytest
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
-from homeassistant.components.sonarr.const import (
- CONF_BASE_PATH,
- CONF_UPCOMING_DAYS,
- DOMAIN,
-)
+from homeassistant.components.sonarr.const import CONF_BASE_PATH, DOMAIN
+from homeassistant.config_entries import ENTRY_STATE_LOADED
from homeassistant.const import (
ATTR_ICON,
ATTR_UNIT_OF_MEASUREMENT,
@@ -23,6 +20,8 @@ from tests.async_mock import patch
from tests.common import async_fire_time_changed
from tests.components.sonarr import (
MOCK_SENSOR_CONFIG,
+ _patch_async_setup,
+ _patch_async_setup_entry,
mock_connection,
setup_integration,
)
@@ -36,18 +35,18 @@ async def test_import_from_sensor_component(
) -> None:
"""Test import from sensor platform."""
mock_connection(aioclient_mock)
- assert await async_setup_component(
- hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: MOCK_SENSOR_CONFIG}
- )
- await hass.async_block_till_done()
+
+ with _patch_async_setup(), _patch_async_setup_entry():
+ assert await async_setup_component(
+ hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: MOCK_SENSOR_CONFIG}
+ )
+ await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
+ assert entries[0].state == ENTRY_STATE_LOADED
assert entries[0].data[CONF_BASE_PATH] == "/api"
- assert entries[0].options[CONF_UPCOMING_DAYS] == 3
-
- assert hass.states.get(UPCOMING_ENTITY_ID)
async def test_sensors(
diff --git a/tests/components/songpal/test_config_flow.py b/tests/components/songpal/test_config_flow.py
index e837ed0e032..155801dca03 100644
--- a/tests/components/songpal/test_config_flow.py
+++ b/tests/components/songpal/test_config_flow.py
@@ -49,14 +49,17 @@ def _flow_next(hass, flow_id):
def _patch_setup():
return patch(
- "homeassistant.components.songpal.async_setup_entry", return_value=True,
+ "homeassistant.components.songpal.async_setup_entry",
+ return_value=True,
)
async def test_flow_ssdp(hass):
"""Test working ssdp flow."""
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DATA,
+ DOMAIN,
+ context={"source": SOURCE_SSDP},
+ data=SSDP_DATA,
)
assert result["type"] == "form"
assert result["step_id"] == "init"
@@ -82,7 +85,8 @@ async def test_flow_user(hass):
with _patch_config_flow_device(mocked_device), _patch_setup():
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER},
+ DOMAIN,
+ context={"source": SOURCE_USER},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "user"
@@ -90,7 +94,8 @@ async def test_flow_user(hass):
_flow_next(hass, result["flow_id"])
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={CONF_ENDPOINT: ENDPOINT},
+ result["flow_id"],
+ user_input={CONF_ENDPOINT: ENDPOINT},
)
assert result["type"] == RESULT_TYPE_CREATE_ENTRY
assert result["title"] == MODEL
@@ -136,9 +141,11 @@ async def test_flow_import_without_name(hass):
def _create_mock_config_entry(hass):
- MockConfigEntry(domain=DOMAIN, unique_id="uuid:0000", data=CONF_DATA,).add_to_hass(
- hass
- )
+ MockConfigEntry(
+ domain=DOMAIN,
+ unique_id="uuid:0000",
+ data=CONF_DATA,
+ ).add_to_hass(hass)
async def test_ssdp_bravia(hass):
@@ -148,7 +155,9 @@ async def test_ssdp_bravia(hass):
"X_ScalarWebAPI_ServiceType"
].append("videoScreen")
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_SSDP}, data=ssdp_data,
+ DOMAIN,
+ context={"source": SOURCE_SSDP},
+ data=ssdp_data,
)
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "not_songpal_device"
@@ -158,7 +167,9 @@ async def test_sddp_exist(hass):
"""Test discovering existed device."""
_create_mock_config_entry(hass)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_SSDP}, data=SSDP_DATA,
+ DOMAIN,
+ context={"source": SOURCE_SSDP},
+ data=SSDP_DATA,
)
assert result["type"] == RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py
index 54d54e6ed5b..dd83aefba81 100644
--- a/tests/components/sonos/test_media_player.py
+++ b/tests/components/sonos/test_media_player.py
@@ -50,7 +50,8 @@ async def test_device_registry(hass, config_entry, config, soco):
device_registry = await hass.helpers.device_registry.async_get_registry()
reg_device = device_registry.async_get_device(
- identifiers={("sonos", "RINCON_test")}, connections=set(),
+ identifiers={("sonos", "RINCON_test")},
+ connections=set(),
)
assert reg_device.model == "Model Name"
assert reg_device.sw_version == "49.2-64250"
diff --git a/tests/components/soundtouch/test_media_player.py b/tests/components/soundtouch/test_media_player.py
index d351df32101..85089854c4d 100644
--- a/tests/components/soundtouch/test_media_player.py
+++ b/tests/components/soundtouch/test_media_player.py
@@ -503,7 +503,10 @@ async def test_should_turn_off(
assert mocked_volume.call_count == 2
await hass.services.async_call(
- "media_player", "turn_off", {"entity_id": "media_player.soundtouch_1"}, True,
+ "media_player",
+ "turn_off",
+ {"entity_id": "media_player.soundtouch_1"},
+ True,
)
assert mocked_status.call_count == 3
assert mocked_power_off.call_count == 1
@@ -522,7 +525,10 @@ async def test_should_turn_on(
assert mocked_volume.call_count == 2
await hass.services.async_call(
- "media_player", "turn_on", {"entity_id": "media_player.soundtouch_1"}, True,
+ "media_player",
+ "turn_on",
+ {"entity_id": "media_player.soundtouch_1"},
+ True,
)
assert mocked_status.call_count == 3
assert mocked_power_on.call_count == 1
@@ -540,7 +546,10 @@ async def test_volume_up(
assert mocked_volume.call_count == 2
await hass.services.async_call(
- "media_player", "volume_up", {"entity_id": "media_player.soundtouch_1"}, True,
+ "media_player",
+ "volume_up",
+ {"entity_id": "media_player.soundtouch_1"},
+ True,
)
assert mocked_volume.call_count == 3
assert mocked_volume_up.call_count == 1
@@ -558,7 +567,10 @@ async def test_volume_down(
assert mocked_volume.call_count == 2
await hass.services.async_call(
- "media_player", "volume_down", {"entity_id": "media_player.soundtouch_1"}, True,
+ "media_player",
+ "volume_down",
+ {"entity_id": "media_player.soundtouch_1"},
+ True,
)
assert mocked_volume.call_count == 3
assert mocked_volume_down.call_count == 1
@@ -614,7 +626,10 @@ async def test_play(mocked_play, mocked_status, mocked_volume, hass, one_device)
assert mocked_volume.call_count == 2
await hass.services.async_call(
- "media_player", "media_play", {"entity_id": "media_player.soundtouch_1"}, True,
+ "media_player",
+ "media_play",
+ {"entity_id": "media_player.soundtouch_1"},
+ True,
)
assert mocked_status.call_count == 3
assert mocked_play.call_count == 1
@@ -630,7 +645,10 @@ async def test_pause(mocked_pause, mocked_status, mocked_volume, hass, one_devic
assert mocked_volume.call_count == 2
await hass.services.async_call(
- "media_player", "media_pause", {"entity_id": "media_player.soundtouch_1"}, True,
+ "media_player",
+ "media_pause",
+ {"entity_id": "media_player.soundtouch_1"},
+ True,
)
assert mocked_status.call_count == 3
assert mocked_pause.call_count == 1
@@ -955,7 +973,11 @@ async def test_remove_zone_slave(
@patch("libsoundtouch.device.SoundTouchDevice.add_zone_slave")
async def test_add_zone_slave(
- mocked_add_zone_slave, mocked_status, mocked_volume, hass, two_zones,
+ mocked_add_zone_slave,
+ mocked_status,
+ mocked_volume,
+ hass,
+ two_zones,
):
"""Test removing a slave from a zone."""
mocked_device = two_zones
@@ -998,7 +1020,11 @@ async def test_add_zone_slave(
@patch("libsoundtouch.device.SoundTouchDevice.create_zone")
async def test_zone_attributes(
- mocked_create_zone, mocked_status, mocked_volume, hass, two_zones,
+ mocked_create_zone,
+ mocked_status,
+ mocked_volume,
+ hass,
+ two_zones,
):
"""Test play everywhere."""
mocked_device = two_zones
diff --git a/tests/components/spaceapi/test_init.py b/tests/components/spaceapi/test_init.py
index 891adac91ae..b831de56b05 100644
--- a/tests/components/spaceapi/test_init.py
+++ b/tests/components/spaceapi/test_init.py
@@ -5,7 +5,7 @@ from unittest.mock import patch
import pytest
from homeassistant.components.spaceapi import DOMAIN, SPACEAPI_VERSION, URL_API_SPACEAPI
-from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE, TEMP_CELSIUS
from homeassistant.setup import async_setup_component
from tests.common import mock_coro
@@ -64,7 +64,7 @@ SENSOR_OUTPUT = {
{"location": "Home", "name": "temp2", "unit": TEMP_CELSIUS, "value": "23"},
],
"humidity": [
- {"location": "Home", "name": "hum1", "unit": UNIT_PERCENTAGE, "value": "88"}
+ {"location": "Home", "name": "hum1", "unit": PERCENTAGE, "value": "88"}
],
}
@@ -82,7 +82,7 @@ def mock_client(hass, hass_client):
"test.temp2", 23, attributes={"unit_of_measurement": TEMP_CELSIUS}
)
hass.states.async_set(
- "test.hum1", 88, attributes={"unit_of_measurement": UNIT_PERCENTAGE}
+ "test.hum1", 88, attributes={"unit_of_measurement": PERCENTAGE}
)
return hass.loop.run_until_complete(hass_client())
diff --git a/tests/components/speedtestdotnet/__init__.py b/tests/components/speedtestdotnet/__init__.py
index f67a633e25f..f6f64b9c7bb 100644
--- a/tests/components/speedtestdotnet/__init__.py
+++ b/tests/components/speedtestdotnet/__init__.py
@@ -9,7 +9,7 @@ MOCK_SERVERS = {
"name": "Server1",
"country": "Country1",
"cc": "LL1",
- "sponsor": "Server1",
+ "sponsor": "Sponsor1",
"id": "1",
"host": "server1:8080",
"d": 1,
@@ -23,7 +23,7 @@ MOCK_SERVERS = {
"name": "Server2",
"country": "Country2",
"cc": "LL2",
- "sponsor": "server2",
+ "sponsor": "Sponsor2",
"id": "2",
"host": "server2:8080",
"d": 2,
diff --git a/tests/components/speedtestdotnet/test_config_flow.py b/tests/components/speedtestdotnet/test_config_flow.py
index 943da319aef..8e7edc2d986 100644
--- a/tests/components/speedtestdotnet/test_config_flow.py
+++ b/tests/components/speedtestdotnet/test_config_flow.py
@@ -25,7 +25,8 @@ from tests.common import MockConfigEntry
def mock_setup():
"""Mock entry setup."""
with patch(
- "homeassistant.components.speedtestdotnet.async_setup_entry", return_value=True,
+ "homeassistant.components.speedtestdotnet.async_setup_entry",
+ return_value=True,
):
yield
@@ -88,7 +89,12 @@ async def test_import_success(hass, mock_setup):
async def test_options(hass):
"""Test updating options."""
- entry = MockConfigEntry(domain=DOMAIN, title="SpeedTest", data={}, options={},)
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ title="SpeedTest",
+ data={},
+ options={},
+ )
entry.add_to_hass(hass)
with patch("speedtest.Speedtest") as mock_api:
@@ -102,7 +108,7 @@ async def test_options(hass):
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
- CONF_SERVER_NAME: "Country1 - Server1",
+ CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1",
CONF_SCAN_INTERVAL: 30,
CONF_MANUAL: False,
},
@@ -110,7 +116,7 @@ async def test_options(hass):
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["data"] == {
- CONF_SERVER_NAME: "Country1 - Server1",
+ CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1",
CONF_SERVER_ID: "1",
CONF_SCAN_INTERVAL: 30,
CONF_MANUAL: False,
@@ -119,7 +125,11 @@ async def test_options(hass):
async def test_integration_already_configured(hass):
"""Test integration is already configured."""
- entry = MockConfigEntry(domain=DOMAIN, data={}, options={},)
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={},
+ options={},
+ )
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
speedtestdotnet.DOMAIN, context={"source": "user"}
diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py
index 7b7eed67c0c..cadf97fc761 100644
--- a/tests/components/speedtestdotnet/test_init.py
+++ b/tests/components/speedtestdotnet/test_init.py
@@ -25,7 +25,10 @@ async def test_setup_with_config(hass):
async def test_successful_config_entry(hass):
"""Test that SpeedTestDotNet is configured successfully."""
- entry = MockConfigEntry(domain=speedtestdotnet.DOMAIN, data={},)
+ entry = MockConfigEntry(
+ domain=speedtestdotnet.DOMAIN,
+ data={},
+ )
entry.add_to_hass(hass)
with patch("speedtest.Speedtest"), patch(
@@ -35,13 +38,19 @@ async def test_successful_config_entry(hass):
await hass.config_entries.async_setup(entry.entry_id)
assert entry.state == config_entries.ENTRY_STATE_LOADED
- assert forward_entry_setup.mock_calls[0][1] == (entry, "sensor",)
+ assert forward_entry_setup.mock_calls[0][1] == (
+ entry,
+ "sensor",
+ )
async def test_setup_failed(hass):
"""Test SpeedTestDotNet failed due to an error."""
- entry = MockConfigEntry(domain=speedtestdotnet.DOMAIN, data={},)
+ entry = MockConfigEntry(
+ domain=speedtestdotnet.DOMAIN,
+ data={},
+ )
entry.add_to_hass(hass)
with patch("speedtest.Speedtest", side_effect=speedtest.ConfigRetrievalError):
@@ -53,7 +62,10 @@ async def test_setup_failed(hass):
async def test_unload_entry(hass):
"""Test removing SpeedTestDotNet."""
- entry = MockConfigEntry(domain=speedtestdotnet.DOMAIN, data={},)
+ entry = MockConfigEntry(
+ domain=speedtestdotnet.DOMAIN,
+ data={},
+ )
entry.add_to_hass(hass)
with patch("speedtest.Speedtest"):
diff --git a/tests/components/spider/test_config_flow.py b/tests/components/spider/test_config_flow.py
index 5c2c074027f..3bf7dd790b2 100644
--- a/tests/components/spider/test_config_flow.py
+++ b/tests/components/spider/test_config_flow.py
@@ -58,9 +58,11 @@ async def test_import(hass, spider):
"""Test import step."""
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
- "homeassistant.components.spider.async_setup", return_value=True,
+ "homeassistant.components.spider.async_setup",
+ return_value=True,
) as mock_setup, patch(
- "homeassistant.components.spider.async_setup_entry", return_value=True,
+ "homeassistant.components.spider.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py
index 860840c477f..3b3c85dd828 100644
--- a/tests/components/spotify/test_config_flow.py
+++ b/tests/components/spotify/test_config_flow.py
@@ -40,7 +40,7 @@ async def test_zeroconf_abort_if_existing_entry(hass):
assert result["reason"] == "already_configured"
-async def test_full_flow(hass, aiohttp_client, aioclient_mock):
+async def test_full_flow(hass, aiohttp_client, aioclient_mock, current_request):
"""Check a full flow."""
assert await setup.async_setup_component(
hass,
@@ -64,7 +64,9 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock):
"?response_type=code&client_id=client"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}"
- "&scope=user-modify-playback-state,user-read-playback-state,user-read-private"
+ "&scope=user-modify-playback-state,user-read-playback-state,user-read-private,"
+ "playlist-read-private,playlist-read-collaborative,user-library-read,"
+ "user-top-read,user-read-playback-position,user-read-recently-played,user-follow-read"
)
client = await aiohttp_client(hass.http.app)
@@ -83,11 +85,15 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock):
)
with patch("homeassistant.components.spotify.config_flow.Spotify") as spotify_mock:
- spotify_mock.return_value.current_user.return_value = {"id": "fake_id"}
+ spotify_mock.return_value.current_user.return_value = {
+ "id": "fake_id",
+ "display_name": "frenck",
+ }
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["data"]["auth_implementation"] == DOMAIN
result["data"]["token"].pop("expires_at")
+ assert result["data"]["name"] == "frenck"
assert result["data"]["token"] == {
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
@@ -96,7 +102,9 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock):
}
-async def test_abort_if_spotify_error(hass, aiohttp_client, aioclient_mock):
+async def test_abort_if_spotify_error(
+ hass, aiohttp_client, aioclient_mock, current_request
+):
"""Check Spotify errors causes flow to abort."""
await setup.async_setup_component(
hass,
@@ -134,3 +142,111 @@ async def test_abort_if_spotify_error(hass, aiohttp_client, aioclient_mock):
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "connection_error"
+
+
+async def test_reauthentication(hass, aiohttp_client, aioclient_mock, current_request):
+ """Test Spotify reauthentication."""
+ await setup.async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {CONF_CLIENT_ID: "client", CONF_CLIENT_SECRET: "secret"},
+ "http": {"base_url": "https://example.com"},
+ },
+ )
+
+ old_entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id=123,
+ version=1,
+ data={"id": "frenck", "auth_implementation": DOMAIN},
+ )
+ old_entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "reauth"}, data=old_entry.data
+ )
+
+ flows = hass.config_entries.flow.async_progress()
+ assert len(flows) == 1
+
+ result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {})
+
+ # pylint: disable=protected-access
+ state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]})
+ client = await aiohttp_client(hass.http.app)
+ await client.get(f"/auth/external/callback?code=abcd&state={state}")
+
+ aioclient_mock.post(
+ "https://accounts.spotify.com/api/token",
+ json={
+ "refresh_token": "mock-refresh-token",
+ "access_token": "mock-access-token",
+ "type": "Bearer",
+ "expires_in": 60,
+ },
+ )
+
+ with patch("homeassistant.components.spotify.config_flow.Spotify") as spotify_mock:
+ spotify_mock.return_value.current_user.return_value = {"id": "frenck"}
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+
+ assert result["data"]["auth_implementation"] == DOMAIN
+ result["data"]["token"].pop("expires_at")
+ assert result["data"]["token"] == {
+ "refresh_token": "mock-refresh-token",
+ "access_token": "mock-access-token",
+ "type": "Bearer",
+ "expires_in": 60,
+ }
+
+
+async def test_reauth_account_mismatch(
+ hass, aiohttp_client, aioclient_mock, current_request
+):
+ """Test Spotify reauthentication with different account."""
+ await setup.async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {CONF_CLIENT_ID: "client", CONF_CLIENT_SECRET: "secret"},
+ "http": {"base_url": "https://example.com"},
+ },
+ )
+
+ old_entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id=123,
+ version=1,
+ data={"id": "frenck", "auth_implementation": DOMAIN},
+ )
+ old_entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "reauth"}, data=old_entry.data
+ )
+
+ flows = hass.config_entries.flow.async_progress()
+ result = await hass.config_entries.flow.async_configure(flows[0]["flow_id"], {})
+
+ # pylint: disable=protected-access
+ state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]})
+ client = await aiohttp_client(hass.http.app)
+ await client.get(f"/auth/external/callback?code=abcd&state={state}")
+
+ aioclient_mock.post(
+ "https://accounts.spotify.com/api/token",
+ json={
+ "refresh_token": "mock-refresh-token",
+ "access_token": "mock-access-token",
+ "type": "Bearer",
+ "expires_in": 60,
+ },
+ )
+
+ with patch("homeassistant.components.spotify.config_flow.Spotify") as spotify_mock:
+ spotify_mock.return_value.current_user.return_value = {"id": "fake_id"}
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "reauth_account_mismatch"
diff --git a/tests/components/squeezebox/test_config_flow.py b/tests/components/squeezebox/test_config_flow.py
index e38188e4a0f..f325024cf00 100644
--- a/tests/components/squeezebox/test_config_flow.py
+++ b/tests/components/squeezebox/test_config_flow.py
@@ -46,7 +46,8 @@ async def test_user_form(hass):
with patch("pysqueezebox.Server.async_query", return_value={"uuid": UUID},), patch(
"homeassistant.components.squeezebox.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.squeezebox.async_setup_entry", return_value=True,
+ "homeassistant.components.squeezebox.async_setup_entry",
+ return_value=True,
) as mock_setup_entry, patch(
"homeassistant.components.squeezebox.config_flow.async_discover", mock_discover
):
@@ -106,11 +107,13 @@ async def test_user_form_timeout(hass):
async def test_user_form_duplicate(hass):
"""Test duplicate discovered servers are skipped."""
with patch(
- "homeassistant.components.squeezebox.config_flow.async_discover", mock_discover,
+ "homeassistant.components.squeezebox.config_flow.async_discover",
+ mock_discover,
), patch("homeassistant.components.squeezebox.config_flow.TIMEOUT", 0.1), patch(
"homeassistant.components.squeezebox.async_setup", return_value=True
), patch(
- "homeassistant.components.squeezebox.async_setup_entry", return_value=True,
+ "homeassistant.components.squeezebox.async_setup_entry",
+ return_value=True,
):
entry = MockConfigEntry(domain=DOMAIN, unique_id=UUID)
await hass.config_entries.async_add(entry)
@@ -153,7 +156,8 @@ async def test_form_cannot_connect(hass):
)
with patch(
- "pysqueezebox.Server.async_query", return_value=False,
+ "pysqueezebox.Server.async_query",
+ return_value=False,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -172,7 +176,8 @@ async def test_form_cannot_connect(hass):
async def test_discovery(hass):
"""Test handling of discovered server."""
with patch(
- "pysqueezebox.Server.async_query", return_value={"uuid": UUID},
+ "pysqueezebox.Server.async_query",
+ return_value={"uuid": UUID},
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -200,7 +205,8 @@ async def test_import(hass):
with patch("pysqueezebox.Server.async_query", return_value={"uuid": UUID},), patch(
"homeassistant.components.squeezebox.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.squeezebox.async_setup_entry", return_value=True,
+ "homeassistant.components.squeezebox.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -248,9 +254,11 @@ async def test_import_existing(hass):
with patch(
"homeassistant.components.squeezebox.async_setup", return_value=True
), patch(
- "homeassistant.components.squeezebox.async_setup_entry", return_value=True,
+ "homeassistant.components.squeezebox.async_setup_entry",
+ return_value=True,
), patch(
- "pysqueezebox.Server.async_query", return_value={"ip": HOST, "uuid": UUID},
+ "pysqueezebox.Server.async_query",
+ return_value={"ip": HOST, "uuid": UUID},
):
entry = MockConfigEntry(domain=DOMAIN, unique_id=UUID)
await hass.config_entries.async_add(entry)
diff --git a/tests/components/startca/test_sensor.py b/tests/components/startca/test_sensor.py
index 302df0492c8..e1d658d05b7 100644
--- a/tests/components/startca/test_sensor.py
+++ b/tests/components/startca/test_sensor.py
@@ -1,7 +1,7 @@
"""Tests for the Start.ca sensor platform."""
from homeassistant.bootstrap import async_setup_component
from homeassistant.components.startca.sensor import StartcaData
-from homeassistant.const import DATA_GIGABYTES, HTTP_NOT_FOUND, UNIT_PERCENTAGE
+from homeassistant.const import DATA_GIGABYTES, HTTP_NOT_FOUND, PERCENTAGE
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -53,7 +53,7 @@ async def test_capped_setup(hass, aioclient_mock):
await hass.async_block_till_done()
state = hass.states.get("sensor.start_ca_usage_ratio")
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
+ assert state.attributes.get("unit_of_measurement") == PERCENTAGE
assert state.state == "76.24"
state = hass.states.get("sensor.start_ca_usage")
@@ -149,7 +149,7 @@ async def test_unlimited_setup(hass, aioclient_mock):
await hass.async_block_till_done()
state = hass.states.get("sensor.start_ca_usage_ratio")
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
+ assert state.attributes.get("unit_of_measurement") == PERCENTAGE
assert state.state == "0"
state = hass.states.get("sensor.start_ca_usage")
diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py
index 6d7f0963fa2..e60c5c2e9a5 100644
--- a/tests/components/statistics/test_sensor.py
+++ b/tests/components/statistics/test_sensor.py
@@ -1,14 +1,21 @@
"""The test for the statistics sensor platform."""
from datetime import datetime, timedelta
+from os import path
import statistics
import unittest
import pytest
+from homeassistant import config as hass_config
from homeassistant.components import recorder
-from homeassistant.components.statistics.sensor import StatisticsSensor
-from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS
-from homeassistant.setup import setup_component
+from homeassistant.components.statistics.sensor import DOMAIN, StatisticsSensor
+from homeassistant.const import (
+ ATTR_UNIT_OF_MEASUREMENT,
+ SERVICE_RELOAD,
+ STATE_UNKNOWN,
+ TEMP_CELSIUS,
+)
+from homeassistant.setup import async_setup_component, setup_component
from homeassistant.util import dt as dt_util
from tests.async_mock import patch
@@ -442,3 +449,54 @@ class TestStatisticsSensor(unittest.TestCase):
assert mock_data["return_time"] == state.attributes.get("max_age") + timedelta(
hours=1
)
+
+
+async def test_reload(hass):
+ """Verify we can reload filter sensors."""
+ await hass.async_add_executor_job(
+ init_recorder_component, hass
+ ) # force in memory db
+
+ hass.states.async_set("sensor.test_monitored", 12345)
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": "statistics",
+ "name": "test",
+ "entity_id": "sensor.test_monitored",
+ "sampling_size": 100,
+ }
+ },
+ )
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 2
+
+ assert hass.states.get("sensor.test")
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "statistics/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 2
+
+ assert hass.states.get("sensor.test") is None
+ assert hass.states.get("sensor.cputest")
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py
index 4c34ec0b341..2359a771035 100644
--- a/tests/components/stream/common.py
+++ b/tests/components/stream/common.py
@@ -1,5 +1,7 @@
"""Collection of test helpers."""
+from fractions import Fraction
import io
+import logging
import av
import numpy as np
@@ -7,27 +9,59 @@ import numpy as np
from homeassistant.components.stream import Stream
from homeassistant.components.stream.const import ATTR_STREAMS, DOMAIN
+_LOGGER = logging.getLogger(__name__)
-def generate_h264_video():
+AUDIO_SAMPLE_RATE = 8000
+
+
+def generate_h264_video(container_format="mp4", audio_codec=None):
"""
Generate a test video.
See: http://docs.mikeboers.com/pyav/develop/cookbook/numpy.html
"""
+ def generate_audio_frame(pcm_mulaw=False):
+ """Generate a blank audio frame."""
+ if pcm_mulaw:
+ audio_frame = av.AudioFrame(format="s16", layout="mono", samples=1)
+ audio_bytes = b"\x00\x00"
+ else:
+ audio_frame = av.AudioFrame(format="dbl", layout="mono", samples=1024)
+ audio_bytes = b"\x00\x00\x00\x00\x00\x00\x00\x00" * 1024
+ audio_frame.planes[0].update(audio_bytes)
+ audio_frame.sample_rate = AUDIO_SAMPLE_RATE
+ audio_frame.time_base = Fraction(1, AUDIO_SAMPLE_RATE)
+ return audio_frame
+
duration = 5
fps = 24
total_frames = duration * fps
output = io.BytesIO()
- output.name = "test.ts"
- container = av.open(output, mode="w")
+ output.name = "test.mov" if container_format == "mov" else "test.mp4"
+ container = av.open(output, mode="w", format=container_format)
stream = container.add_stream("libx264", rate=fps)
stream.width = 480
stream.height = 320
stream.pix_fmt = "yuv420p"
+ a_packet = None
+ last_a_dts = -1
+ if audio_codec is not None:
+ if audio_codec == "empty": # empty we add a stream but don't mux any audio
+ astream = container.add_stream("aac", AUDIO_SAMPLE_RATE)
+ else:
+ astream = container.add_stream(audio_codec, AUDIO_SAMPLE_RATE)
+ # Need to do it multiple times for some reason
+ while not a_packet:
+ a_packets = astream.encode(
+ generate_audio_frame(pcm_mulaw=audio_codec == "pcm_mulaw")
+ )
+ if a_packets:
+ a_packet = a_packets[0]
+
for frame_i in range(total_frames):
img = np.empty((480, 320, 3))
@@ -42,6 +76,17 @@ def generate_h264_video():
for packet in stream.encode(frame):
container.mux(packet)
+ if a_packet is not None:
+ a_packet.pts = int(frame_i / (fps * a_packet.time_base))
+ while a_packet.pts * a_packet.time_base * fps < frame_i + 1:
+ a_packet.dts = a_packet.pts
+ if (
+ a_packet.dts > last_a_dts
+ ): # avoid writing same dts twice in case of rounding
+ container.mux(a_packet)
+ last_a_dts = a_packet.dts
+ a_packet.pts += a_packet.duration
+
# Flush stream
for packet in stream.encode():
container.mux(packet)
diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py
index 3de50d3309c..863513c8157 100644
--- a/tests/components/stream/test_hls.py
+++ b/tests/components/stream/test_hls.py
@@ -2,6 +2,7 @@
from datetime import timedelta
from urllib.parse import urlparse
+import av
import pytest
from homeassistant.components.stream import request_stream
@@ -9,6 +10,7 @@ from homeassistant.const import HTTP_NOT_FOUND
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
+from tests.async_mock import patch
from tests.common import async_fire_time_changed
from tests.components.stream.common import generate_h264_video, preload_stream
@@ -38,6 +40,13 @@ async def test_hls_stream(hass, hass_client):
playlist_response = await http_client.get(parsed_url.path)
assert playlist_response.status == 200
+ # Fetch init
+ playlist = await playlist_response.text()
+ playlist_url = "/".join(parsed_url.path.split("/")[:-1])
+ init_url = playlist_url + "/init.mp4"
+ init_response = await http_client.get(init_url)
+ assert init_response.status == 200
+
# Fetch segment
playlist = await playlist_response.text()
playlist_url = "/".join(parsed_url.path.split("/")[:-1])
@@ -99,18 +108,53 @@ async def test_stream_ended(hass):
source = generate_h264_video()
stream = preload_stream(hass, source)
track = stream.add_provider("hls")
- track.num_segments = 2
# Request stream
request_stream(hass, source)
# Run it dead
- segments = 0
- while await track.recv() is not None:
- segments += 1
+ while True:
+ segment = await track.recv()
+ if segment is None:
+ break
+ segments = segment.sequence
assert segments > 1
assert not track.get_segment()
# Stop stream, if it hasn't quit already
stream.stop()
+
+
+async def test_stream_keepalive(hass):
+ """Test hls stream retries the stream when keepalive=True."""
+ await async_setup_component(hass, "stream", {"stream": {}})
+
+ # Setup demo HLS track
+ source = "test_stream_keepalive_source"
+ stream = preload_stream(hass, source)
+ track = stream.add_provider("hls")
+ track.num_segments = 2
+
+ cur_time = 0
+
+ def time_side_effect():
+ nonlocal cur_time
+ if cur_time >= 80:
+ stream.keepalive = False # Thread should exit and be joinable.
+ cur_time += 40
+ return cur_time
+
+ with patch("av.open") as av_open, patch(
+ "homeassistant.components.stream.worker.time"
+ ) as mock_time:
+ av_open.side_effect = av.error.InvalidDataError(-2, "error")
+ mock_time.time.side_effect = time_side_effect
+ # Request stream
+ request_stream(hass, source, keepalive=True)
+ stream._thread.join()
+ stream._thread = None
+ assert av_open.call_count == 2
+
+ # Stop stream, if it hasn't quit already
+ stream.stop()
diff --git a/tests/components/stream/test_init.py b/tests/components/stream/test_init.py
index dc7892e069a..505de7ca018 100644
--- a/tests/components/stream/test_init.py
+++ b/tests/components/stream/test_init.py
@@ -72,7 +72,6 @@ async def test_record_service_lookback(hass):
):
# Setup stubs
hls_mock = MagicMock()
- hls_mock.num_segments = 3
hls_mock.target_duration = 2
hls_mock.recv = AsyncMock(return_value=None)
stream_mock.return_value.outputs = {"hls": hls_mock}
diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py
index fbbeaf0ff44..cb6a1c9d36f 100644
--- a/tests/components/stream/test_recorder.py
+++ b/tests/components/stream/test_recorder.py
@@ -2,6 +2,7 @@
from datetime import timedelta
from io import BytesIO
+import av
import pytest
from homeassistant.components.stream.core import Segment
@@ -31,12 +32,11 @@ async def test_record_stream(hass, hass_client):
recorder = stream.add_provider("recorder")
stream.start()
- segments = 0
while True:
segment = await recorder.recv()
if not segment:
break
- segments += 1
+ segments = segment.sequence
stream.stop()
@@ -76,7 +76,45 @@ async def test_recorder_save():
output.name = "test.mp4"
# Run
- recorder_save_worker(output, [Segment(1, source, 4)])
+ recorder_save_worker(output, [Segment(1, source, 4)], "mp4")
# Assert
assert output.getvalue()
+
+
+@pytest.mark.skip("Flaky in CI")
+async def test_record_stream_audio(hass, hass_client):
+ """
+ Test treatment of different audio inputs.
+
+ Record stream output should have an audio channel when input has
+ a valid codec and audio packets and no audio channel otherwise.
+ """
+ await async_setup_component(hass, "stream", {"stream": {}})
+
+ for a_codec, expected_audio_streams in (
+ ("aac", 1), # aac is a valid mp4 codec
+ ("pcm_mulaw", 0), # G.711 is not a valid mp4 codec
+ ("empty", 0), # audio stream with no packets
+ (None, 0), # no audio stream
+ ):
+ with patch("homeassistant.components.stream.recorder.recorder_save_worker"):
+ # Setup demo track
+ source = generate_h264_video(
+ container_format="mov", audio_codec=a_codec
+ ) # mov can store PCM
+ stream = preload_stream(hass, source)
+ recorder = stream.add_provider("recorder")
+ stream.start()
+
+ while True:
+ segment = await recorder.recv()
+ if not segment:
+ break
+ last_segment = segment
+
+ result = av.open(last_segment.segment, "r", format="mp4")
+
+ assert len(result.streams.audio) == expected_audio_streams
+ result.close()
+ stream.stop()
diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py
index 56ac683582a..36eaa8398ac 100644
--- a/tests/components/sun/test_init.py
+++ b/tests/components/sun/test_init.py
@@ -120,11 +120,12 @@ async def test_state_change(hass, legacy_patchable_time):
assert sun.STATE_BELOW_HORIZON == hass.states.get(sun.ENTITY_ID).state
- hass.bus.async_fire(
- ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: test_time + timedelta(seconds=5)}
- )
-
- await hass.async_block_till_done()
+ patched_time = test_time + timedelta(seconds=5)
+ with patch(
+ "homeassistant.helpers.condition.dt_util.utcnow", return_value=patched_time
+ ):
+ hass.bus.async_fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: patched_time})
+ await hass.async_block_till_done()
assert sun.STATE_ABOVE_HORIZON == hass.states.get(sun.ENTITY_ID).state
diff --git a/tests/components/automation/test_sun.py b/tests/components/sun/test_trigger.py
similarity index 100%
rename from tests/components/automation/test_sun.py
rename to tests/components/sun/test_trigger.py
diff --git a/tests/components/surepetcare/__init__.py b/tests/components/surepetcare/__init__.py
new file mode 100644
index 00000000000..a7ce6d3b6a6
--- /dev/null
+++ b/tests/components/surepetcare/__init__.py
@@ -0,0 +1,88 @@
+"""Tests for Sure Petcare integration."""
+from homeassistant.components.surepetcare.const import DOMAIN
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+
+from tests.async_mock import patch
+
+HOUSEHOLD_ID = "household-id"
+HUB_ID = "hub-id"
+
+MOCK_HUB = {
+ "id": HUB_ID,
+ "product_id": 1,
+ "household_id": HOUSEHOLD_ID,
+ "name": "Hub",
+ "status": {"online": True, "led_mode": 0, "pairing_mode": 0},
+}
+
+MOCK_FEEDER = {
+ "id": 12345,
+ "product_id": 4,
+ "household_id": HOUSEHOLD_ID,
+ "name": "Feeder",
+ "parent": {"product_id": 1, "id": HUB_ID},
+ "status": {
+ "battery": 6.4,
+ "locking": {"mode": 0},
+ "learn_mode": 0,
+ "signal": {"device_rssi": 60, "hub_rssi": 65},
+ },
+}
+
+MOCK_CAT_FLAP = {
+ "id": 13579,
+ "product_id": 6,
+ "household_id": HOUSEHOLD_ID,
+ "name": "Cat Flap",
+ "parent": {"product_id": 1, "id": HUB_ID},
+ "status": {
+ "battery": 6.4,
+ "locking": {"mode": 0},
+ "learn_mode": 0,
+ "signal": {"device_rssi": 65, "hub_rssi": 64},
+ },
+}
+
+MOCK_PET_FLAP = {
+ "id": 13576,
+ "product_id": 3,
+ "household_id": HOUSEHOLD_ID,
+ "name": "Pet Flap",
+ "parent": {"product_id": 1, "id": HUB_ID},
+ "status": {
+ "battery": 6.4,
+ "locking": {"mode": 0},
+ "learn_mode": 0,
+ "signal": {"device_rssi": 70, "hub_rssi": 65},
+ },
+}
+
+MOCK_PET = {
+ "id": 24680,
+ "household_id": HOUSEHOLD_ID,
+ "name": "Pet",
+ "position": {"since": "2020-08-23T23:10:50", "where": 1},
+ "status": {},
+}
+
+MOCK_API_DATA = {
+ "devices": [MOCK_HUB, MOCK_CAT_FLAP, MOCK_PET_FLAP, MOCK_FEEDER],
+ "pets": [MOCK_PET],
+}
+
+MOCK_CONFIG = {
+ DOMAIN: {
+ CONF_USERNAME: "test-username",
+ CONF_PASSWORD: "test-password",
+ "feeders": [12345],
+ "flaps": [13579, 13576],
+ "pets": [24680],
+ },
+}
+
+
+def _patch_sensor_setup():
+ return patch(
+ "homeassistant.components.surepetcare.sensor.async_setup_platform",
+ return_value=True,
+ )
diff --git a/tests/components/surepetcare/conftest.py b/tests/components/surepetcare/conftest.py
new file mode 100644
index 00000000000..6d85a2b0189
--- /dev/null
+++ b/tests/components/surepetcare/conftest.py
@@ -0,0 +1,23 @@
+"""Define fixtures available for all tests."""
+from pytest import fixture
+from surepy import SurePetcare
+
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from tests.async_mock import AsyncMock, patch
+
+
+@fixture()
+def surepetcare(hass):
+ """Mock the SurePetcare for easier testing."""
+ with patch("homeassistant.components.surepetcare.SurePetcare") as mock_surepetcare:
+ instance = mock_surepetcare.return_value = SurePetcare(
+ "test-username",
+ "test-password",
+ hass.loop,
+ async_get_clientsession(hass),
+ api_timeout=1,
+ )
+ instance.get_data = AsyncMock(return_value=None)
+
+ yield mock_surepetcare
diff --git a/tests/components/surepetcare/test_binary_sensor.py b/tests/components/surepetcare/test_binary_sensor.py
new file mode 100644
index 00000000000..9478ca7a1d4
--- /dev/null
+++ b/tests/components/surepetcare/test_binary_sensor.py
@@ -0,0 +1,32 @@
+"""The tests for the Sure Petcare binary sensor platform."""
+from homeassistant.components.surepetcare.const import DOMAIN
+from homeassistant.setup import async_setup_component
+
+from . import MOCK_API_DATA, MOCK_CONFIG, _patch_sensor_setup
+
+EXPECTED_ENTITY_IDS = {
+ "binary_sensor.pet_flap_pet_flap_connectivity": "household-id-13576-connectivity",
+ "binary_sensor.pet_flap_cat_flap_connectivity": "household-id-13579-connectivity",
+ "binary_sensor.feeder_feeder_connectivity": "household-id-12345-connectivity",
+ "binary_sensor.pet_pet": "household-id-24680",
+ "binary_sensor.hub_hub": "household-id-hub-id",
+}
+
+
+async def test_binary_sensors(hass, surepetcare) -> None:
+ """Test the generation of unique ids."""
+ instance = surepetcare.return_value
+ instance.data = MOCK_API_DATA
+ instance.get_data.return_value = MOCK_API_DATA
+
+ with _patch_sensor_setup():
+ assert await async_setup_component(hass, DOMAIN, MOCK_CONFIG)
+ await hass.async_block_till_done()
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ state_entity_ids = hass.states.async_entity_ids()
+
+ for entity_id, unique_id in EXPECTED_ENTITY_IDS.items():
+ assert entity_id in state_entity_ids
+ entity = entity_registry.async_get(entity_id)
+ assert entity.unique_id == unique_id
diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py
index 5853e5faee2..cf2933282ea 100644
--- a/tests/components/switch/test_init.py
+++ b/tests/components/switch/test_init.py
@@ -1,85 +1,55 @@
"""The tests for the Switch component."""
-# pylint: disable=protected-access
-import unittest
+import pytest
from homeassistant import core
from homeassistant.components import switch
from homeassistant.const import CONF_PLATFORM
-from homeassistant.setup import async_setup_component, setup_component
+from homeassistant.setup import async_setup_component
-from tests.common import get_test_home_assistant, mock_entity_platform
from tests.components.switch import common
-class TestSwitch(unittest.TestCase):
- """Test the switch module."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Set up things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- platform = getattr(self.hass.components, "test.switch")
- platform.init()
- # Switch 1 is ON, switch 2 is OFF
- self.switch_1, self.switch_2, self.switch_3 = platform.ENTITIES
- self.addCleanup(self.hass.stop)
-
- def test_methods(self):
- """Test is_on, turn_on, turn_off methods."""
- assert setup_component(
- self.hass, switch.DOMAIN, {switch.DOMAIN: {CONF_PLATFORM: "test"}}
- )
- self.hass.block_till_done()
- assert switch.is_on(self.hass, self.switch_1.entity_id)
- assert not switch.is_on(self.hass, self.switch_2.entity_id)
- assert not switch.is_on(self.hass, self.switch_3.entity_id)
-
- common.turn_off(self.hass, self.switch_1.entity_id)
- common.turn_on(self.hass, self.switch_2.entity_id)
-
- self.hass.block_till_done()
-
- assert not switch.is_on(self.hass, self.switch_1.entity_id)
- assert switch.is_on(self.hass, self.switch_2.entity_id)
-
- # Turn all off
- common.turn_off(self.hass)
-
- self.hass.block_till_done()
-
- assert not switch.is_on(self.hass, self.switch_1.entity_id)
- assert not switch.is_on(self.hass, self.switch_2.entity_id)
- assert not switch.is_on(self.hass, self.switch_3.entity_id)
-
- # Turn all on
- common.turn_on(self.hass)
-
- self.hass.block_till_done()
-
- assert switch.is_on(self.hass, self.switch_1.entity_id)
- assert switch.is_on(self.hass, self.switch_2.entity_id)
- assert switch.is_on(self.hass, self.switch_3.entity_id)
-
- def test_setup_two_platforms(self):
- """Test with bad configuration."""
- # Test if switch component returns 0 switches
- test_platform = getattr(self.hass.components, "test.switch")
- test_platform.init(True)
-
- mock_entity_platform(self.hass, "switch.test2", test_platform)
- test_platform.init(False)
-
- assert setup_component(
- self.hass,
- switch.DOMAIN,
- {
- switch.DOMAIN: {CONF_PLATFORM: "test"},
- f"{switch.DOMAIN} 2": {CONF_PLATFORM: "test2"},
- },
- )
+@pytest.fixture(autouse=True)
+def entities(hass):
+ """Initialize the test switch."""
+ platform = getattr(hass.components, "test.switch")
+ platform.init()
+ yield platform.ENTITIES
-async def test_switch_context(hass, hass_admin_user):
+async def test_methods(hass, entities):
+ """Test is_on, turn_on, turn_off methods."""
+ switch_1, switch_2, switch_3 = entities
+ assert await async_setup_component(
+ hass, switch.DOMAIN, {switch.DOMAIN: {CONF_PLATFORM: "test"}}
+ )
+ await hass.async_block_till_done()
+ assert switch.is_on(hass, switch_1.entity_id)
+ assert not switch.is_on(hass, switch_2.entity_id)
+ assert not switch.is_on(hass, switch_3.entity_id)
+
+ await common.async_turn_off(hass, switch_1.entity_id)
+ await common.async_turn_on(hass, switch_2.entity_id)
+
+ assert not switch.is_on(hass, switch_1.entity_id)
+ assert switch.is_on(hass, switch_2.entity_id)
+
+ # Turn all off
+ await common.async_turn_off(hass)
+
+ assert not switch.is_on(hass, switch_1.entity_id)
+ assert not switch.is_on(hass, switch_2.entity_id)
+ assert not switch.is_on(hass, switch_3.entity_id)
+
+ # Turn all on
+ await common.async_turn_on(hass)
+
+ assert switch.is_on(hass, switch_1.entity_id)
+ assert switch.is_on(hass, switch_2.entity_id)
+ assert switch.is_on(hass, switch_3.entity_id)
+
+
+async def test_switch_context(hass, entities, hass_admin_user):
"""Test that switch context works."""
assert await async_setup_component(hass, "switch", {"switch": {"platform": "test"}})
diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py
index 9e262fafa7e..aa187b80d8f 100644
--- a/tests/components/switcher_kis/test_init.py
+++ b/tests/components/switcher_kis/test_init.py
@@ -39,9 +39,10 @@ from .consts import (
from tests.common import async_fire_time_changed, async_mock_service
if TYPE_CHECKING:
- from tests.common import MockUser
from aioswitcher.devices import SwitcherV2Device
+ from tests.common import MockUser
+
async def test_failed_config(
hass: HomeAssistantType, mock_failed_bridge: Generator[None, Any, None]
diff --git a/tests/components/syncthru/test_config_flow.py b/tests/components/syncthru/test_config_flow.py
index aac1923caab..1df377ff0e8 100644
--- a/tests/components/syncthru/test_config_flow.py
+++ b/tests/components/syncthru/test_config_flow.py
@@ -24,6 +24,7 @@ def mock_connection(aioclient_mock):
text="""
{
\tstatus: {
+\thrDeviceStatus: 2,
\tstatus1: " Sleeping... "
\t},
\tidentity: {
@@ -57,7 +58,9 @@ async def test_already_configured_by_url(hass, aioclient_mock):
mock_connection(aioclient_mock)
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT,
+ DOMAIN,
+ context={"source": config_entries.SOURCE_USER},
+ data=FIXTURE_USER_INPUT,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py
index f592ad90a88..64527c70964 100644
--- a/tests/components/synology_dsm/test_config_flow.py
+++ b/tests/components/synology_dsm/test_config_flow.py
@@ -421,7 +421,8 @@ async def test_options_flow(hass: HomeAssistantType, service: MagicMock):
# Scan interval
# Default
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={},
+ result["flow_id"],
+ user_input={},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options[CONF_SCAN_INTERVAL] == DEFAULT_SCAN_INTERVAL
@@ -429,7 +430,8 @@ async def test_options_flow(hass: HomeAssistantType, service: MagicMock):
# Manual
result = await hass.config_entries.options.async_init(config_entry.entry_id)
result = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={CONF_SCAN_INTERVAL: 2},
+ result["flow_id"],
+ user_input={CONF_SCAN_INTERVAL: 2},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options[CONF_SCAN_INTERVAL] == 2
diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py
index d19ca2261bb..d3f7447277c 100644
--- a/tests/components/system_log/test_init.py
+++ b/tests/components/system_log/test_init.py
@@ -180,8 +180,8 @@ def log_msg(nr=2):
_LOGGER.error("error message %s", nr)
-async def test_dedup_logs(hass, simple_queue, hass_client):
- """Test that duplicate log entries are dedup."""
+async def test_dedupe_logs(hass, simple_queue, hass_client):
+ """Test that duplicate log entries are dedupe."""
await async_setup_component(hass, system_log.DOMAIN, {})
_LOGGER.error("error message 1")
log_msg()
diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py
index 2e42a2bc1fb..9bd3db5e46b 100644
--- a/tests/components/tado/test_config_flow.py
+++ b/tests/components/tado/test_config_flow.py
@@ -30,11 +30,13 @@ async def test_form(hass):
mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]})
with patch(
- "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api,
+ "homeassistant.components.tado.config_flow.Tado",
+ return_value=mock_tado_api,
), patch(
"homeassistant.components.tado.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.tado.async_setup_entry", return_value=True,
+ "homeassistant.components.tado.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -59,11 +61,13 @@ async def test_import(hass):
mock_tado_api = _get_mock_tado_api(getMe={"homes": [{"id": 1, "name": "myhome"}]})
with patch(
- "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api,
+ "homeassistant.components.tado.config_flow.Tado",
+ return_value=mock_tado_api,
), patch(
"homeassistant.components.tado.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.tado.async_setup_entry", return_value=True,
+ "homeassistant.components.tado.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
@@ -93,7 +97,8 @@ async def test_form_invalid_auth(hass):
mock_tado_api = _get_mock_tado_api(getMe=requests.HTTPError(response=response_mock))
with patch(
- "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api,
+ "homeassistant.components.tado.config_flow.Tado",
+ return_value=mock_tado_api,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -115,7 +120,8 @@ async def test_form_cannot_connect(hass):
mock_tado_api = _get_mock_tado_api(getMe=requests.HTTPError(response=response_mock))
with patch(
- "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api,
+ "homeassistant.components.tado.config_flow.Tado",
+ return_value=mock_tado_api,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
@@ -135,7 +141,8 @@ async def test_no_homes(hass):
mock_tado_api = _get_mock_tado_api(getMe={"homes": []})
with patch(
- "homeassistant.components.tado.config_flow.Tado", return_value=mock_tado_api,
+ "homeassistant.components.tado.config_flow.Tado",
+ return_value=mock_tado_api,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py
index 5c060e76eec..7dc6a21faa7 100644
--- a/tests/components/tado/util.py
+++ b/tests/components/tado/util.py
@@ -10,7 +10,8 @@ from tests.common import MockConfigEntry, load_fixture
async def async_init_integration(
- hass: HomeAssistant, skip_setup: bool = False,
+ hass: HomeAssistant,
+ skip_setup: bool = False,
):
"""Set up the tado integration in Home Assistant."""
@@ -42,7 +43,8 @@ async def async_init_integration(
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),
+ "https://my.tado.com/api/v2/me",
+ text=load_fixture(me_fixture),
)
m.get(
"https://my.tado.com/api/v2/homes/1/devices",
diff --git a/tests/components/tag/__init__.py b/tests/components/tag/__init__.py
new file mode 100644
index 00000000000..5908bd04e59
--- /dev/null
+++ b/tests/components/tag/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Tag integration."""
diff --git a/tests/components/tag/test_init.py b/tests/components/tag/test_init.py
new file mode 100644
index 00000000000..ecb927c1c5c
--- /dev/null
+++ b/tests/components/tag/test_init.py
@@ -0,0 +1,128 @@
+"""Tests for the tag component."""
+import logging
+
+import pytest
+
+from homeassistant.components.tag import DOMAIN, TAGS, async_scan_tag
+from homeassistant.helpers import collection
+from homeassistant.setup import async_setup_component
+from homeassistant.util import dt as dt_util
+
+from tests.async_mock import patch
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@pytest.fixture
+def storage_setup(hass, hass_storage):
+ """Storage setup."""
+
+ async def _storage(items=None):
+ if items is None:
+ hass_storage[DOMAIN] = {
+ "key": DOMAIN,
+ "version": 1,
+ "data": {"items": [{"id": "test tag"}]},
+ }
+ else:
+ hass_storage[DOMAIN] = items
+ config = {DOMAIN: {}}
+ return await async_setup_component(hass, DOMAIN, config)
+
+ return _storage
+
+
+async def test_ws_list(hass, hass_ws_client, storage_setup):
+ """Test listing tags via WS."""
+ assert await storage_setup()
+
+ client = await hass_ws_client(hass)
+
+ await client.send_json({"id": 6, "type": f"{DOMAIN}/list"})
+ resp = await client.receive_json()
+ assert resp["success"]
+
+ result = {item["id"]: item for item in resp["result"]}
+
+ assert len(result) == 1
+ assert "test tag" in result
+
+
+async def test_ws_update(hass, hass_ws_client, storage_setup):
+ """Test listing tags via WS."""
+ assert await storage_setup()
+ await async_scan_tag(hass, "test tag", "some_scanner")
+
+ client = await hass_ws_client(hass)
+
+ await client.send_json(
+ {
+ "id": 6,
+ "type": f"{DOMAIN}/update",
+ f"{DOMAIN}_id": "test tag",
+ "name": "New name",
+ }
+ )
+ resp = await client.receive_json()
+ assert resp["success"]
+
+ item = resp["result"]
+
+ assert item["id"] == "test tag"
+ assert item["name"] == "New name"
+
+
+async def test_tag_scanned(hass, hass_ws_client, storage_setup):
+ """Test scanning tags."""
+ assert await storage_setup()
+
+ client = await hass_ws_client(hass)
+
+ await client.send_json({"id": 6, "type": f"{DOMAIN}/list"})
+ resp = await client.receive_json()
+ assert resp["success"]
+
+ result = {item["id"]: item for item in resp["result"]}
+
+ assert len(result) == 1
+ assert "test tag" in result
+
+ now = dt_util.utcnow()
+ with patch("homeassistant.util.dt.utcnow", return_value=now):
+ await async_scan_tag(hass, "new tag", "some_scanner")
+
+ await client.send_json({"id": 7, "type": f"{DOMAIN}/list"})
+ resp = await client.receive_json()
+ assert resp["success"]
+
+ result = {item["id"]: item for item in resp["result"]}
+
+ assert len(result) == 2
+ assert "test tag" in result
+ assert "new tag" in result
+ assert result["new tag"]["last_scanned"] == now.isoformat()
+
+
+def track_changes(coll: collection.ObservableCollection):
+ """Create helper to track changes in a collection."""
+ changes = []
+
+ async def listener(*args):
+ changes.append(args)
+
+ coll.async_add_listener(listener)
+
+ return changes
+
+
+async def test_tag_id_exists(hass, hass_ws_client, storage_setup):
+ """Test scanning tags."""
+ assert await storage_setup()
+ changes = track_changes(hass.data[DOMAIN][TAGS])
+ client = await hass_ws_client(hass)
+
+ await client.send_json({"id": 2, "type": f"{DOMAIN}/create", "tag_id": "test tag"})
+ response = await client.receive_json()
+ assert not response["success"]
+ assert response["error"]["code"] == "unknown_error"
+ assert len(changes) == 0
diff --git a/tests/components/tag/test_trigger.py b/tests/components/tag/test_trigger.py
new file mode 100644
index 00000000000..0249acaf29b
--- /dev/null
+++ b/tests/components/tag/test_trigger.py
@@ -0,0 +1,85 @@
+"""Tests for tag triggers."""
+
+import pytest
+
+import homeassistant.components.automation as automation
+from homeassistant.components.tag import async_scan_tag
+from homeassistant.components.tag.const import DOMAIN, TAG_ID
+from homeassistant.setup import async_setup_component
+
+from tests.common import async_mock_service
+
+
+@pytest.fixture
+def tag_setup(hass, hass_storage):
+ """Tag setup."""
+
+ async def _storage(items=None):
+ if items is None:
+ hass_storage[DOMAIN] = {
+ "key": DOMAIN,
+ "version": 1,
+ "data": {"items": [{"id": "test tag"}]},
+ }
+ else:
+ hass_storage[DOMAIN] = items
+ config = {DOMAIN: {}}
+ return await async_setup_component(hass, DOMAIN, config)
+
+ return _storage
+
+
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock service."""
+ return async_mock_service(hass, "test", "automation")
+
+
+async def test_triggers(hass, tag_setup, calls):
+ """Test tag triggers."""
+ assert await tag_setup()
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {"platform": DOMAIN, TAG_ID: "abc123"},
+ "action": {
+ "service": "test.automation",
+ "data": {"message": "service called"},
+ },
+ }
+ ]
+ },
+ )
+
+ await hass.async_block_till_done()
+
+ await async_scan_tag(hass, "abc123", None)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data["message"] == "service called"
+
+
+async def test_exception_bad_trigger(hass, calls, caplog):
+ """Test for exception on event triggers firing."""
+
+ await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {"trigger": {"platform": DOMAIN, "oops": "abc123"}},
+ "action": {
+ "service": "test.automation",
+ "data": {"message": "service called"},
+ },
+ }
+ ]
+ },
+ )
+ await hass.async_block_till_done()
+ assert "Invalid config for [automation]" in caplog.text
diff --git a/tests/components/teksavvy/test_sensor.py b/tests/components/teksavvy/test_sensor.py
index 7de8651f16b..b0de95d72d1 100644
--- a/tests/components/teksavvy/test_sensor.py
+++ b/tests/components/teksavvy/test_sensor.py
@@ -1,7 +1,7 @@
"""Tests for the TekSavvy sensor platform."""
from homeassistant.bootstrap import async_setup_component
from homeassistant.components.teksavvy.sensor import TekSavvyData
-from homeassistant.const import DATA_GIGABYTES, HTTP_NOT_FOUND, UNIT_PERCENTAGE
+from homeassistant.const import DATA_GIGABYTES, HTTP_NOT_FOUND, PERCENTAGE
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -75,7 +75,7 @@ async def test_capped_setup(hass, aioclient_mock):
assert state.state == "235.57"
state = hass.states.get("sensor.teksavvy_usage_ratio")
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
+ assert state.attributes.get("unit_of_measurement") == PERCENTAGE
assert state.state == "56.69"
state = hass.states.get("sensor.teksavvy_usage")
@@ -161,7 +161,7 @@ async def test_unlimited_setup(hass, aioclient_mock):
assert state.state == "226.75"
state = hass.states.get("sensor.teksavvy_usage_ratio")
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
+ assert state.attributes.get("unit_of_measurement") == PERCENTAGE
assert state.state == "0"
state = hass.states.get("sensor.teksavvy_remaining")
diff --git a/tests/components/telegram/__init__.py b/tests/components/telegram/__init__.py
new file mode 100644
index 00000000000..feff68fd53a
--- /dev/null
+++ b/tests/components/telegram/__init__.py
@@ -0,0 +1 @@
+"""Tests for telegram component."""
diff --git a/tests/components/telegram/test_notify.py b/tests/components/telegram/test_notify.py
new file mode 100644
index 00000000000..7488db49d9e
--- /dev/null
+++ b/tests/components/telegram/test_notify.py
@@ -0,0 +1,53 @@
+"""The tests for the telegram.notify platform."""
+from os import path
+
+from homeassistant import config as hass_config
+import homeassistant.components.notify as notify
+from homeassistant.components.telegram import DOMAIN
+from homeassistant.const import SERVICE_RELOAD
+from homeassistant.setup import async_setup_component
+
+from tests.async_mock import patch
+
+
+async def test_reload_notify(hass):
+ """Verify we can reload the notify service."""
+
+ with patch("homeassistant.components.telegram_bot.async_setup", return_value=True):
+ assert await async_setup_component(
+ hass,
+ notify.DOMAIN,
+ {
+ notify.DOMAIN: [
+ {
+ "name": DOMAIN,
+ "platform": DOMAIN,
+ "chat_id": 1,
+ },
+ ]
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert hass.services.has_service(notify.DOMAIN, DOMAIN)
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "telegram/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert not hass.services.has_service(notify.DOMAIN, DOMAIN)
+ assert hass.services.has_service(notify.DOMAIN, "telegram_reloaded")
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/template/test_alarm_control_panel.py b/tests/components/template/test_alarm_control_panel.py
index e27359fd56e..d8d4b8fdcf2 100644
--- a/tests/components/template/test_alarm_control_panel.py
+++ b/tests/components/template/test_alarm_control_panel.py
@@ -148,6 +148,7 @@ async def test_optimistic_states(hass):
await common.async_alarm_arm_away(
hass, entity_id="alarm_control_panel.test_template_panel"
)
+ await hass.async_block_till_done()
state = hass.states.get("alarm_control_panel.test_template_panel")
await hass.async_block_till_done()
assert state.state == STATE_ALARM_ARMED_AWAY
@@ -201,6 +202,7 @@ async def test_no_action_scripts(hass):
await common.async_alarm_arm_away(
hass, entity_id="alarm_control_panel.test_template_panel"
)
+ await hass.async_block_till_done()
state = hass.states.get("alarm_control_panel.test_template_panel")
await hass.async_block_till_done()
assert state.state == STATE_ALARM_ARMED_AWAY
@@ -208,6 +210,7 @@ async def test_no_action_scripts(hass):
await common.async_alarm_arm_home(
hass, entity_id="alarm_control_panel.test_template_panel"
)
+ await hass.async_block_till_done()
state = hass.states.get("alarm_control_panel.test_template_panel")
await hass.async_block_till_done()
assert state.state == STATE_ALARM_ARMED_AWAY
@@ -215,6 +218,7 @@ async def test_no_action_scripts(hass):
await common.async_alarm_arm_night(
hass, entity_id="alarm_control_panel.test_template_panel"
)
+ await hass.async_block_till_done()
state = hass.states.get("alarm_control_panel.test_template_panel")
await hass.async_block_till_done()
assert state.state == STATE_ALARM_ARMED_AWAY
@@ -222,6 +226,7 @@ async def test_no_action_scripts(hass):
await common.async_alarm_disarm(
hass, entity_id="alarm_control_panel.test_template_panel"
)
+ await hass.async_block_till_done()
state = hass.states.get("alarm_control_panel.test_template_panel")
await hass.async_block_till_done()
assert state.state == STATE_ALARM_ARMED_AWAY
@@ -341,7 +346,9 @@ async def test_invalid_panel_does_not_create(hass, caplog):
async def test_no_panels_does_not_create(hass, caplog):
"""Test if there are no panels -> no creation."""
await setup.async_setup_component(
- hass, "alarm_control_panel", {"alarm_control_panel": {"platform": "template"}},
+ hass,
+ "alarm_control_panel",
+ {"alarm_control_panel": {"platform": "template"}},
)
await hass.async_block_till_done()
diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py
index aeee08dc757..f9db8be37c6 100644
--- a/tests/components/template/test_binary_sensor.py
+++ b/tests/components/template/test_binary_sensor.py
@@ -1,20 +1,18 @@
"""The tests for the Template Binary sensor platform."""
from datetime import timedelta
+import logging
import unittest
from unittest import mock
from homeassistant import setup
-from homeassistant.components.template import binary_sensor as template
from homeassistant.const import (
+ ATTR_DEVICE_CLASS,
EVENT_HOMEASSISTANT_START,
- MATCH_ALL,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
)
-from homeassistant.exceptions import TemplateError
-from homeassistant.helpers import template as template_hlpr
-from homeassistant.util.async_ import run_callback_threadsafe
+from homeassistant.core import CoreState
import homeassistant.util.dt as dt_util
from tests.common import (
@@ -201,7 +199,8 @@ class TestBinarySensorTemplate(unittest.TestCase):
state = self.hass.states.get("binary_sensor.test_template_sensor")
assert state.attributes.get("test_attribute") == "It ."
-
+ self.hass.states.set("sensor.test_state", "Works2")
+ self.hass.block_till_done()
self.hass.states.set("sensor.test_state", "Works")
self.hass.block_till_done()
state = self.hass.states.get("binary_sensor.test_template_sensor")
@@ -209,10 +208,10 @@ class TestBinarySensorTemplate(unittest.TestCase):
@mock.patch(
"homeassistant.components.template.binary_sensor."
- "BinarySensorTemplate._async_render"
+ "BinarySensorTemplate._update_state"
)
- def test_match_all(self, _async_render):
- """Test MATCH_ALL in template."""
+ def test_match_all(self, _update_state):
+ """Test template that is rerendered on any state lifecycle."""
with assert_setup_component(1):
assert setup.setup_component(
self.hass,
@@ -221,52 +220,27 @@ class TestBinarySensorTemplate(unittest.TestCase):
"binary_sensor": {
"platform": "template",
"sensors": {
- "match_all_template_sensor": {"value_template": "{{ 42 }}"}
+ "match_all_template_sensor": {
+ "value_template": (
+ "{% for state in states %}"
+ "{% if state.entity_id == 'sensor.humidity' %}"
+ "{{ state.entity_id }}={{ state.state }}"
+ "{% endif %}"
+ "{% endfor %}"
+ ),
+ },
},
}
},
)
- self.hass.block_till_done()
self.hass.start()
self.hass.block_till_done()
- init_calls = len(_async_render.mock_calls)
+ init_calls = len(_update_state.mock_calls)
self.hass.states.set("sensor.any_state", "update")
self.hass.block_till_done()
- assert len(_async_render.mock_calls) == init_calls
-
- def test_attributes(self):
- """Test the attributes."""
- vs = run_callback_threadsafe(
- self.hass.loop,
- template.BinarySensorTemplate,
- self.hass,
- "parent",
- "Parent",
- "motion",
- template_hlpr.Template("{{ 1 > 1 }}", self.hass),
- None,
- None,
- None,
- MATCH_ALL,
- None,
- None,
- None,
- None,
- ).result()
- assert not vs.should_poll
- assert "motion" == vs.device_class
- assert "Parent" == vs.name
-
- run_callback_threadsafe(self.hass.loop, vs.async_check_state).result()
- assert not vs.is_on
-
- # pylint: disable=protected-access
- vs._template = template_hlpr.Template("{{ 2 > 1 }}", self.hass)
-
- run_callback_threadsafe(self.hass.loop, vs.async_check_state).result()
- assert vs.is_on
+ assert len(_update_state.mock_calls) == init_calls
def test_event(self):
"""Test the event."""
@@ -298,33 +272,6 @@ class TestBinarySensorTemplate(unittest.TestCase):
state = self.hass.states.get("binary_sensor.test")
assert state.state == "on"
- @mock.patch("homeassistant.helpers.template.Template.render")
- def test_update_template_error(self, mock_render):
- """Test the template update error."""
- vs = run_callback_threadsafe(
- self.hass.loop,
- template.BinarySensorTemplate,
- self.hass,
- "parent",
- "Parent",
- "motion",
- template_hlpr.Template("{{ 1 > 1 }}", self.hass),
- None,
- None,
- None,
- MATCH_ALL,
- None,
- None,
- None,
- None,
- ).result()
- mock_render.side_effect = TemplateError("foo")
- run_callback_threadsafe(self.hass.loop, vs.async_check_state).result()
- mock_render.side_effect = TemplateError(
- "UndefinedError: 'None' has no attribute"
- )
- run_callback_threadsafe(self.hass.loop, vs.async_check_state).result()
-
async def test_template_delay_on(hass):
"""Test binary sensor template delay on."""
@@ -465,7 +412,10 @@ async def test_available_without_availability_template(hass):
await hass.async_start()
await hass.async_block_till_done()
- assert hass.states.get("binary_sensor.test").state != STATE_UNAVAILABLE
+ state = hass.states.get("binary_sensor.test")
+
+ assert state.state != STATE_UNAVAILABLE
+ assert state.attributes[ATTR_DEVICE_CLASS] == "motion"
async def test_availability_template(hass):
@@ -497,7 +447,10 @@ async def test_availability_template(hass):
hass.states.async_set("sensor.test_state", STATE_ON)
await hass.async_block_till_done()
- assert hass.states.get("binary_sensor.test").state != STATE_UNAVAILABLE
+ state = hass.states.get("binary_sensor.test")
+
+ assert state.state != STATE_UNAVAILABLE
+ assert state.attributes[ATTR_DEVICE_CLASS] == "motion"
async def test_invalid_attribute_template(hass, caplog):
@@ -523,11 +476,11 @@ async def test_invalid_attribute_template(hass, caplog):
)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 2
- await hass.helpers.entity_component.async_update_entity(
- "binary_sensor.invalid_template"
- )
+ await hass.async_start()
+ await hass.async_block_till_done()
- assert ("Error rendering attribute test_attribute") in caplog.text
+ assert "test_attribute" in caplog.text
+ assert "TemplateError" in caplog.text
async def test_invalid_availability_template_keeps_component_available(hass, caplog):
@@ -560,6 +513,8 @@ async def test_no_update_template_match_all(hass, caplog):
"""Test that we do not update sensors that match on all."""
hass.states.async_set("binary_sensor.test_sensor", "true")
+ hass.state = CoreState.not_running
+
await setup.async_setup_component(
hass,
"binary_sensor",
@@ -586,26 +541,6 @@ async def test_no_update_template_match_all(hass, caplog):
)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 5
- assert (
- "Template binary sensor 'all_state' has no entity ids "
- "configured to track nor were we able to extract the entities to "
- "track from the value template"
- ) in caplog.text
- assert (
- "Template binary sensor 'all_icon' has no entity ids "
- "configured to track nor were we able to extract the entities to "
- "track from the icon template"
- ) in caplog.text
- assert (
- "Template binary sensor 'all_entity_picture' has no entity ids "
- "configured to track nor were we able to extract the entities to "
- "track from the entity_picture template"
- ) in caplog.text
- assert (
- "Template binary sensor 'all_attribute' has no entity ids "
- "configured to track nor were we able to extract the entities to "
- "track from the test_attribute template"
- ) in caplog.text
assert hass.states.get("binary_sensor.all_state").state == "off"
assert hass.states.get("binary_sensor.all_icon").state == "off"
@@ -671,3 +606,45 @@ async def test_unique_id(hass):
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1
+
+
+async def test_template_validation_error(hass, caplog):
+ """Test binary sensor template delay on."""
+ caplog.set_level(logging.ERROR)
+ config = {
+ "binary_sensor": {
+ "platform": "template",
+ "sensors": {
+ "test": {
+ "friendly_name": "virtual thingy",
+ "value_template": "True",
+ "icon_template": "{{ states.sensor.test_state.state }}",
+ "device_class": "motion",
+ "delay_on": 5,
+ },
+ },
+ },
+ }
+ await setup.async_setup_component(hass, "binary_sensor", config)
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.get("binary_sensor.test")
+ assert state.attributes.get("icon") == ""
+
+ hass.states.async_set("sensor.test_state", "mdi:check")
+ await hass.async_block_till_done()
+
+ state = hass.states.get("binary_sensor.test")
+ assert state.attributes.get("icon") == "mdi:check"
+
+ hass.states.async_set("sensor.test_state", "invalid_icon")
+ await hass.async_block_till_done()
+ assert len(caplog.records) == 1
+ assert caplog.records[0].message.startswith(
+ "Error validating template result 'invalid_icon' from template"
+ )
+
+ state = hass.states.get("binary_sensor.test")
+ assert state.attributes.get("icon") is None
diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py
index d51d71648bf..5deb540782c 100644
--- a/tests/components/template/test_cover.py
+++ b/tests/components/template/test_cover.py
@@ -1079,3 +1079,44 @@ async def test_unique_id(hass):
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1
+
+
+async def test_state_gets_lowercased(hass):
+ """Test True/False is lowercased."""
+
+ hass.states.async_set("binary_sensor.garage_door_sensor", "off")
+
+ await setup.async_setup_component(
+ hass,
+ "cover",
+ {
+ "cover": {
+ "platform": "template",
+ "covers": {
+ "garage_door": {
+ "friendly_name": "Garage Door",
+ "value_template": "{{ is_state('binary_sensor.garage_door_sensor', 'off') }}",
+ "open_cover": {
+ "service": "cover.open_cover",
+ "entity_id": "cover.test_state",
+ },
+ "close_cover": {
+ "service": "cover.close_cover",
+ "entity_id": "cover.test_state",
+ },
+ },
+ },
+ },
+ },
+ )
+
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 2
+
+ assert hass.states.get("cover.garage_door").state == STATE_OPEN
+ hass.states.async_set("binary_sensor.garage_door_sensor", "on")
+ await hass.async_block_till_done()
+ assert hass.states.get("cover.garage_door").state == STATE_CLOSED
diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py
index a56b55fa123..10af5fd0b74 100644
--- a/tests/components/template/test_fan.py
+++ b/tests/components/template/test_fan.py
@@ -414,8 +414,9 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap
await hass.async_block_till_done()
assert hass.states.get("fan.test_fan").state != STATE_UNAVAILABLE
- assert ("Could not render availability_template template") in caplog.text
- assert ("UndefinedError: 'x' is undefined") in caplog.text
+
+ assert "TemplateError" in caplog.text
+ assert "x" in caplog.text
# End of template tests #
diff --git a/tests/components/template/test_init.py b/tests/components/template/test_init.py
new file mode 100644
index 00000000000..2d14ec97574
--- /dev/null
+++ b/tests/components/template/test_init.py
@@ -0,0 +1,257 @@
+"""The test for the Template sensor platform."""
+from os import path
+from unittest.mock import patch
+
+from homeassistant import config
+from homeassistant.components.template import DOMAIN, SERVICE_RELOAD
+from homeassistant.setup import async_setup_component
+
+
+async def test_reloadable(hass):
+ """Test that we can reload."""
+ hass.states.async_set("sensor.test_sensor", "mytest")
+
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": DOMAIN,
+ "sensors": {
+ "state": {
+ "value_template": "{{ states.sensor.test_sensor.state }}"
+ },
+ },
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert hass.states.get("sensor.state").state == "mytest"
+ assert len(hass.states.async_all()) == 2
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "template/sensor_configuration.yaml",
+ )
+ with patch.object(config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 3
+
+ assert hass.states.get("sensor.state") is None
+ assert hass.states.get("sensor.watching_tv_in_master_bedroom").state == "off"
+ assert float(hass.states.get("sensor.combined_sensor_energy_usage").state) == 0
+
+
+async def test_reloadable_can_remove(hass):
+ """Test that we can reload and remove all template sensors."""
+ hass.states.async_set("sensor.test_sensor", "mytest")
+
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": DOMAIN,
+ "sensors": {
+ "state": {
+ "value_template": "{{ states.sensor.test_sensor.state }}"
+ },
+ },
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert hass.states.get("sensor.state").state == "mytest"
+ assert len(hass.states.async_all()) == 2
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "template/empty_configuration.yaml",
+ )
+ with patch.object(config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+
+async def test_reloadable_stops_on_invalid_config(hass):
+ """Test we stop the reload if configuration.yaml is completely broken."""
+ hass.states.async_set("sensor.test_sensor", "mytest")
+
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": DOMAIN,
+ "sensors": {
+ "state": {
+ "value_template": "{{ states.sensor.test_sensor.state }}"
+ },
+ },
+ }
+ },
+ )
+
+ await hass.async_block_till_done()
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert hass.states.get("sensor.state").state == "mytest"
+ assert len(hass.states.async_all()) == 2
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "template/configuration.yaml.corrupt",
+ )
+ with patch.object(config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert hass.states.get("sensor.state").state == "mytest"
+ assert len(hass.states.async_all()) == 2
+
+
+async def test_reloadable_handles_partial_valid_config(hass):
+ """Test we can still setup valid sensors when configuration.yaml has a broken entry."""
+ hass.states.async_set("sensor.test_sensor", "mytest")
+
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": DOMAIN,
+ "sensors": {
+ "state": {
+ "value_template": "{{ states.sensor.test_sensor.state }}"
+ },
+ },
+ }
+ },
+ )
+
+ await hass.async_block_till_done()
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert hass.states.get("sensor.state").state == "mytest"
+ assert len(hass.states.async_all()) == 2
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "template/broken_configuration.yaml",
+ )
+ with patch.object(config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 3
+
+ assert hass.states.get("sensor.state") is None
+ assert hass.states.get("sensor.watching_tv_in_master_bedroom").state == "off"
+ assert float(hass.states.get("sensor.combined_sensor_energy_usage").state) == 0
+
+
+async def test_reloadable_multiple_platforms(hass):
+ """Test that we can reload."""
+ hass.states.async_set("sensor.test_sensor", "mytest")
+
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": DOMAIN,
+ "sensors": {
+ "state": {
+ "value_template": "{{ states.sensor.test_sensor.state }}"
+ },
+ },
+ }
+ },
+ )
+ await async_setup_component(
+ hass,
+ "binary_sensor",
+ {
+ "binary_sensor": {
+ "platform": DOMAIN,
+ "sensors": {
+ "state": {
+ "value_template": "{{ states.sensor.test_sensor.state }}"
+ },
+ },
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert hass.states.get("sensor.state").state == "mytest"
+ assert hass.states.get("binary_sensor.state").state == "off"
+
+ assert len(hass.states.async_all()) == 3
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "template/sensor_configuration.yaml",
+ )
+ with patch.object(config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 3
+
+ assert hass.states.get("sensor.state") is None
+ assert hass.states.get("sensor.watching_tv_in_master_bedroom").state == "off"
+ assert float(hass.states.get("sensor.combined_sensor_energy_usage").state) == 0
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py
index 4c15babdfe2..db1be64c379 100644
--- a/tests/components/template/test_lock.py
+++ b/tests/components/template/test_lock.py
@@ -231,8 +231,8 @@ class TestTemplateLock:
assert self.hass.states.all() == []
- def test_no_template_match_all(self, caplog):
- """Test that we do not allow locks that match on all."""
+ def test_template_static(self, caplog):
+ """Test that we allow static templates."""
with assert_setup_component(1, "lock"):
assert setup.setup_component(
self.hass,
@@ -260,12 +260,6 @@ class TestTemplateLock:
state = self.hass.states.get("lock.template_lock")
assert state.state == lock.STATE_UNLOCKED
- assert (
- "Template lock 'Template Lock' has no entity ids configured to track "
- "nor were we able to extract the entities to track from the value "
- "template(s). This entity will only be able to be updated manually"
- ) in caplog.text
-
self.hass.states.set("lock.template_lock", lock.STATE_LOCKED)
self.hass.block_till_done()
state = self.hass.states.get("lock.template_lock")
diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py
index 3899a7b3afe..6c9bfa7e632 100644
--- a/tests/components/template/test_sensor.py
+++ b/tests/components/template/test_sensor.py
@@ -4,13 +4,18 @@ from unittest.mock import patch
from homeassistant.bootstrap import async_from_config_dict
from homeassistant.const import (
+ ATTR_ENTITY_PICTURE,
+ ATTR_ICON,
EVENT_COMPONENT_LOADED,
EVENT_HOMEASSISTANT_START,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
)
+from homeassistant.core import CoreState, callback
+from homeassistant.helpers.template import Template
from homeassistant.setup import ATTR_COMPONENT, async_setup_component, setup_component
+import homeassistant.util.dt as dt_util
from tests.common import assert_setup_component, get_test_home_assistant
@@ -469,7 +474,8 @@ async def test_creating_sensor_loads_group(hass):
hass.bus.async_listen(EVENT_COMPONENT_LOADED, set_after_dep_event)
with patch(
- "homeassistant.components.group.async_setup", new=async_setup_group,
+ "homeassistant.components.group.async_setup",
+ new=async_setup_group,
), patch(
"homeassistant.components.template.sensor.async_setup_platform",
new=async_setup_template,
@@ -544,9 +550,13 @@ async def test_invalid_attribute_template(hass, caplog):
)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 2
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
await hass.helpers.entity_component.async_update_entity("sensor.invalid_template")
- assert ("Error rendering attribute test_attribute") in caplog.text
+ assert "TemplateError" in caplog.text
+ assert "test_attribute" in caplog.text
async def test_invalid_availability_template_keeps_component_available(hass, caplog):
@@ -577,9 +587,11 @@ async def test_invalid_availability_template_keeps_component_available(hass, cap
async def test_no_template_match_all(hass, caplog):
- """Test that we do not allow sensors that match on all."""
+ """Test that we allow static templates."""
hass.states.async_set("sensor.test_sensor", "startup")
+ hass.state = CoreState.not_running
+
await async_setup_component(
hass,
"sensor",
@@ -617,31 +629,6 @@ async def test_no_template_match_all(hass, caplog):
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 6
- assert (
- "Template sensor 'invalid_state' has no entity ids "
- "configured to track nor were we able to extract the entities to "
- "track from the value template"
- ) in caplog.text
- assert (
- "Template sensor 'invalid_icon' has no entity ids "
- "configured to track nor were we able to extract the entities to "
- "track from the icon template"
- ) in caplog.text
- assert (
- "Template sensor 'invalid_entity_picture' has no entity ids "
- "configured to track nor were we able to extract the entities to "
- "track from the entity_picture template"
- ) in caplog.text
- assert (
- "Template sensor 'invalid_friendly_name' has no entity ids "
- "configured to track nor were we able to extract the entities to "
- "track from the friendly_name template"
- ) in caplog.text
- assert (
- "Template sensor 'invalid_attribute' has no entity ids "
- "configured to track nor were we able to extract the entities to "
- "track from the test_attribute template"
- ) in caplog.text
assert hass.states.get("sensor.invalid_state").state == "unknown"
assert hass.states.get("sensor.invalid_icon").state == "unknown"
@@ -712,3 +699,290 @@ async def test_unique_id(hass):
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1
+
+
+async def test_sun_renders_once_per_sensor(hass):
+ """Test sun change renders the template only once per sensor."""
+
+ now = dt_util.utcnow()
+ hass.states.async_set(
+ "sun.sun", "above_horizon", {"elevation": 45.3, "next_rising": now}
+ )
+
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": "template",
+ "sensors": {
+ "solar_angle": {
+ "friendly_name": "Sun angle",
+ "unit_of_measurement": "degrees",
+ "value_template": "{{ state_attr('sun.sun', 'elevation') }}",
+ },
+ "sunrise": {
+ "value_template": "{{ state_attr('sun.sun', 'next_rising') }}"
+ },
+ },
+ }
+ },
+ )
+
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 3
+
+ assert hass.states.get("sensor.solar_angle").state == "45.3"
+ assert hass.states.get("sensor.sunrise").state == str(now)
+
+ async_render_calls = []
+
+ @callback
+ def _record_async_render(self, *args, **kwargs):
+ """Catch async_render."""
+ async_render_calls.append(self.template)
+ return "mocked"
+
+ later = dt_util.utcnow()
+
+ with patch.object(Template, "async_render", _record_async_render):
+ hass.states.async_set("sun.sun", {"elevation": 50, "next_rising": later})
+ await hass.async_block_till_done()
+
+ assert hass.states.get("sensor.solar_angle").state == "mocked"
+ assert hass.states.get("sensor.sunrise").state == "mocked"
+
+ assert len(async_render_calls) == 2
+ assert set(async_render_calls) == {
+ "{{ state_attr('sun.sun', 'elevation') }}",
+ "{{ state_attr('sun.sun', 'next_rising') }}",
+ }
+
+
+async def test_self_referencing_sensor_loop(hass, caplog):
+ """Test a self referencing sensor does not loop forever."""
+
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": "template",
+ "sensors": {
+ "test": {
+ "value_template": "{{ ((states.sensor.test.state or 0) | int) + 1 }}",
+ },
+ },
+ }
+ },
+ )
+
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ assert "Template loop detected" in caplog.text
+
+ state = hass.states.get("sensor.test")
+ assert int(state.state) == 1
+ await hass.async_block_till_done()
+ assert int(state.state) == 1
+
+
+async def test_self_referencing_sensor_with_icon_loop(hass, caplog):
+ """Test a self referencing sensor loops forever with a valid self referencing icon."""
+
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": "template",
+ "sensors": {
+ "test": {
+ "value_template": "{{ ((states.sensor.test.state or 0) | int) + 1 }}",
+ "icon_template": "{% if ((states.sensor.test.state or 0) | int) >= 1 %}mdi:greater{% else %}mdi:less{% endif %}",
+ },
+ },
+ }
+ },
+ )
+
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ assert "Template loop detected" in caplog.text
+
+ state = hass.states.get("sensor.test")
+ assert int(state.state) == 2
+ assert state.attributes[ATTR_ICON] == "mdi:greater"
+
+ await hass.async_block_till_done()
+ assert int(state.state) == 2
+
+
+async def test_self_referencing_sensor_with_icon_and_picture_entity_loop(hass, caplog):
+ """Test a self referencing sensor loop forevers with a valid self referencing icon."""
+
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": "template",
+ "sensors": {
+ "test": {
+ "value_template": "{{ ((states.sensor.test.state or 0) | int) + 1 }}",
+ "icon_template": "{% if ((states.sensor.test.state or 0) | int) > 3 %}mdi:greater{% else %}mdi:less{% endif %}",
+ "entity_picture_template": "{% if ((states.sensor.test.state or 0) | int) >= 1 %}bigpic{% else %}smallpic{% endif %}",
+ },
+ },
+ }
+ },
+ )
+
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ assert "Template loop detected" in caplog.text
+
+ state = hass.states.get("sensor.test")
+ assert int(state.state) == 3
+ assert state.attributes[ATTR_ICON] == "mdi:less"
+ assert state.attributes[ATTR_ENTITY_PICTURE] == "bigpic"
+
+ await hass.async_block_till_done()
+ assert int(state.state) == 3
+
+
+async def test_self_referencing_entity_picture_loop(hass, caplog):
+ """Test a self referencing sensor does not loop forever with a looping self referencing entity picture."""
+
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": "template",
+ "sensors": {
+ "test": {
+ "value_template": "{{ 1 }}",
+ "entity_picture_template": "{{ ((states.sensor.test.attributes['entity_picture'] or 0) | int) + 1 }}",
+ },
+ },
+ }
+ },
+ )
+
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ assert "Template loop detected" in caplog.text
+
+ state = hass.states.get("sensor.test")
+ assert int(state.state) == 1
+ assert state.attributes[ATTR_ENTITY_PICTURE] == "1"
+
+ await hass.async_block_till_done()
+ assert int(state.state) == 1
+
+
+async def test_self_referencing_icon_with_no_loop(hass, caplog):
+ """Test a self referencing icon that does not loop."""
+
+ hass.states.async_set("sensor.heartworm_high_80", 10)
+ hass.states.async_set("sensor.heartworm_low_57", 10)
+ hass.states.async_set("sensor.heartworm_avg_64", 10)
+ hass.states.async_set("sensor.heartworm_avg_57", 10)
+
+ value_template_str = """{% if (states.sensor.heartworm_high_80.state|int >= 10) and (states.sensor.heartworm_low_57.state|int >= 10) %}
+ extreme
+ {% elif (states.sensor.heartworm_avg_64.state|int >= 30) %}
+ high
+ {% elif (states.sensor.heartworm_avg_64.state|int >= 14) %}
+ moderate
+ {% elif (states.sensor.heartworm_avg_64.state|int >= 5) %}
+ slight
+ {% elif (states.sensor.heartworm_avg_57.state|int >= 5) %}
+ marginal
+ {% elif (states.sensor.heartworm_avg_57.state|int < 5) %}
+ none
+ {% endif %}"""
+
+ icon_template_str = """{% if is_state('sensor.heartworm_risk',"extreme") %}
+ mdi:hazard-lights
+ {% elif is_state('sensor.heartworm_risk',"high") %}
+ mdi:triangle-outline
+ {% elif is_state('sensor.heartworm_risk',"moderate") %}
+ mdi:alert-circle-outline
+ {% elif is_state('sensor.heartworm_risk',"slight") %}
+ mdi:exclamation
+ {% elif is_state('sensor.heartworm_risk',"marginal") %}
+ mdi:heart
+ {% elif is_state('sensor.heartworm_risk',"none") %}
+ mdi:snowflake
+ {% endif %}"""
+
+ await async_setup_component(
+ hass,
+ "sensor",
+ {
+ "sensor": {
+ "platform": "template",
+ "sensors": {
+ "heartworm_risk": {
+ "value_template": value_template_str,
+ "icon_template": icon_template_str,
+ },
+ },
+ }
+ },
+ )
+
+ await hass.async_block_till_done()
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 5
+
+ hass.states.async_set("sensor.heartworm_high_80", 10)
+
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ assert "Template loop detected" not in caplog.text
+
+ state = hass.states.get("sensor.heartworm_risk")
+ assert state.state == "extreme"
+ assert state.attributes[ATTR_ICON] == "mdi:hazard-lights"
+
+ await hass.async_block_till_done()
+ assert state.state == "extreme"
+ assert state.attributes[ATTR_ICON] == "mdi:hazard-lights"
+ assert "Template loop detected" not in caplog.text
diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py
index 191e26a4266..6dab2569e59 100644
--- a/tests/components/template/test_switch.py
+++ b/tests/components/template/test_switch.py
@@ -541,7 +541,11 @@ async def test_off_action_optimistic(hass, calls):
async def test_restore_state(hass):
"""Test state restoration."""
mock_restore_cache(
- hass, (State("switch.s1", STATE_ON), State("switch.s2", STATE_OFF),),
+ hass,
+ (
+ State("switch.s1", STATE_ON),
+ State("switch.s2", STATE_OFF),
+ ),
)
hass.state = CoreState.starting
diff --git a/tests/components/automation/test_template.py b/tests/components/template/test_trigger.py
similarity index 95%
rename from tests/components/automation/test_template.py
rename to tests/components/template/test_trigger.py
index 22f16c3c824..300173fdadf 100644
--- a/tests/components/automation/test_template.py
+++ b/tests/components/template/test_trigger.py
@@ -5,6 +5,7 @@ from unittest import mock
import pytest
import homeassistant.components.automation as automation
+from homeassistant.components.template import trigger as template_trigger
from homeassistant.core import Context, callback
from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
@@ -38,7 +39,10 @@ async def test_if_fires_on_change_bool(hass, calls):
automation.DOMAIN,
{
automation.DOMAIN: {
- "trigger": {"platform": "template", "value_template": "{{ true }}"},
+ "trigger": {
+ "platform": "template",
+ "value_template": "{{ states.test.entity.state and true }}",
+ },
"action": {"service": "test.automation"},
}
},
@@ -63,7 +67,10 @@ async def test_if_fires_on_change_str(hass, calls):
automation.DOMAIN,
{
automation.DOMAIN: {
- "trigger": {"platform": "template", "value_template": '{{ "true" }}'},
+ "trigger": {
+ "platform": "template",
+ "value_template": '{{ states.test.entity.state and "true" }}',
+ },
"action": {"service": "test.automation"},
}
},
@@ -81,7 +88,10 @@ async def test_if_fires_on_change_str_crazy(hass, calls):
automation.DOMAIN,
{
automation.DOMAIN: {
- "trigger": {"platform": "template", "value_template": '{{ "TrUE" }}'},
+ "trigger": {
+ "platform": "template",
+ "value_template": '{{ states.test.entity.state and "TrUE" }}',
+ },
"action": {"service": "test.automation"},
}
},
@@ -99,7 +109,10 @@ async def test_if_not_fires_on_change_bool(hass, calls):
automation.DOMAIN,
{
automation.DOMAIN: {
- "trigger": {"platform": "template", "value_template": "{{ false }}"},
+ "trigger": {
+ "platform": "template",
+ "value_template": "{{ states.test.entity.state and false }}",
+ },
"action": {"service": "test.automation"},
}
},
@@ -177,7 +190,10 @@ async def test_if_fires_on_two_change(hass, calls):
automation.DOMAIN,
{
automation.DOMAIN: {
- "trigger": {"platform": "template", "value_template": "{{ true }}"},
+ "trigger": {
+ "platform": "template",
+ "value_template": "{{ states.test.entity.state and true }}",
+ },
"action": {"service": "test.automation"},
}
},
@@ -782,7 +798,7 @@ async def test_invalid_for_template_1(hass, calls):
},
)
- with mock.patch.object(automation.template, "_LOGGER") as mock_logger:
+ with mock.patch.object(template_trigger, "_LOGGER") as mock_logger:
hass.states.async_set("test.entity", "world")
await hass.async_block_till_done()
assert mock_logger.error.called
diff --git a/tests/components/template/test_vacuum.py b/tests/components/template/test_vacuum.py
index fd77e5455c6..23b03262a78 100644
--- a/tests/components/template/test_vacuum.py
+++ b/tests/components/template/test_vacuum.py
@@ -341,9 +341,12 @@ async def test_invalid_attribute_template(hass, caplog):
)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 1
- await hass.helpers.entity_component.async_update_entity("vacuum.invalid_template")
- assert ("Error rendering attribute test_attribute") in caplog.text
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert "test_attribute" in caplog.text
+ assert "TemplateError" in caplog.text
# End of template tests #
diff --git a/tests/components/tibber/test_config_flow.py b/tests/components/tibber/test_config_flow.py
index 6b2428fcff9..c92ec5bc5c7 100644
--- a/tests/components/tibber/test_config_flow.py
+++ b/tests/components/tibber/test_config_flow.py
@@ -52,7 +52,9 @@ async def test_create_entry(hass):
async def test_flow_entry_already_exists(hass):
"""Test user input for config_entry that already exists."""
first_entry = MockConfigEntry(
- domain="tibber", data={CONF_ACCESS_TOKEN: "valid"}, unique_id="tibber",
+ domain="tibber",
+ data={CONF_ACCESS_TOKEN: "valid"},
+ unique_id="tibber",
)
first_entry.add_to_hass(hass)
diff --git a/tests/components/tile/test_config_flow.py b/tests/components/tile/test_config_flow.py
index 7b9a80b427d..a1812d34a57 100644
--- a/tests/components/tile/test_config_flow.py
+++ b/tests/components/tile/test_config_flow.py
@@ -37,7 +37,8 @@ async def test_invalid_credentials(hass):
}
with patch(
- "homeassistant.components.tile.config_flow.async_login", side_effect=TileError,
+ "homeassistant.components.tile.config_flow.async_login",
+ side_effect=TileError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=conf
diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py
index 75bafba634f..6b5a2596b71 100644
--- a/tests/components/timer/test_init.py
+++ b/tests/components/timer/test_init.py
@@ -24,6 +24,7 @@ from homeassistant.components.timer import (
STATUS_ACTIVE,
STATUS_IDLE,
STATUS_PAUSED,
+ _format_timedelta,
)
from homeassistant.const import (
ATTR_EDITABLE,
@@ -61,7 +62,7 @@ def storage_setup(hass, hass_storage):
{
ATTR_ID: "from_storage",
ATTR_NAME: "timer from storage",
- ATTR_DURATION: 0,
+ ATTR_DURATION: "0:00:00",
}
]
},
@@ -544,7 +545,7 @@ async def test_update(hass, hass_ws_client, storage_setup):
assert resp["success"]
state = hass.states.get(timer_entity_id)
- assert state.attributes[ATTR_DURATION] == str(cv.time_period(33))
+ assert state.attributes[ATTR_DURATION] == _format_timedelta(cv.time_period(33))
async def test_ws_create(hass, hass_ws_client, storage_setup):
@@ -574,7 +575,7 @@ async def test_ws_create(hass, hass_ws_client, storage_setup):
state = hass.states.get(timer_entity_id)
assert state.state == STATUS_IDLE
- assert state.attributes[ATTR_DURATION] == str(cv.time_period(42))
+ assert state.attributes[ATTR_DURATION] == _format_timedelta(cv.time_period(42))
assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, timer_id) == timer_entity_id
diff --git a/tests/components/toon/test_config_flow.py b/tests/components/toon/test_config_flow.py
index e9ad7480928..6fb7a7b53dc 100644
--- a/tests/components/toon/test_config_flow.py
+++ b/tests/components/toon/test_config_flow.py
@@ -1,5 +1,4 @@
"""Tests for the Toon config flow."""
-
from toonapi import Agreement, ToonError
from homeassistant import data_entry_flow
@@ -17,7 +16,8 @@ from tests.common import MockConfigEntry
async def setup_component(hass):
"""Set up Toon component."""
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com"},
+ hass,
+ {"external_url": "https://example.com"},
)
with patch("os.path.isfile", return_value=False):
@@ -39,7 +39,9 @@ async def test_abort_if_no_configuration(hass):
assert result["reason"] == "missing_configuration"
-async def test_full_flow_implementation(hass, aiohttp_client, aioclient_mock):
+async def test_full_flow_implementation(
+ hass, aiohttp_client, aioclient_mock, current_request
+):
"""Test registering an integration and finishing flow works."""
await setup_component(hass)
@@ -95,7 +97,7 @@ async def test_full_flow_implementation(hass, aiohttp_client, aioclient_mock):
}
-async def test_no_agreements(hass, aiohttp_client, aioclient_mock):
+async def test_no_agreements(hass, aiohttp_client, aioclient_mock, current_request):
"""Test abort when there are no displays."""
await setup_component(hass)
result = await hass.config_entries.flow.async_init(
@@ -127,7 +129,9 @@ async def test_no_agreements(hass, aiohttp_client, aioclient_mock):
assert result3["reason"] == "no_agreements"
-async def test_multiple_agreements(hass, aiohttp_client, aioclient_mock):
+async def test_multiple_agreements(
+ hass, aiohttp_client, aioclient_mock, current_request
+):
"""Test abort when there are no displays."""
await setup_component(hass)
result = await hass.config_entries.flow.async_init(
@@ -169,7 +173,9 @@ async def test_multiple_agreements(hass, aiohttp_client, aioclient_mock):
assert result4["data"]["agreement_id"] == 1
-async def test_agreement_already_set_up(hass, aiohttp_client, aioclient_mock):
+async def test_agreement_already_set_up(
+ hass, aiohttp_client, aioclient_mock, current_request
+):
"""Test showing display form again if display already exists."""
await setup_component(hass)
MockConfigEntry(domain=DOMAIN, unique_id=123).add_to_hass(hass)
@@ -202,7 +208,7 @@ async def test_agreement_already_set_up(hass, aiohttp_client, aioclient_mock):
assert result3["reason"] == "already_configured"
-async def test_toon_abort(hass, aiohttp_client, aioclient_mock):
+async def test_toon_abort(hass, aiohttp_client, aioclient_mock, current_request):
"""Test we abort on Toon error."""
await setup_component(hass)
result = await hass.config_entries.flow.async_init(
@@ -247,7 +253,7 @@ async def test_import(hass):
assert result["reason"] == "already_in_progress"
-async def test_import_migration(hass, aiohttp_client, aioclient_mock):
+async def test_import_migration(hass, aiohttp_client, aioclient_mock, current_request):
"""Test if importing step with migration works."""
old_entry = MockConfigEntry(domain=DOMAIN, unique_id=123, version=1)
old_entry.add_to_hass(hass)
diff --git a/tests/components/tplink/test_common.py b/tests/components/tplink/test_common.py
index a2bd7ef87ff..9c219f1ba83 100644
--- a/tests/components/tplink/test_common.py
+++ b/tests/components/tplink/test_common.py
@@ -17,7 +17,7 @@ async def test_async_add_entities_retry(hass: HomeAssistantType):
objects = ["Object 1", "Object 2", "Object 3", "Object 4"]
# For each call to async_add_entities_callback, the following side effects
- # will be triggered in order. This set of side effects accuratley simulates
+ # will be triggered in order. This set of side effects accurateley simulates
# 3 attempts to add all entities while also handling several return types.
# To help understand what's going on, a comment exists describing what the
# object list looks like throughout the iterations.
diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py
index 290151b10cc..8dbe7481339 100644
--- a/tests/components/tplink/test_init.py
+++ b/tests/components/tplink/test_init.py
@@ -68,7 +68,8 @@ async def test_configuring_device_types(hass, name, cls, platform, count):
) as discover, patch(
"homeassistant.components.tplink.common.SmartDevice._query_helper"
), patch(
- "homeassistant.components.tplink.light.async_setup_entry", return_value=True,
+ "homeassistant.components.tplink.light.async_setup_entry",
+ return_value=True,
):
discovery_data = {
f"123.123.123.{c}": cls("123.123.123.123") for c in range(count)
diff --git a/tests/components/tplink/test_light.py b/tests/components/tplink/test_light.py
index 241789270d7..cc9ecff896f 100644
--- a/tests/components/tplink/test_light.py
+++ b/tests/components/tplink/test_light.py
@@ -237,7 +237,10 @@ def dimmer_switch_mock_data_fixture() -> None:
async def update_entity(hass: HomeAssistant, entity_id: str) -> None:
"""Run an update action for an entity."""
await hass.services.async_call(
- HA_DOMAIN, SERVICE_UPDATE_ENTITY, {ATTR_ENTITY_ID: entity_id}, blocking=True,
+ HA_DOMAIN,
+ SERVICE_UPDATE_ENTITY,
+ {ATTR_ENTITY_ID: entity_id},
+ blocking=True,
)
await hass.async_block_till_done()
@@ -321,7 +324,10 @@ async def test_smartswitch(
assert state.state == "off"
await hass.services.async_call(
- LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "light.dimmer1"}, blocking=True,
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "light.dimmer1"},
+ blocking=True,
)
await hass.async_block_till_done()
await update_entity(hass, "light.dimmer1")
@@ -355,7 +361,10 @@ async def test_light(hass: HomeAssistant, light_mock_data: LightMockData) -> Non
assert hass.states.get("light.light1")
await hass.services.async_call(
- LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "light.light1"}, blocking=True,
+ LIGHT_DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "light.light1"},
+ blocking=True,
)
await hass.async_block_till_done()
await update_entity(hass, "light.light1")
@@ -408,7 +417,10 @@ async def test_light(hass: HomeAssistant, light_mock_data: LightMockData) -> Non
light_state["dft_on_state"]["saturation"] = 78
await hass.services.async_call(
- LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "light.light1"}, blocking=True,
+ LIGHT_DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "light.light1"},
+ blocking=True,
)
await hass.async_block_till_done()
await update_entity(hass, "light.light1")
@@ -417,7 +429,10 @@ async def test_light(hass: HomeAssistant, light_mock_data: LightMockData) -> Non
assert state.state == "off"
await hass.services.async_call(
- LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "light.light1"}, blocking=True,
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "light.light1"},
+ blocking=True,
)
await hass.async_block_till_done()
await update_entity(hass, "light.light1")
@@ -504,7 +519,10 @@ async def test_get_light_state_retry(
await hass.async_block_till_done()
await hass.services.async_call(
- LIGHT_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "light.light1"}, blocking=True,
+ LIGHT_DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "light.light1"},
+ blocking=True,
)
await hass.async_block_till_done()
await update_entity(hass, "light.light1")
diff --git a/tests/components/traccar/test_init.py b/tests/components/traccar/test_init.py
index 942fd4b01d5..12e1ff5562a 100644
--- a/tests/components/traccar/test_init.py
+++ b/tests/components/traccar/test_init.py
@@ -61,7 +61,8 @@ async def setup_zones(loop, hass):
async def webhook_id_fixture(hass, client):
"""Initialize the Traccar component and get the webhook_id."""
await async_process_ha_core_config(
- hass, {"external_url": "http://example.com"},
+ hass,
+ {"external_url": "http://example.com"},
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": "user"}
diff --git a/tests/components/tradfri/__init__.py b/tests/components/tradfri/__init__.py
index 4d1b505abc9..e7a6fcb9138 100644
--- a/tests/components/tradfri/__init__.py
+++ b/tests/components/tradfri/__init__.py
@@ -1 +1,2 @@
"""Tests for the tradfri component."""
+MOCK_GATEWAY_ID = "mock-gateway-id"
diff --git a/tests/components/tradfri/conftest.py b/tests/components/tradfri/conftest.py
index 891dd1377fe..a944f836dea 100644
--- a/tests/components/tradfri/conftest.py
+++ b/tests/components/tradfri/conftest.py
@@ -1,7 +1,11 @@
"""Common tradfri test fixtures."""
import pytest
-from tests.async_mock import patch
+from . import MOCK_GATEWAY_ID
+
+from tests.async_mock import Mock, patch
+
+# pylint: disable=protected-access
@pytest.fixture
@@ -9,8 +13,8 @@ def mock_gateway_info():
"""Mock get_gateway_info."""
with patch(
"homeassistant.components.tradfri.config_flow.get_gateway_info"
- ) as mock_gateway:
- yield mock_gateway
+ ) as gateway_info:
+ yield gateway_info
@pytest.fixture
@@ -19,3 +23,64 @@ def mock_entry_setup():
with patch("homeassistant.components.tradfri.async_setup_entry") as mock_setup:
mock_setup.return_value = True
yield mock_setup
+
+
+@pytest.fixture(name="gateway_id")
+def mock_gateway_id_fixture():
+ """Return mock gateway_id."""
+ return MOCK_GATEWAY_ID
+
+
+@pytest.fixture(name="mock_gateway")
+def mock_gateway_fixture(gateway_id):
+ """Mock a Tradfri gateway."""
+
+ def get_devices():
+ """Return mock devices."""
+ return gateway.mock_devices
+
+ def get_groups():
+ """Return mock groups."""
+ return gateway.mock_groups
+
+ gateway_info = Mock(id=gateway_id, firmware_version="1.2.1234")
+
+ def get_gateway_info():
+ """Return mock gateway info."""
+ return gateway_info
+
+ gateway = Mock(
+ get_devices=get_devices,
+ get_groups=get_groups,
+ get_gateway_info=get_gateway_info,
+ mock_devices=[],
+ mock_groups=[],
+ mock_responses=[],
+ )
+ with patch("homeassistant.components.tradfri.Gateway", return_value=gateway), patch(
+ "homeassistant.components.tradfri.config_flow.Gateway", return_value=gateway
+ ):
+ yield gateway
+
+
+@pytest.fixture(name="mock_api")
+def mock_api_fixture(mock_gateway):
+ """Mock api."""
+
+ async def api(command):
+ """Mock api function."""
+ # Store the data for "real" command objects.
+ if hasattr(command, "_data") and not isinstance(command, Mock):
+ mock_gateway.mock_responses.append(command._data)
+ return command
+
+ return api
+
+
+@pytest.fixture(name="api_factory")
+def mock_api_factory_fixture(mock_api):
+ """Mock pytradfri api factory."""
+ with patch("homeassistant.components.tradfri.APIFactory", autospec=True) as factory:
+ factory.init.return_value = factory.return_value
+ factory.return_value.request = mock_api
+ yield factory.return_value
diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py
index 43e33706bf6..c9f72b7a9df 100644
--- a/tests/components/tradfri/test_config_flow.py
+++ b/tests/components/tradfri/test_config_flow.py
@@ -272,7 +272,10 @@ async def test_duplicate_discovery(hass, mock_auth, mock_entry_setup):
async def test_discovery_updates_unique_id(hass):
"""Test a duplicate discovery host aborts and updates existing entry."""
- entry = MockConfigEntry(domain="tradfri", data={"host": "some-host"},)
+ entry = MockConfigEntry(
+ domain="tradfri",
+ data={"host": "some-host"},
+ )
entry.add_to_hass(hass)
flow = await hass.config_entries.flow.async_init(
diff --git a/tests/components/tradfri/test_init.py b/tests/components/tradfri/test_init.py
index 2845137244b..34cc6d38091 100644
--- a/tests/components/tradfri/test_init.py
+++ b/tests/components/tradfri/test_init.py
@@ -1,4 +1,9 @@
"""Tests for Tradfri setup."""
+from homeassistant.components import tradfri
+from homeassistant.helpers.device_registry import (
+ async_entries_for_config_entry,
+ async_get_registry as async_get_device_registry,
+)
from homeassistant.setup import async_setup_component
from tests.async_mock import patch
@@ -48,13 +53,15 @@ async def test_config_json_host_not_imported(hass):
assert len(mock_init.mock_calls) == 0
-async def test_config_json_host_imported(hass, mock_gateway_info, mock_entry_setup):
+async def test_config_json_host_imported(
+ hass, mock_gateway_info, mock_entry_setup, gateway_id
+):
"""Test that we import a configured host."""
mock_gateway_info.side_effect = lambda hass, host, identity, key: {
"host": host,
"identity": identity,
"key": key,
- "gateway_id": "mock-gateway",
+ "gateway_id": gateway_id,
}
with patch(
@@ -68,3 +75,45 @@ async def test_config_json_host_imported(hass, mock_gateway_info, mock_entry_set
assert config_entry.domain == "tradfri"
assert config_entry.source == "import"
assert config_entry.title == "mock-host"
+
+
+async def test_entry_setup_unload(hass, api_factory, gateway_id):
+ """Test config entry setup and unload."""
+ entry = MockConfigEntry(
+ domain=tradfri.DOMAIN,
+ data={
+ tradfri.CONF_HOST: "mock-host",
+ tradfri.CONF_IDENTITY: "mock-identity",
+ tradfri.CONF_KEY: "mock-key",
+ tradfri.CONF_IMPORT_GROUPS: True,
+ tradfri.CONF_GATEWAY_ID: gateway_id,
+ },
+ )
+
+ entry.add_to_hass(hass)
+ with patch.object(
+ hass.config_entries, "async_forward_entry_setup", return_value=True
+ ) as setup:
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+ assert setup.call_count == len(tradfri.PLATFORMS)
+
+ dev_reg = await async_get_device_registry(hass)
+ dev_entries = async_entries_for_config_entry(dev_reg, entry.entry_id)
+
+ assert dev_entries
+ dev_entry = dev_entries[0]
+ assert dev_entry.identifiers == {
+ (tradfri.DOMAIN, entry.data[tradfri.CONF_GATEWAY_ID])
+ }
+ assert dev_entry.manufacturer == tradfri.ATTR_TRADFRI_MANUFACTURER
+ assert dev_entry.name == tradfri.ATTR_TRADFRI_GATEWAY
+ assert dev_entry.model == tradfri.ATTR_TRADFRI_GATEWAY_MODEL
+
+ with patch.object(
+ hass.config_entries, "async_forward_entry_unload", return_value=True
+ ) as unload:
+ assert await hass.config_entries.async_unload(entry.entry_id)
+ await hass.async_block_till_done()
+ assert unload.call_count == len(tradfri.PLATFORMS)
+ assert api_factory.shutdown.call_count == 1
diff --git a/tests/components/tradfri/test_light.py b/tests/components/tradfri/test_light.py
index a5a5823fbf4..cf11d42411e 100644
--- a/tests/components/tradfri/test_light.py
+++ b/tests/components/tradfri/test_light.py
@@ -9,6 +9,8 @@ from pytradfri.device.light_control import LightControl
from homeassistant.components import tradfri
+from . import MOCK_GATEWAY_ID
+
from tests.async_mock import MagicMock, Mock, PropertyMock, patch
from tests.common import MockConfigEntry
@@ -93,48 +95,12 @@ def setup(request):
request.addfinalizer(teardown)
-@pytest.fixture
-def mock_gateway():
- """Mock a Tradfri gateway."""
-
- def get_devices():
- """Return mock devices."""
- return gateway.mock_devices
-
- def get_groups():
- """Return mock groups."""
- return gateway.mock_groups
-
- gateway = Mock(
- get_devices=get_devices,
- get_groups=get_groups,
- mock_devices=[],
- mock_groups=[],
- mock_responses=[],
- )
- return gateway
-
-
-@pytest.fixture
-def mock_api(mock_gateway):
- """Mock api."""
-
- async def api(command):
- """Mock api function."""
- # Store the data for "real" command objects.
- if hasattr(command, "_data") and not isinstance(command, Mock):
- mock_gateway.mock_responses.append(command._data)
- return command
-
- return api
-
-
async def generate_psk(self, code):
"""Mock psk."""
return "mock"
-async def setup_gateway(hass, mock_gateway, mock_api):
+async def setup_integration(hass):
"""Load the Tradfri platform with a mock gateway."""
entry = MockConfigEntry(
domain=tradfri.DOMAIN,
@@ -143,43 +109,51 @@ async def setup_gateway(hass, mock_gateway, mock_api):
"identity": "mock-identity",
"key": "mock-key",
"import_groups": True,
- "gateway_id": "mock-gateway-id",
+ "gateway_id": MOCK_GATEWAY_ID,
},
)
- hass.data[tradfri.KEY_GATEWAY] = {entry.entry_id: mock_gateway}
- hass.data[tradfri.KEY_API] = {entry.entry_id: mock_api}
- await hass.config_entries.async_forward_entry_setup(entry, "light")
+
+ entry.add_to_hass(hass)
+ await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
-def mock_light(test_features={}, test_state={}, n=0):
+def mock_light(test_features=None, test_state=None, light_number=0):
"""Mock a tradfri light."""
+ if test_features is None:
+ test_features = {}
+ if test_state is None:
+ test_state = {}
mock_light_data = Mock(**test_state)
dev_info_mock = MagicMock()
dev_info_mock.manufacturer = "manufacturer"
dev_info_mock.model_number = "model"
dev_info_mock.firmware_version = "1.2.3"
- mock_light = Mock(
- id=f"mock-light-id-{n}",
+ _mock_light = Mock(
+ id=f"mock-light-id-{light_number}",
reachable=True,
observe=Mock(),
device_info=dev_info_mock,
+ has_light_control=True,
+ has_socket_control=False,
+ has_blind_control=False,
+ has_signal_repeater_control=False,
)
- mock_light.name = f"tradfri_light_{n}"
+ _mock_light.name = f"tradfri_light_{light_number}"
# Set supported features for the light.
features = {**DEFAULT_TEST_FEATURES, **test_features}
- lc = LightControl(mock_light)
- for k, v in features.items():
- setattr(lc, k, v)
+ light_control = LightControl(_mock_light)
+ for attr, value in features.items():
+ setattr(light_control, attr, value)
# Store the initial state.
- setattr(lc, "lights", [mock_light_data])
- mock_light.light_control = lc
- return mock_light
+ setattr(light_control, "lights", [mock_light_data])
+ _mock_light.light_control = light_control
+ return _mock_light
-async def test_light(hass, mock_gateway, mock_api):
+async def test_light(hass, mock_gateway, api_factory):
"""Test that lights are correctly added."""
features = {"can_set_dimmer": True, "can_set_color": True, "can_set_temp": True}
@@ -193,7 +167,7 @@ async def test_light(hass, mock_gateway, mock_api):
mock_gateway.mock_devices.append(
mock_light(test_features=features, test_state=state)
)
- await setup_gateway(hass, mock_gateway, mock_api)
+ await setup_integration(hass)
lamp_1 = hass.states.get("light.tradfri_light_0")
assert lamp_1 is not None
@@ -202,48 +176,60 @@ async def test_light(hass, mock_gateway, mock_api):
assert lamp_1.attributes["hs_color"] == (0.549, 0.153)
-async def test_light_observed(hass, mock_gateway, mock_api):
+async def test_light_observed(hass, mock_gateway, api_factory):
"""Test that lights are correctly observed."""
light = mock_light()
mock_gateway.mock_devices.append(light)
- await setup_gateway(hass, mock_gateway, mock_api)
+ await setup_integration(hass)
assert len(light.observe.mock_calls) > 0
-async def test_light_available(hass, mock_gateway, mock_api):
+async def test_light_available(hass, mock_gateway, api_factory):
"""Test light available property."""
- light = mock_light({"state": True}, n=1)
+ light = mock_light({"state": True}, light_number=1)
light.reachable = True
- light2 = mock_light({"state": True}, n=2)
+ light2 = mock_light({"state": True}, light_number=2)
light2.reachable = False
mock_gateway.mock_devices.append(light)
mock_gateway.mock_devices.append(light2)
- await setup_gateway(hass, mock_gateway, mock_api)
+ await setup_integration(hass)
assert hass.states.get("light.tradfri_light_1").state == "on"
assert hass.states.get("light.tradfri_light_2").state == "unavailable"
-# Combine TURN_ON_TEST_CASES and TRANSITION_CASES_FOR_TESTS
-ALL_TURN_ON_TEST_CASES = [["test_features", "test_data", "expected_result", "id"], []]
+def create_all_turn_on_cases():
+ """Create all turn on test cases."""
+ # Combine TURN_ON_TEST_CASES and TRANSITION_CASES_FOR_TESTS
+ all_turn_on_test_cases = [
+ ["test_features", "test_data", "expected_result", "device_id"],
+ [],
+ ]
+ index = 1
+ for test_case in TURN_ON_TEST_CASES:
+ for trans in TRANSITION_CASES_FOR_TESTS:
+ case = deepcopy(test_case)
+ if trans is not None:
+ case[1]["transition"] = trans
+ case.append(index)
+ index += 1
+ all_turn_on_test_cases[1].append(case)
-idx = 1
-for tc in TURN_ON_TEST_CASES:
- for trans in TRANSITION_CASES_FOR_TESTS:
- case = deepcopy(tc)
- if trans is not None:
- case[1]["transition"] = trans
- case.append(idx)
- idx = idx + 1
- ALL_TURN_ON_TEST_CASES[1].append(case)
+ return all_turn_on_test_cases
-@pytest.mark.parametrize(*ALL_TURN_ON_TEST_CASES)
+@pytest.mark.parametrize(*create_all_turn_on_cases())
async def test_turn_on(
- hass, mock_gateway, mock_api, test_features, test_data, expected_result, id
+ hass,
+ mock_gateway,
+ api_factory,
+ test_features,
+ test_data,
+ expected_result,
+ device_id,
):
"""Test turning on a light."""
# Note pytradfri style, not hass. Values not really important.
@@ -255,15 +241,17 @@ async def test_turn_on(
}
# Setup the gateway with a mock light.
- light = mock_light(test_features=test_features, test_state=initial_state, n=id)
+ light = mock_light(
+ test_features=test_features, test_state=initial_state, light_number=device_id
+ )
mock_gateway.mock_devices.append(light)
- await setup_gateway(hass, mock_gateway, mock_api)
+ await setup_integration(hass)
# Use the turn_on service call to change the light state.
await hass.services.async_call(
"light",
"turn_on",
- {"entity_id": f"light.tradfri_light_{id}", **test_data},
+ {"entity_id": f"light.tradfri_light_{device_id}", **test_data},
blocking=True,
)
await hass.async_block_till_done()
@@ -274,39 +262,39 @@ async def test_turn_on(
_, callkwargs = mock_func.call_args
assert "callback" in callkwargs
# Callback function to refresh light state.
- cb = callkwargs["callback"]
+ callback = callkwargs["callback"]
responses = mock_gateway.mock_responses
# State on command data.
data = {"3311": [{"5850": 1}]}
# Add data for all sent commands.
- for r in responses:
- data["3311"][0] = {**data["3311"][0], **r["3311"][0]}
+ for resp in responses:
+ data["3311"][0] = {**data["3311"][0], **resp["3311"][0]}
# Use the callback function to update the light state.
dev = Device(data)
light_data = Light(dev, 0)
light.light_control.lights[0] = light_data
- cb(light)
+ callback(light)
await hass.async_block_till_done()
# Check that the state is correct.
- states = hass.states.get(f"light.tradfri_light_{id}")
- for k, v in expected_result.items():
- if k == "state":
- assert states.state == v
+ states = hass.states.get(f"light.tradfri_light_{device_id}")
+ for result, value in expected_result.items():
+ if result == "state":
+ assert states.state == value
else:
# Allow some rounding error in color conversions.
- assert states.attributes[k] == pytest.approx(v, abs=0.01)
+ assert states.attributes[result] == pytest.approx(value, abs=0.01)
-async def test_turn_off(hass, mock_gateway, mock_api):
+async def test_turn_off(hass, mock_gateway, api_factory):
"""Test turning off a light."""
state = {"state": True, "dimmer": 100}
light = mock_light(test_state=state)
mock_gateway.mock_devices.append(light)
- await setup_gateway(hass, mock_gateway, mock_api)
+ await setup_integration(hass)
# Use the turn_off service call to change the light state.
await hass.services.async_call(
@@ -320,19 +308,19 @@ async def test_turn_off(hass, mock_gateway, mock_api):
_, callkwargs = mock_func.call_args
assert "callback" in callkwargs
# Callback function to refresh light state.
- cb = callkwargs["callback"]
+ callback = callkwargs["callback"]
responses = mock_gateway.mock_responses
data = {"3311": [{}]}
# Add data for all sent commands.
- for r in responses:
- data["3311"][0] = {**data["3311"][0], **r["3311"][0]}
+ for resp in responses:
+ data["3311"][0] = {**data["3311"][0], **resp["3311"][0]}
# Use the callback function to update the light state.
dev = Device(data)
light_data = Light(dev, 0)
light.light_control.lights[0] = light_data
- cb(light)
+ callback(light)
await hass.async_block_till_done()
# Check that the state is correct.
@@ -340,23 +328,25 @@ async def test_turn_off(hass, mock_gateway, mock_api):
assert states.state == "off"
-def mock_group(test_state={}, n=0):
+def mock_group(test_state=None, group_number=0):
"""Mock a Tradfri group."""
+ if test_state is None:
+ test_state = {}
default_state = {"state": False, "dimmer": 0}
state = {**default_state, **test_state}
- mock_group = Mock(member_ids=[], observe=Mock(), **state)
- mock_group.name = f"tradfri_group_{n}"
- return mock_group
+ _mock_group = Mock(member_ids=[], observe=Mock(), **state)
+ _mock_group.name = f"tradfri_group_{group_number}"
+ return _mock_group
-async def test_group(hass, mock_gateway, mock_api):
+async def test_group(hass, mock_gateway, api_factory):
"""Test that groups are correctly added."""
mock_gateway.mock_groups.append(mock_group())
state = {"state": True, "dimmer": 100}
mock_gateway.mock_groups.append(mock_group(state, 1))
- await setup_gateway(hass, mock_gateway, mock_api)
+ await setup_integration(hass)
group = hass.states.get("light.tradfri_group_0")
assert group is not None
@@ -368,15 +358,15 @@ async def test_group(hass, mock_gateway, mock_api):
assert group.attributes["brightness"] == 100
-async def test_group_turn_on(hass, mock_gateway, mock_api):
+async def test_group_turn_on(hass, mock_gateway, api_factory):
"""Test turning on a group."""
group = mock_group()
- group2 = mock_group(n=1)
- group3 = mock_group(n=2)
+ group2 = mock_group(group_number=1)
+ group3 = mock_group(group_number=2)
mock_gateway.mock_groups.append(group)
mock_gateway.mock_groups.append(group2)
mock_gateway.mock_groups.append(group3)
- await setup_gateway(hass, mock_gateway, mock_api)
+ await setup_integration(hass)
# Use the turn_off service call to change the light state.
await hass.services.async_call(
@@ -401,11 +391,11 @@ async def test_group_turn_on(hass, mock_gateway, mock_api):
group3.set_dimmer.assert_called_with(100, transition_time=10)
-async def test_group_turn_off(hass, mock_gateway, mock_api):
+async def test_group_turn_off(hass, mock_gateway, api_factory):
"""Test turning off a group."""
group = mock_group({"state": True})
mock_gateway.mock_groups.append(group)
- await setup_gateway(hass, mock_gateway, mock_api)
+ await setup_integration(hass)
# Use the turn_off service call to change the light state.
await hass.services.async_call(
diff --git a/tests/components/transmission/test_config_flow.py b/tests/components/transmission/test_config_flow.py
index 6b9917d17f4..0820e71ae3b 100644
--- a/tests/components/transmission/test_config_flow.py
+++ b/tests/components/transmission/test_config_flow.py
@@ -204,13 +204,31 @@ async def test_host_already_configured(hass, api):
options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL},
)
entry.add_to_hass(hass)
- result = await hass.config_entries.flow.async_init(
- transmission.DOMAIN, context={"source": "user"}, data=MOCK_ENTRY
- )
+ mock_entry_unique_name = MOCK_ENTRY.copy()
+ mock_entry_unique_name[CONF_NAME] = "Transmission 1"
+ result = await hass.config_entries.flow.async_init(
+ transmission.DOMAIN, context={"source": "user"}, data=mock_entry_unique_name
+ )
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
+ mock_entry_unique_port = MOCK_ENTRY.copy()
+ mock_entry_unique_port[CONF_PORT] = 9092
+ mock_entry_unique_port[CONF_NAME] = "Transmission 2"
+ result = await hass.config_entries.flow.async_init(
+ transmission.DOMAIN, context={"source": "user"}, data=mock_entry_unique_port
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+ mock_entry_unique_host = MOCK_ENTRY.copy()
+ mock_entry_unique_host[CONF_HOST] = "192.168.1.101"
+ mock_entry_unique_host[CONF_NAME] = "Transmission 3"
+ result = await hass.config_entries.flow.async_init(
+ transmission.DOMAIN, context={"source": "user"}, data=mock_entry_unique_host
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
async def test_name_already_configured(hass, api):
"""Test name is already configured."""
diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py
index 0f64afe27cd..43db97b8f80 100644
--- a/tests/components/trend/test_binary_sensor.py
+++ b/tests/components/trend/test_binary_sensor.py
@@ -1,7 +1,10 @@
"""The test for the Trend sensor platform."""
from datetime import timedelta
+from os import path
-from homeassistant import setup
+from homeassistant import config as hass_config, setup
+from homeassistant.components.trend import DOMAIN
+from homeassistant.const import SERVICE_RELOAD
import homeassistant.util.dt as dt_util
from tests.async_mock import patch
@@ -370,3 +373,47 @@ class TestTrendBinarySensor:
self.hass, "binary_sensor", {"binary_sensor": {"platform": "trend"}}
)
assert self.hass.states.all() == []
+
+
+async def test_reload(hass):
+ """Verify we can reload trend sensors."""
+ hass.states.async_set("sensor.test_state", 1234)
+
+ await setup.async_setup_component(
+ hass,
+ "binary_sensor",
+ {
+ "binary_sensor": {
+ "platform": "trend",
+ "sensors": {"test_trend_sensor": {"entity_id": "sensor.test_state"}},
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 2
+
+ assert hass.states.get("binary_sensor.test_trend_sensor")
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "trend/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ DOMAIN,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 2
+
+ assert hass.states.get("binary_sensor.test_trend_sensor") is None
+ assert hass.states.get("binary_sensor.second_test_trend_sensor")
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py
index a100ef22c65..d5f099175b1 100644
--- a/tests/components/tts/test_init.py
+++ b/tests/components/tts/test_init.py
@@ -87,7 +87,8 @@ def mutagen_mock():
async def internal_url_mock(hass):
"""Mock internal URL of the instance."""
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
diff --git a/tests/components/tuya/test_config_flow.py b/tests/components/tuya/test_config_flow.py
index eeda68cd2d3..2ce298640de 100644
--- a/tests/components/tuya/test_config_flow.py
+++ b/tests/components/tuya/test_config_flow.py
@@ -65,9 +65,11 @@ async def test_import(hass, tuya):
"""Test import step."""
await setup.async_setup_component(hass, "persistent_notification", {})
with patch(
- "homeassistant.components.tuya.async_setup", return_value=True,
+ "homeassistant.components.tuya.async_setup",
+ return_value=True,
) as mock_setup, patch(
- "homeassistant.components.tuya.async_setup_entry", return_value=True,
+ "homeassistant.components.tuya.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
diff --git a/tests/components/twitch/test_twitch.py b/tests/components/twitch/test_twitch.py
index f66014d6557..33afde2a076 100644
--- a/tests/components/twitch/test_twitch.py
+++ b/tests/components/twitch/test_twitch.py
@@ -75,7 +75,8 @@ async def test_offline(hass):
twitch_mock.streams.get_stream_by_user.return_value = None
with patch(
- "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock,
+ "homeassistant.components.twitch.sensor.TwitchClient",
+ return_value=twitch_mock,
):
assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True
await hass.async_block_till_done()
@@ -94,7 +95,8 @@ async def test_streaming(hass):
twitch_mock.streams.get_stream_by_user.return_value = STREAM_OBJECT_ONLINE
with patch(
- "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock,
+ "homeassistant.components.twitch.sensor.TwitchClient",
+ return_value=twitch_mock,
):
assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True
await hass.async_block_till_done()
@@ -118,7 +120,8 @@ async def test_oauth_without_sub_and_follow(hass):
twitch_mock.users.check_follows_channel.side_effect = HTTPError()
with patch(
- "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock,
+ "homeassistant.components.twitch.sensor.TwitchClient",
+ return_value=twitch_mock,
):
assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH)
await hass.async_block_till_done()
@@ -140,7 +143,8 @@ async def test_oauth_with_sub(hass):
twitch_mock.users.check_follows_channel.side_effect = HTTPError()
with patch(
- "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock,
+ "homeassistant.components.twitch.sensor.TwitchClient",
+ return_value=twitch_mock,
):
assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH)
await hass.async_block_till_done()
@@ -164,7 +168,8 @@ async def test_oauth_with_follow(hass):
twitch_mock.users.check_follows_channel.return_value = FOLLOW_ACTIVE
with patch(
- "homeassistant.components.twitch.sensor.TwitchClient", return_value=twitch_mock,
+ "homeassistant.components.twitch.sensor.TwitchClient",
+ return_value=twitch_mock,
):
assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH)
await hass.async_block_till_done()
diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py
index 55483d135b6..5600f454336 100644
--- a/tests/components/unifi/test_controller.py
+++ b/tests/components/unifi/test_controller.py
@@ -130,7 +130,8 @@ async def setup_unifi_integration(
return {}
with patch("aiounifi.Controller.check_unifi_os", return_value=True), patch(
- "aiounifi.Controller.login", return_value=True,
+ "aiounifi.Controller.login",
+ return_value=True,
), patch("aiounifi.Controller.sites", return_value=sites), patch(
"aiounifi.Controller.site_description", return_value=site_description
), patch(
diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py
index 8f0df236c75..3fef8a16d68 100644
--- a/tests/components/unifi/test_device_tracker.py
+++ b/tests/components/unifi/test_device_tracker.py
@@ -265,7 +265,8 @@ async def test_tracked_clients(hass):
async def test_tracked_devices(hass):
"""Test the update_items function with some devices."""
controller = await setup_unifi_integration(
- hass, devices_response=[DEVICE_1, DEVICE_2],
+ hass,
+ devices_response=[DEVICE_1, DEVICE_2],
)
assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2
@@ -360,7 +361,9 @@ async def test_remove_clients(hass):
async def test_controller_state_change(hass):
"""Verify entities state reflect on controller becoming unavailable."""
controller = await setup_unifi_integration(
- hass, clients_response=[CLIENT_1], devices_response=[DEVICE_1],
+ hass,
+ clients_response=[CLIENT_1],
+ devices_response=[DEVICE_1],
)
assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2
@@ -390,7 +393,9 @@ async def test_controller_state_change(hass):
async def test_option_track_clients(hass):
"""Test the tracking of clients can be turned off."""
controller = await setup_unifi_integration(
- hass, clients_response=[CLIENT_1, CLIENT_2], devices_response=[DEVICE_1],
+ hass,
+ clients_response=[CLIENT_1, CLIENT_2],
+ devices_response=[DEVICE_1],
)
assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 3
@@ -404,7 +409,8 @@ async def test_option_track_clients(hass):
assert device_1 is not None
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_TRACK_CLIENTS: False},
+ controller.config_entry,
+ options={CONF_TRACK_CLIENTS: False},
)
await hass.async_block_till_done()
@@ -418,7 +424,8 @@ async def test_option_track_clients(hass):
assert device_1 is not None
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_TRACK_CLIENTS: True},
+ controller.config_entry,
+ options={CONF_TRACK_CLIENTS: True},
)
await hass.async_block_till_done()
@@ -435,7 +442,9 @@ async def test_option_track_clients(hass):
async def test_option_track_wired_clients(hass):
"""Test the tracking of wired clients can be turned off."""
controller = await setup_unifi_integration(
- hass, clients_response=[CLIENT_1, CLIENT_2], devices_response=[DEVICE_1],
+ hass,
+ clients_response=[CLIENT_1, CLIENT_2],
+ devices_response=[DEVICE_1],
)
assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 3
@@ -449,7 +458,8 @@ async def test_option_track_wired_clients(hass):
assert device_1 is not None
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_TRACK_WIRED_CLIENTS: False},
+ controller.config_entry,
+ options={CONF_TRACK_WIRED_CLIENTS: False},
)
await hass.async_block_till_done()
@@ -463,7 +473,8 @@ async def test_option_track_wired_clients(hass):
assert device_1 is not None
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_TRACK_WIRED_CLIENTS: True},
+ controller.config_entry,
+ options={CONF_TRACK_WIRED_CLIENTS: True},
)
await hass.async_block_till_done()
@@ -480,7 +491,9 @@ async def test_option_track_wired_clients(hass):
async def test_option_track_devices(hass):
"""Test the tracking of devices can be turned off."""
controller = await setup_unifi_integration(
- hass, clients_response=[CLIENT_1, CLIENT_2], devices_response=[DEVICE_1],
+ hass,
+ clients_response=[CLIENT_1, CLIENT_2],
+ devices_response=[DEVICE_1],
)
assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 3
@@ -494,7 +507,8 @@ async def test_option_track_devices(hass):
assert device_1 is not None
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_TRACK_DEVICES: False},
+ controller.config_entry,
+ options={CONF_TRACK_DEVICES: False},
)
await hass.async_block_till_done()
@@ -508,7 +522,8 @@ async def test_option_track_devices(hass):
assert device_1 is None
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_TRACK_DEVICES: True},
+ controller.config_entry,
+ options={CONF_TRACK_DEVICES: True},
)
await hass.async_block_till_done()
@@ -544,7 +559,8 @@ async def test_option_ssid_filter(hass):
# Setting SSID filter will remove clients outside of filter
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_SSID_FILTER: ["ssid"]},
+ controller.config_entry,
+ options={CONF_SSID_FILTER: ["ssid"]},
)
await hass.async_block_till_done()
@@ -578,7 +594,8 @@ async def test_option_ssid_filter(hass):
# Remove SSID filter
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_SSID_FILTER: []},
+ controller.config_entry,
+ options={CONF_SSID_FILTER: []},
)
event = {"meta": {"message": MESSAGE_CLIENT}, "data": [client_1_copy]}
controller.api.message_handler(event)
@@ -788,7 +805,8 @@ async def test_dont_track_clients(hass):
assert device_1 is not None
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_TRACK_CLIENTS: True},
+ controller.config_entry,
+ options={CONF_TRACK_CLIENTS: True},
)
await hass.async_block_till_done()
@@ -818,7 +836,8 @@ async def test_dont_track_devices(hass):
assert device_1 is None
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_TRACK_DEVICES: True},
+ controller.config_entry,
+ options={CONF_TRACK_DEVICES: True},
)
await hass.async_block_till_done()
@@ -847,7 +866,8 @@ async def test_dont_track_wired_clients(hass):
assert client_2 is None
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_TRACK_WIRED_CLIENTS: True},
+ controller.config_entry,
+ options={CONF_TRACK_WIRED_CLIENTS: True},
)
await hass.async_block_till_done()
diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py
index a768e61468d..2d5fbe96e0f 100644
--- a/tests/components/unifi/test_sensor.py
+++ b/tests/components/unifi/test_sensor.py
@@ -60,7 +60,8 @@ async def test_platform_manually_configured(hass):
async def test_no_clients(hass):
"""Test the update_clients function when no clients are found."""
controller = await setup_unifi_integration(
- hass, options={CONF_ALLOW_BANDWIDTH_SENSORS: True},
+ hass,
+ options={CONF_ALLOW_BANDWIDTH_SENSORS: True},
)
assert len(controller.mock_requests) == 4
@@ -110,7 +111,8 @@ async def test_sensors(hass):
assert wireless_client_tx.state == "6789.0"
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_ALLOW_BANDWIDTH_SENSORS: False},
+ controller.config_entry,
+ options={CONF_ALLOW_BANDWIDTH_SENSORS: False},
)
await hass.async_block_till_done()
@@ -121,7 +123,8 @@ async def test_sensors(hass):
assert wireless_client_tx is None
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_ALLOW_BANDWIDTH_SENSORS: True},
+ controller.config_entry,
+ options={CONF_ALLOW_BANDWIDTH_SENSORS: True},
)
await hass.async_block_till_done()
@@ -135,7 +138,9 @@ async def test_sensors(hass):
async def test_remove_sensors(hass):
"""Test the remove_items function with some clients."""
controller = await setup_unifi_integration(
- hass, options={CONF_ALLOW_BANDWIDTH_SENSORS: True}, clients_response=CLIENTS,
+ hass,
+ options={CONF_ALLOW_BANDWIDTH_SENSORS: True},
+ clients_response=CLIENTS,
)
assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4
assert len(hass.states.async_entity_ids(TRACKER_DOMAIN)) == 2
diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py
index ea198c6d8f4..6c4fc25d828 100644
--- a/tests/components/unifi/test_switch.py
+++ b/tests/components/unifi/test_switch.py
@@ -265,7 +265,8 @@ async def test_platform_manually_configured(hass):
async def test_no_clients(hass):
"""Test the update_clients function when no clients are found."""
controller = await setup_unifi_integration(
- hass, options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False},
+ hass,
+ options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False},
)
assert len(controller.mock_requests) == 4
@@ -517,21 +518,24 @@ async def test_option_block_clients(hass):
# Remove the second switch again
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_BLOCK_CLIENT: [BLOCKED["mac"]]},
+ controller.config_entry,
+ options={CONF_BLOCK_CLIENT: [BLOCKED["mac"]]},
)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1
# Enable one and remove another one
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_BLOCK_CLIENT: [UNBLOCKED["mac"]]},
+ controller.config_entry,
+ options={CONF_BLOCK_CLIENT: [UNBLOCKED["mac"]]},
)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0
# Remove one
hass.config_entries.async_update_entry(
- controller.config_entry, options={CONF_BLOCK_CLIENT: []},
+ controller.config_entry,
+ options={CONF_BLOCK_CLIENT: []},
)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 0
@@ -604,7 +608,9 @@ async def test_ignore_multiple_poe_clients_on_same_port(hass):
clients will be transparently marked as having POE as well.
"""
controller = await setup_unifi_integration(
- hass, clients_response=POE_SWITCH_CLIENTS, devices_response=[DEVICE_1],
+ hass,
+ clients_response=POE_SWITCH_CLIENTS,
+ devices_response=[DEVICE_1],
)
assert len(controller.mock_requests) == 4
diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py
index b50906649f0..76a397496ad 100644
--- a/tests/components/universal/test_media_player.py
+++ b/tests/components/universal/test_media_player.py
@@ -1,17 +1,29 @@
"""The tests for the Universal Media player platform."""
import asyncio
from copy import copy
+from os import path
import unittest
from voluptuous.error import MultipleInvalid
+from homeassistant import config as hass_config
import homeassistant.components.input_number as input_number
import homeassistant.components.input_select as input_select
import homeassistant.components.media_player as media_player
import homeassistant.components.switch as switch
import homeassistant.components.universal.media_player as universal
-from homeassistant.const import STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING
+from homeassistant.const import (
+ SERVICE_RELOAD,
+ STATE_OFF,
+ STATE_ON,
+ STATE_PAUSED,
+ STATE_PLAYING,
+ STATE_UNKNOWN,
+)
+from homeassistant.core import Context, callback
+from homeassistant.setup import async_setup_component
+from tests.async_mock import patch
from tests.common import get_test_home_assistant, mock_service
@@ -337,23 +349,6 @@ class TestMediaPlayer(unittest.TestCase):
self.hass.states.set(self.mock_state_switch_id, STATE_ON)
assert STATE_ON == ump.master_state
- def test_master_state_with_template(self):
- """Test the state_template option."""
- config = copy(self.config_children_and_attr)
- self.hass.states.set("input_boolean.test", STATE_OFF)
- templ = (
- '{% if states.input_boolean.test.state == "off" %}on'
- "{% else %}{{ states.media_player.mock1.state }}{% endif %}"
- )
- config["state_template"] = templ
- config = validate_config(config)
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
-
- assert STATE_ON == ump.master_state
- self.hass.states.set("input_boolean.test", STATE_ON)
- assert STATE_OFF == ump.master_state
-
def test_master_state_with_bad_attrs(self):
"""Test master state property."""
config = copy(self.config_children_and_attr)
@@ -735,3 +730,164 @@ class TestMediaPlayer(unittest.TestCase):
asyncio.run_coroutine_threadsafe(ump.async_turn_off(), self.hass.loop).result()
assert 1 == len(service)
+
+
+async def test_state_template(hass):
+ """Test with a simple valid state template."""
+ hass.states.async_set("sensor.test_sensor", STATE_ON)
+
+ await async_setup_component(
+ hass,
+ "media_player",
+ {
+ "media_player": {
+ "platform": "universal",
+ "name": "tv",
+ "state_template": "{{ states.sensor.test_sensor.state }}",
+ }
+ },
+ )
+ await hass.async_block_till_done()
+ assert len(hass.states.async_all()) == 2
+ await hass.async_start()
+
+ await hass.async_block_till_done()
+ assert hass.states.get("media_player.tv").state == STATE_ON
+ hass.states.async_set("sensor.test_sensor", STATE_OFF)
+ await hass.async_block_till_done()
+ assert hass.states.get("media_player.tv").state == STATE_OFF
+
+
+async def test_invalid_state_template(hass):
+ """Test invalid state template sets state to None."""
+ hass.states.async_set("sensor.test_sensor", "on")
+
+ await async_setup_component(
+ hass,
+ "media_player",
+ {
+ "media_player": {
+ "platform": "universal",
+ "name": "tv",
+ "state_template": "{{ states.sensor.test_sensor.state + x }}",
+ }
+ },
+ )
+ await hass.async_block_till_done()
+ assert len(hass.states.async_all()) == 2
+ await hass.async_start()
+
+ await hass.async_block_till_done()
+ assert hass.states.get("media_player.tv").state == STATE_UNKNOWN
+ hass.states.async_set("sensor.test_sensor", "off")
+ await hass.async_block_till_done()
+ assert hass.states.get("media_player.tv").state == STATE_UNKNOWN
+
+
+async def test_master_state_with_template(hass):
+ """Test the state_template option."""
+ hass.states.async_set("input_boolean.test", STATE_OFF)
+ hass.states.async_set("media_player.mock1", STATE_OFF)
+
+ templ = (
+ '{% if states.input_boolean.test.state == "off" %}on'
+ "{% else %}{{ states.media_player.mock1.state }}{% endif %}"
+ )
+
+ await async_setup_component(
+ hass,
+ "media_player",
+ {
+ "media_player": {
+ "platform": "universal",
+ "name": "tv",
+ "state_template": templ,
+ }
+ },
+ )
+
+ await hass.async_block_till_done()
+ assert len(hass.states.async_all()) == 3
+ await hass.async_start()
+
+ await hass.async_block_till_done()
+ hass.states.get("media_player.tv").state == STATE_ON
+
+ events = []
+
+ hass.helpers.event.async_track_state_change_event(
+ "media_player.tv", callback(lambda event: events.append(event))
+ )
+
+ context = Context()
+ hass.states.async_set("input_boolean.test", STATE_ON, context=context)
+ await hass.async_block_till_done()
+
+ hass.states.get("media_player.tv").state == STATE_OFF
+ assert events[0].context == context
+
+
+async def test_reload(hass):
+ """Test reloading the media player from yaml."""
+ hass.states.async_set("input_boolean.test", STATE_OFF)
+ hass.states.async_set("media_player.mock1", STATE_OFF)
+
+ templ = (
+ '{% if states.input_boolean.test.state == "off" %}on'
+ "{% else %}{{ states.media_player.mock1.state }}{% endif %}"
+ )
+
+ await async_setup_component(
+ hass,
+ "media_player",
+ {
+ "media_player": {
+ "platform": "universal",
+ "name": "tv",
+ "state_template": templ,
+ }
+ },
+ )
+
+ await hass.async_block_till_done()
+ assert len(hass.states.async_all()) == 3
+ await hass.async_start()
+
+ await hass.async_block_till_done()
+ hass.states.get("media_player.tv").state == STATE_ON
+
+ hass.states.async_set("input_boolean.test", STATE_ON)
+ await hass.async_block_till_done()
+
+ hass.states.get("media_player.tv").state == STATE_OFF
+
+ hass.states.async_set("media_player.master_bedroom_2", STATE_OFF)
+ hass.states.async_set(
+ "remote.alexander_master_bedroom",
+ STATE_ON,
+ {"activity_list": ["act1", "act2"], "current_activity": "act2"},
+ )
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "universal/configuration.yaml",
+ )
+ with patch.object(hass_config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ "universal",
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 5
+
+ assert hass.states.get("media_player.tv") is None
+ assert hass.states.get("media_player.master_bed_tv").state == "on"
+ assert hass.states.get("media_player.master_bed_tv").attributes["source"] == "act2"
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(path.dirname(__file__)))
diff --git a/tests/components/upnp/test_config_flow.py b/tests/components/upnp/test_config_flow.py
index 870aa13fc41..98df710d8fe 100644
--- a/tests/components/upnp/test_config_flow.py
+++ b/tests/components/upnp/test_config_flow.py
@@ -54,7 +54,8 @@ async def test_flow_ssdp_discovery(hass: HomeAssistantType):
# Confirm via step ssdp_confirm.
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={},
+ result["flow_id"],
+ user_input={},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -119,7 +120,8 @@ async def test_flow_user(hass: HomeAssistantType):
# Confirmed via step user.
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={"usn": usn},
+ result["flow_id"],
+ user_input={"usn": usn},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -198,12 +200,15 @@ async def test_options_flow(hass: HomeAssistantType):
assert coordinator.update_interval == timedelta(seconds=DEFAULT_SCAN_INTERVAL)
# Options flow with no input results in form.
- result = await hass.config_entries.options.async_init(config_entry.entry_id,)
+ result = await hass.config_entries.options.async_init(
+ config_entry.entry_id,
+ )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
# Options flow with input results in update to entry.
result2 = await hass.config_entries.options.async_configure(
- result["flow_id"], user_input={CONFIG_ENTRY_SCAN_INTERVAL: 60},
+ result["flow_id"],
+ user_input={CONFIG_ENTRY_SCAN_INTERVAL: 60},
)
assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert config_entry.options == {
diff --git a/tests/components/vera/test_climate.py b/tests/components/vera/test_climate.py
index f52bf375d8e..f11f3ea5a3b 100644
--- a/tests/components/vera/test_climate.py
+++ b/tests/components/vera/test_climate.py
@@ -87,7 +87,9 @@ async def test_climate(
assert hass.states.get(entity_id).state == HVAC_MODE_OFF
await hass.services.async_call(
- "climate", "set_fan_mode", {"entity_id": entity_id, "fan_mode": "on"},
+ "climate",
+ "set_fan_mode",
+ {"entity_id": entity_id, "fan_mode": "on"},
)
await hass.async_block_till_done()
vera_device.turn_auto_on.assert_called()
@@ -97,7 +99,9 @@ async def test_climate(
assert hass.states.get(entity_id).attributes["fan_mode"] == FAN_ON
await hass.services.async_call(
- "climate", "set_fan_mode", {"entity_id": entity_id, "fan_mode": "off"},
+ "climate",
+ "set_fan_mode",
+ {"entity_id": entity_id, "fan_mode": "off"},
)
await hass.async_block_till_done()
vera_device.turn_auto_on.assert_called()
@@ -107,7 +111,9 @@ async def test_climate(
assert hass.states.get(entity_id).attributes["fan_mode"] == FAN_AUTO
await hass.services.async_call(
- "climate", "set_temperature", {"entity_id": entity_id, "temperature": 30},
+ "climate",
+ "set_temperature",
+ {"entity_id": entity_id, "temperature": 30},
)
await hass.async_block_till_done()
vera_device.set_temperature.assert_called_with(30)
@@ -145,7 +151,9 @@ async def test_climate_f(
update_callback = component_data.controller_data.update_callback
await hass.services.async_call(
- "climate", "set_temperature", {"entity_id": entity_id, "temperature": 30},
+ "climate",
+ "set_temperature",
+ {"entity_id": entity_id, "temperature": 30},
)
await hass.async_block_till_done()
vera_device.set_temperature.assert_called_with(86)
diff --git a/tests/components/vera/test_cover.py b/tests/components/vera/test_cover.py
index a2dae2bd7f8..311c8013d86 100644
--- a/tests/components/vera/test_cover.py
+++ b/tests/components/vera/test_cover.py
@@ -30,7 +30,9 @@ async def test_cover(
assert hass.states.get(entity_id).attributes["current_position"] == 0
await hass.services.async_call(
- "cover", "open_cover", {"entity_id": entity_id},
+ "cover",
+ "open_cover",
+ {"entity_id": entity_id},
)
await hass.async_block_till_done()
vera_device.open.assert_called()
@@ -42,7 +44,9 @@ async def test_cover(
assert hass.states.get(entity_id).attributes["current_position"] == 100
await hass.services.async_call(
- "cover", "set_cover_position", {"entity_id": entity_id, "position": 50},
+ "cover",
+ "set_cover_position",
+ {"entity_id": entity_id, "position": 50},
)
await hass.async_block_till_done()
vera_device.set_level.assert_called_with(50)
@@ -54,7 +58,9 @@ async def test_cover(
assert hass.states.get(entity_id).attributes["current_position"] == 50
await hass.services.async_call(
- "cover", "stop_cover", {"entity_id": entity_id},
+ "cover",
+ "stop_cover",
+ {"entity_id": entity_id},
)
await hass.async_block_till_done()
vera_device.stop.assert_called()
@@ -64,7 +70,9 @@ async def test_cover(
assert hass.states.get(entity_id).attributes["current_position"] == 50
await hass.services.async_call(
- "cover", "close_cover", {"entity_id": entity_id},
+ "cover",
+ "close_cover",
+ {"entity_id": entity_id},
)
await hass.async_block_till_done()
vera_device.close.assert_called()
diff --git a/tests/components/vera/test_light.py b/tests/components/vera/test_light.py
index 14194d0af52..99391d8d82a 100644
--- a/tests/components/vera/test_light.py
+++ b/tests/components/vera/test_light.py
@@ -32,7 +32,9 @@ async def test_light(
assert hass.states.get(entity_id).state == "off"
await hass.services.async_call(
- "light", "turn_on", {"entity_id": entity_id},
+ "light",
+ "turn_on",
+ {"entity_id": entity_id},
)
await hass.async_block_till_done()
vera_device.switch_on.assert_called()
@@ -42,7 +44,9 @@ async def test_light(
assert hass.states.get(entity_id).state == "on"
await hass.services.async_call(
- "light", "turn_on", {"entity_id": entity_id, ATTR_HS_COLOR: [300, 70]},
+ "light",
+ "turn_on",
+ {"entity_id": entity_id, ATTR_HS_COLOR: [300, 70]},
)
await hass.async_block_till_done()
vera_device.set_color.assert_called_with((255, 76, 255))
@@ -54,7 +58,9 @@ async def test_light(
assert hass.states.get(entity_id).attributes["hs_color"] == (300.0, 70.196)
await hass.services.async_call(
- "light", "turn_on", {"entity_id": entity_id, ATTR_BRIGHTNESS: 55},
+ "light",
+ "turn_on",
+ {"entity_id": entity_id, ATTR_BRIGHTNESS: 55},
)
await hass.async_block_till_done()
vera_device.set_brightness.assert_called_with(55)
@@ -66,7 +72,9 @@ async def test_light(
assert hass.states.get(entity_id).attributes["brightness"] == 55
await hass.services.async_call(
- "light", "turn_off", {"entity_id": entity_id},
+ "light",
+ "turn_off",
+ {"entity_id": entity_id},
)
await hass.async_block_till_done()
vera_device.switch_off.assert_called()
diff --git a/tests/components/vera/test_lock.py b/tests/components/vera/test_lock.py
index 901e09040e9..11af1f5a7b7 100644
--- a/tests/components/vera/test_lock.py
+++ b/tests/components/vera/test_lock.py
@@ -29,7 +29,9 @@ async def test_lock(
assert hass.states.get(entity_id).state == STATE_UNLOCKED
await hass.services.async_call(
- "lock", "lock", {"entity_id": entity_id},
+ "lock",
+ "lock",
+ {"entity_id": entity_id},
)
await hass.async_block_till_done()
vera_device.lock.assert_called()
@@ -39,7 +41,9 @@ async def test_lock(
assert hass.states.get(entity_id).state == STATE_LOCKED
await hass.services.async_call(
- "lock", "unlock", {"entity_id": entity_id},
+ "lock",
+ "unlock",
+ {"entity_id": entity_id},
)
await hass.async_block_till_done()
vera_device.unlock.assert_called()
diff --git a/tests/components/vera/test_scene.py b/tests/components/vera/test_scene.py
index 8f96b7a133a..29ef338b9f1 100644
--- a/tests/components/vera/test_scene.py
+++ b/tests/components/vera/test_scene.py
@@ -18,10 +18,13 @@ async def test_scene(
entity_id = "scene.dev1_1"
await vera_component_factory.configure_component(
- hass=hass, controller_config=new_simple_controller_config(scenes=(vera_scene,)),
+ hass=hass,
+ controller_config=new_simple_controller_config(scenes=(vera_scene,)),
)
await hass.services.async_call(
- "scene", "turn_on", {"entity_id": entity_id},
+ "scene",
+ "turn_on",
+ {"entity_id": entity_id},
)
await hass.async_block_till_done()
diff --git a/tests/components/vera/test_sensor.py b/tests/components/vera/test_sensor.py
index cb50ad82789..36730e8d6d2 100644
--- a/tests/components/vera/test_sensor.py
+++ b/tests/components/vera/test_sensor.py
@@ -3,7 +3,7 @@ from typing import Any, Callable, Tuple
import pyvera as pv
-from homeassistant.const import UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE
from homeassistant.core import HomeAssistant
from .common import ComponentFactory, new_simple_controller_config
@@ -115,7 +115,7 @@ async def test_humidity_sensor(
category=pv.CATEGORY_HUMIDITY_SENSOR,
class_property="humidity",
assert_states=(("12", "12"), ("13", "13")),
- assert_unit_of_measurement=UNIT_PERCENTAGE,
+ assert_unit_of_measurement=PERCENTAGE,
)
diff --git a/tests/components/vera/test_switch.py b/tests/components/vera/test_switch.py
index 2a8bfe68185..60e31add4bd 100644
--- a/tests/components/vera/test_switch.py
+++ b/tests/components/vera/test_switch.py
@@ -28,7 +28,9 @@ async def test_switch(
assert hass.states.get(entity_id).state == "off"
await hass.services.async_call(
- "switch", "turn_on", {"entity_id": entity_id},
+ "switch",
+ "turn_on",
+ {"entity_id": entity_id},
)
await hass.async_block_till_done()
vera_device.switch_on.assert_called()
@@ -38,7 +40,9 @@ async def test_switch(
assert hass.states.get(entity_id).state == "on"
await hass.services.async_call(
- "switch", "turn_off", {"entity_id": entity_id},
+ "switch",
+ "turn_off",
+ {"entity_id": entity_id},
)
await hass.async_block_till_done()
vera_device.switch_off.assert_called()
diff --git a/tests/components/vilfo/test_config_flow.py b/tests/components/vilfo/test_config_flow.py
index d9e8a2ffd24..167fc71a78a 100644
--- a/tests/components/vilfo/test_config_flow.py
+++ b/tests/components/vilfo/test_config_flow.py
@@ -110,7 +110,8 @@ async def test_form_already_configured(hass):
)
with patch("vilfo.Client.ping", return_value=None), patch(
- "vilfo.Client.get_board_information", return_value=None,
+ "vilfo.Client.get_board_information",
+ return_value=None,
), patch("vilfo.Client.resolve_mac_address", return_value=None):
first_flow_result2 = await hass.config_entries.flow.async_configure(
first_flow_result1["flow_id"],
@@ -122,7 +123,8 @@ async def test_form_already_configured(hass):
)
with patch("vilfo.Client.ping", return_value=None), patch(
- "vilfo.Client.get_board_information", return_value=None,
+ "vilfo.Client.get_board_information",
+ return_value=None,
), patch("vilfo.Client.resolve_mac_address", return_value=None):
second_flow_result2 = await hass.config_entries.flow.async_configure(
second_flow_result1["flow_id"],
diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py
index fd5ff7cd468..08e5da5c9e5 100644
--- a/tests/components/vizio/conftest.py
+++ b/tests/components/vizio/conftest.py
@@ -1,5 +1,6 @@
"""Configure py.test."""
import pytest
+from pyvizio.api.apps import AppConfig
from pyvizio.const import DEVICE_CLASS_SPEAKER, MAX_VOLUME
from .const import (
@@ -47,15 +48,42 @@ def skip_notifications_fixture():
yield
+@pytest.fixture(name="vizio_get_unique_id", autouse=True)
+def vizio_get_unique_id_fixture():
+ """Mock get vizio unique ID."""
+ with patch(
+ "homeassistant.components.vizio.config_flow.VizioAsync.get_unique_id",
+ return_value=UNIQUE_ID,
+ ):
+ yield
+
+
+@pytest.fixture(name="vizio_data_coordinator_update", autouse=True)
+def vizio_data_coordinator_update_fixture():
+ """Mock get data coordinator update."""
+ with patch(
+ "homeassistant.components.vizio.gen_apps_list_from_url",
+ return_value=APP_LIST,
+ ):
+ yield
+
+
+@pytest.fixture(name="vizio_no_unique_id")
+def vizio_no_unique_id_fixture():
+ """Mock no vizio unique ID returrned."""
+ with patch(
+ "homeassistant.components.vizio.config_flow.VizioAsync.get_unique_id",
+ return_value=None,
+ ):
+ yield
+
+
@pytest.fixture(name="vizio_connect")
def vizio_connect_fixture():
"""Mock valid vizio device and entry setup."""
with patch(
"homeassistant.components.vizio.config_flow.VizioAsync.validate_ha_config",
return_value=True,
- ), patch(
- "homeassistant.components.vizio.config_flow.VizioAsync.get_unique_id",
- return_value=UNIQUE_ID,
):
yield
@@ -90,7 +118,8 @@ def vizio_invalid_pin_failure_fixture():
"homeassistant.components.vizio.config_flow.VizioAsync.start_pair",
return_value=MockStartPairingResponse(CH_TYPE, RESPONSE_TOKEN),
), patch(
- "homeassistant.components.vizio.config_flow.VizioAsync.pair", return_value=None,
+ "homeassistant.components.vizio.config_flow.VizioAsync.pair",
+ return_value=None,
):
yield
@@ -173,15 +202,12 @@ def vizio_update_with_apps_fixture(vizio_update: pytest.fixture):
with patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_inputs_list",
return_value=get_mock_inputs(INPUT_LIST_WITH_APPS),
- ), patch(
- "homeassistant.components.vizio.media_player.VizioAsync.get_apps_list",
- return_value=APP_LIST,
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_input",
return_value="CAST",
), patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_app_config",
- return_value=CURRENT_APP_CONFIG,
+ return_value=AppConfig(**CURRENT_APP_CONFIG),
):
yield
diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py
index 43605de67ad..6ec746b2c54 100644
--- a/tests/components/vizio/const.py
+++ b/tests/components/vizio/const.py
@@ -72,7 +72,21 @@ INPUT_LIST = ["HDMI", "USB", "Bluetooth", "AUX"]
CURRENT_APP = "Hulu"
CURRENT_APP_CONFIG = {CONF_APP_ID: "3", CONF_NAME_SPACE: 4, CONF_MESSAGE: None}
-APP_LIST = ["Hulu", "Netflix"]
+APP_LIST = [
+ {
+ "name": "Hulu",
+ "country": ["*"],
+ "id": ["1"],
+ "config": [{"NAME_SPACE": 4, "APP_ID": "3", "MESSAGE": None}],
+ },
+ {
+ "name": "Netflix",
+ "country": ["*"],
+ "id": ["2"],
+ "config": [{"NAME_SPACE": 1, "APP_ID": "2", "MESSAGE": None}],
+ },
+]
+APP_NAME_LIST = [app["name"] for app in APP_LIST]
INPUT_LIST_WITH_APPS = INPUT_LIST + ["CAST"]
CUSTOM_CONFIG = {CONF_APP_ID: "test", CONF_MESSAGE: None, CONF_NAME_SPACE: 10}
ADDITIONAL_APP_CONFIG = {
diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py
index 74d4d6a2c62..2506a685e43 100644
--- a/tests/components/vizio/test_config_flow.py
+++ b/tests/components/vizio/test_config_flow.py
@@ -51,7 +51,6 @@ from .const import (
NAME2,
UNIQUE_ID,
VOLUME_STEP,
- ZEROCONF_HOST,
)
from tests.common import MockConfigEntry
@@ -110,12 +109,18 @@ async def test_user_flow_all_fields(
assert CONF_APPS not in result["data"]
-async def test_speaker_options_flow(hass: HomeAssistantType) -> None:
+async def test_speaker_options_flow(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_bypass_update: pytest.fixture,
+) -> None:
"""Test options config flow for speaker."""
- entry = MockConfigEntry(domain=DOMAIN, data=MOCK_SPEAKER_CONFIG)
- entry.add_to_hass(hass)
-
- assert not entry.options
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=MOCK_SPEAKER_CONFIG
+ )
+ await hass.async_block_till_done()
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ entry = result["result"]
result = await hass.config_entries.options.async_init(entry.entry_id, data=None)
@@ -132,12 +137,18 @@ async def test_speaker_options_flow(hass: HomeAssistantType) -> None:
assert CONF_APPS not in result["data"]
-async def test_tv_options_flow_no_apps(hass: HomeAssistantType) -> None:
+async def test_tv_options_flow_no_apps(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_bypass_update: pytest.fixture,
+) -> None:
"""Test options config flow for TV without providing apps option."""
- entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG)
- entry.add_to_hass(hass)
-
- assert not entry.options
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_VALID_TV_CONFIG
+ )
+ await hass.async_block_till_done()
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ entry = result["result"]
result = await hass.config_entries.options.async_init(entry.entry_id, data=None)
@@ -157,12 +168,18 @@ async def test_tv_options_flow_no_apps(hass: HomeAssistantType) -> None:
assert CONF_APPS not in result["data"]
-async def test_tv_options_flow_with_apps(hass: HomeAssistantType) -> None:
+async def test_tv_options_flow_with_apps(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_bypass_update: pytest.fixture,
+) -> None:
"""Test options config flow for TV with providing apps option."""
- entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG)
- entry.add_to_hass(hass)
-
- assert not entry.options
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_VALID_TV_CONFIG
+ )
+ await hass.async_block_till_done()
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ entry = result["result"]
result = await hass.config_entries.options.async_init(entry.entry_id, data=None)
@@ -183,14 +200,23 @@ async def test_tv_options_flow_with_apps(hass: HomeAssistantType) -> None:
assert result["data"][CONF_APPS] == {CONF_INCLUDE: [CURRENT_APP]}
-async def test_tv_options_flow_start_with_volume(hass: HomeAssistantType) -> None:
+async def test_tv_options_flow_start_with_volume(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_bypass_update: pytest.fixture,
+) -> None:
"""Test options config flow for TV with providing apps option after providing volume step in initial config."""
- entry = MockConfigEntry(
- domain=DOMAIN,
- data=MOCK_USER_VALID_TV_CONFIG,
- options={CONF_VOLUME_STEP: VOLUME_STEP},
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_VALID_TV_CONFIG
)
- entry.add_to_hass(hass)
+ await hass.async_block_till_done()
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ entry = result["result"]
+
+ result = await hass.config_entries.options.async_init(
+ entry.entry_id, data={CONF_VOLUME_STEP: VOLUME_STEP}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert entry.options
assert entry.options == {CONF_VOLUME_STEP: VOLUME_STEP}
@@ -223,7 +249,10 @@ async def test_user_host_already_configured(
) -> None:
"""Test host is already configured during user setup."""
entry = MockConfigEntry(
- domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, options={CONF_VOLUME_STEP: VOLUME_STEP}
+ domain=DOMAIN,
+ data=MOCK_SPEAKER_CONFIG,
+ options={CONF_VOLUME_STEP: VOLUME_STEP},
+ unique_id=UNIQUE_ID,
)
entry.add_to_hass(hass)
fail_entry = MOCK_SPEAKER_CONFIG.copy()
@@ -234,61 +263,15 @@ async def test_user_host_already_configured(
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {CONF_HOST: "host_exists"}
+ assert result["errors"] == {CONF_HOST: "existing_config_entry_found"}
-async def test_user_host_already_configured_no_port(
+async def test_user_serial_number_already_exists(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_bypass_setup: pytest.fixture,
) -> None:
- """Test host is already configured during user setup when existing entry has no port."""
- # Mock entry without port so we can test that the same entry WITH a port will fail
- no_port_entry = MOCK_SPEAKER_CONFIG.copy()
- no_port_entry[CONF_HOST] = no_port_entry[CONF_HOST].split(":")[0]
- entry = MockConfigEntry(
- domain=DOMAIN, data=no_port_entry, options={CONF_VOLUME_STEP: VOLUME_STEP}
- )
- entry.add_to_hass(hass)
- fail_entry = MOCK_SPEAKER_CONFIG.copy()
- fail_entry[CONF_NAME] = "newtestname"
-
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=fail_entry
- )
-
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {CONF_HOST: "host_exists"}
-
-
-async def test_user_name_already_configured(
- hass: HomeAssistantType,
- vizio_connect: pytest.fixture,
- vizio_bypass_setup: pytest.fixture,
-) -> None:
- """Test name is already configured during user setup."""
- entry = MockConfigEntry(
- domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, options={CONF_VOLUME_STEP: VOLUME_STEP}
- )
- entry.add_to_hass(hass)
-
- fail_entry = MOCK_SPEAKER_CONFIG.copy()
- fail_entry[CONF_HOST] = "0.0.0.0"
-
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": SOURCE_USER}, data=fail_entry
- )
-
- assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
- assert result["errors"] == {CONF_NAME: "name_exists"}
-
-
-async def test_user_esn_already_exists(
- hass: HomeAssistantType,
- vizio_connect: pytest.fixture,
- vizio_bypass_setup: pytest.fixture,
-) -> None:
- """Test ESN is already configured with different host and name during user setup."""
+ """Test serial_number is already configured with different host and name during user setup."""
# Set up new entry
MockConfigEntry(
domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, unique_id=UNIQUE_ID
@@ -303,14 +286,26 @@ async def test_user_esn_already_exists(
DOMAIN, context={"source": SOURCE_USER}, data=fail_entry
)
- assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
- assert result["reason"] == "already_configured"
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {CONF_HOST: "existing_config_entry_found"}
async def test_user_error_on_could_not_connect(
+ hass: HomeAssistantType, vizio_no_unique_id: pytest.fixture
+) -> None:
+ """Test with could_not_connect during user setup due to no connectivity."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_VALID_TV_CONFIG
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["errors"] == {CONF_HOST: "cannot_connect"}
+
+
+async def test_user_error_on_could_not_connect_invalid_token(
hass: HomeAssistantType, vizio_cant_connect: pytest.fixture
) -> None:
- """Test with could_not_connect during user_setup."""
+ """Test with could_not_connect during user setup due to invalid token."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=MOCK_USER_VALID_TV_CONFIG
)
@@ -683,6 +678,7 @@ async def test_import_error(
domain=DOMAIN,
data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG),
options={CONF_VOLUME_STEP: VOLUME_STEP},
+ unique_id=UNIQUE_ID,
)
entry.add_to_hass(hass)
fail_entry = MOCK_SPEAKER_CONFIG.copy()
@@ -763,10 +759,14 @@ async def test_zeroconf_flow_already_configured(
hass: HomeAssistantType,
vizio_connect: pytest.fixture,
vizio_bypass_setup: pytest.fixture,
+ vizio_guess_device_type: pytest.fixture,
) -> None:
"""Test entity is already configured during zeroconf setup."""
entry = MockConfigEntry(
- domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, options={CONF_VOLUME_STEP: VOLUME_STEP}
+ domain=DOMAIN,
+ data=MOCK_SPEAKER_CONFIG,
+ options={CONF_VOLUME_STEP: VOLUME_STEP},
+ unique_id=UNIQUE_ID,
)
entry.add_to_hass(hass)
@@ -778,7 +778,7 @@ async def test_zeroconf_flow_already_configured(
# Flow should abort because device is already setup
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
- assert result["reason"] == "already_configured_device"
+ assert result["reason"] == "already_configured"
async def test_zeroconf_dupe_fail(
@@ -842,7 +842,7 @@ async def test_zeroconf_abort_when_ignored(
data=MOCK_SPEAKER_CONFIG,
options={CONF_VOLUME_STEP: VOLUME_STEP},
source=SOURCE_IGNORE,
- unique_id=ZEROCONF_HOST,
+ unique_id=UNIQUE_ID,
)
entry.add_to_hass(hass)
@@ -860,12 +860,16 @@ async def test_zeroconf_flow_already_configured_hostname(
vizio_connect: pytest.fixture,
vizio_bypass_setup: pytest.fixture,
vizio_hostname_check: pytest.fixture,
+ vizio_guess_device_type: pytest.fixture,
) -> None:
"""Test entity is already configured during zeroconf setup when existing entry uses hostname."""
config = MOCK_SPEAKER_CONFIG.copy()
config[CONF_HOST] = "hostname"
entry = MockConfigEntry(
- domain=DOMAIN, data=config, options={CONF_VOLUME_STEP: VOLUME_STEP}
+ domain=DOMAIN,
+ data=config,
+ options={CONF_VOLUME_STEP: VOLUME_STEP},
+ unique_id=UNIQUE_ID,
)
entry.add_to_hass(hass)
@@ -877,7 +881,7 @@ async def test_zeroconf_flow_already_configured_hostname(
# Flow should abort because device is already setup
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
- assert result["reason"] == "already_configured_device"
+ assert result["reason"] == "already_configured"
async def test_import_flow_already_configured_hostname(
diff --git a/tests/components/vizio/test_init.py b/tests/components/vizio/test_init.py
index 1be067e9570..33764de8696 100644
--- a/tests/components/vizio/test_init.py
+++ b/tests/components/vizio/test_init.py
@@ -37,7 +37,9 @@ async def test_load_and_unload(
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 1
+ assert DOMAIN in hass.data
- assert await hass.config_entries.async_unload(config_entry.entry_id)
+ assert await config_entry.async_unload(hass)
await hass.async_block_till_done()
assert len(hass.states.async_entity_ids(MP_DOMAIN)) == 0
+ assert DOMAIN not in hass.data
diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py
index 7a2ff1d1c7a..c4620b07025 100644
--- a/tests/components/vizio/test_media_player.py
+++ b/tests/components/vizio/test_media_player.py
@@ -8,6 +8,7 @@ import pytest
from pytest import raises
from pyvizio.api.apps import AppConfig
from pyvizio.const import (
+ APPS,
DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER,
DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV,
INPUT_APPS,
@@ -41,6 +42,7 @@ from homeassistant.components.vizio.const import (
CONF_APPS,
CONF_VOLUME_STEP,
DOMAIN,
+ SERVICE_UPDATE_SETTING,
VIZIO_SCHEMA,
)
from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE
@@ -50,6 +52,7 @@ from homeassistant.util import dt as dt_util
from .const import (
ADDITIONAL_APP_CONFIG,
APP_LIST,
+ APP_NAME_LIST,
CURRENT_APP,
CURRENT_APP_CONFIG,
CURRENT_EQ,
@@ -174,12 +177,14 @@ async def _test_setup_speaker(
unique_id=UNIQUE_ID,
)
+ audio_settings = {
+ "volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_SPEAKER] / 2),
+ "mute": "Off",
+ "eq": CURRENT_EQ,
+ }
+
async with _cm_for_test_setup_without_apps(
- {
- "volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_SPEAKER] / 2),
- "mute": "Off",
- "eq": CURRENT_EQ,
- },
+ audio_settings,
vizio_power_state,
):
with patch(
@@ -206,7 +211,8 @@ async def _cm_for_test_setup_tv_with_apps(
)
async with _cm_for_test_setup_without_apps(
- {"volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2), "mute": "Off"}, True,
+ {"volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2), "mute": "Off"},
+ True,
):
with patch(
"homeassistant.components.vizio.media_player.VizioAsync.get_current_app_config",
@@ -248,6 +254,7 @@ async def _test_setup_failure(hass: HomeAssistantType, config: str) -> None:
async def _test_service(
hass: HomeAssistantType,
+ domain: str,
vizio_func_name: str,
ha_service_name: str,
additional_service_data: Optional[Dict[str, Any]],
@@ -263,7 +270,10 @@ async def _test_service(
f"homeassistant.components.vizio.media_player.VizioAsync.{vizio_func_name}"
) as service_call:
await hass.services.async_call(
- MP_DOMAIN, ha_service_name, service_data=service_data, blocking=True,
+ domain,
+ ha_service_name,
+ service_data=service_data,
+ blocking=True,
)
assert service_call.called
@@ -347,29 +357,67 @@ async def test_services(
"""Test all Vizio media player entity services."""
await _test_setup_tv(hass, True)
- await _test_service(hass, "pow_on", SERVICE_TURN_ON, None)
- await _test_service(hass, "pow_off", SERVICE_TURN_OFF, None)
+ await _test_service(hass, MP_DOMAIN, "pow_on", SERVICE_TURN_ON, None)
+ await _test_service(hass, MP_DOMAIN, "pow_off", SERVICE_TURN_OFF, None)
await _test_service(
- hass, "mute_on", SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: True}
+ hass,
+ MP_DOMAIN,
+ "mute_on",
+ SERVICE_VOLUME_MUTE,
+ {ATTR_MEDIA_VOLUME_MUTED: True},
)
await _test_service(
- hass, "mute_off", SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: False}
+ hass,
+ MP_DOMAIN,
+ "mute_off",
+ SERVICE_VOLUME_MUTE,
+ {ATTR_MEDIA_VOLUME_MUTED: False},
)
await _test_service(
- hass, "set_input", SERVICE_SELECT_SOURCE, {ATTR_INPUT_SOURCE: "USB"}, "USB"
+ hass,
+ MP_DOMAIN,
+ "set_input",
+ SERVICE_SELECT_SOURCE,
+ {ATTR_INPUT_SOURCE: "USB"},
+ "USB",
)
- await _test_service(hass, "vol_up", SERVICE_VOLUME_UP, None)
- await _test_service(hass, "vol_down", SERVICE_VOLUME_DOWN, None)
+ await _test_service(hass, MP_DOMAIN, "vol_up", SERVICE_VOLUME_UP, None)
+ await _test_service(hass, MP_DOMAIN, "vol_down", SERVICE_VOLUME_DOWN, None)
await _test_service(
- hass, "vol_up", SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 1}
+ hass, MP_DOMAIN, "vol_up", SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 1}
)
await _test_service(
- hass, "vol_down", SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 0}
+ hass, MP_DOMAIN, "vol_down", SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 0}
)
- await _test_service(hass, "ch_up", SERVICE_MEDIA_NEXT_TRACK, None)
- await _test_service(hass, "ch_down", SERVICE_MEDIA_PREVIOUS_TRACK, None)
+ await _test_service(hass, MP_DOMAIN, "ch_up", SERVICE_MEDIA_NEXT_TRACK, None)
+ await _test_service(hass, MP_DOMAIN, "ch_down", SERVICE_MEDIA_PREVIOUS_TRACK, None)
await _test_service(
- hass, "set_setting", SERVICE_SELECT_SOUND_MODE, {ATTR_SOUND_MODE: "Music"}
+ hass,
+ MP_DOMAIN,
+ "set_setting",
+ SERVICE_SELECT_SOUND_MODE,
+ {ATTR_SOUND_MODE: "Music"},
+ )
+ # Test that the update_setting service does config validation/transformation correctly
+ await _test_service(
+ hass,
+ DOMAIN,
+ "set_setting",
+ SERVICE_UPDATE_SETTING,
+ {"setting_type": "Audio", "setting_name": "AV Delay", "new_value": "0"},
+ "audio",
+ "av_delay",
+ 0,
+ )
+ await _test_service(
+ hass,
+ DOMAIN,
+ "set_setting",
+ SERVICE_UPDATE_SETTING,
+ {"setting_type": "Audio", "setting_name": "EQ", "new_value": "Music"},
+ "audio",
+ "eq",
+ "Music",
)
@@ -386,10 +434,13 @@ async def test_options_update(
updated_options = {CONF_VOLUME_STEP: VOLUME_STEP}
new_options.update(updated_options)
hass.config_entries.async_update_entry(
- entry=config_entry, options=new_options,
+ entry=config_entry,
+ options=new_options,
)
assert config_entry.options == updated_options
- await _test_service(hass, "vol_up", SERVICE_VOLUME_UP, None, num=VOLUME_STEP)
+ await _test_service(
+ hass, MP_DOMAIN, "vol_up", SERVICE_VOLUME_UP, None, num=VOLUME_STEP
+ )
async def _test_update_availability_switch(
@@ -466,7 +517,7 @@ async def test_setup_with_apps(
hass, MOCK_USER_VALID_TV_CONFIG, CURRENT_APP_CONFIG
):
attr = hass.states.get(ENTITY_ID).attributes
- _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_LIST), attr)
+ _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr)
assert CURRENT_APP in attr["source_list"]
assert attr["source"] == CURRENT_APP
assert attr["app_name"] == CURRENT_APP
@@ -474,10 +525,12 @@ async def test_setup_with_apps(
await _test_service(
hass,
+ MP_DOMAIN,
"launch_app",
SERVICE_SELECT_SOURCE,
{ATTR_INPUT_SOURCE: CURRENT_APP},
CURRENT_APP,
+ APP_LIST,
)
@@ -525,20 +578,22 @@ async def test_setup_with_apps_additional_apps_config(
) -> None:
"""Test device setup with apps and apps["additional_configs"] in config."""
async with _cm_for_test_setup_tv_with_apps(
- hass, MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG, ADDITIONAL_APP_CONFIG["config"],
+ hass,
+ MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG,
+ ADDITIONAL_APP_CONFIG["config"],
):
attr = hass.states.get(ENTITY_ID).attributes
assert attr["source_list"].count(CURRENT_APP) == 1
_assert_source_list_with_apps(
list(
INPUT_LIST_WITH_APPS
- + APP_LIST
+ + APP_NAME_LIST
+ [
app["name"]
for app in MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG[CONF_APPS][
CONF_ADDITIONAL_CONFIGS
]
- if app["name"] not in APP_LIST
+ if app["name"] not in APP_NAME_LIST
]
),
attr,
@@ -550,13 +605,16 @@ async def test_setup_with_apps_additional_apps_config(
await _test_service(
hass,
+ MP_DOMAIN,
"launch_app",
SERVICE_SELECT_SOURCE,
{ATTR_INPUT_SOURCE: "Netflix"},
"Netflix",
+ APP_LIST,
)
await _test_service(
hass,
+ MP_DOMAIN,
"launch_app_config",
SERVICE_SELECT_SOURCE,
{ATTR_INPUT_SOURCE: CURRENT_APP},
@@ -599,7 +657,7 @@ async def test_setup_with_unknown_app_config(
hass, MOCK_USER_VALID_TV_CONFIG, UNKNOWN_APP_CONFIG
):
attr = hass.states.get(ENTITY_ID).attributes
- _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_LIST), attr)
+ _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr)
assert attr["source"] == UNKNOWN_APP
assert attr["app_name"] == UNKNOWN_APP
assert attr["app_id"] == UNKNOWN_APP_CONFIG
@@ -616,7 +674,7 @@ async def test_setup_with_no_running_app(
hass, MOCK_USER_VALID_TV_CONFIG, vars(AppConfig())
):
attr = hass.states.get(ENTITY_ID).attributes
- _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_LIST), attr)
+ _assert_source_list_with_apps(list(INPUT_LIST_WITH_APPS + APP_NAME_LIST), attr)
assert attr["source"] == "CAST"
assert "app_id" not in attr
assert "app_name" not in attr
@@ -635,7 +693,8 @@ async def test_setup_tv_without_mute(
)
async with _cm_for_test_setup_without_apps(
- {"volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2)}, STATE_ON,
+ {"volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2)},
+ STATE_ON,
):
await _add_config_entry_to_hass(hass, config_entry)
@@ -643,3 +702,36 @@ async def test_setup_tv_without_mute(
_assert_sources_and_volume(attr, VIZIO_DEVICE_CLASS_TV)
assert "sound_mode" not in attr
assert "is_volume_muted" not in attr
+
+
+async def test_apps_update(
+ hass: HomeAssistantType,
+ vizio_connect: pytest.fixture,
+ vizio_update_with_apps: pytest.fixture,
+ caplog: pytest.fixture,
+) -> None:
+ """Test device setup with apps where no app is running."""
+ with patch(
+ "homeassistant.components.vizio.gen_apps_list_from_url",
+ return_value=None,
+ ):
+ async with _cm_for_test_setup_tv_with_apps(
+ hass, MOCK_USER_VALID_TV_CONFIG, vars(AppConfig())
+ ):
+ # Check source list, remove TV inputs, and verify that the integration is
+ # using the default APPS list
+ sources = hass.states.get(ENTITY_ID).attributes["source_list"]
+ apps = list(set(sources) - set(INPUT_LIST))
+ assert len(apps) == len(APPS)
+
+ with patch(
+ "homeassistant.components.vizio.gen_apps_list_from_url",
+ return_value=APP_LIST,
+ ):
+ async_fire_time_changed(hass, dt_util.now() + timedelta(days=2))
+ await hass.async_block_till_done()
+ # Check source list, remove TV inputs, and verify that the integration is
+ # now using the APP_LIST list
+ sources = hass.states.get(ENTITY_ID).attributes["source_list"]
+ apps = list(set(sources) - set(INPUT_LIST))
+ assert len(apps) == len(APP_LIST)
diff --git a/tests/components/volumio/test_config_flow.py b/tests/components/volumio/test_config_flow.py
index a7ed4773142..a80d527b3f8 100644
--- a/tests/components/volumio/test_config_flow.py
+++ b/tests/components/volumio/test_config_flow.py
@@ -43,10 +43,12 @@ async def test_form(hass):
), patch(
"homeassistant.components.volumio.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.volumio.async_setup_entry", return_value=True,
+ "homeassistant.components.volumio.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], TEST_CONNECTION,
+ result["flow_id"],
+ TEST_CONNECTION,
)
assert result2["type"] == "create_entry"
@@ -80,11 +82,14 @@ async def test_form_updates_unique_id(hass):
"homeassistant.components.volumio.config_flow.Volumio.get_system_info",
return_value=TEST_SYSTEM_INFO,
), patch("homeassistant.components.volumio.async_setup", return_value=True), patch(
- "homeassistant.components.volumio.async_setup_entry", return_value=True,
+ "homeassistant.components.volumio.async_setup_entry",
+ return_value=True,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], TEST_CONNECTION,
+ result["flow_id"],
+ TEST_CONNECTION,
)
+ await hass.async_block_till_done()
assert result2["type"] == "abort"
assert result2["reason"] == "already_configured"
@@ -106,10 +111,12 @@ async def test_empty_system_info(hass):
), patch(
"homeassistant.components.volumio.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.volumio.async_setup_entry", return_value=True,
+ "homeassistant.components.volumio.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], TEST_CONNECTION,
+ result["flow_id"],
+ TEST_CONNECTION,
)
assert result2["type"] == "create_entry"
@@ -137,7 +144,8 @@ async def test_form_cannot_connect(hass):
side_effect=CannotConnectError,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], TEST_CONNECTION,
+ result["flow_id"],
+ TEST_CONNECTION,
)
assert result2["type"] == "form"
@@ -155,7 +163,8 @@ async def test_form_exception(hass):
side_effect=Exception,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], TEST_CONNECTION,
+ result["flow_id"],
+ TEST_CONNECTION,
)
assert result2["type"] == "form"
@@ -175,10 +184,12 @@ async def test_discovery(hass):
), patch(
"homeassistant.components.volumio.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.volumio.async_setup_entry", return_value=True,
+ "homeassistant.components.volumio.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={},
+ result["flow_id"],
+ user_input={},
)
assert result2["type"] == "create_entry"
@@ -205,7 +216,8 @@ async def test_discovery_cannot_connect(hass):
side_effect=CannotConnectError,
):
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={},
+ result["flow_id"],
+ user_input={},
)
assert result2["type"] == "abort"
@@ -238,15 +250,25 @@ async def test_discovery_updates_unique_id(hass):
"name": "dummy",
"id": TEST_DISCOVERY_RESULT["id"],
},
+ state=config_entries.ENTRY_STATE_SETUP_RETRY,
)
entry.add_to_hass(hass)
- result = await hass.config_entries.flow.async_init(
- DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY
- )
+ with patch(
+ "homeassistant.components.volumio.async_setup", return_value=True
+ ) as mock_setup, patch(
+ "homeassistant.components.volumio.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": "zeroconf"}, data=TEST_DISCOVERY
+ )
+ await hass.async_block_till_done()
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
assert entry.data == TEST_DISCOVERY_RESULT
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
diff --git a/tests/components/wake_on_lan/test_switch.py b/tests/components/wake_on_lan/test_switch.py
index b6747062ce5..4eae15911c2 100644
--- a/tests/components/wake_on_lan/test_switch.py
+++ b/tests/components/wake_on_lan/test_switch.py
@@ -101,16 +101,6 @@ class TestWolSwitch(unittest.TestCase):
state = self.hass.states.get("switch.wake_on_lan")
assert STATE_ON == state.state
- @patch("wakeonlan.send_magic_packet", new=send_magic_packet)
- @patch("subprocess.call", new=call)
- def test_minimal_config(self):
- """Test with minimal config."""
- assert setup_component(
- self.hass,
- switch.DOMAIN,
- {"switch": {"platform": "wake_on_lan", "mac": "00-01-02-03-04-05"}},
- )
-
@patch("wakeonlan.send_magic_packet", new=send_magic_packet)
@patch("subprocess.call", new=call)
def test_broadcast_config_ip_and_port(self):
diff --git a/tests/components/webhook/test_init.py b/tests/components/webhook/test_init.py
index 9051b6325bf..44c45c1e9d8 100644
--- a/tests/components/webhook/test_init.py
+++ b/tests/components/webhook/test_init.py
@@ -37,7 +37,8 @@ async def test_unregistering_webhook(hass, mock_client):
async def test_generate_webhook_url(hass):
"""Test we generate a webhook url correctly."""
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com"},
+ hass,
+ {"external_url": "https://example.com"},
)
url = hass.components.webhook.async_generate_url("some_id")
diff --git a/tests/components/automation/test_webhook.py b/tests/components/webhook/test_trigger.py
similarity index 100%
rename from tests/components/automation/test_webhook.py
rename to tests/components/webhook/test_trigger.py
diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py
index 3b213303281..1b85daa1922 100644
--- a/tests/components/webostv/test_media_player.py
+++ b/tests/components/webostv/test_media_player.py
@@ -50,7 +50,9 @@ def client_fixture():
async def setup_webostv(hass):
"""Initialize webostv and media_player for tests."""
assert await async_setup_component(
- hass, DOMAIN, {DOMAIN: {CONF_HOST: "fake", CONF_NAME: NAME}},
+ hass,
+ DOMAIN,
+ {DOMAIN: {CONF_HOST: "fake", CONF_NAME: NAME}},
)
await hass.async_block_till_done()
diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py
index 57724fb54c6..1b9eea86018 100644
--- a/tests/components/websocket_api/test_commands.py
+++ b/tests/components/websocket_api/test_commands.py
@@ -8,12 +8,13 @@ from homeassistant.components.websocket_api.auth import (
TYPE_AUTH_REQUIRED,
)
from homeassistant.components.websocket_api.const import URL
-from homeassistant.core import callback
+from homeassistant.core import Context, callback
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import entity
from homeassistant.loader import async_get_integration
from homeassistant.setup import async_setup_component
-from tests.common import async_mock_service
+from tests.common import MockEntity, MockEntityPlatform, async_mock_service
async def test_call_service(hass, websocket_client):
@@ -419,29 +420,33 @@ async def test_render_template_renders_template(
assert msg["id"] == 5
assert msg["type"] == "event"
event = msg["event"]
- assert event == {"result": "State is: on"}
+ assert event == {
+ "result": "State is: on",
+ "listeners": {"all": False, "domains": [], "entities": ["light.test"]},
+ }
hass.states.async_set("light.test", "off")
msg = await websocket_client.receive_json()
assert msg["id"] == 5
assert msg["type"] == "event"
event = msg["event"]
- assert event == {"result": "State is: off"}
+ assert event == {
+ "result": "State is: off",
+ "listeners": {"all": False, "domains": [], "entities": ["light.test"]},
+ }
-async def test_render_template_with_manual_entity_ids(
+async def test_render_template_manual_entity_ids_no_longer_needed(
hass, websocket_client, hass_admin_user
):
"""Test that updates to specified entity ids cause a template rerender."""
hass.states.async_set("light.test", "on")
- hass.states.async_set("light.test2", "on")
await websocket_client.send_json(
{
"id": 5,
"type": "render_template",
"template": "State is: {{ states('light.test') }}",
- "entity_ids": ["light.test2"],
}
)
@@ -454,14 +459,46 @@ async def test_render_template_with_manual_entity_ids(
assert msg["id"] == 5
assert msg["type"] == "event"
event = msg["event"]
- assert event == {"result": "State is: on"}
+ assert event == {
+ "result": "State is: on",
+ "listeners": {"all": False, "domains": [], "entities": ["light.test"]},
+ }
- hass.states.async_set("light.test2", "off")
+ hass.states.async_set("light.test", "off")
msg = await websocket_client.receive_json()
assert msg["id"] == 5
assert msg["type"] == "event"
event = msg["event"]
- assert event == {"result": "State is: on"}
+ assert event == {
+ "result": "State is: off",
+ "listeners": {"all": False, "domains": [], "entities": ["light.test"]},
+ }
+
+
+async def test_render_template_with_error(
+ hass, websocket_client, hass_admin_user, caplog
+):
+ """Test a template with an error."""
+ await websocket_client.send_json(
+ {"id": 5, "type": "render_template", "template": "{{ my_unknown_var() + 1 }}"}
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == 5
+ assert msg["type"] == const.TYPE_RESULT
+ assert msg["success"]
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == 5
+ assert msg["type"] == "event"
+ event = msg["event"]
+ assert event == {
+ "result": None,
+ "listeners": {"all": True, "domains": [], "entities": []},
+ }
+
+ assert "my_unknown_var" in caplog.text
+ assert "TemplateError" in caplog.text
async def test_render_template_returns_with_match_all(
@@ -519,3 +556,194 @@ async def test_manifest_get(hass, websocket_client):
assert msg["type"] == const.TYPE_RESULT
assert not msg["success"]
assert msg["error"]["code"] == "not_found"
+
+
+async def test_entity_source_admin(hass, websocket_client, hass_admin_user):
+ """Check that we fetch sources correctly."""
+ platform = MockEntityPlatform(hass)
+
+ await platform.async_add_entities(
+ [MockEntity(name="Entity 1"), MockEntity(name="Entity 2")]
+ )
+
+ # Fetch all
+ await websocket_client.send_json({"id": 6, "type": "entity/source"})
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == 6
+ assert msg["type"] == const.TYPE_RESULT
+ assert msg["success"]
+ assert msg["result"] == {
+ "test_domain.entity_1": {
+ "source": entity.SOURCE_PLATFORM_CONFIG,
+ "domain": "test_platform",
+ },
+ "test_domain.entity_2": {
+ "source": entity.SOURCE_PLATFORM_CONFIG,
+ "domain": "test_platform",
+ },
+ }
+
+ # Fetch one
+ await websocket_client.send_json(
+ {"id": 7, "type": "entity/source", "entity_id": ["test_domain.entity_2"]}
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == 7
+ assert msg["type"] == const.TYPE_RESULT
+ assert msg["success"]
+ assert msg["result"] == {
+ "test_domain.entity_2": {
+ "source": entity.SOURCE_PLATFORM_CONFIG,
+ "domain": "test_platform",
+ },
+ }
+
+ # Fetch two
+ await websocket_client.send_json(
+ {
+ "id": 8,
+ "type": "entity/source",
+ "entity_id": ["test_domain.entity_2", "test_domain.entity_1"],
+ }
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == 8
+ assert msg["type"] == const.TYPE_RESULT
+ assert msg["success"]
+ assert msg["result"] == {
+ "test_domain.entity_1": {
+ "source": entity.SOURCE_PLATFORM_CONFIG,
+ "domain": "test_platform",
+ },
+ "test_domain.entity_2": {
+ "source": entity.SOURCE_PLATFORM_CONFIG,
+ "domain": "test_platform",
+ },
+ }
+
+ # Fetch non existing
+ await websocket_client.send_json(
+ {
+ "id": 9,
+ "type": "entity/source",
+ "entity_id": ["test_domain.entity_2", "test_domain.non_existing"],
+ }
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == 9
+ assert msg["type"] == const.TYPE_RESULT
+ assert not msg["success"]
+ assert msg["error"]["code"] == const.ERR_NOT_FOUND
+
+ # Mock policy
+ hass_admin_user.groups = []
+ hass_admin_user.mock_policy(
+ {"entities": {"entity_ids": {"test_domain.entity_2": True}}}
+ )
+
+ # Fetch all
+ await websocket_client.send_json({"id": 10, "type": "entity/source"})
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == 10
+ assert msg["type"] == const.TYPE_RESULT
+ assert msg["success"]
+ assert msg["result"] == {
+ "test_domain.entity_2": {
+ "source": entity.SOURCE_PLATFORM_CONFIG,
+ "domain": "test_platform",
+ },
+ }
+
+ # Fetch unauthorized
+ await websocket_client.send_json(
+ {"id": 11, "type": "entity/source", "entity_id": ["test_domain.entity_1"]}
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == 11
+ assert msg["type"] == const.TYPE_RESULT
+ assert not msg["success"]
+ assert msg["error"]["code"] == const.ERR_UNAUTHORIZED
+
+
+async def test_subscribe_trigger(hass, websocket_client):
+ """Test subscribing to a trigger."""
+ init_count = sum(hass.bus.async_listeners().values())
+
+ await websocket_client.send_json(
+ {
+ "id": 5,
+ "type": "subscribe_trigger",
+ "trigger": {"platform": "event", "event_type": "test_event"},
+ "variables": {"hello": "world"},
+ }
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == 5
+ assert msg["type"] == const.TYPE_RESULT
+ assert msg["success"]
+
+ # Verify we have a new listener
+ assert sum(hass.bus.async_listeners().values()) == init_count + 1
+
+ context = Context()
+
+ hass.bus.async_fire("ignore_event")
+ hass.bus.async_fire("test_event", {"hello": "world"}, context=context)
+ hass.bus.async_fire("ignore_event")
+
+ with timeout(3):
+ msg = await websocket_client.receive_json()
+
+ assert msg["id"] == 5
+ assert msg["type"] == "event"
+ assert msg["event"]["context"]["id"] == context.id
+ assert msg["event"]["variables"]["trigger"]["platform"] == "event"
+
+ event = msg["event"]["variables"]["trigger"]["event"]
+
+ assert event["event_type"] == "test_event"
+ assert event["data"] == {"hello": "world"}
+ assert event["origin"] == "LOCAL"
+
+ await websocket_client.send_json(
+ {"id": 6, "type": "unsubscribe_events", "subscription": 5}
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == 6
+ assert msg["type"] == const.TYPE_RESULT
+ assert msg["success"]
+
+ # Check our listener got unsubscribed
+ assert sum(hass.bus.async_listeners().values()) == init_count
+
+
+async def test_test_condition(hass, websocket_client):
+ """Test testing a condition."""
+ hass.states.async_set("hello.world", "paulus")
+
+ await websocket_client.send_json(
+ {
+ "id": 5,
+ "type": "test_condition",
+ "condition": {
+ "condition": "state",
+ "entity_id": "hello.world",
+ "state": "paulus",
+ },
+ "variables": {"hello": "world"},
+ }
+ )
+
+ msg = await websocket_client.receive_json()
+ assert msg["id"] == 5
+ assert msg["type"] == const.TYPE_RESULT
+ assert msg["success"]
+ assert msg["result"]["result"] is True
diff --git a/tests/components/wiffi/test_config_flow.py b/tests/components/wiffi/test_config_flow.py
index 87e119d2c6a..cd129d112bc 100644
--- a/tests/components/wiffi/test_config_flow.py
+++ b/tests/components/wiffi/test_config_flow.py
@@ -82,7 +82,8 @@ async def test_form(hass, dummy_tcp_server):
assert result["step_id"] == config_entries.SOURCE_USER
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=MOCK_CONFIG,
+ result["flow_id"],
+ user_input=MOCK_CONFIG,
)
assert result2["type"] == RESULT_TYPE_CREATE_ENTRY
@@ -94,7 +95,8 @@ async def test_form_addr_in_use(hass, addr_in_use):
)
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=MOCK_CONFIG,
+ result["flow_id"],
+ user_input=MOCK_CONFIG,
)
assert result2["type"] == RESULT_TYPE_ABORT
assert result2["reason"] == "addr_in_use"
@@ -107,7 +109,8 @@ async def test_form_start_server_failed(hass, start_server_failed):
)
result2 = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input=MOCK_CONFIG,
+ result["flow_id"],
+ user_input=MOCK_CONFIG,
)
assert result2["type"] == RESULT_TYPE_ABORT
assert result2["reason"] == "start_server_failed"
diff --git a/tests/components/wilight/__init__.py b/tests/components/wilight/__init__.py
new file mode 100644
index 00000000000..e1c31345235
--- /dev/null
+++ b/tests/components/wilight/__init__.py
@@ -0,0 +1,85 @@
+"""Tests for the WiLight component."""
+from homeassistant.components.ssdp import (
+ ATTR_SSDP_LOCATION,
+ ATTR_UPNP_MANUFACTURER,
+ ATTR_UPNP_MODEL_NAME,
+ ATTR_UPNP_MODEL_NUMBER,
+ ATTR_UPNP_SERIAL,
+)
+from homeassistant.components.wilight.config_flow import (
+ CONF_MODEL_NAME,
+ CONF_SERIAL_NUMBER,
+)
+from homeassistant.components.wilight.const import DOMAIN
+from homeassistant.const import CONF_HOST
+from homeassistant.helpers.typing import HomeAssistantType
+
+from tests.common import MockConfigEntry
+
+HOST = "127.0.0.1"
+WILIGHT_ID = "000000000099"
+SSDP_LOCATION = "http://127.0.0.1/"
+UPNP_MANUFACTURER = "All Automacao Ltda"
+UPNP_MODEL_NAME_P_B = "WiLight 0102001800010009-10010010"
+UPNP_MODEL_NAME_DIMMER = "WiLight 0100001700020009-10010010"
+UPNP_MODEL_NAME_COLOR = "WiLight 0107001800020009-11010"
+UPNP_MODEL_NAME_LIGHT_FAN = "WiLight 0104001800010009-10"
+UPNP_MODEL_NUMBER = "123456789012345678901234567890123456"
+UPNP_SERIAL = "000000000099"
+UPNP_MAC_ADDRESS = "5C:CF:7F:8B:CA:56"
+UPNP_MANUFACTURER_NOT_WILIGHT = "Test"
+CONF_COMPONENTS = "components"
+
+MOCK_SSDP_DISCOVERY_INFO_P_B = {
+ ATTR_SSDP_LOCATION: SSDP_LOCATION,
+ ATTR_UPNP_MANUFACTURER: UPNP_MANUFACTURER,
+ ATTR_UPNP_MODEL_NAME: UPNP_MODEL_NAME_P_B,
+ ATTR_UPNP_MODEL_NUMBER: UPNP_MODEL_NUMBER,
+ ATTR_UPNP_SERIAL: UPNP_SERIAL,
+}
+
+MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTORER = {
+ ATTR_SSDP_LOCATION: SSDP_LOCATION,
+ ATTR_UPNP_MANUFACTURER: UPNP_MANUFACTURER_NOT_WILIGHT,
+ ATTR_UPNP_MODEL_NAME: UPNP_MODEL_NAME_P_B,
+ ATTR_UPNP_MODEL_NUMBER: UPNP_MODEL_NUMBER,
+ ATTR_UPNP_SERIAL: ATTR_UPNP_SERIAL,
+}
+
+MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTORER = {
+ ATTR_SSDP_LOCATION: SSDP_LOCATION,
+ ATTR_UPNP_MODEL_NAME: UPNP_MODEL_NAME_P_B,
+ ATTR_UPNP_MODEL_NUMBER: UPNP_MODEL_NUMBER,
+ ATTR_UPNP_SERIAL: ATTR_UPNP_SERIAL,
+}
+
+MOCK_SSDP_DISCOVERY_INFO_LIGHT_FAN = {
+ ATTR_SSDP_LOCATION: SSDP_LOCATION,
+ ATTR_UPNP_MANUFACTURER: UPNP_MANUFACTURER,
+ ATTR_UPNP_MODEL_NAME: UPNP_MODEL_NAME_LIGHT_FAN,
+ ATTR_UPNP_MODEL_NUMBER: UPNP_MODEL_NUMBER,
+ ATTR_UPNP_SERIAL: ATTR_UPNP_SERIAL,
+}
+
+
+async def setup_integration(
+ hass: HomeAssistantType,
+) -> MockConfigEntry:
+ """Mock ConfigEntry in Home Assistant."""
+
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id=WILIGHT_ID,
+ data={
+ CONF_HOST: HOST,
+ CONF_SERIAL_NUMBER: UPNP_SERIAL,
+ CONF_MODEL_NAME: UPNP_MODEL_NAME_P_B,
+ },
+ )
+
+ entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ return entry
diff --git a/tests/components/wilight/test_config_flow.py b/tests/components/wilight/test_config_flow.py
new file mode 100644
index 00000000000..7ca6b3241ff
--- /dev/null
+++ b/tests/components/wilight/test_config_flow.py
@@ -0,0 +1,160 @@
+"""Test the WiLight config flow."""
+from asynctest import patch
+import pytest
+
+from homeassistant.components.wilight.config_flow import (
+ CONF_MODEL_NAME,
+ CONF_SERIAL_NUMBER,
+)
+from homeassistant.components.wilight.const import DOMAIN
+from homeassistant.config_entries import SOURCE_SSDP
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_SOURCE
+from homeassistant.data_entry_flow import (
+ RESULT_TYPE_ABORT,
+ RESULT_TYPE_CREATE_ENTRY,
+ RESULT_TYPE_FORM,
+)
+from homeassistant.helpers.typing import HomeAssistantType
+
+from tests.common import MockConfigEntry
+from tests.components.wilight import (
+ CONF_COMPONENTS,
+ HOST,
+ MOCK_SSDP_DISCOVERY_INFO_LIGHT_FAN,
+ MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTORER,
+ MOCK_SSDP_DISCOVERY_INFO_P_B,
+ MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTORER,
+ UPNP_MODEL_NAME_P_B,
+ UPNP_SERIAL,
+ WILIGHT_ID,
+)
+
+
+@pytest.fixture(name="dummy_get_components_from_model_clear")
+def mock_dummy_get_components_from_model():
+ """Mock a clear components list."""
+ components = []
+ with patch(
+ "pywilight.get_components_from_model",
+ return_value=components,
+ ):
+ yield components
+
+
+async def test_show_ssdp_form(hass: HomeAssistantType) -> None:
+ """Test that the ssdp confirmation form is served."""
+
+ discovery_info = MOCK_SSDP_DISCOVERY_INFO_P_B.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info
+ )
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["step_id"] == "confirm"
+ assert result["description_placeholders"] == {
+ CONF_NAME: f"WL{WILIGHT_ID}",
+ CONF_COMPONENTS: "light",
+ }
+
+
+async def test_ssdp_not_wilight_abort_1(hass: HomeAssistantType) -> None:
+ """Test that the ssdp aborts not_wilight."""
+
+ discovery_info = MOCK_SSDP_DISCOVERY_INFO_WRONG_MANUFACTORER.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "not_wilight_device"
+
+
+async def test_ssdp_not_wilight_abort_2(hass: HomeAssistantType) -> None:
+ """Test that the ssdp aborts not_wilight."""
+
+ discovery_info = MOCK_SSDP_DISCOVERY_INFO_MISSING_MANUFACTORER.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "not_wilight_device"
+
+
+async def test_ssdp_not_wilight_abort_3(
+ hass: HomeAssistantType, dummy_get_components_from_model_clear
+) -> None:
+ """Test that the ssdp aborts not_wilight."""
+
+ discovery_info = MOCK_SSDP_DISCOVERY_INFO_P_B.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "not_wilight_device"
+
+
+async def test_ssdp_not_supported_abort(hass: HomeAssistantType) -> None:
+ """Test that the ssdp aborts not_supported."""
+
+ discovery_info = MOCK_SSDP_DISCOVERY_INFO_LIGHT_FAN.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "not_supported_device"
+
+
+async def test_ssdp_device_exists_abort(hass: HomeAssistantType) -> None:
+ """Test abort SSDP flow if WiLight already configured."""
+ entry = MockConfigEntry(
+ domain=DOMAIN,
+ unique_id=WILIGHT_ID,
+ data={
+ CONF_HOST: HOST,
+ CONF_SERIAL_NUMBER: UPNP_SERIAL,
+ CONF_MODEL_NAME: UPNP_MODEL_NAME_P_B,
+ },
+ )
+
+ entry.add_to_hass(hass)
+
+ discovery_info = MOCK_SSDP_DISCOVERY_INFO_P_B.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_SSDP},
+ data=discovery_info,
+ )
+
+ assert result["type"] == RESULT_TYPE_ABORT
+ assert result["reason"] == "already_configured"
+
+
+async def test_full_ssdp_flow_implementation(hass: HomeAssistantType) -> None:
+ """Test the full SSDP flow from start to finish."""
+
+ discovery_info = MOCK_SSDP_DISCOVERY_INFO_P_B.copy()
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={CONF_SOURCE: SOURCE_SSDP}, data=discovery_info
+ )
+
+ assert result["type"] == RESULT_TYPE_FORM
+ assert result["step_id"] == "confirm"
+ assert result["description_placeholders"] == {
+ CONF_NAME: f"WL{WILIGHT_ID}",
+ "components": "light",
+ }
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={}
+ )
+
+ assert result["type"] == RESULT_TYPE_CREATE_ENTRY
+ assert result["title"] == f"WL{WILIGHT_ID}"
+
+ assert result["data"]
+ assert result["data"][CONF_HOST] == HOST
+ assert result["data"][CONF_SERIAL_NUMBER] == UPNP_SERIAL
+ assert result["data"][CONF_MODEL_NAME] == UPNP_MODEL_NAME_P_B
diff --git a/tests/components/wilight/test_init.py b/tests/components/wilight/test_init.py
new file mode 100644
index 00000000000..01ed57fdcd1
--- /dev/null
+++ b/tests/components/wilight/test_init.py
@@ -0,0 +1,66 @@
+"""Tests for the WiLight integration."""
+from asynctest import patch
+import pytest
+import pywilight
+
+from homeassistant.components.wilight.const import DOMAIN
+from homeassistant.config_entries import (
+ ENTRY_STATE_LOADED,
+ ENTRY_STATE_NOT_LOADED,
+ ENTRY_STATE_SETUP_RETRY,
+)
+from homeassistant.helpers.typing import HomeAssistantType
+
+from tests.components.wilight import (
+ HOST,
+ UPNP_MAC_ADDRESS,
+ UPNP_MODEL_NAME_P_B,
+ UPNP_MODEL_NUMBER,
+ UPNP_SERIAL,
+ setup_integration,
+)
+
+
+@pytest.fixture(name="dummy_device_from_host")
+def mock_dummy_device_from_host():
+ """Mock a valid api_devce."""
+
+ device = pywilight.wilight_from_discovery(
+ f"http://{HOST}:45995/wilight.xml",
+ UPNP_MAC_ADDRESS,
+ UPNP_MODEL_NAME_P_B,
+ UPNP_SERIAL,
+ UPNP_MODEL_NUMBER,
+ )
+
+ device.set_dummy(True)
+
+ with patch(
+ "pywilight.device_from_host",
+ return_value=device,
+ ):
+ yield device
+
+
+async def test_config_entry_not_ready(hass: HomeAssistantType) -> None:
+ """Test the WiLight configuration entry not ready."""
+ entry = await setup_integration(hass)
+
+ assert entry.state == ENTRY_STATE_SETUP_RETRY
+
+
+async def test_unload_config_entry(
+ hass: HomeAssistantType, dummy_device_from_host
+) -> None:
+ """Test the WiLight configuration entry unloading."""
+ entry = await setup_integration(hass)
+
+ 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()
+
+ if DOMAIN in hass.data:
+ assert entry.entry_id not in hass.data[DOMAIN]
+ assert entry.state == ENTRY_STATE_NOT_LOADED
diff --git a/tests/components/wilight/test_light.py b/tests/components/wilight/test_light.py
new file mode 100644
index 00000000000..4d4a32604ad
--- /dev/null
+++ b/tests/components/wilight/test_light.py
@@ -0,0 +1,374 @@
+"""Tests for the WiLight integration."""
+from asynctest import patch
+import pytest
+import pywilight
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS,
+ ATTR_HS_COLOR,
+ DOMAIN as LIGHT_DOMAIN,
+)
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ SERVICE_TURN_OFF,
+ SERVICE_TURN_ON,
+ STATE_OFF,
+ STATE_ON,
+)
+from homeassistant.helpers.typing import HomeAssistantType
+
+from tests.components.wilight import (
+ HOST,
+ UPNP_MAC_ADDRESS,
+ UPNP_MODEL_NAME_COLOR,
+ UPNP_MODEL_NAME_DIMMER,
+ UPNP_MODEL_NAME_LIGHT_FAN,
+ UPNP_MODEL_NAME_P_B,
+ UPNP_MODEL_NUMBER,
+ UPNP_SERIAL,
+ WILIGHT_ID,
+ setup_integration,
+)
+
+
+@pytest.fixture(name="dummy_get_components_from_model_light")
+def mock_dummy_get_components_from_model_light():
+ """Mock a components list with light."""
+ components = ["light"]
+ with patch(
+ "pywilight.get_components_from_model",
+ return_value=components,
+ ):
+ yield components
+
+
+@pytest.fixture(name="dummy_device_from_host_light_fan")
+def mock_dummy_device_from_host_light_fan():
+ """Mock a valid api_devce."""
+
+ device = pywilight.wilight_from_discovery(
+ f"http://{HOST}:45995/wilight.xml",
+ UPNP_MAC_ADDRESS,
+ UPNP_MODEL_NAME_LIGHT_FAN,
+ UPNP_SERIAL,
+ UPNP_MODEL_NUMBER,
+ )
+
+ device.set_dummy(True)
+
+ with patch(
+ "pywilight.device_from_host",
+ return_value=device,
+ ):
+ yield device
+
+
+@pytest.fixture(name="dummy_device_from_host_pb")
+def mock_dummy_device_from_host_pb():
+ """Mock a valid api_devce."""
+
+ device = pywilight.wilight_from_discovery(
+ f"http://{HOST}:45995/wilight.xml",
+ UPNP_MAC_ADDRESS,
+ UPNP_MODEL_NAME_P_B,
+ UPNP_SERIAL,
+ UPNP_MODEL_NUMBER,
+ )
+
+ device.set_dummy(True)
+
+ with patch(
+ "pywilight.device_from_host",
+ return_value=device,
+ ):
+ yield device
+
+
+@pytest.fixture(name="dummy_device_from_host_dimmer")
+def mock_dummy_device_from_host_dimmer():
+ """Mock a valid api_devce."""
+
+ device = pywilight.wilight_from_discovery(
+ f"http://{HOST}:45995/wilight.xml",
+ UPNP_MAC_ADDRESS,
+ UPNP_MODEL_NAME_DIMMER,
+ UPNP_SERIAL,
+ UPNP_MODEL_NUMBER,
+ )
+
+ device.set_dummy(True)
+
+ with patch(
+ "pywilight.device_from_host",
+ return_value=device,
+ ):
+ yield device
+
+
+@pytest.fixture(name="dummy_device_from_host_color")
+def mock_dummy_device_from_host_color():
+ """Mock a valid api_devce."""
+
+ device = pywilight.wilight_from_discovery(
+ f"http://{HOST}:45995/wilight.xml",
+ UPNP_MAC_ADDRESS,
+ UPNP_MODEL_NAME_COLOR,
+ UPNP_SERIAL,
+ UPNP_MODEL_NUMBER,
+ )
+
+ device.set_dummy(True)
+
+ with patch(
+ "pywilight.device_from_host",
+ return_value=device,
+ ):
+ yield device
+
+
+async def test_loading_light(
+ hass: HomeAssistantType,
+ dummy_device_from_host_light_fan,
+ dummy_get_components_from_model_light,
+) -> None:
+ """Test the WiLight configuration entry loading."""
+
+ # Using light_fan and removind fan from get_components_from_model
+ # to test light.py line 28
+ entry = await setup_integration(hass)
+ assert entry
+ assert entry.unique_id == WILIGHT_ID
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ # First segment of the strip
+ state = hass.states.get("light.wl000000000099_1")
+ assert state
+ assert state.state == STATE_OFF
+
+ entry = entity_registry.async_get("light.wl000000000099_1")
+ assert entry
+ assert entry.unique_id == "WL000000000099_0"
+
+
+async def test_on_off_light_state(
+ hass: HomeAssistantType, dummy_device_from_host_pb
+) -> None:
+ """Test the change of state of the light switches."""
+ await setup_integration(hass)
+
+ # Turn on
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "light.wl000000000099_1"},
+ blocking=True,
+ )
+
+ await hass.async_block_till_done()
+ state = hass.states.get("light.wl000000000099_1")
+ assert state
+ assert state.state == STATE_ON
+
+ # Turn off
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "light.wl000000000099_1"},
+ blocking=True,
+ )
+
+ await hass.async_block_till_done()
+ state = hass.states.get("light.wl000000000099_1")
+ assert state
+ assert state.state == STATE_OFF
+
+
+async def test_dimmer_light_state(
+ hass: HomeAssistantType, dummy_device_from_host_dimmer
+) -> None:
+ """Test the change of state of the light switches."""
+ await setup_integration(hass)
+
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_BRIGHTNESS: 42, ATTR_ENTITY_ID: "light.wl000000000099_1"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get("light.wl000000000099_1")
+ assert state
+ assert state.state == STATE_ON
+ assert state.attributes.get(ATTR_BRIGHTNESS) == 42
+
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_BRIGHTNESS: 0, ATTR_ENTITY_ID: "light.wl000000000099_1"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get("light.wl000000000099_1")
+ assert state
+ assert state.state == STATE_OFF
+
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_BRIGHTNESS: 100, ATTR_ENTITY_ID: "light.wl000000000099_1"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get("light.wl000000000099_1")
+ assert state
+ assert state.state == STATE_ON
+ assert state.attributes.get(ATTR_BRIGHTNESS) == 100
+
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "light.wl000000000099_1"},
+ blocking=True,
+ )
+
+ await hass.async_block_till_done()
+ state = hass.states.get("light.wl000000000099_1")
+ assert state
+ assert state.state == STATE_OFF
+
+ # Turn on
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "light.wl000000000099_1"},
+ blocking=True,
+ )
+
+ await hass.async_block_till_done()
+ state = hass.states.get("light.wl000000000099_1")
+ assert state
+ assert state.state == STATE_ON
+
+
+async def test_color_light_state(
+ hass: HomeAssistantType, dummy_device_from_host_color
+) -> None:
+ """Test the change of state of the light switches."""
+ await setup_integration(hass)
+
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {
+ ATTR_BRIGHTNESS: 42,
+ ATTR_HS_COLOR: [0, 100],
+ ATTR_ENTITY_ID: "light.wl000000000099_1",
+ },
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get("light.wl000000000099_1")
+ assert state
+ assert state.state == STATE_ON
+ assert state.attributes.get(ATTR_BRIGHTNESS) == 42
+ state_color = [
+ round(state.attributes.get(ATTR_HS_COLOR)[0]),
+ round(state.attributes.get(ATTR_HS_COLOR)[1]),
+ ]
+ assert state_color == [0, 100]
+
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_BRIGHTNESS: 0, ATTR_ENTITY_ID: "light.wl000000000099_1"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get("light.wl000000000099_1")
+ assert state
+ assert state.state == STATE_OFF
+
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {
+ ATTR_BRIGHTNESS: 100,
+ ATTR_HS_COLOR: [270, 50],
+ ATTR_ENTITY_ID: "light.wl000000000099_1",
+ },
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get("light.wl000000000099_1")
+ assert state
+ assert state.state == STATE_ON
+ assert state.attributes.get(ATTR_BRIGHTNESS) == 100
+ state_color = [
+ round(state.attributes.get(ATTR_HS_COLOR)[0]),
+ round(state.attributes.get(ATTR_HS_COLOR)[1]),
+ ]
+ assert state_color == [270, 50]
+
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "light.wl000000000099_1"},
+ blocking=True,
+ )
+
+ await hass.async_block_till_done()
+ state = hass.states.get("light.wl000000000099_1")
+ assert state
+ assert state.state == STATE_OFF
+
+ # Turn on
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "light.wl000000000099_1"},
+ blocking=True,
+ )
+
+ await hass.async_block_till_done()
+ state = hass.states.get("light.wl000000000099_1")
+ assert state
+ assert state.state == STATE_ON
+
+ # Hue = 0, Saturation = 100
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_HS_COLOR: [0, 100], ATTR_ENTITY_ID: "light.wl000000000099_1"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get("light.wl000000000099_1")
+ assert state
+ assert state.state == STATE_ON
+ state_color = [
+ round(state.attributes.get(ATTR_HS_COLOR)[0]),
+ round(state.attributes.get(ATTR_HS_COLOR)[1]),
+ ]
+ assert state_color == [0, 100]
+
+ # Brightness = 60
+ await hass.services.async_call(
+ LIGHT_DOMAIN,
+ SERVICE_TURN_ON,
+ {ATTR_BRIGHTNESS: 60, ATTR_ENTITY_ID: "light.wl000000000099_1"},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get("light.wl000000000099_1")
+ assert state
+ assert state.state == STATE_ON
+ assert state.attributes.get(ATTR_BRIGHTNESS) == 60
diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py
index 3ed3b39daee..a09876868a7 100644
--- a/tests/components/withings/common.py
+++ b/tests/components/withings/common.py
@@ -252,7 +252,8 @@ class ComponentFactory:
data_manager = get_data_manager_by_user_id(self._hass, user_id)
self._aioclient_mock.clear_requests()
self._aioclient_mock.request(
- "HEAD", data_manager.webhook_config.url,
+ "HEAD",
+ data_manager.webhook_config.url,
)
return self._api_class_mock.return_value
diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py
index 8f3347c8867..3477671ea79 100644
--- a/tests/components/withings/test_binary_sensor.py
+++ b/tests/components/withings/test_binary_sensor.py
@@ -21,7 +21,9 @@ async def test_binary_sensor(
person0 = new_profile_config("person0", 0)
person1 = new_profile_config("person1", 1)
- entity_registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry()
+ entity_registry: EntityRegistry = (
+ await hass.helpers.entity_registry.async_get_registry()
+ )
await component_factory.configure_component(profile_configs=(person0, person1))
assert not await async_get_entity_id(hass, in_bed_attribute, person0.user_id)
diff --git a/tests/components/withings/test_common.py b/tests/components/withings/test_common.py
index 1e9711a71a6..2bf71ab7aa5 100644
--- a/tests/components/withings/test_common.py
+++ b/tests/components/withings/test_common.py
@@ -104,7 +104,9 @@ async def test_webhook_post(
async def test_webhook_head(
- hass: HomeAssistant, component_factory: ComponentFactory, aiohttp_client,
+ hass: HomeAssistant,
+ component_factory: ComponentFactory,
+ aiohttp_client,
) -> None:
"""Test head method on webhook view."""
person0 = new_profile_config("person0", 0)
@@ -119,7 +121,9 @@ async def test_webhook_head(
async def test_webhook_put(
- hass: HomeAssistant, component_factory: ComponentFactory, aiohttp_client,
+ hass: HomeAssistant,
+ component_factory: ComponentFactory,
+ aiohttp_client,
) -> None:
"""Test webhook callback."""
person0 = new_profile_config("person0", 0)
@@ -187,7 +191,9 @@ async def test_data_manager_webhook_subscription(
aioclient_mock.clear_requests()
aioclient_mock.request(
- "HEAD", data_manager.webhook_config.url, status=200,
+ "HEAD",
+ data_manager.webhook_config.url,
+ status=200,
)
# Test subscribing
diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py
index f47a8f95e53..2b4776c0ca5 100644
--- a/tests/components/withings/test_config_flow.py
+++ b/tests/components/withings/test_config_flow.py
@@ -65,7 +65,10 @@ async def test_config_reauth_profile(
assert result["step_id"] == "reauth"
assert result["description_placeholders"] == {const.PROFILE: "person0"}
- result = await hass.config_entries.flow.async_configure(result["flow_id"], {},)
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
# pylint: disable=protected-access
state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]})
diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py
index 3370c23e3d8..16b83a585aa 100644
--- a/tests/components/withings/test_sensor.py
+++ b/tests/components/withings/test_sensor.py
@@ -304,7 +304,9 @@ async def test_sensor_default_enabled_entities(
hass: HomeAssistant, component_factory: ComponentFactory
) -> None:
"""Test entities enabled by default."""
- entity_registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry()
+ entity_registry: EntityRegistry = (
+ await hass.helpers.entity_registry.async_get_registry()
+ )
await component_factory.configure_component(profile_configs=(PERSON0,))
@@ -345,7 +347,9 @@ async def test_all_entities(
hass: HomeAssistant, component_factory: ComponentFactory
) -> None:
"""Test all entities."""
- entity_registry: EntityRegistry = await hass.helpers.entity_registry.async_get_registry()
+ entity_registry: EntityRegistry = (
+ await hass.helpers.entity_registry.async_get_registry()
+ )
with patch(
"homeassistant.components.withings.sensor.BaseWithingsSensor.entity_registry_enabled_default"
diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py
index f5f1ec3099c..a0c5c11e7ce 100644
--- a/tests/components/wled/test_config_flow.py
+++ b/tests/components/wled/test_config_flow.py
@@ -18,7 +18,8 @@ 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},
+ config_flow.DOMAIN,
+ context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
@@ -193,7 +194,8 @@ async def test_full_user_flow_implementation(
)
result = await hass.config_entries.flow.async_init(
- config_flow.DOMAIN, context={"source": SOURCE_USER},
+ config_flow.DOMAIN,
+ context={"source": SOURCE_USER},
)
assert result["step_id"] == "user"
diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py
index 35e8ea0f7ef..66aa50cb71b 100644
--- a/tests/components/wled/test_light.py
+++ b/tests/components/wled/test_light.py
@@ -114,7 +114,9 @@ async def test_segment_change_state(
)
await hass.async_block_till_done()
light_mock.assert_called_once_with(
- on=False, segment_id=0, transition=50,
+ on=False,
+ segment_id=0,
+ transition=50,
)
with patch("wled.WLED.segment") as light_mock:
@@ -149,7 +151,9 @@ async def test_segment_change_state(
)
await hass.async_block_till_done()
light_mock.assert_called_once_with(
- color_primary=(255, 159, 70), on=True, segment_id=0,
+ color_primary=(255, 159, 70),
+ on=True,
+ segment_id=0,
)
@@ -168,7 +172,8 @@ async def test_master_change_state(
)
await hass.async_block_till_done()
light_mock.assert_called_once_with(
- on=False, transition=50,
+ on=False,
+ transition=50,
)
with patch("wled.WLED.master") as light_mock:
@@ -184,7 +189,9 @@ async def test_master_change_state(
)
await hass.async_block_till_done()
light_mock.assert_called_once_with(
- brightness=42, on=True, transition=50,
+ brightness=42,
+ on=True,
+ transition=50,
)
with patch("wled.WLED.master") as light_mock:
@@ -196,7 +203,8 @@ async def test_master_change_state(
)
await hass.async_block_till_done()
light_mock.assert_called_once_with(
- on=False, transition=50,
+ on=False,
+ transition=50,
)
with patch("wled.WLED.master") as light_mock:
@@ -212,7 +220,9 @@ async def test_master_change_state(
)
await hass.async_block_till_done()
light_mock.assert_called_once_with(
- brightness=42, on=True, transition=50,
+ brightness=42,
+ on=True,
+ transition=50,
)
@@ -231,7 +241,8 @@ async def test_dynamically_handle_segments(
# Test removal if segment went missing, including the master entity
with patch(
- "homeassistant.components.wled.WLED.update", return_value=device,
+ "homeassistant.components.wled.WLED.update",
+ return_value=device,
):
async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL)
await hass.async_block_till_done()
@@ -259,7 +270,8 @@ async def test_single_segment_behavior(
# Test absent master
with patch(
- "homeassistant.components.wled.WLED.update", return_value=device,
+ "homeassistant.components.wled.WLED.update",
+ return_value=device,
):
async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL)
await hass.async_block_till_done()
@@ -274,7 +286,8 @@ async def test_single_segment_behavior(
device.state.brightness = 100
device.state.segments[0].brightness = 255
with patch(
- "homeassistant.components.wled.WLED.update", return_value=device,
+ "homeassistant.components.wled.WLED.update",
+ return_value=device,
):
async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL)
await hass.async_block_till_done()
@@ -286,7 +299,8 @@ async def test_single_segment_behavior(
# Test segment is off when master is off
device.state.on = False
with patch(
- "homeassistant.components.wled.WLED.update", return_value=device,
+ "homeassistant.components.wled.WLED.update",
+ return_value=device,
):
async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL)
await hass.async_block_till_done()
@@ -304,7 +318,8 @@ async def test_single_segment_behavior(
)
await hass.async_block_till_done()
master_mock.assert_called_once_with(
- on=False, transition=50,
+ on=False,
+ transition=50,
)
# Test master is turned on when turning on a single segment, and segment
@@ -389,7 +404,9 @@ async def test_rgbw_light(
)
await hass.async_block_till_done()
light_mock.assert_called_once_with(
- on=True, segment_id=0, color_primary=(255, 159, 70, 139),
+ on=True,
+ segment_id=0,
+ color_primary=(255, 159, 70, 139),
)
with patch("wled.WLED.segment") as light_mock:
@@ -401,7 +418,9 @@ async def test_rgbw_light(
)
await hass.async_block_till_done()
light_mock.assert_called_once_with(
- color_primary=(255, 0, 0, 100), on=True, segment_id=0,
+ color_primary=(255, 0, 0, 100),
+ on=True,
+ segment_id=0,
)
with patch("wled.WLED.segment") as light_mock:
@@ -417,7 +436,9 @@ async def test_rgbw_light(
)
await hass.async_block_till_done()
light_mock.assert_called_once_with(
- color_primary=(0, 0, 0, 100), on=True, segment_id=0,
+ color_primary=(0, 0, 0, 100),
+ on=True,
+ segment_id=0,
)
@@ -442,7 +463,11 @@ async def test_effect_service(
)
await hass.async_block_till_done()
light_mock.assert_called_once_with(
- effect="Rainbow", intensity=200, reverse=True, segment_id=0, speed=100,
+ effect="Rainbow",
+ intensity=200,
+ reverse=True,
+ segment_id=0,
+ speed=100,
)
with patch("wled.WLED.segment") as light_mock:
@@ -454,7 +479,8 @@ async def test_effect_service(
)
await hass.async_block_till_done()
light_mock.assert_called_once_with(
- segment_id=0, effect=9,
+ segment_id=0,
+ effect=9,
)
with patch("wled.WLED.segment") as light_mock:
@@ -471,7 +497,10 @@ async def test_effect_service(
)
await hass.async_block_till_done()
light_mock.assert_called_once_with(
- intensity=200, reverse=True, segment_id=0, speed=100,
+ intensity=200,
+ reverse=True,
+ segment_id=0,
+ speed=100,
)
with patch("wled.WLED.segment") as light_mock:
@@ -488,7 +517,10 @@ async def test_effect_service(
)
await hass.async_block_till_done()
light_mock.assert_called_once_with(
- effect="Rainbow", reverse=True, segment_id=0, speed=100,
+ effect="Rainbow",
+ reverse=True,
+ segment_id=0,
+ speed=100,
)
with patch("wled.WLED.segment") as light_mock:
@@ -505,7 +537,10 @@ async def test_effect_service(
)
await hass.async_block_till_done()
light_mock.assert_called_once_with(
- effect="Rainbow", intensity=200, segment_id=0, speed=100,
+ effect="Rainbow",
+ intensity=200,
+ segment_id=0,
+ speed=100,
)
with patch("wled.WLED.segment") as light_mock:
@@ -522,7 +557,10 @@ async def test_effect_service(
)
await hass.async_block_till_done()
light_mock.assert_called_once_with(
- effect="Rainbow", intensity=200, reverse=True, segment_id=0,
+ effect="Rainbow",
+ intensity=200,
+ reverse=True,
+ segment_id=0,
)
diff --git a/tests/components/wled/test_sensor.py b/tests/components/wled/test_sensor.py
index 66fedfdd274..f4efea1b57d 100644
--- a/tests/components/wled/test_sensor.py
+++ b/tests/components/wled/test_sensor.py
@@ -3,7 +3,10 @@ from datetime import datetime
import pytest
-from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.components.sensor import (
+ DEVICE_CLASS_CURRENT,
+ DOMAIN as SENSOR_DOMAIN,
+)
from homeassistant.components.wled.const import (
ATTR_LED_COUNT,
ATTR_MAX_POWER,
@@ -12,10 +15,11 @@ from homeassistant.components.wled.const import (
SIGNAL_DBM,
)
from homeassistant.const import (
+ ATTR_DEVICE_CLASS,
ATTR_ICON,
ATTR_UNIT_OF_MEASUREMENT,
DATA_BYTES,
- UNIT_PERCENTAGE,
+ PERCENTAGE,
)
from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
@@ -94,6 +98,7 @@ async def test_sensors(
assert state.attributes.get(ATTR_LED_COUNT) == 30
assert state.attributes.get(ATTR_MAX_POWER) == 850
assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == CURRENT_MA
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_CURRENT
assert state.state == "470"
entry = registry.async_get("sensor.wled_rgb_light_estimated_current")
@@ -123,7 +128,7 @@ async def test_sensors(
state = hass.states.get("sensor.wled_rgb_light_wifi_signal")
assert state
assert state.attributes.get(ATTR_ICON) == "mdi:wifi"
- assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UNIT_PERCENTAGE
+ assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE
assert state.state == "76"
entry = registry.async_get("sensor.wled_rgb_light_wifi_signal")
diff --git a/tests/components/wolflink/test_config_flow.py b/tests/components/wolflink/test_config_flow.py
index f2074f482eb..897650b48e8 100644
--- a/tests/components/wolflink/test_config_flow.py
+++ b/tests/components/wolflink/test_config_flow.py
@@ -66,7 +66,8 @@ async def test_create_entry(hass):
)
result_create_entry = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"device_name": CONFIG[DEVICE_NAME]},
+ result["flow_id"],
+ {"device_name": CONFIG[DEVICE_NAME]},
)
assert result_create_entry["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
@@ -132,7 +133,8 @@ async def test_already_configured_error(hass):
)
result_create_entry = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"device_name": CONFIG[DEVICE_NAME]},
+ result["flow_id"],
+ {"device_name": CONFIG[DEVICE_NAME]},
)
assert result_create_entry["type"] == data_entry_flow.RESULT_TYPE_ABORT
diff --git a/tests/components/xiaomi_aqara/test_config_flow.py b/tests/components/xiaomi_aqara/test_config_flow.py
index 06fda84c934..c84758f8b63 100644
--- a/tests/components/xiaomi_aqara/test_config_flow.py
+++ b/tests/components/xiaomi_aqara/test_config_flow.py
@@ -88,7 +88,8 @@ async def test_config_flow_user_success(hass):
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE},
+ result["flow_id"],
+ {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE},
)
assert result["type"] == "form"
@@ -96,7 +97,8 @@ async def test_config_flow_user_success(hass):
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {const.CONF_KEY: TEST_KEY, CONF_NAME: TEST_NAME},
+ result["flow_id"],
+ {const.CONF_KEY: TEST_KEY, CONF_NAME: TEST_NAME},
)
assert result["type"] == "create_entry"
@@ -129,7 +131,8 @@ async def test_config_flow_user_multiple_success(hass):
return_value=mock_gateway_discovery,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE},
+ result["flow_id"],
+ {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE},
)
assert result["type"] == "form"
@@ -137,7 +140,8 @@ async def test_config_flow_user_multiple_success(hass):
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {"select_ip": TEST_HOST_2},
+ result["flow_id"],
+ {"select_ip": TEST_HOST_2},
)
assert result["type"] == "form"
@@ -145,7 +149,8 @@ async def test_config_flow_user_multiple_success(hass):
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {const.CONF_KEY: TEST_KEY, CONF_NAME: TEST_NAME},
+ result["flow_id"],
+ {const.CONF_KEY: TEST_KEY, CONF_NAME: TEST_NAME},
)
assert result["type"] == "create_entry"
@@ -172,7 +177,8 @@ async def test_config_flow_user_no_key_success(hass):
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE},
+ result["flow_id"],
+ {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE},
)
assert result["type"] == "form"
@@ -180,7 +186,8 @@ async def test_config_flow_user_no_key_success(hass):
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_NAME: TEST_NAME},
+ result["flow_id"],
+ {CONF_NAME: TEST_NAME},
)
assert result["type"] == "create_entry"
@@ -226,7 +233,8 @@ async def test_config_flow_user_host_mac_success(hass):
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_NAME: TEST_NAME},
+ result["flow_id"],
+ {CONF_NAME: TEST_NAME},
)
assert result["type"] == "create_entry"
@@ -259,7 +267,8 @@ async def test_config_flow_user_discovery_error(hass):
return_value=mock_gateway_discovery,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE},
+ result["flow_id"],
+ {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE},
)
assert result["type"] == "form"
@@ -284,7 +293,8 @@ async def test_config_flow_user_invalid_interface(hass):
return_value=mock_gateway_discovery,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE},
+ result["flow_id"],
+ {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE},
)
assert result["type"] == "form"
@@ -369,7 +379,8 @@ async def test_config_flow_user_invalid_key(hass):
return_value=mock_gateway_discovery,
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE},
+ result["flow_id"],
+ {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE},
)
assert result["type"] == "form"
@@ -377,7 +388,8 @@ async def test_config_flow_user_invalid_key(hass):
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {const.CONF_KEY: TEST_KEY, CONF_NAME: TEST_NAME},
+ result["flow_id"],
+ {const.CONF_KEY: TEST_KEY, CONF_NAME: TEST_NAME},
)
assert result["type"] == "form"
@@ -402,7 +414,8 @@ async def test_zeroconf_success(hass):
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE},
+ result["flow_id"],
+ {const.CONF_INTERFACE: config_flow.DEFAULT_INTERFACE},
)
assert result["type"] == "form"
@@ -410,7 +423,8 @@ async def test_zeroconf_success(hass):
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {const.CONF_KEY: TEST_KEY, CONF_NAME: TEST_NAME},
+ result["flow_id"],
+ {const.CONF_KEY: TEST_KEY, CONF_NAME: TEST_NAME},
)
assert result["type"] == "create_entry"
diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py
index 01ae690bc25..d620e739e18 100644
--- a/tests/components/xiaomi_miio/test_config_flow.py
+++ b/tests/components/xiaomi_miio/test_config_flow.py
@@ -50,7 +50,10 @@ async def test_config_flow_step_user_no_device(hass):
assert result["step_id"] == "user"
assert result["errors"] == {}
- result = await hass.config_entries.flow.async_configure(result["flow_id"], {},)
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
assert result["type"] == "form"
assert result["step_id"] == "user"
@@ -68,7 +71,8 @@ async def test_config_flow_step_gateway_connect_error(hass):
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {config_flow.CONF_GATEWAY: True},
+ result["flow_id"],
+ {config_flow.CONF_GATEWAY: True},
)
assert result["type"] == "form"
@@ -100,7 +104,8 @@ async def test_config_flow_gateway_success(hass):
assert result["errors"] == {}
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {config_flow.CONF_GATEWAY: True},
+ result["flow_id"],
+ {config_flow.CONF_GATEWAY: True},
)
assert result["type"] == "form"
@@ -162,7 +167,8 @@ async def test_zeroconf_gateway_success(hass):
"homeassistant.components.xiaomi_miio.async_setup_entry", return_value=True
):
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], {CONF_NAME: TEST_NAME, CONF_TOKEN: TEST_TOKEN},
+ result["flow_id"],
+ {CONF_NAME: TEST_NAME, CONF_TOKEN: TEST_TOKEN},
)
assert result["type"] == "create_entry"
diff --git a/tests/components/yandex_transport/test_yandex_transport_sensor.py b/tests/components/yandex_transport/test_yandex_transport_sensor.py
index 069d171a371..c70ebf6806c 100644
--- a/tests/components/yandex_transport/test_yandex_transport_sensor.py
+++ b/tests/components/yandex_transport/test_yandex_transport_sensor.py
@@ -24,13 +24,13 @@ def mock_requester():
yield instance
-STOP_ID = 9639579
+STOP_ID = "stop__9639579"
ROUTES = ["194", "т36", "т47", "м10"]
NAME = "test_name"
TEST_CONFIG = {
"sensor": {
"platform": "yandex_transport",
- "stop_id": 9639579,
+ "stop_id": "stop__9639579",
"routes": ROUTES,
"name": NAME,
}
diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py
index 7f1f7d7d236..a2f5b935947 100644
--- a/tests/components/yeelight/__init__.py
+++ b/tests/components/yeelight/__init__.py
@@ -9,9 +9,9 @@ from homeassistant.components.yeelight import (
DOMAIN,
NIGHTLIGHT_SWITCH_TYPE_LIGHT,
)
-from homeassistant.const import CONF_DEVICES, CONF_NAME
+from homeassistant.const import CONF_DEVICES, CONF_ID, CONF_NAME
-from tests.async_mock import MagicMock
+from tests.async_mock import MagicMock, patch
IP_ADDRESS = "192.168.1.239"
MODEL = "color"
@@ -70,6 +70,10 @@ YAML_CONFIGURATION = {
}
}
+CONFIG_ENTRY_DATA = {
+ CONF_ID: ID,
+}
+
def _mocked_bulb(cannot_connect=False):
bulb = MagicMock()
@@ -85,3 +89,12 @@ def _mocked_bulb(cannot_connect=False):
bulb.music_mode = False
return bulb
+
+
+def _patch_discovery(prefix, no_device=False):
+ def _mocked_discovery(timeout=2, interface=False):
+ if no_device:
+ return []
+ return [{"ip": IP_ADDRESS, "port": 55443, "capabilities": CAPABILITIES}]
+
+ return patch(f"{prefix}.discover_bulbs", side_effect=_mocked_discovery)
diff --git a/tests/components/yeelight/test_binary_sensor.py b/tests/components/yeelight/test_binary_sensor.py
index bf20a7ec5b0..b3281168077 100644
--- a/tests/components/yeelight/test_binary_sensor.py
+++ b/tests/components/yeelight/test_binary_sensor.py
@@ -12,7 +12,9 @@ from tests.async_mock import patch
async def test_nightlight(hass: HomeAssistant):
"""Test nightlight sensor."""
mocked_bulb = _mocked_bulb()
- with patch(f"{MODULE}.Bulb", return_value=mocked_bulb):
+ with patch(f"{MODULE}.Bulb", return_value=mocked_bulb), patch(
+ f"{MODULE}.config_flow.yeelight.Bulb", return_value=mocked_bulb
+ ):
await async_setup_component(hass, DOMAIN, YAML_CONFIGURATION)
await hass.async_block_till_done()
diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py
new file mode 100644
index 00000000000..921011a510d
--- /dev/null
+++ b/tests/components/yeelight/test_config_flow.py
@@ -0,0 +1,261 @@
+"""Test the Yeelight config flow."""
+from homeassistant import config_entries
+from homeassistant.components.yeelight import (
+ CONF_DEVICE,
+ CONF_MODE_MUSIC,
+ CONF_MODEL,
+ CONF_NIGHTLIGHT_SWITCH,
+ CONF_NIGHTLIGHT_SWITCH_TYPE,
+ CONF_SAVE_ON_CHANGE,
+ CONF_TRANSITION,
+ DEFAULT_MODE_MUSIC,
+ DEFAULT_NAME,
+ DEFAULT_NIGHTLIGHT_SWITCH,
+ DEFAULT_SAVE_ON_CHANGE,
+ DEFAULT_TRANSITION,
+ DOMAIN,
+ NIGHTLIGHT_SWITCH_TYPE_LIGHT,
+)
+from homeassistant.const import CONF_HOST, CONF_ID, CONF_NAME
+from homeassistant.core import HomeAssistant
+
+from . import (
+ ID,
+ IP_ADDRESS,
+ MODULE,
+ MODULE_CONFIG_FLOW,
+ NAME,
+ _mocked_bulb,
+ _patch_discovery,
+)
+
+from tests.async_mock import MagicMock, patch
+from tests.common import MockConfigEntry
+
+DEFAULT_CONFIG = {
+ CONF_NAME: NAME,
+ CONF_MODEL: "",
+ CONF_TRANSITION: DEFAULT_TRANSITION,
+ CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC,
+ CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE,
+ CONF_NIGHTLIGHT_SWITCH: DEFAULT_NIGHTLIGHT_SWITCH,
+}
+
+
+async def test_discovery(hass: HomeAssistant):
+ """Test setting up discovery."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+ assert not result["errors"]
+
+ with _patch_discovery(f"{MODULE_CONFIG_FLOW}.yeelight"):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
+ assert result2["type"] == "form"
+ assert result2["step_id"] == "pick_device"
+ assert not result2["errors"]
+
+ with patch(f"{MODULE}.async_setup", return_value=True) as mock_setup, patch(
+ f"{MODULE}.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result3 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_DEVICE: ID}
+ )
+
+ assert result3["type"] == "create_entry"
+ assert result3["title"] == NAME
+ assert result3["data"] == {CONF_ID: ID}
+ await hass.async_block_till_done()
+ mock_setup.assert_called_once()
+ mock_setup_entry.assert_called_once()
+
+ # ignore configured devices
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+ assert not result["errors"]
+
+ with _patch_discovery(f"{MODULE_CONFIG_FLOW}.yeelight"):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
+ assert result2["type"] == "abort"
+ assert result2["reason"] == "no_devices_found"
+
+
+async def test_discovery_no_device(hass: HomeAssistant):
+ """Test discovery without device."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ with _patch_discovery(f"{MODULE_CONFIG_FLOW}.yeelight", no_device=True):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
+
+ assert result2["type"] == "abort"
+ assert result2["reason"] == "no_devices_found"
+
+
+async def test_import(hass: HomeAssistant):
+ """Test import from yaml."""
+ config = {
+ CONF_NAME: DEFAULT_NAME,
+ CONF_HOST: IP_ADDRESS,
+ CONF_TRANSITION: DEFAULT_TRANSITION,
+ CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC,
+ CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE,
+ CONF_NIGHTLIGHT_SWITCH_TYPE: NIGHTLIGHT_SWITCH_TYPE_LIGHT,
+ }
+
+ # Cannot connect
+ mocked_bulb = _mocked_bulb(cannot_connect=True)
+ with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config
+ )
+ type(mocked_bulb).get_capabilities.assert_called_once()
+ assert result["type"] == "abort"
+ assert result["reason"] == "cannot_connect"
+
+ # Success
+ mocked_bulb = _mocked_bulb()
+ with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb), patch(
+ f"{MODULE}.async_setup", return_value=True
+ ) as mock_setup, patch(
+ f"{MODULE}.async_setup_entry",
+ return_value=True,
+ ) as mock_setup_entry:
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config
+ )
+ type(mocked_bulb).get_capabilities.assert_called_once()
+ assert result["type"] == "create_entry"
+ assert result["title"] == DEFAULT_NAME
+ assert result["data"] == {
+ CONF_NAME: DEFAULT_NAME,
+ CONF_HOST: IP_ADDRESS,
+ CONF_TRANSITION: DEFAULT_TRANSITION,
+ CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC,
+ CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE,
+ CONF_NIGHTLIGHT_SWITCH: True,
+ }
+ await hass.async_block_till_done()
+ mock_setup.assert_called_once()
+ mock_setup_entry.assert_called_once()
+
+ # Duplicate
+ mocked_bulb = _mocked_bulb()
+ with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb):
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=config
+ )
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+
+
+async def test_manual(hass: HomeAssistant):
+ """Test manually setup."""
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ assert result["type"] == "form"
+ assert result["step_id"] == "user"
+ assert not result["errors"]
+
+ # Cannot connect (timeout)
+ mocked_bulb = _mocked_bulb(cannot_connect=True)
+ with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_HOST: IP_ADDRESS}
+ )
+ assert result2["type"] == "form"
+ assert result2["step_id"] == "user"
+ assert result2["errors"] == {"base": "cannot_connect"}
+
+ # Cannot connect (error)
+ type(mocked_bulb).get_capabilities = MagicMock(side_effect=OSError)
+ with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb):
+ result3 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_HOST: IP_ADDRESS}
+ )
+ assert result3["errors"] == {"base": "cannot_connect"}
+
+ # Success
+ mocked_bulb = _mocked_bulb()
+ with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb), patch(
+ f"{MODULE}.async_setup", return_value=True
+ ), patch(
+ f"{MODULE}.async_setup_entry",
+ return_value=True,
+ ):
+ result4 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_HOST: IP_ADDRESS}
+ )
+ assert result4["type"] == "create_entry"
+ assert result4["data"] == {CONF_HOST: IP_ADDRESS}
+
+ # Duplicate
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+ mocked_bulb = _mocked_bulb()
+ with patch(f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb):
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {CONF_HOST: IP_ADDRESS}
+ )
+ assert result2["type"] == "abort"
+ assert result2["reason"] == "already_configured"
+
+
+async def test_options(hass: HomeAssistant):
+ """Test options flow."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: IP_ADDRESS})
+ config_entry.add_to_hass(hass)
+
+ mocked_bulb = _mocked_bulb()
+ with patch(f"{MODULE}.Bulb", return_value=mocked_bulb):
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ config = {
+ CONF_MODEL: "",
+ CONF_TRANSITION: DEFAULT_TRANSITION,
+ CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC,
+ CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE,
+ CONF_NIGHTLIGHT_SWITCH: DEFAULT_NIGHTLIGHT_SWITCH,
+ }
+ assert config_entry.options == {
+ CONF_NAME: "",
+ **config,
+ }
+ assert hass.states.get(f"light.{NAME}_nightlight") is None
+
+ result = await hass.config_entries.options.async_init(config_entry.entry_id)
+ assert result["type"] == "form"
+ assert result["step_id"] == "init"
+
+ config[CONF_NIGHTLIGHT_SWITCH] = True
+ with patch(f"{MODULE}.Bulb", return_value=mocked_bulb):
+ result2 = await hass.config_entries.options.async_configure(
+ result["flow_id"], config
+ )
+ await hass.async_block_till_done()
+ assert result2["type"] == "create_entry"
+ assert result2["data"] == {
+ CONF_NAME: "",
+ **config,
+ }
+ assert result2["data"] == config_entry.options
+ assert hass.states.get(f"light.{NAME}_nightlight") is not None
diff --git a/tests/components/yeelight/test_init.py b/tests/components/yeelight/test_init.py
new file mode 100644
index 00000000000..004efed8deb
--- /dev/null
+++ b/tests/components/yeelight/test_init.py
@@ -0,0 +1,69 @@
+"""Test Yeelight."""
+from homeassistant.components.yeelight import (
+ CONF_NIGHTLIGHT_SWITCH_TYPE,
+ DOMAIN,
+ NIGHTLIGHT_SWITCH_TYPE_LIGHT,
+)
+from homeassistant.const import CONF_DEVICES, CONF_NAME
+from homeassistant.core import HomeAssistant
+from homeassistant.setup import async_setup_component
+
+from . import (
+ CONFIG_ENTRY_DATA,
+ IP_ADDRESS,
+ MODULE,
+ MODULE_CONFIG_FLOW,
+ NAME,
+ _mocked_bulb,
+ _patch_discovery,
+)
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+
+async def test_setup_discovery(hass: HomeAssistant):
+ """Test setting up Yeelight by discovery."""
+ config_entry = MockConfigEntry(domain=DOMAIN, data=CONFIG_ENTRY_DATA)
+ config_entry.add_to_hass(hass)
+
+ mocked_bulb = _mocked_bulb()
+ with _patch_discovery(MODULE), patch(f"{MODULE}.Bulb", return_value=mocked_bulb):
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
+
+ assert hass.states.get(f"binary_sensor.{NAME}_nightlight") is not None
+ assert hass.states.get(f"light.{NAME}") is not None
+
+ # Unload
+ assert await hass.config_entries.async_unload(config_entry.entry_id)
+ assert hass.states.get(f"binary_sensor.{NAME}_nightlight") is None
+ assert hass.states.get(f"light.{NAME}") is None
+
+
+async def test_setup_import(hass: HomeAssistant):
+ """Test import from yaml."""
+ mocked_bulb = _mocked_bulb()
+ name = "yeelight"
+ with patch(f"{MODULE}.Bulb", return_value=mocked_bulb), patch(
+ f"{MODULE_CONFIG_FLOW}.yeelight.Bulb", return_value=mocked_bulb
+ ):
+ assert await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {
+ CONF_DEVICES: {
+ IP_ADDRESS: {
+ CONF_NAME: name,
+ CONF_NIGHTLIGHT_SWITCH_TYPE: NIGHTLIGHT_SWITCH_TYPE_LIGHT,
+ }
+ }
+ }
+ },
+ )
+ await hass.async_block_till_done()
+
+ assert hass.states.get(f"binary_sensor.{name}_nightlight") is not None
+ assert hass.states.get(f"light.{name}") is not None
+ assert hass.states.get(f"light.{name}_nightlight") is not None
diff --git a/tests/components/yeelight/test_light.py b/tests/components/yeelight/test_light.py
index c44c343e51b..8e8916ce303 100644
--- a/tests/components/yeelight/test_light.py
+++ b/tests/components/yeelight/test_light.py
@@ -34,10 +34,15 @@ from homeassistant.components.yeelight import (
ATTR_TRANSITIONS,
CONF_CUSTOM_EFFECTS,
CONF_FLOW_PARAMS,
- CONF_NIGHTLIGHT_SWITCH_TYPE,
+ CONF_MODE_MUSIC,
+ CONF_NIGHTLIGHT_SWITCH,
+ CONF_SAVE_ON_CHANGE,
+ CONF_TRANSITION,
+ DEFAULT_MODE_MUSIC,
+ DEFAULT_NIGHTLIGHT_SWITCH,
+ DEFAULT_SAVE_ON_CHANGE,
DEFAULT_TRANSITION,
DOMAIN,
- NIGHTLIGHT_SWITCH_TYPE_LIGHT,
YEELIGHT_HSV_TRANSACTION,
YEELIGHT_RGB_TRANSITION,
YEELIGHT_SLEEP_TRANSACTION,
@@ -66,7 +71,7 @@ from homeassistant.components.yeelight.light import (
YEELIGHT_MONO_EFFECT_LIST,
YEELIGHT_TEMP_ONLY_EFFECT_LIST,
)
-from homeassistant.const import ATTR_ENTITY_ID, CONF_DEVICES, CONF_NAME
+from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_ID, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.util.color import (
@@ -79,24 +84,38 @@ from homeassistant.util.color import (
)
from . import (
- CAPABILITIES,
ENTITY_LIGHT,
ENTITY_NIGHTLIGHT,
+ IP_ADDRESS,
MODULE,
NAME,
PROPERTIES,
- YAML_CONFIGURATION,
_mocked_bulb,
+ _patch_discovery,
)
from tests.async_mock import MagicMock, patch
+from tests.common import MockConfigEntry
async def test_services(hass: HomeAssistant, caplog):
"""Test Yeelight services."""
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ CONF_ID: "",
+ CONF_HOST: IP_ADDRESS,
+ CONF_TRANSITION: DEFAULT_TRANSITION,
+ CONF_MODE_MUSIC: True,
+ CONF_SAVE_ON_CHANGE: True,
+ CONF_NIGHTLIGHT_SWITCH: True,
+ },
+ )
+ config_entry.add_to_hass(hass)
+
mocked_bulb = _mocked_bulb()
- with patch(f"{MODULE}.Bulb", return_value=mocked_bulb):
- await async_setup_component(hass, DOMAIN, YAML_CONFIGURATION)
+ with _patch_discovery(MODULE), patch(f"{MODULE}.Bulb", return_value=mocked_bulb):
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
async def _async_test_service(service, data, method, payload=None, domain=DOMAIN):
@@ -264,70 +283,70 @@ async def test_services(hass: HomeAssistant, caplog):
async def test_device_types(hass: HomeAssistant):
"""Test different device types."""
+ mocked_bulb = _mocked_bulb()
properties = {**PROPERTIES}
properties.pop("active_mode")
properties["color_mode"] = "3"
+ mocked_bulb.last_properties = properties
- def _create_mocked_bulb(bulb_type, model, unique_id):
- capabilities = {**CAPABILITIES}
- capabilities["id"] = f"yeelight.{unique_id}"
- mocked_bulb = _mocked_bulb()
- mocked_bulb.bulb_type = bulb_type
- mocked_bulb.last_properties = properties
- mocked_bulb.capabilities = capabilities
- model_specs = _MODEL_SPECS.get(model)
- type(mocked_bulb).get_model_specs = MagicMock(return_value=model_specs)
- return mocked_bulb
-
- types = {
- "default": (None, "mono"),
- "white": (BulbType.White, "mono"),
- "color": (BulbType.Color, "color"),
- "white_temp": (BulbType.WhiteTemp, "ceiling1"),
- "white_temp_mood": (BulbType.WhiteTempMood, "ceiling4"),
- "ambient": (BulbType.WhiteTempMood, "ceiling4"),
- }
-
- devices = {}
- mocked_bulbs = []
- unique_id = 0
- for name, (bulb_type, model) in types.items():
- devices[f"{name}.yeelight"] = {CONF_NAME: name}
- devices[f"{name}_nightlight.yeelight"] = {
- CONF_NAME: f"{name}_nightlight",
- CONF_NIGHTLIGHT_SWITCH_TYPE: NIGHTLIGHT_SWITCH_TYPE_LIGHT,
- }
- mocked_bulbs.append(_create_mocked_bulb(bulb_type, model, unique_id))
- mocked_bulbs.append(_create_mocked_bulb(bulb_type, model, unique_id + 1))
- unique_id += 2
-
- with patch(f"{MODULE}.Bulb", side_effect=mocked_bulbs):
- await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_DEVICES: devices}})
- await hass.async_block_till_done()
+ async def _async_setup(config_entry):
+ with patch(f"{MODULE}.Bulb", return_value=mocked_bulb):
+ await hass.config_entries.async_setup(config_entry.entry_id)
+ await hass.async_block_till_done()
async def _async_test(
- name,
bulb_type,
model,
target_properties,
nightlight_properties=None,
- entity_name=None,
- entity_id=None,
+ name=NAME,
+ entity_id=ENTITY_LIGHT,
):
- if entity_id is None:
- entity_id = f"light.{name}"
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ CONF_ID: "",
+ CONF_HOST: IP_ADDRESS,
+ CONF_TRANSITION: DEFAULT_TRANSITION,
+ CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC,
+ CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE,
+ CONF_NIGHTLIGHT_SWITCH: False,
+ },
+ )
+ config_entry.add_to_hass(hass)
+
+ mocked_bulb.bulb_type = bulb_type
+ model_specs = _MODEL_SPECS.get(model)
+ type(mocked_bulb).get_model_specs = MagicMock(return_value=model_specs)
+ await _async_setup(config_entry)
+
state = hass.states.get(entity_id)
assert state.state == "on"
- target_properties["friendly_name"] = entity_name or name
+ target_properties["friendly_name"] = name
target_properties["flowing"] = False
target_properties["night_light"] = True
assert dict(state.attributes) == target_properties
+ await hass.config_entries.async_unload(config_entry.entry_id)
+ await config_entry.async_remove(hass)
+
# nightlight
if nightlight_properties is None:
return
- name += "_nightlight"
- entity_id = f"light.{name}"
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ CONF_ID: "",
+ CONF_HOST: IP_ADDRESS,
+ CONF_TRANSITION: DEFAULT_TRANSITION,
+ CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC,
+ CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE,
+ CONF_NIGHTLIGHT_SWITCH: True,
+ },
+ )
+ config_entry.add_to_hass(hass)
+ await _async_setup(config_entry)
+
assert hass.states.get(entity_id).state == "off"
state = hass.states.get(f"{entity_id}_nightlight")
assert state.state == "on"
@@ -337,6 +356,9 @@ async def test_device_types(hass: HomeAssistant):
nightlight_properties["night_light"] = True
assert dict(state.attributes) == nightlight_properties
+ await hass.config_entries.async_unload(config_entry.entry_id)
+ await config_entry.async_remove(hass)
+
bright = round(255 * int(PROPERTIES["bright"]) / 100)
current_brightness = round(255 * int(PROPERTIES["current_brightness"]) / 100)
ct = color_temperature_kelvin_to_mired(int(PROPERTIES["ct"]))
@@ -355,7 +377,6 @@ async def test_device_types(hass: HomeAssistant):
# Default
await _async_test(
- "default",
None,
"mono",
{
@@ -367,7 +388,6 @@ async def test_device_types(hass: HomeAssistant):
# White
await _async_test(
- "white",
BulbType.White,
"mono",
{
@@ -380,7 +400,6 @@ async def test_device_types(hass: HomeAssistant):
# Color
model_specs = _MODEL_SPECS["color"]
await _async_test(
- "color",
BulbType.Color,
"color",
{
@@ -404,7 +423,6 @@ async def test_device_types(hass: HomeAssistant):
# WhiteTemp
model_specs = _MODEL_SPECS["ceiling1"]
await _async_test(
- "white_temp",
BulbType.WhiteTemp,
"ceiling1",
{
@@ -427,9 +445,10 @@ async def test_device_types(hass: HomeAssistant):
)
# WhiteTempMood
+ properties.pop("power")
+ properties["main_power"] = "on"
model_specs = _MODEL_SPECS["ceiling4"]
await _async_test(
- "white_temp_mood",
BulbType.WhiteTempMood,
"ceiling4",
{
@@ -454,7 +473,6 @@ async def test_device_types(hass: HomeAssistant):
},
)
await _async_test(
- "ambient",
BulbType.WhiteTempMood,
"ceiling4",
{
@@ -468,36 +486,52 @@ async def test_device_types(hass: HomeAssistant):
"rgb_color": bg_rgb_color,
"xy_color": bg_xy_color,
},
- entity_name="ambient ambilight",
- entity_id="light.ambient_ambilight",
+ name=f"{NAME} ambilight",
+ entity_id=f"{ENTITY_LIGHT}_ambilight",
)
async def test_effects(hass: HomeAssistant):
"""Test effects."""
- yaml_configuration = {
- DOMAIN: {
- CONF_DEVICES: YAML_CONFIGURATION[DOMAIN][CONF_DEVICES],
- CONF_CUSTOM_EFFECTS: [
- {
- CONF_NAME: "mock_effect",
- CONF_FLOW_PARAMS: {
- ATTR_COUNT: 3,
- ATTR_TRANSITIONS: [
- {YEELIGHT_HSV_TRANSACTION: [300, 50, 500, 50]},
- {YEELIGHT_RGB_TRANSITION: [100, 100, 100, 300, 30]},
- {YEELIGHT_TEMPERATURE_TRANSACTION: [3000, 200, 20]},
- {YEELIGHT_SLEEP_TRANSACTION: [800]},
- ],
+ assert await async_setup_component(
+ hass,
+ DOMAIN,
+ {
+ DOMAIN: {
+ CONF_CUSTOM_EFFECTS: [
+ {
+ CONF_NAME: "mock_effect",
+ CONF_FLOW_PARAMS: {
+ ATTR_COUNT: 3,
+ ATTR_TRANSITIONS: [
+ {YEELIGHT_HSV_TRANSACTION: [300, 50, 500, 50]},
+ {YEELIGHT_RGB_TRANSITION: [100, 100, 100, 300, 30]},
+ {YEELIGHT_TEMPERATURE_TRANSACTION: [3000, 200, 20]},
+ {YEELIGHT_SLEEP_TRANSACTION: [800]},
+ ],
+ },
},
- },
- ],
- }
- }
+ ],
+ },
+ },
+ )
+
+ config_entry = MockConfigEntry(
+ domain=DOMAIN,
+ data={
+ CONF_ID: "",
+ CONF_HOST: IP_ADDRESS,
+ CONF_TRANSITION: DEFAULT_TRANSITION,
+ CONF_MODE_MUSIC: DEFAULT_MODE_MUSIC,
+ CONF_SAVE_ON_CHANGE: DEFAULT_SAVE_ON_CHANGE,
+ CONF_NIGHTLIGHT_SWITCH: DEFAULT_NIGHTLIGHT_SWITCH,
+ },
+ )
+ config_entry.add_to_hass(hass)
mocked_bulb = _mocked_bulb()
- with patch(f"{MODULE}.Bulb", return_value=mocked_bulb):
- assert await async_setup_component(hass, DOMAIN, yaml_configuration)
+ with _patch_discovery(MODULE), patch(f"{MODULE}.Bulb", return_value=mocked_bulb):
+ assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get(ENTITY_LIGHT).attributes.get(
diff --git a/tests/components/yr/__init__.py b/tests/components/yr/__init__.py
deleted file mode 100644
index d85c8ab9758..00000000000
--- a/tests/components/yr/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-"""Tests for the yr component."""
diff --git a/tests/components/yr/test_sensor.py b/tests/components/yr/test_sensor.py
deleted file mode 100644
index b339dd9c132..00000000000
--- a/tests/components/yr/test_sensor.py
+++ /dev/null
@@ -1,127 +0,0 @@
-"""The tests for the Yr sensor platform."""
-from datetime import datetime
-
-from homeassistant.bootstrap import async_setup_component
-from homeassistant.const import DEGREE, SPEED_METERS_PER_SECOND, UNIT_PERCENTAGE
-import homeassistant.util.dt as dt_util
-
-from tests.async_mock import patch
-from tests.common import assert_setup_component, load_fixture
-
-NOW = datetime(2016, 6, 9, 1, tzinfo=dt_util.UTC)
-
-
-async def test_default_setup(hass, legacy_patchable_time, aioclient_mock):
- """Test the default setup."""
- aioclient_mock.get(
- "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/1.9/",
- text=load_fixture("yr.no.xml"),
- )
- config = {"platform": "yr", "elevation": 0}
- hass.allow_pool = True
-
- with patch(
- "homeassistant.components.yr.sensor.dt_util.utcnow", return_value=NOW
- ), assert_setup_component(1):
- await async_setup_component(hass, "sensor", {"sensor": config})
- await hass.async_block_till_done()
-
- state = hass.states.get("sensor.yr_symbol")
-
- assert state.state == "3"
- assert state.attributes.get("unit_of_measurement") is None
-
-
-async def test_custom_setup(hass, legacy_patchable_time, aioclient_mock):
- """Test a custom setup."""
- aioclient_mock.get(
- "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/1.9/",
- text=load_fixture("yr.no.xml"),
- )
-
- config = {
- "platform": "yr",
- "elevation": 0,
- "monitored_conditions": [
- "pressure",
- "windDirection",
- "humidity",
- "fog",
- "windSpeed",
- ],
- }
- hass.allow_pool = True
-
- with patch(
- "homeassistant.components.yr.sensor.dt_util.utcnow", return_value=NOW
- ), assert_setup_component(1):
- await async_setup_component(hass, "sensor", {"sensor": config})
- await hass.async_block_till_done()
-
- state = hass.states.get("sensor.yr_pressure")
- assert state.attributes.get("unit_of_measurement") == "hPa"
- assert state.state == "1009.3"
-
- state = hass.states.get("sensor.yr_wind_direction")
- assert state.attributes.get("unit_of_measurement") == DEGREE
- assert state.state == "103.6"
-
- state = hass.states.get("sensor.yr_humidity")
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
- assert state.state == "55.5"
-
- state = hass.states.get("sensor.yr_fog")
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
- assert state.state == "0.0"
-
- state = hass.states.get("sensor.yr_wind_speed")
- assert state.attributes.get("unit_of_measurement") == SPEED_METERS_PER_SECOND
- assert state.state == "3.5"
-
-
-async def test_forecast_setup(hass, legacy_patchable_time, aioclient_mock):
- """Test a custom setup with 24h forecast."""
- aioclient_mock.get(
- "https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/1.9/",
- text=load_fixture("yr.no.xml"),
- )
-
- config = {
- "platform": "yr",
- "elevation": 0,
- "forecast": 24,
- "monitored_conditions": [
- "pressure",
- "windDirection",
- "humidity",
- "fog",
- "windSpeed",
- ],
- }
- hass.allow_pool = True
-
- with patch(
- "homeassistant.components.yr.sensor.dt_util.utcnow", return_value=NOW
- ), assert_setup_component(1):
- await async_setup_component(hass, "sensor", {"sensor": config})
- await hass.async_block_till_done()
-
- state = hass.states.get("sensor.yr_pressure")
- assert state.attributes.get("unit_of_measurement") == "hPa"
- assert state.state == "1008.3"
-
- state = hass.states.get("sensor.yr_wind_direction")
- assert state.attributes.get("unit_of_measurement") == DEGREE
- assert state.state == "148.9"
-
- state = hass.states.get("sensor.yr_humidity")
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
- assert state.state == "77.4"
-
- state = hass.states.get("sensor.yr_fog")
- assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE
- assert state.state == "0.0"
-
- state = hass.states.get("sensor.yr_wind_speed")
- assert state.attributes.get("unit_of_measurement") == SPEED_METERS_PER_SECOND
- assert state.state == "3.6"
diff --git a/tests/components/zeroconf/conftest.py b/tests/components/zeroconf/conftest.py
new file mode 100644
index 00000000000..e7d7b030aaf
--- /dev/null
+++ b/tests/components/zeroconf/conftest.py
@@ -0,0 +1,11 @@
+"""conftest for zeroconf."""
+import pytest
+
+from tests.async_mock import patch
+
+
+@pytest.fixture
+def mock_zeroconf():
+ """Mock zeroconf."""
+ with patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc:
+ yield mock_zc.return_value
diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py
index dee8ca97433..ae1f6d5fd98 100644
--- a/tests/components/zeroconf/test_init.py
+++ b/tests/components/zeroconf/test_init.py
@@ -1,5 +1,4 @@
"""Test Zeroconf component setup process."""
-import pytest
from zeroconf import (
BadTypeInNameException,
InterfaceChoice,
@@ -28,13 +27,6 @@ HOMEKIT_STATUS_UNPAIRED = b"1"
HOMEKIT_STATUS_PAIRED = b"0"
-@pytest.fixture
-def mock_zeroconf():
- """Mock zeroconf."""
- with patch("homeassistant.components.zeroconf.HaZeroconf") as mock_zc:
- yield mock_zc.return_value
-
-
def service_update_mock(zeroconf, services, handlers):
"""Call service update handler."""
for service in services:
@@ -87,6 +79,24 @@ def get_homekit_info_mock(model, pairing_status):
return mock_homekit_info
+def get_zeroconf_info_mock(macaddress):
+ """Return info for get_service_info for an zeroconf device."""
+
+ def mock_zc_info(service_type, name):
+ return ServiceInfo(
+ service_type,
+ name,
+ addresses=[b"\n\x00\x00\x14"],
+ port=80,
+ weight=0,
+ priority=0,
+ server="name.local.",
+ properties={b"macaddress": macaddress.encode()},
+ )
+
+ return mock_zc_info
+
+
async def test_setup(hass, mock_zeroconf):
"""Test configured options for a device are loaded via config entry."""
with patch.object(
@@ -102,7 +112,11 @@ async def test_setup(hass, mock_zeroconf):
assert len(mock_service_browser.mock_calls) == 1
expected_flow_calls = 0
for matching_components in zc_gen.ZEROCONF.values():
- expected_flow_calls += len(matching_components)
+ domains = set()
+ for component in matching_components:
+ if len(component) == 1:
+ domains.add(component["domain"])
+ expected_flow_calls += len(domains)
assert len(mock_config_flow.mock_calls) == expected_flow_calls
# Test instance is set.
@@ -110,6 +124,28 @@ async def test_setup(hass, mock_zeroconf):
assert await hass.components.zeroconf.async_get_instance() is mock_zeroconf
+async def test_setup_with_overly_long_url_and_name(hass, mock_zeroconf, caplog):
+ """Test we still setup with long urls and names."""
+ with patch.object(hass.config_entries.flow, "async_init"), patch.object(
+ zeroconf, "HaServiceBrowser", side_effect=service_update_mock
+ ) as mock_service_browser, patch(
+ "homeassistant.components.zeroconf.get_url",
+ return_value="https://this.url.is.way.too.long/very/deep/path/that/will/make/us/go/over/the/maximum/string/length/and/would/cause/zeroconf/to/fail/to/startup/because/the/key/and/value/can/only/be/255/bytes/and/this/string/is/a/bit/longer/than/the/maximum/length/that/we/allow/for/a/value",
+ ), patch.object(
+ hass.config,
+ "location_name",
+ "\u00dcBER \u00dcber German Umlaut long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string long string",
+ ):
+ mock_zeroconf.get_service_info.side_effect = get_service_info_mock
+ assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
+ await hass.async_block_till_done()
+
+ assert len(mock_service_browser.mock_calls) == 1
+ assert "https://this.url.is.way.too.long" in caplog.text
+ assert "German Umlaut" in caplog.text
+
+
async def test_setup_with_default_interface(hass, mock_zeroconf):
"""Test default interface config."""
with patch.object(hass.config_entries.flow, "async_init"), patch.object(
@@ -195,10 +231,77 @@ async def test_service_with_invalid_name(hass, mock_zeroconf, caplog):
assert "Failed to get info for device name" in caplog.text
+async def test_zeroconf_match(hass, mock_zeroconf):
+ """Test configured options for a device are loaded via config entry."""
+
+ def http_only_service_update_mock(zeroconf, services, handlers):
+ """Call service update handler."""
+ handlers[0](
+ zeroconf,
+ "_http._tcp.local.",
+ "shelly108._http._tcp.local.",
+ ServiceStateChange.Added,
+ )
+
+ with patch.dict(
+ zc_gen.ZEROCONF,
+ {"_http._tcp.local.": [{"domain": "shelly", "name": "shelly*"}]},
+ clear=True,
+ ), patch.object(
+ hass.config_entries.flow, "async_init"
+ ) as mock_config_flow, patch.object(
+ zeroconf, "HaServiceBrowser", side_effect=http_only_service_update_mock
+ ) as mock_service_browser:
+ mock_zeroconf.get_service_info.side_effect = get_zeroconf_info_mock(
+ "FFAADDCC11DD"
+ )
+ assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
+ await hass.async_block_till_done()
+
+ assert len(mock_service_browser.mock_calls) == 1
+ assert len(mock_config_flow.mock_calls) == 1
+ assert mock_config_flow.mock_calls[0][1][0] == "shelly"
+
+
+async def test_zeroconf_no_match(hass, mock_zeroconf):
+ """Test configured options for a device are loaded via config entry."""
+
+ def http_only_service_update_mock(zeroconf, services, handlers):
+ """Call service update handler."""
+ handlers[0](
+ zeroconf,
+ "_http._tcp.local.",
+ "somethingelse._http._tcp.local.",
+ ServiceStateChange.Added,
+ )
+
+ with patch.dict(
+ zc_gen.ZEROCONF,
+ {"_http._tcp.local.": [{"domain": "shelly", "name": "shelly*"}]},
+ clear=True,
+ ), patch.object(
+ hass.config_entries.flow, "async_init"
+ ) as mock_config_flow, patch.object(
+ zeroconf, "HaServiceBrowser", side_effect=http_only_service_update_mock
+ ) as mock_service_browser:
+ mock_zeroconf.get_service_info.side_effect = get_zeroconf_info_mock(
+ "FFAADDCC11DD"
+ )
+ assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
+ await hass.async_block_till_done()
+
+ assert len(mock_service_browser.mock_calls) == 1
+ assert len(mock_config_flow.mock_calls) == 0
+
+
async def test_homekit_match_partial_space(hass, mock_zeroconf):
"""Test configured options for a device are loaded via config entry."""
with patch.dict(
- zc_gen.ZEROCONF, {zeroconf.HOMEKIT_TYPE: ["homekit_controller"]}, clear=True
+ zc_gen.ZEROCONF,
+ {zeroconf.HOMEKIT_TYPE: [{"domain": "homekit_controller"}]},
+ clear=True,
), patch.object(
hass.config_entries.flow, "async_init"
) as mock_config_flow, patch.object(
@@ -219,7 +322,9 @@ async def test_homekit_match_partial_space(hass, mock_zeroconf):
async def test_homekit_match_partial_dash(hass, mock_zeroconf):
"""Test configured options for a device are loaded via config entry."""
with patch.dict(
- zc_gen.ZEROCONF, {zeroconf.HOMEKIT_TYPE: ["homekit_controller"]}, clear=True
+ zc_gen.ZEROCONF,
+ {zeroconf.HOMEKIT_TYPE: [{"domain": "homekit_controller"}]},
+ clear=True,
), patch.object(
hass.config_entries.flow, "async_init"
) as mock_config_flow, patch.object(
@@ -240,7 +345,9 @@ async def test_homekit_match_partial_dash(hass, mock_zeroconf):
async def test_homekit_match_full(hass, mock_zeroconf):
"""Test configured options for a device are loaded via config entry."""
with patch.dict(
- zc_gen.ZEROCONF, {zeroconf.HOMEKIT_TYPE: ["homekit_controller"]}, clear=True
+ zc_gen.ZEROCONF,
+ {zeroconf.HOMEKIT_TYPE: [{"domain": "homekit_controller"}]},
+ clear=True,
), patch.object(
hass.config_entries.flow, "async_init"
) as mock_config_flow, patch.object(
@@ -253,11 +360,6 @@ async def test_homekit_match_full(hass, mock_zeroconf):
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
- homekit_mock = get_homekit_info_mock("BSB002", HOMEKIT_STATUS_UNPAIRED)
- info = homekit_mock("_hap._tcp.local.", "BSB002._hap._tcp.local.")
- import pprint
-
- pprint.pprint(["homekit", info])
assert len(mock_service_browser.mock_calls) == 1
assert len(mock_config_flow.mock_calls) == 1
assert mock_config_flow.mock_calls[0][1][0] == "hue"
@@ -266,7 +368,9 @@ async def test_homekit_match_full(hass, mock_zeroconf):
async def test_homekit_already_paired(hass, mock_zeroconf):
"""Test that an already paired device is sent to homekit_controller."""
with patch.dict(
- zc_gen.ZEROCONF, {zeroconf.HOMEKIT_TYPE: ["homekit_controller"]}, clear=True
+ zc_gen.ZEROCONF,
+ {zeroconf.HOMEKIT_TYPE: [{"domain": "homekit_controller"}]},
+ clear=True,
), patch.object(
hass.config_entries.flow, "async_init"
) as mock_config_flow, patch.object(
@@ -288,7 +392,9 @@ async def test_homekit_already_paired(hass, mock_zeroconf):
async def test_homekit_invalid_paring_status(hass, mock_zeroconf):
"""Test that missing paring data is not sent to homekit_controller."""
with patch.dict(
- zc_gen.ZEROCONF, {zeroconf.HOMEKIT_TYPE: ["homekit_controller"]}, clear=True
+ zc_gen.ZEROCONF,
+ {zeroconf.HOMEKIT_TYPE: [{"domain": "homekit_controller"}]},
+ clear=True,
), patch.object(
hass.config_entries.flow, "async_init"
) as mock_config_flow, patch.object(
@@ -309,7 +415,9 @@ async def test_homekit_invalid_paring_status(hass, mock_zeroconf):
async def test_homekit_not_paired(hass, mock_zeroconf):
"""Test that an not paired device is sent to homekit_controller."""
with patch.dict(
- zc_gen.ZEROCONF, {zeroconf.HOMEKIT_TYPE: ["homekit_controller"]}, clear=True
+ zc_gen.ZEROCONF,
+ {zeroconf.HOMEKIT_TYPE: [{"domain": "homekit_controller"}]},
+ clear=True,
), patch.object(
hass.config_entries.flow, "async_init"
) as mock_config_flow, patch.object(
diff --git a/tests/components/zeroconf/test_usage.py b/tests/components/zeroconf/test_usage.py
new file mode 100644
index 00000000000..e45a93a38b3
--- /dev/null
+++ b/tests/components/zeroconf/test_usage.py
@@ -0,0 +1,60 @@
+"""Test Zeroconf multiple instance protection."""
+import zeroconf
+
+from homeassistant.components.zeroconf import async_get_instance
+from homeassistant.components.zeroconf.usage import install_multiple_zeroconf_catcher
+
+from tests.async_mock import Mock, patch
+
+
+async def test_multiple_zeroconf_instances(hass, mock_zeroconf, caplog):
+ """Test creating multiple zeroconf throws without an integration."""
+
+ zeroconf_instance = await async_get_instance(hass)
+
+ install_multiple_zeroconf_catcher(zeroconf_instance)
+
+ new_zeroconf_instance = zeroconf.Zeroconf()
+ assert new_zeroconf_instance == zeroconf_instance
+
+ assert "Zeroconf" in caplog.text
+
+
+async def test_multiple_zeroconf_instances_gives_shared(hass, mock_zeroconf, caplog):
+ """Test creating multiple zeroconf gives the shared instance to an integration."""
+
+ zeroconf_instance = await async_get_instance(hass)
+
+ install_multiple_zeroconf_catcher(zeroconf_instance)
+
+ correct_frame = Mock(
+ filename="/config/custom_components/burncpu/light.py",
+ lineno="23",
+ line="self.light.is_on",
+ )
+ with patch(
+ "homeassistant.helpers.frame.extract_stack",
+ return_value=[
+ Mock(
+ filename="/home/dev/homeassistant/core.py",
+ lineno="23",
+ line="do_something()",
+ ),
+ correct_frame,
+ Mock(
+ filename="/home/dev/homeassistant/components/zeroconf/usage.py",
+ lineno="23",
+ line="self.light.is_on",
+ ),
+ Mock(
+ filename="/home/dev/mdns/lights.py",
+ lineno="2",
+ line="something()",
+ ),
+ ],
+ ):
+ assert zeroconf.Zeroconf() == zeroconf_instance
+
+ assert "custom_components/burncpu/light.py" in caplog.text
+ assert "23" in caplog.text
+ assert "self.light.is_on" in caplog.text
diff --git a/tests/components/zerproc/test_config_flow.py b/tests/components/zerproc/test_config_flow.py
index 4e9c380910e..9ffafca76db 100644
--- a/tests/components/zerproc/test_config_flow.py
+++ b/tests/components/zerproc/test_config_flow.py
@@ -22,9 +22,13 @@ async def test_flow_success(hass):
), patch(
"homeassistant.components.zerproc.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.zerproc.async_setup_entry", return_value=True,
+ "homeassistant.components.zerproc.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
- result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {},)
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
assert result2["type"] == "create_entry"
assert result2["title"] == "Zerproc"
@@ -50,9 +54,13 @@ async def test_flow_no_devices_found(hass):
), patch(
"homeassistant.components.zerproc.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.zerproc.async_setup_entry", return_value=True,
+ "homeassistant.components.zerproc.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
- result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {},)
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
assert result2["type"] == "abort"
assert result2["reason"] == "no_devices_found"
@@ -76,9 +84,13 @@ async def test_flow_exceptions_caught(hass):
), patch(
"homeassistant.components.zerproc.async_setup", return_value=True
) as mock_setup, patch(
- "homeassistant.components.zerproc.async_setup_entry", return_value=True,
+ "homeassistant.components.zerproc.async_setup_entry",
+ return_value=True,
) as mock_setup_entry:
- result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {},)
+ result2 = await hass.config_entries.flow.async_configure(
+ result["flow_id"],
+ {},
+ )
assert result2["type"] == "abort"
assert result2["reason"] == "no_devices_found"
diff --git a/tests/components/zha/test_climate.py b/tests/components/zha/test_climate.py
index 8ca949cd44e..fe69e6536d3 100644
--- a/tests/components/zha/test_climate.py
+++ b/tests/components/zha/test_climate.py
@@ -466,7 +466,11 @@ async def test_target_temperature(
@pytest.mark.parametrize(
"preset, unoccupied, target_temp",
- ((None, 1800, 17), (PRESET_AWAY, 1800, 18), (PRESET_AWAY, None, None),),
+ (
+ (None, 1800, 17),
+ (PRESET_AWAY, 1800, 18),
+ (PRESET_AWAY, None, None),
+ ),
)
async def test_target_temperature_high(
hass, device_climate_mock, preset, unoccupied, target_temp
@@ -502,7 +506,11 @@ async def test_target_temperature_high(
@pytest.mark.parametrize(
"preset, unoccupied, target_temp",
- ((None, 1600, 21), (PRESET_AWAY, 1600, 16), (PRESET_AWAY, None, None),),
+ (
+ (None, 1600, 21),
+ (PRESET_AWAY, 1600, 16),
+ (PRESET_AWAY, None, None),
+ ),
)
async def test_target_temperature_low(
hass, device_climate_mock, preset, unoccupied, target_temp
diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py
index 9894360da46..709b9a0ff22 100644
--- a/tests/components/zha/test_config_flow.py
+++ b/tests/components/zha/test_config_flow.py
@@ -53,7 +53,8 @@ async def test_user_flow(detect_mock, hass):
@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()]))
@patch(
- "homeassistant.components.zha.config_flow.detect_radios", return_value=None,
+ "homeassistant.components.zha.config_flow.detect_radios",
+ return_value=None,
)
async def test_user_flow_not_detected(detect_mock, hass):
"""Test user flow, radio not detected."""
@@ -76,7 +77,8 @@ async def test_user_flow_not_detected(detect_mock, hass):
async def test_user_flow_show_form(hass):
"""Test user step form."""
result = await hass.config_entries.flow.async_init(
- DOMAIN, context={CONF_SOURCE: SOURCE_USER},
+ DOMAIN,
+ context={CONF_SOURCE: SOURCE_USER},
)
assert result["type"] == RESULT_TYPE_FORM
@@ -163,7 +165,8 @@ async def test_user_port_config_fail(probe_mock, hass):
)
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"},
+ result["flow_id"],
+ user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"},
)
assert result["type"] == RESULT_TYPE_FORM
assert result["step_id"] == "port_config"
@@ -184,7 +187,8 @@ async def test_user_port_config(probe_mock, hass):
)
result = await hass.config_entries.flow.async_configure(
- result["flow_id"], user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"},
+ result["flow_id"],
+ user_input={zigpy.config.CONF_DEVICE_PATH: "/dev/ttyUSB33"},
)
assert result["type"] == "create_entry"
diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py
index d32eac130b0..c6c0e74050d 100644
--- a/tests/components/zha/test_cover.py
+++ b/tests/components/zha/test_cover.py
@@ -314,7 +314,10 @@ async def test_shade(hass, zha_device_joined_restored, zigpy_shade_device):
# test cover stop
with patch("zigpy.zcl.Cluster.request", side_effect=asyncio.TimeoutError):
await hass.services.async_call(
- DOMAIN, SERVICE_STOP_COVER, {"entity_id": entity_id}, blocking=True,
+ DOMAIN,
+ SERVICE_STOP_COVER,
+ {"entity_id": entity_id},
+ blocking=True,
)
assert cluster_level.request.call_count == 1
assert cluster_level.request.call_args[0][0] is False
diff --git a/tests/components/zha/test_device.py b/tests/components/zha/test_device.py
index a408f655ea3..83a57f1fabf 100644
--- a/tests/components/zha/test_device.py
+++ b/tests/components/zha/test_device.py
@@ -235,12 +235,32 @@ async def test_ota_sw_version(hass, ota_zha_device):
"device, last_seen_delta, is_available",
(
("zigpy_device", 0, True),
- ("zigpy_device", zha_core_device.CONSIDER_UNAVAILABLE_MAINS + 2, True,),
- ("zigpy_device", zha_core_device.CONSIDER_UNAVAILABLE_BATTERY - 2, True,),
- ("zigpy_device", zha_core_device.CONSIDER_UNAVAILABLE_BATTERY + 2, False,),
+ (
+ "zigpy_device",
+ zha_core_device.CONSIDER_UNAVAILABLE_MAINS + 2,
+ True,
+ ),
+ (
+ "zigpy_device",
+ zha_core_device.CONSIDER_UNAVAILABLE_BATTERY - 2,
+ True,
+ ),
+ (
+ "zigpy_device",
+ zha_core_device.CONSIDER_UNAVAILABLE_BATTERY + 2,
+ False,
+ ),
("zigpy_device_mains", 0, True),
- ("zigpy_device_mains", zha_core_device.CONSIDER_UNAVAILABLE_MAINS - 2, True,),
- ("zigpy_device_mains", zha_core_device.CONSIDER_UNAVAILABLE_MAINS + 2, False,),
+ (
+ "zigpy_device_mains",
+ zha_core_device.CONSIDER_UNAVAILABLE_MAINS - 2,
+ True,
+ ),
+ (
+ "zigpy_device_mains",
+ zha_core_device.CONSIDER_UNAVAILABLE_MAINS + 2,
+ False,
+ ),
(
"zigpy_device_mains",
zha_core_device.CONSIDER_UNAVAILABLE_BATTERY - 2,
diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py
index 266094963f2..e0a73903e0d 100644
--- a/tests/components/zha/test_device_trigger.py
+++ b/tests/components/zha/test_device_trigger.py
@@ -1,12 +1,23 @@
"""ZHA device automation trigger tests."""
+from datetime import timedelta
+import time
+
import pytest
import zigpy.zcl.clusters.general as general
import homeassistant.components.automation as automation
+import homeassistant.components.zha.core.device as zha_core_device
from homeassistant.helpers.device_registry import async_get_registry
from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
-from tests.common import async_get_device_automations, async_mock_service
+from .common import async_enable_traffic
+
+from tests.common import (
+ async_fire_time_changed,
+ async_get_device_automations,
+ async_mock_service,
+)
ON = 1
OFF = 0
@@ -49,7 +60,7 @@ async def mock_devices(hass, zigpy_device_mock, zha_device_joined_restored):
"out_clusters": [general.OnOff.cluster_id],
"device_type": 0,
}
- },
+ }
)
zha_device = await zha_device_joined_restored(zigpy_device)
@@ -79,6 +90,13 @@ async def test_triggers(hass, mock_devices):
triggers = await async_get_device_automations(hass, "trigger", reg_device.id)
expected_triggers = [
+ {
+ "device_id": reg_device.id,
+ "domain": "zha",
+ "platform": "device",
+ "type": "device_offline",
+ "subtype": "device_offline",
+ },
{
"device_id": reg_device.id,
"domain": "zha",
@@ -128,7 +146,15 @@ async def test_no_triggers(hass, mock_devices):
reg_device = ha_device_registry.async_get_device({("zha", ieee_address)}, set())
triggers = await async_get_device_automations(hass, "trigger", reg_device.id)
- assert triggers == []
+ assert triggers == [
+ {
+ "device_id": reg_device.id,
+ "domain": "zha",
+ "platform": "device",
+ "type": "device_offline",
+ "subtype": "device_offline",
+ }
+ ]
async def test_if_fires_on_event(hass, mock_devices, calls):
@@ -180,6 +206,74 @@ async def test_if_fires_on_event(hass, mock_devices, calls):
assert calls[0].data["message"] == "service called"
+async def test_device_offline_fires(
+ hass, zigpy_device_mock, zha_device_restored, calls
+):
+ """Test for device offline triggers firing."""
+
+ zigpy_device = zigpy_device_mock(
+ {
+ 1: {
+ "in_clusters": [general.Basic.cluster_id],
+ "out_clusters": [general.OnOff.cluster_id],
+ "device_type": 0,
+ }
+ }
+ )
+
+ zha_device = await zha_device_restored(zigpy_device, last_seen=time.time())
+ await async_enable_traffic(hass, [zha_device])
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(
+ hass,
+ automation.DOMAIN,
+ {
+ automation.DOMAIN: [
+ {
+ "trigger": {
+ "device_id": zha_device.device_id,
+ "domain": "zha",
+ "platform": "device",
+ "type": "device_offline",
+ "subtype": "device_offline",
+ },
+ "action": {
+ "service": "test.automation",
+ "data": {"message": "service called"},
+ },
+ }
+ ]
+ },
+ )
+
+ await hass.async_block_till_done()
+ assert zha_device.available is True
+
+ zigpy_device.last_seen = (
+ time.time() - zha_core_device.CONSIDER_UNAVAILABLE_BATTERY - 2
+ )
+
+ # there are 3 checkins to perform before marking the device unavailable
+ future = dt_util.utcnow() + timedelta(seconds=90)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ future = dt_util.utcnow() + timedelta(seconds=90)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ future = dt_util.utcnow() + timedelta(
+ seconds=zha_core_device.CONSIDER_UNAVAILABLE_BATTERY + 100
+ )
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert zha_device.available is False
+ assert len(calls) == 1
+ assert calls[0].data["message"] == "service called"
+
+
async def test_exception_no_triggers(hass, mock_devices, calls, caplog):
"""Test for exception on event triggers firing."""
diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py
index 9fd01f1de8d..0cd8c58e49f 100644
--- a/tests/components/zha/test_discover.py
+++ b/tests/components/zha/test_discover.py
@@ -61,7 +61,10 @@ def channels_mock(zha_device_mock):
)
@pytest.mark.parametrize("device", DEVICES)
async def test_devices(
- device, hass_disable_services, zigpy_device_mock, zha_device_joined_restored,
+ device,
+ hass_disable_services,
+ zigpy_device_mock,
+ zha_device_joined_restored,
):
"""Test device discovery."""
entity_registry = await homeassistant.helpers.entity_registry.async_get_registry(
diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py
index 25fecd2d82c..7cba51b3e5c 100644
--- a/tests/components/zha/test_sensor.py
+++ b/tests/components/zha/test_sensor.py
@@ -14,12 +14,12 @@ from homeassistant.const import (
CONF_UNIT_SYSTEM,
CONF_UNIT_SYSTEM_IMPERIAL,
CONF_UNIT_SYSTEM_METRIC,
+ PERCENTAGE,
POWER_WATT,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
- UNIT_PERCENTAGE,
)
from homeassistant.helpers import restore_state
from homeassistant.util import dt as dt_util
@@ -36,7 +36,7 @@ from .common import (
async def async_test_humidity(hass, cluster, entity_id):
"""Test humidity sensor."""
await send_attributes_report(hass, cluster, {1: 1, 0: 1000, 2: 100})
- assert_state(hass, entity_id, "10.0", UNIT_PERCENTAGE)
+ assert_state(hass, entity_id, "10.0", PERCENTAGE)
async def async_test_temperature(hass, cluster, entity_id):
@@ -268,7 +268,9 @@ async def test_temp_uom(
async def test_electrical_measurement_init(
- hass, zigpy_device_mock, zha_device_joined,
+ hass,
+ zigpy_device_mock,
+ zha_device_joined,
):
"""Test proper initialization of the electrical measurement cluster."""
diff --git a/tests/components/automation/test_zone.py b/tests/components/zone/test_trigger.py
similarity index 100%
rename from tests/components/automation/test_zone.py
rename to tests/components/zone/test_trigger.py
diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py
index 12b2c59ca81..4cf639e7faf 100644
--- a/tests/components/zwave/test_init.py
+++ b/tests/components/zwave/test_init.py
@@ -1762,12 +1762,15 @@ class TestZWaveServices(unittest.TestCase):
assert node.refresh_value.called
assert len(node.refresh_value.mock_calls) == 2
- assert sorted(
- [
- node.refresh_value.mock_calls[0][1][0],
- node.refresh_value.mock_calls[1][1][0],
- ]
- ) == sorted([value.value_id, power_value.value_id])
+ assert (
+ sorted(
+ [
+ node.refresh_value.mock_calls[0][1][0],
+ node.refresh_value.mock_calls[1][1][0],
+ ]
+ )
+ == sorted([value.value_id, power_value.value_id])
+ )
def test_refresh_node(self):
"""Test zwave refresh_node service."""
diff --git a/tests/components/zwave/test_sensor.py b/tests/components/zwave/test_sensor.py
index 817a43d4f58..b2cd895df37 100644
--- a/tests/components/zwave/test_sensor.py
+++ b/tests/components/zwave/test_sensor.py
@@ -153,12 +153,12 @@ def test_alarm_sensor_value_changed(mock_openzwave):
node = MockNode(
command_classes=[const.COMMAND_CLASS_ALARM, const.COMMAND_CLASS_SENSOR_ALARM]
)
- value = MockValue(data=12.34, node=node, units=homeassistant.const.UNIT_PERCENTAGE)
+ value = MockValue(data=12.34, node=node, units=homeassistant.const.PERCENTAGE)
values = MockEntityValues(primary=value)
device = sensor.get_device(node=node, values=values, node_config={})
assert device.state == 12.34
- assert device.unit_of_measurement == homeassistant.const.UNIT_PERCENTAGE
+ assert device.unit_of_measurement == homeassistant.const.PERCENTAGE
value.data = 45.67
value_changed(value)
assert device.state == 45.67
diff --git a/tests/components/zwave/test_websocket_api.py b/tests/components/zwave/test_websocket_api.py
index 978fc09a10d..25bc364a630 100644
--- a/tests/components/zwave/test_websocket_api.py
+++ b/tests/components/zwave/test_websocket_api.py
@@ -2,6 +2,7 @@
from homeassistant.bootstrap import async_setup_component
from homeassistant.components.zwave.const import (
CONF_AUTOHEAL,
+ CONF_NETWORK_KEY,
CONF_POLLING_INTERVAL,
CONF_USB_STICK_PATH,
)
@@ -19,6 +20,7 @@ async def test_zwave_ws_api(hass, mock_openzwave, hass_ws_client):
CONF_AUTOHEAL: False,
CONF_USB_STICK_PATH: "/dev/zwave",
CONF_POLLING_INTERVAL: 6000,
+ CONF_NETWORK_KEY: "0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST",
}
},
)
@@ -35,3 +37,13 @@ async def test_zwave_ws_api(hass, mock_openzwave, hass_ws_client):
assert result[CONF_USB_STICK_PATH] == "/dev/zwave"
assert not result[CONF_AUTOHEAL]
assert result[CONF_POLLING_INTERVAL] == 6000
+
+ await client.send_json({ID: 6, TYPE: "zwave/get_migration_config"})
+ msg = await client.receive_json()
+ result = msg["result"]
+
+ assert result[CONF_USB_STICK_PATH] == "/dev/zwave"
+ assert (
+ result[CONF_NETWORK_KEY]
+ == "0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST, 0xTE, 0xST"
+ )
diff --git a/tests/conftest.py b/tests/conftest.py
index 5c90dcb063e..9008359e539 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,8 +3,10 @@ import asyncio
import datetime
import functools
import logging
+import ssl
import threading
+from aiohttp.test_utils import make_mocked_request
import pytest
import requests_mock as _requests_mock
@@ -24,7 +26,7 @@ from homeassistant.helpers import event
from homeassistant.setup import async_setup_component
from homeassistant.util import location
-from tests.async_mock import MagicMock, patch
+from tests.async_mock import MagicMock, Mock, patch
from tests.ignore_uncaught_exceptions import IGNORE_UNCAUGHT_EXCEPTIONS
pytest.register_assert_rewrite("tests.common")
@@ -263,6 +265,20 @@ def hass_client(hass, aiohttp_client, hass_access_token):
return auth_client
+@pytest.fixture
+def current_request(hass):
+ """Mock current request."""
+ with patch("homeassistant.helpers.network.current_request") as mock_request_context:
+ mocked_request = make_mocked_request(
+ "GET",
+ "/some/request",
+ headers={"Host": "example.com"},
+ sslcontext=ssl.SSLContext(ssl.PROTOCOL_TLS),
+ )
+ mock_request_context.get = Mock(return_value=mocked_request)
+ yield mock_request_context
+
+
@pytest.fixture
def hass_ws_client(aiohttp_client, hass_access_token, hass):
"""Websocket client fixture connected to websocket server."""
@@ -319,15 +335,39 @@ def mqtt_config():
def mqtt_client_mock(hass):
"""Fixture to mock MQTT client."""
- @ha.callback
- def _async_fire_mqtt_message(topic, payload, qos, retain):
- async_fire_mqtt_message(hass, topic, payload, qos, retain)
+ mid = 0
+
+ def get_mid():
+ nonlocal mid
+ mid += 1
+ return mid
+
+ class FakeInfo:
+ def __init__(self, mid):
+ self.mid = mid
+ self.rc = 0
with patch("paho.mqtt.client.Client") as mock_client:
+
+ @ha.callback
+ def _async_fire_mqtt_message(topic, payload, qos, retain):
+ async_fire_mqtt_message(hass, topic, payload, qos, retain)
+ mid = get_mid()
+ mock_client.on_publish(0, 0, mid)
+ return FakeInfo(mid)
+
+ def _subscribe(topic, qos=0):
+ mock_client.on_subscribe(0, 0, mid)
+ return (0, mid)
+
+ def _unsubscribe(topic):
+ mock_client.on_unsubscribe(0, 0, mid)
+ return (0, mid)
+
mock_client = mock_client.return_value
mock_client.connect.return_value = 0
- mock_client.subscribe.return_value = (0, 0)
- mock_client.unsubscribe.return_value = (0, 0)
+ mock_client.subscribe.side_effect = _subscribe
+ mock_client.unsubscribe.side_effect = _unsubscribe
mock_client.publish.side_effect = _async_fire_mqtt_message
yield mock_client
diff --git a/tests/fixtures/accuweather/forecast_data.json b/tests/fixtures/accuweather/forecast_data.json
new file mode 100644
index 00000000000..2de06dc66f4
--- /dev/null
+++ b/tests/fixtures/accuweather/forecast_data.json
@@ -0,0 +1,981 @@
+[
+ {
+ "Date": "2020-07-26T07:00:00+02:00",
+ "EpochDate": 1595739600,
+ "HoursOfSun": 7.2,
+ "DegreeDaySummary": {
+ "Heating": {
+ "Value": 0.0,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Cooling": {
+ "Value": 4.0,
+ "Unit": "C",
+ "UnitType": 17
+ }
+ },
+ "Ozone": {
+ "Value": 32,
+ "Category": "Good",
+ "CategoryValue": 1
+ },
+ "Grass": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "Mold": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "Ragweed": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "Tree": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "UVIndex": {
+ "Value": 5,
+ "Category": "Moderate",
+ "CategoryValue": 2
+ },
+ "TemperatureMin": {
+ "Value": 15.4,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "TemperatureMax": {
+ "Value": 29.5,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureMin": {
+ "Value": 15.1,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureMax": {
+ "Value": 29.8,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureShadeMin": {
+ "Value": 15.1,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureShadeMax": {
+ "Value": 28.0,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "IconDay": 17,
+ "IconPhraseDay": "Partly sunny w/ t-storms",
+ "HasPrecipitationDay": true,
+ "PrecipitationTypeDay": "Rain",
+ "PrecipitationIntensityDay": "Moderate",
+ "ShortPhraseDay": "A shower and t-storm around",
+ "LongPhraseDay": "Clouds and sunshine with a couple of showers and a thunderstorm around late this afternoon",
+ "PrecipitationProbabilityDay": 60,
+ "ThunderstormProbabilityDay": 40,
+ "RainProbabilityDay": 60,
+ "SnowProbabilityDay": 0,
+ "IceProbabilityDay": 0,
+ "WindDay": {
+ "Speed": {
+ "Value": 13.0,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 166,
+ "Localized": "SSE",
+ "English": "SSE"
+ }
+ },
+ "WindGustDay": {
+ "Speed": {
+ "Value": 29.6,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 178,
+ "Localized": "S",
+ "English": "S"
+ }
+ },
+ "TotalLiquidDay": {
+ "Value": 2.5,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "RainDay": {
+ "Value": 2.5,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SnowDay": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "IceDay": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "HoursOfPrecipitationDay": 1.0,
+ "HoursOfRainDay": 1.0,
+ "HoursOfSnowDay": 0.0,
+ "HoursOfIceDay": 0.0,
+ "CloudCoverDay": 58,
+ "IconNight": 41,
+ "IconPhraseNight": "Partly cloudy w/ t-storms",
+ "HasPrecipitationNight": true,
+ "PrecipitationTypeNight": "Rain",
+ "PrecipitationIntensityNight": "Moderate",
+ "ShortPhraseNight": "Partly cloudy",
+ "LongPhraseNight": "Partly cloudy",
+ "PrecipitationProbabilityNight": 57,
+ "ThunderstormProbabilityNight": 40,
+ "RainProbabilityNight": 57,
+ "SnowProbabilityNight": 0,
+ "IceProbabilityNight": 0,
+ "WindNight": {
+ "Speed": {
+ "Value": 7.4,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 289,
+ "Localized": "WNW",
+ "English": "WNW"
+ }
+ },
+ "WindGustNight": {
+ "Speed": {
+ "Value": 18.5,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 256,
+ "Localized": "WSW",
+ "English": "WSW"
+ }
+ },
+ "TotalLiquidNight": {
+ "Value": 2.3,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "RainNight": {
+ "Value": 2.3,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SnowNight": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "IceNight": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "HoursOfPrecipitationNight": 1.0,
+ "HoursOfRainNight": 1.0,
+ "HoursOfSnowNight": 0.0,
+ "HoursOfIceNight": 0.0,
+ "CloudCoverNight": 65
+ },
+ {
+ "Date": "2020-07-27T07:00:00+02:00",
+ "EpochDate": 1595826000,
+ "HoursOfSun": 7.4,
+ "DegreeDaySummary": {
+ "Heating": {
+ "Value": 0.0,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Cooling": {
+ "Value": 3.0,
+ "Unit": "C",
+ "UnitType": 17
+ }
+ },
+ "Ozone": {
+ "Value": 39,
+ "Category": "Good",
+ "CategoryValue": 1
+ },
+ "Grass": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "Mold": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "Ragweed": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "Tree": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "UVIndex": {
+ "Value": 7,
+ "Category": "High",
+ "CategoryValue": 3
+ },
+ "TemperatureMin": {
+ "Value": 15.9,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "TemperatureMax": {
+ "Value": 26.2,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureMin": {
+ "Value": 15.8,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureMax": {
+ "Value": 28.9,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureShadeMin": {
+ "Value": 15.8,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureShadeMax": {
+ "Value": 25.0,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "IconDay": 4,
+ "IconPhraseDay": "Intermittent clouds",
+ "HasPrecipitationDay": false,
+ "ShortPhraseDay": "Clouds and sun",
+ "LongPhraseDay": "Clouds and sun",
+ "PrecipitationProbabilityDay": 25,
+ "ThunderstormProbabilityDay": 24,
+ "RainProbabilityDay": 25,
+ "SnowProbabilityDay": 0,
+ "IceProbabilityDay": 0,
+ "WindDay": {
+ "Speed": {
+ "Value": 9.3,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 297,
+ "Localized": "WNW",
+ "English": "WNW"
+ }
+ },
+ "WindGustDay": {
+ "Speed": {
+ "Value": 14.8,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 317,
+ "Localized": "NW",
+ "English": "NW"
+ }
+ },
+ "TotalLiquidDay": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "RainDay": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SnowDay": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "IceDay": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "HoursOfPrecipitationDay": 0.0,
+ "HoursOfRainDay": 0.0,
+ "HoursOfSnowDay": 0.0,
+ "HoursOfIceDay": 0.0,
+ "CloudCoverDay": 52,
+ "IconNight": 36,
+ "IconPhraseNight": "Intermittent clouds",
+ "HasPrecipitationNight": false,
+ "ShortPhraseNight": "Partly cloudy",
+ "LongPhraseNight": "Partly cloudy",
+ "PrecipitationProbabilityNight": 6,
+ "ThunderstormProbabilityNight": 0,
+ "RainProbabilityNight": 6,
+ "SnowProbabilityNight": 0,
+ "IceProbabilityNight": 0,
+ "WindNight": {
+ "Speed": {
+ "Value": 7.4,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 162,
+ "Localized": "SSE",
+ "English": "SSE"
+ }
+ },
+ "WindGustNight": {
+ "Speed": {
+ "Value": 14.8,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 175,
+ "Localized": "S",
+ "English": "S"
+ }
+ },
+ "TotalLiquidNight": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "RainNight": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SnowNight": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "IceNight": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "HoursOfPrecipitationNight": 0.0,
+ "HoursOfRainNight": 0.0,
+ "HoursOfSnowNight": 0.0,
+ "HoursOfIceNight": 0.0,
+ "CloudCoverNight": 63
+ },
+ {
+ "Date": "2020-07-28T07:00:00+02:00",
+ "EpochDate": 1595912400,
+ "HoursOfSun": 5.7,
+ "DegreeDaySummary": {
+ "Heating": {
+ "Value": 0.0,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Cooling": {
+ "Value": 6.0,
+ "Unit": "C",
+ "UnitType": 17
+ }
+ },
+ "Ozone": {
+ "Value": 29,
+ "Category": "Good",
+ "CategoryValue": 1
+ },
+ "Grass": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "Mold": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "Ragweed": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "Tree": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "UVIndex": {
+ "Value": 7,
+ "Category": "High",
+ "CategoryValue": 3
+ },
+ "TemperatureMin": {
+ "Value": 16.8,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "TemperatureMax": {
+ "Value": 31.7,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureMin": {
+ "Value": 16.7,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureMax": {
+ "Value": 31.6,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureShadeMin": {
+ "Value": 16.7,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureShadeMax": {
+ "Value": 30.0,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "IconDay": 4,
+ "IconPhraseDay": "Intermittent clouds",
+ "HasPrecipitationDay": false,
+ "ShortPhraseDay": "Partly sunny and very warm",
+ "LongPhraseDay": "Very warm with a blend of sun and clouds",
+ "PrecipitationProbabilityDay": 10,
+ "ThunderstormProbabilityDay": 4,
+ "RainProbabilityDay": 10,
+ "SnowProbabilityDay": 0,
+ "IceProbabilityDay": 0,
+ "WindDay": {
+ "Speed": {
+ "Value": 16.7,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 198,
+ "Localized": "SSW",
+ "English": "SSW"
+ }
+ },
+ "WindGustDay": {
+ "Speed": {
+ "Value": 24.1,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 198,
+ "Localized": "SSW",
+ "English": "SSW"
+ }
+ },
+ "TotalLiquidDay": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "RainDay": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SnowDay": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "IceDay": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "HoursOfPrecipitationDay": 0.0,
+ "HoursOfRainDay": 0.0,
+ "HoursOfSnowDay": 0.0,
+ "HoursOfIceDay": 0.0,
+ "CloudCoverDay": 65,
+ "IconNight": 36,
+ "IconPhraseNight": "Intermittent clouds",
+ "HasPrecipitationNight": false,
+ "ShortPhraseNight": "Partly cloudy",
+ "LongPhraseNight": "Partly cloudy",
+ "PrecipitationProbabilityNight": 25,
+ "ThunderstormProbabilityNight": 24,
+ "RainProbabilityNight": 25,
+ "SnowProbabilityNight": 0,
+ "IceProbabilityNight": 0,
+ "WindNight": {
+ "Speed": {
+ "Value": 9.3,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 265,
+ "Localized": "W",
+ "English": "W"
+ }
+ },
+ "WindGustNight": {
+ "Speed": {
+ "Value": 22.2,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 271,
+ "Localized": "W",
+ "English": "W"
+ }
+ },
+ "TotalLiquidNight": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "RainNight": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SnowNight": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "IceNight": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "HoursOfPrecipitationNight": 0.0,
+ "HoursOfRainNight": 0.0,
+ "HoursOfSnowNight": 0.0,
+ "HoursOfIceNight": 0.0,
+ "CloudCoverNight": 53
+ },
+ {
+ "Date": "2020-07-29T07:00:00+02:00",
+ "EpochDate": 1595998800,
+ "HoursOfSun": 9.4,
+ "DegreeDaySummary": {
+ "Heating": {
+ "Value": 0.0,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Cooling": {
+ "Value": 0.0,
+ "Unit": "C",
+ "UnitType": 17
+ }
+ },
+ "Ozone": {
+ "Value": 18,
+ "Category": "Good",
+ "CategoryValue": 1
+ },
+ "Grass": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "Mold": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "Ragweed": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "Tree": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "UVIndex": {
+ "Value": 6,
+ "Category": "High",
+ "CategoryValue": 3
+ },
+ "TemperatureMin": {
+ "Value": 11.7,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "TemperatureMax": {
+ "Value": 24.0,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureMin": {
+ "Value": 10.1,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureMax": {
+ "Value": 26.5,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureShadeMin": {
+ "Value": 10.1,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureShadeMax": {
+ "Value": 22.5,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "IconDay": 3,
+ "IconPhraseDay": "Partly sunny",
+ "HasPrecipitationDay": false,
+ "ShortPhraseDay": "Cooler with partial sunshine",
+ "LongPhraseDay": "Cooler with partial sunshine",
+ "PrecipitationProbabilityDay": 9,
+ "ThunderstormProbabilityDay": 0,
+ "RainProbabilityDay": 9,
+ "SnowProbabilityDay": 0,
+ "IceProbabilityDay": 0,
+ "WindDay": {
+ "Speed": {
+ "Value": 13.0,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 293,
+ "Localized": "WNW",
+ "English": "WNW"
+ }
+ },
+ "WindGustDay": {
+ "Speed": {
+ "Value": 24.1,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 271,
+ "Localized": "W",
+ "English": "W"
+ }
+ },
+ "TotalLiquidDay": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "RainDay": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SnowDay": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "IceDay": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "HoursOfPrecipitationDay": 0.0,
+ "HoursOfRainDay": 0.0,
+ "HoursOfSnowDay": 0.0,
+ "HoursOfIceDay": 0.0,
+ "CloudCoverDay": 45,
+ "IconNight": 34,
+ "IconPhraseNight": "Mostly clear",
+ "HasPrecipitationNight": false,
+ "ShortPhraseNight": "Mainly clear",
+ "LongPhraseNight": "Mainly clear",
+ "PrecipitationProbabilityNight": 1,
+ "ThunderstormProbabilityNight": 0,
+ "RainProbabilityNight": 1,
+ "SnowProbabilityNight": 0,
+ "IceProbabilityNight": 0,
+ "WindNight": {
+ "Speed": {
+ "Value": 11.1,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 264,
+ "Localized": "W",
+ "English": "W"
+ }
+ },
+ "WindGustNight": {
+ "Speed": {
+ "Value": 18.5,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 266,
+ "Localized": "W",
+ "English": "W"
+ }
+ },
+ "TotalLiquidNight": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "RainNight": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SnowNight": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "IceNight": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "HoursOfPrecipitationNight": 0.0,
+ "HoursOfRainNight": 0.0,
+ "HoursOfSnowNight": 0.0,
+ "HoursOfIceNight": 0.0,
+ "CloudCoverNight": 27
+ },
+ {
+ "Date": "2020-07-30T07:00:00+02:00",
+ "EpochDate": 1596085200,
+ "HoursOfSun": 9.2,
+ "DegreeDaySummary": {
+ "Heating": {
+ "Value": 1.0,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "Cooling": {
+ "Value": 0.0,
+ "Unit": "C",
+ "UnitType": 17
+ }
+ },
+ "Ozone": {
+ "Value": 14,
+ "Category": "Good",
+ "CategoryValue": 1
+ },
+ "Grass": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "Mold": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "Ragweed": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "Tree": {
+ "Value": 0,
+ "Category": "Low",
+ "CategoryValue": 1
+ },
+ "UVIndex": {
+ "Value": 7,
+ "Category": "High",
+ "CategoryValue": 3
+ },
+ "TemperatureMin": {
+ "Value": 12.2,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "TemperatureMax": {
+ "Value": 21.4,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureMin": {
+ "Value": 11.3,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureMax": {
+ "Value": 22.2,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureShadeMin": {
+ "Value": 11.3,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "RealFeelTemperatureShadeMax": {
+ "Value": 19.5,
+ "Unit": "C",
+ "UnitType": 17
+ },
+ "IconDay": 4,
+ "IconPhraseDay": "Intermittent clouds",
+ "HasPrecipitationDay": false,
+ "ShortPhraseDay": "Clouds and sun",
+ "LongPhraseDay": "Intervals of clouds and sunshine",
+ "PrecipitationProbabilityDay": 1,
+ "ThunderstormProbabilityDay": 0,
+ "RainProbabilityDay": 1,
+ "SnowProbabilityDay": 0,
+ "IceProbabilityDay": 0,
+ "WindDay": {
+ "Speed": {
+ "Value": 18.5,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 280,
+ "Localized": "W",
+ "English": "W"
+ }
+ },
+ "WindGustDay": {
+ "Speed": {
+ "Value": 27.8,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 273,
+ "Localized": "W",
+ "English": "W"
+ }
+ },
+ "TotalLiquidDay": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "RainDay": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SnowDay": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "IceDay": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "HoursOfPrecipitationDay": 0.0,
+ "HoursOfRainDay": 0.0,
+ "HoursOfSnowDay": 0.0,
+ "HoursOfIceDay": 0.0,
+ "CloudCoverDay": 50,
+ "IconNight": 34,
+ "IconPhraseNight": "Mostly clear",
+ "HasPrecipitationNight": false,
+ "ShortPhraseNight": "Mostly clear",
+ "LongPhraseNight": "Mostly clear",
+ "PrecipitationProbabilityNight": 3,
+ "ThunderstormProbabilityNight": 0,
+ "RainProbabilityNight": 3,
+ "SnowProbabilityNight": 0,
+ "IceProbabilityNight": 0,
+ "WindNight": {
+ "Speed": {
+ "Value": 9.3,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 272,
+ "Localized": "W",
+ "English": "W"
+ }
+ },
+ "WindGustNight": {
+ "Speed": {
+ "Value": 18.5,
+ "Unit": "km/h",
+ "UnitType": 7
+ },
+ "Direction": {
+ "Degrees": 274,
+ "Localized": "W",
+ "English": "W"
+ }
+ },
+ "TotalLiquidNight": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "RainNight": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "SnowNight": {
+ "Value": 0.0,
+ "Unit": "cm",
+ "UnitType": 4
+ },
+ "IceNight": {
+ "Value": 0.0,
+ "Unit": "mm",
+ "UnitType": 3
+ },
+ "HoursOfPrecipitationNight": 0.0,
+ "HoursOfRainNight": 0.0,
+ "HoursOfSnowNight": 0.0,
+ "HoursOfIceNight": 0.0,
+ "CloudCoverNight": 13
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/bayesian/configuration.yaml b/tests/fixtures/bayesian/configuration.yaml
new file mode 100644
index 00000000000..56a490d4aec
--- /dev/null
+++ b/tests/fixtures/bayesian/configuration.yaml
@@ -0,0 +1,10 @@
+binary_sensor:
+ - platform: bayesian
+ prior: 0.1
+ observations:
+ - entity_id: 'switch.kitchen_lights'
+ prob_given_true: 0.6
+ prob_given_false: 0.2
+ platform: 'state'
+ to_state: 'on'
+ name: test2
diff --git a/tests/fixtures/command_line/configuration.yaml b/tests/fixtures/command_line/configuration.yaml
new file mode 100644
index 00000000000..f210b640338
--- /dev/null
+++ b/tests/fixtures/command_line/configuration.yaml
@@ -0,0 +1,6 @@
+cover:
+ - platform: command_line
+ covers:
+ from_yaml:
+ command_state: "echo closed"
+ value_template: "{{ value }}"
diff --git a/tests/fixtures/filesize/configuration.yaml b/tests/fixtures/filesize/configuration.yaml
new file mode 100644
index 00000000000..ea73be72b80
--- /dev/null
+++ b/tests/fixtures/filesize/configuration.yaml
@@ -0,0 +1,4 @@
+sensor:
+ - platform: filesize
+ file_paths:
+ - "/dev/null"
diff --git a/tests/fixtures/filter/configuration.yaml b/tests/fixtures/filter/configuration.yaml
new file mode 100644
index 00000000000..c29c504b2ee
--- /dev/null
+++ b/tests/fixtures/filter/configuration.yaml
@@ -0,0 +1,11 @@
+sensor:
+ - platform: filter
+ name: "filtered realistic humidity"
+ entity_id: sensor.realistic_humidity
+ filters:
+ - filter: outlier
+ window_size: 4
+ radius: 4.0
+ - filter: lowpass
+ time_constant: 10
+ precision: 2
diff --git a/tests/fixtures/flo/device_info_response.json b/tests/fixtures/flo/device_info_response.json
new file mode 100644
index 00000000000..24351c0e632
--- /dev/null
+++ b/tests/fixtures/flo/device_info_response.json
@@ -0,0 +1,238 @@
+{
+ "isConnected": true,
+ "fwVersion": "6.1.1",
+ "lastHeardFromTime": "2020-07-24T12:45:00Z",
+ "fwProperties": {
+ "alarm_away_high_flow_rate_shut_off_enabled": true,
+ "alarm_away_high_water_use_shut_off_enabled": true,
+ "alarm_away_long_flow_event_shut_off_enabled": true,
+ "alarm_away_v2_shut_off_enabled": true,
+ "alarm_home_high_flow_rate_shut_off_deferment": 300,
+ "alarm_home_high_flow_rate_shut_off_enabled": true,
+ "alarm_home_high_water_use_shut_off_deferment": 300,
+ "alarm_home_high_water_use_shut_off_enabled": true,
+ "alarm_home_long_flow_event_shut_off_deferment": 300,
+ "alarm_home_long_flow_event_shut_off_enabled": true,
+ "alarm_shut_off_enabled": true,
+ "alarm_shutoff_id": "",
+ "alarm_shutoff_time_epoch_sec": -1,
+ "alarm_snooze_enabled": true,
+ "alarm_suppress_duplicate_duration": 300,
+ "alarm_suppress_until_event_end": false,
+ "data_flosense_force_retrain": 1,
+ "data_flosense_min_flodetect_sec": 0,
+ "data_flosense_min_irr_sec": 180,
+ "data_flosense_status_interval": 1200,
+ "data_flosense_verbosity": 1,
+ "device_data_free_mb": 1465,
+ "device_installed": true,
+ "device_mem_available_kb": 339456,
+ "device_rootfs_free_kb": 711504,
+ "device_uptime_sec": 867190,
+ "feature_mode": "default",
+ "flodetect_post_enabled": true,
+ "flodetect_post_frequency": 0,
+ "flodetect_storage_days": 60,
+ "flosense_action": "",
+ "flosense_deployment_result": "success",
+ "flosense_link": "",
+ "flosense_shut_off_enabled": true,
+ "flosense_shut_off_level": 3,
+ "flosense_state": "active",
+ "flosense_version_app": "2.5.3",
+ "flosense_version_model": "2.5.0",
+ "fw_ver": "6.1.1",
+ "fw_ver_a": "6.1.1",
+ "fw_ver_b": "6.0.3",
+ "heartbeat_frequency": 1800,
+ "ht_attempt_interval": 60000,
+ "ht_check_window_max_pressure_decay_limit": 0.1,
+ "ht_check_window_width": 30000,
+ "ht_controller": "ultima",
+ "ht_max_open_closed_pressure_decay_pct_limit": 2,
+ "ht_max_pressure_growth_limit": 3,
+ "ht_max_pressure_growth_pct_limit": 3,
+ "ht_max_valve_closures_per_24h": 0,
+ "ht_min_computable_point_limit": 3,
+ "ht_min_pressure_limit": 10,
+ "ht_min_r_squared_limit": 0.9,
+ "ht_min_slope_limit": -0.6,
+ "ht_phase_1_max_pressure_decay_limit": 6,
+ "ht_phase_1_max_pressure_decay_pct_limit": 10,
+ "ht_phase_1_time_index": 12000,
+ "ht_phase_2_max_pressure_decay_limit": 6,
+ "ht_phase_2_max_pressure_decay_pct_limit": 10,
+ "ht_phase_2_time_index": 30000,
+ "ht_phase_3_max_pressure_decay_limit": 3,
+ "ht_phase_3_max_pressure_decay_pct_limit": 5,
+ "ht_phase_3_time_index": 240000,
+ "ht_phase_4_max_pressure_decay_limit": 1.5,
+ "ht_phase_4_max_pressure_decay_pct_limit": 5,
+ "ht_phase_4_time_index": 480000,
+ "ht_pre_delay": 0,
+ "ht_recent_flow_event_cool_down": 1000,
+ "ht_retry_on_fail_interval": 900000,
+ "ht_scheduler": "flosense",
+ "ht_scheduler_end": "08:00",
+ "ht_scheduler_start": "06:00",
+ "ht_scheduler_ultima_allotted_time_1": "06:00",
+ "ht_scheduler_ultima_allotted_time_2": "07:00",
+ "ht_scheduler_ultima_allotted_time_3": "",
+ "ht_times_per_day": 1,
+ "log_bytes_sent": 0,
+ "log_enabled": true,
+ "log_frequency": 3600,
+ "log_send": false,
+ "mender_check": false,
+ "mender_host": "https://mender.flotech.co",
+ "mender_parts_link": "",
+ "mender_ping_delay": 300,
+ "mender_signature": "20200610",
+ "motor_delay_close": 175,
+ "motor_delay_open": 0,
+ "motor_retry_count": 2,
+ "motor_timeout": 5000,
+ "mqtt_host": "mqtt.flosecurecloud.com",
+ "mqtt_port": 8884,
+ "pes_away_max_duration": 1505,
+ "pes_away_max_pressure": 150,
+ "pes_away_max_temperature": 226,
+ "pes_away_max_volume": 91.8913240498193,
+ "pes_away_min_pressure": 20,
+ "pes_away_min_pressure_duration": 5,
+ "pes_away_min_temperature": 36,
+ "pes_away_min_temperature_duration": 10,
+ "pes_away_v1_high_flow_rate": 7.825131772346,
+ "pes_away_v1_high_flow_rate_duration": 5,
+ "pes_away_v2_high_flow_rate": 0.5,
+ "pes_away_v2_high_flow_rate_duration": 5,
+ "pes_home_high_flow_rate": 1000,
+ "pes_home_high_flow_rate_duration": 20,
+ "pes_home_max_duration": 7431,
+ "pes_home_max_pressure": 150,
+ "pes_home_max_temperature": 226,
+ "pes_home_max_volume": 185.56459045410156,
+ "pes_home_min_pressure": 20,
+ "pes_home_min_pressure_duration": 5,
+ "pes_home_min_temperature": 36,
+ "pes_home_min_temperature_duration": 10,
+ "pes_moderately_high_pressure": 80,
+ "pes_moderately_high_pressure_count": 43200,
+ "pes_moderately_high_pressure_delay": 300,
+ "pes_moderately_high_pressure_period": 10,
+ "player_action": "disabled",
+ "player_flow": 0,
+ "player_min_pressure": 40,
+ "player_pressure": 60,
+ "player_temperature": 50,
+ "power_downtime_last_24h": 91,
+ "power_downtime_last_7days": 91,
+ "power_downtime_last_reboot": 91,
+ "pt_state": "ok",
+ "reboot_count": 26,
+ "reboot_count_7days": 1,
+ "reboot_reason": "power_cycle",
+ "s3_bucket_host": "api-bulk.meetflo.com",
+ "serial_number": "111111111111",
+ "system_mode": 2,
+ "tag": "",
+ "telemetry_batched_enabled": true,
+ "telemetry_batched_hf_enabled": true,
+ "telemetry_batched_hf_interval": 10800,
+ "telemetry_batched_hf_poll_rate": 100,
+ "telemetry_batched_interval": 300,
+ "telemetry_batched_pending_storage": 30,
+ "telemetry_batched_sent_storage": 30,
+ "telemetry_flow_rate": 0,
+ "telemetry_pressure": 42.4,
+ "telemetry_realtime_change_gpm": 0,
+ "telemetry_realtime_change_psi": 0,
+ "telemetry_realtime_enabled": true,
+ "telemetry_realtime_interval": 1,
+ "telemetry_realtime_packet_uptime": 0,
+ "telemetry_realtime_session_last_epoch": 1595555701518,
+ "telemetry_realtime_sessions_7days": 25,
+ "telemetry_realtime_storage": 7,
+ "telemetry_realtime_timeout": 300,
+ "telemetry_temperature": 68,
+ "valve_actuation_count": 906,
+ "valve_actuation_timeout_count": 0,
+ "valve_state": 1,
+ "vpn_enabled": false,
+ "vpn_ip": "",
+ "water_event_enabled": false,
+ "water_event_min_duration": 2,
+ "water_event_min_gallons": 0.1,
+ "wifi_bytes_received": 24164,
+ "wifi_bytes_sent": 18319,
+ "wifi_disconnections": 76,
+ "wifi_rssi": -50,
+ "wifi_sta_enc": "psk2",
+ "wifi_sta_ip": "192.168.1.1",
+ "wifi_sta_ssid": "SOMESSID",
+ "zit_auto_count": 2363,
+ "zit_manual_count": 0
+ },
+ "id": "98765",
+ "macAddress": "111111111111",
+ "nickname": "Smart Water Shutoff",
+ "isPaired": true,
+ "deviceModel": "flo_device_075_v2",
+ "deviceType": "flo_device_v2",
+ "irrigationType": "sprinklers",
+ "systemMode": {
+ "isLocked": false,
+ "shouldInherit": true,
+ "lastKnown": "home",
+ "target": "home"
+ },
+ "valve": { "target": "open", "lastKnown": "open" },
+ "installStatus": {
+ "isInstalled": true,
+ "installDate": "2019-05-04T13:50:04.758Z"
+ },
+ "learning": { "outOfLearningDate": "2019-05-10T21:45:48.916Z" },
+ "notifications": {
+ "pending": {
+ "infoCount": 0,
+ "warningCount": 2,
+ "criticalCount": 0,
+ "alarmCount": [
+ { "id": 30, "severity": "warning", "count": 1 },
+ { "id": 31, "severity": "warning", "count": 1 }
+ ],
+ "info": { "count": 0, "devices": { "count": 0, "absolute": 0 } },
+ "warning": { "count": 2, "devices": { "count": 1, "absolute": 1 } },
+ "critical": { "count": 0, "devices": { "count": 0, "absolute": 0 } }
+ }
+ },
+ "hardwareThresholds": {
+ "gpm": { "okMin": 0, "okMax": 29, "minValue": 0, "maxValue": 35 },
+ "psi": { "okMin": 30, "okMax": 80, "minValue": 0, "maxValue": 100 },
+ "lpm": { "okMin": 0, "okMax": 110, "minValue": 0, "maxValue": 130 },
+ "kPa": { "okMin": 210, "okMax": 550, "minValue": 0, "maxValue": 700 },
+ "tempF": { "okMin": 50, "okMax": 80, "minValue": 0, "maxValue": 100 },
+ "tempC": { "okMin": 10, "okMax": 30, "minValue": 0, "maxValue": 40 }
+ },
+ "serialNumber": "111111111111",
+ "connectivity": { "rssi": -47, "ssid": "SOMESSID" },
+ "telemetry": {
+ "current": {
+ "gpm": 0,
+ "psi": 54.20000076293945,
+ "tempF": 70,
+ "updated": "2020-07-24T12:20:58Z"
+ }
+ },
+ "healthTest": {
+ "config": {
+ "enabled": true,
+ "timesPerDay": 1,
+ "start": "02:00",
+ "end": "04:00"
+ }
+ },
+ "shutoff": { "scheduledAt": "1970-01-01T00:00:00.000Z" },
+ "actionRules": [],
+ "location": { "id": "mmnnoopp" }
+}
diff --git a/tests/fixtures/flo/device_info_response_closed.json b/tests/fixtures/flo/device_info_response_closed.json
new file mode 100644
index 00000000000..28ae3d8154c
--- /dev/null
+++ b/tests/fixtures/flo/device_info_response_closed.json
@@ -0,0 +1,238 @@
+{
+ "isConnected": true,
+ "fwVersion": "6.1.1",
+ "lastHeardFromTime": "2020-07-24T12:45:00Z",
+ "fwProperties": {
+ "alarm_away_high_flow_rate_shut_off_enabled": true,
+ "alarm_away_high_water_use_shut_off_enabled": true,
+ "alarm_away_long_flow_event_shut_off_enabled": true,
+ "alarm_away_v2_shut_off_enabled": true,
+ "alarm_home_high_flow_rate_shut_off_deferment": 300,
+ "alarm_home_high_flow_rate_shut_off_enabled": true,
+ "alarm_home_high_water_use_shut_off_deferment": 300,
+ "alarm_home_high_water_use_shut_off_enabled": true,
+ "alarm_home_long_flow_event_shut_off_deferment": 300,
+ "alarm_home_long_flow_event_shut_off_enabled": true,
+ "alarm_shut_off_enabled": true,
+ "alarm_shutoff_id": "",
+ "alarm_shutoff_time_epoch_sec": -1,
+ "alarm_snooze_enabled": true,
+ "alarm_suppress_duplicate_duration": 300,
+ "alarm_suppress_until_event_end": false,
+ "data_flosense_force_retrain": 1,
+ "data_flosense_min_flodetect_sec": 0,
+ "data_flosense_min_irr_sec": 180,
+ "data_flosense_status_interval": 1200,
+ "data_flosense_verbosity": 1,
+ "device_data_free_mb": 1465,
+ "device_installed": true,
+ "device_mem_available_kb": 339456,
+ "device_rootfs_free_kb": 711504,
+ "device_uptime_sec": 867190,
+ "feature_mode": "default",
+ "flodetect_post_enabled": true,
+ "flodetect_post_frequency": 0,
+ "flodetect_storage_days": 60,
+ "flosense_action": "",
+ "flosense_deployment_result": "success",
+ "flosense_link": "",
+ "flosense_shut_off_enabled": true,
+ "flosense_shut_off_level": 3,
+ "flosense_state": "active",
+ "flosense_version_app": "2.5.3",
+ "flosense_version_model": "2.5.0",
+ "fw_ver": "6.1.1",
+ "fw_ver_a": "6.1.1",
+ "fw_ver_b": "6.0.3",
+ "heartbeat_frequency": 1800,
+ "ht_attempt_interval": 60000,
+ "ht_check_window_max_pressure_decay_limit": 0.1,
+ "ht_check_window_width": 30000,
+ "ht_controller": "ultima",
+ "ht_max_open_closed_pressure_decay_pct_limit": 2,
+ "ht_max_pressure_growth_limit": 3,
+ "ht_max_pressure_growth_pct_limit": 3,
+ "ht_max_valve_closures_per_24h": 0,
+ "ht_min_computable_point_limit": 3,
+ "ht_min_pressure_limit": 10,
+ "ht_min_r_squared_limit": 0.9,
+ "ht_min_slope_limit": -0.6,
+ "ht_phase_1_max_pressure_decay_limit": 6,
+ "ht_phase_1_max_pressure_decay_pct_limit": 10,
+ "ht_phase_1_time_index": 12000,
+ "ht_phase_2_max_pressure_decay_limit": 6,
+ "ht_phase_2_max_pressure_decay_pct_limit": 10,
+ "ht_phase_2_time_index": 30000,
+ "ht_phase_3_max_pressure_decay_limit": 3,
+ "ht_phase_3_max_pressure_decay_pct_limit": 5,
+ "ht_phase_3_time_index": 240000,
+ "ht_phase_4_max_pressure_decay_limit": 1.5,
+ "ht_phase_4_max_pressure_decay_pct_limit": 5,
+ "ht_phase_4_time_index": 480000,
+ "ht_pre_delay": 0,
+ "ht_recent_flow_event_cool_down": 1000,
+ "ht_retry_on_fail_interval": 900000,
+ "ht_scheduler": "flosense",
+ "ht_scheduler_end": "08:00",
+ "ht_scheduler_start": "06:00",
+ "ht_scheduler_ultima_allotted_time_1": "06:00",
+ "ht_scheduler_ultima_allotted_time_2": "07:00",
+ "ht_scheduler_ultima_allotted_time_3": "",
+ "ht_times_per_day": 1,
+ "log_bytes_sent": 0,
+ "log_enabled": true,
+ "log_frequency": 3600,
+ "log_send": false,
+ "mender_check": false,
+ "mender_host": "https://mender.flotech.co",
+ "mender_parts_link": "",
+ "mender_ping_delay": 300,
+ "mender_signature": "20200610",
+ "motor_delay_close": 175,
+ "motor_delay_open": 0,
+ "motor_retry_count": 2,
+ "motor_timeout": 5000,
+ "mqtt_host": "mqtt.flosecurecloud.com",
+ "mqtt_port": 8884,
+ "pes_away_max_duration": 1505,
+ "pes_away_max_pressure": 150,
+ "pes_away_max_temperature": 226,
+ "pes_away_max_volume": 91.8913240498193,
+ "pes_away_min_pressure": 20,
+ "pes_away_min_pressure_duration": 5,
+ "pes_away_min_temperature": 36,
+ "pes_away_min_temperature_duration": 10,
+ "pes_away_v1_high_flow_rate": 7.825131772346,
+ "pes_away_v1_high_flow_rate_duration": 5,
+ "pes_away_v2_high_flow_rate": 0.5,
+ "pes_away_v2_high_flow_rate_duration": 5,
+ "pes_home_high_flow_rate": 1000,
+ "pes_home_high_flow_rate_duration": 20,
+ "pes_home_max_duration": 7431,
+ "pes_home_max_pressure": 150,
+ "pes_home_max_temperature": 226,
+ "pes_home_max_volume": 185.56459045410156,
+ "pes_home_min_pressure": 20,
+ "pes_home_min_pressure_duration": 5,
+ "pes_home_min_temperature": 36,
+ "pes_home_min_temperature_duration": 10,
+ "pes_moderately_high_pressure": 80,
+ "pes_moderately_high_pressure_count": 43200,
+ "pes_moderately_high_pressure_delay": 300,
+ "pes_moderately_high_pressure_period": 10,
+ "player_action": "disabled",
+ "player_flow": 0,
+ "player_min_pressure": 40,
+ "player_pressure": 60,
+ "player_temperature": 50,
+ "power_downtime_last_24h": 91,
+ "power_downtime_last_7days": 91,
+ "power_downtime_last_reboot": 91,
+ "pt_state": "ok",
+ "reboot_count": 26,
+ "reboot_count_7days": 1,
+ "reboot_reason": "power_cycle",
+ "s3_bucket_host": "api-bulk.meetflo.com",
+ "serial_number": "111111111111",
+ "system_mode": 2,
+ "tag": "",
+ "telemetry_batched_enabled": true,
+ "telemetry_batched_hf_enabled": true,
+ "telemetry_batched_hf_interval": 10800,
+ "telemetry_batched_hf_poll_rate": 100,
+ "telemetry_batched_interval": 300,
+ "telemetry_batched_pending_storage": 30,
+ "telemetry_batched_sent_storage": 30,
+ "telemetry_flow_rate": 0,
+ "telemetry_pressure": 42.4,
+ "telemetry_realtime_change_gpm": 0,
+ "telemetry_realtime_change_psi": 0,
+ "telemetry_realtime_enabled": true,
+ "telemetry_realtime_interval": 1,
+ "telemetry_realtime_packet_uptime": 0,
+ "telemetry_realtime_session_last_epoch": 1595555701518,
+ "telemetry_realtime_sessions_7days": 25,
+ "telemetry_realtime_storage": 7,
+ "telemetry_realtime_timeout": 300,
+ "telemetry_temperature": 68,
+ "valve_actuation_count": 906,
+ "valve_actuation_timeout_count": 0,
+ "valve_state": 1,
+ "vpn_enabled": false,
+ "vpn_ip": "",
+ "water_event_enabled": false,
+ "water_event_min_duration": 2,
+ "water_event_min_gallons": 0.1,
+ "wifi_bytes_received": 24164,
+ "wifi_bytes_sent": 18319,
+ "wifi_disconnections": 76,
+ "wifi_rssi": -50,
+ "wifi_sta_enc": "psk2",
+ "wifi_sta_ip": "192.168.1.1",
+ "wifi_sta_ssid": "SOMESSID",
+ "zit_auto_count": 2363,
+ "zit_manual_count": 0
+ },
+ "id": "98765",
+ "macAddress": "111111111111",
+ "nickname": "Smart Water Shutoff",
+ "isPaired": true,
+ "deviceModel": "flo_device_075_v2",
+ "deviceType": "flo_device_v2",
+ "irrigationType": "sprinklers",
+ "systemMode": {
+ "isLocked": false,
+ "shouldInherit": true,
+ "lastKnown": "home",
+ "target": "home"
+ },
+ "valve": { "target": "closed", "lastKnown": "closed" },
+ "installStatus": {
+ "isInstalled": true,
+ "installDate": "2019-05-04T13:50:04.758Z"
+ },
+ "learning": { "outOfLearningDate": "2019-05-10T21:45:48.916Z" },
+ "notifications": {
+ "pending": {
+ "infoCount": 0,
+ "warningCount": 2,
+ "criticalCount": 0,
+ "alarmCount": [
+ { "id": 30, "severity": "warning", "count": 1 },
+ { "id": 31, "severity": "warning", "count": 1 }
+ ],
+ "info": { "count": 0, "devices": { "count": 0, "absolute": 0 } },
+ "warning": { "count": 2, "devices": { "count": 1, "absolute": 1 } },
+ "critical": { "count": 0, "devices": { "count": 0, "absolute": 0 } }
+ }
+ },
+ "hardwareThresholds": {
+ "gpm": { "okMin": 0, "okMax": 29, "minValue": 0, "maxValue": 35 },
+ "psi": { "okMin": 30, "okMax": 80, "minValue": 0, "maxValue": 100 },
+ "lpm": { "okMin": 0, "okMax": 110, "minValue": 0, "maxValue": 130 },
+ "kPa": { "okMin": 210, "okMax": 550, "minValue": 0, "maxValue": 700 },
+ "tempF": { "okMin": 50, "okMax": 80, "minValue": 0, "maxValue": 100 },
+ "tempC": { "okMin": 10, "okMax": 30, "minValue": 0, "maxValue": 40 }
+ },
+ "serialNumber": "111111111111",
+ "connectivity": { "rssi": -47, "ssid": "SOMESSID" },
+ "telemetry": {
+ "current": {
+ "gpm": 0,
+ "psi": 54.20000076293945,
+ "tempF": 70,
+ "updated": "2020-07-24T12:20:58Z"
+ }
+ },
+ "healthTest": {
+ "config": {
+ "enabled": true,
+ "timesPerDay": 1,
+ "start": "02:00",
+ "end": "04:00"
+ }
+ },
+ "shutoff": { "scheduledAt": "1970-01-01T00:00:00.000Z" },
+ "actionRules": [],
+ "location": { "id": "12345abcde" }
+}
diff --git a/tests/fixtures/flo/location_info_base_response.json b/tests/fixtures/flo/location_info_base_response.json
new file mode 100644
index 00000000000..f6840a0742b
--- /dev/null
+++ b/tests/fixtures/flo/location_info_base_response.json
@@ -0,0 +1,89 @@
+{
+ "id": "mmnnoopp",
+ "users": [
+ {
+ "id": "12345abcde"
+ }
+ ],
+ "devices": [
+ {
+ "id": "98765",
+ "macAddress": "123456abcdef"
+ }
+ ],
+ "userRoles": [
+ {
+ "userId": "12345abcde",
+ "roles": [
+ "owner"
+ ]
+ }
+ ],
+ "address": "123 Main Street",
+ "city": "Boston",
+ "state": "MA",
+ "country": "us",
+ "postalCode": "12345",
+ "timezone": "US/Easter",
+ "gallonsPerDayGoal": 240,
+ "occupants": 2,
+ "stories": 2,
+ "isProfileComplete": true,
+ "nickname": "Home",
+ "irrigationSchedule": {
+ "isEnabled": false
+ },
+ "systemMode": {
+ "target": "home"
+ },
+ "locationType": "sfh",
+ "locationSize": "lte_4000_sq_ft",
+ "waterShutoffKnown": "unsure",
+ "indoorAmenities": [],
+ "outdoorAmenities": [],
+ "plumbingAppliances": [
+ "exp_tank"
+ ],
+ "notifications": {
+ "pending": {
+ "infoCount": 0,
+ "warningCount": 1,
+ "criticalCount": 0,
+ "alarmCount": [
+ {
+ "id": 57,
+ "severity": "warning",
+ "count": 1
+ }
+ ]
+ }
+ },
+ "areas": {
+ "default": [
+ {
+ "id": "xxxxx",
+ "name": "Attic"
+ },
+ {
+ "id": "xxxxx",
+ "name": "Basement"
+ },
+ {
+ "id": "xxxxx",
+ "name": "Garage"
+ },
+ {
+ "id": "xxxxx",
+ "name": "Main Floor"
+ },
+ {
+ "id": "xxxxx",
+ "name": "Upstairs"
+ }
+ ],
+ "custom": []
+ },
+ "account": {
+ "id": "aabbccdd"
+ }
+}
diff --git a/tests/fixtures/flo/location_info_expand_devices_response.json b/tests/fixtures/flo/location_info_expand_devices_response.json
new file mode 100644
index 00000000000..138de88db25
--- /dev/null
+++ b/tests/fixtures/flo/location_info_expand_devices_response.json
@@ -0,0 +1,308 @@
+{
+ "id": "mmnnoopp",
+ "users": [
+ {
+ "id": "12345abcde"
+ }
+ ],
+ "devices": [
+ {
+ "isConnected": true,
+ "fwVersion": "4.2.4",
+ "lastHeardFromTime": "2020-01-16T19:42:06Z",
+ "fwProperties": {
+ "alarm_home_high_flow_rate_shut_off_deferment": 300,
+ "alarm_home_high_water_use_shut_off_deferment": 300,
+ "alarm_home_long_flow_event_shut_off_deferment": 300,
+ "alarm_shutoff_time_epoch_sec": -1,
+ "alarm_snooze_enabled": true,
+ "alarm_suppress_duplicate_duration": 300,
+ "alarm_suppress_until_event_end": false,
+ "data_flosense_force_retrain": 0,
+ "data_flosense_status_interval": 1200,
+ "data_flosense_verbosity": 1,
+ "device_data_free_mb": 1464,
+ "device_installed": true,
+ "device_mem_available_kb": 292780,
+ "device_rootfs_free_kb": 802604,
+ "device_uptime_sec": 334862,
+ "flosense_action": "start",
+ "flosense_deployment_result": "success",
+ "flosense_link": "",
+ "flosense_shut_off_enabled": true,
+ "flosense_shut_off_level": 2,
+ "flosense_state": "active",
+ "flosense_version_app": "2.0.0",
+ "flosense_version_model": "2.0.0",
+ "fw_ver": "4.2.4",
+ "fw_ver_a": "4.1.5",
+ "fw_ver_b": "4.2.4",
+ "ht_attempt_interval": 60000,
+ "ht_check_window_max_pressure_decay_limit": 0.1,
+ "ht_check_window_width": 30000,
+ "ht_max_open_closed_pressure_decay_pct_limit": 2,
+ "ht_max_pressure_growth_limit": 3,
+ "ht_max_pressure_growth_pct_limit": 3,
+ "ht_min_computable_point_limit": 3,
+ "ht_min_pressure_limit": 10,
+ "ht_min_r_squared_limit": 0.9,
+ "ht_min_slope_limit": -0.6,
+ "ht_phase_1_max_pressure_decay_limit": 6,
+ "ht_phase_1_max_pressure_decay_pct_limit": 10,
+ "ht_phase_1_time_index": 12000,
+ "ht_phase_2_max_pressure_decay_limit": 6,
+ "ht_phase_2_max_pressure_decay_pct_limit": 10,
+ "ht_phase_2_time_index": 30000,
+ "ht_phase_3_max_pressure_decay_limit": 3,
+ "ht_phase_3_max_pressure_decay_pct_limit": 5,
+ "ht_phase_3_time_index": 240000,
+ "ht_phase_4_max_pressure_decay_limit": 1.5,
+ "ht_phase_4_max_pressure_decay_pct_limit": 5,
+ "ht_phase_4_time_index": 480000,
+ "ht_pre_delay": 0,
+ "ht_recent_flow_event_cool_down": 1000,
+ "ht_retry_on_fail_interval": 900000,
+ "ht_times_per_day": 1,
+ "log_bytes_sent": 176255,
+ "log_frequency": 3600,
+ "mender_host": "https://mender.flotech.co",
+ "motor_delay_close": 175,
+ "motor_delay_open": 0,
+ "motor_retry_count": 2,
+ "motor_timeout": 5000,
+ "pes_away_max_duration": 3600,
+ "pes_away_max_pressure": 150,
+ "pes_away_max_temperature": 226,
+ "pes_away_max_volume": 50,
+ "pes_away_min_pressure": 20,
+ "pes_away_min_temperature": 36,
+ "pes_away_v1_high_flow_rate": 8,
+ "pes_away_v1_high_flow_rate_duration": 5,
+ "pes_away_v2_high_flow_rate": 0.5,
+ "pes_away_v2_high_flow_rate_duration": 5,
+ "pes_home_high_flow_rate": 9.902778339386035,
+ "pes_home_high_flow_rate_duration": 20,
+ "pes_home_max_duration": 1738,
+ "pes_home_max_pressure": 150,
+ "pes_home_max_temperature": 226,
+ "pes_home_max_volume": 33.851015281677256,
+ "pes_home_min_pressure": 20,
+ "pes_home_min_temperature": 36,
+ "pes_moderately_high_pressure": 80,
+ "pes_moderately_high_pressure_count": 43200,
+ "pes_moderately_high_pressure_delay": 300,
+ "pes_moderately_high_pressure_period": 10,
+ "player_action": "disabled",
+ "player_flow": 0,
+ "player_min_pressure": 40,
+ "player_pressure": 60,
+ "player_temperature": 50,
+ "power_downtime_last_24h": 0,
+ "power_downtime_last_7days": 69,
+ "power_downtime_last_reboot": 0,
+ "reboot_count": 27,
+ "reboot_count_7days": 2,
+ "reboot_reason": "power_cycle",
+ "s3_bucket_host": "api-bulk.meetflo.com",
+ "serial_number": "294215640115",
+ "system_mode": 2,
+ "telemetry_batched_enabled": true,
+ "telemetry_batched_interval": 300,
+ "telemetry_batched_pending_storage": 30,
+ "telemetry_batched_sent_storage": 30,
+ "telemetry_flow_rate": 0,
+ "telemetry_pressure": 78.07500375373304,
+ "telemetry_realtime_change_gpm": 0,
+ "telemetry_realtime_change_psi": 0,
+ "telemetry_realtime_interval": 1,
+ "telemetry_realtime_session_last_epoch": 0,
+ "telemetry_realtime_sessions_7days": 0,
+ "telemetry_realtime_storage": 7,
+ "telemetry_realtime_timeout": 299,
+ "telemetry_temperature": 57.00000047232966,
+ "valve_actuation_count": 3465,
+ "valve_actuation_timeout_count": 0,
+ "valve_state": 1,
+ "wifi_bytes_received": 145018827,
+ "wifi_bytes_sent": 80891494,
+ "wifi_disconnections": 423,
+ "wifi_rssi": -61,
+ "wifi_sta_enc": "psk2",
+ "wifi_sta_ssid": "IP freely",
+ "zit_auto_count": 233,
+ "zit_manual_count": 0
+ },
+ "id": "98765",
+ "macAddress": "123456abcdef",
+ "nickname": "Smart Water Shutoff",
+ "isPaired": true,
+ "deviceModel": "flo_device_075_v2",
+ "deviceType": "flo_device_v2",
+ "irrigationType": "sprinklers",
+ "systemMode": {
+ "isLocked": false,
+ "shouldInherit": true,
+ "lastKnown": "home",
+ "target": "home"
+ },
+ "valve": {
+ "target": "open",
+ "lastKnown": "open"
+ },
+ "installStatus": {
+ "isInstalled": true,
+ "installDate": "2018-08-16T02:07:39.483Z"
+ },
+ "learning": {
+ "outOfLearningDate": "2018-08-16T02:07:39.483Z"
+ },
+ "notifications": {
+ "pending": {
+ "infoCount": 0,
+ "warningCount": 1,
+ "criticalCount": 0,
+ "alarmCount": [
+ {
+ "id": 57,
+ "severity": "warning",
+ "count": 1
+ }
+ ]
+ }
+ },
+ "hardwareThresholds": {
+ "gpm": {
+ "okMin": 0,
+ "okMax": 29,
+ "minValue": 0,
+ "maxValue": 35
+ },
+ "psi": {
+ "okMin": 30,
+ "okMax": 80,
+ "minValue": 0,
+ "maxValue": 100
+ },
+ "lpm": {
+ "okMin": 0,
+ "okMax": 110,
+ "minValue": 0,
+ "maxValue": 130
+ },
+ "kPa": {
+ "okMin": 210,
+ "okMax": 550,
+ "minValue": 0,
+ "maxValue": 700
+ },
+ "tempF": {
+ "okMin": 50,
+ "okMax": 80,
+ "minValue": 0,
+ "maxValue": 100
+ },
+ "tempC": {
+ "okMin": 10,
+ "okMax": 30,
+ "minValue": 0,
+ "maxValue": 40
+ }
+ },
+ "serialNumber": "xxxxx",
+ "connectivity": {
+ "rssi": -61,
+ "ssid": "IP freely"
+ },
+ "telemetry": {
+ "current": {
+ "gpm": 0,
+ "psi": 78.9000015258789,
+ "tempF": 57,
+ "updated": "2020-01-16T19:01:59Z"
+ }
+ },
+ "shutoff": {
+ "scheduledAt": "1970-01-01T00:00:00.000Z"
+ },
+ "actionRules": [],
+ "location": {
+ "id": "mmnnoopp"
+ }
+ }
+ ],
+ "userRoles": [
+ {
+ "userId": "12345abcde",
+ "roles": [
+ "owner"
+ ]
+ }
+ ],
+ "address": "123 Main Street",
+ "city": "Boston",
+ "state": "MA",
+ "country": "us",
+ "postalCode": "12345",
+ "timezone": "US/Eastern",
+ "gallonsPerDayGoal": 240,
+ "occupants": 2,
+ "stories": 2,
+ "isProfileComplete": true,
+ "nickname": "Home",
+ "irrigationSchedule": {
+ "isEnabled": false
+ },
+ "systemMode": {
+ "target": "home"
+ },
+ "locationType": "sfh",
+ "locationSize": "lte_4000_sq_ft",
+ "waterShutoffKnown": "unsure",
+ "indoorAmenities": [],
+ "outdoorAmenities": [],
+ "plumbingAppliances": [
+ "exp_tank"
+ ],
+ "notifications": {
+ "pending": {
+ "infoCount": 0,
+ "warningCount": 1,
+ "criticalCount": 0,
+ "alarmCount": [
+ {
+ "id": 57,
+ "severity": "warning",
+ "count": 1
+ }
+ ]
+ }
+ },
+ "areas": {
+ "default": [
+ {
+ "id": "xxxx",
+ "name": "Attic"
+ },
+ {
+ "id": "xxxx",
+ "name": "Basement"
+ },
+ {
+ "id": "xxxx",
+ "name": "Garage"
+ },
+ {
+ "id": "xxxx",
+ "name": "Main Floor"
+ },
+ {
+ "id": "xxxx",
+ "name": "Upstairs"
+ }
+ ],
+ "custom": []
+ },
+ "account": {
+ "id": "aabbccdd"
+ }
+}
diff --git a/tests/fixtures/flo/user_info_base_response.json b/tests/fixtures/flo/user_info_base_response.json
new file mode 100644
index 00000000000..646b62ee834
--- /dev/null
+++ b/tests/fixtures/flo/user_info_base_response.json
@@ -0,0 +1,34 @@
+{
+ "id": "12345abcde",
+ "email": "email@address.com",
+ "isActive": true,
+ "firstName": "Tom",
+ "lastName": "Jones",
+ "unitSystem": "imperial_us",
+ "phoneMobile": "+1 123-456-7890",
+ "locale": "en-US",
+ "locations": [
+ {
+ "id": "mmnnoopp"
+ }
+ ],
+ "alarmSettings": [],
+ "locationRoles": [
+ {
+ "locationId": "mmnnoopp",
+ "roles": [
+ "owner"
+ ]
+ }
+ ],
+ "accountRole": {
+ "accountId": "aabbccdd",
+ "roles": [
+ "owner"
+ ]
+ },
+ "account": {
+ "id": "aabbccdd"
+ },
+ "enabledFeatures": []
+}
diff --git a/tests/fixtures/flo/user_info_expand_locations_response.json b/tests/fixtures/flo/user_info_expand_locations_response.json
new file mode 100644
index 00000000000..829596b6849
--- /dev/null
+++ b/tests/fixtures/flo/user_info_expand_locations_response.json
@@ -0,0 +1,120 @@
+{
+ "id": "12345abcde",
+ "email": "email@address.com",
+ "isActive": true,
+ "firstName": "Tom",
+ "lastName": "Jones",
+ "unitSystem": "imperial_us",
+ "phoneMobile": "+1 123-456-7890",
+ "locale": "en-US",
+ "locations": [
+ {
+ "id": "mmnnoopp",
+ "users": [
+ {
+ "id": "12345abcde"
+ }
+ ],
+ "devices": [
+ {
+ "id": "98765",
+ "macAddress": "606405c11e10"
+ }
+ ],
+ "userRoles": [
+ {
+ "userId": "12345abcde",
+ "roles": [
+ "owner"
+ ]
+ }
+ ],
+ "address": "123 Main Stree",
+ "city": "Boston",
+ "state": "MA",
+ "country": "us",
+ "postalCode": "12345",
+ "timezone": "US/Easter",
+ "gallonsPerDayGoal": 240,
+ "occupants": 2,
+ "stories": 2,
+ "isProfileComplete": true,
+ "nickname": "Home",
+ "irrigationSchedule": {
+ "isEnabled": false
+ },
+ "systemMode": {
+ "target": "home"
+ },
+ "locationType": "sfh",
+ "locationSize": "lte_4000_sq_ft",
+ "waterShutoffKnown": "unsure",
+ "indoorAmenities": [],
+ "outdoorAmenities": [],
+ "plumbingAppliances": [
+ "exp_tank"
+ ],
+ "notifications": {
+ "pending": {
+ "infoCount": 0,
+ "warningCount": 1,
+ "criticalCount": 0,
+ "alarmCount": [
+ {
+ "id": 57,
+ "severity": "warning",
+ "count": 1
+ }
+ ]
+ }
+ },
+ "areas": {
+ "default": [
+ {
+ "id": "xxxxx",
+ "name": "Attic"
+ },
+ {
+ "id": "xxxxx",
+ "name": "Basement"
+ },
+ {
+ "id": "xxxxx",
+ "name": "Garage"
+ },
+ {
+ "id": "xxxxx",
+ "name": "Main Floor"
+ },
+ {
+ "id": "xxxxx",
+ "name": "Upstairs"
+ }
+ ],
+ "custom": []
+ },
+ "account": {
+ "id": "aabbccdd"
+ }
+ }
+ ],
+ "alarmSettings": [],
+ "locationRoles": [
+ {
+ "locationId": "mmnnoopp",
+ "roles": [
+ "owner"
+ ]
+ }
+ ],
+ "accountRole": {
+ "accountId": "aabbccdd",
+ "roles": [
+ "owner"
+ ]
+ },
+ "account": {
+ "id": "aabbccdd"
+ },
+ "enabledFeatures": []
+}
diff --git a/tests/fixtures/flo/water_consumption_info_response.json b/tests/fixtures/flo/water_consumption_info_response.json
new file mode 100644
index 00000000000..ea173da98ea
--- /dev/null
+++ b/tests/fixtures/flo/water_consumption_info_response.json
@@ -0,0 +1,34 @@
+{
+ "params": {
+ "startDate": "2020-01-16T07:00:00.000Z",
+ "endDate": "2020-01-17T06:59:59.999Z",
+ "interval": "1h",
+ "tz": "US/Mountain",
+ "locationId": "mmnnoopp"
+ },
+ "aggregations": {
+ "sumTotalGallonsConsumed": 3.674
+ },
+ "items": [
+ {
+ "time": "2020-01-16T00:00:00-07:00",
+ "gallonsConsumed": 0.04
+ },
+ {
+ "time": "2020-01-16T01:00:00-07:00",
+ "gallonsConsumed": 0.477
+ },
+ {
+ "time": "2020-01-16T03:00:00-07:00",
+ "gallonsConsumed": 0.442
+ },
+ {
+ "time": "2020-01-16T07:00:00-07:00",
+ "gallonsConsumed": 1.216
+ },
+ {
+ "time": "2020-01-16T08:00:00-07:00",
+ "gallonsConsumed": 1.499
+ }
+ ]
+}
diff --git a/tests/fixtures/generic/configuration.yaml b/tests/fixtures/generic/configuration.yaml
new file mode 100644
index 00000000000..7ed443a3fb1
--- /dev/null
+++ b/tests/fixtures/generic/configuration.yaml
@@ -0,0 +1,6 @@
+camera:
+ - platform: generic
+ name: reload
+ still_image_url: "http://example.com"
+ username: user
+ password: pass
diff --git a/tests/fixtures/generic_thermostat/configuration.yaml b/tests/fixtures/generic_thermostat/configuration.yaml
new file mode 100644
index 00000000000..48b5ee2ed7b
--- /dev/null
+++ b/tests/fixtures/generic_thermostat/configuration.yaml
@@ -0,0 +1,5 @@
+climate:
+ - platform: generic_thermostat
+ name: reload
+ heater: switch.any
+ target_sensor: sensor.any
diff --git a/tests/fixtures/gios/indexes.json b/tests/fixtures/gios/indexes.json
new file mode 100644
index 00000000000..4fe4293706c
--- /dev/null
+++ b/tests/fixtures/gios/indexes.json
@@ -0,0 +1,29 @@
+{
+ "id": 123,
+ "stCalcDate": "2020-07-31 15:10:17",
+ "stIndexLevel": { "id": 1, "indexLevelName": "dobry" },
+ "stSourceDataDate": "2020-07-31 14:00:00",
+ "so2CalcDate": "2020-07-31 15:10:17",
+ "so2IndexLevel": { "id": 0, "indexLevelName": "bardzo dobry" },
+ "so2SourceDataDate": "2020-07-31 14:00:00",
+ "no2CalcDate": 1596201017000,
+ "no2IndexLevel": { "id": 0, "indexLevelName": "dobry" },
+ "no2SourceDataDate": "2020-07-31 14:00:00",
+ "coCalcDate": "2020-07-31 15:10:17",
+ "coIndexLevel": { "id": 0, "indexLevelName": "dobry" },
+ "coSourceDataDate": "2020-07-31 14:00:00",
+ "pm10CalcDate": "2020-07-31 15:10:17",
+ "pm10IndexLevel": { "id": 0, "indexLevelName": "dobry" },
+ "pm10SourceDataDate": "2020-07-31 14:00:00",
+ "pm25CalcDate": "2020-07-31 15:10:17",
+ "pm25IndexLevel": { "id": 0, "indexLevelName": "dobry" },
+ "pm25SourceDataDate": "2020-07-31 14:00:00",
+ "o3CalcDate": "2020-07-31 15:10:17",
+ "o3IndexLevel": { "id": 1, "indexLevelName": "dobry" },
+ "o3SourceDataDate": "2020-07-31 14:00:00",
+ "c6h6CalcDate": "2020-07-31 15:10:17",
+ "c6h6IndexLevel": { "id": 0, "indexLevelName": "bardzo dobry" },
+ "c6h6SourceDataDate": "2020-07-31 14:00:00",
+ "stIndexStatus": true,
+ "stIndexCrParam": "OZON"
+ }
\ No newline at end of file
diff --git a/tests/fixtures/gios/sensors.json b/tests/fixtures/gios/sensors.json
new file mode 100644
index 00000000000..3103a2bf16e
--- /dev/null
+++ b/tests/fixtures/gios/sensors.json
@@ -0,0 +1,51 @@
+{
+ "SO2": {
+ "values": [
+ { "date": "2020-07-31 15:00:00", "value": 4.35478 },
+ { "date": "2020-07-31 14:00:00", "value": 4.25478 },
+ { "date": "2020-07-31 13:00:00", "value": 4.34309 }
+ ]
+ },
+ "C6H6": {
+ "values": [
+ { "date": "2020-07-31 15:00:00", "value": 0.23789 },
+ { "date": "2020-07-31 14:00:00", "value": 0.22789 },
+ { "date": "2020-07-31 13:00:00", "value": 0.21315 }
+ ]
+ },
+ "CO": {
+ "values": [
+ { "date": "2020-07-31 15:00:00", "value": 251.874 },
+ { "date": "2020-07-31 14:00:00", "value": 250.874 },
+ { "date": "2020-07-31 13:00:00", "value": 251.097 }
+ ]
+ },
+ "NO2": {
+ "values": [
+ { "date": "2020-07-31 15:00:00", "value": 7.13411 },
+ { "date": "2020-07-31 14:00:00", "value": 7.33411 },
+ { "date": "2020-07-31 13:00:00", "value": 9.32578 }
+ ]
+ },
+ "O3": {
+ "values": [
+ { "date": "2020-07-31 15:00:00", "value": 95.7768 },
+ { "date": "2020-07-31 14:00:00", "value": 93.7768 },
+ { "date": "2020-07-31 13:00:00", "value": 89.4232 }
+ ]
+ },
+ "PM2.5": {
+ "values": [
+ { "date": "2020-07-31 15:00:00", "value": 4 },
+ { "date": "2020-07-31 14:00:00", "value": 4 },
+ { "date": "2020-07-31 13:00:00", "value": 5 }
+ ]
+ },
+ "PM10": {
+ "values": [
+ { "date": "2020-07-31 15:00:00", "value": 16.8344 },
+ { "date": "2020-07-31 14:00:00", "value": 17.8344 },
+ { "date": "2020-07-31 13:00:00", "value": 20.8094 }
+ ]
+ }
+ }
\ No newline at end of file
diff --git a/tests/fixtures/gios/station.json b/tests/fixtures/gios/station.json
new file mode 100644
index 00000000000..0eaa98a1d3c
--- /dev/null
+++ b/tests/fixtures/gios/station.json
@@ -0,0 +1,72 @@
+[
+ {
+ "id": 672,
+ "stationId": 117,
+ "param": {
+ "paramName": "dwutlenek siarki",
+ "paramFormula": "SO2",
+ "paramCode": "SO2",
+ "idParam": 1
+ }
+ },
+ {
+ "id": 658,
+ "stationId": 117,
+ "param": {
+ "paramName": "benzen",
+ "paramFormula": "C6H6",
+ "paramCode": "C6H6",
+ "idParam": 10
+ }
+ },
+ {
+ "id": 660,
+ "stationId": 117,
+ "param": {
+ "paramName": "tlenek węgla",
+ "paramFormula": "CO",
+ "paramCode": "CO",
+ "idParam": 8
+ }
+ },
+ {
+ "id": 665,
+ "stationId": 117,
+ "param": {
+ "paramName": "dwutlenek azotu",
+ "paramFormula": "NO2",
+ "paramCode": "NO2",
+ "idParam": 6
+ }
+ },
+ {
+ "id": 667,
+ "stationId": 117,
+ "param": {
+ "paramName": "ozon",
+ "paramFormula": "O3",
+ "paramCode": "O3",
+ "idParam": 5
+ }
+ },
+ {
+ "id": 670,
+ "stationId": 117,
+ "param": {
+ "paramName": "pył zawieszony PM2.5",
+ "paramFormula": "PM2.5",
+ "paramCode": "PM2.5",
+ "idParam": 69
+ }
+ },
+ {
+ "id": 14395,
+ "stationId": 117,
+ "param": {
+ "paramName": "pył zawieszony PM10",
+ "paramFormula": "PM10",
+ "paramCode": "PM10",
+ "idParam": 3
+ }
+ }
+ ]
\ No newline at end of file
diff --git a/tests/fixtures/group/configuration.yaml b/tests/fixtures/group/configuration.yaml
new file mode 100644
index 00000000000..0a5c9e18bd1
--- /dev/null
+++ b/tests/fixtures/group/configuration.yaml
@@ -0,0 +1,18 @@
+light:
+ - platform: group
+ name: Master Hall Lights G
+ entities:
+ - light.master_hall_lights
+ - light.master_hall_lights_2
+ - platform: group
+ name: Outside Patio Lights G
+ entities:
+ - light.outside_patio_lights
+ - light.outside_patio_lights_2
+
+notify:
+ - platform: group
+ name: new_group_notify
+ services:
+ - service: demo1
+ - service: demo2
diff --git a/tests/fixtures/helpers/reload_configuration.yaml b/tests/fixtures/helpers/reload_configuration.yaml
new file mode 100644
index 00000000000..f7c628bec5a
--- /dev/null
+++ b/tests/fixtures/helpers/reload_configuration.yaml
@@ -0,0 +1,14 @@
+test_domain:
+ - platform: test_platform
+ sensors:
+ combined_sensor_energy_usage:
+ friendly_name: Combined Sense Energy Usage
+ unit_of_measurement: kW
+ value_template: '{{ ((states(''sensor.energy_usage'') | float) + (states(''sensor.energy_usage_2'')
+ | float)) / 1000 }}'
+ watching_tv_in_master_bedroom:
+ friendly_name: Watching TV in Master Bedroom
+ value_template: '{% if state_attr("remote.alexander_master_bedroom","current_activity")
+ == "Watch TV" or state_attr("remote.alexander_master_bedroom","current_activity")
+ == "Watch Apple TV" %}on{% else %}off{% endif %}'
+
diff --git a/tests/fixtures/helpers/test_domain_configuration.yaml b/tests/fixtures/helpers/test_domain_configuration.yaml
new file mode 100644
index 00000000000..3c9c6ed8a7b
--- /dev/null
+++ b/tests/fixtures/helpers/test_domain_configuration.yaml
@@ -0,0 +1,4 @@
+test_domain:
+ - name: one
+ - name: two
+
diff --git a/tests/fixtures/history_stats/configuration.yaml b/tests/fixtures/history_stats/configuration.yaml
new file mode 100644
index 00000000000..15704996998
--- /dev/null
+++ b/tests/fixtures/history_stats/configuration.yaml
@@ -0,0 +1,7 @@
+sensor:
+ - platform: history_stats
+ entity_id: binary_sensor.test_id
+ name: second_test
+ state: "on"
+ start: "{{ as_timestamp(now()) - 3600 }}"
+ end: "{{ now() }}"
diff --git a/tests/fixtures/homekit/configuration.yaml b/tests/fixtures/homekit/configuration.yaml
new file mode 100644
index 00000000000..546c4a1a09f
--- /dev/null
+++ b/tests/fixtures/homekit/configuration.yaml
@@ -0,0 +1,3 @@
+homekit:
+ - name: reloadable
+ port: 45678
diff --git a/tests/fixtures/homematicip_cloud.json b/tests/fixtures/homematicip_cloud.json
index 1aa3bfa48ad..4060ca7a820 100644
--- a/tests/fixtures/homematicip_cloud.json
+++ b/tests/fixtures/homematicip_cloud.json
@@ -14,6 +14,848 @@
}
},
"devices": {
+ "3014F7110TILTVIBRATIONSENSOR": {
+ "availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
+ "firmwareVersion": "1.0.16",
+ "firmwareVersionInteger": 65552,
+ "functionalChannels": {
+ "0": {
+ "busConfigMismatch": null,
+ "coProFaulty": false,
+ "coProRestartNeeded": false,
+ "coProUpdateFailure": false,
+ "configPending": false,
+ "deviceId": "3014F7110TILTVIBRATIONSENSOR",
+ "deviceOverheated": false,
+ "deviceOverloaded": false,
+ "devicePowerFailureDetected": false,
+ "deviceUndervoltage": false,
+ "dutyCycle": false,
+ "functionalChannelType": "DEVICE_BASE",
+ "groupIndex": 0,
+ "groups": [],
+ "index": 0,
+ "label": "",
+ "lowBat": false,
+ "multicastRoutingEnabled": false,
+ "powerShortCircuit": null,
+ "routerModuleEnabled": false,
+ "routerModuleSupported": false,
+ "rssiDeviceValue": -59,
+ "rssiPeerValue": null,
+ "shortCircuitDataLine": null,
+ "supportedOptionalFeatures": {
+ "IFeatureBusConfigMismatch": false,
+ "IFeatureDeviceCoProError": false,
+ "IFeatureDeviceCoProRestart": false,
+ "IFeatureDeviceCoProUpdate": false,
+ "IFeatureDeviceIdentify": false,
+ "IFeatureDeviceOverheated": false,
+ "IFeatureDeviceOverloaded": false,
+ "IFeatureDevicePowerFailure": false,
+ "IFeatureDeviceTemperatureOutOfRange": false,
+ "IFeatureDeviceUndervoltage": false,
+ "IFeatureMulticastRouter": false,
+ "IFeaturePowerShortCircuit": false,
+ "IFeatureRssiValue": true,
+ "IFeatureShortCircuitDataLine": false,
+ "IOptionalFeatureDutyCycle": true,
+ "IOptionalFeatureLowBat": true
+ },
+ "temperatureOutOfRange": false,
+ "unreach": false
+ },
+ "1": {
+ "accelerationSensorEventFilterPeriod": 0.5,
+ "accelerationSensorMode": "FLAT_DECT",
+ "accelerationSensorNeutralPosition": "VERTICAL",
+ "accelerationSensorSensitivity": "SENSOR_RANGE_2G",
+ "accelerationSensorTriggerAngle": 45,
+ "accelerationSensorTriggered": true,
+ "deviceId": "3014F7110TILTVIBRATIONSENSOR",
+ "functionalChannelType": "TILT_VIBRATION_SENSOR_CHANNEL",
+ "groupIndex": 1,
+ "groups": [],
+ "index": 1,
+ "label": ""
+ }
+ },
+ "homeId": "00000000-0000-0000-0000-000000000001",
+ "id": "3014F7110TILTVIBRATIONSENSOR",
+ "label": "Garage Neigungs- und Ersch\u00fctterungssensor",
+ "lastStatusUpdate": 1598610615630,
+ "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
+ "manufacturerCode": 1,
+ "modelId": 378,
+ "modelType": "HmIP-STV",
+ "oem": "eQ-3",
+ "permanentlyReachable": false,
+ "serializedGlobalTradeItemNumber": "3014F7110TILTVIBRATIONSENSOR",
+ "type": "TILT_VIBRATION_SENSOR",
+ "updateState": "UP_TO_DATE"
+ },
+ "3014F711000WIREDSWITCH8": {
+ "availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_WIRED",
+ "firmwareVersion": "1.2.4",
+ "firmwareVersionInteger": 66052,
+ "functionalChannels": {
+ "0": {
+ "coProFaulty": false,
+ "coProRestartNeeded": false,
+ "coProUpdateFailure": false,
+ "configPending": false,
+ "deviceId": "3014F711000WIREDSWITCH8",
+ "deviceOverheated": false,
+ "deviceOverloaded": false,
+ "devicePowerFailureDetected": false,
+ "deviceUndervoltage": false,
+ "dutyCycle": null,
+ "functionalChannelType": "DEVICE_BASE",
+ "groupIndex": 0,
+ "groups": [],
+ "index": 0,
+ "label": "",
+ "lowBat": null,
+ "routerModuleEnabled": false,
+ "routerModuleSupported": false,
+ "rssiDeviceValue": null,
+ "rssiPeerValue": null,
+ "supportedOptionalFeatures": {
+ "IFeatureDeviceCoProError": false,
+ "IFeatureDeviceCoProRestart": false,
+ "IFeatureDeviceCoProUpdate": false,
+ "IFeatureDeviceIdentify": true,
+ "IFeatureDeviceOverheated": true,
+ "IFeatureDeviceOverloaded": false,
+ "IFeatureDevicePowerFailure": false,
+ "IFeatureDeviceTemperatureOutOfRange": false,
+ "IFeatureDeviceUndervoltage": true
+ },
+ "temperatureOutOfRange": false,
+ "unreach": false
+ },
+ "1": {
+ "deviceId": "3014F711000WIREDSWITCH8",
+ "functionalChannelType": "SWITCH_CHANNEL",
+ "groupIndex": 1,
+ "groups": [],
+ "index": 1,
+ "label": "Fernseher (Wohnzimmer)",
+ "on": true,
+ "profileMode": "AUTOMATIC",
+ "userDesiredProfileMode": "AUTOMATIC"
+ },
+ "2": {
+ "deviceId": "3014F711000WIREDSWITCH8",
+ "functionalChannelType": "SWITCH_CHANNEL",
+ "groupIndex": 2,
+ "groups": [],
+ "index": 2,
+ "label": "Steckdosen (Wohnzimmer)",
+ "on": true,
+ "profileMode": "AUTOMATIC",
+ "userDesiredProfileMode": "AUTOMATIC"
+ },
+ "3": {
+ "deviceId": "3014F711000WIREDSWITCH8",
+ "functionalChannelType": "SWITCH_CHANNEL",
+ "groupIndex": 3,
+ "groups": [],
+ "index": 3,
+ "label": "Steckdosen Kaffeeecke (K\u00fcche)",
+ "on": true,
+ "profileMode": "AUTOMATIC",
+ "userDesiredProfileMode": "AUTOMATIC"
+ },
+ "4": {
+ "deviceId": "3014F711000WIREDSWITCH8",
+ "functionalChannelType": "SWITCH_CHANNEL",
+ "groupIndex": 4,
+ "groups": [],
+ "index": 4,
+ "label": "Steckdosen (K\u00fcche)",
+ "on": true,
+ "profileMode": "AUTOMATIC",
+ "userDesiredProfileMode": "AUTOMATIC"
+ },
+ "5": {
+ "deviceId": "3014F711000WIREDSWITCH8",
+ "functionalChannelType": "SWITCH_CHANNEL",
+ "groupIndex": 5,
+ "groups": [],
+ "index": 5,
+ "label": "T\u00fcrlicht (Garten)",
+ "on": false,
+ "profileMode": "AUTOMATIC",
+ "userDesiredProfileMode": "AUTOMATIC"
+ },
+ "6": {
+ "deviceId": "3014F711000WIREDSWITCH8",
+ "functionalChannelType": "SWITCH_CHANNEL",
+ "groupIndex": 6,
+ "groups": [],
+ "index": 6,
+ "label": "Arbeitsplattenlicht (K\u00fcche)",
+ "on": false,
+ "profileMode": "AUTOMATIC",
+ "userDesiredProfileMode": "AUTOMATIC"
+ },
+ "7": {
+ "deviceId": "3014F711000WIREDSWITCH8",
+ "functionalChannelType": "SWITCH_CHANNEL",
+ "groupIndex": 7,
+ "groups": [],
+ "index": 7,
+ "label": "Raumlich (Flur/Oben)",
+ "on": false,
+ "profileMode": "AUTOMATIC",
+ "userDesiredProfileMode": "AUTOMATIC"
+ },
+ "8": {
+ "deviceId": "3014F711000WIREDSWITCH8",
+ "functionalChannelType": "SWITCH_CHANNEL",
+ "groupIndex": 8,
+ "groups": [],
+ "index": 8,
+ "label": "Spiegellicht (Bad/Oben)",
+ "on": false,
+ "profileMode": "AUTOMATIC",
+ "userDesiredProfileMode": "AUTOMATIC"
+ }
+ },
+ "homeId": "00000000-0000-0000-0000-000000000001",
+ "id": "3014F711000WIREDSWITCH8",
+ "label": "Wired Schaltaktor \u2013 8-fach",
+ "lastStatusUpdate": 1595225535806,
+ "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
+ "manufacturerCode": 1,
+ "modelId": 343,
+ "modelType": "HmIPW-DRS8",
+ "oem": "eQ-3",
+ "permanentlyReachable": true,
+ "serializedGlobalTradeItemNumber": "3014F711000WIREDSWITCH8",
+ "type": "WIRED_SWITCH_8",
+ "updateState": "UP_TO_DATE"
+ },
+ "3014F711000WIREDDIMMER3": {
+ "availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_WIRED",
+ "firmwareVersion": "1.0.0",
+ "firmwareVersionInteger": 65536,
+ "functionalChannels": {
+ "0": {
+ "coProFaulty": false,
+ "coProRestartNeeded": false,
+ "coProUpdateFailure": false,
+ "configPending": false,
+ "deviceId": "3014F711000WIREDDIMMER3",
+ "deviceOverheated": false,
+ "deviceOverloaded": false,
+ "devicePowerFailureDetected": false,
+ "deviceUndervoltage": false,
+ "dutyCycle": null,
+ "functionalChannelType": "DEVICE_BASE",
+ "groupIndex": 0,
+ "groups": [],
+ "index": 0,
+ "label": "",
+ "lowBat": null,
+ "routerModuleEnabled": false,
+ "routerModuleSupported": false,
+ "rssiDeviceValue": null,
+ "rssiPeerValue": null,
+ "supportedOptionalFeatures": {
+ "IFeatureDeviceCoProError": false,
+ "IFeatureDeviceCoProRestart": false,
+ "IFeatureDeviceCoProUpdate": false,
+ "IFeatureDeviceIdentify": true,
+ "IFeatureDeviceOverheated": true,
+ "IFeatureDeviceOverloaded": false,
+ "IFeatureDevicePowerFailure": false,
+ "IFeatureDeviceTemperatureOutOfRange": false,
+ "IFeatureDeviceUndervoltage": true
+ },
+ "temperatureOutOfRange": false,
+ "unreach": false
+ },
+ "1": {
+ "coProFaulty": false,
+ "coProRestartNeeded": false,
+ "deviceId": "3014F711000WIREDDIMMER3",
+ "deviceOverheated": false,
+ "deviceOverloaded": false,
+ "dimLevel": 0.0,
+ "functionalChannelType": "DIMMER_CHANNEL",
+ "groupIndex": 1,
+ "groups": [],
+ "index": 1,
+ "label": "Raumlich (K\u00fcche)",
+ "on": false,
+ "profileMode": "AUTOMATIC",
+ "supportedOptionalFeatures": {
+ "IFeatureDeviceOverloaded": true
+ },
+ "userDesiredProfileMode": "AUTOMATIC"
+ },
+ "2": {
+ "coProFaulty": false,
+ "coProRestartNeeded": false,
+ "deviceId": "3014F711000WIREDDIMMER3",
+ "deviceOverheated": false,
+ "deviceOverloaded": false,
+ "dimLevel": 0.0,
+ "functionalChannelType": "DIMMER_CHANNEL",
+ "groupIndex": 2,
+ "groups": [],
+ "index": 2,
+ "label": "Raumlicht (Bad/Oben)",
+ "on": false,
+ "profileMode": "AUTOMATIC",
+ "supportedOptionalFeatures": {
+ "IFeatureDeviceOverloaded": true
+ },
+ "userDesiredProfileMode": "AUTOMATIC"
+ },
+ "3": {
+ "coProFaulty": false,
+ "coProRestartNeeded": false,
+ "deviceId": "3014F711000WIREDDIMMER3",
+ "deviceOverheated": false,
+ "deviceOverloaded": false,
+ "dimLevel": 0.0,
+ "functionalChannelType": "DIMMER_CHANNEL",
+ "groupIndex": 3,
+ "groups": [],
+ "index": 3,
+ "label": "Raumlich (Kinderzimmer/Oben)",
+ "on": false,
+ "profileMode": "AUTOMATIC",
+ "supportedOptionalFeatures": {
+ "IFeatureDeviceOverloaded": true
+ },
+ "userDesiredProfileMode": "AUTOMATIC"
+ }
+ },
+ "homeId": "00000000-0000-0000-0000-000000000001",
+ "id": "3014F711000WIREDDIMMER3",
+ "label": "Wired Dimmaktor \u2013 3-fach (K\u00fcche)",
+ "lastStatusUpdate": 1595225686220,
+ "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
+ "manufacturerCode": 1,
+ "modelId": 345,
+ "modelType": "HmIPW-DRD3",
+ "oem": "eQ-3",
+ "permanentlyReachable": true,
+ "serializedGlobalTradeItemNumber": "3014F711000WIREDDIMMER3",
+ "type": "WIRED_DIMMER_3",
+ "updateState": "UP_TO_DATE"
+ },
+ "3014F711000WIREDINPUT32": {
+ "availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_WIRED",
+ "firmwareVersion": "1.2.2",
+ "firmwareVersionInteger": 66050,
+ "functionalChannels": {
+ "0": {
+ "coProFaulty": false,
+ "coProRestartNeeded": false,
+ "coProUpdateFailure": false,
+ "configPending": false,
+ "deviceId": "3014F711000WIREDINPUT32",
+ "deviceOverheated": false,
+ "deviceOverloaded": false,
+ "devicePowerFailureDetected": false,
+ "deviceUndervoltage": false,
+ "dutyCycle": null,
+ "functionalChannelType": "DEVICE_BASE",
+ "groupIndex": 0,
+ "groups": [],
+ "index": 0,
+ "label": "",
+ "lowBat": null,
+ "routerModuleEnabled": false,
+ "routerModuleSupported": false,
+ "rssiDeviceValue": null,
+ "rssiPeerValue": null,
+ "supportedOptionalFeatures": {
+ "IFeatureDeviceCoProError": false,
+ "IFeatureDeviceCoProRestart": false,
+ "IFeatureDeviceCoProUpdate": false,
+ "IFeatureDeviceIdentify": true,
+ "IFeatureDeviceOverheated": true,
+ "IFeatureDeviceOverloaded": false,
+ "IFeatureDevicePowerFailure": false,
+ "IFeatureDeviceTemperatureOutOfRange": false,
+ "IFeatureDeviceUndervoltage": true
+ },
+ "temperatureOutOfRange": false,
+ "unreach": false
+ },
+ "1": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 1,
+ "groups": [],
+ "index": 1,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "10": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 10,
+ "groups": [],
+ "index": 10,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "11": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 11,
+ "groups": [],
+ "index": 11,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "12": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 12,
+ "groups": [],
+ "index": 12,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "13": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 13,
+ "groups": [],
+ "index": 13,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "14": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 14,
+ "groups": [],
+ "index": 14,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "15": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 15,
+ "groups": [],
+ "index": 15,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "16": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 16,
+ "groups": [],
+ "index": 16,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "17": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 17,
+ "groups": [],
+ "index": 17,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "18": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 18,
+ "groups": [],
+ "index": 18,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "19": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 19,
+ "groups": [],
+ "index": 19,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "2": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 2,
+ "groups": [],
+ "index": 2,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "20": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 20,
+ "groups": [],
+ "index": 20,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "21": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 21,
+ "groups": [],
+ "index": 21,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "22": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 22,
+ "groups": [],
+ "index": 22,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "23": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 23,
+ "groups": [],
+ "index": 23,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "24": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 24,
+ "groups": [],
+ "index": 24,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "25": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 25,
+ "groups": [],
+ "index": 25,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "26": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 26,
+ "groups": [],
+ "index": 26,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "27": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 27,
+ "groups": [],
+ "index": 27,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "28": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 28,
+ "groups": [],
+ "index": 28,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "29": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 29,
+ "groups": [],
+ "index": 29,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "3": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 3,
+ "groups": [],
+ "index": 3,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "30": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 30,
+ "groups": [],
+ "index": 30,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "31": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 31,
+ "groups": [],
+ "index": 31,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "32": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 32,
+ "groups": [],
+ "index": 32,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "4": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 4,
+ "groups": [],
+ "index": 4,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "5": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 5,
+ "groups": [],
+ "index": 5,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "6": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 6,
+ "groups": [],
+ "index": 6,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "7": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 7,
+ "groups": [],
+ "index": 7,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "8": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 8,
+ "groups": [],
+ "index": 8,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ },
+ "9": {
+ "binaryBehaviorType": "NORMALLY_CLOSE",
+ "deviceId": "3014F711000WIREDINPUT32",
+ "functionalChannelType": "MULTI_MODE_INPUT_CHANNEL",
+ "groupIndex": 9,
+ "groups": [],
+ "index": 9,
+ "label": "",
+ "multiModeInputMode": "KEY_BEHAVIOR",
+ "supportedOptionalFeatures": {
+ "IOptionalFeatureWindowState": false
+ },
+ "windowState": "CLOSED"
+ }
+ },
+ "homeId": "00000000-0000-0000-0000-000000000001",
+ "id": "3014F711000WIREDINPUT32",
+ "label": "Wired Eingangsmodul \u2013 32-fach",
+ "lastStatusUpdate": 1595222885844,
+ "liveUpdateState": "LIVE_UPDATE_NOT_SUPPORTED",
+ "manufacturerCode": 1,
+ "modelId": 347,
+ "modelType": "HmIPW-DRI32",
+ "oem": "eQ-3",
+ "permanentlyReachable": true,
+ "serializedGlobalTradeItemNumber": "3014F711000WIREDINPUT32",
+ "type": "WIRED_INPUT_32",
+ "updateState": "UP_TO_DATE"
+ },
"3014F7110SHUTTER_OPTICAL": {
"availableFirmwareVersion": "1.16.10",
"connectionType": "HMIP_RF",
@@ -88,6 +930,7 @@
"3014F7110000000HmIPFSI16": {
"availableFirmwareVersion": "0.0.0",
"connectionType": "HMIP_RF",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.16.2",
"firmwareVersionInteger": 69634,
"functionalChannels": {
@@ -156,6 +999,7 @@
},
"3014F7110000000HOERMANN": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.14",
"firmwareVersionInteger": 65550,
"functionalChannels": {
@@ -220,6 +1064,7 @@
},
"3014F711000BBBB000000000": {
"availableFirmwareVersion": "2.0.2",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "2.0.2",
"firmwareVersionInteger": 131074,
"functionalChannels": {
@@ -287,6 +1132,7 @@
},
"3014F711000000BBBB000005": {
"availableFirmwareVersion": "1.0.16",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.12",
"firmwareVersionInteger": 65548,
"functionalChannels": {
@@ -350,6 +1196,7 @@
},
"3014F711000000000000AAA5": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.12",
"firmwareVersionInteger": 65548,
"functionalChannels": {
@@ -417,6 +1264,7 @@
},
"3014F7110000000000ABCD50": {
"availableFirmwareVersion": "1.0.12",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.12",
"firmwareVersionInteger": 65548,
"functionalChannels": {
@@ -481,6 +1329,7 @@
},
"3014F7110000000000000049": {
"availableFirmwareVersion": "1.0.8",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.8",
"firmwareVersionInteger": 65544,
"functionalChannels": {
@@ -674,6 +1523,7 @@
},
"3014F7110000000000000148": {
"availableFirmwareVersion": "1.4.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.4.0",
"firmwareVersionInteger": 66560,
"functionalChannels": {
@@ -756,6 +1606,7 @@
},
"3014F7110000ABCDABCD0033": {
"availableFirmwareVersion": "1.0.6",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.6",
"firmwareVersionInteger": 65542,
"functionalChannels": {
@@ -818,6 +1669,7 @@
},
"3014F7110000000000000031": {
"availableFirmwareVersion": "1.2.1",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.2.1",
"firmwareVersionInteger": 66049,
"functionalChannels": {
@@ -886,6 +1738,7 @@
},
"3014F7110000000000000052": {
"availableFirmwareVersion": "1.0.5",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.5",
"firmwareVersionInteger": 65541,
"functionalChannels": {
@@ -1004,6 +1857,7 @@
},
"3014F71100000000FAL24C10": {
"availableFirmwareVersion": "1.6.2",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.6.2",
"firmwareVersionInteger": 67074,
"functionalChannels": {
@@ -1161,6 +2015,7 @@
},
"3014F71100000000000BBL24": {
"availableFirmwareVersion": "1.6.2",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.6.2",
"firmwareVersionInteger": 67074,
"functionalChannels": {
@@ -1227,6 +2082,7 @@
},
"3014F7110000000000BCBB11": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.10.10",
"firmwareVersionInteger": 68106,
"functionalChannels": {
@@ -1290,6 +2146,7 @@
},
"3014F711ABCD0ABCD000002": {
"availableFirmwareVersion": "1.6.4",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.6.4",
"firmwareVersionInteger": 67076,
"functionalChannels": {
@@ -1378,6 +2235,7 @@
"3014F71100000000ABCDEF10": {
"automaticValveAdaptionNeeded": false,
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.6",
"firmwareVersionInteger": 65542,
"functionalChannels": {
@@ -1433,6 +2291,7 @@
},
"3014F71100000000000TEST1": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.8.8",
"firmwareVersionInteger": 67592,
"functionalChannels": {
@@ -1489,6 +2348,7 @@
},
"3014F7110000000000000064": {
"availableFirmwareVersion": "1.0.6",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.6",
"firmwareVersionInteger": 65542,
"functionalChannels": {
@@ -1561,6 +2421,7 @@
},
"3014F711BADCAFE000000001": {
"availableFirmwareVersion": "1.2.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.2.0",
"firmwareVersionInteger": 66048,
"functionalChannels": {
@@ -1626,6 +2487,7 @@
},
"3014F7110000000000000055": {
"availableFirmwareVersion": "1.2.4",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.2.4",
"firmwareVersionInteger": 66052,
"functionalChannels": {
@@ -1698,6 +2560,7 @@
},
"3014F711ABCDEF0000000014": {
"availableFirmwareVersion": "1.4.2",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.4.2",
"firmwareVersionInteger": 66562,
"functionalChannels": {
@@ -1769,6 +2632,7 @@
},
"3014F711BSL0000000000050": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.2",
"firmwareVersionInteger": 65538,
"functionalChannels": {
@@ -1843,6 +2707,7 @@
},
"3014F711SLO0000000000026": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.16",
"firmwareVersionInteger": 65552,
"functionalChannels": {
@@ -1892,6 +2757,7 @@
},
"3014F7110000000000000054": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.0",
"firmwareVersionInteger": 65536,
"functionalChannels": {
@@ -1949,6 +2815,7 @@
},
"3014F711000000000AAAAA25": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.12",
"firmwareVersionInteger": 65548,
"functionalChannels": {
@@ -2025,6 +2892,7 @@
},
"3014F7110000000000000038": {
"availableFirmwareVersion": "1.0.18",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.18",
"firmwareVersionInteger": 65554,
"functionalChannels": {
@@ -2088,6 +2956,7 @@
},
"3014F7110000000000BBBBB1": {
"availableFirmwareVersion": "1.6.2",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.6.2",
"firmwareVersionInteger": 67074,
"functionalChannels": {
@@ -2216,6 +3085,7 @@
},
"3014F7110000000000BBBBB8": {
"availableFirmwareVersion": "1.2.16",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.2.16",
"firmwareVersionInteger": 66064,
"functionalChannels": {
@@ -2264,6 +3134,7 @@
},
"3014F711000000000000BB11": {
"availableFirmwareVersion": "1.4.8",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.4.8",
"firmwareVersionInteger": 66568,
"functionalChannels": {
@@ -2316,6 +3187,7 @@
},
"3014F71100000000000BBB17": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.2",
"firmwareVersionInteger": 65538,
"functionalChannels": {
@@ -2369,6 +3241,7 @@
},
"3014F7110000000000000050": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.2",
"firmwareVersionInteger": "65538",
"functionalChannels": {
@@ -2427,6 +3300,7 @@
},
"3014F7110000000000000000": {
"availableFirmwareVersion": "1.16.8",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.16.8",
"firmwareVersionInteger": 69640,
"functionalChannels": {
@@ -2482,6 +3356,7 @@
},
"3014F7110000000000005551": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.2.12",
"firmwareVersionInteger": 66060,
"functionalChannels": {
@@ -2532,6 +3407,7 @@
},
"3014F7110000000000000001": {
"availableFirmwareVersion": "1.16.8",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.16.8",
"firmwareVersionInteger": 69640,
"functionalChannels": {
@@ -2587,6 +3463,7 @@
},
"3014F7110000000000000002": {
"availableFirmwareVersion": "1.16.8",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.16.8",
"firmwareVersionInteger": 69640,
"functionalChannels": {
@@ -2642,6 +3519,7 @@
},
"3014F7110000000000000003": {
"availableFirmwareVersion": "1.16.8",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.16.8",
"firmwareVersionInteger": 69640,
"functionalChannels": {
@@ -2697,6 +3575,7 @@
},
"3014F7110000000000000004": {
"availableFirmwareVersion": "1.16.8",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.16.8",
"firmwareVersionInteger": 69640,
"functionalChannels": {
@@ -2752,6 +3631,7 @@
},
"3014F7110000000000000005": {
"availableFirmwareVersion": "1.16.8",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.16.8",
"firmwareVersionInteger": 69640,
"functionalChannels": {
@@ -2807,6 +3687,7 @@
},
"3014F7110000000000000006": {
"availableFirmwareVersion": "1.16.8",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.16.8",
"firmwareVersionInteger": 69640,
"functionalChannels": {
@@ -2861,6 +3742,7 @@
},
"3014F7110000000000000007": {
"availableFirmwareVersion": "1.16.8",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.16.8",
"firmwareVersionInteger": 69640,
"functionalChannels": {
@@ -2915,6 +3797,7 @@
},
"3014F7110000000000000108": {
"availableFirmwareVersion": "1.12.6",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.12.6",
"firmwareVersionInteger": 68614,
"functionalChannels": {
@@ -2984,6 +3867,7 @@
},
"3014F7110000000000000109": {
"availableFirmwareVersion": "1.6.2",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.6.2",
"firmwareVersionInteger": 67074,
"functionalChannels": {
@@ -3053,6 +3937,7 @@
},
"3014F7110000000000000008": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "2.6.2",
"firmwareVersionInteger": 132610,
"functionalChannels": {
@@ -3107,6 +3992,7 @@
},
"3014F7110000000000000009": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "2.6.2",
"firmwareVersionInteger": 132610,
"functionalChannels": {
@@ -3161,6 +4047,7 @@
},
"3014F7110000000000000010": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "2.6.2",
"firmwareVersionInteger": 132610,
"functionalChannels": {
@@ -3215,6 +4102,7 @@
},
"3014F7110000000000000110": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "2.6.2",
"firmwareVersionInteger": 132610,
"functionalChannels": {
@@ -3268,6 +4156,7 @@
"3014F7110000000000000011": {
"automaticValveAdaptionNeeded": false,
"availableFirmwareVersion": "2.0.2",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "2.0.2",
"firmwareVersionInteger": 131074,
"functionalChannels": {
@@ -3324,6 +4213,7 @@
"3014F7110000000000000012": {
"automaticValveAdaptionNeeded": false,
"availableFirmwareVersion": "2.0.2",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "2.0.2",
"firmwareVersionInteger": 131074,
"functionalChannels": {
@@ -3380,6 +4270,7 @@
"3014F7110000000000000013": {
"automaticValveAdaptionNeeded": false,
"availableFirmwareVersion": "2.0.2",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "2.0.2",
"firmwareVersionInteger": 131074,
"functionalChannels": {
@@ -3436,6 +4327,7 @@
"3014F7110000000000000014": {
"automaticValveAdaptionNeeded": false,
"availableFirmwareVersion": "2.0.2",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "2.0.2",
"firmwareVersionInteger": 131074,
"functionalChannels": {
@@ -3492,6 +4384,7 @@
"3014F7110000000000000015": {
"automaticValveAdaptionNeeded": false,
"availableFirmwareVersion": "2.0.2",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "2.0.2",
"firmwareVersionInteger": 131074,
"functionalChannels": {
@@ -3548,6 +4441,7 @@
"3014F7110000000000000016": {
"automaticValveAdaptionNeeded": false,
"availableFirmwareVersion": "2.0.2",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "2.0.2",
"firmwareVersionInteger": 131074,
"functionalChannels": {
@@ -3604,6 +4498,7 @@
"3014F7110000000000000017": {
"automaticValveAdaptionNeeded": false,
"availableFirmwareVersion": "2.0.2",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "2.0.2",
"firmwareVersionInteger": 131074,
"functionalChannels": {
@@ -3659,6 +4554,7 @@
},
"3014F7110000000000000018": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.11",
"firmwareVersionInteger": 65547,
"functionalChannels": {
@@ -3711,6 +4607,7 @@
},
"3014F7110000000000000019": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.11",
"firmwareVersionInteger": 65547,
"functionalChannels": {
@@ -3763,6 +4660,7 @@
},
"3014F7110000000000000020": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.11",
"firmwareVersionInteger": 65547,
"functionalChannels": {
@@ -3815,6 +4713,7 @@
},
"3014F7110000000000000021": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.11",
"firmwareVersionInteger": 65547,
"functionalChannels": {
@@ -3867,6 +4766,7 @@
},
"3014F7110000000000000022": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.8.0",
"firmwareVersionInteger": 67584,
"functionalChannels": {
@@ -3924,6 +4824,7 @@
},
"3014F7110000000000000023": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.8.0",
"firmwareVersionInteger": 67584,
"functionalChannels": {
@@ -3981,6 +4882,7 @@
},
"3014F7110000000000000024": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.8.0",
"firmwareVersionInteger": 67584,
"functionalChannels": {
@@ -4038,6 +4940,7 @@
},
"3014F7110000000000000025": {
"availableFirmwareVersion": "1.8.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.8.0",
"firmwareVersionInteger": 67584,
"functionalChannels": {
@@ -4095,6 +4998,7 @@
},
"3014F7110000000000000029": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.14",
"firmwareVersionInteger": 65550,
"functionalChannels": {
@@ -4147,6 +5051,7 @@
},
"3014F711AAAA000000000001": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.10",
"firmwareVersionInteger": 65546,
"functionalChannels": {
@@ -4215,6 +5120,7 @@
},
"3014F711AAAA000000000002": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.6",
"firmwareVersionInteger": 65542,
"functionalChannels": {
@@ -4267,6 +5173,7 @@
},
"3014F711AAAA000000000003": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.10",
"firmwareVersionInteger": 65546,
"functionalChannels": {
@@ -4324,6 +5231,7 @@
},
"3014F711AAAA000000000004": {
"availableFirmwareVersion": "1.2.10",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.2.10",
"firmwareVersionInteger": 66058,
"functionalChannels": {
@@ -4372,6 +5280,7 @@
},
"3014F711AAAA000000000005": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.4.8",
"firmwareVersionInteger": 66568,
"functionalChannels": {
@@ -4424,6 +5333,7 @@
},
"3014F711BBBBBBBBBBBBB017": {
"availableFirmwareVersion": "1.0.19",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.19",
"firmwareVersionInteger": 65555,
"functionalChannels": {
@@ -4516,6 +5426,7 @@
},
"3014F711BBBBBBBBBBBBB016": {
"availableFirmwareVersion": "1.0.19",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.19",
"firmwareVersionInteger": 65555,
"functionalChannels": {
@@ -4628,6 +5539,7 @@
},
"3014F711AAAAAAAAAAAAAA51": {
"availableFirmwareVersion": "1.4.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.4.0",
"firmwareVersionInteger": 66560,
"functionalChannels": {
@@ -4686,6 +5598,7 @@
},
"3014F711ACBCDABCADCA66": {
"availableFirmwareVersion": "1.6.2",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.6.2",
"firmwareVersionInteger": 67074,
"functionalChannels": {
@@ -4750,6 +5663,7 @@
},
"3014F711BBBBBBBBBBBBB18": {
"availableFirmwareVersion": "0.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.8.12",
"firmwareVersionInteger": 67596,
"functionalChannels": {
@@ -4894,6 +5808,7 @@
},
"3014F0000000000000FAF9B4": {
"availableFirmwareVersion": "1.0.0",
+ "connectionType": "HMIP_RF",
"firmwareVersion": "1.0.0",
"firmwareVersionInteger": 65536,
"functionalChannels": {
@@ -6391,6 +7306,13 @@
}
},
"home": {
+ "accessPointUpdateStates": {
+ "3014F711A000000BAD0C0DED": {
+ "accessPointUpdateState": "UP_TO_DATE",
+ "successfulUpdateTimestamp": 0,
+ "updateStateChangedTimestamp": 0
+ }
+ },
"apExchangeClientId": null,
"apExchangeState": "NONE",
"availableAPVersion": null,
@@ -6527,4 +7449,4 @@
"windSpeed": 8.568
}
}
-}
+}
\ No newline at end of file
diff --git a/tests/fixtures/min_max/configuration.yaml b/tests/fixtures/min_max/configuration.yaml
new file mode 100644
index 00000000000..707b57ffab4
--- /dev/null
+++ b/tests/fixtures/min_max/configuration.yaml
@@ -0,0 +1,7 @@
+sensor:
+ - platform: min_max
+ entity_ids:
+ - sensor.test_1
+ - sensor.test_2
+ name: second_test
+ type: mean
diff --git a/tests/fixtures/mqtt/configuration.yaml b/tests/fixtures/mqtt/configuration.yaml
new file mode 100644
index 00000000000..96c7e57f72b
--- /dev/null
+++ b/tests/fixtures/mqtt/configuration.yaml
@@ -0,0 +1,4 @@
+light:
+ - platform: mqtt
+ name: reload
+ command_topic: "test/set"
diff --git a/tests/fixtures/openhardwaremonitor.json b/tests/fixtures/openhardwaremonitor.json
index 13c5b5481e0..960da03f535 100644
--- a/tests/fixtures/openhardwaremonitor.json
+++ b/tests/fixtures/openhardwaremonitor.json
@@ -91,9 +91,9 @@
"id": 12,
"Text": "CPU Core #2",
"Children": [],
- "Min": "29.0 °C",
- "Value": "30.0 °C",
- "Max": "61.0 °C",
+ "Min": "29,0 °C",
+ "Value": "30,0 °C",
+ "Max": "61,0 °C",
"ImageURL": "images/transparent.png"
},
{
diff --git a/tests/fixtures/ozw/generic_network_dump.csv b/tests/fixtures/ozw/generic_network_dump.csv
index a953121e881..5ca41e879ab 100644
--- a/tests/fixtures/ozw/generic_network_dump.csv
+++ b/tests/fixtures/ozw/generic_network_dump.csv
@@ -280,4 +280,5 @@ OpenZWave/1/node/39/instance/1/commandclass/43/,{ "Instance": 1, "CommandC
OpenZWave/1/node/39/instance/1/commandclass/43/value/562950622511127/,{ "Label": "Scene", "Value": 0, "Units": "", "Min": -2147483648, "Max": 2147483647, "Type": "Int", "Instance": 1, "CommandClass": "COMMAND_CLASS_SCENE_ACTIVATION", "Index": 0, "Node": 7, "Genre": "User", "Help": "", "ValueIDKey": 122339347, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579630367}
OpenZWave/1/node/39/instance/1/commandclass/91/,{ "Instance": 1, "CommandClassId": 91, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "TimeStamp": 1579630630}
OpenZWave/1/node/39/instance/1/commandclass/91/value/281476005806100/,{ "Label": "Scene 1", "Value": { "List": [ { "Value": 0, "Label": "Inactive" }, { "Value": 1, "Label": "Pressed 1 Time" }, { "Value": 2, "Label": "Key Released" }, { "Value": 3, "Label": "Key Held down" } ], "Selected": "Inactive", "Selected_id": 0 }, "Units": "", "Min": 0, "Max": 0, "Type": "List", "Instance": 1, "CommandClass": "COMMAND_CLASS_CENTRAL_SCENE", "Index": 1, "Node": 61, "Genre": "User", "Help": "", "ValueIDKey": 281476005806100, "ReadOnly": false, "WriteOnly": false, "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Event": "valueChanged", "TimeStamp": 1579640710}
-OpenZWave/1/node/39/statistics/,{ "sendCount": 57, "sentFailed": 0, "retries": 1, "receivedPackets": 3594, "receivedDupPackets": 12, "receivedUnsolicited": 3546, "lastSentTimeStamp": 1595764791, "lastReceivedTimeStamp": 1595802261, "lastRequestRTT": 26, "averageRequestRTT": 29, "lastResponseRTT": 38, "averageResponseRTT": 37, "quality": 0, "extendedTXSupported": false, "txTime": 0, "hops": 0, "rssi_1": "", "rssi_2": "", "rssi_3": "", "rssi_4": "", "rssi_5": "", "route_1": 0, "route_2": 0, "route_3": 0, "route_4": 0, "ackChannel": 0, "lastTXChannel": 0, "routeScheme": "Idle", "routeUsed": "", "routeSpeed": "Auto", "routeTries": 0, "lastFailedLinkFrom": 0, "lastFailedLinkTo": 0}
\ No newline at end of file
+OpenZWave/1/node/39/statistics/,{ "sendCount": 57, "sentFailed": 0, "retries": 1, "receivedPackets": 3594, "receivedDupPackets": 12, "receivedUnsolicited": 3546, "lastSentTimeStamp": 1595764791, "lastReceivedTimeStamp": 1595802261, "lastRequestRTT": 26, "averageRequestRTT": 29, "lastResponseRTT": 38, "averageResponseRTT": 37, "quality": 0, "extendedTXSupported": false, "txTime": 0, "hops": 0, "rssi_1": "", "rssi_2": "", "rssi_3": "", "rssi_4": "", "rssi_5": "", "route_1": 0, "route_2": 0, "route_3": 0, "route_4": 0, "ackChannel": 0, "lastTXChannel": 0, "routeScheme": "Idle", "routeUsed": "", "routeSpeed": "Auto", "routeTries": 0, "lastFailedLinkFrom": 0, "lastFailedLinkTo": 0}
+OpenZWave/1/statistics/,{ "SOFCnt": 92220, "ACKWaiting": 0, "readAborts": 0, "badChecksum": 0, "readCnt": 92220, "writeCnt": 2150, "CANCnt": 0, "NAKCnt": 0, "ACKCnt": 2150, "OOFCnt": 0, "dropped": 27, "retries": 0, "callbacks": 1, "badroutes": 0, "noack": 18, "netbusy": 0, "notidle": 0, "txverified": 0, "nondelivery": 0, "routedbusy": 0, "broadcastReadCnt": 42190, "broadcastWriteCnt": 25}
\ No newline at end of file
diff --git a/tests/fixtures/ozw/sensor_string_value_network_dump.csv b/tests/fixtures/ozw/sensor_string_value_network_dump.csv
new file mode 100644
index 00000000000..071d92da0d0
--- /dev/null
+++ b/tests/fixtures/ozw/sensor_string_value_network_dump.csv
@@ -0,0 +1,5 @@
+OpenZWave/1/status/,{ "OpenZWave_Version": "1.6.1240", "OZWDaemon_Version": "0.1.170", "QTOpenZWave_Version": "1.2.0", "QT_Version": "5.12.9", "Status": "driverAllNodesQueried", "TimeStamp": 1598022319, "ManufacturerSpecificDBReady": true, "homeID": 3389163831, "getControllerNodeId": 1, "getSUCNodeId": 1, "isPrimaryController": true, "isBridgeController": false, "hasExtendedTXStatistics": true, "getControllerLibraryVersion": "Z-Wave 3.95", "getControllerLibraryType": "Static Controller", "getControllerPath": "/dev/zwave"}
+OpenZWave/1/node/49/,{ "NodeID": 49, "NodeQueryStage": "Complete", "isListening": false, "isFlirs": true, "isBeaming": true, "isRouting": true, "isSecurityv1": false, "isZWavePlus": true, "isNIFRecieved": true, "isAwake": true, "isFailed": false, "MetaData": { "OZWInfoURL": "http://www.openzwave.com/device-database/0373:0001:0003", "ZWAProductURL": "https://products.z-wavealliance.org/products/2780/", "ProductPic": "images/idlock/idlock150.png", "Description": "A module enabling your ID Lock digital door lock to a Z-Wave Plus enabled digital door Lock. The module is compatible with ID Lock 101 and ID Lock 150. It enables your ID Lock to operate in a Z-Wave network with numerous access control funtions and notifications.", "ProductManualURL": "https://idlock.no/wp-content/uploads/2019/08/IDLock150_ZWave_UserManual_v3.02.pdf", "ProductPageURL": "https://idlock.no/z-wave/", "InclusionHelp": "Inclusion – (Puts your device in inclusion mode) • Push and hold key button until all LEDs on touch panel activates (with ID Lock in an unlocked state). • Release button. • Enter Master PIN followed by * on touch panel. • Press digit \"2\" for settings followed by * on touch panel. • Press digit “5” on touch panel. Inclusion mode starts immediately. LED indicator below logo signals this by flashing blue.", "ExclusionHelp": "Exclusion – (Puts your device in exclusion mode) • Push and hold key button until all LEDs on touch panel activates (with ID Lock in an unlocked state). • Release button. • Enter Master PIN followed by * on touch panel. • Press digit \"2\" for settings followed by * on touch panel. • Press digit “5” on touch panel. Exclusion mode starts immediately. LED indicator below logo signals this by flashing blue.", "ResetHelp": "Device reset – (This will reset RF interface module to factory default settings) Warning: Please do only proceed with the following reset procedure, if primary network controller is missing or otherwise inoperable. RESET Z-WAVE MODULE: • Push and hold key button until all LEDs on touch panel activates (with ID Lock in an unlocked state). • Release button. • Enter Master PIN followed by * on keypad. • Press digit \"2\" for settings followed by * on keypad. • Press digit “0” on keypad. If the Z-wave module is not included in a Z-wave network the door lock will also return to factory settings when following the above procedure. FACTORY RESET DOOR LOCK FIRMWARE: • Push and hold inside lock/unlock button while inserting the fourth battery. • Receive reset sound. • Release button. • Receive confirmation sound.", "WakeupHelp": "Activate by touching the touch panel with finger(s), the palm of the hand on the outside unit or by pushing the key button on the inside unit.", "ProductSupportURL": "https://idlock.no/kundesenter/", "Frequency": "CEPT (Europe)", "Name": "ID Lock 150 Z-Wave module", "ProductPicBase64": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACaCAIAAABjfJA1AAAACXBIWXMAAA7EAAAOxAGVKw4bAAAgAElEQVR4nOy8Wa9lR3YmtlYMezrzcMe8U85MMjmzWMUqUlWqQaUqSQV1q9GS5Uaj3XADNgw/+MkwDFt68pP/gNsG/OiG293VakHqdqnmSUUWyeSUyUzmdOfp3DPueUfEWn44ySyKUhs2bMMwrLjAAfa95+yIG/tb3/rWEAeZGf6vDWZGxH/X5f+Nb/4/tYy/nff/lXkfDyQiAEDE+Qcev/4fnOZvx/+fx/8OyP5mDBHRaDSy1v4/vLC/Hf/fHs1mMwzDObbmQHqMM/XJ9zEzM+/vH/x3//SfPnzwEMgRESJadEop4YhRAqJzDgCBQQjBzPObMeCc6vjju5NzwAwAQkgUKIVktohAjqx1iMAAAqVzpJR6vCAGkgIB2DkGEADAwICMACgEMAIDIAgUACAEOjLzNSihiIjIoWBmFkKSk8wshJjfGREdGCYSQgiQQiAzExMzC4HIQkqJiM4Z5xwzC6kQ8fGuIQpmojmjEyGiEIIZiBwTCykAEef/OuLjeZkZQTrnhBDMVkiUUiJKKaUxhpmttUIIrRWRIyJgkEIQEQPCxw4EEZRS5BgBAIGJEZGI5rvLQI92H5GIUEprDCIKwUppRCRyzrn5DjyaQsn5TTzPA4D5Xx8NgVJKay0wO+tQCCEUorDWABIACCGklPOPdDqdb33rW6+++gUh8FOO7q+4QgCYzWb/1X/5Xx8fHzOzEEDzxyA1Azhn0bGQkpwjBkRUUpEgmMMHkByLOYjmCCNyziEiAwMKRBCIwMDMxCylAABGJAYpBBMrKYUQDPyrXXDEDEIAijnugemRAQghHmORiKUUSgtnLTMz48dIYuccCjGHgtKaUTwyLHYACMDuY1IW7FAIrTUjVVXFzIjy0ZuFmO8S4MdTP7KmR6blnJOI8PEv53ZimUDMHz/D/AeYmYQQUiqAX3kQIhIC50B31kkhAMARoxBzdFprlVJSCGcdMyul5suYPx1mdo6MsYiolEIhichZy2znu4SPuACUUtZaIpJSaq0RUUr5GFjzuVCKR0vCx0NrrauqMqYgJqWUlHLOOHPr+sM//MMvfOGVTxohAPyKKuZoeO+9904OjwSiQGHZzd/kr0XoK8fESMSEzkkQTIRCSBJzC0OQzMDMnpQCBTkHDGKOV2Kczzc3Q4EKBDgiIkAUUllrmR6ZnXUkWAkxB+KjTZdSMLGx1to5iQoiImAERBBaKiUkMLBABhZijgwQQqAS84lRIUhgZ6WUzEyEc3p0wjKxEIIBiRywUIhKKeecEHMCc0JJLT1rzCd2E+e4JGYE0J73iBeZiT8GPwIwSymtNQyECACotWeMcc5JqeYPeM4izs1ZkueomhsYAzw2e2stIc53mEwlUDgiYDLGSSURBQAQs3VOMAshtKetZXJuznlCCEC01qJAJdUclI+YCYCIGMCT8rHjmu8AAyAAEllrEdEP/Pl65qh4xBrM3/72t9vt5vXr1x8D6dOuEACGw6EFQkYEJClZidWXz6dLJneVEwAERKSFUEKWFRsmAvaFRsGEQpOQnveIDZ3TUiMDEjFYED4wSmAFIvA8jdJZa61lENrzEREdS5QIgIBKKSWVdZaApRQSpYRHvlaitNYCIjBIpZy1zISIKNAYEygPWQDynHKcc845JZW1pLVm5kZUR0SBoizLsiwQhed7SZoKRHYYhkGe50islRZSlFVJREpJwWyMAUAByvf92XSKkhHRkSuMJVtJXzpL1laATCScI8cExAjITAAoBCqlXGWZaP5ItNZERI4QwBEh4By1jmmuJRQTzsmbEVkAAjmaW8Vjj0OOwDmHIISQJBw5IkZmCw4BEKVCxcCVMQ5YCvSUR+SIWAI6IkJnGYUQUmjnnBGEAEI45wi1ZiUAGBAcWCYLjpCFj6q01ZxLrbVKKnIuS9Mf/vCHTz311CeFvHqsuR4vV4pH9MjIS9eXsqWc2K2FvY3lC0fHh/UoSk05Gg+6YRgXU3CFYN0Nl7RSk8m4G3WG4xNHjhEkEAOURaG07jS6w+GZA8dKUZGDIyGEI2LG3OVKqjmZzfVKFIYud9YarRQAWCJGQMTA8yWjsdbzPN8PnM2JnJColAIA3w+V9lcWl52p5haZ57nWOs8LYG42m1p79SACBET0tWednU1ni50VKaVA4YwLw1AqObdg51zNDwWiMSavyjAIGNjTgXOuKEup0DlnTJVCWZRpTtVHh/en8VhKEKgB0Dk7V35zSUDMAgVIJOY5gJhorvCkks45YJ77NWKeq0CplDVmTgFSSOfcY2UJxOycEAKFqKyZc7MSgoicdQLFozmJ50h1TM6RFKgB5n4TQSDAnG4R0RHNfZ8QgshKIiklsJNCWmsF6Mjz0RDlxLE1hyVaV5YlP+JPFkIOh8NP+sFfMdbj6/maUCAgaKDrT5zfqfafaqxwQjiMuVTLOlJRdyehzeW12eR4lswKpS4vXkgmo6QRbS1dyKPeYHyU2rLf7iqpBqOhQH1x+fJA1EbTs1azGflBnKUlOwBwhFJ58EjvkhDIxEAkAoFCmEeq85EUAAYAlGGAKBGFFYxC+Ep5vjeLZ5G2Hgt7fOQAfN9HJt85yrKW71vnaDKZFkWp/MAPmo3GaBYXReEHflxZR46ZQy/KEPIsV0J0u11rnZGamH3P86WanJ0YY8Na5Pu+qarMOc/3pcAmYMTexNnZeGipVKEHZJ1z2vOsc8SMCCgBmZWUjgiApEQAJHYokREcl4gECFIAgENgEgRSMluhCJgRUACgemz8zBKddIwCAQXCXJMQIigUmhwbmAc4c7XpSAr0lCTn2JFAAQQENH/ijAwIQDQPcFgQATIJJ0ACAhM4RmErDcSsGDvYqwXe8P5k7rmrqtLaEx+z0ifF+68ipk9qybnCiGo1BBuWriej6eEJOhdIGY+Hxzt3ymIKaX784QPFniKf0uT07oOmbrgsvXfjA6gEO2xHzYc3bnkOW7X6ye0Ps5NjUxVtXb//5k1tmSrrjHXOVmVRVYVzlhmMsY7YAZTWGmtNWbJzPN+R+U4556xFYCBSAHWpmo6zh3sRQGRpdud+RAZdlaZTawquSjZFkcycqcCZACEyaTl4YLOkE/nVyXaN2dfSzUbFyS6ZNAKaHNzzVVWVeXx4d3D/FtMsHh/ceeenXE0cxWcHtwe7d5yJPaiO794sRkdFfJQfPMgPHybTk5Ly0uTELvC8tldrqEgqUMhaCqWEkKwl+RoFskKnJaAAgaClDHwtlYh0WFe+L0iDQZdrSYohlL4nAbjUgpRQhM5ySeQI2LBzVBIUIC1DBVgCV+Aqj0E5KRkVk3RGMQcI2jmsKkEkEZRkBEY0ApwkA7ZSQAggANiRZKPQSjaOnWHLSBIdkFFIKF3lTZee7CxvdLQUnqc9z/84isJPAUn+8R//8SdR9dFHH92+fXuu71bP9RY2GzuDvQe7u6PEjWOzvHL+0qVLYb1bkKh3e7pZO4nPEKOF3mrY7E6yotZodnqdjNkPOoHXee7ZV1A3Ot21CxeeGseZrkXajxZXlk9mYyNFRWyZHotKJiZiay0zELFjskAOmIWwzhFCZa1lYgSCud8kQDy3tlY6lxkT1Rora+ujNJ6UeWVNZYwBMkwOuCyrZrNtDfXaC8zCODTWdrr9cZrHadLtdIuimualY5SeSuJsMpv1+924mO2f7JdlFnhBmhVFWfnSz/PyZHRmTVUUaVLEo9kITOlJvHO6DZoFu1CoTr230DonFI7TIbBDx4HvN/zWcntFOCEIAhW2W50sTfvtrknLdq3lsX9p9XI9iMazIYEFZgGurutPXXi2FXWyWba5srG1dMFVpakyQKvI1rVeqvWxouX2onbBE1tPt4OWTaqr559s19tZOg0i31RlPaoHniZniIyW6vK5Jza7m4JlUcxYkhQSERBZCCMFITqJJJAFMiBIBCVACJIISomAiCUrqaMiSsaZI0JmgQIR6436l770pTmw5kN9KkHKzMYYJRUAPPX0ddaZQb17chpw9w+++HJTF9odNaV4YmVl5uT7N3eee+65fnu5yPOwVau3RafZVmpj8tGdyAsUB7sHQ3JMZZZMsk5nCyV5SpKoxGTsSSy5qsgIgSjVXNMys5RybgTOOQUohEAUjIAMc+kqUACDE8BEJdi7BwcssAQ8imdja0qyhKxRSilBCCJXWcME0ySN/MagYqkbpS2KIosazUyJqizsbObXm/kkWW53Kxvu7O0oLU6tmaKcVSxrQauzeHR4HAbepUtPTCeTg7sfZGm6sbp2cHJQlE51m6UrTcWqIBlJcnA2GljiJJ06rpwjwSBz4esFXzQlmrJMlhcWmVk6AUZI8LQIZ/lZXEyPhrslpXODKa1TGOwfPNQq1MrPs+xscGeaDxiNBElMitRya9MVhyu982M5un//wyis1Wr1o6NDAlELmiQNkFDog2WFzlCJYLNsdjw7iO3MQYVOGKrmSUhABwyAaIWYB7caaI4IAodKkakMI0qJ0gJ41rm5mTNZ7XmfxM9f0Vj4CTfJAMZZIrrx1rtPvLpRFgUb/Po3vnouKnpi/P3v/vj8+vpnvvDFuwNaX7sMTv305z8zVekrj4Cfvf50p9navXO7FoTf/MY333rrzbfefIuZvvnbv+X73u7u9mB8duHald0Hx+2FVq0ZIIMELaQEIALjrGNGYCWEEEqTsww8T4/NLQMRyTlAVAigpZSq54fC2pmnqixrOwianYPJmQMnhGT4OJeIQOwqU7RDcFkS1EK2gPmkrlTMAssCbREGcnv7Tgi2Fei4NOVkFCl0gbTZbApWapeb2a3777M1QklmMzjbJ3KooUhGVlphCzbMVoISWvdQyKJMUdByb72u67vHO9rT2SzpN7rxcLLZ38yLbHR2OJudLvQ2k3QKXEoGNo4tSBTIMM+GbmxeLHN7dnLUbdYbUWscH7FkRw6RbOWmcbHcWx+OpoXLZ/mkcEWvs2KrNM3SZqOFFiQhuNIRMhMRCSfa9U6gwunBacWVBMVgAZGYEfhRfO0eaTNCSeyYnVRiHkY4lLLigk2WJMQMDFJKkKiUeuzyfpV5/1Qea55XraoKABqtti+9GuvV/kIvgKvnu//Ff/LfHp1kgfeBoej6y69cPb9knXv62sXLm5drgX/r3l2tPSngt37rm0WcUFWhcUUcK6kWWp28qr78xde+8+PvKS2+/rWv/ss//RcbW6tSKyVlVVkhBDuWUjPNoygWAnmeFAVnbK6UAhDACgCUUAKVI8fGtZr+/e2Hut1u16N4MCS2loAFzBE6Zz4UwtqKjZPghgc73c2tld7i7RtvrZ3baPSX8vHZ0d0Ha1eeXFlavPnWTzY2L9UaDWmq/e2H9aUeKjnaPV7Z2CSlcDobTQbtpXPtev3OB+9euHQlFx6PRooqxaK0VFZWCduv13v+ggirg9nO8cmuREnGlUlSGoNBBdq8f/ftNM/8mswmhR94J6exYyJwtaCV29S6nJkFkqP8g913fF1jH0bZZDgagnRATMSAVIlskh85amZ55vvBxsJ5BKyKKkSv3oxKk3pa9mqLQRCNJietsOlpmZtsPBklSYrokSsQieFR2CrEI05x8zQcgGRH7IQAZocoAJiIUUhjyyrHPMuBWWsPEcg5hl/Vc+aZs0+nG+av8ygMWTgWGZHQARgGUS/CpeBchGRGmY1CUZW5c/Jwe3v3g3dbjca0cpeuPlkpvP/gKJ3MyMK9e/cOTo61Um++c8ML/KyMra0Y3GiYfPaFz999eGdptd3udk/PBvV6oypy50hqlELMCxFKaeccokfkP8pdA0ophZhXRYS19tRaXFyIK+csN1fXJnFsCYRQjEpJZGYp0LEDFAzoL/Tanhgl+Ww6ufa5z+wfH89Ot50rg63OneFHfly78tLzpyfD2Ti9sLKy9dTze6dH2qnrr33+YP+wTLOtrY22W7m3d1gQvPq1r+8cHB0dH19dW6+3GrXBYLnf9zyP2JwNj9I8rmxuTSWNCGSk0B8OD2OXHY5JCFSsFAa5Rbbuo7vvOLICZDHKWrKdlFOSSkrJ7ExB4MqYYgdUVQycQ6Uk60ADM1prx7PDsT3S6CnhdXtLzrnB2c7K8gYzDcfHUul+f3kyG+eFWV3orfY2Hjy8A4Y2Vy+ejvZ2TmJEZyTNh5Q4rztZZ+fOwYJgZkIGZGYnpQCwjtCqiFmbec3HkRDCD4LHfvAxttRjSOHHlSkhxDxVsrq6XBa5tWY6SAqQWRX8+//g3/vX3/43rXrrK19+6WCUHB6dNVqLv/zFW69+9sWNjc233785Gk0dy3/2P3+b2R7uHwwHoyRNlBIpm8Wlxc+98jnta0EyLYwOauubl4IwSJNci6DmNQMZzuKJlKi1FkLMq3golFYaURhTKSmRgVkQo5AIzJFf86Rc6S4ejqe2shxJvxZVMc0LWoiAKJWSXFaACBL2T3c1gUFryvLgJMudcWydK3dPBlVVLC2vH0/GDo3UdDo5DbzAKutcdfvBR1VZsqP3t6eCOCvLEMP3HtyM0yTDcnu4jyNV85v9xpLWgaNyVtybJGdElhxgii51v/G13zACXr/z+mG5TyWak+K1555ZX9u6tf/hzdEdIhKxU2yXlxpRc0M1PPRUlbvJ4ejJzaun09ObZ+8lJpZORia4tng1rDeiTvRwtPfR3p0mN873zns17+HZw5NqX4IbHhwsR2tPd68fJUcHRw8rYp+9eOfI6yyFqA4H27tn28Jx1zZWe6sFF/eTnRILYVVfdBeaK4Vzx/H9iksCbFJrobVMSIez/ZQLdCg8aYFqvUarWcRpSsYRkc/wyQrVHEufjgpv3bp148Y7zA4Qzp/fDFre8fhsNJwxy06zu74cfvM3P/viC+dv3Ts6msl/+xc/2VjorPb72q85Uu+8f/vc+talS0+12q2Nja0rV69eeeLqhUsXL12+cvHy5Zde/mxrod/s9QgxTbOdnQdRGBBSWhaM2Gg2syKzZJXSvg58L5yLdHLkeVpIsM6WVYlSKU85dtYaBJDkRBzv3P6w2+16zEd37y00GgaQmQSKOcUxMxMDgJKiyXD6YGd5cUE4N9o77NUbCqGv/Wo42dy4XEeRHRwuNruaOGIcn5x2m3WsTHZ8vNTvlCarWeayCKMgQCgHw167CYbrAFglD053DydHk+R0Gk+MKQmIiHWOZj8e7J0+/+yLT2xe3Vhdv/XRncP7R4cf7l2/fP2Vz7xy4dz5o+OT09PR/Xfu1UTw+7/7exdXz0/TyS/feeOdN99IB+O/+82/8/SF6712/+6De6fbp9lB/LVXv/bqZ14NdPCXN/5yODg7vX346ouvfP21b2wtbR4dHs4G09HO9OLy+d/7+u9dXL8Sx8lgcJbuTdeaq9/6+u9eP3/dOT48PZodjuWEv/LSV37txS9LGW4fHeCQ26b91c98/cvPfTFU4c7hPo/IG8vfeOnrX3vpqzUV7YyPDeUsWAsdFPXZ4YwAtNRaK8/zGs36l770pTmQ5q/yj/7ojz7pCj/88MO33npbKYnAvX7Xq3s7J0ej4XB375RASb+zfTg+GNgUG3/2vZ/t722//JnniyxVnk7yIiny9fW1b//pt5P8tNNr9peWO73e8urKuXNrC4tLXhAQEThmS/3u4uba1tlguL62ubSwEuqo2+x60quHjaXu8mJ7uV3r9JoLveZir7nYbSw0w06n1vdk5PuhkorZOWecrYyxS6vLXhhmDF4QnL90aZjFuTGMOC9izOtZUgilFBMvLa00W92zLMMgePLa9UmSWmuXFlfrnX5WOOn5Vy49kVa2IFpaOtdtdQeTqbNw+dKlOC9mSba4uNjr9ifTqef7l1e3wNNJni+2emurmx/u7JbOaF9nxXShs5CVmTUmzIPTB0en00GtFjR9/1x/+d79j2784sbw8CQpU0emW+/Uo/CNH//s4Z17h8c762vnQvC2Nrd+/uOfv/nzNw+PDpqtqFuLNla37n94/40fv36ws+MFsh5Fy71+Gic/+8FPdx8+SLPp+fWNyAsWWyvf/fPv3Xz/1ng8OL+27oF8cuvq977zndvv3trd2716+XJdhs9efe71n/7luz+7cbZ/sLK+0Gu0Xrr60t5HD9780Zu7d++tba1uLq9fO3d1Mpj95Ds/2rl7P2oGT1156srSFSXVh0d3iMATYa1qxicJA0RBFEWh5/tB6D9ON/zNRWghxLyVoqro3Mp6QmNHFSCULn/jxpul5WuXr6R5fuPGz+7t3CWuIJIypFyUKxcu5EAy0ARJa2FZeNhd6KbTWWUMCyGEkAKuXLg8noyzNNMKyzzrdzvMZKuy3ajbqnKFnSYxkvrp279QLDrtbpLEtahGAGWePvP09Tw15zZWTk5PO7U6tmg6OTubTo9ns3a3LZLcAcTWelFHVFOWGPp6qb84Phs6KktnS2c1ygdnA3AmNZV27sPDPeOMdXR/eIpCpKZwDsvhmWPHtjo+2QdP5FVR5Obmg4dJmhDzIZ56yhcYtupLurmg81RjCl79ZJI8cen6YDqUvkyq5PjklIWjijDzi8x85nOfX1xaNQzG2qVwocG+7PS/+NWvxpNYCQhZiZlrot89339w8mBrcTMdJXZUtWXTW4v244Odw23P93uyHpSe7nVzVQ6GRz6LDocwcaEfqra8cfuN6xvXhfTMWYzGXb72xHQ2pLI0VdePuYrT+tbSjY/elklRlXnLBRAXeqN+d3inv91jwytRLzsZc5PeP75xYX+96y/2vdbscEJNdze/97M3/+K5tef7sqmMX/kFIHoSXVkZV4Jzjnzf9z8ZEj7q+4C/NhCRiBGFJVKeJnZa6JX++n/0j//J73/1xc9f7HzxycX/9D/4/X/4936/riM7SiZHg6c2n9xYuvylV7+y1F8CiRaBpBiejZ+8fG2lvXhxdf1zz7+gEfNsdnKy325FokzcdOCmZ/1QX1haHB0fsCnPTg6TwbEs05YSX33ttQur5/bv3T+/uvo7X/n1tU59sH234audnV2BQjie7ux6WbEShPW8yPcOFrXn0jg/POg4FyL0UDRmOc6KwGKQmUXPDyU6UzSsbVZmOYzagqIiWw50qKBGpp6n/cgLXanyuM3c97z4+FSldHB/f3fnyPM6QjRbzdXx0P385x+Mx4apNo2dkq1uZ8O6YHvn7ORkiIxVYabjWZzE5KxfiWKUALiY89NiPB4NFWAovX6rs7axtnt6GFd5PB0ttFoLzdbK0lJvdfEsH1u23VZ7Y3Vla2396aeerjf741Ea+Y2Nla1IBt1+NxPVWT5bWl7a3Nxc7fTXzi1TXT443RESVrr9cysr57e2VrcuPDwdEMvVhXMXt670F5ZaK0u6Vd8/PV5c6D1x+Wq/v9DbWh8rt3282+u0r1643F/orF7dnNjk5s6tej3YWju31Ol1t/oz7d7bvYWSl5vdrgpNlVubSy0cO+dMWZZJEpuqmpfbPjl+5Qrn17dv337nnXfngeGFyxemZrx9slfmxT/5g390qU3rrfT2mz9QdnxxtVurLZWse72lxmI3NUZodXR8EEbhR3dvNlphs9aO/Kgbhpqo5vtVXg6Hw0arBYyB8AZ7x7PhzORWCi2lOh2NlO/tbT9caTVOdx5aY/Z29j/88MPbH948PDh48OAegm21mpM4zYxpt9u+pwPJWTKL6o0wCibjESpfCBlIWWRps7vYaTbGgzOvXstN2Y6CNCsqIgSx2luYHZ2oRl0I9ityzJa4U69Ph2Pt+4FQqrCeFyrpBfXOB7fuMnoSveXO8mJr4cL6BbJUD0NT5gcnB3uHu8PhQDDPRiPB7OlwdXH14rnzT196eq17TpYoSnHwYI9qsrnVzkzpGRwfnKRJXBU5hpK7vhRshtN4NElms8KWajUUgccTOz09s8YWpnBNKep+OUpNnOZZ9sbbby1eXXE15NwFzouH4/FkbGrWNJlL65XKZsVwOrIasa1HyVnklMvyOIuPZydipZYVeS/sKMPWuYPT4+Vrm7NqSmmx5HWl8u49vGsXVOabdDa70DoX+MGd7ftmkY3HpSmWZb9fb98/2j2pJkqoumuN9qeGLRJaa6WU7U7rk64QEdUnlfvHQaMDAGvtQrc9nY6FL59/5tlmYC+utP7z/+y/eXD7JIrgP/yPi6vP/voLzz6fJPHbb7//rd/+naqs3nrjl9eevFbzwvVWL9Iy0PLGL392evtubaEXBLXexpar2JX84Hj3Zz/+yVtvvNFo1F966aVrLzwDAj3PX9/aSs5Orzz1zOs3Pjg6OUEJTzz3zHQ2HWV51Ok2ltdvvfOLxXOrJ5OTZqslpbBRjWvNSZ7I/qLVgdbSWI5dWhbWqihcXre+by3nkYqLvKwcSDHMc+/c8miaMPBir5vkWVIRGqqtre2fHkQ6uHL54uhsQkLcf7g/GsXnlte+9du/u764KIRCVM8/9SwASlQoxVy9EXAQBM46RySVAHZaqx/94LsJNPJkMolnmxefaPkLjVZ9Z/vgcHC7FjTKsoK2qGxZIh2Pj3c/epAVGfjggGdp8taHv2i6erPVdsKV6Mpk1AyWHj58kCWpX/cWl9dPzclJMbx96zYUlrmStYAsT0xxcLx3sn1qcyMDPwj8OE5H8ezwzgMjKOrUEpMw8cib3rx1Ky2zeqfpaa+ssonj9+/c0ULKhleoqS1p6JK3P7gRidBvBhygIM5deWvno3gYUzLvw8NxNmMmAWLeRfLXIQSP22b+SqAopbXWOZcVuXR4vr+01usJFAXLQe55y9dKQff38mc/75SvesHC9v2Hx/v7y4tLgVJKyJr0yrNZR7dG4+N33vrlmg4kO2fKt3755mut3tH+/vb2dq0RffYLL55bXl7qtlwyvnrt2vHhXiuqm6g1yMwXf/2rQNaagoSHiAqZUJSmevrKpU63kRSF48JTYmVjYxLbUOlO5BuGrKp8Twf9bpxnxibNmpxlY6oKw9zwNAguK1OawpNYq4VZXiRJwkjEtn6MfgcAACAASURBVCxzD7lXa8uqvPf2L6OF5SLP97bvSwj+0T/8x41ag8gSA5mSpVDad0wahdZKIAopiZnIAbOpKmZ3ejRUzAGIh3fvTKp4Aatmt3l0fDAZn1Y7g/XVTdCiRFMVcWzc8X5SnsQrqysYeaWxKIQFc/rg/uXLV4NOo9VvPDi6u2MSs5d7Qp+/sLnQbQ32D86mw9uT2LPab3hOqloUnk32d0YHItatVuj86ujsyA+b79+7JU/yTq9NAVWmCL3GMEnO9valkt21xXtn+5M4yU36YLQtDJuIS1NyZqdc3p3cD21U6iqvShIgUewPD4u9JA5KarFRrrLG1x4x2erTqHqcDf1VSefxb51zRKS1brVah7MDYp7MRsbZIGj9vT/4w3/17T/zvfDVL72cpFVZQegpT6vXf/qDS+cvZLNJWZZxlV/sr/itTsiOpG/n3fFSjkej4WxSCn7ulc/oUB3sPtxcWRod7QAJS4DSDKeH3ZW1ZrcXBWL75kf72/cuPf2iUPrN13904doTtXbPc8VbP333yeefL4pif3u7v7QY9ZaqdHLn1s2Xf+1LZVYd7T/odjpLq5vZdHrrjTeuvvwiibI6GXj1ul+rUeU6qD96772nPvPZs6IqT85a/bbSKpJi9+attaeebfeX/Y2LD/f2hPYvX702PJhGUc3TQZ5nbKvb77+/ubnRXli0ADs7D7TWSmvrbJEXaZbmWZGk8XQ6Pj04eOP1vxSeeDB6SAv+0Jzeuv8uMTkwSVEcD4erF1dmqiRXELvJdGSnebPXU72as4kjiou0LPPxeBw0+HD3uBLl3mgkJxBx2Gw2b957r5ImK/LDs0wbfzVaqsjEZ+Mkjw/OEi/RUX3VsEni49Pdm3rGwRD8UFWBNVTtHz7cL7aXJvVOrT3zihN9OM3GvlODeKRLKc4p51xRlbYqx0V9HKe1lVASCARgGKXj/Dhtr7bAAYDwfT+1MQCoj63rr3c3fLofyzlnjNNaae0r0NrX0kAl7N7Z8HS2+s3f/PxrnzuvQBqBH+1Pj05OVlfXglr0xLXnXnzhhdz9RDgKMZgcj2uqe3Y6XlvduLK2UW/U/GYtFhKkJWEzk3rS91vNvekYtCdkkCBD2KrVelrXpfCVDhbXt1q9ZdQBMLz0ym+glEEUJuRfejKqNfqWptdf/FycxJ7veap77aXPlgQyCi8+8+x4NAubLaG851/98iQfG4Ct688cnw2SOAFmrofPvfqlw8kgMeXFp66Np5M0Tf1W6+oLLw1m49HRkIQGdgLU7Xv3FhsL77399kJ/8Ze//MVit/v+O+8cbW+WxDtHR9t7h4ColZo31FtrK1saWzFzGk93Dg+G1aC2VveVLqq8NDkIZLZxmSf7B531XoWGLTNRanOXlwfHJ6sbW6ayJCgxsSOzf3R05XK3ImsMzfLYJVWD6i4XFMmKTFpmnFs5yTBC2YoIDNuqqKAaV9vy2LvSLE1pqBTCi7N873iANQ3CGk5D4cdJliZO+0FVK9mAJ3xb0XA4Cf2a7bvKWemEQO9sfJZ5AQSOmRD9tCzycQq+xhYyOdRoyeZ57quAEDzP+6R4f5RueAy0X9WllVJK12q1JMssOXCWNN8/fBiFtbVuK5RQFLOE1Yfb+2ke97l7/flrMhT/6/f/wveCzY317Yd3ts5fkCqw1pLQNx48iOOp1Gp5bbXVbC30F6uqAsCa9NutxuHOA+WFAaqyLJtN3W7Xp2n6wXvvO2NsZa1xtqr2Hu4t9JesIQBw5BCRmKJ6DcWjFnIiqtciApOaZHlrpdgret1evRaFql0y7Z8cF1QadJ72E2vTKjHOKO3vT8amqsDTkziLQXhliaeDcH2lMBQFulX3BoOjd99+K56le/uH5KjI8we7J9L3KufyrHT20YmPX9VYhQZmVLXu8hIwQR0DPwRCIEBkyxSXFaQ0y/IKEVE65yxDkueQyF5VAkJZFpWtyqosU9ObjKnO5IgcF7mhLFGzsNapC2AqKyV1meej0WgR60yQ50XLb0zTSQbV8lbdKqwKbqjaWTbLqtnCZh/Jq0qoy1qal3k6qjXr4DNaCHxPgoiTpDyroIlgrXbK5lWSxAUY7nsulIBWO0oGp6mt6ms9YdgJ65isNQLkXI7/DRrrU3ms+eWj5vmaDxN19fyLk+RMoxgf7E72w40Ll9Jxejw4aS2tRvWWI5kURWnKQTYOuXZWTJ/53Mvf+96PLly6PIhnS/2FWqNZGdfqttdWNyO/fnp2erB/4HmSqTLdrk3HSXwmlcmSEZtyMJrtHQwubl7otFrT0chWZa/dWQiaP/nZz4GUVD7K+ckZRhxIqQDYGOP7viO7s3P/m7/zG33VWOov+VEURNGEkpkZlZNEIl/ZvMjs+aEPzElFaTFhJI9cFadLaxsHcbKyvKZXVndGI0JnnN26uPHu5IPbD247I5hAatVu9PPcxtNpnMyyNAVAz9POOQQEBKV93/eV0vXAu3Dl6Td3C4oAUTgH1jkfRJqlmcvT2exkchLW6gpUUWSzdDaajko0y2WOPlhj4yydTibSiOV45kchCkzydJrFMCWV+qENHbqsKqssz2dpq+a61lpyRVVBlp5Nx57zF/gcABlTJnY2zWICbpk2EFhymS3LKpvEI51rnwUyx2niYj6dTdpBLYCaIScsFM4OkmmEpiP7AEAAFbvjdNqREGEPABAFMAshnHMokD6uW38yj/U3FKFNVTnnBtYOxmeBF+SpkegJLhu+V1TZOB6068obVWk89aOm1vr6089Ox9OF/noUhd1e++HDh6995WsPH+6A0Lv7B57SjshZGp2NFhaW0klS0x472/VRFeOrl5/rLK6OZzO3uLW983B7Z7fd7Hz+s5+Tjqsyf/+9G6FWkpksVGWutEUhENBaA4+OLrFztswzT2skqOuGb7zZ/vQzr1wjxJpsjA73z194MimKo6PkbDyJwsBWxdNXLmaTkU1LoWSvVndZTKY6HA+kVIVxgFCxrTXrFy5fnpzOlnurtjBlVRlyR0fHVFJeZcPJwDjbaDbLqrRkwshXTguL2ldL9dWN82uip9+6+54hU5auslagmmazlNNC0c7J/npnra4a02Scuaz0jMAyTZJQBnmeOaRUVj7rvCqU07M8Scs8h5zQlmCM49LavHAs1RRLjTV2orKcZplwkhQndmar0qKNswkyo8I4nVV5JSOvLEowsTPVLJ3WbRttkOVZ6UycFdN8qowAp8qichbO0uksmVaebUAXrQJhysLkYFw2OUcsifSjjLfP5BAQ4VEHKX/iGP2nNRYAOOsqU5VFufNg5/yVLV1Tt3d3C5durm4Oxsn6otgeDhOGc8u9o9PDswcHtaDBjAggAYYxlLaSnjh/aSM4UoMzQETKHGs6nZ5exisLS/1GGMZnA54el3F29+49eXC2tLJ8Nh5s7+3X6/Wj4+N/8a/+VZnmaRzfuXX7cH//yqUreV5qrYSYH1EEqeSj3ixigEdnmxDEn/35vzG22tjcevPm+61Ou9VplVze3X7Q7a+QVZLFc08//6///E/PP3GFe4sKUAgZ1IPB0a4kqlVFWlb1disx4Igqazv91srS0tnx2axIe93u2tpab6E/nU6tsUWeng4H156+fu/+h8PpKDel5ynta6lVWKvdObxrBNWiKC9SIrDWlMhZlRtNJmD2oLTGIzNNU6vIBOR7ZGzlky6NISTyWdb9gim0OImzwhiUtHRuQXrSgYvTVCoZ1Hx/c6nVaXBlyjxjY5NqFi5ExJ4UMi1TY10KVnZDVIXzReVMUhapMwRGd3wOsXQmNyXYsiqcaEpoSyAGspVxh7MDbILX9R2BRNbOj21itFUNDYqYeVYmBm3lrHzUFCf/eoL0r+Sx5keLnHPzHq5+pw8EnvJatR5XapqasNmcTU4lirDeklb26r3x5CCJz4TyGNF3enf7YejXwlZjd+9+KdL1K+emk8mi12032+PxeO/o4frq+jRJ3nz33Td/+sMoiC5de2Z163y90zodDMOgUeTlK6+85gzdunlzmheoA+GFhSMSQFIQAtD8aLWYn5l8fBbKkXMIJHCSxMfv3Qgf1K4+cbXT7ZiqWF/sdANvPCnazWh8empmsZlMzz66/8LLrxCqvYNbSCZQfrPXCa0b5QUJo1AYclLYkisVVI1m4avp9p29pdULvVYrCAKUfDbqRnVvsXnJCTEryzu7u5NJrIOQlIyLbBJPHFIt9FF41plZkadVGS00z1/q1uuNkqppkc2KhAK48PTF5fYCgcurcjgZZ1W+srnUqHVkQ8cmGycTo1znXKcVdnXdq2wxS2dJEQsN7eVOFAaW7SyZMluvFpy/sIVOAkCe50CMqoyWW6q3iCFmZcaOPQ9gWUKldVMbVxTOeQr9vi8KrPUia21mq9CTwYIutVc/12ScdxxBbbG+9vRqb2WRFYFEVMDOKCJU2hF9ClR/sysEAClACilQDM8mWxfWnQLh6MUrL0Rh+6Nbdzu15vrm5uu/+Mu/+Ol3v/V3fzcU9e/+8Adf/8Y3pObZbBhozWDvPvywVqv5Jmw2GrYyga+1h+12yxgXNZq+X//Cr/26F9Sssc8993yr1775wft1LRbXzjU7C3lVvfP+O+Rc4MkokleeOB/6QbWfKgBn7DzNNj855Rw9oqt5AxnzQq/3zDPX/War2Wl1+r3RcDQbnKy36uVocHoa37x9JwhCP/CU9jcvXBqMBw5ooVFPMplIsRtPBQhTVigZ54fjQRC6dqSM091W1wS1s8lBq9lMDSRJ4rcbBrKqiJv9fiVtLVCp50V+yJJJQLvZKlxZ2syYihCSNGXmdqvdWeyXRYEC0zwxbOv1Zrffq8rKMWVxXpaVJ73F5UUhlGObpXlZFkrqztKiFNoSmLKM05jALa6sXN24fmf7dmnKcZEawNVO99knnr+0+cQsm/0v3/nnACLw/VanTgSWbZnMpHORClsbPXDKMk2mE3Ksw2h5cREsVeSSNLa2kl6je26hv6ZLS845qZVFFpHsX1jRUjGjNaw90YzqcZbY+RFv/nRI+MgVfpK0hBBaqaqqQKJw1ez08PoLLz335DNJkp4OBmtLy7M4/uXrb9x8/+bT16/b0tx6/1YjrP/4+z8qyV578mqnvZwm6frSZpGXUbPR9Bpep+asaUb1UTFRAtphp7XYIcerSxtExEizbDI8G65uruze+8BvLUyS8h/8/b/vK4+q4vbN91dXV49OTv+nf/bPs6wABiHF3PE5YmYgR0xWa02WPJRf+eKvnzu3zIhXr10La/WTk9Mf/fi7OVjlh1FbLSwtb25snA7PSgbjBUEtqAdenhWTZN/YpIWMSsykMSyBmZiANQtRa7dLUYtR5p7HkTecjNDX3XMbJivrft0utW2VNKVoBt6RmxpnBYIji4ihDpQScTqbxeM0iQPPW+z2Iz9CwsFkOIsTH4N2rR0GocFqNh3P0gSE7Lb79bBeVlWZZ0k89RFb9VazVi/LsiiLUTwkcsvNtZefeu3O/TuZycezs9KaVtD+8vNf+8Izv1bmZUO0vvHZ3/7e29+xylZEjmxSpKWphCf9Wt0QSAl5llfGSelJFebWShQ52dxZVB4qrzAmDDSgm/cfC2AiRCEJFLLQKBmBpbDMWnvO/ZWQ8N/Z3UBERVEwcxiGL3/uxSo/3b1/7zOLa81+sx01f/SzHxd5zsS/+bXf+K2vf8Ma88JTz/73/+P/UDn33PNPff8H37n21OVrV56u+0EzaAgUw7MzZmYWybS0pas3anmes4EwDJ0zf/Inf1IUJUte6Hd3T0Ze0Dg5G3W6Cz/6yU9ajUYg1Y233tn5l3/iB34cJ0rpeZfjPCqZ9zXOv+hBKUVEjul73/++7+vFpSVDnGTpLJnt7e9PsuLK5SdAelpiEARVWRKRY5CersDlrky48iWEOhycDvRCy1Tm0TcjOAuEZ0UpPTWeDRlVv9cdTdOFpaX9yVFfhaNRUno6YM4Hw3pYIzoprfXEx42TDIEKrO+yPG/VRaPeiPw6svR16OswUqbRqi00O1JI48lUJoHUzU5tsdvXwleBl6WZB7peqy32lqTwhFZ5UnkY1euNL734lWQ0s6aE0ggLLa/54uUXn9p8ukxL64wpyrZsvXr9tbfuv51UcV7lpqyUlEEQCfCAoKryKkslYC1sesJjy4UtiyoTLJUOpIocYZKnzpDUSisNKOaBuCEnUfhSWXLK9wjAmAoYiOdn8eGTWFL8174QC0EwkKlsVVRaU1mZN15/3Q8CY2yZ5UoIT6pWo/X+u+95Su3s7i4t9KJWfePc1u/9nT/84Q9/cO2y3n6489KzL0RB+OG77927e+/JZ56prL1x483f+a3fDjxvOhkJ2RHIAuiLr32+1e788Ic/2Nzaanc6o9FoZ3eHYHS4v3/79p3xaJLO4meffgYMC8FKSAfzA3AoAKRS1rLQGgAYuQI3TuKQgsn29vbJsfQUINTrte7iovK8euB9VJS7B3vSU4wAbA4Otx0YZqcVKO1TrdkJawdnJ1LC/JiQkM4SaUJnTM33Emuzwp1//rmDk2MJAdaagSeHgyMr9dKVS7c+uuscoSNySintnCEgduwrr9daAEYhhEYFjIiiX+91gpYUwhcSmaXw+s1uq1ZHQI1ao2LgheZCJ+pKISRqpP+NvffqsSzLzsTWWnvvc871JmxGZERG+kpXPruru9lDN91DMxRGAjgPEiBAAgVBT9KDIL3pkdBfGAiQBAgYzABDJ5ESSQ05TXazq7qqy2SlN5GR4f319x6391p62PdGRmUZUiMIGklzgIy89/h7ztp7uW99iwh4qjI9VZpaXliqRMWNzTWNOFubLQWFYlR+98q3DQYImgw4GRlj5qMz1bCe20wXQAMBgQAYUQBgVAiBC7VopQm0E0AEQybQASmthJABlRYliAQWUCtAAFYCIiRpljnFZIzSGhBAxmW08GVoMpxK6YhIbi0h5bk1Btm5/f3NTl+azSlrbW800KHJhT/7/M65xbOFKHr85MHW3tal61e2d7eUDqq1epLmAHjp/MUsTspBdGH53D/69d/44GcfbNea3739ntKq1+l6jpBCEN757N6tG9d5OAzSztaTjfr82Zuv39JaN5vNzNlOu/Xi2XNtVJJmVsjmrLXxbAgAKM6dmI1EBIw2zxWVLl69fO7ihdzaF8+fJzbXipDw+vUbU1PTU816q9spVgq7+1tKoWNHBCzgLKed42Q4CjU41D6xSowaVNkUOp3+1OwMd7okaed4HzkDZeOsO0wcgVOk4rjPIpkIs9MUZM4BCqEnQFGhQRHQWiESs0MiJE1RIMKAKCAIYFRRq6JjZ5QCZkUE2ig0IqJICYgiImWmG9PXL9/8/P4dK5aZFVGtWFtZvDDdnBGBjLP1/Rdra6sIsHL+fLPZPB4eawoKoVhhO8kWK6VImUCNuZkQQWsUCXylnSJEItAE7IiICEEhIgohEfjYgkiOmpxLTFAEQBxTpHwxjgVfWhQpn7UOgsBmUZpmgyQ7Uz5LeRYRg6JkOPzJBz+NfvVX56Zn17d2dg72M4U/OHdpd3evVCoxMwLsHx4oAUFM88w556wV646Pj6JCwZf9I+JoNMosf/jhz4Bd7qBSaYCTANWoP7x28fKZZuPF82f/8T/+x3F/+Cd/8mejJHFORDxHilPKeAYfD9sIghCFvvveexcvnmeBC5cu1Rr1hZnZv/ng/SiKRqPk8KiFqLNUfDmG9yadY1Lj2btWrkFssWgGSTK2Q1ERYCdNq4vL+0cHgFwrFLcfr166drXd69Vy1el1i1N1Ejxe35op1RFQIRL4zDQjoLUOAZCQCJgF0TELouCYgglF2DmLiCLAAkTaPz3PYjWmH5sYxWEQvXv9W8651qDjWHz1fcVUb6zcDIKCc/bB0zt3nn/ikxPdvNNsTiOAML60c2RctnTC3QXgWSMEUfmCZmF0Y86mMVcUOz6hv/JUOszg3JjmSY1D1q8ur3qFiBhEgc9Dd0cxWCWm4Ez++MVqFBVBYGZmdmpqdvXZ2t0799dK65Vq83sXr3b63VAZIi4WDOf50e7h7//z35uemn327Fmv1//oZx9vbe6K0I9/9JPZ+blWuz0zPatQ/eAH/2Aw6OXW1urNnYODURwvnTn7YvX5e++8bbS6dHYxdBazfNBui7MoEAaBIBhtBIRIFIG1DkAAxOYZsO11u0mS9rv9Wzdu2TirVKuZdWkcV6ulqFDa39m0ebq5u/HatSsgKjAFEwVI0O93meQoT7Fa6oz6Cjz5F4FgDpKzG3T3GXNEyZ189/u/uLW/l7GKpmbOr5x/8nw11Pqtt75998lTJaJINIhGJYAWWKGfUsfsaQIg4ABQgFmYiKxjIBIAJAw86RQrEQFCQc+ohUzAzAbU9Qs3FxaWPr33iYjTGp0VIJqemluYW84dr21t3Vv9XJQzmqzj4/g4O879pAikCUEBKkTPWuPpYgBAgF3uvOb3BDgAAs4pITfmkCIFQkgkokhpUgLCgAEqsSIkgnzi+p2YVV/AY53IVm5zZmZhhFCXS9e+de5ynuSZjcJiaAphEGmt5+YWnHPD4TAwJghDB3LY3ezFR5cWr3KGTx8/2XqxpdEorQHkn/yT/w5AEPH+vQdIqI25fOVKGAaDZLS2vWUUasUlzETZg4NtDNS/+KM/bLWOWsfHrdbx+tqLm9eup6OUyCChAIsCYXHshASRlAqIEICQzMef3fvws88LpfCTh/cq5XIQhCA8WypWq6X2/m7nYP/2r//a09VH3XabyETFkpXcibWuQ8L9Ud/T2JEmYRYAgESJVFSQdkZRs3oQ9zvZ6N7mauLyRGHaO3Tdfcsp5dnoYPMw6TF6GxcYAJA81RshsifeOMUXhx4zAKAgsNYhIoEn8nMiIP7dMAN4ghZSrGbL02/eeMcY0+ocKyQGQG1AcGXlfLFSOuwcffLoE0R+4+IbN268+aMf/2i99aIzaoFmFgFGJEUinv5CEEn76izv7JEvzfKkX2M+OUTwPHgiqEgpEhFBZYEcg1XgkFETAOS5DcMx1d03pXRgkihUSr148SSaKuV72Xdvf5cdvHi+vra5ceXKldXV1U8//viHP/xhmqbDeLSzs9Pqdb/1/bdyHm7tvzi/eK3T7WqlFSqlFAIIjAsjhUVE0ix7+my10axm+Yh0rhUxpC9ePJ6entcmWDp7tlCI1jfXt4/3U2SrcJRnLBwZypydlFUSAIIgAHmXMLcud7kmo7TuDHqskBVenJ+bbdS5fzTbONcZ5cPh4Gfvf7CzubV8bhFJtduHqcujQqSIBFgholJ+poJxoSySYDEsFmqaldY8KgUGMluMCr3RoBlEyTB2SiuAyDIWiw+EmcmxAIki8jQF7JwXLC9SY+3DMM6BgKfn85yczAhEiOzZEn0OTgjAUPDOzdu1YmOQ9IbDIaFCUs650ITnzq4Awf2n9/tJWxt95cL1s9PLVy9d3/j5FosTQgTQBNZ7DZPqkrF8MwIJTOjdvKnKYj2Xk49Ce/nL81wpxc4RIgiydczMzr1UoBPj/aUqPBGxl6uMEWZr7WCQzJyZ/5Vf+sVGrZEmWZGiIDBpEhdC85/9zu9MNaeIME6Sjc3NR8+eNCv1tR3bitvF431DKifSShtj8jwHEU8RSUSCSEShCUXowoWLn36wmbi8sLJy/d33nj555pLO+tZuGIXi+MqFS71W69alK8UgfPjwMQIbY2D8CBBJIxE7JkZxIAKZTZZnzr77rdvOuSAMTBQled7u9y+dXVnd3MzZ3Pn87mef39WhyTInmM3PzlOge4NBkgwYRHnPBj03qn8mZNkdxaOwEPV7fRFVBENIpagcj5JQh6Acl3QWJy7NG2G5AAoBUBGDAAh5yi+FMFEQSikkL08ICDa3qMiTq4JCCyKI5MmDWFARAQgACU1Vpi+sXCZSx/3jEY/QKGctkapU6pVKbf9gf3X9sVIoiJ8+vuMUPdt+LoTsUGntybpIGLXxZhIReZIwH63U2jh2ThwQisiEThGZ/PxKMAFxKAIE1qRJgTaKc8ecIZJM5PJkhkJE/YqgAYDfj5lnm/V6MUSXrz5bbbVa7Xa7UCl1Op352fnXXru2s729vr6ORM+ePtvd3S3OFCwAgljnFBIzo0YRIaVAXmJLcmZAdI6tcwcHh8vnL+wf7HdHSavdb8zOh0Ew1Wheu3RlNBgq4M8++nBuZibP3NqT53uHRyaMRDwx7njuBkEUBASj9aVzF9579/Zbt97c3dt58+23hTAIzOd3Pun32jNz86NEFhcWr11/7eD4mC1v7+41m3NaQrB9lzMazIUBhdkSMHp+MAAkSDmLezELC8GeTRqV6m6/nbksT/qmaHqdtihs1qqHh60YnBFnrFNaKSSGEwLfsa6xzik9Rmf4oSEISCTCAWoUcOy89wQEAEhkEFgJXlq5XAxL1rrO8RFLToqQEBmnmjOs6MN7HyUuzZwjkrWdpxv7z3MRANG++luRE2QrSis/VZJSXoEgKUDNIgg6GBt14rQFz/8m3maSiXYk/wWIcnAYadLasRB9I4L09OKRa8xcjMJA0ajfW1tb3draFoCgWATE/f39O3c+c9aura3t7O6awCRJ0ut0gTkwWpMaxaM0TcFjoh0jeYY67ViyLDOBOW63mkGTMW9Uo4Xl83GaE0GaZ2eXlwKtPvnso9FgWC4WHzx/9id//mfpKP38wf3eYMgMjp0iQkKtlPMuBgsAKqQoMIfHBz/58Y8LhWhjeysqlRAkS/suHd6amuZAmlON/b19J1wKC/Mzsz//6P3Fs/NohNCKsNfViEgTAlxSSgkYrQO2LqROGhNYmw41OFFEIOCsJkKlFGCo9Hik0zjODPSSydjHdZlZrHj6Am/fsLAnYBYRFCAkJGBhQGAWYSbEyITnls8DE4gbJTESsTApUqQW5hd2Dra2OptWOScohIrQgrXOkiIAhTg2HWCMGxuTJzgRCiP+xgAAIABJREFU5ywQKYXixLsJnoWXPbkooSZvMJEnyYVTqWTrnBNGpaJQiwgpOi08r0beT3u2zjmlVKvXtUHWjntxnOvIsMiz1WdXrl3rdFp/+aO/vnblSqlYrpTK27s7hVrlwvLFo0cHpVLJEB4c7wcUSsJ37txxzIioTRCGZnllqVKrV6emTKQFeHdnq7MnSwtLB0edxvz8k9WnQSGMQqORB3a0v986iHs7/dbzZ6upyq0S53jxzAIAFAoRAluXxXG8f3gUBIHNXWKlMxwmzs3MTH324H6xWIoKhXq9UqvU3v/407kzi3cf3sszq0nXa804785MVZ48+nzpwpIpGAuZBnI+c6EQCB0z5w4Fg6iQD0a1pblenDZN0NvYa8zPDkgaprj1ZHXxyoVRksRbB1PFSiAkAlopAAFhJCKlAQDQk1pjYJSn2faBBqU0wXiWIqXGisLbdqQAcwRGpkKh1Kw2FSjQAEQijFoDOwSabjTvP7tvIQNN4gAIhQgYACySCBIz+6FIJ/wJShEhAwv6uJQFtoggCj1FGQGJAOJ4XMCEJwYRx9S0CEYprZFBbG5FhB1/deT99CovVeMQqjIjJT/59K8r0VSjNt3vD0UbMtHy0vn3X/zs7r1n1XK538uqlWnShoEAoVItV4uV5kzz2uVr7eP2KBmcO3fu0cNH1iblcvHypXPTcwtnFs8CW8mypNNXkMdhsX10nBGUCqVCEFUaFcdZPTQLxdKZs4u3bl7/4z/+49EgBkfpIL312q3Nrc0z82cKhTC3dmPzBcsDa22z2QSE27ffvXj5UqlaIqXKYaFSrS2fO3fns8/ifPvaW+9MLV2Q3GqFpXLhX/75n1Kbl5Yuvlh7trRyLohCUayIQJiJwLGzzERM0LGDuXPz262jnF2X+cZ77+22juL2sRZ37Xvvbe7tJOwu3bxx3GplKKFWztmJdiBPwG2dI/KEj+DYaa0AERhzm2s9to69eDGzJh+XsALMIgSqVCobY4jRMTarTR3oHK0gKK2Vpt39XQRSIKFiQhFAVkTeYhQZC4IiH62yCCDiwx2A6EQUTkCwAMCC4snl2Rt73osEEeccIfIk/AYggk5r5QMipNTXRt5PL0Tkje7hYDQzP/trP/hHlWIly1y5VDWk93f3zi0vn1lYYuvSYUwEhUIBiXqjg6XGVAHAufzihYsLC4v1Sk0rdfHSpeEoTvLuysrKretvAuDyynllVJJmMzMLJYKFxaUYwunFs6VqVUAci9ElhMBZUSosRNXf/I1/dzgY9lrtbJSdmVs4ah2fXV4MA8NORHhqujk3N3dweJhmabFUqpWrhUJpkMatdu/Grbe0UpevvNZPko1nz2emZ8Nq9cmzJz/94Emn1xfIc7FLy5d6rd6br79WqkZ7e9vDUddoCMi02aVOCkZplP3j/ZHLAMU6uLv2NGdn2dlkONhZz/McCDf2d0ZZRgDWujA0MHmkY4ptEQDPpuTZY4U0ESGzMI8NZK8i/AumsdMORAodZlnmHBsEUnL10rWj7uHO4Xqc5dVKoztMevGQUQhOzmOBkMg7e44QBdF5h9zTg5NynmGMfPrDGaMd88v5ZSIZ1uP3EJAAERhAoQIgALIiHCjOLSk91vtfNKXgdOT9NIjUo5gXzs43ylNFKFeDSjfprz18XCwXr12/9qd//md3Pr3zW7/+m3MzM51u56NP38/ZvvfOG7PFqacPH711e7FerTUa9Xq1Fo+SQlQoViqlQJ2/slKqlIDRBFqFoUVYPLdy59NPHqxt3Lz5erVSi7N0FMdaYZYnWmtS2uZWGMIgkCLbNHdZ/7jVXlg8szBXmZ2tDAYJw8zGlhuORivnl2u1+nS9emF52Vq33T3Oms3f/9P/DQEKhcLFc0tz1bCgZZS1ludqq6vknNWKhoNk2Et/9TvfKRejNMsTHZlQbNY/2NuJFqdSm9dMcePx48WrlweDFgOUFBZzVkqPgAtRkHQHpUopt3lk8wAVOiQz9i0VEZECAmbxrj1M1jrnUNCoQJNx7ABQaQUwznyDAHhlJGCUsrkdDobD4bBUKaFQLaz+5vd+izHfbR/defj5wdG+I2bHpEjEiEBuLaJDREFgEfIs3yQ4FnD00xICAQt4unwW8O0OvOU3piUTxaAUIaETJlJe3nxkS0QEBZVy1nl1+QrbDPg41sn0dSJu3jduNprxcPjGzVvNWn04HDZKpQ8++vCgUgsY/6N//z84u7CgAKeqVXF27/holPH0wgVTbGSMOirstY4UqqhWkkBNL8wMkkNTDHNtLbt20moUZrTBYdqPygXQJAYyl1iXXVpZ0k72Dw+ctSYIKvNzD548ikrhKO2kdlipV+fmF1uH0a//+vfq1TID/8Ef/8uoUu92u07SmenarRsXL66cRYDk/ePN4/7u9ka93qhUiqNh5/zN69PNKiD/xV+8f+Xi8qDfLVfKxpilheXpWunypUVB9ckn6daRS6kwdWHlsH9M5Dqj7so7Nw8OjgqoRCSSYLZQEZB+3FKiq4WqmCBNbbFYdcxAAigo/n9wzilUMmHL8/rGWucVZZZmxmhvydjcCYBzlkgBSG4zpRQKsBUWGaSDv/zZX3z7je/NNeYRbJ7la1svfn7/o17cpQiRBBicOPZJHkRAdON0KoAH2Y4JktEKk0/2WYeASGAZxlSRHkGJKFYIUWAcokVGQWAUEFEKncuVUiLaaKW0QbBaaR8GPxEpOQ1NPj1jeWVvjOEsDVW+9WL12TBzIqnNpxpTO1tbs1MzS4uLneNWFIRbW1uG9Kg/eDpozy8u6iD62ft/Y/O8VC4JS71WW91+wSS3br07NzeHASmlxYEOIhVSuVwtV2r+iiYKBkn/yeMHZ2pT9ahQr9dR4ebhbrfbqqj6MBl0Bx10cX1qxoQhGQMiRBiGYRDJQqW5vbUugvVaDdACSKlczHZHzXp9enoqjWMpF7UClEQATagBnDY6CAKtjUya3hC4UrGUc7sdD1KbRIEuxEIBbe7uVMKCaifFqfruqAdGJTaPnbVJUi2Wur2eBTZskyR1YhFDNCEpJb4XDiijMXOZTzywjJ1zIlLK28bjfirCHARBnlvvZU76XAARAcrG8fr+j/bLlUaxVO52u91ey0oCILY38T0JfGcdJgBC52BiDAERAikEdOyUMI1nD3XSV+ZlNMHLBCISKlQCdrIDAIDW2v8KpZRSygdZgQjJR9y+sOCXS+xFJE1TAJiZmbl06cL65t1O62Bj62iUJCYIkzwtV0oicO/efZflrcOjLMtmFubjOHnt9SsHxwc7z5/oUK5cPk+ktCnUKhVmUBQ2a1MiYpGRVKSCfr9v2UZBAIC5y43RO/sH2kjraC85PqzXattbggL7x62cc6jVBr34cL/1vfe+Xy6XsFhYXd1aXqjv7h0dHI5SKVXrtas3XidIHz1Zu3Z5pT/s3Xu62U0pLBWQKAwipODJ053L52ePO+3do/7OUS+3LgqjWr3OzBtbe6WyZpvfffi4MHdGBnsiqFjPNqYEIe4cBxRWK+GQyACG1iFKCmw0ik2IWBMqcSVtRIEgIWlQSsCNk82AihAmwFci7WtawiCY9E4So5VzolCJErZOkyYhy1YIxgz/CLEdDNp9e8zemQMRr84EhZmJsWCKxah02DnwbhoiOs/XDyDMDgB5TGyKAI5FHAujIx8RFvbWkxCjYwEWN0lDgTBrJN8PyRNai7OsHGnMEyvMJ1i/0ymdicoc25jjzczc6/X2jw4dBLtHrc6gExa043xza2tpeaXb7X3y+Z3OoFeolBOb//SDD4ZpjAh5Ojrc23j32gWVdSMj9ZIeHKy7wXGlHDnKUh6l6Sgd9U0ARkOzXgVh/1gr5SI4C6O8f3RkB+3AxbWIGsVAuSwbJpK5uDN88+atLEl3NzY7h0eHnfyje7sbB7EqV45aGzt7T4ajwzjJ+7Fa2x0+XD1uTp2TUV7U5TAoFIuFVqs3tIWNY1zfh+m5SzP1qbffeGNuftYERlPkVHPr0H3+5DCYXtw42MvRAWHCdiPrrPX2ezJqx8MdYw/63chEOMiKCTRMaS4sHz19cabcKIqGfqqGSYihY3Y2d3nurHMuZ7bWZSxOhK3NHds0i5lzcdbZzNkcxAlbYQvCzubA4g3c3OaWXc7OirPM5VqdSQPoUlBSTAVTqJfqSmiqNlUr1whIi1mcXnz35rvloFiLKpWgGJGuF4IIpFEsGlTW5izszfUsz9myMKCgFjACigEZCRQIImtxBKxE0LFYyyJgrbPWhw2FWYStA4fkO22NIQ8nSnAcBTyZD0+sK++h9Pv9n3/8mQpqCxcuWoO9LG3Hw1/6wQ9rzanZhcXjdufuvYeZdcfdTqFUVIJbGxuBNpEJS0GRLM1Um5I7o7RGJc5mSVwuFiJNzWbtuH2Uuqzdbi3MzRaU1IrB4fbO4e7+0VH7448+y/MkyxJxNk2H1uYPHz5yFrXWrdbh0rmpyyvVQftpnh1L1m1vPq7B6NrVlaP9rft3P+n2txJov9h5OBxuF0zntYvly8vR7KyuzaizS1G//+LR+t2t1kYi3TNLtWJZTzXCxdmoUk2Oh2t7e096o4Pto2ecH5SIAhQAJpeTAKIiBY5zQE7ytL6wUJ5dsLn0kvTmd759PBpZhvLsdHl23jKyMIP4HiTGBFprY4x397wSMUYDICARBcZESukgCJVSWhtfGkJKgSaLQlr5V0OoQl3QaEqm9A9/+FvT1Znzi5fOLV5qVmZfv/ZWISjPTZ+Zmz2jlOr1+0mcXrt6q1yoL81d/M2/99slmf2tX/7txelFQwZ9KBQBCEWhKGIFAg6IEVkjiQCPu1wREWnAgHSkfa3XeFFaa619+yoW9pgI33LmZK4aG+9fDjegIlRkrW1OTz/eXJOQ3nz3W1EQJUm6/mKr1em+/e7ti5ev2Dy3WX7h2mtKawIc5f3VZ/frjTPv33n89o13czH91PUHbv9g9VvF6UJUHB11yqXo8b3702cWiASR97a3qsXC04eP4tSaIKrVZl67/jqAtrlNkoSIRNyZuTnSqAo6TeLjbqdIeGZ5uRcPR6Pe3NxMaiVL89evvZHEiQt1nHCtEG6tvwiRnHN7BwcLl64QBkjDpw/vXbrxelCrxk7yzCE5YffZxx9euvbGMBv2jluVRnOm2pCY1lefX3/7TSR9vLaaib509bbi/v33fzp7ZWV70H7R3rNAFnLNrrO/nTuHSDv9lgLN5JP8gijMTkQ7x6SUJ3gGEAQlAFqhZU6djcKIUHybQq211jrPcwEhX4DEjJ7ymqhaqjaqjbXV59vbu1lm89xFQWAtpDmTCtJ4mEsySuLN7W0Hsr65ftQ+Vkrt7m0x5Dt7W2wFGRmIxbIQCwuR89S1IOxb/QA5EBYxYAmVCAMQW6fDwLsEzOyDD4ycA4hmrVXsUjzVluK0eH1FSseTfypSnFsM7d7B0ZWFlbjX3d3dLRVL1Xrtb3761z/+q7+6fv16rVrt9UeO2Rj91ls3lAnn584tzFcLnGlyM4vN+uvv/OVf/7VlvvPZJ5h2L547s7m6HRZLoYkOD/f31lZfO7f04OGThUvXgsgogumZ6emp6tmzi1rrYrEIxelW+hgdBaa4cGa5XG4CQH16cTQaFatzJowK2tz99NNrFy/EneMzzWVdqRaKxflfvBAnw1KxFMxsP9t+AoyNcu32L/zKMEn3OvuDPEbhSBcaC8tvff+Xtrd3Y+ZLt97MODvqjwTtlTff6Gb5cNCZPns20EF/2DZE17717tGwWzSmSsZleaywHETDdne6XklsFpECK0XCFBBQgEDE+2nk2JJWDlArLcIC7AQVkXPsbO7DC57bx78Ptk6TisbUAQ4BBdzWzrrWOnWjOw8/jpN4uNMzJkiS9JP77cQmzuWI4pjZOUY4aO2Lcvvtvd6gldj053d/ltmUSBwLCqCAz/k5EUIasxl7v5GFABgIBJkREIAo81gSBBHHTL67lCMUFOcci3j9+GUp+krBQkSsVKtnZqeP4uN/57f/w8WZs71e90XjxZP1NQOSj4a//Avf+/a3v+2cc1n+wc8/osCsPX9ea1RKJbq2wDjqHe4drpy98XTj/srSOUa3vb1zfq6+vHTuk4frca9XaIT9dieJ47NLZ4/7I2301MxsEEIpq3cTl2+3QcSy6/S6z19sLiyfr5WmCqb88c8+R9LenVFaWevOr5xbWrqwtrmzvvbCmmD3wR0ruHLu/J179y++drVYwtzlxSBs1qc4tUVU87Uzh8PhUXsLKB05t7t/2Is7qMxREneG3cPj40CLKjWCsHp8fNA+iqtRrdasPt/ZQomZXZF0sVyymKV5LAxTjWkIKM7yMIrCKCAkQNBKB0GQZZnvOhmGISoSAMdsjKdm0cASaAQBhnEPW2OMtdbDnrxB4u0hbTSgxHmfUwaSYRozSu6y2CWImCSxCCs1xqKKMIBvSydOXC8doaI4HiBpx4zoW10iALKwv1uYhMuRxzBlQI0+QT7OFSKOHRFFSEoprTUqQEKttUgG+LKY4iR0BV+OY/lLIWKz0bh08Ty+SCnnx48fNRtTuzsH5UJp1BuUo+Ltd94BK5Jbm8QaOIjM7PyCCWBW9Y5frP0X//l/m8Txr/zyt//r/+a/uruVqtLUe9/9nh2O/vr9+8tL56fKhdX7H1699nqrUb+zujEQd2GugZTluRy1ju9/fNemGSBaa6NCIRNO8iQKiMTZePhrP/i1P/rDPySlfvUf/ODe3XuhwkIUlcr15eUrry1fuDLTKFWKVhePdrYi5MAUIlWuRlWd59//zuta04Onq8P1USksGlPK47xoTB4UBIlzrpkqF/IkyzQWIKGZymx/1KmVp7IUpmeWWofPrWQjx4fxQBT1bd53rlagQaeXI0uWhCrPwCFoRQoYFCoPlUMEm2U+naxJO2ZxogLjnAO23sz17X8JlWMHhE4cWwEA0sqJ2Dwn39GGxbHzsXBx456iXj05Z73rbzkndkoAAaxDZADRwgS+2ScJIDi06IQRtdJWHAoBAfk+h6hA5Sge9qaBgB0qzb43se8/6hyHKgChoFAgTLzbeFoXfgE2c9rScswscnB4uL61Y4B6rfZn9x989NHHYVi4+cbrSBjHyd1792ye3X/woN/rLp0/Fzh7ZvFsFg/Pvtb87//o9zY7mbjCn//k0X+J6VStkIWRDmlv92j7ePft80tnL14pNxtJmg3T4ZPnz8+eO4dEli0JzkzNXr5yeWtzK01SE0Vvvf32Bx986Kw8efb4jVuvR0HU7XaeP1911n7ve9+1ed5qtxrNunM5oHz6+b3jvXXOhylEplytzTY1shYITVgrh5oSZFpeaHxw52FYLNs0q1Zwf311dvFcO+5At2NzrpcLGVJ/Y/3s+RWtwmJKfLxXqk8FKrSjzDSq7VE/yG3cHRUrUUkFve3t5vzMyGYFy3YwKGnq2ty6jBQiiVIKUFgcKVRa+66IgMjMbG2oonde/04xKGY2+fjTj+MsrTVrR50jtPn8zNyV85fv3r+vjK7X6k+ePLlw9rwDfv7iRb3UeOv1N5+vPXuxtz7u2wt+rnKhji6ev/Jk7XGWpM16szPqKq2jKBoOB5lLAQQEri1fz9k92XrE4jSqki5ON2e297dJQ6PaOG4dVsr1UBciY6brzXsv7qeQCqrxJMdjlKlSmlAZE3DgBIEIdWDklFTBadjMaUiWF7Isy5LU9UYdONzt93uOeTgcPl57duv1W/FRdvdnP61UKrtHB/1Bb6dzVCyV5hcWTIjbB6O//w//vX/6ez9ut0a333lL6WBr+yBsmOZ0fTgsFUvLUSlc39xEYFOIGmfmv7O4FEaFYrE0GPTqlUqv36/NTFEYJGlaLBQLldLM/HxUKpeqzVZ78Nm9Bz/6yU+KYWRM8C9+//dqzcZr168bE7Tb7U8++VSED3d36rUakC7WkvJ0rVwp2dTu7x4uNa/GaW60vnv/6dT0/HGvEwZG6eCNt27vt9uY0qVLlweD/tGgZx2dmZ0lBEIohEWlQhdgoGRhbm4geQdHxVJ5qjG1228D0NUbN1uDnmEpFsq1mflPnm+DWD9RMbPSKk9yDw3wLpXwOMGvRKETDdGLte2oUpieO7ux8WJlaaXdbjvmWrH+/Ok6ZfriuUvD0Wh59vz8zPLO3iYKhkEhHVoNATGIg0pUmpmeXtt6bhEWZs/OVc/olXB9Y+P73/p7f/av/nTl/Hkierr6JLcpAIYmvHD2cpbn69tbIxwg0M0rN751473/8ff+hxuXb7xz681/9gf//Iff+mGe8WH34PUrr3cHg7XDNSYSZBrD/WDSOFKSdEQESutJdPRL3A0nwvQyjkX0rdu3r752pTHdONre+fG9jw62jkwUzs/Mz52dL1bCy1curb148eDxo3KlUqxVsyx57cbVxlQtibO9Y3nn5s3/9U//p81nDy9fPP9XHzwyjXPH3b3pSrke0EFrWIB8lAx29w/Ov3azWC0jaUU4SnvDuF+rFecWZpxlQQIBAkDBX2hMt9sDZ+nGzbfOrVy0LiMUhaADAkJlTDJIfvGXfuW1668DkThHAk6AFAURrq4+2946qlUbB+30/rP28dG+mJIuBlm3ldt4vXOEAN1eL0d6tLcXx8NRnlpnISokR4eWXeZstaJG7SGiAhv3beLYbY56GCOK9POsdbSdswuUHqX9A5sPcza+2zFoQGHnk3MkLN7GIqIgNMwswqnLNvfWbr95Owqizc31/c2N0TCOgijNs7BUrDYaD+89PGwfHh0dzc3NDYadYjUCsofdvfpsY/1wmwEDE96+fvvm1Vv/7H/+p4eDozzJKmGlMF0OsKBdEGJULlQiXQD37O9/59eiMPr87ue7+/vMLoiC0ZCE5fna2hvn3zxTO/MLN79vnPrOa98/37iw2Vl/8Px+EAaFsJSxEDm2ThQhIAk6ZwEUE+TsQlQg4vnJTs9Kr+KxXiahAT78+UeDUa/cLKPShXol2T9cPr9UDor379+L4/Ov33r7yuUbw5G9ev2aMeajn/+01xt98vHP41G6cGZxbm75aK/j0sZf/HxPVS9t7R1k6WDbbs/NNFoJdFefl8uVVPSjJ0+jcum43TJalUrlo6PD588eESmXO0WmVqu12+3QRMZExULt3PL887VHx63jqBgm8SCOR1Mz04VyIc5GSsn60SOFkQZzeLBXLhaDsBAnIwErwHMLTZvbveP9xNlKtZFbOTzYrFTLrd5BPGwHRAnHGfBhd0AAcZ4BUXuQWueEOSDT6Q9z50CDZmCbAwGhU4KWGREMKmRfQqgCEiLJlZBC8n2LUCnSxgQvkXa+LgYEQJzkmzub8TBtNqYLJrh0/hKiXpw92318d9Tr7/X2EahSrmrSyWiUxnGlXg11KbHZ/NzCk4dPQNTs1Nzrr71hdOG9d7//J3/1v/QHg6Pesc3sQWvvzNk5FcJha2/5zLmEhx/f/RgA0iRdXjrnXA7b1qByYIfZ8Cg57qWDf/Xpj25evrlxuBFDMtOY+vb17882pgej2ChNAFZ5sBUQoW/8TkqhMYKS5ZmiV8u/8HQxxRc3kIhbW1v7Tvre86PV/qh99ersQkP1DraWF6oA9q/+5l9Wq9HC2UJmW0DB8so0qWSU58VqvTPsf3jvzvmV5aEMhil3d18sLy22j8mYsNSsF/tptVJtNBqbGxv1eq1Wq5eiWrVasdYVw0qjWR8MBnmWl0tFrXWtWl9Zubi3t5dlaa0R5lluOag1KvfubSDo+YWFdv8QNLOm/mjUbFRIwnJ9KtRBqVjo9vtKky+6IqOqU9WD453Y1QJjdJrE8VAprKvAdtrlZq2VDJs6yOIYFYVBYFJnSoVRntZMkCVpX4vSppgJF4Juns6qgk2zPgIpFSQcRcUup1UV0DAuGJ2ByKQ2QSY1Of4DEQkwe1gfEijJJI0pWd1+hixv33oTGI/ahyBu0O9evnz52bPVje21hcXFp883L5xfyVya5iMn+Wd3PnSSEUine/SH//sf2pwtupxzJzDk4erG6jDpH394fNw9aCWtg+5+ztlBb4cQhWF180m92hgksRPHInGe/vGP/iTLsoNnB3c37tvcwsHDINBaqFSq9ro9GsO0zQm42guNQcXsUGkQ8GDjk1DWiTjpE/T7ycBShAAqHiVPHz9R80RiS2iKFIysROVo5dLlT+7e+eTjT8Og/Iu/9B1S+OzJaqGos2RUKqXNxlzr4DBQ9ODRXZunURjZLO31++fOrSTDQevoME+SYhT2eh0i3NnZadTrhwfJxubG9NTM8tL5YS/tto84t61WWylVqVT7/W4cj7I0brfbLCwABObcykpUMHkrzvIR5qIoEx71Bu0gKJxbWUjSfiUmJBbwtaA4jLuCablSkCQvFopZnktUkHxEVQVap5JOFyoWw45LIhVAPiqFpSRNyzoacF5UpkCRsmlUKNo0aZpwEOcSaiAsaCqXiqNuXDEGmUph2M4yARREATQm8M/YWlcshForAU7T1Nd3GW2A7c7+RmgMCLx/5ydZboVECrzX3dv52Q5plbPdf7TvIH+08YBBUInL0m4vAy2M0s/z3vGIRQDAQkaSffrwQwF0wsNOTApTmw/bMSIQkq+qWd17Svs6JycCjCLinGVBccw28XXPZLPcshtkAyJEYBYkz2mAZB0rRchiMy1kAw/fAsDxNPxFVXgyd52s4jE8DT7/7P7F717a63SbUzNlYVerpXa0unlveqpw+92bRhXKBVBavfXGVaUgCEypVM0yVyqW8jx57eqSh+8AwJkzNeeyJEmWl89MT80lSTw3O1OplBfOzHW73XqjPjVdT0b9/d0niDA7W6nWZmbm5judY22gUDSoojNn5iv1cpIMrRvNLVS7g/32o0OtCRgazan9g50ceaa5sH+w/9nnnyktzqWkBLyZA4hknbVPnjwUwJCC5YXFnb2d3CZKSXw8EIA0YwKM41hrXYkKBzt7qc1rKhMgAAAgAElEQVSyUQ4AcRwbNSyXynsHh5nL0+RIE/X7KSIWo8JWbydPs1E3KRRKw2EMQE7EiThhJWKCABF9YIAFEUiRttaJR9MIKSBfaeKEWZwmw6ydsAOnidh5gCYCIQk6doAakHCcUHYsAoTOOechn4iTjJzHf4qM467j2gxAEWQNKEKTDqMKRADRZ7sF0SGg0gQgZIG0QpoA5xFJ+3AVB1xQYECiMHDW0Slr6lVVeBrlRxMURLvVPdo9yCL65OmzrXa322q73EUUgpOAtM3l1g0UgAcPHzubV2qNK1eu3bt3/5233snz7NNP75FCZs6yDECiMFJajUYjADSBERFC1NokaaLHtPKCAFEUxfnIunH3Lo9OFBknzsbxEgQEH5Tz9b3sOBfWpBRLTjSuBCdCgnFROZJmn4RHAFAgnypgQAGAjC0AEJEGb2QDCToRQV8QDQCgcPLUGACRQTQon2hFAkAgrUiDMiYqREFIAJYUMNjcsjFGkBk5t7kf4EyiFAohkgTFSCll8xyICIS0ytIhEqJBZgfCipRoQARrrWXLwALk85EsIgi5dQDCwIponHUhQAUA4lyuFHhGAhrXaHjoKAF7ZL0I5B4B4ROYY/kAAESFDALClsc1Ikgk4IAQaZTzQNpbqbW+BuNltcRLr/ArjPeJqCVptvV0fe7SPBVw2Gsb7csnERgyYVFyb/W+y9gY49i1Bp2PPv85s/zNRz8NjVFRSEgaAJQJggAJmdlEVC6VvcudZzkLmKCotULKA6WU1iIC1mpNzEATqgkAEAHrbLEQoFgAsM4yoCKTW+fAIqIJA+csccgsWhPAmHFAxmUwBMKKtDCzOOdSEcXO+fJx6ywAMxKL7wlJSilnrQefiIgiD/0GAnQiaDRyJgCpzRAdEjpCCjAsBFEexFnPGE1EoDFO4iAMHXrcHWoim1vnnNa+/YdY5xTRWEpYkJDHf5kZPAwQ0AGysxZhjAMlRIWYWw9hH/tbCQv6qkh2gICkUseTt4ngw5O+Wz2SD7Fba1HcGPAu44yLTKppCcfoMQGnFKHSTBRCIe1l8ZEu2tCljoGU0vSVeCz40nJCwahIkna29emGKbPS5Jzr9WJrJU0zZ8XjipLM4gTh6u8PUXzWRSutlZ50RgVrrfMkGQj+EBnzHyMp8m+Rx+L/0uwbP0qi09OsAMikVsDvOT4XCE7KRZgZkURE+UmMBRBIEzMrpdg6YUEE0OOM76QeACa/AmTCVaf1+ME5dr5IUICVIkQEdgKgQsOKycerDACKF2VCICIL4sTzKSqfR0TyBr74O5ST8r3x+TzalAHGAoae4IHHaGD/i3yozFrrZwlBQMATbIyflphFKS2CzjlCQnETKB85xyCCvopYGMeeDjpkZiZFOHkLKDyGNpAKsVDSpcXpBSwSoihlQIDlZXT9Cymd09PVy5cnokChECcwjC0iImrMkVwOcZ7EqY9d2DyH8bE4ecduXERANh+XtY3/eICOh4ELC4wlCSaFnegjPyATegmQ8TBFHFcpj5U8ewHll3OvyLjSBBDVONMGqJRizhFoXOMPVkQYLE/Kncd6D9HHxD1Jjx/E4weC6DJhdt5UdTCexoREhBWIINo4RzgZ+R7axw78PTt/FiJCzhDYi78fCTT+XSAiSCg+mM4gHlNOSoQYHaAVAQJy4hCJwRKCCFvy+ARBBJqkhwSQAAXHZfUOrKdTYnYkXvhElFM4llUhEkYCGSNFRREZ8O9rLA/G+4SGKDCqGEWIWgSNCUEpa+1pfMwXvMLTqnB2dpaIhBkQc8EgMIIZWAjCIEtTQkWaUkqVUuIssyBpPyswM43nCd/T3BM/nQizMDMqnEw2yGN0pHfIfYWSA/TwgMlNiQD6QnNAAGFBEhDxDAI42QsJnWMExnFX1QnJC4hzPlvFkzt5WbnrhV15KwkBmfyoFXIv6wMEAMfT6uR643jU2A8i5bf6EoWJ+QqAoic34W8HgZkUjwslhPDlcPJagoR8xaoDVooYnEMmAhKFoH0RhFY0Zv0EHtMjecDeuAoDFCoCT9LEBk7KhRB8rFZNTB3USAgiHtkszMRKKQJAN/4RQgDezlVai1csWhUK5ahYI9IAlCS5MkxEYRCe1idjwYIvLrdv37569eqjhw8BwATK2sxzsLCzzuY+SIOklDKAlOe5JnTOKYXjElsnzOzP75GsXi0SEWklIsJC6Jm8vAwJETIzACGaU3gm9ql4GdfGvVSORGo8vr3hM9E8XmcAAJ2QNU7EiNkzJHhvCMavHr3KUICeZHMsb0T65CRjXfCFq7+0J0TETxFE3hjyatq/PABgX7QNY50wLnFG8ohjmNgBBCJKKefGCkijEhAt5GsrxvB1BQhIk+JEUtpjCowfGMAojEQKCdx4sp4UcjGMy+TFExWJ+Mp+8sGliYIajxwRHld0j5/DGAqqlDJKl8tlYwwj5M6SJkSMoqhQLM5Mz7wiSHi6LZh/H4cHB7/7u7+7uvo8Ho08HJWUstYSETvHzNabS8x5nvun7w8/CQyexPhPPp+W5dPXgpdaeKLmhE8k4/SBJ47FK8GRkzUnKyd//caXkjEWtsmeNHajxosaUyULKjr9o04Ohy+6zP6iDJOShPGqsYc1+eM5UCaFn3hidaCIn6vopOyB2Wtkr/b9sV5IEYEAQeE4NgEop2wA3xlj3NFUEcK4kAvduJiCPBkMEY4dQBE9EZqJkQcyIRYAGMsDkRYRY4yIeLRMKYwCE+Q2ZyBffBpGYb1e10r/J//p71y9evWkEv/kiX8B3cAi3U7n9//gDx4/fJilmQDARH14TON4vH/NcnK2V077ty040T1wEm37u5zh9D5f3P9EOLyoAXhf/GuuLSemO/3d73ksKy///+K2U6fHU/fmVaRMHiPKibxPdpexxXiy/8t59otr4OWH8W88fSY/pOGkj+7Egzx1a5OPCCccf6dE9ovG9ys/zQ/O6amp3/jN33j9jTe8Ynn5I05mlJOxPr4p5iRJvCL7t8u/Xb5uiaJI668CuL+iCk8mm5d7nGre9JVTyCsrTykj/Lqv33AsfFH9ffPO/3rXfeUq/xp39crD+fLWr/z6in7/uq/wxXni38zrfnn5oh2C+Erh/ZcP/oZz/V9c/u878/9Tt/F/6lT/377uy/Kvk1VfltnTn1+ZsV7Z58SKfWnOfs1R37AGJ8vpz19pbL2y6ZXdvvLrK7d0euj/3a/7lctXDvev3M1/+Iad/+4X/Tfquqf/fiGOdXLMaefry1f9uun36+7yy6f6hl/yzb/tKwfA193n6a/foEf+Lk/z5CSIp83wl3dy+lSvOLyvHPuVVzy9z5cv9P+W655+pF8w3v/W5/u3LnhK0b6y8uuu8nVbXznVl45lARJxfgP4GvCJgzV2omTyj0FIxhn/l46TAKBPn6HPSIsIggLyQc1TF0MAAUFBi0AiKCgoAEh44sD64Pn4qn4hAQaYMCRP0k3/P1no65TF6Q+nN33D1i+f/etWvjJVfMN883XHMiRJnv34pz91NsslE86dS50IiHWSO+eODve7/Z6T3InbWluPnRPJRYTBCrO1GYuAuJytFSdgLSd7W2uP1p4K5AC5gGXJGVIW+T+4e9MoS5LrPOzeGxGZ+daqerVX9b5Ob7MCg8FCLAQBUiQoAqQMUhQk0RYpS7TExaJ9eGwf0pZlyTLFIx0dHdLiApoCQFKkBZIAQRDbYADMgll7Zrp7el9rr/eq3p5bRNzrH1nd01PdM5whTR8fxa/Ml5EZmS9u3BsR997v8+Ltlvs6z13uXJw7b11y7cZlFg/gAJx3NmXrJXfWO85Ecmb2YFmciGfxzz7z7dTaN/hz/vM4vfULwW3CcecNdzUWr3f1rqJ219M3ee+dr1sUAQEIiLPFhaXf/a1f+eMvfSsb9n7yv/7ZnPnSpYtJzr3+5jOPf+Pywlqrl25stp9+/JvAcvXKtdjmwPZLX/ziJz/52y+/eGat2ey1e3G/t7S0Gif2U7/9WySwuLyeZfmFSze8d5vr7eWVldWFq5/7wpcE7Lcee/TXfv03rl9ZPnX6dGuj9bk//mLOfO3qQpI6APidz/y+89mnPvOZJ59+ttfvLywvdjt9tmmnP9jYXFu6emVzkNz6nLsO121X39LpW3rU/wft3iWvEO9mg/BuNm6btN1Z7S95CnebaYqIgAcBAlcpV5Nh+cb1hW9km0ePHvvsH3x2fLz+6KNPJVnb9+PS+OyF89f6rZXRkB/98h//37/3J8cf+cA//gc/eG3x2o//+E+0N5Y+9Tu/71I3Pz8SVBqt7mbHRuuXXvnTK+0aDXfu3v/Ec8/ceOlsWI0+9N3vvXL1BkM+MzPzwosXsiw9f/bCN756/fri5mNf+cKnP/3Z+97xzp/+yb8f2t6TTz116L77z598+Uu/+3unF5sf+9hfnxopn7nRatRq+8ZDgVc3pl9v3b3t7715Whhn72yT/ZAoVGYa0Nx1XnHrrjsf9ZbafRNvdfd2i6K3Xbuz9rbTO6vdtcJd7/0LnG57461hIARQsNKgoH3g8L4nnjqzd77mXGbTxATU6VvShqLy9bNPdvrZiXt2hWC+76Pff8/R4yQoNv/Sl/+sEhjnhDUolg98+Lv+4A8+u3fnxHhj/B0TR84+/5U8zyPNBw7cr2v90clxm+XMmOVwcOfE0y+fqyDnngMDitT3f/Sjh44cRZb3fed3/NN/+Wv/7t/+bxdeeGlu155HvucjDz9y9Bf++1966OHDU1NHHLfwtd/4xhtIr14VSZOXB5ufSwfPex7exAmPovJ9lcZHSpWHCPW2fn69wfnW2n2LlW//EV9PA21bCPxlTt/Sk9+43KrMIgJ5mua/8qu/8V/+2N+u1crJMEN05Wp9cWFpdn4+iXtZyrXRsc5mqxyWkVy5Wl1ZaU5MTESh8t4vLy/vmN+1udlSUVkLl8vlTjJEcRUTWNIllJXVjdkdc0k/BpWHpcp6szM3PZXEcWdzozE7Z1ObZgNkMzY+srK8Nj4xoQJF4trd9sRoozcYakWtbn/H7GR7cxAGHEQjn/z3v/LxT/z45Fj9zX8sgLDvt1d/Ne5+XUDw1YAxLNYTCBjVHm7M/ozSY4jqjf/qN1/u7CP48+a+d5btLp3Xe/Sdp9vs1xtXvv2uN3P1DU639DwkjsPU20CQtEOJhACKwBBkBtAggMygUNiDEhFCIAAWYmJiArLAmhEAWIFDNB5AASJ4EQIEBC9wM8AcLKG6yYaCiFD4g50QiiCiR0+MAJ7ICBTYMijAyEqICZC9RxXga3vorgrg1rH3neb1/yFPL8LrLycRUAXzU7v/uTYzt3fHXdfXb7LdbTfeXhPukLNta/ZX++uvWmO9SRm9XYD+s2/39lN4bSfdduyaN34xGTwDb6IEpUNTu3+ZKHzzquH12v3LnN5+fJe8wtvbhtcazu1znTvmiXeV6zt7cduT79ru7Q3dee9d67/Ve2+/643vfeP3fONPeDPfeMexDLuPpv2nYat1hDty2G8veXKh3/7DkfEfefMNvfEnvKXKd730V+mtu/3BfxFb///fxt5aeYuvJgIidu3KP7bppeU1OzutNzc5TtzO+Wh5NavXVa/nZ2eDrU3dm3GuWjfmDv4HQPMXm1f9v15eV2PdKq83wX+Dyq+5RV4db9uu/gVOX++tEG+Nla3pz1+yoddr9y9e5M/VO68Wly/l2VVAXFtPhgOTZDaK9MkzveWlOEulXNXrG2ZmohSnbmFp8N53TwOwcxtpfDqq3P9XpyjeUrkLKIiIWGtXV1etdTervZG9u/PHrb655RdBBNyuP+8UlFsPvO1RxfF2+3vXUwCUrTheec0lgZtPeFM+FYStpddbVXzbXv72B976G95kp2t5tuDTFOErNwaEMjZSQuLpqRICivB9x+onX+rPzkSlKAAEEAKQ9aVnnarJa997m03fPmxufSzATdm/+dqv9WoR0dz8XBiGt1V4QwfdnQbyzJkzv/M7v9PtdJkFifIsR4IiTYVuUh4iEBJ6727drZQqck7Ye7qJXQ4CWmvBIlKeQGSLsYsZiYRZRJRWxgRFFg0AGGOKTBJEFJYtbHQRAGD2W9HlAEUmq9Z6iyuBgT3eDDC3gqiV2qJ7BESWginZiXjvtdaIW1luxRNuPRMAQqU9MwJywaljbRGA770HQKVUQTtVLpVEgIW9K7KSiq+WggudFBEqAAgCIyzeOYAtZgpELEaL91yEyBXFOSciiMAsDxxb/8C7WwBwYzEerQU3lpLGWJBkLh76xmjY7mbHjtbW1vPZyQDgZnoNwMlTk088M5M656wtsOaLfIJXE7kIgyAshlmRmacEANE55wt4ZIEixxFuSmFBEO68A4DR8cYn/tYnDhw8AHfVI9um19uc0BsbG//yf/9X/f7Ae8/gWVjEoyEk8uw1qQJAzFknKICgyBTZFoKolUYB9l4RAaJn79lrrZ31COicA2EABsCb1ImCQEEQaK1NWPJerHPCjgikSI0VESjSZICQSBXLdu89K6UKcCbxgoiO2XnWWoWmwmC9eGu9QinCvNk5AGAWY4KbBCQOt2QFBArGbOWcI0QQUEobo1k4txYK8E1VUJUAauVYwBXSiUqR9wwgbB0Raa0ZxBVZlwwFqDAKOGuJiJERsYgCVgUDNJDWRli2qFAIPTtmf9+x9ne9v/lGOu1u5ZkXJp96ftY5V3BReAARVkQFTYaIYMFwbAyKaKWLoY5FXidAIVvWW+cskVJkgBFREd1c36CvVMo/8RM/sWfv7j/3ZbYHlZ4+fbrT72VZToqccG1fdXR6zEeSZRkzo1ZZnlnrvMJAQqVUkvUBkQEtE7CE2igKtvjmAQVUyqww0ErZNNXaOOcLHLoix5WQHBWZGqnIVvJvoRPDIEQAx+yd01oVtJcFEHSeZQiotQYWYCxGhQaqV0bu23VCC3Xj3kvXXxr6XCmllSIvRArZAylUlOeWuWBy2yJzc4gaDVtgAEXokcgoxZpyBAAnzgEQarGCSMSixDjrlCamQhuhY0WkMDChCgKR3FprrXfOKgZCz4SA4LHg5oUii02U0YpRRESQ8ywHRCAkMj1bLmYAAvLyqf7hg5V+3y2uJHt2lhtjptAnW9b9ZpKugGx0JfeZMUoEQEAxF9SWpBQCFBkxIi6zlpTy4pGxADI2GARKGx1454jEKPLei/IAiAKetzQcCwz6g09/6tM/+09+plKp3K6P7lz+b0//6vf7Ls1DrRGhsXOktrc2t2PXemcFBimRCjRmGQBoJ3Rsx31BEL1y9YV+NrQouXNGaU1gDKWZLSyOc16EHThBCUKwPi/ybhmQgb3zTJqUAhHnHYIopfKbYfa5sdaxMAc1w7rAr2RSQkTWulvACuIYCQEVElmw75oLj+8+9q1Xnm32uxa81rpIBGXZ4kcWERBBKlgjELngn0TxVpFy3gMCEZFyxQ5/YWoRCUBIaWQxpBWpsqkZY6zz5BkRSlTkYwkp471XHIQFfRyIIoNAiEBCW3lXLFprYVaIwL5oAhAJSaFCAaOdkwWFjADjYyrPeWoi6PbcYMgXr3Y7m1l1xOS5yzMshfTudzaIUABxzwMH5mpFYjIRcZEiJaJky84qpRAhz62IFLOZPLFBGGhSRmks6MC1AoEkTUhU0os3V1u+nwMAsy2mPc1m8+zZcw899OCtzLBbwnNrGge3Z0K/elkJgxfOq7XRXfWxCdFZzwbOdgf9UqnUiKIRU3a5G7FYD4KOC4aYKTCooaAKYOsYEZRGBNGkQDGgNkQQzcweiJO02b6kIFQKAJBYDJHzDhk0FBSSJChM4oFIwKASTSLivFNKMYjRAYh473Art8mzeO+VRXAkVy481V19pdVv3VcfE9aINDY2tbmxpqs1sBzn/dznzmWhiogodrlnp4x2Lot0UNFBCj621mhjtE5c7kHKaFJvETFErUQyAS0qVCqqVJt5D7RldCwWQQEhAAqSaAARh1tTFC9JgacoxZgmJIVESIaQAYo0LCEogIkBmbmP6kY2vi9sIWxlxLxyvjc1FY03ovX1tNEIZ6eDky/3ZqbDMCRrJQpx001hY2zn6DgUCasAOgjjOFZISiQIQ2E2pAuwf+98FIVESglmeQYAiFvm2IQhIFqbJ4N+SZc3ljdOfvPFfqvrRaMUUDc4HA7+3MXyXUiaEJFIhVFl12jtPQcOLF9buPL02fVOl0ONIVZV8LP/1d/dWFte6WdtB5txP6cYQQwSsQAIW++BGYDEOAEnHhnIQmBwVqJ6UGmll/fuOVCr1Jx3WsCE5T/9ylfLI6MFt6X3/PTzT+/dt39qdFrApXnv5NmXd87O7ZrbnVvbHw4vXD4/MT6+b+ceEIjT+OrixeP3HjbMM42psqmk2WAw6I2GVQJ1vbUgjDOTB3njRliaDku1/kavR3K939k7u2ew0enkvXKlNLaSDKraUbyjXOqn+blh7+jOA/lS65IejFdrY6kbKunY5MjUjs21jSbnOxojUTfRVdMhcCieLYJXyiulRKCY6SMW8xlWShEjiidSHvwWDoBnArWV9Q0iW+AfJEi4xbfLLw/md4UbCmG96Xu9RGl9fSGLQr1zZxkAKmU6frReLmulwGhkoOvuuEbxNhGg0ZGRQb9vPCsXN8bGvHPep9ZZp7XLgZA0Rc7lztpSGAShYu/jJNZased6OSBFifU0Gmmk6X2N+7Ojj/3J48zOUKRI4W0Cs8383b7evEvijrVWa2g0pldW2r/yy/++URtduLG+vpmEpajdbtZKlc/8xmfqVXNxbdDNsvlD0yOzFWTLioad7kSlbILKjbW1amOURTz4TAqYeWdEDTkJIUpEjt/znkM7jxRTBAC58OLqX/+BH5iZnhWB5ZXrp59++R9+/B+e2H8/gPSS9t/7qb//o9/1Yx98zwcZKMkGP/VzP/m97/m+H/q+j4OAd/nP//N/osSAuGOHHnn46Ps8M1tnAnX26ulL3/yUhdiTb8ztuDZo6zyerDU6g40YNaugVh2N2310bnZ2Zq3TSsVRuZJ523Xpho1rjWp3Y9OmyY763EZvY9NZJ0pH4WavN41Sw8AlDCKe0jwdEAErdFvLPWBEQAQWhQRauZvdoEjx1iKfRAAYC0JyRPSeiRSJUoAogADtXD3T3fHO0RsP3f8av3Wlom4evKoULif7u9AwBrxLiTSCA/FxYolQQKzLsyxDIoVGKW2t1eQBkTQMhh1EKpUibdDazHvf7nOlXI4qoUqt0RoDU23Ugqjms5xZxLlbBCe3l9uN4HZTeMuzobVRCtdW106efK5cNi67eOTI0dX1S512XC/XFPDjz75i8/ihhx+5sbSxsdr7/h/9SJeXr1+/9sDMxA+99z1hOXj85At/+NSzU3v3WSAVBrtnJ1d7Pc+Cgb3QutwLfB/ijJNi0wCJhpR18o7uCgVmPWtNHd93LWkGnUsKKM8zX6Km3VjuLYvHJB1kIJ00Xuuu2NwCQCvulU3oLa22mi7Lc5uDZ0Kzur6oNSJWVuJuM97IvPXp8Pxwc5injv1zK+d1YNhw2yXPbVxBFh2qx9bPuzxnhafXryNi5vN4kH09H4hDpdQzq1dQMAe+sLk8Ux5rgB8R3fHi0DKC8wIIWmsRYS+IqJFYUBwBCCIV4ERbSd4CggpRAaqbGyvALIAFcIsIALKcyWsGZh8aWSk4ee9ucgQX3MEr8QEKXO4hChWn6XA4ZGFFlCZpMoyd8CBJwlJJC2ZxAogAGedJEASkyFk3jGMAKNbLmbOYZ6mzkLlqpapE6cCEWmfei/Xs+fYXuSUzdyqt7ftYn//85z//+T95+OG3rzdX/+RzfzQx3rhy8cLefXvm5nefv3hV2EdGRaG5cunC2x9+Rz9Oh3FyzwMHyiO+EeInvuddv/Zv/81Ks/t3Pv43O/Xosy++EI1NVXdNlfZM5ZLHNs+9smK9SFWVG6YMLEYHAjjIEgeMmlgk8dYqQZYQVGhCQhxkSUkFgWhFSiMPkrgclUIKMnYC2NzcVCUO0DRMrVGZYGZNBMCteLOZbd4kpHEkYADjK0txuxt7Ww/LJWX6bDm3FRX0JK8YVaWgZ9PY2UoQhqR7ScqEZRM4z8iAgQ4cWeGaCkar9YmxmaEdrtu1fr4BClAEi004DQXNO1GgtQEABksFTRMUOAtQ4EIgKATa2gVGVEpjsZXMQmqLUY4QdpTzt40sN4K06NCbCEuAIANXOTM4MKDdCrXSajhIa9WRIAgQUJgrUTmJ4zCK4jTJnGWQ0BitdZblpbBcPMHgFrxonuelUsl7zwq9Z6M1WEtEYRjGLffoHzyeJikwOueMMT/4Nz72vve976572rfkbLtaE5EkSZ9//vnBoLux2ba573Ti06fP3VhcK1fGTGCEVLc/7A2zJ558emZ6qlKpLq4s76qP/7Xv+tCTTz118kqmwgN/8OipX/yFf3BudW0htu1hr78JR0IzaqLHF1Y7qRfmDsqKUjkKayECh2JBLHMx9MmzVmC0CVETYY6ikQxhXsCxIaKgEhTPIKIUSh+BYMFsuLWLURRaa531ECiDGoRIOSIVhuFIqbKTK1CJrvaa75rY121unOfuaK1h1vszjUYM/t7KTLPfOec2Hpo71L28uB7p0ARzqtJz6fW8+2BjZ7fZXXSDPdMz5eEw8m512E04t857ZEJGBMuenGIRZiHMc+u0umn+iIgKVhJQROwBQYCw4CnRqAwVkzPFDEphQRDtRK7nwY32zvlqvrM0aITWYG5FD31tORlry6R3XI0cI3jPeZ4LCwkBQLfdM+O6WBWmcRKWS7m16LRSQb1cdnnG3mutjSYRAc+RNjZJgyCIgtJwGEPu+ukgCiNUWOy0ec+EWwSed7WD28p2wUJEJ67T7Xrrxsemut2uMqFlm1tbkkw85RadUHV0JAirOZkIMU2Tbru/2enM7Ts+s7uZcnT02K44c3GSo1ge8Gi3+teOvm1+avrqE7+9uN7qZ1uKL8sAACAASURBVJlmGNVhfbTxgUfe9+2nv/3A/Q+srq8lWTo9NX32yvmjx4+deeXMrl27RGS9tb53796zZ88dOHCg2Woy+ImJyQtXLh46cvjFl54T6+M8VgGCBacVgs/Jkw7Eey3oTEAAFkQFpj4/3a/ZicBwZp24RLhaG7Hd3kacHJ/bsdzfZPASKIoCl/hePByfnbrRvJ4izJcnreRDKxRUMUz67Jk5onLuGERCkAwYhBkESQGgsBR41YXPgEUAwTOTALubmAlSgFp58IhIwpKjF0TyrMFrJGLvWAo8BUJNoFaG5aVeSZMiJAAVmhIiIjgStGg9CQDkeTaI+6mzgTGJz1vtNhIN0jR3WTawzOK0i3M02jjnC0UZmkBrnWUZKSjg2CTuF+44xy5PhpQmoQ3FMCETail2q99QqrZmWtsyof/oj/7o05/5jFH0w//FD336t39r7969q6trL5w6lee5UoqAjDKVsDI6OdqL083+RqUWjc+M7d0757rr//N/94+aiwvtbuf4Aw9+8vd/t2tMznncqDij3hvNV3T522dOX8qyG4NOlMNEk8RxSQUsBdgPIgCRYmETmCzPjAkcM7OEQWBtoagdIQXGDAeD2kh9vd3yBpNR7mMaoLKKp0ojApgnyUi50s3Th++5P9L6m2dPWmd3HN7bq1O8cKnsHBhTMhESkmMp0H8AwVAklLD11qkw8AicZKBwLhpZy3qDLJssjxGqTtyfjGqHRqYgrDTjbtcNNrN1BsvEpJC2RIqJlKItXCy66ZRjASIFAAyiVAEhuAUgIyAFC6DaQoVBlK3dSwEC0OVSKc9sQZ5CpAmViGhS5bCkVeisj0qlPMtHR0Yl9/VaPU4SpQPnHSDkPnfOa6VCHXrvjTYAVC6VhnEcBBoBEDFN41IUAYLzbmsP0ueFh8P44Nt/+mKv2WFPiGiM+dgPffR973vfnVrqNRprm3VUStUqtcBAGvfuPXroh3/kh5999tnF1dXWxgYz18rVWsn8rY9/7Dve/86f+blfuL6w2d2Qh9/zoDfxwOqf+6Vf/o6HjkdB+Lv/6t/YoLTv+JFkbTGJ81Xix1oLvU5MAnkudQ56mxtgxwEw9W4LplWKSHaLhD5OEBHSvBD5OE2UwszlwMBOPLMIdwdDYaZAOe2khoSByvPvfeihpcUbLUpP7D/8n5589MEdh2bC6tUrN9ajmJh0whXWSlLxgSg2ohxzFOjxoNLPYisSGq2spASRDgVkqP2e2vj40E6XZs9L5+EdRxbOX+CofHRmV37txuje/U0Q78Q5J+QEEEl5XwS9bGUnFtv6XlhACNEBAjMSHp+9/9j8iVpUl1tDnOUW1z0AINyakxXwpahJ+S2UUdravi8mScpobbzbYoE22ghLEATWWqV1lmXabNElsefUpYuthXbczlPrrRijJbekNQhHSnNmo1KJmb3zRMgeNZkojJRTBRcQIsltfuq7WsBXBWvbqlApVSmF3vnnT75y48rlf/Yv/g/nMk1SKZVs7hSw0qUb168+/lhntFx9/zvek4Mc2HXg/MLLoS6fb7eWvvYSi2eiBw/vPX9mcWXhcvnEDhmtSC9PekmaZdqEZR9+8O3fe/KJZ8Qzkgb0GjGMIiAdx+kWxK8HIjIkxRAXEfAAAEigEJkRERkg0Hp2cvpCb7U0HmFKaeYG3dS5jPNkrFT91te/vLs2urG+0jMyNT0Dud2Xl1S1fDXvPTJ9MF1vv+Cbc7WJsZV+fWxszQ3vq8y32+2z3DoyPje4utys1hcH/bGpXRubLevtyqBVm2ncaK8vdDYP7NnlxJR0HaSvGR2gBxbnEIGAFKIScZ4LI3grOIOAwiD8G2/70R2N3WeXTi23lxQVebYILDed0wACpLam+cVEWAGqLSNIwkhERgeIyOyAoVqpABB49Oyr5Yp4HwRBHMdIZK0tlUpFdlCaZrONubcdeMf5hXMX1i6y8CAZarBaGwAB0Ox9P8mEOAyDxOY5O2TMYm9sUFIlVxUpoNGcu6WG3kiwtlVi5v4gsdYtrzaRaWRs9MWXXnjggfvOnjufJlksSN4/fvLMn361+a53v29+144vf/mr3Y0NsbyxtvGht72j7G1ckdFyvTTgZGf9et2t16JW7kkHURSJ0d6xsXDplXNKKRYAERSYmGgw+9TaHdNT6+tNZy0DolJSBNvcBMNFxJuQuFsjBoUO77rn4kvr569cLSvzf13540Bpo/WNpeXRqZl6udaYmOTFKxgGFtiL1CammnFTWCEpqpQwhY1Bb2b39OrmpvPOBiqolu26H6RpY25mqb0+sMnp9asxOya50F4CAMt+Kel4MpP1cjePU+9T5y2xoNcaRcQzkUJxAixKEYIW8FtLDtDv3vedU7XZX3/0/2zHm2oLvBgBQDwbbbYw1dkrpQwZBFV4nwBAI6EggkYkTTrUIZFyLmfGWq1WjSqc5kma1uujSEEQhOvrq5VKiZmr1VqS+jAM4zhZ2FidHZ99YM+9iR2mPvHeaKQwjNIkIUXW5kppIVRKZVlmCqByEWGX29TmqXXe5vau+1ivK1i3NJb3PsnyLM0cS71aavfaO3fvarZa5XIlHqZKkTHiRTqJffGVs+udXmKzG8sLpUapUq9vNjendswvtBd0jqPR6NC6ZDB0FQTBxGbDJHHeM+BYeeyBE/c/9pVHi8GtQY3WR9obrbIxJa0ao/X1jZYiAkJ/E6H6ZkgCGGO897eIgbxwpVaZqFfyaBSEzAgIYoQqz1Ot8PzVqzeWFmLw3kFEus/JM82LoWGl9dOrZ0NGDLnv89PdFWYAkTPrN2KXJ4Sn1peMMalzu2qjuykaMF/Ouiemd/XWWksq3jUy0fCknXQ9hhSQJ2L0ikBQWAS9AG8hMqJWSiEQAgoDohzddeIb5x7txC1CBGAWtQU5LAo9khCKL+JgcmaBrY17BQqVKoCfQZCJMiciDkEUqHiYsBMETGxu+x1NChHjLHbimTn34gUTm2VZFmdJP+1Nj0zWo5Gr128QQikkGMowjot1hNLaAQpzGIVsXRRFKBSKJOmw3+8xEyLeLlhv4Ni5S6V42FdEczNTm63V3PvpyYmVtdXcOTJKAOJh4oXTNLly5XKSJeVS2QE1RucnJ3c9e+qpqxtr5Urp6vXm2tqGc06AJh4+LDUTA+fGawKbZysry48vD7ZyMJkZMc0d6iBJhlonmcurI/UgDBBw0I+RKB4OFcj87Mxg2EcQrXV/MExSa60Vb7/xxDeaccvUw1x8VgC7o4hR1zaXj8/tS/vDvD+gWjX3mYAYjaMmioKAiMjQiFKIWKLKgb3Hz149fXzXPcubS6fXbszV62NaXem02HNQqpgs1QyjKgx0ac1moQlDYUAWcd6lwk7Ai4AXRkRN5HJbIGMDAUOR8YMgQkobNJuDDjACFED2UiCte0ZSBgEJNXtPpEfKIwLST3oAXoQFVbGBSoRaBdY5DWh0RKhKQSjWs0hAphqW2eWVSkUxGB3lWVYPS4NBXA6iejU0OrDepcmwHJRGoor3vmSMUirAMPfWizCKSwZKqQBVgmlmY0QSKDcak3nCirQIFHRAcDf382tcOneaQmtTVlQfqV6/lu/fs4NBr6ysxvGwWq0SIDAb1LVyLXO59x4Ih2mWo52aGhnf3Ziol8phtNkaVCvVY/c8EPcGzXK2nG46mw3zmDyGJpyZbuyrzL9y6rQIM4IItNqdSqkEZDLHve5gbGykGpYAgPM8CMJ02Dc6yLI0DEJmrlQqWe6QTL/fD7T+3g9/9xee+fpK2olAMwopxc5ioJQ2lwYtrTWMlssmZGaruKrVrsaePfOHlaIrN87mfuC9tewW1lcVsF5brwwTAD48taN76uL4VP3qYKPpk9RZZPnGykUlKvY+bq3NlccntDZRyaWYs+QigqARhL1iKAixCAhYpNhtA0ZEBr5pExxphcwhKYW6oDXx3tdL9eM7jx+evWd6ZNqY4NzCmc89/1kkBaCIVEFLLuLTNN2KNxSrFHjvvbVbBsf5PHUIKYEqQKy11kEU5t6CAy/ovUvzPAqioruVIDEQYEChE1+qlCtRDUREhIXRAyIGpSAMQusswx3Yvncc3zrYbgqZuYDjP3BgL6eDR+499MrlRZtneZ73ej1kmBitvett915bbZ48dbq1sYFG2TXYNT+x1F1xmGQS1Am14wPTe3/mx/6brNf/11/+D1dX1xV4BVKg4bssp8rW4oIBQbA/jAdxAsxIpHWw2RtsdPpBGHjnTOAE6CapPTjL3e7Aec/CRMQAX3n0ayu9VV+i2Zm5QRKHUTAcDtrD4WitNkxT5kxpPUjj+SCgPPNOl8OxWjCR5RkAZX6Y5qKZ53ZNPr127UVoTjSm/OLCC9cuTE7X2/1mw5T21MZbw17bJgfqk+TlbK85HZXnyyWwDA4MaPSoULE4ZCFAoq1YRCpCNrFAlkUkAmYQIAGjjGdBIkRd1AGRhw+9492H3lsOSkkWL20ubfSbS+0FRAQBIu2ceMcAws4CUBEE65xzjjVSYEyWps65KAyJlLUeBLQW731/0HcCeZ5b68plDsMAFCV5ttnrGqPjfJimqTB7yJEIh4qUFhHnHAGGYRhGofI+z3P27HxGRMYEb2AK7+KEvuXoYVHiXWSCfbt37Nk5tbS+2miMmSAAIGPMkf1z/+1P/e3/+NnPXV9cWNts29y984HjAVggsiLWMbEqBeW4M1i+sTAYtJc7LYsIgozkIEeR3NvV5gqC3EJ1BwQBD+gFsFKtdDptUmpmZtJbKyAwWsuzvFKuJEmSJ+nY2FiaZiy4vLoigPcfPg6LFEXmo9/zsXQY/9LvfXI97c80xtiBRlUdGUmytBRFaWqBCFktrFzc7KyUIhPnvdS5zPkj87s3T7/QKJUWB8PxUT4G5YFR1warjGo2qo6mVKHRNrjdo7Nr56/Ww2iyWnerreqUGSSDxEvm0ZJTHotYK++UdYxomFHpCAo4ZEEU0qRvxjOgQgWCWgUIGJnwB97+0f2T+zeHm988/fXLK5est6AQgQgiBBJPWgdbgEjixXvwDIhKK/CY2VwARKP34gHKYZlI5XkqAmEYgaBiHB8ZHw4GkQlKYakWlYShFARhGAYqSHXC3vbjAZEiIDAGBBBy7xgxYK+cQCYZiMMC91deYwpvic02s3h3vkLr3OLiYmt58ctf+FxQqYTaJIBZZgNvlpc2f/7n/0VUKe+YmQtMVK7Xq6Xynz32hd0HdgXjIbHE3g7SZNDNT730ci/t5uLIsRL0LAzkLEyNTh2d3vv4+rdgC6FYANAzCmoEzK0IGGaKhym7zARBlmVZmpkg9CBDl7t+x+YWUFkUQlBBcGFlyafpxuJvTk9M9tnGWigwjcpIznZ5s8WhzjIeI1CANW32zeydndxrjLq6ek56S1qysqlM7j5+T22MRuoVTZccdrpriFGgoZUMUpV10zRh99jShbCm+oPkQmt578SUJSYTqJxQAFmEvIAIUOaK7SZQQMyF7fAi7JkcIIOgoAZEIQABx4GO/ua7fnRmZPbZy89968zjnvy+6X33zN8zXZ9caC1+7fRXQdCxR2sL8jAGJwLsyRgj3gszClpw7IUZ4ySzRpDIO6dZ2Huf5UQ6Zg/AeZb30v5YdX2sPtaNe77nrXNRKRB2lnPxoLXGGJ1zBfC9YKKdclRGkjzLlSp0lXoDU3ir6Nsl7pazWgQWFhY6zY1m32bNpUceefcrZ8+5fOjBYli/vN4Gbr3j3e/J+eIgSdYXl08cOXL28oXj9QN1VNozAFxZXvjDr/2Z1Z4PjTIIIhiGstIjpdKV65dWzl7RoIhIxNfKFSQMgzDLMgAxCpnAO5sNYyAS8c6BB+oN4jzPENE7LyLO5gDinH3syW/lTk4cOPL++x+eGB178g8XVJrFg8GATJqm06PjK+tro3NjGtAynzl37frZhUA9h1oCYx944LAHnQ55emr+4KGD/UF24dr5p9YvD1ymtTaAISpSSgl6EE0kAImSqlJWRIkoR4ZIAeaegYjQyE16PxDx7ITZmEAr7QpCpYIPgAEZSCEioeBHH/nY9OjM10999dnLz8yP7fzuB75nYmTKeduP+3EWO+cKDx0Fhq333jF7o7VAwSvIgQ6V1iColCnCYb3zRMy+IEvAxlij0+kQAJEKVJhlqUJlyIRUtmhNKN5a59kJElJuvZBHRCRCZBbOHesgr1TLxhjvxVprjLlDE73GG/2qE3rb5jv7HBEsy0a3jUSbnf6p8+estw4ZFafeWYG15sYx8T2fObYnz50zZXXk+L0XLl88PnKEJMu8m5mePnLk4NqwfYmH5EVQlzH64APv/OyjX5msNh46cOjUS6eUECKUS+VhPIyicGysnud5mqSlcpRl2eTExGAwzG1aKQVjYc05J+VwMByEQVAqVXq92KZWK/2+D3zwm6efXly88awlQJRhDgL95Y20v2LZqVJU3jWaDXuZqzqxWe6cd4pEKdVqb6y1E01w3wH98smr9See8N5P7tlRjfWRWq0X+JWsP12vjw+FyxMvp82Hp/ddPnU6Gq3snpzPr62O7xhbsR1xnPvMg4BnA+C8R0TvXRGtUPBtMItnMMaIKEBEpQGUQg0Abzv49v3TB5+7/MxzF587sfv+Dz/43SDw3IVnztw400t7iKoa1Qu9bChgbYGZFQdGC/tiYl4JKyLgnFdKUCkENNoAoAdXCSPnHFhfC0tEynvH3oVGa63CIKjUylmWWZcaE7KYOE2L7ketJyrJ7vryaNQuqVhA5Vxap0ZngVfXVeHh3iZPWylAt82p4E4nNADkGWgTLS02O/HQOstGrayuVioVUuSZu90uEQXaXLl0WZwnNFmWtQe9E2QAyTkmtMJwfP+Jn/7xf/SFx7908aWvgPWh1VUq7QvHotj1ebjSXPcsAqyVavf6WqtOt99sbQCgNmYwiAVkYWVVRExg8mHqu4Moith5YBLE9V6rWOJ7Z7/y2FfbMtSR8RUdlUv1frm92snaXcU6UEoGuR7mmsF650n+x7/3s+8+/pAulX/1t3/zK09/LWdrHVnUQ05XV5amx8ZrqaWBNHZN+rxP+WBj2CuNjDe73SHkr7QXqjtnlztN6HZP7N1nmaIw7GaDIladFSR5LiABEwKKFwTRCtEzIhmlCEAK+iURRnQilbDy7nveu9pd+cqLX5kf3/nhB757kAz/6Jk/Xmwuaq20CgghTywJhkFIBM56hQRANhd2iKgDY2zOznFgAgTxjgnBCSOSIpOluQhk2YCMKbiibJ4hYWptPx7eWFkkRC8pInr2aNB7X9Hpu+YXdta7sJXkV8xUkrEd7YM/IucvVL72xFRuX+PJeT3HzqvbDVvRj8yZi1VQqo+Op3l5MBiMN8bj4dBaG4ahiBTe6HKptLywWCmXTRgCOCQxijTggbHpQa+zCnl30FtdWbp65UrqcnZeRFLnzi9fTzUY5mGSACEo8gAus+T8llEXoCwbG6mOjIwaU+xDU6fbb/cGw05PGBCJKHfeMTgAMEp/8APf+fUXH/c2f2D3vsbExMbKSjgzu7zSLymDSBlw6m0uHgHQ8/r6Kg9T6+XQzFj0/vsZCIQb1R3TY7sPHz7y1FNPLrTWr2cbg02fSq4UZc6fay3n4AFkcbARcDcHBtu/0A5GKiM5O9yirPPeMhYcKhSAsEJEz7qg2CpcNL4gDBMAJPQg7t7dD5fC6EvPf5EEPvLQ93nvP/vt/7TRaYZK7509cP+e+8cqI3EaX1699MqNs85lAABISilh1ga1VsLWewzCUKtiM0JMGERBSSll8xwFvffVSkVEiqlqqRyy+EAZAiqZABGVDou/PcnT+fHkkdlzod6KeIXXhDEIERw9PJyfu/GHX9x1uwBts4DbBev2n4xWiuRtD95r831vO77rtz79+dOvXMzT1KU5kTq4d/eHPvjw8y+e++oTT7e4NTE3MzE7mTYXnWUBWY67YTUYNtvi5YWXXlhYX/bKe8KEZSj5F08/E1bK89WZIzsOPPPkM+xRIRIBsAAUaQOeQKYaE6VylNtso7WhlK6WImFutXMmFAAvHgAVBgyOEJPecJjYy82lp3/310NtJhoNY7Rl9mlSqZSFWNVDQSHPTuzVhesvBM+PT0z1B4N+d0gKHVBVxxevnLl67VKvN9BBFBgThgoz7VFmqqNT3sQC17PuiZk9yfrmqiRTlbGRVEKAprWRCjWq1LEoUIQARFqDALNnRtnaSmBh1MoUC2BFxKIA4J6dxwbJ4MLqxRN77p+oTXzrlW/2+91Ahe+/9/0P7H/wFjvLnpk9x3Yd+/LzX3LekSAgehCjtSLlnAMUTSY0ATtnUBttQq2Y2SgFIIRks0Qbk6VDRLTWIaJzVmulFbHAIInDIMzyvB7Fj8y9HKpbue93KYJYr/sf/N4bKdnbqSHvqrr0NkEjojAqhYGy1tYrXK2qkaqZnZ7a2Oyyl6gS3X/voQ+9/97NzZW912c7vfjAnt0zu+aa6wuAggqSPGYVisaXL1+5uLicB+wPT4pnQUi8LUVmoja+udB6enFza3MHidkj4s3sd0HEJI4Fubm+XqlUjTGjY/UgMs12E1EDKGbxnpXWAJBl+alTZ5yLd+6cyoaNAzM7q1HlpVNnktxznA2Gg7AWVrCBCKxRHJy6dnHl2mJ9bGKxtXJ58SqK0YQ//Xff/rZ7DwO4MCg5y5Xn8ijf3KzwuXjDOleuN/IkVTEEJvQYos0UBSVDClVJR9Yn3lsWxx51UUQJF45C451oTVqjc8wsCgtSJGDra+X69OjMhaXzzrkTu08MbXLyymkPdN+ee+/f9+AwT05efKGQrchED+5/4DtOvP+xlx575Pi7Or32wsbiffvue+7C88d3HquVKy9cfrFeHTswu886d/Li8wcmDjRq4xeXzwvDwR0H1zvrSZ7snNwFgNblJy88b23umbvJEACHLjHiUPyHd5yLVP5afIkiRQhvpuBvXanVeLL2BMp3btFQ3ea63b7zfse6UcqV8tPf/jZ5/8rZq8tr3YKYynuHLCdfvvDCqZPjjYmgXIk8snW7ZmZGq1WDEKKMl8o6UB3vD87tmZmYudxeXi6wARTm4jlHdt252cbe0blTz53SWvPNqZ8AgDAgMLMJgk67Q4qMMb1eb2RsNE4y4a3AXQBQSnnviSCM9Pu/851f/PajzbQ3psJ37zseRZVXTr0STFb7kIpQMFFxBkwQxN4i4JWlhUvdVJXLURB6Ag1Yq45iaH7rP37q/e98R5a5q1evv+uRh1YXXjJ+oFR/KR028xvWeVH4retnNUMqbtC2m1GtwcoigWgQzV6BQhESpswxIZog8taTIkBVJN0XdIBFNh8AjNfHIxO2eq16NDI1OrW2sU4sZVV6+8GHFNKFGxcW1heds7nN33PiPUqpvdN7ng+rU/XJ+bFZAD48f3iltXx0z9Fh0gfvD+84uH92P4NcW7rs2O2f399Le4Rq/9yB5fZKuVTdt+Nglg37cV+RKmJyQmUAADAUgR219kxl0Gzl4xN6Y8OPj2vvYTCwo3Wz2c3rNaVJ5c4bs0XUmPWfzLNLQXToTpv46s779iUhs8+9zThOHIo7/cq1NE2PHD0eX7zoRBxgBrTRytY7y0ePHVteWdlcb/e7rVKkEBUAWW/Z+yRLH7rnbX/n45/45Bc+s7b4vBcfMBomRIhKwWAwWBosK1KeRbjg50RCEiQRFHGbmxt79+1z7Lq9ngq0Y99qthQSIOXOURFQhyDgbG4/9/k/jQMfjlQhx87Qcrer69VGY35i5wwoDAJFmc26yUYgkxOT/8tP/0+z5YmgHL109oUXzj8zEUZRZWQ46PU6rbc/eN8TTz69tL7QzY7d8G73ziOtG7hzempleXEj6aXip2ujhvPVXnemMjIZVTSpxOgsT6y1pI3SSDfD1RUp79gDGK2ceOcRUYmwBhIREEKgclDy3vXintJakV7trKR2OFZt1Mp1Frl/74nd07u/8PTnJ0YnDu04hIgIanJ0aqG5dHzvsdnxeRHeM7W7ZMKLCxcQ1fzkXKffHquNHdx1z+Xli47tzqmdCpUVu9Jc3jm1WwEmNm/32l48g4AAiyCA5JkodXBiXRDOXRwcdJVLV4fVsqqP6DTnK1cH5y4m+/dGUaQuX00/9v1TTz/XnhwP9+0tD9tfM7MHbzM128t2U3hzCi/ec2/YzfO81WqRDpxzpEhAkiS21i6vrAdhKCwmxDgbetFz8wfGJqaYrFZWM4l3rbXVtZUlYEAkz957r4Vclk2GY0GOgA7AQxE7ViSqEnnvhVSz2y+1NqemJqJypI1eXFoaJCmAIkKttlKZAUBYTBh88MMfeub0c52sX43CZLAxOTlTLuseM1RVKdANMJ/4yEcm6rV/9/if9dntnJ55cN8JClTcW+90rutub1djpG3zhYXr/+x//UVBxWEA6N1gkCSDdreXxMlRM1olWoTkvun9V59/qdIYGSmP5qsb5elqnA2cRhMEwBaLmHbvbwYykAIOtub2SIQBGSVIgAHpigmamyu/+cVfy9lb73/ji7/hva9GtciEWAQ7IL506Xlx/uFDb3feKlQCoEmvbCzfv//E7NjsanttbnIekZbay5Ojk0ZHy73VKCxPNaZeuPjCQnN55+Q8Al5ZvTpIBwXUj3M2tXnsrEfw3qdpprWyAuDtdKUPACzgRVbX03o9yFmWF5Pp2SgM6d5j9TMXhjt3BABg9FaIYZacgpsdt014CqW1BRBdlOJaqVQqyKXZgTgIVNjtdt1NXI3hcGiMIaRupwMgpJQTtuI325tXr1w/sP+YsBLEc1fPvXL53CBLcgAP4EGEMLGZKJzfMT8/t4MLfJibDAaIWOTWM5BgsLDSOn/pxvJqe2G52drsA2kB5fwWFznfZHln5marudnrWoIhuN9/6sv/+vOfWkt6Ogw9ASo1WR+ba9TmJsYmTEnQtJqr4vJ+t720tGDJV+bGB+Q9aWR9pmETUQAAIABJREFU+vTlleW2zWg8jMYyh8OhCU1YrmYz42ftRtsPH79+ZnOy3Bx2X1m/NhirbLIPgrIRrcnkvMV4qdSW5gYgRvAoDgQItTFKqy2ab1Kkw1ygn6dhEIUUouD/096bxkiWZedh55x771tij9zXqqy9t+p9eoacmeaQnEUztEAalm1JJmwThCyYsmzYsAEb0A/BgGkaNiDZlkxDkGVKoAValKUZUpwZLrN1T3dP711VXfualVm5Z2RERsTb7r3n+MeLzM6u7mmNLPuHDL4fVbG89/LFe+eee+53vvOdUJlqUCGgwhVKqe3e9m53J1TqrWuvvn/7gialCYs8GfS7zrpKFG/sPqiEFetz7+yjxx8xpI/PLpogaFfb8+Oz250NowOlaH1rpaLD2ISKsF6pzU7MNqKqQlRKKSKltYAyWofGAsCx+cr8TPTsM83xtp6bMs2WiQy2m1opmhqLyo6szz7dOrFUBQBntz+KMxxaEXw0CY2IRKC1Of/k4++8/U6WZSKYZQMRiaIIRCrVapqmURwhURCGhVjLohAmJ8batdbO5hpKAURXl+9f/99+009EeHJSA2pSCijQ4dZe76XNN8cLo5URUuyFDtqPIigWISp788pwmJakK6IQiRhYfMlIAqRRZ3nni8s3rmfi2Xkg+MVf+MXV7c1rK7fAOibOnR1q+Nvf+IM4iO4Fzgbx5srKy9t/cOzE8UGyvZ11wEXtxrQRXJia/cpf/OWN7a2bd24ntjCzY1v9vTRPZ2uNbmfdaLC24DhMuRhAbj3288IAe3aVoEZoRCS3FggVoGdfPjfwkBUFEikSa5kKIkEvkhbFIEu01qE2e72ORqpWKoq0tXmWp1eXLz939lOD4eD0wiNGKQQ4tXBGQLa7O8tbDxj5zRvvVKP47tZyYKJ+0nfsBvnw+uq1Oys36tXWwuRiGJiN3bVry1eAoJv2TBj2Bt1bD24BQGoTBK+QiZAUKAUeMs9cWsjS8UhATi5W5RgI4MREcPt29uwzlSCgxcXw2GL44ayNgyNqR/AR3OFh2gwAFIWPY//CZx7ffXDl1nInK5KkyF06wD4BYDWpWmuNUUEQ7HY6EzPtWjV23q4+uNvZ7D5y/pEwrjD4+ZlJEpVE0gFE8gEXj7fGv/rCz/yDb/3eycfOBQN/89pt8F6RKkU4RARGdQTk2ZWSToeCHOy5HOwHE7ePYxNFYb/nfvZzL/6z17+TiePEL5ixVtPsd7ZdTLvpviO5vrsWhmFbB1G1JoX9o1d/qDuDL3z2p6WFbR1X9v14PbBAw3Q4SJIsy00Q7Lvi+tbqmu0XAA1bWUr0GWq/o3Y+NXX6zjvv91qVqq5U9/baU81UIAhMKTuCSkkpVTUSmWP2PiAToAJF3olWKtZ1o0yk4kpQFRJNuhZWlVBIRgSd90qZi7cvzY7Pn1kcxcUISIiZzV67/FpZHHDt/hVAFM9vD98SligIL9x4T5hRZD/p3165WR745tXXSyzKeb832L+xdrsMSLQ2qfVlLJ2lXkEEIrnTkXEI0OvznbuDE8cqQLy8nJ87G0dB8FBZDgAggNLjh72BPpo0FJGPKf9ClF6vf39545lPfy7xrzNt9QfDQb9f9iFyhVVEZdnFwtTUl7/ypZ39Tcf4yquv/dznP3f33u2tztbe3uDLL37lv/iP/vPf+dY//vtvfZMFPMtgOIgo2Nrp3Fh7eQLjVlQvZTTKigOEg/oAKEtZSqcqBzYnzB9cPQt0+wkOUkD5/e/9cW6cKLCB+sbr37lzb3nh+LRyerxWy4ocA+URh8NkutUswKdjan56Zj3b2N8Y7Et6euJYV+zkWLs1PrY/6Mf1alvGZ6utYxiNVfQ6p5ikaXN8e7DXRL28s1Y9Mx3ubHqfT5w8ZXOCwisFLssIRAFqpQtnA6MR0TNb4FAREAlzoEygjDaIBGSQtHaeiYwXIERGFPbaKESMo/BH7798dvGRE7On4rjinN3ubl5fue58PtZouoPO7cyslIqiSI7Im6VpqrUiKpOwHAYhszjvkzxFwEo1TrJEWECcUiVdB7xPtI6208qi2QeQW7f7zz7Vevu9vSz3zbpRqC7f7O9sZWFERtP+vqtU9ZOP1eNIB/E5+Liw/UO0maPwAwAIMCndqI+fPXOaqLq9vd3rdvf29gaDIQAaoxEgrlTmFxZOnTr9yGOPfP+VbzvmrODvv/LK51/8qemJpla6gtDbWr+xfD1TQjYQ0ht553/6J7+dabHe7Qx6m2sbkcdQmTKhhqSAJQhDZz2SYmalUCnwzhkTeMdlfYH3YALjvFdaWV9EtaA9No7CRgf1er0ShF2732zXGLiT7jMLsSJlAhMYpRSjQ3fixAm3vHpiafHt7XtryZYOalNkfvkv/GoQYRiFa2trg9QuLTxaCM+l2/dXV24UK4tjU7XVjCJY7u48WZ3c6mzf2txsBHVALeid9zTSDHTIHChiZhAOtA6DAFjYoxUfRYrBOe8mWmPMIgwKsRJXNCnvPTEYrTc6Gza3qR2+e/3tS7cvKqXyvAACYwJmJsDxxpjWRitNirTSzN4e1Cszc2YyQDxopCssZUbRt2ttAAnjyNrcM7drbREW9CBAJID+1nZjsd4DxNMnK+9d7CWJRLE+d6Z++VrfeWmPBfWavvB+f3E2nJ4Ou/suilW18YVRl/MfU1HxQRK6tC0RQaIoCuI4rkTRiaWlMAhqlXhmatI5l2UpkY4r1bhSHR8fn5+fV4rKciQhlVj+zg9eQQAS+dV/81f+4f/xu5vdDRV4Qw4ZLErUatRq0WBtO1Hpft473pw4Pru439v33s3Nzr37znunT52/fOX9EyeO7+7usudTS2fefuutRx97/O695XarpRRtbW+cOP7opfcvP/eZF/7Zd7996vy52OVhtSIE5xaPzVWapyamX712wSrfbjbAemOCPPeGdAzaAHWL/EHaWzo+uTnsLdYnJ60fY/jmd//gqTNP/pmvfuXSxUuvvfbar/3l/3Bts31l9WYWBJOLC93dB1luzz36yM2tB4bC6vjEhDHK22pgeoM0SS2SIfCWbSkFIswgQohe2HofKBXqkJ0PjBGvEeiL578oAKWOoyqZgCLlVLG8tfzOjTf7+/txJS6hnygKAhN69oS0OHn8uUeeK8uWqFTEOvJYscxfCACOWoELlgpfhwgmHLQ5l9WdB4SmDGGF4dZO69n5rWZUEClS0GxgsxFeurZ/7lSt2/WkMA7x7MlKox7Uq4REYXw2qj07AhM+4rY+no9VhjJlLYAJgrGxsSzLLl94Ryl8/PHHbl273kuKU5853WyNtVqtWq1mjMFSxJKFFTJqQmB2X//uH6X7SRrmwfkFERLmRqNiSImDk8dmOXe9Zg1yd6e7JoIsrP0gmm+6ho6mm1ILYtVy3qWRb5+c2Xb7ZrIWjDX2Orv1uQlqV9rHpu/urVUXJnbtMJYmiddMd6/cKmpjuhJGJq7Ugt6gWyBzmgRBnFtf46Lvcu/d/Z0HMlbrZ2lLV4+Pz1ikelTdTwbXrl2+cvXSveXbb775WlpkbPLb3QcamJXv2cGl7TuiEBHe374Xq2is0qrHzV4/L6xXyrDNyajDW0dExhgoCsmtilQUBE6cywuNZQwiAEAEiCgHmr8IAohL08cnmmPXlq+sbq6meRKU6Tyk6Ynp03NnZ8ZmP6jpG7Ej4TC1IlAS6+UwF1QC54cxNQAwMBGxZ2OUd4UgOOeNkdzDD+7O/sIj92s19eQTTWEgguMQwxFNm1a7ZMugs7gz/NpUySf78dvHAKTW2uPHjkHgX3nn1e5+F7WXmsdK9Y07l1iSuB69fvXNEydONIcTKMUgGQ6yrFFthsStdjMrCieOiadbk26cx8aaK3HR6+0Mc5fkuSHWQVgNg7nJuf2JyUu3bjTqNSfSS5N16Z94dOnRE2c6vp8gPHr68fubm/suf/JTz37vvbfjppGJeGvoJhvVLhZjS3MGsdqqBto4dmNRNTTh9trWveX79UY9rAZKUwixs0mj3c7zolaphlpXHfnQ/PTS2fTC9U999rl3Vpbf2ryz2J5nksXF41ubm1OTU2fPnFvdWJtfWKiEZmLXDAepTQFssXByQUSsS1NKFQg75x2I9dVataKDzJIXVqS9uJKOp8kIeqN1FIRxHA/tIAxDcUhIhDhS9DyQeBwZBYCI1KL6c2dfePr0s71BL88zpXSr1QpV9NEy0Q88An5onV/uU1bLPRTk4EgvgoTFIyCSVoF4BFHL3dpLt+e+cPIBEiAd5nA+7IoACwt/8J25p56rHvJkHsJBDz/5eK2jqenptY01aKjlveV6WyeN/FhTPXl6vJdEN7a3Vm53J+abc2Ozve7OnQfXK1F8/ulHJ+fHdZGHqPfybo7q/NSTn/uZry0dW/j1v/ff5lF0st2+t7rx1spqEFWfO/nEX/mLf+m3v/6P765u/I//1X+D3v13f+dvdIr+XG3sRGPiJSt9mzx76kx/a6s5NvXc4qnvX7+AlQhrkWpW98FmaefExNy/98LPnDuxmO8P94c95xx7X/9srLWuViphWNWt9o3t3V//7d8c7g9MEOzt9+pjdRJhKa6u3T752MKfXHvXG3quOlUDfXN34+U3vv+ZF55TEnqt7txfu3Hn/vPPn9+5M6jG8Wx9zGkf2er0zEwv213rriqlEMQzK0R21qaZsDekkcEXTgURSFmyRiYwQJhmGbNY5w1CqexX6hjJSJsIS5lgPGA1IQKiGW9OyEjcET9qNyJHs3ijD8tPysdcir+VngKO+BYpYy8Wz4zIgIIKBVgJXt5sDgv9MyfX61F+MM3SyG4BEWRzm/7R71eHWfzUc5/gqkbbw5XQAFCmzdFB1k+RuR6qz586+fyxxffevPrY2NSfefLRv3bnd6vNhhef5gMyXhnsbHZt6BcnxtPc7klReGg0miHDYL+bFsnZ+el//6tffPPC++//ww0htbK28nf/97+zM+xHWn3zm99QAXXSnmnUuulgo7s7tGkBvNnrNtoTzharezuCREjeOSCsVKuRMs16FX2S9Du7e/w3/ubfRsJKFC8tHvvyV768P0grVTdpDCcDTaTiKLe20ajHcdS3A8O+k/a31/skUgGNtXpewOTM7IUrV1/87OcajVq/366eXLh78xYne2NxND89F+uIGZZOnv7h668158YrYcMWiVZEAhpJo0JmJYAi4FkfzIbWFqUqg3POs9OkFVFpTDLi+gPACJwYyWUfKYAhwtJh7PS2nbUgYrRpNcaU0gBQ2GKnu1WJqs1qq6zGZ+aN3XWt9GR7CkDWd9YRcWpsGgRKkcHbG3fyIhtvjk21pkvJVcc5IeUuz2wqIkYRoNzdix+8c/zczODceHe8mgfKA0DuVCazb7ymXn9X0iyv1z/kmeDDcEM5MkZww0PsBgD03lfCihUIAzVTiV989PG//tf+5qvv39Ta/MZ/+atPz89m+5maJkAtYMI4qta5Um8sb289dfrRO3c2KqZWF+lfv3o1390Y7FZU6xvffe3myqpVBOgubdx+MOyoMBiq/LXVS6CojwUOuqlSr9y5WG9XQ/bfuvBqo95M0uTGcLNZq5pAB1o3TTAR12Jtit7QgOT9wgT1vOBf+7VfazbqgSYC1ETOyrDTGfR6WWZja8mTTwoDZtw056PCVPX1dPfp2RPp9t7b96/Otxdnl+bev3l7mOb9wZbAbpLuz8zoPN9Ihiu+qHkHC3PHXJKemF+8cOfG7NmJAgQBtAJFGoGMCrQtnHVAZEhrUpZZWCRQDhEFQmWIISDFjAfzy0igAaDMSY80sg4e2AdP5PLdS7vdHWA0ga5Vmp89/1mjgyQf/vDSy2cWzj5z5lkBEBHr3Y8uvzLRnJwamxaWN6++EQTmyy98FRCB/euXX7354JZWSmv15U99rVltIpLWBoSVKsUEkYKwKHIWYaGLG62Lm22DNtCelAYyJyeeuHb3WpKssQj+mGj9IfN6uEoHAIrCJknSwOrd1dsB+Ha8qJjeu7/lg/kkT+486M6ON9/cXrbQV0qsy0Bw/cHKubo+Pze/dfHyxFQ14fz+YHWwv3l1Y6UH6Rvbye2tzs5er0+g0uFji6f7m3uTk+PpcB8jssImNuPVukGyRRFTXJJuE1eAUUDUgCCgQCMszcxOVBrTjfGXXvrh3pleK251uruBCf/P3/lHyXDw5//8nzu9dIKdc+CJ1bVbtybj1hOnHv/hO2/VFpooZL2M1dsZJATBVL3dGbpesT9WFHVbPLZ0KgzM2nq/P9iv1WLkdLC/sZP0b770cl3PRuHlp559WpQUPmdxCCDCZahUxuIlwRwBEKkMVJVSjsU7r5G0USjMzhvSdDCgEQGREelgxgARIUI5WLjhgY6DUvT5535ua2/jxurV3d7uzPisHOSGjgbPhOjLBSlRGIXAUmq0Fd7eX18ea7R//rkvJdmwWW8Ji2Nrba7VKOQCBGczYUYACyDeISErn1sxADHpwhVCXJbc4UeivYfefnyMVabhsiw3gQG03TTZs94H5j/4d/+N3/qdb03OnvjS1776P//dv2Vm5hOXaNSFh2PzZ3/qhS+SEAE+/+gX9iB55d0/utW95bJ8Ix/u1UyeFRF6Mp6tj018cvrYy+/deezRR29t3BlkgwwkCHQn6RmtnXdACgR84UQQgQAxDqJAIYm4NBFrTp6Y/5GYPM89c5IkS0tLv/IrvzIc9OPYjLS1gZDo/ZvX2mPT89UpBNnv9+doIVX85sbtKCIf6D+5eYHZB5X4scnWVr+np8y71967fWe51+/3k57YAmwRa5107aljk+3AXL57a3N77eRjC0ky0OUABdFagyITGLLoSsiAqBwVWmtfFCRgtCYi661WqmyAcRAw4Yj8NDIsAoCDqp7DR1V+joXNnbOl4mS5ld00jgTOwp4/eLoiZW63XCSGQdTpdl668P1T86fbzXFARFRECgnZCgKMJFPlAJRGRMGy6ggFvHNaqVa7vb668VC0d+iPPspjeBhuEJHC5lpTmiZO/Fav9+0fvbHQCn/xK5//0pc+Q0b/3h99bxhU6pEej6uK9MClFVV/+0fvLB1fSGzK6OvNyYjiRHKvRRDEiohZ28/SYTrMs7rPr/r3TRUvvvf2zOzY7rDXisLC543xdn+YNOr1tfVtpaNmc/xAsE+5IhfnAFVoQo20vvagGgfNZs15v7axeXf1wa//9/9Dq9X4C//2n6tFsSItkgvxyZOnL965t93ZUVEoiCrQvudOTUwrybY5X2xOasab3c13Vm4v1Vt9HlZblWeeX+r20yRJ2PpskNhhck/Wrq1fq3Yq4+PNubmJ1kzdcVEqrAFA7qywKix7AS9KQCEDAniWwntGEADPTDjSG3XsuAyuR+pleHjrWRgPiHM4WiiCAFprsyz7wdvfR4JWdWysPl6O/KIo7EH1c7mlzlat9cIgkOS5Z1/qzitSn33m8+/devf++vKd9bvPD7vPnH5eALyILSwzK6W9eGEWECQkAUJiYfAEIoKQGe+F2/WmIsXeg3zgmY6azUMe6mG4AQCI0HnnvLOghaJOL/2tP/zRP/2Tt02k03w4N784ee7s3v6OAVOBkDyRMt768frkUiW8ef92TBQAOdEAYiQn5orSEGgtcVWH/87nv7ago8sXX69VGsdOn3n51uULW7czsNlel9AMk34c1x3LbmcPQcr4l4gorhkkFuigXb5+cWZmZqI14YfF8dnpX/63/vUwDJCoWa0IA4uwJ+X5p5569qWb77989Q0X61oU7vf7IVCzY1tzrcFg++mFU6vvXp2q1mbHp5sb/Wi8mUoxG1UCEh7TRmsCECuPPXWy0+k565vN2th4E1DQE4swgjDHJhAdkNIqCFAcsyBCqAPLzjErhoiMRsXWhYEJ9Kg3DAB8kPQsYy0RQjoakxzCVIExjWr9ydPPBDpsN9pGByyMAM45Zs/CZbGZQqpGcdkuJXPF/mC/ElUAQXhkpD/7zM+vH1v79pt/2O13QcSzZ/Yi3qJkRQIlH4NIKcVinRcAUbp0n4IY5kXR7fXYs/eHqkw/mT7W0a1MQsVxjCAB0ed++rNTY5X+fi9N07BeiyLcTPZ3eahSDCnoS+rJ3rt3a1oP5qZab7558cUv/dmtzf6w6GUuW+vs8dJUzCS5DUBXGtU7N27vQ9DppnudJClk0N/L88KHogEBuF6vOqZef8AspMkBizB4Ic5jzeILDOp9Sueremtv496Vq/tpEZgYADTp5RuxZyZDgvbETFNmTkeNKkcBsM/y3ASB2HSnwuu99R757928WG+HO0myu3lvOmru7Gz+3CPnd965phbbDwbDalTRqIg8BdieaZd9LPreCnhrLSE6VHHA1nsFnp0v2AuK98zs2QuKQiklr5QwFK4ArUMdIEMZ+gCU2DsjEBGURKBDgGAEn4sAorW2cG6yNRWaQABYypZg4py7cf/63ZU7JxZOPvfIp4jUVGv6we7Kt3/0rcIWodFnF84SEihMsuS7b/5JGITOukAF8xOLhGBUGKjAedQoQOi9N8FIh6wM4JhBWFAhImkBZh5mKTOXHS5+nJf6kGE99F5EiiIf9pN+MtRKfemF57Xht95+ezLSs5Pjuyv37vWT+NhsxLotpl5pbsEeEcaVqFGt1yrNImWlYidumCXW5mG9UqCytmhU42E/u7/+YHl4Z291Y9jZY+HxyfH508eDVoWEBRnIZ26YZIUKg7AaAQoQMDMwI9iCLSB3817UrGqkP371SmdzCwBssbXd2RUQhaC0rtZqwv5is/FTX120lnNlJ5qtIAxDHSitht6KRmJxPuuRC1AQcIi5QPHynfefOjG30t/tS6pFhTpKC0sCzbiSO+ikKQtrwqKwjUqNAR0LI0VRGFdi6fUEnTZhScQgIkOIoLyzQRCSCrxzjhmEHPNBZgVHbfdkhD8dQPDAUoZPQoBT7elqVJNRbmbkLJQ2J2ZPE4IXX6s0SonDp888E4TR8vpdhXTu2COPHDvH3jOzQnXu2KN31+9qE75w7vzpudMMMkx6hU0AxBbes0ck58uFiAAgESKxoBJGFhGN1mVBJWBmY8zRKPCfY1gfAmcRkXCwvz8hrdmpdrsaXXr/4i89+9SzJ45fvnjh9M++eKfb+c3vfG/sxDGqVgcKCkajK83GREdq6/c6z37mM5Wqnp5rNB3sp4OtNE21bRljk0wHqjlezw1WKwu+mPTi4moFQ0QsDJEXTwiIUo8IwRMyAzCPUERCQKTy/mqExGfLWddF6PK0kCFNVqwrtFJa60TcVHsm1cF333ktiDVoLFyepWmv1UiLdMFXGnF4lzufmn1k6879TiOerNeDzT60Zm8NdyugpoaYR+GT40ud6/fqk82h4qks7GXJuk8fmzmW3V3rteOZuFUbFCG6XjrkOFZKoQgRKKJRQxHvtDYoDF689VFcy1xW5ExCN1dvTDz+02WOjYiE+TBRe+RfABgRIJ84cf4QoEDEMqfYqDRefObFkcsoOTpAYRA9e+a5p089fbA3AJRNgdRTZ55+8vTTZaGKFy8CN1dvsSfnC8dlmKXYywisL0N59AIjGqbzXsDV61WlS4bGh+L0h3DQD/hYDwH/iBjHlfmFOaU8sM2LQZX4p86f+0/+s79+c2WvXa/8vf/lvz5daxaotpNdE0UArEgbHUT1Vlr4qF5j8eVvwPKHadrrp465nxTOewEvRtBohaZAkqIAT0KICsX7spOORsVl2HEQixzI+AkKVmJ9e3/dtjwyMseEVWEJSm+LgETbkoTKMhFnVKnXuoN+pdlY7m0Gsa7NtPNs6HJO2Tdnpx501rpIjx5bvN/ZElAuDsPJMdfZ7Cbp9MkTl3cfDMEtTo3n3SLp2Qy5uTizsrveBD8zOeELlJJnWy7laLSgK6MQUSCMIsgsRZExO6WQAd64+ubs+Ozi1HE8WCEekBnpsL/f4dyBB2qRAgd46gdwVzmhHtRgjQ4sy1IE5PAgARHhMp5DRCCh2w9u3Vm/a3TA4hWyUopFWA78JIyI3yB4wIwHRNLGNOqNo1LIRwfDQ7YFhzHWUY9FhA9WV6YaYxgTCEw06oPu/nqHcjW9MnSbu4PpuLkn2lR1HMYd6mptpqZng0BNTI4HgY6jhrPkgZADFK9Q9+Ni4DIXIThkDrUicU6MscyAyCCACgDKek4hKHXMDCoSAsKyZxWzlH0nc+88AoYRAjjvRRhZDJKIRyREzQADEUZwIVPeN4pS16twRTh8eeVaGKoooHc3bxAp8c7m/tXO3bwogOG9tbuOwEtxaXe5ooOhLUT4Rxu3mAQJ3l+/E2hFpO53N3vD4WR10mrtSl4DESkQBC+syBAiixBCGATC3nsfBqHWyhYApL7xyjefPfX0mcXT7UYbD/J6dIhLyYGNlDcDqUwkl93LEMCPDBGPBmQAwMylF/Tsy2mzZGWJCLMoRcLSHexdunXhxuq1YZGRIi9loOHKAqIyccPCDASAKCBEjAjC1ruQgrwovPcmDA6jpo/OgIcffoC8H1pYnmdcrUVhDKagMLjV2akvzn3hZ59/6eXXX/jc8wtnFm598+sLU08AeQQKq/qlV7/76ec/k+eZIcXsb965ffHta4XNkjzJbN6cnCz2dpE9eV+KhNfj6ueffW7twYPCOY+UFMWDjdX22FhqrdI6ydO8KCpxTIikVOHcfp7G9aqIKCKDSgQqYZmUxWQwBCeFdzlCpRopBbboG1BRFDnh/XSoFBltAlJRHVvtsK1bLsQBZY+Pz9MwX5ZeNarO6tomDXfy9OzYDGbFNd6ZqbZmVGUj3Vsv+o83Jmzu7sn+TLVZFbWdDkibuTCOEVe21hxP5TYnpcpkPzMrAAXovRf23hciIkCI6JmFJSsGSql3b7/x6sXv16q1wAREKk1T730URXEcgUcCRURFUSBA2X62sGWfnrLvpheWwBhEtNYqNZpzGBiRoigurPPO5XkeRIGzNs3TwtsgCAoTxMWWAAAP50lEQVRbIIFzlhkIAxEQQGAHgiUGRoTeM6vSZaEGLjNCIlK4Ik2TLM+YucrVj9rTUTTrg6nw6HciQkTWWe99oGDf5Uls/td/+rt/6Vd/4a/85V9MrPzW7/yT2vQcGOXYeVfMLk1Gqv6HP/i9555+cjgcrKysPvPUc4+dP7uxuZG5PB0MN+6v+ySr16sCgCzC7onF+a+eelafeGZ3mFIYbe7sfuP+73/t2Z8P4rCz1/2/vvH1rLf/8//an222moD80is/3L66/MKLL87PzyvSt+/eef3tN1oTLRJRijjPa5VK4K1z/Mz555954nwy7IfVOAiC115/9eqD+15YEIeFz0LTfuTswsKJvsmF/cmxuY2de1UVTtValX07G9ez3C61JnfurFQhXGrN4NreRL2eAs9W29v93YpXp8bnt6/cmRivBbV6oxDx0Gy2gkqstfaFFxKt0DpfWBsYQ0oJiHMOEUmr1GZKKYPaOeu9c1KwcFKkwzQp6xqsd3ZYDLOhwlHPWCi7IRMBkvOslSoFmI0xAELZaIpUipz3pA568gz2vGdjjPMuYbSFRcLcZ8lwWPIBHTvnPZQefdRxeGQNZQtIEk9IKEAoKEIAgErEt1vN0jyOGsxDtnX0xcNJaEQExvZYm0gVJNoXjfH2le3kP/3N3xoLKoktZh95dCjD/aRjFEQq1hQ3G+NFeH9jb7AwfryYb251sqg+vrdyL/UpaWieOD5fqSTpNqBXnhDd/NJcpEIdKM36+Kkz49P7Fy5eeeKxpwXEHXNXr1y//2D1K1/6cqvZAoHOVm9jdfurX/za8eNLSvC1V1+9e/d2c7o9gERIIolMaEIQZFxaOv7UmWcCQ5XAMLAUWR+6qfP76cALQmz2q/nrw3voMYrMH9+5pDSmRb69s1YLTL+fiofvLl8KNG3n2WsPrteCqL+fFex+uLPM4iy7N1ZvRO2gk+zGLpkLa61KG03gxCmjHFsCYmGlySM7YE2okcq+1GEYpimHJkDBIAi89xoUIMRh7B0LQxSGY804GSZKaROYPM+992EQaK2LoiiKohLESitEyvK8JPp57xlYlZOm954liirOu0EyYGJBBgSbW+dcEATEJaDFRZFbZ0WY0ZX+yosXQRFmsWUlLZEAeyAUD0orEAYgy7bT3yn7/34y3HBoSPqheF5EsjR33oFgW9XHSY1VgntTW42lc0umnRjz7tbd8SCMsbpc7E3E9YWgCpRW2liPEyUd3QDb67Uidfx4e7OAU62pWDVa4ycjsIO9DlXDK8sX4naUKHn3zXc6g2xtfzg+MwmN2jvXrgJzrVaLG63K/vDqjdtbW9u1Wl2HlVZ7srM3WF5+1bH3KCfOPPq1r/3c6sYNUUbXmp3OigLpJvszixOd/t4gGdy9eyeMgmjCPPHk01Ozx6/cuDLZHNsjvzvcng7jPTdMOKuGYaRM5gpNOFlp6KH0uKgEoQZUea4VBcZAtq8VhRpBNAgrwiqZPguyA7IWitX9DZ0EucvLxIcWA0gKQIMQs3O+DG6QlGcpCosApaSH9YUwiIJABSLS7/eH6RAQyKOGwDrH3luwypGICMkg72MxWhuDgyAISqw8jCvCiIiaFBEa1PVqTYRNEABCwT7Pc6UUChpEFmZkANBaeyiXQ8iIIkBEwqZcHop4RkYW0oYBgRE9ClCt0T6I5D7GjI4CC+Xbh9kNiEiK1tfXW8cbRabGp6bWNtYrWKtQbdjJdN2M2+q51nxnb8t4qWo92Oi36uPiVBvr6dbmVp6emDtmVzemmu1umk3TmB9SZSLK97rd1c364nRAZm+484Pk+34aYJLWiisrt2Slc+1+54aXkoVN0IRvv/b73osiDSD1ufDbr/6e8Ch3eOrk2TgMbWcwe/zY1mBwpjHR3+2EYby8dW3Z33TeS8CpGD1U1Wo7gOpnzn/q/R++Gk42lpoTjb3s2MTszd7685NLq5dvmFZtrNXkB93p6Zk7w71nxpaWb9zmanxq+vje5bvzi3Ob+eCJysz22lZHm7OTx9au3F2YaoumiUQixzXRrA0haqWEmEWUIm1UmaEDJUDoRMA7UCSKxPk8y4wxYRQN+sMkSSEGQkqKRDHpQIkTnydKBUqrpEhEPIhoMuBLolXZbxiyNCVEAUjTDHAkFy+ubJzOpIlT4TL5gkiKvC9w1BVRmFlAlX0SmUul/dH6kYgAQYi8eK2UF2ZArXVJdtVaiUgURQ9NeR+dGUdT4UOurISAtQpTZ7e31mxQAHORJJ2s4Lix31vPXfHOyjWjTABBb5A3asG1exfR2rvdVUA38P0LD/amK429/YGQf5Ds1oNWRbLOcFifnW22W2Z7hQi0JgPomTUVUUiff/E8AWrSI2pAuYz3XKbVlFblAtqzgNLWggnN6SeeWdm8Pz493e9uqHYNM440kFGEWgkikCCHWi/OzL9/68L8U+eGznUH6/WlmU6vE5toL+tPnFkcdncG+XDhzML2fk8bnaCfObPU3VpJi+zEU4/e313XpLEWV2fH9vY7vXx46vy5WxsPjKiJhTnrIV9dJqfzwoEKBFzhRaNoJACxzI59aLQwO2e11mWpkWWPCqFIkRgQrM9NYNAIC6MEWitVarsjEgXOeaNNYIwweO+MMVpp9uycDXUgIo7ZsSitidBSISJhFBLhYDBgkVpU0UolWSKslNbOWSLUWgsICzCPMoNSkjWAVRkXeiYs8+GskFAEBZizWjU0Rhd5Jgdy3J+8fQzyjkRpWqwur+u5ytv37tphDix5XiBqo4xYFu+FRRMhiM0zRvDsBbT40gnTxbJmRcRoo0SNta7v7e1676Mo7g/2Ef0BRbtcbhNAKdtZch9REJgZAYgAEAlJuJRzRgLUOnzqKXvx6ruZS2vV1n5voywuJtHMrI0uwSGlyChz8uTOhevvVaI4DCu9/c3vj4SZ9Rt4yzOX6v5EN733qPhVvIyIwFwutQhYWN6iq0RQloaX1TIg9Ja5EleqglQ1CoHVKItM7L33I8xTHDM5Khf9eW7CEACQyFuLWlCJ84XP2VkmUt75xGZRFCIAe8fMRFqRYc+JTZVSImKdVVRiq+gsa609e62Vd7mQMsoYY6wtnDAJBTpQATnvnHgGhaIAoRKFImJdYZ0tATClxLpyyanKqUzjqGkVACohYmTNAG6QDCYnJ9bX1g7pqEfhz6MeSz6WNoOInoUlz1fTmnjVImIBBE1Ka+PtCPVnD4gGRTx7UCQk7IoygCAiECFAgwTsreTbnRyFBCB3KWj2LIQ46ugI6AWU0o49lQkcJIYD4BmFCNlzmewgEhIpOP/Rez8ssw27xR5iiEyWWcCJCFqGEuxxiOz2Lr3riPPBgHtDgJIuQpllpVMRVqy9pzLVSlaLMCkSB4heRBAP1s/AgTFEgFSwcBiZjCi3NjCqGgPknotcKahUIpZSXxxqtVqRJsJsjGk1GoN+H0SiOGq1WsPhsMhtGITNVnOwPyRQYRhqE/T6/SiMyqbkaZrEcWxMMBgMyhFVq9XYuzRJtNFBGFnvrXPGKKONiIiwUiDiiiKzroiiEImKPM/zXBsjRGXqetTUQzgwgQg45zzbkokDZZ1FGWaBx3IsCSKABtSZrG2spUkiIyrox0yFR00IPjYJTUSKAmTpP+jqHlAg3hZpkonAcJAX1vmR7xQpFXqIBIA9K1LMfKjHVWreCzCAKKQSE/Gl40EUL0hY9q4lIu+dNhoREYlZtNJKk2c/YoWPQgFGICQidBrIiwDKQYETAqrSvbmy/RkhIisoBVCVMIAgECMhYQnFSmBM2ci0NOIyF8aeAbFsIYkARMqDi8LIe8e67PmBKoC4FpvY7CZbAqKNJmI3SInQCwJAkve8eAGmXPWyjngPADpXnWxLBMAxDGB3EJRMd1LKe0daq0whlq1TYDf1Qaht4VhEedWXAAGd98iABWltRLj0KOW6sHCBiHj0KsACWBjIKFX2eWdSANZaKw4NGiKPPgqCNLMEZdMyh0SKyCjj2QMgM8dRhF4IyXbz61dWi+5aqONqpUr0MarJP3YqPPRpExMTqmT8oyCHRc+KcOGC4dDawqZZbm3ZhwGF+Uh1sohAqa1CiCPRFRAEHJFZpax2Ejwgixw1wQ+Q4oNzEWIJZOPooQMRsQCPRowIcIk8lsAbsz+guH0A05UnJEThMpYApMPkEMABzVwO2ASjIXhQ0yCHdwZHUQUDk1JYVtcSgCIkj4SKCAmYvdK6HGsi4kmUVixMAmV2a5RwLqmnIOW10RFQEREQlYgoVapqiQCQIjm8Swyk1KiiUAARkEZVFUQsAiY0tWZ9MBwWRSHCpV6iEYUgpBQQl/AHKVWv17q9XoaslIrCMMIw1CYKgkolQgCtDUEsjpHF9zgfKEPoHYsuCdmfRJgZGdZDAOmnP/3pU6dO3bt3z3uPzOiBMHT5HniLYhUQgyqrS7hcqiIeriuAAQQYRB+I2EBZ5wE4cm0iwkyEgARMBzf1wPb9B/lWwNIrUhleCAB4IAASwpJBBxpEyt2UUswEpRUisoyi/tHtH/VrLpMnZcs/8CBEChyMWHciasRfQ4U04hwTg5SCoaU9gJYRJwFAqOyviKOeOSQEoEe6OiMjRisMAIwozEji2SMdmOyoE3Tp9EeJHEQULgABpCiDUGZWSAfZuxJlAig7PAKM6i9KExUlIiBFh4YHGW5hcQeUUCw5hIgIMFSAcWXYbrX3tna01pUKZdWgGmkfaK5ElTgWIvGORGlSxBwrFGEBKHVoxyfG4Z/Hx3q42TgAbGxs/MZv/MatW7e6vT1jjGco8sI7V1hrbemxoLy7B1yPD7EhGQ6/GBXOYdkd++CgkjciR65MDma78jMZkTcOwkMYZVlLosPoDn+QIDtwt+Xzkod/8MHgPsysgQD4w0d5EIkeckFKIxThw8yuPzinwoOyh9K4SsGJkR3JyBWVRVkHQ2T0svyPDy9nlFAhIDiApfGARF/SAD8IimFUgepLa4KDmrHRjxlxBUE8jjwYlXeDR3x6INIiggIG6MDZMBFOTk52BwPvfRiGgdbVOK5EsdZBEASESGQUonNOKRIRUjoMo1qttri4+Ff/4796VO39oRB+5H0fgrzKp9Xtdr/+9a9fv349z/OHUPyjbz95+2Rk9nDC+oR9jn7y0ZM/dIaHzvyTXMwn7I8HNZ8f/eqjF/+hCx6xDQ7/3MGoOnrgyB4ADzlWP8Ff+YRPDn/O4ZmPXMsopQ1HBv/HHv4wveIjewZB8Pjjj//SL/1So9H4sRd9uP/h7Xto9Vj+pU8O0P50+/jtoXv2Ew3DfwU2LIs4Dl6XLz6aEvyxHuv/3Uv5BNP85G//dPtXevuxNNOj09//47N/st18ss19woH/Mpf0ydu/6Jn/Za7z/3/fHt0+kAB96N+HTnT41UN7PnTIT7LbT/L2g8X/v/i3/9/93aO35Wi486ffHr49vF3/NwLY4uZXxbRUAAAAAElFTkSuQmCC" }, "Event": "nodeQueriesComplete", "TimeStamp": 1598022340, "NodeManufacturerName": "ID Lock AS", "NodeProductName": "ID-150 Z-Wave Module", "NodeBasicString": "Routing Slave", "NodeBasic": 4, "NodeGenericString": "Entry Control", "NodeGeneric": 64, "NodeSpecificString": "Secure Keypad Door Lock", "NodeSpecific": 3, "NodeManufacturerID": "0x0373", "NodeProductType": "0x0003", "NodeProductID": "0x0001", "NodeBaudRate": 100000, "NodeVersion": 4, "NodeGroups": 1, "NodeName": "", "NodeLocation": "", "NodeDeviceTypeString": "Door Lock Keypad", "NodeDeviceType": 768, "NodeRole": 7, "NodeRoleString": "Listening Sleeping Slave", "NodePlusType": 0, "NodePlusTypeString": "Z-Wave+ node", "Neighbors": [ 1, 46, 48, 50 ], "Neighbors": [ 1, 46, 48, 50 ]}
+OpenZWave/1/node/49/instance/1/,{ "Instance": 1, "TimeStamp": 1598022021}
+OpenZWave/1/node/49/instance/1/commandclass/113/,{ "Instance": 1, "CommandClassId": 113, "CommandClass": "COMMAND_CLASS_NOTIFICATION", "CommandClassVersion": 4, "TimeStamp": 1598022021}
+OpenZWave/1/node/49/instance/1/commandclass/113/value/73464969749610519/,{ "Label": "User Code", "Value": "asdfgh", "Units": "", "ValueSet": false, "ValuePolled": false, "ChangeVerified": false, "Min": 0, "Max": 0, "Type": "String", "Instance": 1, "CommandClass": "COMMAND_CLASS_NOTIFICATION", "Index": 261, "Node": 49, "Genre": "User", "Help": "User Code that was used", "ValueIDKey": 73464969749610519, "ReadOnly": true, "WriteOnly": false, "Event": "valueAdded", "TimeStamp": 1598022021}
\ No newline at end of file
diff --git a/tests/fixtures/ping/configuration.yaml b/tests/fixtures/ping/configuration.yaml
new file mode 100644
index 00000000000..201c020835e
--- /dev/null
+++ b/tests/fixtures/ping/configuration.yaml
@@ -0,0 +1,5 @@
+binary_sensor:
+ - platform: ping
+ name: test2
+ host: 127.0.0.1
+ count: 1
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_all_devices.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_all_devices.json
new file mode 100644
index 00000000000..61ebc4d9a6c
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_all_devices.json
@@ -0,0 +1 @@
+{"df4a4a8169904cdb9c03d61a21f42140": {"name": "Zone Lisa Bios", "types": {"py/set": ["thermostat"]}, "class": "zone_thermostat", "location": "12493538af164a409c6a1c79e38afe1c"}, "b310b72a0e354bfab43089919b9a88bf": {"name": "Floor kraan", "types": {"py/set": ["thermostat"]}, "class": "thermo_sensor", "location": "c50f167537524366a5af7aa3942feb1e"}, "a2c3583e0a6349358998b760cea82d2a": {"name": "Bios Cv Thermostatic Radiator ", "types": {"py/set": ["thermostat"]}, "class": "thermo_sensor", "location": "12493538af164a409c6a1c79e38afe1c"}, "b59bcebaf94b499ea7d46e4a66fb62d8": {"name": "Zone Lisa WK", "types": {"py/set": ["thermostat"]}, "class": "zone_thermostat", "location": "c50f167537524366a5af7aa3942feb1e"}, "fe799307f1624099878210aa0b9f1475": {"name": "Adam", "types": {"py/set": ["temperature", "thermostat", "home"]}, "class": "gateway", "location": "1f9dcf83fd4e4b66b72ff787957bfe5d"}, "d3da73bde12a47d5a6b8f9dad971f2ec": {"name": "Thermostatic Radiator Jessie", "types": {"py/set": ["thermostat"]}, "class": "thermo_sensor", "location": "82fa13f017d240daa0d0ea1775420f24"}, "21f2b542c49845e6bb416884c55778d6": {"name": "Playstation Smart Plug", "types": {"py/set": ["plug", "power"]}, "class": "game_console", "location": "cd143c07248f491493cea0533bc3d669"}, "78d1126fc4c743db81b61c20e88342a7": {"name": "CV Pomp", "types": {"py/set": ["plug", "power"]}, "class": "central_heating_pump", "location": "c50f167537524366a5af7aa3942feb1e"}, "90986d591dcd426cae3ec3e8111ff730": {"name": "Adam", "types": {"py/set": ["temperature", "thermostat", "home"]}, "class": "heater_central", "location": "1f9dcf83fd4e4b66b72ff787957bfe5d"}, "cd0ddb54ef694e11ac18ed1cbce5dbbd": {"name": "NAS", "types": {"py/set": ["plug", "power"]}, "class": "vcr", "location": "cd143c07248f491493cea0533bc3d669"}, "4a810418d5394b3f82727340b91ba740": {"name": "USG Smart Plug", "types": {"py/set": ["plug", "power"]}, "class": "router", "location": "cd143c07248f491493cea0533bc3d669"}, "02cf28bfec924855854c544690a609ef": {"name": "NVR", "types": {"py/set": ["plug", "power"]}, "class": "vcr", "location": "cd143c07248f491493cea0533bc3d669"}, "a28f588dc4a049a483fd03a30361ad3a": {"name": "Fibaro HC2", "types": {"py/set": ["plug", "power"]}, "class": "settop", "location": "cd143c07248f491493cea0533bc3d669"}, "6a3bf693d05e48e0b460c815a4fdd09d": {"name": "Zone Thermostat Jessie", "types": {"py/set": ["thermostat"]}, "class": "zone_thermostat", "location": "82fa13f017d240daa0d0ea1775420f24"}, "680423ff840043738f42cc7f1ff97a36": {"name": "Thermostatic Radiator Badkamer", "types": {"py/set": ["thermostat"]}, "class": "thermo_sensor", "location": "08963fec7c53423ca5680aa4cb502c63"}, "f1fee6043d3642a9b0a65297455f008e": {"name": "Zone Thermostat Badkamer", "types": {"py/set": ["thermostat"]}, "class": "zone_thermostat", "location": "08963fec7c53423ca5680aa4cb502c63"}, "675416a629f343c495449970e2ca37b5": {"name": "Ziggo Modem", "types": {"py/set": ["plug", "power"]}, "class": "router", "location": "cd143c07248f491493cea0533bc3d669"}, "e7693eb9582644e5b865dba8d4447cf1": {"name": "CV Kraan Garage", "types": {"py/set": ["thermostat"]}, "class": "thermostatic_radiator_valve", "location": "446ac08dd04d4eff8ac57489757b7314"}}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/02cf28bfec924855854c544690a609ef.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/02cf28bfec924855854c544690a609ef.json
new file mode 100644
index 00000000000..238da9d846a
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/02cf28bfec924855854c544690a609ef.json
@@ -0,0 +1 @@
+{"electricity_consumed": 34.0, "electricity_consumed_interval": 9.15, "electricity_produced": 0.0, "electricity_produced_interval": 0.0, "relay": true}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/21f2b542c49845e6bb416884c55778d6.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/21f2b542c49845e6bb416884c55778d6.json
new file mode 100644
index 00000000000..4fcb40c4cf8
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/21f2b542c49845e6bb416884c55778d6.json
@@ -0,0 +1 @@
+{"electricity_consumed": 82.6, "electricity_consumed_interval": 8.6, "electricity_produced": 0.0, "electricity_produced_interval": 0.0, "relay": true}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/4a810418d5394b3f82727340b91ba740.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/4a810418d5394b3f82727340b91ba740.json
new file mode 100644
index 00000000000..feb6290c9c4
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/4a810418d5394b3f82727340b91ba740.json
@@ -0,0 +1 @@
+{"electricity_consumed": 8.5, "electricity_consumed_interval": 0.0, "electricity_produced": 0.0, "electricity_produced_interval": 0.0, "relay": true}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/675416a629f343c495449970e2ca37b5.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/675416a629f343c495449970e2ca37b5.json
new file mode 100644
index 00000000000..74d15fac374
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/675416a629f343c495449970e2ca37b5.json
@@ -0,0 +1 @@
+{"electricity_consumed": 12.2, "electricity_consumed_interval": 2.97, "electricity_produced": 0.0, "electricity_produced_interval": 0.0, "relay": true}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/680423ff840043738f42cc7f1ff97a36.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/680423ff840043738f42cc7f1ff97a36.json
new file mode 100644
index 00000000000..75bc62fbad4
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/680423ff840043738f42cc7f1ff97a36.json
@@ -0,0 +1 @@
+{"setpoint": 14.0, "temperature": 19.1, "battery": 0.51, "valve_position": 0.0, "temperature_difference": -0.4}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/6a3bf693d05e48e0b460c815a4fdd09d.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/6a3bf693d05e48e0b460c815a4fdd09d.json
new file mode 100644
index 00000000000..41333f374e1
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/6a3bf693d05e48e0b460c815a4fdd09d.json
@@ -0,0 +1 @@
+{"setpoint": 15.0, "temperature": 17.2, "battery": 0.37, "active_preset": "asleep", "presets": {"home": [20.0, 22.0], "no_frost": [10.0, 30.0], "away": [12.0, 25.0], "vacation": [11.0, 28.0], "asleep": [16.0, 24.0]}, "schedule_temperature": 15.0, "available_schedules": ["CV Jessie"], "selected_schedule": "CV Jessie", "last_used": "CV Jessie"}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/78d1126fc4c743db81b61c20e88342a7.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/78d1126fc4c743db81b61c20e88342a7.json
new file mode 100644
index 00000000000..7a9c3e9be01
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/78d1126fc4c743db81b61c20e88342a7.json
@@ -0,0 +1 @@
+{"electricity_consumed": 35.6, "electricity_consumed_interval": 7.37, "electricity_produced": 0.0, "electricity_produced_interval": 0.0, "relay": true}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/90986d591dcd426cae3ec3e8111ff730.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/90986d591dcd426cae3ec3e8111ff730.json
new file mode 100644
index 00000000000..5e481d36b46
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/90986d591dcd426cae3ec3e8111ff730.json
@@ -0,0 +1 @@
+{"water_temperature": 70.0, "intended_boiler_temperature": 70.0, "modulation_level": 0.01}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/a28f588dc4a049a483fd03a30361ad3a.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/a28f588dc4a049a483fd03a30361ad3a.json
new file mode 100644
index 00000000000..0aeca4cc18e
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/a28f588dc4a049a483fd03a30361ad3a.json
@@ -0,0 +1 @@
+{"electricity_consumed": 12.5, "electricity_consumed_interval": 3.8, "electricity_produced": 0.0, "electricity_produced_interval": 0.0, "relay": true}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/a2c3583e0a6349358998b760cea82d2a.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/a2c3583e0a6349358998b760cea82d2a.json
new file mode 100644
index 00000000000..eef83a67a20
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/a2c3583e0a6349358998b760cea82d2a.json
@@ -0,0 +1 @@
+{"setpoint": 13.0, "temperature": 17.2, "battery": 0.62, "valve_position": 0.0, "temperature_difference": -0.2}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/b310b72a0e354bfab43089919b9a88bf.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/b310b72a0e354bfab43089919b9a88bf.json
new file mode 100644
index 00000000000..16da5f44ef5
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/b310b72a0e354bfab43089919b9a88bf.json
@@ -0,0 +1 @@
+{"setpoint": 21.5, "temperature": 26.0, "valve_position": 1.0, "temperature_difference": 3.5}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/b59bcebaf94b499ea7d46e4a66fb62d8.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/b59bcebaf94b499ea7d46e4a66fb62d8.json
new file mode 100644
index 00000000000..65fa0dd3d52
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/b59bcebaf94b499ea7d46e4a66fb62d8.json
@@ -0,0 +1 @@
+{"setpoint": 21.5, "temperature": 20.9, "battery": 0.34, "active_preset": "home", "presets": {"vacation": [15.0, 28.0], "asleep": [18.0, 24.0], "no_frost": [12.0, 30.0], "away": [17.0, 25.0], "home": [21.5, 22.0]}, "schedule_temperature": 21.5, "available_schedules": ["GF7 Woonkamer"], "selected_schedule": "GF7 Woonkamer", "last_used": "GF7 Woonkamer"}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/cd0ddb54ef694e11ac18ed1cbce5dbbd.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/cd0ddb54ef694e11ac18ed1cbce5dbbd.json
new file mode 100644
index 00000000000..fbefc5bba25
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/cd0ddb54ef694e11ac18ed1cbce5dbbd.json
@@ -0,0 +1 @@
+{"electricity_consumed": 16.5, "electricity_consumed_interval": 0.5, "electricity_produced": 0.0, "electricity_produced_interval": 0.0, "relay": true}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/d3da73bde12a47d5a6b8f9dad971f2ec.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/d3da73bde12a47d5a6b8f9dad971f2ec.json
new file mode 100644
index 00000000000..fd202e05586
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/d3da73bde12a47d5a6b8f9dad971f2ec.json
@@ -0,0 +1 @@
+{"setpoint": 15.0, "temperature": 17.1, "battery": 0.62, "valve_position": 0.0, "temperature_difference": 0.1}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/df4a4a8169904cdb9c03d61a21f42140.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/df4a4a8169904cdb9c03d61a21f42140.json
new file mode 100644
index 00000000000..12947c42ce0
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/df4a4a8169904cdb9c03d61a21f42140.json
@@ -0,0 +1 @@
+{"setpoint": 13.0, "temperature": 16.5, "battery": 0.67, "active_preset": "away", "presets": {"home": [20.0, 22.0], "away": [12.0, 25.0], "vacation": [12.0, 28.0], "no_frost": [8.0, 30.0], "asleep": [15.0, 24.0]}, "schedule_temperature": null, "available_schedules": [], "selected_schedule": null, "last_used": null}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/e7693eb9582644e5b865dba8d4447cf1.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/e7693eb9582644e5b865dba8d4447cf1.json
new file mode 100644
index 00000000000..151b4b41f70
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/e7693eb9582644e5b865dba8d4447cf1.json
@@ -0,0 +1 @@
+{"setpoint": 5.5, "temperature": 15.6, "battery": 0.68, "valve_position": 0.0, "temperature_difference": 0.0, "active_preset": "no_frost", "presets": {"home": [20.0, 22.0], "asleep": [17.0, 24.0], "away": [15.0, 25.0], "vacation": [15.0, 28.0], "no_frost": [10.0, 30.0]}, "schedule_temperature": null, "available_schedules": [], "selected_schedule": null, "last_used": null}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/f1fee6043d3642a9b0a65297455f008e.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/f1fee6043d3642a9b0a65297455f008e.json
new file mode 100644
index 00000000000..9934e109033
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/f1fee6043d3642a9b0a65297455f008e.json
@@ -0,0 +1 @@
+{"setpoint": 14.0, "temperature": 18.9, "battery": 0.92, "active_preset": "away", "presets": {"asleep": [17.0, 24.0], "no_frost": [10.0, 30.0], "away": [14.0, 25.0], "home": [21.0, 22.0], "vacation": [12.0, 28.0]}, "schedule_temperature": 14.0, "available_schedules": ["Badkamer Schema"], "selected_schedule": "Badkamer Schema", "last_used": "Badkamer Schema"}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/fe799307f1624099878210aa0b9f1475.json b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/fe799307f1624099878210aa0b9f1475.json
new file mode 100644
index 00000000000..ef325af7bc2
--- /dev/null
+++ b/tests/fixtures/plugwise/adam_multiple_devices_per_zone/get_device_data/fe799307f1624099878210aa0b9f1475.json
@@ -0,0 +1 @@
+{"outdoor_temperature": 7.81}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/anna_heatpump/get_all_devices.json b/tests/fixtures/plugwise/anna_heatpump/get_all_devices.json
new file mode 100644
index 00000000000..4992a175b14
--- /dev/null
+++ b/tests/fixtures/plugwise/anna_heatpump/get_all_devices.json
@@ -0,0 +1 @@
+{"1cbf783bb11e4a7c8a6843dee3a86927": {"name": "Anna", "types": {"py/set": ["temperature", "thermostat", "home"]}, "class": "heater_central", "location": "a57efe5f145f498c9be62a9b63626fbf"}, "015ae9ea3f964e668e490fa39da3870b": {"name": "Anna", "types": {"py/set": ["temperature", "thermostat", "home"]}, "class": "gateway", "location": "a57efe5f145f498c9be62a9b63626fbf"}, "3cb70739631c4d17a86b8b12e8a5161b": {"name": "Anna", "types": {"py/set": ["thermostat"]}, "class": "thermostat", "location": "c784ee9fdab44e1395b8dee7d7a497d5"}}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/anna_heatpump/get_device_data/015ae9ea3f964e668e490fa39da3870b.json b/tests/fixtures/plugwise/anna_heatpump/get_device_data/015ae9ea3f964e668e490fa39da3870b.json
new file mode 100644
index 00000000000..750aa8b455c
--- /dev/null
+++ b/tests/fixtures/plugwise/anna_heatpump/get_device_data/015ae9ea3f964e668e490fa39da3870b.json
@@ -0,0 +1 @@
+{"outdoor_temperature": 20.2}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/anna_heatpump/get_device_data/1cbf783bb11e4a7c8a6843dee3a86927.json b/tests/fixtures/plugwise/anna_heatpump/get_device_data/1cbf783bb11e4a7c8a6843dee3a86927.json
new file mode 100644
index 00000000000..a8aea8e1357
--- /dev/null
+++ b/tests/fixtures/plugwise/anna_heatpump/get_device_data/1cbf783bb11e4a7c8a6843dee3a86927.json
@@ -0,0 +1 @@
+{"outdoor_temperature": 18.0, "heating_state": false, "dhw_state": false, "water_temperature": 29.1, "return_temperature": 25.1, "water_pressure": 1.57, "intended_boiler_temperature": 0.0, "modulation_level": 0.52, "cooling_state": false, "slave_boiler_state": false, "compressor_state": true, "flame_state": false}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/anna_heatpump/get_device_data/3cb70739631c4d17a86b8b12e8a5161b.json b/tests/fixtures/plugwise/anna_heatpump/get_device_data/3cb70739631c4d17a86b8b12e8a5161b.json
new file mode 100644
index 00000000000..2a092e792d5
--- /dev/null
+++ b/tests/fixtures/plugwise/anna_heatpump/get_device_data/3cb70739631c4d17a86b8b12e8a5161b.json
@@ -0,0 +1 @@
+{"setpoint": 21.0, "temperature": 23.3, "active_preset": "home", "presets": {"no_frost": [10.0, 30.0], "home": [21.0, 22.0], "away": [20.0, 25.0], "asleep": [20.5, 24.0], "vacation": [17.0, 28.0]}, "schedule_temperature": null, "available_schedules": ["standaard"], "selected_schedule": "standaard", "last_used": "standaard", "illuminance": 86.0}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/p1v3_full_option/get_all_devices.json b/tests/fixtures/plugwise/p1v3_full_option/get_all_devices.json
new file mode 100644
index 00000000000..e25fcb953c8
--- /dev/null
+++ b/tests/fixtures/plugwise/p1v3_full_option/get_all_devices.json
@@ -0,0 +1 @@
+{"e950c7d5e1ee407a858e2a8b5016c8b3": {"name": "P1", "types": {"py/set": ["power", "home"]}, "class": "gateway", "location": "cd3e822288064775a7c4afcdd70bdda2"}}
\ No newline at end of file
diff --git a/tests/fixtures/plugwise/p1v3_full_option/get_device_data/e950c7d5e1ee407a858e2a8b5016c8b3.json b/tests/fixtures/plugwise/p1v3_full_option/get_device_data/e950c7d5e1ee407a858e2a8b5016c8b3.json
new file mode 100644
index 00000000000..36cb66c7902
--- /dev/null
+++ b/tests/fixtures/plugwise/p1v3_full_option/get_device_data/e950c7d5e1ee407a858e2a8b5016c8b3.json
@@ -0,0 +1 @@
+{"net_electricity_point": -2761.0, "electricity_consumed_peak_point": 0.0, "electricity_consumed_off_peak_point": 0.0, "net_electricity_cumulative": 442972.0, "electricity_consumed_peak_cumulative": 442932.0, "electricity_consumed_off_peak_cumulative": 551090.0, "net_electricity_interval": 0.0, "electricity_consumed_peak_interval": 0.0, "electricity_consumed_off_peak_interval": 0.0, "electricity_produced_peak_point": 2761.0, "electricity_produced_off_peak_point": 0.0, "electricity_produced_peak_cumulative": 396559.0, "electricity_produced_off_peak_cumulative": 154491.0, "electricity_produced_peak_interval": 0.0, "electricity_produced_off_peak_interval": 0.0, "gas_consumed_cumulative": 584.9, "gas_consumed_interval": 0.0}
\ No newline at end of file
diff --git a/tests/fixtures/rest/configuration.yaml b/tests/fixtures/rest/configuration.yaml
new file mode 100644
index 00000000000..69a4e771ebf
--- /dev/null
+++ b/tests/fixtures/rest/configuration.yaml
@@ -0,0 +1,10 @@
+sensor:
+ - platform: rest
+ resource: "http://localhost"
+ method: GET
+ name: rollout
+
+notify:
+ - name: rest_reloaded
+ platform: rest
+ resource: http://127.0.0.1/on
diff --git a/tests/fixtures/smart_meter_texas/latestodrread.json b/tests/fixtures/smart_meter_texas/latestodrread.json
new file mode 100644
index 00000000000..1f48ada166a
--- /dev/null
+++ b/tests/fixtures/smart_meter_texas/latestodrread.json
@@ -0,0 +1,9 @@
+{
+ "data": {
+ "odrstatus": "COMPLETED",
+ "odrread": "9751.212",
+ "odrusage": "43.826",
+ "odrdate": "08/15/2020 14:07:40",
+ "responseMessage": "SUCCESS"
+ }
+}
diff --git a/tests/fixtures/smart_meter_texas/meter.json b/tests/fixtures/smart_meter_texas/meter.json
new file mode 100644
index 00000000000..55e38f205c4
--- /dev/null
+++ b/tests/fixtures/smart_meter_texas/meter.json
@@ -0,0 +1,22 @@
+{
+ "data": [
+ {
+ "customer": "X",
+ "email": "Y",
+ "description": "123 Main St",
+ "address": "123 MAIN ST",
+ "city": "DALLAS",
+ "state": "TX",
+ "zip": "75001-0.00",
+ "esiid": "12345678901234567",
+ "meterNumber": "123456789",
+ "meterMultiplier": 1,
+ "fullAddress": "123 MAIN ST, DALLAS, TX, 75001-0.00",
+ "errmsg": "Success",
+ "recordStatus": "ESIID-RECORD-EXISTS",
+ "statusIndicator": true,
+ "dunsNumber": "NO_DUNS"
+ }
+ ]
+ }
+
\ No newline at end of file
diff --git a/tests/fixtures/smtp/configuration.yaml b/tests/fixtures/smtp/configuration.yaml
new file mode 100644
index 00000000000..03bed686909
--- /dev/null
+++ b/tests/fixtures/smtp/configuration.yaml
@@ -0,0 +1,5 @@
+notify:
+ - name: smtp_reloaded
+ platform: smtp
+ sender: test@example.com
+ recipient: test@example.com
diff --git a/tests/fixtures/statistics/configuration.yaml b/tests/fixtures/statistics/configuration.yaml
new file mode 100644
index 00000000000..a6ce34377a0
--- /dev/null
+++ b/tests/fixtures/statistics/configuration.yaml
@@ -0,0 +1,4 @@
+sensor:
+ - platform: statistics
+ entity_id: sensor.cpu
+ name: cputest
diff --git a/tests/fixtures/telegram/configuration.yaml b/tests/fixtures/telegram/configuration.yaml
new file mode 100644
index 00000000000..ab50b4df5ee
--- /dev/null
+++ b/tests/fixtures/telegram/configuration.yaml
@@ -0,0 +1,4 @@
+notify:
+ - name: telegram_reloaded
+ platform: telegram
+ chat_id: 2
diff --git a/tests/fixtures/template/broken_configuration.yaml b/tests/fixtures/template/broken_configuration.yaml
new file mode 100644
index 00000000000..9d21081ac87
--- /dev/null
+++ b/tests/fixtures/template/broken_configuration.yaml
@@ -0,0 +1,16 @@
+sensor:
+ - platform: template
+ broken:
+ - platform: template
+ sensors:
+ combined_sensor_energy_usage:
+ friendly_name: Combined Sense Energy Usage
+ unit_of_measurement: kW
+ value_template: '{{ ((states(''sensor.energy_usage'') | float) + (states(''sensor.energy_usage_2'')
+ | float)) / 1000 }}'
+ watching_tv_in_master_bedroom:
+ friendly_name: Watching TV in Master Bedroom
+ value_template: '{% if state_attr("remote.alexander_master_bedroom","current_activity")
+ == "Watch TV" or state_attr("remote.alexander_master_bedroom","current_activity")
+ == "Watch Apple TV" %}on{% else %}off{% endif %}'
+
diff --git a/tests/fixtures/template/configuration.yaml.corrupt b/tests/fixtures/template/configuration.yaml.corrupt
new file mode 100644
index 00000000000..b30a14ec331
Binary files /dev/null and b/tests/fixtures/template/configuration.yaml.corrupt differ
diff --git a/tests/fixtures/template/empty_configuration.yaml b/tests/fixtures/template/empty_configuration.yaml
new file mode 100644
index 00000000000..8b137891791
--- /dev/null
+++ b/tests/fixtures/template/empty_configuration.yaml
@@ -0,0 +1 @@
+
diff --git a/tests/fixtures/template/sensor_configuration.yaml b/tests/fixtures/template/sensor_configuration.yaml
new file mode 100644
index 00000000000..48ef4cf4304
--- /dev/null
+++ b/tests/fixtures/template/sensor_configuration.yaml
@@ -0,0 +1,23 @@
+sensor:
+ - platform: snmp
+ name: UPS kW
+ unit_of_measurement: kW
+ baseoid: 1.3.6.1.4.1.3808.1.1.1.4.2.5.0
+ host: 192.168.210.25
+ community: public
+ accept_errors: true
+ value_template: '{{ ((value | int) / 1000) | float | round(3) }}'
+ scan_interval: 900
+ - platform: template
+ sensors:
+ combined_sensor_energy_usage:
+ friendly_name: Combined Sense Energy Usage
+ unit_of_measurement: kW
+ value_template: '{{ ((states(''sensor.energy_usage'') | float) + (states(''sensor.energy_usage_2'')
+ | float)) / 1000 }}'
+ watching_tv_in_master_bedroom:
+ friendly_name: Watching TV in Master Bedroom
+ value_template: '{% if state_attr("remote.alexander_master_bedroom","current_activity")
+ == "Watch TV" or state_attr("remote.alexander_master_bedroom","current_activity")
+ == "Watch Apple TV" %}on{% else %}off{% endif %}'
+
diff --git a/tests/fixtures/trend/configuration.yaml b/tests/fixtures/trend/configuration.yaml
new file mode 100644
index 00000000000..a3826cbb9e3
--- /dev/null
+++ b/tests/fixtures/trend/configuration.yaml
@@ -0,0 +1,5 @@
+binary_sensor:
+ - platform: trend
+ sensors:
+ second_test_trend_sensor:
+ entity_id: sensor.test_state
diff --git a/tests/fixtures/universal/configuration.yaml b/tests/fixtures/universal/configuration.yaml
new file mode 100644
index 00000000000..c3e445615f1
--- /dev/null
+++ b/tests/fixtures/universal/configuration.yaml
@@ -0,0 +1,9 @@
+media_player:
+ - platform: universal
+ name: Master Bed TV
+ children:
+ - media_player.master_bedroom_2
+ attributes:
+ state: remote.alexander_master_bedroom
+ source_list: remote.alexander_master_bedroom|activity_list
+ source: remote.alexander_master_bedroom|current_activity
diff --git a/tests/fixtures/yr.no.xml b/tests/fixtures/yr.no.xml
deleted file mode 100644
index b181fdfd85b..00000000000
--- a/tests/fixtures/yr.no.xml
+++ /dev/null
@@ -1,1184 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/tests/hassfest/test_dependencies.py b/tests/hassfest/test_dependencies.py
index b9690107619..dcef6d34844 100644
--- a/tests/hassfest/test_dependencies.py
+++ b/tests/hassfest/test_dependencies.py
@@ -2,6 +2,7 @@
import ast
import pytest
+
from script.hassfest.dependencies import ImportCollector
diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py
index b2cb1ff100c..71770d21186 100644
--- a/tests/helpers/test_condition.py
+++ b/tests/helpers/test_condition.py
@@ -1,8 +1,12 @@
"""Test the condition helper."""
+from logging import ERROR
+
import pytest
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import condition
+from homeassistant.helpers.template import Template
+from homeassistant.setup import async_setup_component
from homeassistant.util import dt
from tests.async_mock import patch
@@ -125,10 +129,7 @@ async def test_or_condition_with_template(hass):
{
"condition": "or",
"conditions": [
- {
- "condition": "template",
- "value_template": '{{ states.sensor.temperature.state == "100" }}',
- },
+ {'{{ states.sensor.temperature.state == "100" }}'},
{
"condition": "numeric_state",
"entity_id": "sensor.temperature",
@@ -224,29 +225,118 @@ async def test_time_window(hass):
"homeassistant.helpers.condition.dt_util.now",
return_value=dt.now().replace(hour=3),
):
- assert not condition.time(after=sixam, before=sixpm)
- assert condition.time(after=sixpm, before=sixam)
+ assert not condition.time(hass, after=sixam, before=sixpm)
+ assert condition.time(hass, after=sixpm, before=sixam)
with patch(
"homeassistant.helpers.condition.dt_util.now",
return_value=dt.now().replace(hour=9),
):
- assert condition.time(after=sixam, before=sixpm)
- assert not condition.time(after=sixpm, before=sixam)
+ assert condition.time(hass, after=sixam, before=sixpm)
+ assert not condition.time(hass, after=sixpm, before=sixam)
with patch(
"homeassistant.helpers.condition.dt_util.now",
return_value=dt.now().replace(hour=15),
):
- assert condition.time(after=sixam, before=sixpm)
- assert not condition.time(after=sixpm, before=sixam)
+ assert condition.time(hass, after=sixam, before=sixpm)
+ assert not condition.time(hass, after=sixpm, before=sixam)
with patch(
"homeassistant.helpers.condition.dt_util.now",
return_value=dt.now().replace(hour=21),
):
- assert not condition.time(after=sixam, before=sixpm)
- assert condition.time(after=sixpm, before=sixam)
+ assert not condition.time(hass, after=sixam, before=sixpm)
+ assert condition.time(hass, after=sixpm, before=sixam)
+
+
+async def test_time_using_input_datetime(hass):
+ """Test time conditions using input_datetime entities."""
+ await async_setup_component(
+ hass,
+ "input_datetime",
+ {
+ "input_datetime": {
+ "am": {"has_date": True, "has_time": True},
+ "pm": {"has_date": True, "has_time": True},
+ }
+ },
+ )
+
+ await hass.services.async_call(
+ "input_datetime",
+ "set_datetime",
+ {
+ "entity_id": "input_datetime.am",
+ "datetime": str(
+ dt.now()
+ .replace(hour=6, minute=0, second=0, microsecond=0)
+ .replace(tzinfo=None)
+ ),
+ },
+ blocking=True,
+ )
+
+ await hass.services.async_call(
+ "input_datetime",
+ "set_datetime",
+ {
+ "entity_id": "input_datetime.pm",
+ "datetime": str(
+ dt.now()
+ .replace(hour=18, minute=0, second=0, microsecond=0)
+ .replace(tzinfo=None)
+ ),
+ },
+ blocking=True,
+ )
+
+ with patch(
+ "homeassistant.helpers.condition.dt_util.now",
+ return_value=dt.now().replace(hour=3),
+ ):
+ assert not condition.time(
+ hass, after="input_datetime.am", before="input_datetime.pm"
+ )
+ assert condition.time(
+ hass, after="input_datetime.pm", before="input_datetime.am"
+ )
+
+ with patch(
+ "homeassistant.helpers.condition.dt_util.now",
+ return_value=dt.now().replace(hour=9),
+ ):
+ assert condition.time(
+ hass, after="input_datetime.am", before="input_datetime.pm"
+ )
+ assert not condition.time(
+ hass, after="input_datetime.pm", before="input_datetime.am"
+ )
+
+ with patch(
+ "homeassistant.helpers.condition.dt_util.now",
+ return_value=dt.now().replace(hour=15),
+ ):
+ assert condition.time(
+ hass, after="input_datetime.am", before="input_datetime.pm"
+ )
+ assert not condition.time(
+ hass, after="input_datetime.pm", before="input_datetime.am"
+ )
+
+ with patch(
+ "homeassistant.helpers.condition.dt_util.now",
+ return_value=dt.now().replace(hour=21),
+ ):
+ assert not condition.time(
+ hass, after="input_datetime.am", before="input_datetime.pm"
+ )
+ assert condition.time(
+ hass, after="input_datetime.pm", before="input_datetime.am"
+ )
+
+ assert not condition.time(hass, after="input_datetime.not_existing")
+ assert not condition.time(hass, before="input_datetime.not_existing")
async def test_if_numeric_state_not_raise_on_unavailable(hass):
@@ -321,6 +411,121 @@ async def test_multiple_states(hass):
assert not test(hass)
+async def test_state_attribute(hass):
+ """Test with state attribute in condition."""
+ test = await condition.async_from_config(
+ hass,
+ {
+ "condition": "and",
+ "conditions": [
+ {
+ "condition": "state",
+ "entity_id": "sensor.temperature",
+ "attribute": "attribute1",
+ "state": "200",
+ },
+ ],
+ },
+ )
+
+ hass.states.async_set("sensor.temperature", 100, {"unkown_attr": 200})
+ assert not test(hass)
+
+ hass.states.async_set("sensor.temperature", 100, {"attribute1": 200})
+ assert test(hass)
+
+ hass.states.async_set("sensor.temperature", 100, {"attribute1": "200"})
+ assert test(hass)
+
+ hass.states.async_set("sensor.temperature", 100, {"attribute1": 201})
+ assert not test(hass)
+
+ hass.states.async_set("sensor.temperature", 100, {"attribute1": None})
+ assert not test(hass)
+
+
+async def test_state_using_input_entities(hass):
+ """Test state conditions using input_* entities."""
+ await async_setup_component(
+ hass,
+ "input_text",
+ {
+ "input_text": {
+ "hello": {"initial": "goodbye"},
+ }
+ },
+ )
+
+ await async_setup_component(
+ hass,
+ "input_select",
+ {
+ "input_select": {
+ "hello": {"options": ["cya", "goodbye", "welcome"], "initial": "cya"},
+ }
+ },
+ )
+
+ test = await condition.async_from_config(
+ hass,
+ {
+ "condition": "and",
+ "conditions": [
+ {
+ "condition": "state",
+ "entity_id": "sensor.salut",
+ "state": [
+ "input_text.hello",
+ "input_select.hello",
+ "input_number.not_exist",
+ "salut",
+ ],
+ },
+ ],
+ },
+ )
+
+ hass.states.async_set("sensor.salut", "goodbye")
+ assert test(hass)
+
+ hass.states.async_set("sensor.salut", "salut")
+ assert test(hass)
+
+ hass.states.async_set("sensor.salut", "hello")
+ assert not test(hass)
+
+ await hass.services.async_call(
+ "input_text",
+ "set_value",
+ {
+ "entity_id": "input_text.hello",
+ "value": "hi",
+ },
+ blocking=True,
+ )
+ assert not test(hass)
+
+ hass.states.async_set("sensor.salut", "hi")
+ assert test(hass)
+
+ hass.states.async_set("sensor.salut", "cya")
+ assert test(hass)
+
+ await hass.services.async_call(
+ "input_select",
+ "select_option",
+ {
+ "entity_id": "input_select.hello",
+ "option": "welcome",
+ },
+ blocking=True,
+ )
+ assert not test(hass)
+
+ hass.states.async_set("sensor.salut", "welcome")
+ assert test(hass)
+
+
async def test_numeric_state_multiple_entities(hass):
"""Test with multiple entities in condition."""
test = await condition.async_from_config(
@@ -350,6 +555,95 @@ async def test_numeric_state_multiple_entities(hass):
assert not test(hass)
+async def test_numberic_state_attribute(hass):
+ """Test with numeric state attribute in condition."""
+ test = await condition.async_from_config(
+ hass,
+ {
+ "condition": "and",
+ "conditions": [
+ {
+ "condition": "numeric_state",
+ "entity_id": "sensor.temperature",
+ "attribute": "attribute1",
+ "below": 50,
+ },
+ ],
+ },
+ )
+
+ hass.states.async_set("sensor.temperature", 100, {"unkown_attr": 10})
+ assert not test(hass)
+
+ hass.states.async_set("sensor.temperature", 100, {"attribute1": 49})
+ assert test(hass)
+
+ hass.states.async_set("sensor.temperature", 100, {"attribute1": "49"})
+ assert test(hass)
+
+ hass.states.async_set("sensor.temperature", 100, {"attribute1": 51})
+ assert not test(hass)
+
+ hass.states.async_set("sensor.temperature", 100, {"attribute1": None})
+ assert not test(hass)
+
+
+async def test_numeric_state_using_input_number(hass):
+ """Test numeric_state conditions using input_number entities."""
+ await async_setup_component(
+ hass,
+ "input_number",
+ {
+ "input_number": {
+ "low": {"min": 0, "max": 255, "initial": 10},
+ "high": {"min": 0, "max": 255, "initial": 100},
+ }
+ },
+ )
+
+ test = await condition.async_from_config(
+ hass,
+ {
+ "condition": "and",
+ "conditions": [
+ {
+ "condition": "numeric_state",
+ "entity_id": "sensor.temperature",
+ "below": "input_number.high",
+ "above": "input_number.low",
+ },
+ ],
+ },
+ )
+
+ hass.states.async_set("sensor.temperature", 42)
+ assert test(hass)
+
+ hass.states.async_set("sensor.temperature", 10)
+ assert not test(hass)
+
+ hass.states.async_set("sensor.temperature", 100)
+ assert not test(hass)
+
+ await hass.services.async_call(
+ "input_number",
+ "set_value",
+ {
+ "entity_id": "input_number.high",
+ "value": 101,
+ },
+ blocking=True,
+ )
+ assert test(hass)
+
+ assert not condition.async_numeric_state(
+ hass, entity="sensor.temperature", below="input_number.not_exist"
+ )
+ assert not condition.async_numeric_state(
+ hass, entity="sensor.temperature", above="input_number.not_exist"
+ )
+
+
async def test_zone_multiple_entities(hass):
"""Test with multiple entities in condition."""
test = await condition.async_from_config(
@@ -514,6 +808,7 @@ async def test_extract_entities():
"entity_id": ["sensor.temperature_9", "sensor.temperature_10"],
"below": 110,
},
+ Template("{{ is_state('light.example', 'on') }}"),
],
}
) == {
@@ -532,47 +827,66 @@ async def test_extract_entities():
async def test_extract_devices():
"""Test extracting devices."""
- assert condition.async_extract_devices(
- {
- "condition": "and",
- "conditions": [
- {"condition": "device", "device_id": "abcd", "domain": "light"},
- {"condition": "device", "device_id": "qwer", "domain": "switch"},
- {
- "condition": "state",
- "entity_id": "sensor.not_a_device",
- "state": "100",
- },
- {
- "condition": "not",
- "conditions": [
- {
- "condition": "device",
- "device_id": "abcd_not",
- "domain": "light",
- },
- {
- "condition": "device",
- "device_id": "qwer_not",
- "domain": "switch",
- },
- ],
- },
- {
- "condition": "or",
- "conditions": [
- {
- "condition": "device",
- "device_id": "abcd_or",
- "domain": "light",
- },
- {
- "condition": "device",
- "device_id": "qwer_or",
- "domain": "switch",
- },
- ],
- },
- ],
- }
- ) == {"abcd", "qwer", "abcd_not", "qwer_not", "abcd_or", "qwer_or"}
+ assert (
+ condition.async_extract_devices(
+ {
+ "condition": "and",
+ "conditions": [
+ {"condition": "device", "device_id": "abcd", "domain": "light"},
+ {"condition": "device", "device_id": "qwer", "domain": "switch"},
+ {
+ "condition": "state",
+ "entity_id": "sensor.not_a_device",
+ "state": "100",
+ },
+ {
+ "condition": "not",
+ "conditions": [
+ {
+ "condition": "device",
+ "device_id": "abcd_not",
+ "domain": "light",
+ },
+ {
+ "condition": "device",
+ "device_id": "qwer_not",
+ "domain": "switch",
+ },
+ ],
+ },
+ {
+ "condition": "or",
+ "conditions": [
+ {
+ "condition": "device",
+ "device_id": "abcd_or",
+ "domain": "light",
+ },
+ {
+ "condition": "device",
+ "device_id": "qwer_or",
+ "domain": "switch",
+ },
+ ],
+ },
+ Template("{{ is_state('light.example', 'on') }}"),
+ ],
+ }
+ )
+ == {"abcd", "qwer", "abcd_not", "qwer_not", "abcd_or", "qwer_or"}
+ )
+
+
+async def test_condition_template_error(hass, caplog):
+ """Test invalid template."""
+ caplog.set_level(ERROR)
+
+ test = await condition.async_from_config(
+ hass, {"condition": "template", "value_template": "{{ undefined.state }}"}
+ )
+
+ assert not test(hass)
+ assert len(caplog.records) == 1
+ assert caplog.records[0].message.startswith(
+ "Error during template condition: UndefinedError:"
+ )
diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py
index 7893650d420..adbcca63990 100644
--- a/tests/helpers/test_config_entry_flow.py
+++ b/tests/helpers/test_config_entry_flow.py
@@ -259,7 +259,8 @@ async def test_webhook_config_flow_registers_webhook(hass, webhook_flow_conf):
flow.hass = hass
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com"},
+ hass,
+ {"external_url": "https://example.com"},
)
result = await flow.async_step_user(user_input={})
diff --git a/tests/helpers/test_config_entry_oauth2_flow.py b/tests/helpers/test_config_entry_oauth2_flow.py
index 957bd507af7..691b2e93d56 100644
--- a/tests/helpers/test_config_entry_oauth2_flow.py
+++ b/tests/helpers/test_config_entry_oauth2_flow.py
@@ -8,6 +8,7 @@ import pytest
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.config import async_process_ha_core_config
from homeassistant.helpers import config_entry_oauth2_flow
+from homeassistant.helpers.network import NoURLAvailableError
from tests.async_mock import patch
from tests.common import MockConfigEntry, mock_platform
@@ -128,10 +129,83 @@ async def test_abort_if_authorization_timeout(hass, flow_handler, local_impl):
assert result["reason"] == "authorize_url_timeout"
+async def test_abort_if_no_url_available(hass, flow_handler, local_impl):
+ """Check no_url_available generating authorization url."""
+ flow_handler.async_register_implementation(hass, local_impl)
+
+ flow = flow_handler()
+ flow.hass = hass
+
+ with patch.object(
+ local_impl, "async_generate_authorize_url", side_effect=NoURLAvailableError
+ ):
+ result = await flow.async_step_user()
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "no_url_available"
+
+
+async def test_abort_if_oauth_error(
+ hass, flow_handler, local_impl, aiohttp_client, aioclient_mock, current_request
+):
+ """Check bad oauth token."""
+ await async_process_ha_core_config(
+ hass,
+ {"external_url": "https://example.com"},
+ )
+
+ flow_handler.async_register_implementation(hass, local_impl)
+ config_entry_oauth2_flow.async_register_implementation(
+ hass, TEST_DOMAIN, MockOAuth2Implementation()
+ )
+
+ result = await hass.config_entries.flow.async_init(
+ TEST_DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result["step_id"] == "pick_implementation"
+
+ # Pick implementation
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], user_input={"implementation": TEST_DOMAIN}
+ )
+
+ state = config_entry_oauth2_flow._encode_jwt(hass, {"flow_id": result["flow_id"]})
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP
+ assert result["url"] == (
+ f"{AUTHORIZE_URL}?response_type=code&client_id={CLIENT_ID}"
+ "&redirect_uri=https://example.com/auth/external/callback"
+ f"&state={state}&scope=read+write"
+ )
+
+ client = await aiohttp_client(hass.http.app)
+ resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
+ assert resp.status == 200
+ assert resp.headers["content-type"] == "text/html; charset=utf-8"
+
+ aioclient_mock.post(
+ TOKEN_URL,
+ json={
+ "refresh_token": REFRESH_TOKEN,
+ "access_token": ACCESS_TOKEN_1,
+ "type": "bearer",
+ "expires_in": "badnumber",
+ },
+ )
+
+ result = await hass.config_entries.flow.async_configure(result["flow_id"])
+
+ assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result["reason"] == "oauth_error"
+
+
async def test_step_discovery(hass, flow_handler, local_impl):
"""Check flow triggers from discovery."""
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com"},
+ hass,
+ {"external_url": "https://example.com"},
)
flow_handler.async_register_implementation(hass, local_impl)
config_entry_oauth2_flow.async_register_implementation(
@@ -149,7 +223,8 @@ async def test_step_discovery(hass, flow_handler, local_impl):
async def test_abort_discovered_multiple(hass, flow_handler, local_impl):
"""Test if aborts when discovered multiple times."""
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com"},
+ hass,
+ {"external_url": "https://example.com"},
)
flow_handler.async_register_implementation(hass, local_impl)
@@ -175,14 +250,18 @@ async def test_abort_discovered_multiple(hass, flow_handler, local_impl):
async def test_abort_discovered_existing_entries(hass, flow_handler, local_impl):
"""Test if abort discovery when entries exists."""
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com"},
+ hass,
+ {"external_url": "https://example.com"},
)
flow_handler.async_register_implementation(hass, local_impl)
config_entry_oauth2_flow.async_register_implementation(
hass, TEST_DOMAIN, MockOAuth2Implementation()
)
- entry = MockConfigEntry(domain=TEST_DOMAIN, data={},)
+ entry = MockConfigEntry(
+ domain=TEST_DOMAIN,
+ data={},
+ )
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
@@ -194,11 +273,12 @@ async def test_abort_discovered_existing_entries(hass, flow_handler, local_impl)
async def test_full_flow(
- hass, flow_handler, local_impl, aiohttp_client, aioclient_mock
+ hass, flow_handler, local_impl, aiohttp_client, aioclient_mock, current_request
):
"""Check full flow."""
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com"},
+ hass,
+ {"external_url": "https://example.com"},
)
flow_handler.async_register_implementation(hass, local_impl)
diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py
index 7da0557c9eb..f7d7acbb08e 100644
--- a/tests/helpers/test_config_validation.py
+++ b/tests/helpers/test_config_validation.py
@@ -443,6 +443,29 @@ def test_template():
schema(value)
+def test_dynamic_template():
+ """Test dynamic template validator."""
+ schema = vol.Schema(cv.dynamic_template)
+
+ for value in (
+ None,
+ 1,
+ "{{ partial_print }",
+ "{% if True %}Hello",
+ ["test"],
+ "just a string",
+ ):
+ with pytest.raises(vol.Invalid):
+ schema(value)
+
+ options = (
+ "{{ beer }}",
+ "{% if 1 == 1 %}Hello{% else %}World{% endif %}",
+ )
+ for value in options:
+ schema(value)
+
+
def test_template_complex():
"""Test template_complex validator."""
schema = vol.Schema(cv.template_complex)
@@ -1092,3 +1115,24 @@ def test_script(caplog):
cv.script_action(data)
assert msg in str(excinfo.value)
+
+
+def test_whitespace():
+ """Test whitespace validation."""
+ schema = vol.Schema(cv.whitespace)
+
+ for value in (
+ None,
+ "" "T",
+ "negative",
+ "lock",
+ "tr ue",
+ [],
+ [1, 2],
+ {"one": "two"},
+ ):
+ with pytest.raises(vol.MultipleInvalid):
+ schema(value)
+
+ for value in (" ", " "):
+ assert schema(value)
diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py
index 181a012807a..7c9e8a6e262 100644
--- a/tests/helpers/test_device_registry.py
+++ b/tests/helpers/test_device_registry.py
@@ -839,3 +839,92 @@ async def test_restore_simple_device(hass, registry, update_events):
assert update_events[2]["device_id"] == entry2.id
assert update_events[3]["action"] == "create"
assert update_events[3]["device_id"] == entry3.id
+
+
+async def test_get_or_create_empty_then_set_default_values(hass, registry):
+ """Test creating an entry, then setting default name, model, manufacturer."""
+ entry = registry.async_get_or_create(
+ identifiers={("bridgeid", "0123")}, config_entry_id="1234"
+ )
+ assert entry.name is None
+ assert entry.model is None
+ assert entry.manufacturer is None
+
+ entry = registry.async_get_or_create(
+ config_entry_id="1234",
+ identifiers={("bridgeid", "0123")},
+ default_name="default name 1",
+ default_model="default model 1",
+ default_manufacturer="default manufacturer 1",
+ )
+ assert entry.name == "default name 1"
+ assert entry.model == "default model 1"
+ assert entry.manufacturer == "default manufacturer 1"
+
+ entry = registry.async_get_or_create(
+ config_entry_id="1234",
+ identifiers={("bridgeid", "0123")},
+ default_name="default name 2",
+ default_model="default model 2",
+ default_manufacturer="default manufacturer 2",
+ )
+ assert entry.name == "default name 1"
+ assert entry.model == "default model 1"
+ assert entry.manufacturer == "default manufacturer 1"
+
+
+async def test_get_or_create_empty_then_update(hass, registry):
+ """Test creating an entry, then setting name, model, manufacturer."""
+ entry = registry.async_get_or_create(
+ identifiers={("bridgeid", "0123")}, config_entry_id="1234"
+ )
+ assert entry.name is None
+ assert entry.model is None
+ assert entry.manufacturer is None
+
+ entry = registry.async_get_or_create(
+ config_entry_id="1234",
+ identifiers={("bridgeid", "0123")},
+ name="name 1",
+ model="model 1",
+ manufacturer="manufacturer 1",
+ )
+ assert entry.name == "name 1"
+ assert entry.model == "model 1"
+ assert entry.manufacturer == "manufacturer 1"
+
+ entry = registry.async_get_or_create(
+ config_entry_id="1234",
+ identifiers={("bridgeid", "0123")},
+ default_name="default name 1",
+ default_model="default model 1",
+ default_manufacturer="default manufacturer 1",
+ )
+ assert entry.name == "name 1"
+ assert entry.model == "model 1"
+ assert entry.manufacturer == "manufacturer 1"
+
+
+async def test_get_or_create_sets_default_values(hass, registry):
+ """Test creating an entry, then setting default name, model, manufacturer."""
+ entry = registry.async_get_or_create(
+ config_entry_id="1234",
+ identifiers={("bridgeid", "0123")},
+ default_name="default name 1",
+ default_model="default model 1",
+ default_manufacturer="default manufacturer 1",
+ )
+ assert entry.name == "default name 1"
+ assert entry.model == "default model 1"
+ assert entry.manufacturer == "default manufacturer 1"
+
+ entry = registry.async_get_or_create(
+ config_entry_id="1234",
+ identifiers={("bridgeid", "0123")},
+ default_name="default name 2",
+ default_model="default model 2",
+ default_manufacturer="default manufacturer 2",
+ )
+ assert entry.name == "default name 1"
+ assert entry.model == "default model 1"
+ assert entry.manufacturer == "default manufacturer 1"
diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py
index 49f8fbdef7c..1513d573b56 100644
--- a/tests/helpers/test_entity.py
+++ b/tests/helpers/test_entity.py
@@ -11,7 +11,13 @@ from homeassistant.core import Context
from homeassistant.helpers import entity, entity_registry
from tests.async_mock import MagicMock, PropertyMock, patch
-from tests.common import get_test_home_assistant, mock_registry
+from tests.common import (
+ MockConfigEntry,
+ MockEntity,
+ MockEntityPlatform,
+ get_test_home_assistant,
+ mock_registry,
+)
def test_generate_entity_id_requires_hass_or_ids():
@@ -603,7 +609,7 @@ async def test_disabled_in_entity_registry(hass):
entity_id="hello.world",
unique_id="test-unique-id",
platform="test-platform",
- disabled_by="user",
+ disabled_by=None,
)
registry = mock_registry(hass, {"hello.world": entry})
@@ -611,23 +617,24 @@ async def test_disabled_in_entity_registry(hass):
ent.hass = hass
ent.entity_id = "hello.world"
ent.registry_entry = entry
- ent.platform = MagicMock(platform_name="test-platform")
+ assert ent.enabled is True
- await ent.async_internal_added_to_hass()
- ent.async_write_ha_state()
- assert hass.states.get("hello.world") is None
+ ent.add_to_platform_start(hass, MagicMock(platform_name="test-platform"), None)
+ await ent.add_to_platform_finish()
+ assert hass.states.get("hello.world") is not None
- entry2 = registry.async_update_entity("hello.world", disabled_by=None)
+ entry2 = registry.async_update_entity("hello.world", disabled_by="user")
await hass.async_block_till_done()
assert entry2 != entry
assert ent.registry_entry == entry2
- assert ent.enabled is True
+ assert ent.enabled is False
+ assert hass.states.get("hello.world") is None
- entry3 = registry.async_update_entity("hello.world", disabled_by="user")
+ entry3 = registry.async_update_entity("hello.world", disabled_by=None)
await hass.async_block_till_done()
assert entry3 != entry2
- assert ent.registry_entry == entry3
- assert ent.enabled is False
+ # Entry is no longer updated, entity is no longer tracking changes
+ assert ent.registry_entry == entry2
async def test_capability_attrs(hass):
@@ -690,3 +697,31 @@ async def test_warn_slow_write_state_custom_component(hass, caplog):
"(.CustomComponentEntity'>) "
"took 10.000 seconds. Please report it to the custom component author."
) in caplog.text
+
+
+async def test_setup_source(hass):
+ """Check that we register sources correctly."""
+ platform = MockEntityPlatform(hass)
+
+ entity_platform = MockEntity(name="Platform Config Source")
+ await platform.async_add_entities([entity_platform])
+
+ platform.config_entry = MockConfigEntry()
+ entity_entry = MockEntity(name="Config Entry Source")
+ await platform.async_add_entities([entity_entry])
+
+ assert entity.entity_sources(hass) == {
+ "test_domain.platform_config_source": {
+ "source": entity.SOURCE_PLATFORM_CONFIG,
+ "domain": "test_platform",
+ },
+ "test_domain.config_entry_source": {
+ "source": entity.SOURCE_CONFIG_ENTRY,
+ "config_entry": platform.config_entry.entry_id,
+ "domain": "test_platform",
+ },
+ }
+
+ await platform.async_reset()
+
+ assert entity.entity_sources(hass) == {}
diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py
index 05361fe2006..0373705186f 100644
--- a/tests/helpers/test_entity_component.py
+++ b/tests/helpers/test_entity_component.py
@@ -80,7 +80,9 @@ async def test_setup_recovers_when_setup_raises(hass):
assert platform2_setup.called
-@patch("homeassistant.helpers.entity_component.EntityComponent.async_setup_platform",)
+@patch(
+ "homeassistant.helpers.entity_component.EntityComponent.async_setup_platform",
+)
@patch("homeassistant.setup.async_setup_component", return_value=True)
async def test_setup_does_discovery(mock_setup_component, mock_setup, hass):
"""Test setup for discovery."""
diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py
index 6d03b087151..7e923e6abc0 100644
--- a/tests/helpers/test_entity_platform.py
+++ b/tests/helpers/test_entity_platform.py
@@ -5,7 +5,7 @@ import logging
import pytest
-from homeassistant.const import UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers import entity_platform, entity_registry
@@ -777,7 +777,13 @@ async def test_device_info_not_overrides(hass):
async_add_entities(
[
MockEntity(
- unique_id="qwer", device_info={"connections": {("mac", "abcd")}}
+ unique_id="qwer",
+ device_info={
+ "connections": {("mac", "abcd")},
+ "default_name": "default name 1",
+ "default_model": "default model 1",
+ "default_manufacturer": "default manufacturer 1",
+ },
)
]
)
@@ -832,7 +838,7 @@ async def test_entity_info_added_to_entity_registry(hass):
capability_attributes={"max": 100},
supported_features=5,
device_class="mock-device-class",
- unit_of_measurement=UNIT_PERCENTAGE,
+ unit_of_measurement=PERCENTAGE,
)
await component.async_add_entities([entity_default])
@@ -844,7 +850,7 @@ async def test_entity_info_added_to_entity_registry(hass):
assert entry_default.capabilities == {"max": 100}
assert entry_default.supported_features == 5
assert entry_default.device_class == "mock-device-class"
- assert entry_default.unit_of_measurement == UNIT_PERCENTAGE
+ assert entry_default.unit_of_measurement == PERCENTAGE
async def test_override_restored_entities(hass):
diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py
index 97d8af7d0ee..8f5f8fc501b 100644
--- a/tests/helpers/test_entity_registry.py
+++ b/tests/helpers/test_entity_registry.py
@@ -526,7 +526,10 @@ async def test_restore_states(hass):
registry = await entity_registry.async_get_registry(hass)
registry.async_get_or_create(
- "light", "hue", "1234", suggested_object_id="simple",
+ "light",
+ "hue",
+ "1234",
+ suggested_object_id="simple",
)
# Should not be created
registry.async_get_or_create(
diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py
index aa0a69d1d67..ba14c8a757f 100644
--- a/tests/helpers/test_event.py
+++ b/tests/helpers/test_event.py
@@ -4,23 +4,29 @@ import asyncio
from datetime import datetime, timedelta
from astral import Astral
+import jinja2
import pytest
from homeassistant.components import sun
from homeassistant.const import MATCH_ALL
import homeassistant.core as ha
from homeassistant.core import callback
+from homeassistant.exceptions import TemplateError
from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
from homeassistant.helpers.event import (
+ TrackTemplate,
+ TrackTemplateResult,
async_call_later,
async_track_point_in_time,
async_track_point_in_utc_time,
async_track_same_state,
+ async_track_state_added_domain,
async_track_state_change,
async_track_state_change_event,
async_track_sunrise,
async_track_sunset,
async_track_template,
+ async_track_template_result,
async_track_time_change,
async_track_time_interval,
async_track_utc_time_change,
@@ -341,6 +347,88 @@ async def test_async_track_state_change_event(hass):
unsub_throws()
+async def test_async_track_state_added_domain(hass):
+ """Test async_track_state_added_domain."""
+ single_entity_id_tracker = []
+ multiple_entity_id_tracker = []
+
+ @ha.callback
+ def single_run_callback(event):
+ old_state = event.data.get("old_state")
+ new_state = event.data.get("new_state")
+
+ single_entity_id_tracker.append((old_state, new_state))
+
+ @ha.callback
+ def multiple_run_callback(event):
+ old_state = event.data.get("old_state")
+ new_state = event.data.get("new_state")
+
+ multiple_entity_id_tracker.append((old_state, new_state))
+
+ @ha.callback
+ def callback_that_throws(event):
+ raise ValueError
+
+ unsub_single = async_track_state_added_domain(hass, "light", single_run_callback)
+ unsub_multi = async_track_state_added_domain(
+ hass, ["light", "switch"], multiple_run_callback
+ )
+ unsub_throws = async_track_state_added_domain(
+ hass, ["light", "switch"], callback_that_throws
+ )
+
+ # Adding state to state machine
+ hass.states.async_set("light.Bowl", "on")
+ await hass.async_block_till_done()
+ assert len(single_entity_id_tracker) == 1
+ assert single_entity_id_tracker[-1][0] is None
+ assert single_entity_id_tracker[-1][1] is not None
+ assert len(multiple_entity_id_tracker) == 1
+ assert multiple_entity_id_tracker[-1][0] is None
+ assert multiple_entity_id_tracker[-1][1] is not None
+
+ # Set same state should not trigger a state change/listener
+ hass.states.async_set("light.Bowl", "on")
+ await hass.async_block_till_done()
+ assert len(single_entity_id_tracker) == 1
+ assert len(multiple_entity_id_tracker) == 1
+
+ # State change off -> on - nothing added so no trigger
+ hass.states.async_set("light.Bowl", "off")
+ await hass.async_block_till_done()
+ assert len(single_entity_id_tracker) == 1
+ assert len(multiple_entity_id_tracker) == 1
+
+ # State change off -> off - nothing added so no trigger
+ hass.states.async_set("light.Bowl", "off", {"some_attr": 1})
+ await hass.async_block_till_done()
+ assert len(single_entity_id_tracker) == 1
+ assert len(multiple_entity_id_tracker) == 1
+
+ # Removing state does not trigger
+ hass.states.async_remove("light.bowl")
+ await hass.async_block_till_done()
+ assert len(single_entity_id_tracker) == 1
+ assert len(multiple_entity_id_tracker) == 1
+
+ # Set state for different entity id
+ hass.states.async_set("switch.kitchen", "on")
+ await hass.async_block_till_done()
+ assert len(single_entity_id_tracker) == 1
+ assert len(multiple_entity_id_tracker) == 2
+
+ unsub_single()
+ # Ensure unsubing the listener works
+ hass.states.async_set("light.new", "off")
+ await hass.async_block_till_done()
+ assert len(single_entity_id_tracker) == 1
+ assert len(multiple_entity_id_tracker) == 3
+
+ unsub_multi()
+ unsub_throws()
+
+
async def test_track_template(hass):
"""Test tracking template."""
specific_runs = []
@@ -407,61 +495,842 @@ async def test_track_template(hass):
assert len(wildcard_runs) == 2
assert len(wildercard_runs) == 2
+ template_iterate = Template("{{ (states.switch | length) > 0 }}", hass)
+ iterate_calls = []
-async def test_track_same_state_simple_trigger(hass):
- """Test track_same_change with trigger simple."""
- thread_runs = []
- callback_runs = []
- coroutine_runs = []
- period = timedelta(minutes=1)
+ @ha.callback
+ def iterate_callback(entity_id, old_state, new_state):
+ iterate_calls.append((entity_id, old_state, new_state))
- def thread_run_callback():
- thread_runs.append(1)
+ async_track_template(hass, template_iterate, iterate_callback)
+ await hass.async_block_till_done()
- async_track_same_state(
- hass,
- period,
- thread_run_callback,
- lambda _, _2, to_s: to_s.state == "on",
- entity_ids="light.Bowl",
+ hass.states.async_set("switch.new", "on")
+ await hass.async_block_till_done()
+
+ assert len(iterate_calls) == 1
+ assert iterate_calls[0][0] == "switch.new"
+ assert iterate_calls[0][1] is None
+ assert iterate_calls[0][2].state == "on"
+
+
+async def test_track_template_error(hass, caplog):
+ """Test tracking template with error."""
+ template_error = Template("{{ (states.switch | lunch) > 0 }}", hass)
+ error_calls = []
+
+ @ha.callback
+ def error_callback(entity_id, old_state, new_state):
+ error_calls.append((entity_id, old_state, new_state))
+
+ async_track_template(hass, template_error, error_callback)
+ await hass.async_block_till_done()
+
+ hass.states.async_set("switch.new", "on")
+ await hass.async_block_till_done()
+
+ assert not error_calls
+ assert "lunch" in caplog.text
+ assert "TemplateAssertionError" in caplog.text
+
+ caplog.clear()
+
+ with patch.object(Template, "async_render") as render:
+ render.return_value = "ok"
+
+ hass.states.async_set("switch.not_exist", "off")
+ await hass.async_block_till_done()
+
+ assert "lunch" not in caplog.text
+ assert "TemplateAssertionError" not in caplog.text
+
+
+async def test_track_template_error_can_recover(hass, caplog):
+ """Test tracking template with error."""
+ hass.states.async_set("switch.data_system", "cow", {"opmode": 0})
+ template_error = Template(
+ "{{ states.sensor.data_system.attributes['opmode'] == '0' }}", hass
+ )
+ error_calls = []
+
+ @ha.callback
+ def error_callback(entity_id, old_state, new_state):
+ error_calls.append((entity_id, old_state, new_state))
+
+ async_track_template(hass, template_error, error_callback)
+ await hass.async_block_till_done()
+ assert not error_calls
+
+ hass.states.async_remove("switch.data_system")
+
+ assert "UndefinedError" in caplog.text
+
+ hass.states.async_set("switch.data_system", "cow", {"opmode": 0})
+
+ caplog.clear()
+
+ assert "UndefinedError" not in caplog.text
+
+
+async def test_track_template_result(hass):
+ """Test tracking template."""
+ specific_runs = []
+ wildcard_runs = []
+ wildercard_runs = []
+
+ template_condition = Template("{{states.sensor.test.state}}", hass)
+ template_condition_var = Template(
+ "{{(states.sensor.test.state|int) + test }}", hass
+ )
+
+ def specific_run_callback(event, updates):
+ track_result = updates.pop()
+ specific_runs.append(int(track_result.result))
+
+ async_track_template_result(
+ hass, [TrackTemplate(template_condition, None)], specific_run_callback
)
@ha.callback
- def callback_run_callback():
- callback_runs.append(1)
+ def wildcard_run_callback(event, updates):
+ track_result = updates.pop()
+ wildcard_runs.append(
+ (int(track_result.last_result or 0), int(track_result.result))
+ )
- async_track_same_state(
- hass,
- period,
- callback_run_callback,
- callback(lambda _, _2, to_s: to_s.state == "on"),
- entity_ids="light.Bowl",
+ async_track_template_result(
+ hass, [TrackTemplate(template_condition, None)], wildcard_run_callback
)
- async def coroutine_run_callback():
- coroutine_runs.append(1)
+ async def wildercard_run_callback(event, updates):
+ track_result = updates.pop()
+ wildercard_runs.append(
+ (int(track_result.last_result or 0), int(track_result.result))
+ )
- async_track_same_state(
+ async_track_template_result(
hass,
- period,
- coroutine_run_callback,
- callback(lambda _, _2, to_s: to_s.state == "on"),
+ [TrackTemplate(template_condition_var, {"test": 5})],
+ wildercard_run_callback,
+ )
+ await hass.async_block_till_done()
+
+ hass.states.async_set("sensor.test", 5)
+ await hass.async_block_till_done()
+
+ assert specific_runs == [5]
+ assert wildcard_runs == [(0, 5)]
+ assert wildercard_runs == [(0, 10)]
+
+ hass.states.async_set("sensor.test", 30)
+ await hass.async_block_till_done()
+
+ assert specific_runs == [5, 30]
+ assert wildcard_runs == [(0, 5), (5, 30)]
+ assert wildercard_runs == [(0, 10), (10, 35)]
+
+ hass.states.async_set("sensor.test", 30)
+ await hass.async_block_till_done()
+
+ assert len(specific_runs) == 2
+ assert len(wildcard_runs) == 2
+ assert len(wildercard_runs) == 2
+
+ hass.states.async_set("sensor.test", 5)
+ await hass.async_block_till_done()
+
+ assert len(specific_runs) == 3
+ assert len(wildcard_runs) == 3
+ assert len(wildercard_runs) == 3
+
+ hass.states.async_set("sensor.test", 5)
+ await hass.async_block_till_done()
+
+ assert len(specific_runs) == 3
+ assert len(wildcard_runs) == 3
+ assert len(wildercard_runs) == 3
+
+ hass.states.async_set("sensor.test", 20)
+ await hass.async_block_till_done()
+
+ assert len(specific_runs) == 4
+ assert len(wildcard_runs) == 4
+ assert len(wildercard_runs) == 4
+
+
+async def test_track_template_result_complex(hass):
+ """Test tracking template."""
+ specific_runs = []
+ template_complex_str = """
+
+{% if states("sensor.domain") == "light" %}
+ {{ states.light | map(attribute='entity_id') | list }}
+{% elif states("sensor.domain") == "lock" %}
+ {{ states.lock | map(attribute='entity_id') | list }}
+{% elif states("sensor.domain") == "single_binary_sensor" %}
+ {{ states("binary_sensor.single") }}
+{% else %}
+ {{ states | map(attribute='entity_id') | list }}
+{% endif %}
+
+"""
+ template_complex = Template(template_complex_str, hass)
+
+ def specific_run_callback(event, updates):
+ specific_runs.append(updates.pop().result)
+
+ hass.states.async_set("light.one", "on")
+ hass.states.async_set("lock.one", "locked")
+
+ info = async_track_template_result(
+ hass, [TrackTemplate(template_complex, None)], specific_run_callback
+ )
+ await hass.async_block_till_done()
+
+ assert info.listeners == {"all": True, "domains": set(), "entities": set()}
+
+ hass.states.async_set("sensor.domain", "light")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+ assert specific_runs[0].strip() == "['light.one']"
+
+ assert info.listeners == {
+ "all": False,
+ "domains": {"light"},
+ "entities": {"sensor.domain"},
+ }
+
+ hass.states.async_set("sensor.domain", "lock")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
+ assert specific_runs[1].strip() == "['lock.one']"
+ assert info.listeners == {
+ "all": False,
+ "domains": {"lock"},
+ "entities": {"sensor.domain"},
+ }
+
+ hass.states.async_set("sensor.domain", "all")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 3
+ assert "light.one" in specific_runs[2]
+ assert "lock.one" in specific_runs[2]
+ assert "sensor.domain" in specific_runs[2]
+ assert info.listeners == {"all": True, "domains": set(), "entities": set()}
+
+ hass.states.async_set("sensor.domain", "light")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 4
+ assert specific_runs[3].strip() == "['light.one']"
+ assert info.listeners == {
+ "all": False,
+ "domains": {"light"},
+ "entities": {"sensor.domain"},
+ }
+
+ hass.states.async_set("light.two", "on")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 5
+ assert "light.one" in specific_runs[4]
+ assert "light.two" in specific_runs[4]
+ assert "sensor.domain" not in specific_runs[4]
+ assert info.listeners == {
+ "all": False,
+ "domains": {"light"},
+ "entities": {"sensor.domain"},
+ }
+
+ hass.states.async_set("light.three", "on")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 6
+ assert "light.one" in specific_runs[5]
+ assert "light.two" in specific_runs[5]
+ assert "light.three" in specific_runs[5]
+ assert "sensor.domain" not in specific_runs[5]
+ assert info.listeners == {
+ "all": False,
+ "domains": {"light"},
+ "entities": {"sensor.domain"},
+ }
+
+ hass.states.async_set("sensor.domain", "lock")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 7
+ assert specific_runs[6].strip() == "['lock.one']"
+ assert info.listeners == {
+ "all": False,
+ "domains": {"lock"},
+ "entities": {"sensor.domain"},
+ }
+
+ hass.states.async_set("sensor.domain", "single_binary_sensor")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 8
+ assert specific_runs[7].strip() == "unknown"
+ assert info.listeners == {
+ "all": False,
+ "domains": set(),
+ "entities": {"binary_sensor.single", "sensor.domain"},
+ }
+
+ hass.states.async_set("binary_sensor.single", "binary_sensor_on")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 9
+ assert specific_runs[8].strip() == "binary_sensor_on"
+ assert info.listeners == {
+ "all": False,
+ "domains": set(),
+ "entities": {"binary_sensor.single", "sensor.domain"},
+ }
+
+ hass.states.async_set("sensor.domain", "lock")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 10
+ assert specific_runs[9].strip() == "['lock.one']"
+ assert info.listeners == {
+ "all": False,
+ "domains": {"lock"},
+ "entities": {"sensor.domain"},
+ }
+
+
+async def test_track_template_result_with_wildcard(hass):
+ """Test tracking template with a wildcard."""
+ specific_runs = []
+ template_complex_str = r"""
+
+{% for state in states %}
+ {% if state.entity_id | regex_match('.*\.office_') %}
+ {{ state.entity_id }}={{ state.state }}
+ {% endif %}
+{% endfor %}
+
+"""
+ template_complex = Template(template_complex_str, hass)
+
+ def specific_run_callback(event, updates):
+ specific_runs.append(updates.pop().result)
+
+ hass.states.async_set("cover.office_drapes", "closed")
+ hass.states.async_set("cover.office_window", "closed")
+ hass.states.async_set("cover.office_skylight", "open")
+
+ info = async_track_template_result(
+ hass, [TrackTemplate(template_complex, None)], specific_run_callback
+ )
+ await hass.async_block_till_done()
+
+ hass.states.async_set("cover.office_window", "open")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+ assert info.listeners == {"all": True, "domains": set(), "entities": set()}
+
+ assert "cover.office_drapes=closed" in specific_runs[0]
+ assert "cover.office_window=open" in specific_runs[0]
+ assert "cover.office_skylight=open" in specific_runs[0]
+
+
+async def test_track_template_result_with_group(hass):
+ """Test tracking template with a group."""
+ hass.states.async_set("sensor.power_1", 0)
+ hass.states.async_set("sensor.power_2", 200.2)
+ hass.states.async_set("sensor.power_3", 400.4)
+ hass.states.async_set("sensor.power_4", 800.8)
+
+ assert await async_setup_component(
+ hass,
+ "group",
+ {"group": {"power_sensors": "sensor.power_1,sensor.power_2,sensor.power_3"}},
+ )
+ await hass.async_block_till_done()
+
+ assert hass.states.get("group.power_sensors")
+ assert hass.states.get("group.power_sensors").state
+
+ specific_runs = []
+ template_complex_str = r"""
+
+{{ states.group.power_sensors.attributes.entity_id | expand | map(attribute='state')|map('float')|sum }}
+
+"""
+ template_complex = Template(template_complex_str, hass)
+
+ def specific_run_callback(event, updates):
+ specific_runs.append(updates.pop().result)
+
+ info = async_track_template_result(
+ hass, [TrackTemplate(template_complex, None)], specific_run_callback
+ )
+ await hass.async_block_till_done()
+
+ assert info.listeners == {
+ "all": False,
+ "domains": set(),
+ "entities": {
+ "group.power_sensors",
+ "sensor.power_1",
+ "sensor.power_2",
+ "sensor.power_3",
+ },
+ }
+
+ hass.states.async_set("sensor.power_1", 100.1)
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+
+ assert specific_runs[0] == str(100.1 + 200.2 + 400.4)
+
+ hass.states.async_set("sensor.power_3", 0)
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
+
+ assert specific_runs[1] == str(100.1 + 200.2 + 0)
+
+ with patch(
+ "homeassistant.config.load_yaml_config_file",
+ return_value={
+ "group": {
+ "power_sensors": "sensor.power_1,sensor.power_2,sensor.power_3,sensor.power_4",
+ }
+ },
+ ):
+ await hass.services.async_call("group", "reload")
+ await hass.async_block_till_done()
+
+ assert specific_runs[-1] == str(100.1 + 200.2 + 0 + 800.8)
+
+
+async def test_track_template_result_and_conditional(hass):
+ """Test tracking template with an and conditional."""
+ specific_runs = []
+ hass.states.async_set("light.a", "off")
+ hass.states.async_set("light.b", "off")
+ template_str = '{% if states.light.a.state == "on" and states.light.b.state == "on" %}on{% else %}off{% endif %}'
+
+ template = Template(template_str, hass)
+
+ def specific_run_callback(event, updates):
+ specific_runs.append(updates.pop().result)
+
+ info = async_track_template_result(
+ hass, [TrackTemplate(template, None)], specific_run_callback
+ )
+ await hass.async_block_till_done()
+ assert info.listeners == {"all": False, "domains": set(), "entities": {"light.a"}}
+
+ hass.states.async_set("light.b", "on")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 0
+
+ hass.states.async_set("light.a", "on")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+ assert specific_runs[0] == "on"
+ assert info.listeners == {
+ "all": False,
+ "domains": set(),
+ "entities": {"light.a", "light.b"},
+ }
+
+ hass.states.async_set("light.b", "off")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
+ assert specific_runs[1] == "off"
+ assert info.listeners == {
+ "all": False,
+ "domains": set(),
+ "entities": {"light.a", "light.b"},
+ }
+
+ hass.states.async_set("light.a", "off")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
+
+ hass.states.async_set("light.b", "on")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
+
+ hass.states.async_set("light.a", "on")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 3
+ assert specific_runs[2] == "on"
+
+
+async def test_track_template_result_iterator(hass):
+ """Test tracking template."""
+ iterator_runs = []
+
+ @ha.callback
+ def iterator_callback(event, updates):
+ iterator_runs.append(updates.pop().result)
+
+ async_track_template_result(
+ hass,
+ [
+ TrackTemplate(
+ Template(
+ """
+ {% for state in states.sensor %}
+ {% if state.state == 'on' %}
+ {{ state.entity_id }},
+ {% endif %}
+ {% endfor %}
+ """,
+ hass,
+ ),
+ None,
+ )
+ ],
+ iterator_callback,
+ )
+ await hass.async_block_till_done()
+
+ hass.states.async_set("sensor.test", 5)
+ await hass.async_block_till_done()
+
+ assert iterator_runs == [""]
+
+ filter_runs = []
+
+ @ha.callback
+ def filter_callback(event, updates):
+ filter_runs.append(updates.pop().result)
+
+ info = async_track_template_result(
+ hass,
+ [
+ TrackTemplate(
+ Template(
+ """{{ states.sensor|selectattr("state","equalto","on")
+ |join(",", attribute="entity_id") }}""",
+ hass,
+ ),
+ None,
+ )
+ ],
+ filter_callback,
+ )
+ await hass.async_block_till_done()
+ assert info.listeners == {
+ "all": False,
+ "domains": {"sensor"},
+ "entities": {"sensor.test"},
+ }
+
+ hass.states.async_set("sensor.test", 6)
+ await hass.async_block_till_done()
+
+ assert filter_runs == [""]
+ assert iterator_runs == [""]
+
+ hass.states.async_set("sensor.new", "on")
+ await hass.async_block_till_done()
+ assert iterator_runs == ["", "sensor.new,"]
+ assert filter_runs == ["", "sensor.new"]
+
+
+async def test_track_template_result_errors(hass, caplog):
+ """Test tracking template with errors in the template."""
+ template_syntax_error = Template("{{states.switch", hass)
+
+ template_not_exist = Template("{{states.switch.not_exist.state }}", hass)
+
+ syntax_error_runs = []
+ not_exist_runs = []
+
+ @ha.callback
+ def syntax_error_listener(event, updates):
+ track_result = updates.pop()
+ syntax_error_runs.append(
+ (
+ event,
+ track_result.template,
+ track_result.last_result,
+ track_result.result,
+ )
+ )
+
+ async_track_template_result(
+ hass, [TrackTemplate(template_syntax_error, None)], syntax_error_listener
+ )
+ await hass.async_block_till_done()
+
+ assert len(syntax_error_runs) == 0
+ assert "TemplateSyntaxError" in caplog.text
+
+ @ha.callback
+ def not_exist_runs_error_listener(event, updates):
+ template_track = updates.pop()
+ not_exist_runs.append(
+ (
+ event,
+ template_track.template,
+ template_track.last_result,
+ template_track.result,
+ )
+ )
+
+ async_track_template_result(
+ hass,
+ [TrackTemplate(template_not_exist, None)],
+ not_exist_runs_error_listener,
+ )
+ await hass.async_block_till_done()
+
+ assert len(syntax_error_runs) == 0
+ assert len(not_exist_runs) == 0
+
+ hass.states.async_set("switch.not_exist", "off")
+ await hass.async_block_till_done()
+
+ assert len(not_exist_runs) == 1
+ assert not_exist_runs[0][0].data.get("entity_id") == "switch.not_exist"
+ assert not_exist_runs[0][1] == template_not_exist
+ assert not_exist_runs[0][2] is None
+ assert not_exist_runs[0][3] == "off"
+
+ hass.states.async_set("switch.not_exist", "on")
+ await hass.async_block_till_done()
+
+ assert len(syntax_error_runs) == 1
+ assert len(not_exist_runs) == 2
+ assert not_exist_runs[1][0].data.get("entity_id") == "switch.not_exist"
+ assert not_exist_runs[1][1] == template_not_exist
+ assert not_exist_runs[1][2] == "off"
+ assert not_exist_runs[1][3] == "on"
+
+ with patch.object(Template, "async_render") as render:
+ render.side_effect = TemplateError(jinja2.TemplateError())
+
+ hass.states.async_set("switch.not_exist", "off")
+ await hass.async_block_till_done()
+
+ assert len(not_exist_runs) == 3
+ assert not_exist_runs[2][0].data.get("entity_id") == "switch.not_exist"
+ assert not_exist_runs[2][1] == template_not_exist
+ assert not_exist_runs[2][2] == "on"
+ assert isinstance(not_exist_runs[2][3], TemplateError)
+
+
+async def test_static_string(hass):
+ """Test a static string."""
+ template_refresh = Template("{{ 'static' }}", hass)
+
+ refresh_runs = []
+
+ @ha.callback
+ def refresh_listener(event, updates):
+ refresh_runs.append(updates.pop().result)
+
+ info = async_track_template_result(
+ hass, [TrackTemplate(template_refresh, None)], refresh_listener
+ )
+ await hass.async_block_till_done()
+ info.async_refresh()
+ await hass.async_block_till_done()
+
+ assert refresh_runs == ["static"]
+
+
+async def test_string(hass):
+ """Test a string."""
+ template_refresh = Template("no_template", hass)
+
+ refresh_runs = []
+
+ @ha.callback
+ def refresh_listener(event, updates):
+ refresh_runs.append(updates.pop().result)
+
+ info = async_track_template_result(
+ hass, [TrackTemplate(template_refresh, None)], refresh_listener
+ )
+ await hass.async_block_till_done()
+ info.async_refresh()
+ await hass.async_block_till_done()
+
+ assert refresh_runs == ["no_template"]
+
+
+async def test_track_template_result_refresh_cancel(hass):
+ """Test cancelling and refreshing result."""
+ template_refresh = Template("{{states.switch.test.state == 'on' and now() }}", hass)
+
+ refresh_runs = []
+
+ @ha.callback
+ def refresh_listener(event, updates):
+ refresh_runs.append(updates.pop().result)
+
+ info = async_track_template_result(
+ hass, [TrackTemplate(template_refresh, None)], refresh_listener
+ )
+ await hass.async_block_till_done()
+
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+
+ assert refresh_runs == ["False"]
+
+ assert len(refresh_runs) == 1
+
+ info.async_refresh()
+ hass.states.async_set("switch.test", "on")
+ await hass.async_block_till_done()
+
+ assert len(refresh_runs) == 2
+ assert refresh_runs[0] != refresh_runs[1]
+
+ info.async_remove()
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+
+ assert len(refresh_runs) == 2
+
+ template_refresh = Template("{{ value }}", hass)
+ refresh_runs = []
+
+ info = async_track_template_result(
+ hass,
+ [TrackTemplate(template_refresh, {"value": "duck"})],
+ refresh_listener,
+ )
+ await hass.async_block_till_done()
+ info.async_refresh()
+ await hass.async_block_till_done()
+
+ assert refresh_runs == ["duck"]
+
+ info.async_refresh()
+ await hass.async_block_till_done()
+ assert refresh_runs == ["duck"]
+
+
+async def test_async_track_template_result_multiple_templates(hass):
+ """Test tracking multiple templates."""
+
+ template_1 = Template("{{ states.switch.test.state == 'on' }}")
+ template_2 = Template("{{ states.switch.test.state == 'on' }}")
+ template_3 = Template("{{ states.switch.test.state == 'off' }}")
+ template_4 = Template(
+ "{{ states.binary_sensor | map(attribute='entity_id') | list }}"
)
- # Adding state to state machine
- hass.states.async_set("light.Bowl", "on")
- await hass.async_block_till_done()
- assert len(thread_runs) == 0
- assert len(callback_runs) == 0
- assert len(coroutine_runs) == 0
+ refresh_runs = []
- # change time to track and see if they trigger
- future = dt_util.utcnow() + period
- async_fire_time_changed(hass, future)
+ @ha.callback
+ def refresh_listener(event, updates):
+ refresh_runs.append(updates)
+
+ async_track_template_result(
+ hass,
+ [
+ TrackTemplate(template_1, None),
+ TrackTemplate(template_2, None),
+ TrackTemplate(template_3, None),
+ TrackTemplate(template_4, None),
+ ],
+ refresh_listener,
+ )
+
+ hass.states.async_set("switch.test", "on")
await hass.async_block_till_done()
- assert len(thread_runs) == 1
- assert len(callback_runs) == 1
- assert len(coroutine_runs) == 1
+
+ assert refresh_runs == [
+ [
+ TrackTemplateResult(template_1, None, "True"),
+ TrackTemplateResult(template_2, None, "True"),
+ TrackTemplateResult(template_3, None, "False"),
+ ]
+ ]
+
+ refresh_runs = []
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+
+ assert refresh_runs == [
+ [
+ TrackTemplateResult(template_1, "True", "False"),
+ TrackTemplateResult(template_2, "True", "False"),
+ TrackTemplateResult(template_3, "False", "True"),
+ ]
+ ]
+
+ refresh_runs = []
+ hass.states.async_set("binary_sensor.test", "off")
+ await hass.async_block_till_done()
+
+ assert refresh_runs == [
+ [TrackTemplateResult(template_4, None, "['binary_sensor.test']")]
+ ]
+
+
+async def test_async_track_template_result_multiple_templates_mixing_domain(hass):
+ """Test tracking multiple templates when tracking entities and an entire domain."""
+
+ template_1 = Template("{{ states.switch.test.state == 'on' }}")
+ template_2 = Template("{{ states.switch.test.state == 'on' }}")
+ template_3 = Template("{{ states.switch.test.state == 'off' }}")
+ template_4 = Template("{{ states.switch | map(attribute='entity_id') | list }}")
+
+ refresh_runs = []
+
+ @ha.callback
+ def refresh_listener(event, updates):
+ refresh_runs.append(updates)
+
+ async_track_template_result(
+ hass,
+ [
+ TrackTemplate(template_1, None),
+ TrackTemplate(template_2, None),
+ TrackTemplate(template_3, None),
+ TrackTemplate(template_4, None),
+ ],
+ refresh_listener,
+ )
+
+ hass.states.async_set("switch.test", "on")
+ await hass.async_block_till_done()
+
+ assert refresh_runs == [
+ [
+ TrackTemplateResult(template_1, None, "True"),
+ TrackTemplateResult(template_2, None, "True"),
+ TrackTemplateResult(template_3, None, "False"),
+ TrackTemplateResult(template_4, None, "['switch.test']"),
+ ]
+ ]
+
+ refresh_runs = []
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+
+ assert refresh_runs == [
+ [
+ TrackTemplateResult(template_1, "True", "False"),
+ TrackTemplateResult(template_2, "True", "False"),
+ TrackTemplateResult(template_3, "False", "True"),
+ ]
+ ]
+
+ refresh_runs = []
+ hass.states.async_set("binary_sensor.test", "off")
+ await hass.async_block_till_done()
+
+ assert refresh_runs == []
+
+ refresh_runs = []
+ hass.states.async_set("switch.new", "off")
+ await hass.async_block_till_done()
+
+ assert refresh_runs == [
+ [
+ TrackTemplateResult(
+ template_4, "['switch.test']", "['switch.new', 'switch.test']"
+ )
+ ]
+ ]
async def test_track_same_state_simple_no_trigger(hass):
diff --git a/tests/helpers/test_frame.py b/tests/helpers/test_frame.py
index 2e8c83c6517..6daae51403c 100644
--- a/tests/helpers/test_frame.py
+++ b/tests/helpers/test_frame.py
@@ -36,6 +36,43 @@ async def test_extract_frame_integration(caplog):
assert found_frame == correct_frame
+async def test_extract_frame_integration_with_excluded_intergration(caplog):
+ """Test extracting the current frame from integration context."""
+ correct_frame = Mock(
+ filename="/home/dev/homeassistant/components/mdns/light.py",
+ lineno="23",
+ line="self.light.is_on",
+ )
+ with patch(
+ "homeassistant.helpers.frame.extract_stack",
+ return_value=[
+ Mock(
+ filename="/home/dev/homeassistant/core.py",
+ lineno="23",
+ line="do_something()",
+ ),
+ correct_frame,
+ Mock(
+ filename="/home/dev/homeassistant/components/zeroconf/usage.py",
+ lineno="23",
+ line="self.light.is_on",
+ ),
+ Mock(
+ filename="/home/dev/mdns/lights.py",
+ lineno="2",
+ line="something()",
+ ),
+ ],
+ ):
+ found_frame, integration, path = frame.get_integration_frame(
+ exclude_integrations={"zeroconf"}
+ )
+
+ assert integration == "mdns"
+ assert path == "homeassistant/components/"
+ assert found_frame == correct_frame
+
+
async def test_extract_frame_no_integration(caplog):
"""Test extracting the current frame without integration context."""
with patch(
diff --git a/tests/helpers/test_location.py b/tests/helpers/test_location.py
index 2f99d42616f..219d015bdf7 100644
--- a/tests/helpers/test_location.py
+++ b/tests/helpers/test_location.py
@@ -62,7 +62,9 @@ async def test_coordinates_function_as_state(hass):
async def test_coordinates_function_device_tracker_in_zone(hass):
"""Test coordinates function."""
hass.states.async_set(
- "zone.home", "zoning", {"latitude": 32.87336, "longitude": -117.22943},
+ "zone.home",
+ "zoning",
+ {"latitude": 32.87336, "longitude": -117.22943},
)
hass.states.async_set("device_tracker.device", "home")
assert (
@@ -87,7 +89,8 @@ async def test_coordinates_function_device_tracker_from_input_select(hass):
def test_coordinates_function_returns_none_on_recursion(hass):
"""Test coordinates function."""
hass.states.async_set(
- "test.first", "test.second",
+ "test.first",
+ "test.second",
)
hass.states.async_set("test.second", "test.first")
assert location.find_coordinates(hass, "test.first") is None
@@ -96,7 +99,8 @@ def test_coordinates_function_returns_none_on_recursion(hass):
async def test_coordinates_function_returns_none_if_invalid_coord(hass):
"""Test test_coordinates function."""
hass.states.async_set(
- "test.object", "abc",
+ "test.object",
+ "abc",
)
assert location.find_coordinates(hass, "test.object") is None
diff --git a/tests/helpers/test_network.py b/tests/helpers/test_network.py
index f6665b054e7..ed97b3e3757 100644
--- a/tests/helpers/test_network.py
+++ b/tests/helpers/test_network.py
@@ -10,19 +10,25 @@ from homeassistant.helpers.network import (
_get_deprecated_base_url,
_get_external_url,
_get_internal_url,
+ _get_request_host,
get_url,
)
from tests.async_mock import Mock, patch
+from tests.common import mock_component
async def test_get_url_internal(hass: HomeAssistant):
"""Test getting an instance URL when the user has set an internal URL."""
assert hass.config.internal_url is None
+ with pytest.raises(NoURLAvailableError):
+ _get_internal_url(hass, require_current_request=True)
+
# Test with internal URL: http://example.local:8123
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:8123"},
+ hass,
+ {"internal_url": "http://example.local:8123"},
)
assert hass.config.internal_url == "http://example.local:8123"
@@ -35,9 +41,35 @@ async def test_get_url_internal(hass: HomeAssistant):
with pytest.raises(NoURLAvailableError):
_get_internal_url(hass, require_ssl=True)
+ with pytest.raises(NoURLAvailableError):
+ _get_internal_url(hass, require_current_request=True)
+
+ with patch(
+ "homeassistant.helpers.network._get_request_host", return_value="example.local"
+ ):
+ assert (
+ _get_internal_url(hass, require_current_request=True)
+ == "http://example.local:8123"
+ )
+
+ with pytest.raises(NoURLAvailableError):
+ _get_internal_url(
+ hass, require_current_request=True, require_standard_port=True
+ )
+
+ with pytest.raises(NoURLAvailableError):
+ _get_internal_url(hass, require_current_request=True, require_ssl=True)
+
+ with patch(
+ "homeassistant.helpers.network._get_request_host",
+ return_value="no_match.example.local",
+ ), pytest.raises(NoURLAvailableError):
+ _get_internal_url(hass, require_current_request=True)
+
# Test with internal URL: https://example.local:8123
await async_process_ha_core_config(
- hass, {"internal_url": "https://example.local:8123"},
+ hass,
+ {"internal_url": "https://example.local:8123"},
)
assert hass.config.internal_url == "https://example.local:8123"
@@ -50,7 +82,8 @@ async def test_get_url_internal(hass: HomeAssistant):
# Test with internal URL: http://example.local:80/
await async_process_ha_core_config(
- hass, {"internal_url": "http://example.local:80/"},
+ hass,
+ {"internal_url": "http://example.local:80/"},
)
assert hass.config.internal_url == "http://example.local:80/"
@@ -63,7 +96,8 @@ async def test_get_url_internal(hass: HomeAssistant):
# Test with internal URL: https://example.local:443
await async_process_ha_core_config(
- hass, {"internal_url": "https://example.local:443"},
+ hass,
+ {"internal_url": "https://example.local:443"},
)
assert hass.config.internal_url == "https://example.local:443"
@@ -76,7 +110,8 @@ async def test_get_url_internal(hass: HomeAssistant):
# Test with internal URL: https://192.168.0.1
await async_process_ha_core_config(
- hass, {"internal_url": "https://192.168.0.1"},
+ hass,
+ {"internal_url": "https://192.168.0.1"},
)
assert hass.config.internal_url == "https://192.168.0.1"
@@ -89,7 +124,8 @@ async def test_get_url_internal(hass: HomeAssistant):
# Test with internal URL: http://192.168.0.1:8123
await async_process_ha_core_config(
- hass, {"internal_url": "http://192.168.0.1:8123"},
+ hass,
+ {"internal_url": "http://192.168.0.1:8123"},
)
assert hass.config.internal_url == "http://192.168.0.1:8123"
@@ -104,6 +140,25 @@ async def test_get_url_internal(hass: HomeAssistant):
with pytest.raises(NoURLAvailableError):
_get_internal_url(hass, allow_ip=False)
+ with patch(
+ "homeassistant.helpers.network._get_request_host", return_value="192.168.0.1"
+ ):
+ assert (
+ _get_internal_url(hass, require_current_request=True)
+ == "http://192.168.0.1:8123"
+ )
+
+ with pytest.raises(NoURLAvailableError):
+ _get_internal_url(hass, require_current_request=True, allow_ip=False)
+
+ with pytest.raises(NoURLAvailableError):
+ _get_internal_url(
+ hass, require_current_request=True, require_standard_port=True
+ )
+
+ with pytest.raises(NoURLAvailableError):
+ _get_internal_url(hass, require_current_request=True, require_ssl=True)
+
async def test_get_url_internal_fallback(hass: HomeAssistant):
"""Test getting an instance URL when the user has not set an internal URL."""
@@ -171,9 +226,13 @@ async def test_get_url_external(hass: HomeAssistant):
"""Test getting an instance URL when the user has set an external URL."""
assert hass.config.external_url is None
+ with pytest.raises(NoURLAvailableError):
+ _get_external_url(hass, require_current_request=True)
+
# Test with external URL: http://example.com:8123
await async_process_ha_core_config(
- hass, {"external_url": "http://example.com:8123"},
+ hass,
+ {"external_url": "http://example.com:8123"},
)
assert hass.config.external_url == "http://example.com:8123"
@@ -188,9 +247,35 @@ async def test_get_url_external(hass: HomeAssistant):
with pytest.raises(NoURLAvailableError):
_get_external_url(hass, require_ssl=True)
+ with pytest.raises(NoURLAvailableError):
+ _get_external_url(hass, require_current_request=True)
+
+ with patch(
+ "homeassistant.helpers.network._get_request_host", return_value="example.com"
+ ):
+ assert (
+ _get_external_url(hass, require_current_request=True)
+ == "http://example.com:8123"
+ )
+
+ with pytest.raises(NoURLAvailableError):
+ _get_external_url(
+ hass, require_current_request=True, require_standard_port=True
+ )
+
+ with pytest.raises(NoURLAvailableError):
+ _get_external_url(hass, require_current_request=True, require_ssl=True)
+
+ with patch(
+ "homeassistant.helpers.network._get_request_host",
+ return_value="no_match.example.com",
+ ), pytest.raises(NoURLAvailableError):
+ _get_external_url(hass, require_current_request=True)
+
# Test with external URL: http://example.com:80/
await async_process_ha_core_config(
- hass, {"external_url": "http://example.com:80/"},
+ hass,
+ {"external_url": "http://example.com:80/"},
)
assert hass.config.external_url == "http://example.com:80/"
@@ -205,7 +290,8 @@ async def test_get_url_external(hass: HomeAssistant):
# Test with external url: https://example.com:443/
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com:443/"},
+ hass,
+ {"external_url": "https://example.com:443/"},
)
assert hass.config.external_url == "https://example.com:443/"
assert _get_external_url(hass) == "https://example.com"
@@ -217,7 +303,8 @@ async def test_get_url_external(hass: HomeAssistant):
# Test with external URL: https://example.com:80
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com:80"},
+ hass,
+ {"external_url": "https://example.com:80"},
)
assert hass.config.external_url == "https://example.com:80"
assert _get_external_url(hass) == "https://example.com:80"
@@ -231,7 +318,8 @@ async def test_get_url_external(hass: HomeAssistant):
# Test with external URL: https://192.168.0.1
await async_process_ha_core_config(
- hass, {"external_url": "https://192.168.0.1"},
+ hass,
+ {"external_url": "https://192.168.0.1"},
)
assert hass.config.external_url == "https://192.168.0.1"
assert _get_external_url(hass) == "https://192.168.0.1"
@@ -245,6 +333,20 @@ async def test_get_url_external(hass: HomeAssistant):
with pytest.raises(NoURLAvailableError):
_get_external_url(hass, require_ssl=True)
+ with patch(
+ "homeassistant.helpers.network._get_request_host", return_value="192.168.0.1"
+ ):
+ assert (
+ _get_external_url(hass, require_current_request=True)
+ == "https://192.168.0.1"
+ )
+
+ with pytest.raises(NoURLAvailableError):
+ _get_external_url(hass, require_current_request=True, allow_ip=False)
+
+ with pytest.raises(NoURLAvailableError):
+ _get_external_url(hass, require_current_request=True, require_ssl=True)
+
async def test_get_cloud_url(hass: HomeAssistant):
"""Test getting an instance URL when the user has set an external URL."""
@@ -258,6 +360,24 @@ async def test_get_cloud_url(hass: HomeAssistant):
):
assert _get_cloud_url(hass) == "https://example.nabu.casa"
+ with pytest.raises(NoURLAvailableError):
+ _get_cloud_url(hass, require_current_request=True)
+
+ with patch(
+ "homeassistant.helpers.network._get_request_host",
+ return_value="example.nabu.casa",
+ ):
+ assert (
+ _get_cloud_url(hass, require_current_request=True)
+ == "https://example.nabu.casa"
+ )
+
+ with patch(
+ "homeassistant.helpers.network._get_request_host",
+ return_value="no_match.nabu.casa",
+ ), pytest.raises(NoURLAvailableError):
+ _get_cloud_url(hass, require_current_request=True)
+
with patch.object(
hass.components.cloud,
"async_remote_ui_url",
@@ -273,7 +393,8 @@ async def test_get_external_url_cloud_fallback(hass: HomeAssistant):
# Test with external URL: http://1.1.1.1:8123
await async_process_ha_core_config(
- hass, {"external_url": "http://1.1.1.1:8123"},
+ hass,
+ {"external_url": "http://1.1.1.1:8123"},
)
assert hass.config.external_url == "http://1.1.1.1:8123"
@@ -298,7 +419,8 @@ async def test_get_external_url_cloud_fallback(hass: HomeAssistant):
# Test with external URL: https://example.com
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com"},
+ hass,
+ {"external_url": "https://example.com"},
)
assert hass.config.external_url == "https://example.com"
@@ -345,7 +467,8 @@ async def test_get_url(hass: HomeAssistant):
# Test only external
hass.config.api = None
await async_process_ha_core_config(
- hass, {"external_url": "https://example.com"},
+ hass,
+ {"external_url": "https://example.com"},
)
assert hass.config.external_url == "https://example.com"
assert hass.config.internal_url is None
@@ -372,6 +495,51 @@ async def test_get_url(hass: HomeAssistant):
with pytest.raises(NoURLAvailableError):
get_url(hass, allow_external=False, allow_internal=False)
+ with pytest.raises(NoURLAvailableError):
+ get_url(hass, require_current_request=True)
+
+ with patch(
+ "homeassistant.helpers.network._get_request_host", return_value="example.com"
+ ), patch("homeassistant.helpers.network.current_request"):
+ assert get_url(hass, require_current_request=True) == "https://example.com"
+ assert (
+ get_url(hass, require_current_request=True, require_ssl=True)
+ == "https://example.com"
+ )
+
+ with pytest.raises(NoURLAvailableError):
+ get_url(hass, require_current_request=True, allow_external=False)
+
+ with patch(
+ "homeassistant.helpers.network._get_request_host", return_value="example.local"
+ ), patch("homeassistant.helpers.network.current_request"):
+ assert get_url(hass, require_current_request=True) == "http://example.local"
+
+ with pytest.raises(NoURLAvailableError):
+ get_url(hass, require_current_request=True, allow_internal=False)
+
+ with pytest.raises(NoURLAvailableError):
+ get_url(hass, require_current_request=True, require_ssl=True)
+
+ with patch(
+ "homeassistant.helpers.network._get_request_host",
+ return_value="no_match.example.com",
+ ), pytest.raises(NoURLAvailableError):
+ _get_internal_url(hass, require_current_request=True)
+
+
+async def test_get_request_host(hass: HomeAssistant):
+ """Test getting the host of the current web request from the request context."""
+ with pytest.raises(NoURLAvailableError):
+ _get_request_host()
+
+ with patch("homeassistant.helpers.network.current_request") as mock_request_context:
+ mock_request = Mock()
+ mock_request.url = "http://example.com:8123/test/request"
+ mock_request_context.get = Mock(return_value=mock_request)
+
+ assert _get_request_host() == "example.com"
+
async def test_get_deprecated_base_url_internal(hass: HomeAssistant):
"""Test getting an internal instance URL from the deprecated base_url."""
@@ -550,7 +718,8 @@ async def test_get_internal_url_with_base_url_fallback(hass: HomeAssistant):
# Add internal URL
await async_process_ha_core_config(
- hass, {"internal_url": "https://internal.local"},
+ hass,
+ {"internal_url": "https://internal.local"},
)
assert _get_internal_url(hass) == "https://internal.local"
assert _get_internal_url(hass, allow_ip=False) == "https://internal.local"
@@ -561,7 +730,8 @@ async def test_get_internal_url_with_base_url_fallback(hass: HomeAssistant):
# Add internal URL, mixed results
await async_process_ha_core_config(
- hass, {"internal_url": "http://internal.local:8123"},
+ hass,
+ {"internal_url": "http://internal.local:8123"},
)
assert _get_internal_url(hass) == "http://internal.local:8123"
assert _get_internal_url(hass, allow_ip=False) == "http://internal.local:8123"
@@ -572,7 +742,8 @@ async def test_get_internal_url_with_base_url_fallback(hass: HomeAssistant):
# Add internal URL set to an IP
await async_process_ha_core_config(
- hass, {"internal_url": "http://10.10.10.10:8123"},
+ hass,
+ {"internal_url": "http://10.10.10.10:8123"},
)
assert _get_internal_url(hass) == "http://10.10.10.10:8123"
assert _get_internal_url(hass, allow_ip=False) == "https://example.local"
@@ -599,7 +770,8 @@ async def test_get_external_url_with_base_url_fallback(hass: HomeAssistant):
# Add external URL
await async_process_ha_core_config(
- hass, {"external_url": "https://external.example.com"},
+ hass,
+ {"external_url": "https://external.example.com"},
)
assert _get_external_url(hass) == "https://external.example.com"
assert _get_external_url(hass, allow_ip=False) == "https://external.example.com"
@@ -611,7 +783,8 @@ async def test_get_external_url_with_base_url_fallback(hass: HomeAssistant):
# Add external URL, mixed results
await async_process_ha_core_config(
- hass, {"external_url": "http://external.example.com:8123"},
+ hass,
+ {"external_url": "http://external.example.com:8123"},
)
assert _get_external_url(hass) == "http://external.example.com:8123"
assert _get_external_url(hass, allow_ip=False) == "http://external.example.com:8123"
@@ -620,9 +793,62 @@ async def test_get_external_url_with_base_url_fallback(hass: HomeAssistant):
# Add external URL set to an IP
await async_process_ha_core_config(
- hass, {"external_url": "http://1.1.1.1:8123"},
+ hass,
+ {"external_url": "http://1.1.1.1:8123"},
)
assert _get_external_url(hass) == "http://1.1.1.1:8123"
assert _get_external_url(hass, allow_ip=False) == "https://example.com"
assert _get_external_url(hass, require_standard_port=True) == "https://example.com"
assert _get_external_url(hass, require_ssl=True) == "https://example.com"
+
+
+async def test_get_current_request_url_with_known_host(
+ hass: HomeAssistant, current_request
+):
+ """Test getting current request URL with known hosts addresses."""
+ hass.config.api = Mock(
+ use_ssl=False, port=8123, local_ip="127.0.0.1", deprecated_base_url=None
+ )
+ assert hass.config.internal_url is None
+
+ with pytest.raises(NoURLAvailableError):
+ get_url(hass, require_current_request=True)
+
+ # Ensure we accept localhost
+ with patch(
+ "homeassistant.helpers.network._get_request_host", return_value="localhost"
+ ):
+ assert get_url(hass, require_current_request=True) == "http://localhost:8123"
+ with pytest.raises(NoURLAvailableError):
+ get_url(hass, require_current_request=True, require_ssl=True)
+ with pytest.raises(NoURLAvailableError):
+ get_url(hass, require_current_request=True, require_standard_port=True)
+
+ # Ensure we accept local loopback ip (e.g., 127.0.0.1)
+ with patch(
+ "homeassistant.helpers.network._get_request_host", return_value="127.0.0.8"
+ ):
+ assert get_url(hass, require_current_request=True) == "http://127.0.0.8:8123"
+ with pytest.raises(NoURLAvailableError):
+ get_url(hass, require_current_request=True, allow_ip=False)
+
+ # Ensure hostname from Supervisor is accepted transparently
+ mock_component(hass, "hassio")
+ hass.components.hassio.is_hassio = Mock(return_value=True)
+ hass.components.hassio.get_host_info = Mock(
+ return_value={"hostname": "homeassistant"}
+ )
+
+ with patch(
+ "homeassistant.helpers.network._get_request_host",
+ return_value="homeassistant.local",
+ ):
+ assert (
+ get_url(hass, require_current_request=True)
+ == "http://homeassistant.local:8123"
+ )
+
+ with patch(
+ "homeassistant.helpers.network._get_request_host", return_value="unknown.local"
+ ), pytest.raises(NoURLAvailableError):
+ get_url(hass, require_current_request=True)
diff --git a/tests/helpers/test_reload.py b/tests/helpers/test_reload.py
new file mode 100644
index 00000000000..25844151533
--- /dev/null
+++ b/tests/helpers/test_reload.py
@@ -0,0 +1,242 @@
+"""Tests for the reload helper."""
+import logging
+from os import path
+
+import pytest
+
+from homeassistant import config
+from homeassistant.const import SERVICE_RELOAD
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.reload import (
+ async_get_platform,
+ async_integration_yaml_config,
+ async_reload_integration_platforms,
+ async_setup_reload_service,
+)
+from homeassistant.loader import async_get_integration
+
+from tests.async_mock import AsyncMock, Mock, patch
+from tests.common import (
+ MockModule,
+ MockPlatform,
+ mock_entity_platform,
+ mock_integration,
+)
+
+_LOGGER = logging.getLogger(__name__)
+DOMAIN = "test_domain"
+PLATFORM = "test_platform"
+
+
+async def test_reload_platform(hass):
+ """Test the polling of only updated entities."""
+ component_setup = Mock(return_value=True)
+
+ setup_called = []
+
+ async def setup_platform(*args):
+ setup_called.append(args)
+
+ mock_integration(hass, MockModule(DOMAIN, setup=component_setup))
+ mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN]))
+
+ mock_platform = MockPlatform(async_setup_platform=setup_platform)
+ mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform)
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ await component.async_setup({DOMAIN: {"platform": PLATFORM, "sensors": None}})
+ await hass.async_block_till_done()
+ assert component_setup.called
+
+ assert f"{DOMAIN}.{PLATFORM}" in hass.config.components
+ assert len(setup_called) == 1
+
+ platform = async_get_platform(hass, PLATFORM, DOMAIN)
+ assert platform.platform_name == PLATFORM
+ assert platform.domain == DOMAIN
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "helpers/reload_configuration.yaml",
+ )
+ with patch.object(config, "YAML_CONFIG_FILE", yaml_path):
+ await async_reload_integration_platforms(hass, PLATFORM, [DOMAIN])
+
+ assert len(setup_called) == 2
+
+
+async def test_setup_reload_service(hass):
+ """Test setting up a reload service."""
+ component_setup = Mock(return_value=True)
+
+ setup_called = []
+
+ async def setup_platform(*args):
+ setup_called.append(args)
+
+ mock_integration(hass, MockModule(DOMAIN, setup=component_setup))
+ mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN]))
+
+ mock_platform = MockPlatform(async_setup_platform=setup_platform)
+ mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform)
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ await component.async_setup({DOMAIN: {"platform": PLATFORM, "sensors": None}})
+ await hass.async_block_till_done()
+ assert component_setup.called
+
+ assert f"{DOMAIN}.{PLATFORM}" in hass.config.components
+ assert len(setup_called) == 1
+
+ await async_setup_reload_service(hass, PLATFORM, [DOMAIN])
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "helpers/reload_configuration.yaml",
+ )
+ with patch.object(config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ PLATFORM,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(setup_called) == 2
+
+
+async def test_setup_reload_service_when_async_process_component_config_fails(hass):
+ """Test setting up a reload service with the config processing failing."""
+ component_setup = Mock(return_value=True)
+
+ setup_called = []
+
+ async def setup_platform(*args):
+ setup_called.append(args)
+
+ mock_integration(hass, MockModule(DOMAIN, setup=component_setup))
+ mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN]))
+
+ mock_platform = MockPlatform(async_setup_platform=setup_platform)
+ mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform)
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ await component.async_setup({DOMAIN: {"platform": PLATFORM, "sensors": None}})
+ await hass.async_block_till_done()
+ assert component_setup.called
+
+ assert f"{DOMAIN}.{PLATFORM}" in hass.config.components
+ assert len(setup_called) == 1
+
+ await async_setup_reload_service(hass, PLATFORM, [DOMAIN])
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "helpers/reload_configuration.yaml",
+ )
+ with patch.object(config, "YAML_CONFIG_FILE", yaml_path), patch.object(
+ config, "async_process_component_config", return_value=None
+ ):
+ await hass.services.async_call(
+ PLATFORM,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(setup_called) == 1
+
+
+async def test_setup_reload_service_with_platform_that_provides_async_reset_platform(
+ hass,
+):
+ """Test setting up a reload service using a platform that has its own async_reset_platform."""
+ component_setup = AsyncMock(return_value=True)
+
+ setup_called = []
+ async_reset_platform_called = []
+
+ async def setup_platform(*args):
+ setup_called.append(args)
+
+ async def async_reset_platform(*args):
+ async_reset_platform_called.append(args)
+
+ mock_integration(hass, MockModule(DOMAIN, async_setup=component_setup))
+ integration = await async_get_integration(hass, DOMAIN)
+ integration.get_component().async_reset_platform = async_reset_platform
+
+ mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN]))
+
+ mock_platform = MockPlatform(async_setup_platform=setup_platform)
+ mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform)
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ await component.async_setup({DOMAIN: {"platform": PLATFORM, "name": "xyz"}})
+ await hass.async_block_till_done()
+ assert component_setup.called
+
+ assert f"{DOMAIN}.{PLATFORM}" in hass.config.components
+ assert len(setup_called) == 1
+
+ await async_setup_reload_service(hass, PLATFORM, [DOMAIN])
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "helpers/reload_configuration.yaml",
+ )
+ with patch.object(config, "YAML_CONFIG_FILE", yaml_path):
+ await hass.services.async_call(
+ PLATFORM,
+ SERVICE_RELOAD,
+ {},
+ blocking=True,
+ )
+ await hass.async_block_till_done()
+
+ assert len(setup_called) == 1
+ assert len(async_reset_platform_called) == 1
+
+
+async def test_async_integration_yaml_config(hass):
+ """Test loading yaml config for an integration."""
+ mock_integration(hass, MockModule(DOMAIN))
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ f"helpers/{DOMAIN}_configuration.yaml",
+ )
+ with patch.object(config, "YAML_CONFIG_FILE", yaml_path):
+ processed_config = await async_integration_yaml_config(hass, DOMAIN)
+
+ assert processed_config == {DOMAIN: [{"name": "one"}, {"name": "two"}]}
+
+
+async def test_async_integration_missing_yaml_config(hass):
+ """Test loading missing yaml config for an integration."""
+ mock_integration(hass, MockModule(DOMAIN))
+
+ yaml_path = path.join(
+ _get_fixtures_base_path(),
+ "fixtures",
+ "helpers/does_not_exist_configuration.yaml",
+ )
+ with pytest.raises(FileNotFoundError), patch.object(
+ config, "YAML_CONFIG_FILE", yaml_path
+ ):
+ await async_integration_yaml_config(hass, DOMAIN)
+
+
+def _get_fixtures_base_path():
+ return path.dirname(path.dirname(__file__))
diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py
index 7866662266d..15eed1c7e19 100644
--- a/tests/helpers/test_restore_state.py
+++ b/tests/helpers/test_restore_state.py
@@ -27,6 +27,7 @@ async def test_caching_data(hass):
]
data = await RestoreStateData.async_get_instance(hass)
+ await hass.async_block_till_done()
await data.store.async_save([state.as_dict() for state in stored_states])
# Emulate a fresh load
@@ -41,6 +42,7 @@ async def test_caching_data(hass):
"homeassistant.helpers.restore_state.Store.async_save"
) as mock_write_data:
state = await entity.async_get_last_state()
+ await hass.async_block_till_done()
assert state is not None
assert state.entity_id == "input_boolean.b1"
@@ -61,6 +63,7 @@ async def test_hass_starting(hass):
]
data = await RestoreStateData.async_get_instance(hass)
+ await hass.async_block_till_done()
await data.store.async_save([state.as_dict() for state in stored_states])
# Emulate a fresh load
@@ -76,6 +79,7 @@ async def test_hass_starting(hass):
"homeassistant.helpers.restore_state.Store.async_save"
) as mock_write_data, patch.object(hass.states, "async_all", return_value=states):
state = await entity.async_get_last_state()
+ await hass.async_block_till_done()
assert state is not None
assert state.entity_id == "input_boolean.b1"
diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py
index fbfd06aa930..0bd353e1fa0 100644
--- a/tests/helpers/test_script.py
+++ b/tests/helpers/test_script.py
@@ -16,7 +16,6 @@ import homeassistant.components.scene as scene
from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON
from homeassistant.core import Context, CoreState, callback
from homeassistant.helpers import config_validation as cv, script
-from homeassistant.helpers.event import async_call_later
import homeassistant.util.dt as dt_util
from tests.async_mock import patch
@@ -29,49 +28,6 @@ from tests.common import (
ENTITY_ID = "script.test"
-@pytest.fixture
-def mock_timeout(hass, monkeypatch):
- """Mock async_timeout.timeout."""
-
- class MockTimeout:
- def __init__(self, timeout):
- self._timeout = timeout
- self._loop = asyncio.get_event_loop()
- self._task = None
- self._cancelled = False
- self._unsub = None
-
- async def __aenter__(self):
- if self._timeout is None:
- return self
- self._task = asyncio.Task.current_task()
- if self._timeout <= 0:
- self._loop.call_soon(self._cancel_task)
- return self
- # Wait for a time_changed event instead of real time passing.
- self._unsub = async_call_later(hass, self._timeout, self._cancel_task)
- return self
-
- async def __aexit__(self, exc_type, exc_val, exc_tb):
- if exc_type is asyncio.CancelledError and self._cancelled:
- self._unsub = None
- self._task = None
- raise asyncio.TimeoutError
- if self._timeout is not None and self._unsub:
- self._unsub()
- self._unsub = None
- self._task = None
- return None
-
- @callback
- def _cancel_task(self, now=None):
- if self._task is not None:
- self._task.cancel()
- self._cancelled = True
-
- monkeypatch.setattr(script, "timeout", MockTimeout)
-
-
def async_watch_for_action(script_obj, message):
"""Watch for message in last_action."""
flag = asyncio.Event()
@@ -85,14 +41,16 @@ def async_watch_for_action(script_obj, message):
return flag
-async def test_firing_event_basic(hass):
+async def test_firing_event_basic(hass, caplog):
"""Test the firing of events."""
event = "test_event"
context = Context()
events = async_capture_events(hass, event)
sequence = cv.SCRIPT_SCHEMA({"event": event, "event_data": {"hello": "world"}})
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(
+ hass, sequence, "Test Name", "test_domain", running_description="test script"
+ )
await script_obj.async_run(context=context)
await hass.async_block_till_done()
@@ -100,6 +58,8 @@ async def test_firing_event_basic(hass):
assert len(events) == 1
assert events[0].context is context
assert events[0].data.get("hello") == "world"
+ assert ".test_name:" in caplog.text
+ assert "Test Name: Running test script" in caplog.text
async def test_firing_event_template(hass):
@@ -111,7 +71,7 @@ async def test_firing_event_template(hass):
sequence = cv.SCRIPT_SCHEMA(
{
"event": event,
- "event_data_template": {
+ "event_data": {
"dict": {
1: "{{ is_world }}",
2: "{{ is_world }}{{ is_world }}",
@@ -119,9 +79,17 @@ async def test_firing_event_template(hass):
},
"list": ["{{ is_world }}", "{{ is_world }}{{ is_world }}"],
},
+ "event_data_template": {
+ "dict2": {
+ 1: "{{ is_world }}",
+ 2: "{{ is_world }}{{ is_world }}",
+ 3: "{{ is_world }}{{ is_world }}{{ is_world }}",
+ },
+ "list2": ["{{ is_world }}", "{{ is_world }}{{ is_world }}"],
+ },
}
)
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
await script_obj.async_run(MappingProxyType({"is_world": "yes"}), context=context)
await hass.async_block_till_done()
@@ -131,6 +99,8 @@ async def test_firing_event_template(hass):
assert events[0].data == {
"dict": {1: "yes", 2: "yesyes", 3: "yesyesyes"},
"list": ["yes", "yesyes"],
+ "dict2": {1: "yes", 2: "yesyes", 3: "yesyesyes"},
+ "list2": ["yes", "yesyes"],
}
@@ -140,7 +110,7 @@ async def test_calling_service_basic(hass):
calls = async_mock_service(hass, "test", "script")
sequence = cv.SCRIPT_SCHEMA({"service": "test.script", "data": {"hello": "world"}})
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
await script_obj.async_run(context=context)
await hass.async_block_till_done()
@@ -174,7 +144,7 @@ async def test_calling_service_template(hass):
},
}
)
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
await script_obj.async_run(MappingProxyType({"is_world": "yes"}), context=context)
await hass.async_block_till_done()
@@ -184,6 +154,26 @@ async def test_calling_service_template(hass):
assert calls[0].data.get("hello") == "world"
+async def test_data_template_with_templated_key(hass):
+ """Test the calling of a service with a data_template with a templated key."""
+ context = Context()
+ calls = async_mock_service(hass, "test", "script")
+
+ sequence = cv.SCRIPT_SCHEMA(
+ {"service": "test.script", "data_template": {"{{ hello_var }}": "world"}}
+ )
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
+
+ await script_obj.async_run(
+ MappingProxyType({"hello_var": "hello"}), context=context
+ )
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].context is context
+ assert "hello" in calls[0].data
+
+
async def test_multiple_runs_no_wait(hass):
"""Test multiple runs with no wait in script."""
logger = logging.getLogger("TEST")
@@ -227,7 +217,9 @@ async def test_multiple_runs_no_wait(hass):
},
]
)
- script_obj = script.Script(hass, sequence, script_mode="parallel", max_runs=2)
+ script_obj = script.Script(
+ hass, sequence, "Test Name", "test_domain", script_mode="parallel", max_runs=2
+ )
# Start script twice in such a way that second run will be started while first run
# is in the middle of the first service call.
@@ -238,7 +230,8 @@ async def test_multiple_runs_no_wait(hass):
script_obj.async_run(
MappingProxyType(
{"fire1": "1", "listen1": "2", "fire2": "3", "listen2": "4"}
- )
+ ),
+ Context(),
)
)
await asyncio.wait_for(heard_event.wait(), 1)
@@ -246,7 +239,8 @@ async def test_multiple_runs_no_wait(hass):
logger.debug("starting 2nd script")
await script_obj.async_run(
- MappingProxyType({"fire1": "2", "listen1": "3", "fire2": "4", "listen2": "4"})
+ MappingProxyType({"fire1": "2", "listen1": "3", "fire2": "4", "listen2": "4"}),
+ Context(),
)
await hass.async_block_till_done()
@@ -259,7 +253,7 @@ async def test_activating_scene(hass):
calls = async_mock_service(hass, scene.DOMAIN, SERVICE_TURN_ON)
sequence = cv.SCRIPT_SCHEMA({"scene": "scene.hello"})
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
await script_obj.async_run(context=context)
await hass.async_block_till_done()
@@ -285,13 +279,20 @@ async def test_stop_no_wait(hass, count):
hass.services.async_register("test", "script", async_simulate_long_service)
sequence = cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": event}])
- script_obj = script.Script(hass, sequence, script_mode="parallel", max_runs=count)
+ script_obj = script.Script(
+ hass,
+ sequence,
+ "Test Name",
+ "test_domain",
+ script_mode="parallel",
+ max_runs=count,
+ )
# Get script started specified number of times and wait until the test.script
# service has started for each run.
tasks = []
for _ in range(count):
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
tasks.append(hass.async_create_task(service_started_sem.acquire()))
await asyncio.wait_for(asyncio.gather(*tasks), 1)
@@ -313,15 +314,15 @@ async def test_stop_no_wait(hass, count):
assert len(events) == 0
-async def test_delay_basic(hass, mock_timeout):
+async def test_delay_basic(hass):
"""Test the delay."""
delay_alias = "delay step"
sequence = cv.SCRIPT_SCHEMA({"delay": {"seconds": 5}, "alias": delay_alias})
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
delay_started_flag = async_watch_for_action(script_obj, delay_alias)
try:
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(delay_started_flag.wait(), 1)
assert script_obj.is_running
@@ -337,7 +338,7 @@ async def test_delay_basic(hass, mock_timeout):
assert script_obj.last_action is None
-async def test_multiple_runs_delay(hass, mock_timeout):
+async def test_multiple_runs_delay(hass):
"""Test multiple runs with delay in script."""
event = "test_event"
events = async_capture_events(hass, event)
@@ -349,11 +350,13 @@ async def test_multiple_runs_delay(hass, mock_timeout):
{"event": event, "event_data": {"value": 2}},
]
)
- script_obj = script.Script(hass, sequence, script_mode="parallel", max_runs=2)
+ script_obj = script.Script(
+ hass, sequence, "Test Name", "test_domain", script_mode="parallel", max_runs=2
+ )
delay_started_flag = async_watch_for_action(script_obj, "delay")
try:
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(delay_started_flag.wait(), 1)
assert script_obj.is_running
@@ -366,7 +369,7 @@ async def test_multiple_runs_delay(hass, mock_timeout):
# Start second run of script while first run is in a delay.
script_obj.sequence[1]["alias"] = "delay run 2"
delay_started_flag = async_watch_for_action(script_obj, "delay run 2")
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(delay_started_flag.wait(), 1)
async_fire_time_changed(hass, dt_util.utcnow() + delay)
await hass.async_block_till_done()
@@ -378,14 +381,14 @@ async def test_multiple_runs_delay(hass, mock_timeout):
assert events[-1].data["value"] == 2
-async def test_delay_template_ok(hass, mock_timeout):
+async def test_delay_template_ok(hass):
"""Test the delay as a template."""
sequence = cv.SCRIPT_SCHEMA({"delay": "00:00:{{ 5 }}"})
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
delay_started_flag = async_watch_for_action(script_obj, "delay")
try:
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(delay_started_flag.wait(), 1)
assert script_obj.is_running
@@ -411,10 +414,10 @@ async def test_delay_template_invalid(hass, caplog):
{"event": event},
]
)
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
start_idx = len(caplog.records)
- await script_obj.async_run()
+ await script_obj.async_run(context=Context())
await hass.async_block_till_done()
assert any(
@@ -426,14 +429,14 @@ async def test_delay_template_invalid(hass, caplog):
assert len(events) == 1
-async def test_delay_template_complex_ok(hass, mock_timeout):
+async def test_delay_template_complex_ok(hass):
"""Test the delay with a working complex template."""
sequence = cv.SCRIPT_SCHEMA({"delay": {"seconds": "{{ 5 }}"}})
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
delay_started_flag = async_watch_for_action(script_obj, "delay")
try:
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(delay_started_flag.wait(), 1)
assert script_obj.is_running
except (AssertionError, asyncio.TimeoutError):
@@ -458,10 +461,10 @@ async def test_delay_template_complex_invalid(hass, caplog):
{"event": event},
]
)
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
start_idx = len(caplog.records)
- await script_obj.async_run()
+ await script_obj.async_run(context=Context())
await hass.async_block_till_done()
assert any(
@@ -478,11 +481,11 @@ async def test_cancel_delay(hass):
event = "test_event"
events = async_capture_events(hass, event)
sequence = cv.SCRIPT_SCHEMA([{"delay": {"seconds": 5}}, {"event": event}])
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
delay_started_flag = async_watch_for_action(script_obj, "delay")
try:
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(delay_started_flag.wait(), 1)
assert script_obj.is_running
@@ -504,21 +507,26 @@ async def test_cancel_delay(hass):
assert len(events) == 0
-async def test_wait_template_basic(hass):
- """Test the wait template."""
+@pytest.mark.parametrize("action_type", ["template", "trigger"])
+async def test_wait_basic(hass, action_type):
+ """Test wait actions."""
wait_alias = "wait step"
- sequence = cv.SCRIPT_SCHEMA(
- {
- "wait_template": "{{ states.switch.test.state == 'off' }}",
- "alias": wait_alias,
+ action = {"alias": wait_alias}
+ if action_type == "template":
+ action["wait_template"] = "{{ states.switch.test.state == 'off' }}"
+ else:
+ action["wait_for_trigger"] = {
+ "platform": "state",
+ "entity_id": "switch.test",
+ "to": "off",
}
- )
- script_obj = script.Script(hass, sequence)
+ sequence = cv.SCRIPT_SCHEMA(action)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
wait_started_flag = async_watch_for_action(script_obj, wait_alias)
try:
hass.states.async_set("switch.test", "on")
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(wait_started_flag.wait(), 1)
assert script_obj.is_running
@@ -534,34 +542,50 @@ async def test_wait_template_basic(hass):
assert script_obj.last_action is None
-async def test_multiple_runs_wait_template(hass):
- """Test multiple runs with wait_template in script."""
+@pytest.mark.parametrize("action_type", ["template", "trigger"])
+async def test_multiple_runs_wait(hass, action_type):
+ """Test multiple runs with wait in script."""
event = "test_event"
events = async_capture_events(hass, event)
+ if action_type == "template":
+ action = {"wait_template": "{{ states.switch.test.state == 'off' }}"}
+ else:
+ action = {
+ "wait_for_trigger": {
+ "platform": "state",
+ "entity_id": "switch.test",
+ "to": "off",
+ }
+ }
sequence = cv.SCRIPT_SCHEMA(
[
{"event": event, "event_data": {"value": 1}},
- {"wait_template": "{{ states.switch.test.state == 'off' }}"},
+ action,
{"event": event, "event_data": {"value": 2}},
]
)
- script_obj = script.Script(hass, sequence, script_mode="parallel", max_runs=2)
+ script_obj = script.Script(
+ hass, sequence, "Test Name", "test_domain", script_mode="parallel", max_runs=2
+ )
wait_started_flag = async_watch_for_action(script_obj, "wait")
try:
hass.states.async_set("switch.test", "on")
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(wait_started_flag.wait(), 1)
assert script_obj.is_running
assert len(events) == 1
assert events[-1].data["value"] == 1
+
+ # Start second run of script while first run is in wait_template.
+ wait_started_flag.clear()
+ hass.async_create_task(script_obj.async_run())
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
except (AssertionError, asyncio.TimeoutError):
await script_obj.async_stop()
raise
else:
- # Start second run of script while first run is in wait_template.
- hass.async_create_task(script_obj.async_run())
hass.states.async_set("switch.test", "off")
await hass.async_block_till_done()
@@ -572,22 +596,28 @@ async def test_multiple_runs_wait_template(hass):
assert events[-1].data["value"] == 2
-async def test_cancel_wait_template(hass):
- """Test the cancelling while wait_template is present."""
+@pytest.mark.parametrize("action_type", ["template", "trigger"])
+async def test_cancel_wait(hass, action_type):
+ """Test the cancelling while wait is present."""
event = "test_event"
events = async_capture_events(hass, event)
- sequence = cv.SCRIPT_SCHEMA(
- [
- {"wait_template": "{{ states.switch.test.state == 'off' }}"},
- {"event": event},
- ]
- )
- script_obj = script.Script(hass, sequence)
+ if action_type == "template":
+ action = {"wait_template": "{{ states.switch.test.state == 'off' }}"}
+ else:
+ action = {
+ "wait_for_trigger": {
+ "platform": "state",
+ "entity_id": "switch.test",
+ "to": "off",
+ }
+ }
+ sequence = cv.SCRIPT_SCHEMA([action, {"event": event}])
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
wait_started_flag = async_watch_for_action(script_obj, "wait")
try:
hass.states.async_set("switch.test", "on")
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(wait_started_flag.wait(), 1)
assert script_obj.is_running
@@ -620,10 +650,10 @@ async def test_wait_template_not_schedule(hass):
{"event": event},
]
)
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
hass.states.async_set("switch.test", "on")
- await script_obj.async_run()
+ await script_obj.async_run(context=Context())
await hass.async_block_till_done()
assert not script_obj.is_running
@@ -631,25 +661,84 @@ async def test_wait_template_not_schedule(hass):
@pytest.mark.parametrize(
- "continue_on_timeout,n_events", [(False, 0), (True, 1), (None, 1)]
+ "timeout_param", [5, "{{ 5 }}", {"seconds": 5}, {"seconds": "{{ 5 }}"}]
)
-async def test_wait_template_timeout(hass, mock_timeout, continue_on_timeout, n_events):
- """Test the wait template, halt on timeout."""
+@pytest.mark.parametrize("action_type", ["template", "trigger"])
+async def test_wait_timeout(hass, caplog, timeout_param, action_type):
+ """Test the wait timeout option."""
event = "test_event"
events = async_capture_events(hass, event)
- sequence = [
- {"wait_template": "{{ states.switch.test.state == 'off' }}", "timeout": 5},
- {"event": event},
- ]
- if continue_on_timeout is not None:
- sequence[0]["continue_on_timeout"] = continue_on_timeout
- sequence = cv.SCRIPT_SCHEMA(sequence)
- script_obj = script.Script(hass, sequence)
+ if action_type == "template":
+ action = {"wait_template": "{{ states.switch.test.state == 'off' }}"}
+ else:
+ action = {
+ "wait_for_trigger": {
+ "platform": "state",
+ "entity_id": "switch.test",
+ "to": "off",
+ }
+ }
+ action["timeout"] = timeout_param
+ action["continue_on_timeout"] = True
+ sequence = cv.SCRIPT_SCHEMA([action, {"event": event}])
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
wait_started_flag = async_watch_for_action(script_obj, "wait")
try:
hass.states.async_set("switch.test", "on")
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert len(events) == 0
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ cur_time = dt_util.utcnow()
+ async_fire_time_changed(hass, cur_time + timedelta(seconds=4))
+ await asyncio.sleep(0)
+
+ assert len(events) == 0
+
+ async_fire_time_changed(hass, cur_time + timedelta(seconds=5))
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(events) == 1
+ assert "(timeout: 0:00:05)" in caplog.text
+
+
+@pytest.mark.parametrize(
+ "continue_on_timeout,n_events", [(False, 0), (True, 1), (None, 1)]
+)
+@pytest.mark.parametrize("action_type", ["template", "trigger"])
+async def test_wait_continue_on_timeout(
+ hass, continue_on_timeout, n_events, action_type
+):
+ """Test the wait continue_on_timeout option."""
+ event = "test_event"
+ events = async_capture_events(hass, event)
+ if action_type == "template":
+ action = {"wait_template": "{{ states.switch.test.state == 'off' }}"}
+ else:
+ action = {
+ "wait_for_trigger": {
+ "platform": "state",
+ "entity_id": "switch.test",
+ "to": "off",
+ }
+ }
+ action["timeout"] = 5
+ if continue_on_timeout is not None:
+ action["continue_on_timeout"] = continue_on_timeout
+ sequence = cv.SCRIPT_SCHEMA([action, {"event": event}])
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
+ wait_started_flag = async_watch_for_action(script_obj, "wait")
+
+ try:
+ hass.states.async_set("switch.test", "on")
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(wait_started_flag.wait(), 1)
assert script_obj.is_running
@@ -665,16 +754,16 @@ async def test_wait_template_timeout(hass, mock_timeout, continue_on_timeout, n_
assert len(events) == n_events
-async def test_wait_template_variables(hass):
- """Test the wait template with variables."""
+async def test_wait_template_variables_in(hass):
+ """Test the wait template with input variables."""
sequence = cv.SCRIPT_SCHEMA({"wait_template": "{{ is_state(data, 'off') }}"})
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
wait_started_flag = async_watch_for_action(script_obj, "wait")
try:
hass.states.async_set("switch.test", "on")
hass.async_create_task(
- script_obj.async_run(MappingProxyType({"data": "switch.test"}))
+ script_obj.async_run(MappingProxyType({"data": "switch.test"}), Context())
)
await asyncio.wait_for(wait_started_flag.wait(), 1)
@@ -689,6 +778,99 @@ async def test_wait_template_variables(hass):
assert not script_obj.is_running
+@pytest.mark.parametrize("mode", ["no_timeout", "timeout_finish", "timeout_not_finish"])
+@pytest.mark.parametrize("action_type", ["template", "trigger"])
+async def test_wait_variables_out(hass, mode, action_type):
+ """Test the wait output variable."""
+ event = "test_event"
+ events = async_capture_events(hass, event)
+ if action_type == "template":
+ action = {"wait_template": "{{ states.switch.test.state == 'off' }}"}
+ event_key = "completed"
+ else:
+ action = {
+ "wait_for_trigger": {
+ "platform": "state",
+ "entity_id": "switch.test",
+ "to": "off",
+ }
+ }
+ event_key = "trigger"
+ if mode != "no_timeout":
+ action["timeout"] = 5
+ action["continue_on_timeout"] = True
+ sequence = [
+ action,
+ {
+ "event": event,
+ "event_data_template": {
+ event_key: f"{{{{ wait.{event_key} }}}}",
+ "remaining": "{{ wait.remaining }}",
+ },
+ },
+ ]
+ sequence = cv.SCRIPT_SCHEMA(sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
+ wait_started_flag = async_watch_for_action(script_obj, "wait")
+
+ try:
+ hass.states.async_set("switch.test", "on")
+ hass.async_create_task(script_obj.async_run(context=Context()))
+ await asyncio.wait_for(wait_started_flag.wait(), 1)
+
+ assert script_obj.is_running
+ assert len(events) == 0
+ except (AssertionError, asyncio.TimeoutError):
+ await script_obj.async_stop()
+ raise
+ else:
+ if mode == "timeout_not_finish":
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5))
+ else:
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(events) == 1
+ if action_type == "template":
+ assert events[0].data["completed"] == str(mode != "timeout_not_finish")
+ elif mode != "timeout_not_finish":
+ assert "'to_state': default."""
max_runs = script.DEFAULT_MAX + 1
script_obj = script.Script(
- hass, cv.SCRIPT_SCHEMA(action), script_mode="parallel", max_runs=max_runs
+ hass,
+ cv.SCRIPT_SCHEMA(action),
+ "Test Name",
+ "test_domain",
+ script_mode="parallel",
+ max_runs=max_runs,
)
events = async_capture_events(hass, "abc")
for _ in range(max_runs):
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await hass.async_block_till_done()
assert "WARNING" not in caplog.text
@@ -1052,13 +1252,13 @@ async def test_last_triggered(hass):
"""Test the last_triggered."""
event = "test_event"
sequence = cv.SCRIPT_SCHEMA({"event": event})
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
assert script_obj.last_triggered is None
time = dt_util.utcnow()
with mock.patch("homeassistant.helpers.script.utcnow", return_value=time):
- await script_obj.async_run()
+ await script_obj.async_run(context=Context())
await hass.async_block_till_done()
assert script_obj.last_triggered == time
@@ -1069,10 +1269,10 @@ async def test_propagate_error_service_not_found(hass):
event = "test_event"
events = async_capture_events(hass, event)
sequence = cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": event}])
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
with pytest.raises(exceptions.ServiceNotFound):
- await script_obj.async_run()
+ await script_obj.async_run(context=Context())
assert len(events) == 0
assert not script_obj.is_running
@@ -1086,10 +1286,10 @@ async def test_propagate_error_invalid_service_data(hass):
sequence = cv.SCRIPT_SCHEMA(
[{"service": "test.script", "data": {"text": 1}}, {"event": event}]
)
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
with pytest.raises(vol.Invalid):
- await script_obj.async_run()
+ await script_obj.async_run(context=Context())
assert len(events) == 0
assert len(calls) == 0
@@ -1109,10 +1309,10 @@ async def test_propagate_error_service_exception(hass):
hass.services.async_register("test", "script", record_call)
sequence = cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": event}])
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
with pytest.raises(ValueError):
- await script_obj.async_run()
+ await script_obj.async_run(context=Context())
assert len(events) == 0
assert not script_obj.is_running
@@ -1143,6 +1343,8 @@ async def test_referenced_entities(hass):
{"delay": "{{ delay_period }}"},
]
),
+ "Test Name",
+ "test_domain",
)
assert script_obj.referenced_entities == {
"light.service_not_list",
@@ -1168,6 +1370,8 @@ async def test_referenced_devices(hass):
},
]
),
+ "Test Name",
+ "test_domain",
)
assert script_obj.referenced_devices == {"script-dev-id", "condition-dev-id"}
# Test we cache results.
@@ -1191,12 +1395,12 @@ async def test_script_mode_single(hass, caplog):
{"event": event, "event_data": {"value": 2}},
]
)
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
wait_started_flag = async_watch_for_action(script_obj, "wait")
try:
hass.states.async_set("switch.test", "on")
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(wait_started_flag.wait(), 1)
assert script_obj.is_running
@@ -1205,7 +1409,7 @@ async def test_script_mode_single(hass, caplog):
# Start second run of script while first run is suspended in wait_template.
- await script_obj.async_run()
+ await script_obj.async_run(context=Context())
assert "Already running" in caplog.text
assert script_obj.is_running
@@ -1221,6 +1425,60 @@ async def test_script_mode_single(hass, caplog):
assert events[1].data["value"] == 2
+@pytest.mark.parametrize("max_exceeded", [None, "WARNING", "INFO", "ERROR", "SILENT"])
+@pytest.mark.parametrize(
+ "script_mode,max_runs", [("single", 1), ("parallel", 2), ("queued", 2)]
+)
+async def test_max_exceeded(hass, caplog, max_exceeded, script_mode, max_runs):
+ """Test max_exceeded option."""
+ sequence = cv.SCRIPT_SCHEMA(
+ {"wait_template": "{{ states.switch.test.state == 'off' }}"}
+ )
+ if max_exceeded is None:
+ script_obj = script.Script(
+ hass,
+ sequence,
+ "Test Name",
+ "test_domain",
+ script_mode=script_mode,
+ max_runs=max_runs,
+ )
+ else:
+ script_obj = script.Script(
+ hass,
+ sequence,
+ "Test Name",
+ "test_domain",
+ script_mode=script_mode,
+ max_runs=max_runs,
+ max_exceeded=max_exceeded,
+ )
+ hass.states.async_set("switch.test", "on")
+ for _ in range(max_runs + 1):
+ hass.async_create_task(script_obj.async_run(context=Context()))
+ hass.states.async_set("switch.test", "off")
+ await hass.async_block_till_done()
+ if max_exceeded is None:
+ max_exceeded = "WARNING"
+ if max_exceeded == "SILENT":
+ assert not any(
+ any(
+ message in rec.message
+ for message in ("Already running", "Maximum number of runs exceeded")
+ )
+ for rec in caplog.records
+ )
+ else:
+ assert any(
+ rec.levelname == max_exceeded
+ and any(
+ message in rec.message
+ for message in ("Already running", "Maximum number of runs exceeded")
+ )
+ for rec in caplog.records
+ )
+
+
@pytest.mark.parametrize(
"script_mode,messages,last_events",
[("restart", ["Restarting"], [2]), ("parallel", [], [2, 2])],
@@ -1239,13 +1497,19 @@ async def test_script_mode_2(hass, caplog, script_mode, messages, last_events):
logger = logging.getLogger("TEST")
max_runs = 1 if script_mode == "restart" else 2
script_obj = script.Script(
- hass, sequence, script_mode=script_mode, max_runs=max_runs, logger=logger
+ hass,
+ sequence,
+ "Test Name",
+ "test_domain",
+ script_mode=script_mode,
+ max_runs=max_runs,
+ logger=logger,
)
wait_started_flag = async_watch_for_action(script_obj, "wait")
try:
hass.states.async_set("switch.test", "on")
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(wait_started_flag.wait(), 1)
assert script_obj.is_running
@@ -1255,7 +1519,7 @@ async def test_script_mode_2(hass, caplog, script_mode, messages, last_events):
# Start second run of script while first run is suspended in wait_template.
wait_started_flag.clear()
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(wait_started_flag.wait(), 1)
assert script_obj.is_running
@@ -1303,7 +1567,13 @@ async def test_script_mode_queued(hass):
)
logger = logging.getLogger("TEST")
script_obj = script.Script(
- hass, sequence, script_mode="queued", max_runs=2, logger=logger
+ hass,
+ sequence,
+ "Test Name",
+ "test_domain",
+ script_mode="queued",
+ max_runs=2,
+ logger=logger,
)
watch_messages = []
@@ -1325,7 +1595,7 @@ async def test_script_mode_queued(hass):
assert script_obj.runs == 0
hass.states.async_set("switch.test", "on")
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(wait_started_flag_1.wait(), 1)
assert script_obj.is_running
@@ -1336,7 +1606,7 @@ async def test_script_mode_queued(hass):
# Start second run of script while first run is suspended in wait_template.
# This second run should not start until the first run has finished.
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.sleep(0)
assert script_obj.is_running
@@ -1379,7 +1649,8 @@ async def test_script_mode_queued_cancel(hass):
script_obj = script.Script(
hass,
cv.SCRIPT_SCHEMA({"wait_template": "{{ false }}"}),
- "test",
+ "Test Name",
+ "test_domain",
script_mode="queued",
max_runs=2,
)
@@ -1389,9 +1660,9 @@ async def test_script_mode_queued_cancel(hass):
assert not script_obj.is_running
assert script_obj.runs == 0
- task1 = hass.async_create_task(script_obj.async_run())
+ task1 = hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(wait_started_flag.wait(), 1)
- task2 = hass.async_create_task(script_obj.async_run())
+ task2 = hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.sleep(0)
assert script_obj.is_running
@@ -1417,25 +1688,21 @@ async def test_script_mode_queued_cancel(hass):
async def test_script_logging(hass, caplog):
"""Test script logging."""
- script_obj = script.Script(hass, [], "Script with % Name")
+ script_obj = script.Script(hass, [], "Script with % Name", "test_domain")
script_obj._log("Test message with name %s", 1)
assert "Script with % Name: Test message with name 1" in caplog.text
- script_obj = script.Script(hass, [])
- script_obj._log("Test message without name %s", 2)
- assert "Test message without name 2" in caplog.text
-
async def test_shutdown_at(hass, caplog):
"""Test stopping scripts at shutdown."""
delay_alias = "delay step"
sequence = cv.SCRIPT_SCHEMA({"delay": {"seconds": 120}, "alias": delay_alias})
- script_obj = script.Script(hass, sequence, "test script")
+ script_obj = script.Script(hass, sequence, "test script", "test_domain")
delay_started_flag = async_watch_for_action(script_obj, delay_alias)
try:
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(delay_started_flag.wait(), 1)
assert script_obj.is_running
@@ -1455,7 +1722,7 @@ async def test_shutdown_after(hass, caplog):
"""Test stopping scripts at shutdown."""
delay_alias = "delay step"
sequence = cv.SCRIPT_SCHEMA({"delay": {"seconds": 120}, "alias": delay_alias})
- script_obj = script.Script(hass, sequence, "test script")
+ script_obj = script.Script(hass, sequence, "test script", "test_domain")
delay_started_flag = async_watch_for_action(script_obj, delay_alias)
hass.state = CoreState.stopping
@@ -1463,7 +1730,7 @@ async def test_shutdown_after(hass, caplog):
await hass.async_block_till_done()
try:
- hass.async_create_task(script_obj.async_run())
+ hass.async_create_task(script_obj.async_run(context=Context()))
await asyncio.wait_for(delay_started_flag.wait(), 1)
assert script_obj.is_running
@@ -1485,9 +1752,9 @@ async def test_shutdown_after(hass, caplog):
async def test_update_logger(hass, caplog):
"""Test updating logger."""
sequence = cv.SCRIPT_SCHEMA({"event": "test_event"})
- script_obj = script.Script(hass, sequence)
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
- await script_obj.async_run()
+ await script_obj.async_run(context=Context())
await hass.async_block_till_done()
assert script.__name__ in caplog.text
@@ -1495,7 +1762,65 @@ async def test_update_logger(hass, caplog):
log_name = "testing.123"
script_obj.update_logger(logging.getLogger(log_name))
- await script_obj.async_run()
+ await script_obj.async_run(context=Context())
await hass.async_block_till_done()
assert log_name in caplog.text
+
+
+async def test_started_action(hass, caplog):
+ """Test the callback of started_action."""
+ event = "test_event"
+ log_message = "The script started!"
+ logger = logging.getLogger("TEST")
+
+ sequence = cv.SCRIPT_SCHEMA({"event": event})
+ script_obj = script.Script(hass, sequence, "Test Name", "test_domain")
+
+ @callback
+ def started_action():
+ logger.info(log_message)
+
+ await script_obj.async_run(context=Context(), started_action=started_action)
+ await hass.async_block_till_done()
+
+ assert log_message in caplog.text
+
+
+async def test_set_variable(hass, caplog):
+ """Test setting variables in scripts."""
+ sequence = cv.SCRIPT_SCHEMA(
+ [
+ {"variables": {"variable": "value"}},
+ {"service": "test.script", "data": {"value": "{{ variable }}"}},
+ ]
+ )
+ script_obj = script.Script(hass, sequence, "test script", "test_domain")
+
+ mock_calls = async_mock_service(hass, "test", "script")
+
+ await script_obj.async_run(context=Context())
+ await hass.async_block_till_done()
+
+ assert mock_calls[0].data["value"] == "value"
+
+
+async def test_set_redefines_variable(hass, caplog):
+ """Test setting variables based on their current value."""
+ sequence = cv.SCRIPT_SCHEMA(
+ [
+ {"variables": {"variable": "1"}},
+ {"service": "test.script", "data": {"value": "{{ variable }}"}},
+ {"variables": {"variable": "{{ variable | int + 1 }}"}},
+ {"service": "test.script", "data": {"value": "{{ variable }}"}},
+ ]
+ )
+ script_obj = script.Script(hass, sequence, "test script", "test_domain")
+
+ mock_calls = async_mock_service(hass, "test", "script")
+
+ await script_obj.async_run(context=Context())
+ await hass.async_block_till_done()
+
+ assert mock_calls[0].data["value"] == "1"
+ assert mock_calls[1].data["value"] == "2"
diff --git a/tests/helpers/test_script_variables.py b/tests/helpers/test_script_variables.py
new file mode 100644
index 00000000000..20a70cb33eb
--- /dev/null
+++ b/tests/helpers/test_script_variables.py
@@ -0,0 +1,112 @@
+"""Test script variables."""
+import pytest
+
+from homeassistant.helpers import config_validation as cv, template
+
+
+async def test_static_vars():
+ """Test static vars."""
+ orig = {"hello": "world"}
+ var = cv.SCRIPT_VARIABLES_SCHEMA(orig)
+ rendered = var.async_render(None, None)
+ assert rendered is not orig
+ assert rendered == orig
+
+
+async def test_static_vars_run_args():
+ """Test static vars."""
+ orig = {"hello": "world"}
+ orig_copy = dict(orig)
+ var = cv.SCRIPT_VARIABLES_SCHEMA(orig)
+ rendered = var.async_render(None, {"hello": "override", "run": "var"})
+ assert rendered == {"hello": "override", "run": "var"}
+ # Make sure we don't change original vars
+ assert orig == orig_copy
+
+
+async def test_static_vars_no_default():
+ """Test static vars."""
+ orig = {"hello": "world"}
+ var = cv.SCRIPT_VARIABLES_SCHEMA(orig)
+ rendered = var.async_render(None, None, render_as_defaults=False)
+ assert rendered is not orig
+ assert rendered == orig
+
+
+async def test_static_vars_run_args_no_default():
+ """Test static vars."""
+ orig = {"hello": "world"}
+ orig_copy = dict(orig)
+ var = cv.SCRIPT_VARIABLES_SCHEMA(orig)
+ rendered = var.async_render(
+ None, {"hello": "override", "run": "var"}, render_as_defaults=False
+ )
+ assert rendered == {"hello": "world", "run": "var"}
+ # Make sure we don't change original vars
+ assert orig == orig_copy
+
+
+async def test_template_vars(hass):
+ """Test template vars."""
+ var = cv.SCRIPT_VARIABLES_SCHEMA({"hello": "{{ 1 + 1 }}"})
+ rendered = var.async_render(hass, None)
+ assert rendered == {"hello": "2"}
+
+
+async def test_template_vars_run_args(hass):
+ """Test template vars."""
+ var = cv.SCRIPT_VARIABLES_SCHEMA(
+ {
+ "something": "{{ run_var_ex + 1 }}",
+ "something_2": "{{ run_var_ex + 1 }}",
+ }
+ )
+ rendered = var.async_render(
+ hass,
+ {
+ "run_var_ex": 5,
+ "something_2": 1,
+ },
+ )
+ assert rendered == {
+ "run_var_ex": 5,
+ "something": "6",
+ "something_2": 1,
+ }
+
+
+async def test_template_vars_no_default(hass):
+ """Test template vars."""
+ var = cv.SCRIPT_VARIABLES_SCHEMA({"hello": "{{ 1 + 1 }}"})
+ rendered = var.async_render(hass, None, render_as_defaults=False)
+ assert rendered == {"hello": "2"}
+
+
+async def test_template_vars_run_args_no_default(hass):
+ """Test template vars."""
+ var = cv.SCRIPT_VARIABLES_SCHEMA(
+ {
+ "something": "{{ run_var_ex + 1 }}",
+ "something_2": "{{ run_var_ex + 1 }}",
+ }
+ )
+ rendered = var.async_render(
+ hass,
+ {
+ "run_var_ex": 5,
+ "something_2": 1,
+ },
+ render_as_defaults=False,
+ )
+ assert rendered == {
+ "run_var_ex": 5,
+ "something": "6",
+ "something_2": "6",
+ }
+
+
+async def test_template_vars_error(hass):
+ """Test template vars."""
+ var = cv.SCRIPT_VARIABLES_SCHEMA({"hello": "{{ canont.work }}"})
+ with pytest.raises(template.TemplateError):
+ var.async_render(hass, None)
diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py
index ba72cbc83ca..1a0cdb79bbc 100644
--- a/tests/helpers/test_service.py
+++ b/tests/helpers/test_service.py
@@ -44,7 +44,8 @@ SUPPORT_C = 4
def mock_handle_entity_call():
"""Mock service platform call."""
with patch(
- "homeassistant.helpers.service._handle_entity_call", return_value=None,
+ "homeassistant.helpers.service._handle_entity_call",
+ return_value=None,
) as mock_call:
yield mock_call
@@ -144,16 +145,16 @@ class TestServiceHelpers(unittest.TestCase):
"""Stop down everything that was started."""
self.hass.stop()
- def test_template_service_call(self):
+ def test_service_call(self):
"""Test service call with templating."""
config = {
- "service_template": "{{ 'test_domain.test_service' }}",
+ "service": "{{ 'test_domain.test_service' }}",
"entity_id": "hello.world",
- "data_template": {
+ "data": {
"hello": "{{ 'goodbye' }}",
"data": {"value": "{{ 'complex' }}", "simple": "simple"},
- "list": ["{{ 'list' }}", "2"],
},
+ "data_template": {"list": ["{{ 'list' }}", "2"]},
}
service.call_from_config(self.hass, config)
@@ -164,6 +165,19 @@ class TestServiceHelpers(unittest.TestCase):
assert self.calls[0].data["data"]["simple"] == "simple"
assert self.calls[0].data["list"][0] == "list"
+ def test_service_template_service_call(self):
+ """Test legacy service_template call with templating."""
+ config = {
+ "service_template": "{{ 'test_domain.test_service' }}",
+ "entity_id": "hello.world",
+ "data": {"hello": "goodbye"},
+ }
+
+ service.call_from_config(self.hass, config)
+ self.hass.block_till_done()
+
+ assert self.calls[0].data["hello"] == "goodbye"
+
def test_passing_variables_to_templates(self):
"""Test passing variables to templates."""
config = {
@@ -688,7 +702,9 @@ async def test_domain_control_unauthorized(hass, hass_read_only_user):
hass,
{
"light.kitchen": ent_reg.RegistryEntry(
- entity_id="light.kitchen", unique_id="kitchen", platform="test_domain",
+ entity_id="light.kitchen",
+ unique_id="kitchen",
+ platform="test_domain",
)
},
)
@@ -725,7 +741,9 @@ async def test_domain_control_admin(hass, hass_admin_user):
hass,
{
"light.kitchen": ent_reg.RegistryEntry(
- entity_id="light.kitchen", unique_id="kitchen", platform="test_domain",
+ entity_id="light.kitchen",
+ unique_id="kitchen",
+ platform="test_domain",
)
},
)
@@ -761,7 +779,9 @@ async def test_domain_control_no_user(hass):
hass,
{
"light.kitchen": ent_reg.RegistryEntry(
- entity_id="light.kitchen", unique_id="kitchen", platform="test_domain",
+ entity_id="light.kitchen",
+ unique_id="kitchen",
+ platform="test_domain",
)
},
)
@@ -822,7 +842,11 @@ async def test_extract_from_service_available_device(hass):
await service.async_extract_entities(
hass,
entities,
- ha.ServiceCall("test", "service", data={"entity_id": ENTITY_MATCH_NONE},),
+ ha.ServiceCall(
+ "test",
+ "service",
+ data={"entity_id": ENTITY_MATCH_NONE},
+ ),
)
== []
)
diff --git a/tests/helpers/test_state.py b/tests/helpers/test_state.py
index b19669d13f4..626f8a83744 100644
--- a/tests/helpers/test_state.py
+++ b/tests/helpers/test_state.py
@@ -68,7 +68,9 @@ async def test_call_to_component(hass):
context = "dummy_context"
await state.async_reproduce_state(
- hass, [state_media_player, state_climate], context=context,
+ hass,
+ [state_media_player, state_climate],
+ context=context,
)
media_player_fun.assert_called_once_with(
diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py
index 89486129760..c1f018b47d6 100644
--- a/tests/helpers/test_template.py
+++ b/tests/helpers/test_template.py
@@ -20,7 +20,14 @@ from homeassistant.helpers import template
import homeassistant.util.dt as dt_util
from homeassistant.util.unit_system import UnitSystem
-from tests.async_mock import patch
+from tests.async_mock import Mock, patch
+
+
+@pytest.fixture()
+def allow_extract_entities():
+ """Allow extract entities."""
+ with patch("homeassistant.helpers.template.report"):
+ yield
def _set_up_units(hass):
@@ -39,25 +46,22 @@ def render_to_info(hass, template_str, variables=None):
def extract_entities(hass, template_str, variables=None):
"""Extract entities from a template."""
info = render_to_info(hass, template_str, variables)
- # pylint: disable=protected-access
- assert not hasattr(info, "_domains")
- return info._entities
+ return info.entities
def assert_result_info(info, result, entities=None, domains=None, all_states=False):
"""Check result info."""
- assert info.result == result
- # pylint: disable=protected-access
- assert info._all_states == all_states
+ assert info.result() == result
+ assert info.all_states == all_states
assert info.filter_lifecycle("invalid_entity_name.somewhere") == all_states
if entities is not None:
- assert info._entities == frozenset(entities)
+ assert info.entities == frozenset(entities)
assert all([info.filter(entity) for entity in entities])
assert not info.filter("invalid_entity_name.somewhere")
else:
- assert not info._entities
+ assert not info.entities
if domains is not None:
- assert info._domains == frozenset(domains)
+ assert info.domains == frozenset(domains)
assert all([info.filter_lifecycle(domain + ".entity") for domain in domains])
else:
assert not hasattr(info, "_domains")
@@ -92,7 +96,7 @@ def test_invalid_template(hass):
info = tmpl.async_render_to_info()
with pytest.raises(TemplateError):
- assert info.result == "impossible"
+ assert info.result() == "impossible"
tmpl = template.Template("{{states(keyword)}}", hass)
@@ -507,6 +511,19 @@ def test_timestamp_local(hass):
)
+def test_as_local(hass):
+ """Test converting time to local."""
+
+ hass.states.async_set("test.object", "available")
+ last_updated = hass.states.get("test.object").last_updated
+ assert template.Template(
+ "{{ as_local(states.test.object.last_updated) }}", hass
+ ).async_render() == str(dt_util.as_local(last_updated))
+ assert template.Template(
+ "{{ states.test.object.last_updated | as_local }}", hass
+ ).async_render() == str(dt_util.as_local(last_updated))
+
+
def test_to_json(hass):
"""Test the object to JSON string filter."""
@@ -868,7 +885,10 @@ def test_relative_time(mock_is_safe, hass):
)
assert (
"string"
- == template.Template('{{relative_time("string")}}', hass,).async_render()
+ == template.Template(
+ '{{relative_time("string")}}',
+ hass,
+ ).async_render()
)
@@ -1256,7 +1276,7 @@ async def test_closest_function_home_vs_group_entity_id(hass):
info = render_to_info(hass, '{{ closest("group.location_group").entity_id }}')
assert_result_info(
- info, "test_domain.object", ["test_domain.object", "group.location_group"]
+ info, "test_domain.object", {"group.location_group", "test_domain.object"}
)
@@ -1281,12 +1301,12 @@ async def test_closest_function_home_vs_group_state(hass):
info = render_to_info(hass, '{{ closest("group.location_group").entity_id }}')
assert_result_info(
- info, "test_domain.object", ["test_domain.object", "group.location_group"]
+ info, "test_domain.object", {"group.location_group", "test_domain.object"}
)
info = render_to_info(hass, "{{ closest(states.group.location_group).entity_id }}")
assert_result_info(
- info, "test_domain.object", ["test_domain.object", "group.location_group"]
+ info, "test_domain.object", {"test_domain.object", "group.location_group"}
)
@@ -1303,7 +1323,7 @@ async def test_expand(hass):
info = render_to_info(
hass, "{{ expand('test.object') | map(attribute='entity_id') | join(', ') }}"
)
- assert_result_info(info, "test.object", [])
+ assert_result_info(info, "test.object", ["test.object"])
info = render_to_info(
hass,
@@ -1322,26 +1342,45 @@ async def test_expand(hass):
hass,
"{{ expand('group.new_group') | map(attribute='entity_id') | join(', ') }}",
)
- assert_result_info(info, "test.object", ["group.new_group"])
+ assert_result_info(info, "test.object", {"group.new_group", "test.object"})
info = render_to_info(
hass, "{{ expand(states.group) | map(attribute='entity_id') | join(', ') }}"
)
- assert_result_info(info, "test.object", ["group.new_group"], ["group"])
+ assert_result_info(
+ info, "test.object", {"test.object", "group.new_group"}, ["group"]
+ )
info = render_to_info(
hass,
"{{ expand('group.new_group', 'test.object')"
" | map(attribute='entity_id') | join(', ') }}",
)
- assert_result_info(info, "test.object", ["group.new_group"])
+ assert_result_info(info, "test.object", {"test.object", "group.new_group"})
info = render_to_info(
hass,
"{{ ['group.new_group', 'test.object'] | expand"
" | map(attribute='entity_id') | join(', ') }}",
)
- assert_result_info(info, "test.object", ["group.new_group"])
+ assert_result_info(info, "test.object", {"test.object", "group.new_group"})
+
+ hass.states.async_set("sensor.power_1", 0)
+ hass.states.async_set("sensor.power_2", 200.2)
+ hass.states.async_set("sensor.power_3", 400.4)
+ await group.Group.async_create_group(
+ hass, "power sensors", ["sensor.power_1", "sensor.power_2", "sensor.power_3"]
+ )
+
+ info = render_to_info(
+ hass,
+ "{{ states.group.power_sensors.attributes.entity_id | expand | map(attribute='state')|map('float')|sum }}",
+ )
+ assert_result_info(
+ info,
+ str(200.2 + 400.4),
+ {"group.power_sensors", "sensor.power_1", "sensor.power_2", "sensor.power_3"},
+ )
def test_closest_function_to_coord(hass):
@@ -1390,6 +1429,203 @@ def test_closest_function_to_coord(hass):
assert tpl.async_render() == "test_domain.closest_zone"
+def test_async_render_to_info_with_branching(hass):
+ """Test async_render_to_info function by domain."""
+ hass.states.async_set("light.a", "off")
+ hass.states.async_set("light.b", "on")
+ hass.states.async_set("light.c", "off")
+
+ info = render_to_info(
+ hass,
+ """
+{% if states.light.a == "on" %}
+ {{ states.light.b.state }}
+{% else %}
+ {{ states.light.c.state }}
+{% endif %}
+""",
+ )
+ assert_result_info(info, "off", {"light.a", "light.c"})
+
+ info = render_to_info(
+ hass,
+ """
+ {% if states.light.a.state == "off" %}
+ {% set domain = "light" %}
+ {{ states[domain].b.state }}
+ {% endif %}
+""",
+ )
+ assert_result_info(info, "on", {"light.a", "light.b"})
+
+
+def test_async_render_to_info_with_complex_branching(hass):
+ """Test async_render_to_info function by domain."""
+ hass.states.async_set("light.a", "off")
+ hass.states.async_set("light.b", "on")
+ hass.states.async_set("light.c", "off")
+ hass.states.async_set("vacuum.a", "off")
+ hass.states.async_set("device_tracker.a", "off")
+ hass.states.async_set("device_tracker.b", "off")
+ hass.states.async_set("lock.a", "off")
+ hass.states.async_set("sensor.a", "off")
+ hass.states.async_set("binary_sensor.a", "off")
+
+ info = render_to_info(
+ hass,
+ """
+{% set domain = "vacuum" %}
+{% if states.light.a == "on" %}
+ {{ states.light.b.state }}
+{% elif states.light.a == "on" %}
+ {{ states.device_tracker }}
+{% elif states.light.a == "on" %}
+ {{ states[domain] | list }}
+{% elif states('light.b') == "on" %}
+ {{ states[otherdomain] | map(attribute='entity_id') | list }}
+{% elif states.light.a == "on" %}
+ {{ states["nonexist"] | list }}
+{% else %}
+ else
+{% endif %}
+""",
+ {"otherdomain": "sensor"},
+ )
+
+ assert_result_info(info, "['sensor.a']", {"light.a", "light.b"}, {"sensor"})
+
+
+async def test_async_render_to_info_with_wildcard_matching_entity_id(hass):
+ """Test tracking template with a wildcard."""
+ template_complex_str = r"""
+
+{% for state in states %}
+ {% if state.entity_id | regex_match('.*\.office_') %}
+ {{ state.entity_id }}={{ state.state }}
+ {% endif %}
+{% endfor %}
+
+"""
+ hass.states.async_set("cover.office_drapes", "closed")
+ hass.states.async_set("cover.office_window", "closed")
+ hass.states.async_set("cover.office_skylight", "open")
+ info = render_to_info(hass, template_complex_str)
+
+ assert not info.domains
+ assert info.entities == {
+ "cover.office_drapes",
+ "cover.office_window",
+ "cover.office_skylight",
+ }
+ assert info.all_states is True
+
+
+async def test_async_render_to_info_with_wildcard_matching_state(hass):
+ """Test tracking template with a wildcard."""
+ template_complex_str = """
+
+{% for state in states %}
+ {% if state.state | regex_match('ope.*') %}
+ {{ state.entity_id }}={{ state.state }}
+ {% endif %}
+{% endfor %}
+
+"""
+ hass.states.async_set("cover.office_drapes", "closed")
+ hass.states.async_set("cover.office_window", "closed")
+ hass.states.async_set("cover.office_skylight", "open")
+ hass.states.async_set("cover.x_skylight", "open")
+ hass.states.async_set("binary_sensor.door", "open")
+
+ info = render_to_info(hass, template_complex_str)
+
+ assert not info.domains
+ assert info.entities == {
+ "cover.x_skylight",
+ "binary_sensor.door",
+ "cover.office_drapes",
+ "cover.office_window",
+ "cover.office_skylight",
+ }
+ assert info.all_states is True
+
+ hass.states.async_set("binary_sensor.door", "closed")
+ info = render_to_info(hass, template_complex_str)
+
+ assert not info.domains
+ assert info.entities == {
+ "cover.x_skylight",
+ "binary_sensor.door",
+ "cover.office_drapes",
+ "cover.office_window",
+ "cover.office_skylight",
+ }
+ assert info.all_states is True
+
+ template_cover_str = """
+
+{% for state in states.cover %}
+ {% if state.state | regex_match('ope.*') %}
+ {{ state.entity_id }}={{ state.state }}
+ {% endif %}
+{% endfor %}
+
+"""
+ hass.states.async_set("cover.x_skylight", "closed")
+ info = render_to_info(hass, template_cover_str)
+
+ assert info.domains == {"cover"}
+ assert info.entities == {
+ "cover.x_skylight",
+ "cover.office_drapes",
+ "cover.office_window",
+ "cover.office_skylight",
+ }
+ assert info.all_states is False
+
+
+def test_nested_async_render_to_info_case(hass):
+ """Test a deeply nested state with async_render_to_info."""
+
+ hass.states.async_set("input_select.picker", "vacuum.a")
+ hass.states.async_set("vacuum.a", "off")
+
+ info = render_to_info(
+ hass, "{{ states[states['input_select.picker'].state].state }}", {}
+ )
+ assert_result_info(info, "off", {"input_select.picker", "vacuum.a"})
+
+
+def test_result_as_boolean(hass):
+ """Test converting a template result to a boolean."""
+
+ assert template.result_as_boolean(True) is True
+ assert template.result_as_boolean(" 1 ") is True
+ assert template.result_as_boolean(" true ") is True
+ assert template.result_as_boolean(" TrUE ") is True
+ assert template.result_as_boolean(" YeS ") is True
+ assert template.result_as_boolean(" On ") is True
+ assert template.result_as_boolean(" Enable ") is True
+ assert template.result_as_boolean(1) is True
+ assert template.result_as_boolean(-1) is True
+ assert template.result_as_boolean(500) is True
+ assert template.result_as_boolean(0.5) is True
+ assert template.result_as_boolean(0.389) is True
+ assert template.result_as_boolean(35) is True
+
+ assert template.result_as_boolean(False) is False
+ assert template.result_as_boolean(" 0 ") is False
+ assert template.result_as_boolean(" false ") is False
+ assert template.result_as_boolean(" FaLsE ") is False
+ assert template.result_as_boolean(" no ") is False
+ assert template.result_as_boolean(" off ") is False
+ assert template.result_as_boolean(" disable ") is False
+ assert template.result_as_boolean(0) is False
+ assert template.result_as_boolean(0.0) is False
+ assert template.result_as_boolean("0.00") is False
+ assert template.result_as_boolean(None) is False
+
+
def test_closest_function_to_entity_id(hass):
"""Test closest function to entity id."""
hass.states.async_set(
@@ -1550,7 +1786,7 @@ def test_closest_function_no_location_states(hass):
)
-def test_extract_entities_none_exclude_stuff(hass):
+def test_extract_entities_none_exclude_stuff(hass, allow_extract_entities):
"""Test extract entities function with none or exclude stuff."""
assert template.extract_entities(hass, None) == []
@@ -1558,18 +1794,21 @@ def test_extract_entities_none_exclude_stuff(hass):
assert (
template.extract_entities(
- hass, "{{ closest(states.zone.far_away, states.test_domain).entity_id }}"
+ hass,
+ "{{ closest(states.zone.far_away, states.test_domain.xxx).entity_id }}",
)
== MATCH_ALL
)
assert (
- template.extract_entities(hass, '{{ distance("123", states.test_object_2) }}')
+ template.extract_entities(
+ hass, '{{ distance("123", states.test_object_2.user) }}'
+ )
== MATCH_ALL
)
-def test_extract_entities_no_match_entities(hass):
+def test_extract_entities_no_match_entities(hass, allow_extract_entities):
"""Test extract entities function with none entities stuff."""
assert (
template.extract_entities(
@@ -1673,7 +1912,43 @@ def test_generate_select(hass):
)
-async def test_extract_entities_match_entities(hass):
+async def test_async_render_to_info_in_conditional(hass):
+ """Test extract entities function with none entities stuff."""
+ template_str = """
+{{ states("sensor.xyz") == "dog" }}
+ """
+
+ tmp = template.Template(template_str, hass)
+ info = tmp.async_render_to_info()
+ assert_result_info(info, "False", ["sensor.xyz"], [])
+
+ hass.states.async_set("sensor.xyz", "dog")
+ hass.states.async_set("sensor.cow", "True")
+ await hass.async_block_till_done()
+
+ template_str = """
+{% if states("sensor.xyz") == "dog" %}
+ {{ states("sensor.cow") }}
+{% else %}
+ {{ states("sensor.pig") }}
+{% endif %}
+ """
+
+ tmp = template.Template(template_str, hass)
+ info = tmp.async_render_to_info()
+ assert_result_info(info, "True", ["sensor.xyz", "sensor.cow"], [])
+
+ hass.states.async_set("sensor.xyz", "sheep")
+ hass.states.async_set("sensor.pig", "oink")
+
+ await hass.async_block_till_done()
+
+ tmp = template.Template(template_str, hass)
+ info = tmp.async_render_to_info()
+ assert_result_info(info, "oink", ["sensor.xyz", "sensor.pig"], [])
+
+
+async def test_extract_entities_match_entities(hass, allow_extract_entities):
"""Test extract entities function with entities stuff."""
assert (
template.extract_entities(
@@ -1739,8 +2014,8 @@ Hercules you power goes done!.
hass,
"""
{{
-states.sensor.pick_temperature.state ~ „°C (“ ~
-states.sensor.pick_humidity.state ~ „ %“
+states.sensor.pick_temperature.state ~ "°C (" ~
+states.sensor.pick_humidity.state ~ " %"
}}
""",
)
@@ -1771,13 +2046,17 @@ states.sensor.pick_humidity.state ~ „ %“
hass, "{{ expand('group.expand_group') | list | length }}"
)
)
-
assert ["test_domain.entity"] == template.Template(
'{{ is_state("test_domain.entity", "on") }}', hass
).extract_entities()
+ # No expand, extract finds the group
+ assert template.extract_entities(hass, "{{ states('group.empty_group') }}") == [
+ "group.empty_group"
+ ]
-def test_extract_entities_with_variables(hass):
+
+def test_extract_entities_with_variables(hass, allow_extract_entities):
"""Test extract entities function with variables and entities stuff."""
hass.states.async_set("input_boolean.switch", "on")
assert ["input_boolean.switch"] == template.extract_entities(
@@ -1812,6 +2091,107 @@ def test_extract_entities_with_variables(hass):
)
+def test_extract_entities_domain_states_inner(hass, allow_extract_entities):
+ """Test extract entities function by domain."""
+ hass.states.async_set("light.switch", "on")
+ hass.states.async_set("light.switch2", "on")
+ hass.states.async_set("light.switch3", "off")
+
+ assert (
+ set(
+ template.extract_entities(
+ hass,
+ "{{ states['light'] | selectattr('state','eq','on') | list | count > 0 }}",
+ {},
+ )
+ )
+ == {"light.switch", "light.switch2", "light.switch3"}
+ )
+
+
+def test_extract_entities_domain_states_outer(hass, allow_extract_entities):
+ """Test extract entities function by domain."""
+ hass.states.async_set("light.switch", "on")
+ hass.states.async_set("light.switch2", "on")
+ hass.states.async_set("light.switch3", "off")
+
+ assert (
+ set(
+ template.extract_entities(
+ hass,
+ "{{ states.light | selectattr('state','eq','off') | list | count > 0 }}",
+ {},
+ )
+ )
+ == {"light.switch", "light.switch2", "light.switch3"}
+ )
+
+
+def test_extract_entities_domain_states_outer_with_group(hass, allow_extract_entities):
+ """Test extract entities function by domain."""
+ hass.states.async_set("light.switch", "on")
+ hass.states.async_set("light.switch2", "on")
+ hass.states.async_set("light.switch3", "off")
+ hass.states.async_set("switch.pool_light", "off")
+ hass.states.async_set("group.lights", "off", {"entity_id": ["switch.pool_light"]})
+
+ assert (
+ set(
+ template.extract_entities(
+ hass,
+ "{{ states.light | selectattr('entity_id', 'in', state_attr('group.lights', 'entity_id')) }}",
+ {},
+ )
+ )
+ == {"light.switch", "light.switch2", "light.switch3", "group.lights"}
+ )
+
+
+def test_extract_entities_blocked_from_core_code(hass):
+ """Test extract entities is blocked from core code."""
+ with pytest.raises(RuntimeError):
+ template.extract_entities(
+ hass,
+ "{{ states.light }}",
+ {},
+ )
+
+
+def test_extract_entities_warns_and_logs_from_an_integration(hass, caplog):
+ """Test extract entities works from a custom_components with a log message."""
+
+ correct_frame = Mock(
+ filename="/config/custom_components/burncpu/light.py",
+ lineno="23",
+ line="self.light.is_on",
+ )
+ with patch(
+ "homeassistant.helpers.frame.extract_stack",
+ return_value=[
+ Mock(
+ filename="/home/dev/homeassistant/core.py",
+ lineno="23",
+ line="do_something()",
+ ),
+ correct_frame,
+ Mock(
+ filename="/home/dev/mdns/lights.py",
+ lineno="2",
+ line="something()",
+ ),
+ ],
+ ):
+ template.extract_entities(
+ hass,
+ "{{ states.light }}",
+ {},
+ )
+
+ assert "custom_components/burncpu/light.py" in caplog.text
+ assert "23" in caplog.text
+ assert "self.light.is_on" in caplog.text
+
+
def test_jinja_namespace(hass):
"""Test Jinja's namespace command can be used."""
test_template = template.Template(
@@ -1877,7 +2257,8 @@ def test_render_complex_handling_non_template_values(hass):
def test_urlencode(hass):
"""Test the urlencode method."""
tpl = template.Template(
- ("{% set dict = {'foo': 'x&y', 'bar': 42} %}" "{{ dict | urlencode }}"), hass,
+ ("{% set dict = {'foo': 'x&y', 'bar': 42} %}" "{{ dict | urlencode }}"),
+ hass,
)
assert tpl.async_render() == "foo=x%26y&bar=42"
tpl = template.Template(
@@ -1892,13 +2273,17 @@ async def test_cache_garbage_collection():
template_string = (
"{% set dict = {'foo': 'x&y', 'bar': 42} %} {{ dict | urlencode }}"
)
- tpl = template.Template((template_string),)
+ tpl = template.Template(
+ (template_string),
+ )
tpl.ensure_valid()
assert template._NO_HASS_ENV.template_cache.get(
template_string
) # pylint: disable=protected-access
- tpl2 = template.Template((template_string),)
+ tpl2 = template.Template(
+ (template_string),
+ )
tpl2.ensure_valid()
assert template._NO_HASS_ENV.template_cache.get(
template_string
@@ -1912,3 +2297,27 @@ async def test_cache_garbage_collection():
assert not template._NO_HASS_ENV.template_cache.get(
template_string
) # pylint: disable=protected-access
+
+
+def test_is_template_string():
+ """Test is template string."""
+ assert template.is_template_string("{{ x }}") is True
+ assert template.is_template_string("{% if x == 2 %}1{% else %}0{%end if %}") is True
+ assert template.is_template_string("{# a comment #} Hey") is True
+ assert template.is_template_string("1") is False
+ assert template.is_template_string("Some Text") is False
+
+
+async def test_protected_blocked(hass):
+ """Test accessing __getattr__ produces a template error."""
+ tmp = template.Template('{{ states.__getattr__("any") }}', hass)
+ with pytest.raises(TemplateError):
+ tmp.async_render()
+
+ tmp = template.Template('{{ states.sensor.__getattr__("any") }}', hass)
+ with pytest.raises(TemplateError):
+ tmp.async_render()
+
+ tmp = template.Template('{{ states.sensor.any.__getattr__("any") }}', hass)
+ with pytest.raises(TemplateError):
+ tmp.async_render()
diff --git a/tests/helpers/test_trigger.py b/tests/helpers/test_trigger.py
new file mode 100644
index 00000000000..b4bfb881186
--- /dev/null
+++ b/tests/helpers/test_trigger.py
@@ -0,0 +1,12 @@
+"""The tests for the trigger helper."""
+import pytest
+import voluptuous as vol
+
+from homeassistant.helpers.trigger import async_validate_trigger_config
+
+
+async def test_bad_trigger_platform(hass):
+ """Test bad trigger platform."""
+ with pytest.raises(vol.Invalid) as ex:
+ await async_validate_trigger_config(hass, [{"platform": "not_a_platform"}])
+ assert "Invalid platform 'not_a_platform' specified" in str(ex)
diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py
index 56c53f1994c..ee3c4af6daf 100644
--- a/tests/helpers/test_update_coordinator.py
+++ b/tests/helpers/test_update_coordinator.py
@@ -11,7 +11,7 @@ import requests
from homeassistant.helpers import update_coordinator
from homeassistant.util.dt import utcnow
-from tests.async_mock import AsyncMock, Mock
+from tests.async_mock import AsyncMock, Mock, patch
from tests.common import async_fire_time_changed
LOGGER = logging.getLogger(__name__)
@@ -224,3 +224,29 @@ async def test_refresh_recover(crd, caplog):
assert crd.last_update_success is True
assert "Fetching test data recovered" in caplog.text
+
+
+async def test_coordinator_entity(crd):
+ """Test the CoordinatorEntity class."""
+ entity = update_coordinator.CoordinatorEntity(crd)
+
+ assert entity.should_poll is False
+
+ crd.last_update_success = False
+ assert entity.available is False
+
+ await entity.async_update()
+ assert entity.available is True
+
+ with patch(
+ "homeassistant.helpers.entity.Entity.async_on_remove"
+ ) as mock_async_on_remove:
+ await entity.async_added_to_hass()
+
+ assert mock_async_on_remove.called
+
+ # Verify we do not update if the entity is disabled
+ crd.last_update_success = False
+ with patch("homeassistant.helpers.entity.Entity.enabled", False):
+ await entity.async_update()
+ assert entity.available is False
diff --git a/tests/ignore_uncaught_exceptions.py b/tests/ignore_uncaught_exceptions.py
index 20d32202a1a..e68131d689b 100644
--- a/tests/ignore_uncaught_exceptions.py
+++ b/tests/ignore_uncaught_exceptions.py
@@ -1,6 +1,9 @@
"""List of tests that have uncaught exceptions today. Will be shrunk over time."""
IGNORE_UNCAUGHT_EXCEPTIONS = [
- ("test_homeassistant_bridge", "test_homeassistant_bridge_fan_setup",),
+ (
+ "test_homeassistant_bridge",
+ "test_homeassistant_bridge_fan_setup",
+ ),
(
"tests.components.owntracks.test_device_tracker",
"test_mobile_multiple_async_enter_exit",
diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py
index 03c5ba68e59..59e77dc1388 100644
--- a/tests/test_bootstrap.py
+++ b/tests/test_bootstrap.py
@@ -172,7 +172,8 @@ async def test_setup_after_deps_in_stage_1_ignored(hass):
mock_integration(
hass,
MockModule(
- domain="an_after_dep", async_setup=gen_domain_setup("an_after_dep"),
+ domain="an_after_dep",
+ async_setup=gen_domain_setup("an_after_dep"),
),
)
mock_integration(
diff --git a/tests/test_config.py b/tests/test_config.py
index 22b5987f69c..fb22ee1118e 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -364,7 +364,7 @@ async def test_loading_configuration_from_storage(hass, hass_storage):
assert hass.config.time_zone.zone == "Europe/Copenhagen"
assert hass.config.external_url == "https://www.example.com"
assert hass.config.internal_url == "http://example.local"
- assert len(hass.config.allowlist_external_dirs) == 2
+ assert len(hass.config.allowlist_external_dirs) == 3
assert "/etc" in hass.config.allowlist_external_dirs
assert hass.config.config_source == SOURCE_STORAGE
@@ -421,7 +421,7 @@ async def test_override_stored_configuration(hass, hass_storage):
assert hass.config.location_name == "Home"
assert hass.config.units.name == CONF_UNIT_SYSTEM_METRIC
assert hass.config.time_zone.zone == "Europe/Copenhagen"
- assert len(hass.config.allowlist_external_dirs) == 2
+ assert len(hass.config.allowlist_external_dirs) == 3
assert "/etc" in hass.config.allowlist_external_dirs
assert hass.config.config_source == config_util.SOURCE_YAML
@@ -440,6 +440,7 @@ async def test_loading_configuration(hass):
"allowlist_external_dirs": "/etc",
"external_url": "https://www.example.com",
"internal_url": "http://example.local",
+ "media_dirs": {"mymedia": "/usr"},
},
)
@@ -451,8 +452,10 @@ async def test_loading_configuration(hass):
assert hass.config.time_zone.zone == "America/New_York"
assert hass.config.external_url == "https://www.example.com"
assert hass.config.internal_url == "http://example.local"
- assert len(hass.config.allowlist_external_dirs) == 2
+ assert len(hass.config.allowlist_external_dirs) == 3
assert "/etc" in hass.config.allowlist_external_dirs
+ assert "/usr" in hass.config.allowlist_external_dirs
+ assert hass.config.media_dirs == {"mymedia": "/usr"}
assert hass.config.config_source == config_util.SOURCE_YAML
@@ -483,6 +486,22 @@ async def test_loading_configuration_temperature_unit(hass):
assert hass.config.config_source == config_util.SOURCE_YAML
+async def test_loading_configuration_default_media_dirs_docker(hass):
+ """Test loading core config onto hass object."""
+ with patch("homeassistant.config.is_docker_env", return_value=True):
+ await config_util.async_process_ha_core_config(
+ hass,
+ {
+ "name": "Huis",
+ },
+ )
+
+ assert hass.config.location_name == "Huis"
+ assert len(hass.config.allowlist_external_dirs) == 2
+ assert "/media" in hass.config.allowlist_external_dirs
+ assert hass.config.media_dirs == {"local": "/media"}
+
+
async def test_loading_configuration_from_packages(hass):
"""Test loading packages config onto hass object config."""
await config_util.async_process_ha_core_config(
@@ -958,20 +977,23 @@ async def test_component_config_exceptions(hass, caplog):
# component.PLATFORM_SCHEMA
caplog.clear()
- assert await config_util.async_process_component_config(
- hass,
- {"test_domain": {"platform": "test_platform"}},
- integration=Mock(
- domain="test_domain",
- get_platform=Mock(return_value=None),
- get_component=Mock(
- return_value=Mock(
- spec=["PLATFORM_SCHEMA_BASE"],
- PLATFORM_SCHEMA_BASE=Mock(side_effect=ValueError("broken")),
- )
+ assert (
+ await config_util.async_process_component_config(
+ hass,
+ {"test_domain": {"platform": "test_platform"}},
+ integration=Mock(
+ domain="test_domain",
+ get_platform=Mock(return_value=None),
+ get_component=Mock(
+ return_value=Mock(
+ spec=["PLATFORM_SCHEMA_BASE"],
+ PLATFORM_SCHEMA_BASE=Mock(side_effect=ValueError("broken")),
+ )
+ ),
),
- ),
- ) == {"test_domain": []}
+ )
+ == {"test_domain": []}
+ )
assert "ValueError: broken" in caplog.text
assert (
"Unknown error validating test_platform platform config with test_domain component platform schema"
@@ -990,15 +1012,20 @@ async def test_component_config_exceptions(hass, caplog):
)
),
):
- assert await config_util.async_process_component_config(
- hass,
- {"test_domain": {"platform": "test_platform"}},
- integration=Mock(
- domain="test_domain",
- get_platform=Mock(return_value=None),
- get_component=Mock(return_value=Mock(spec=["PLATFORM_SCHEMA_BASE"])),
- ),
- ) == {"test_domain": []}
+ assert (
+ await config_util.async_process_component_config(
+ hass,
+ {"test_domain": {"platform": "test_platform"}},
+ integration=Mock(
+ domain="test_domain",
+ get_platform=Mock(return_value=None),
+ get_component=Mock(
+ return_value=Mock(spec=["PLATFORM_SCHEMA_BASE"])
+ ),
+ ),
+ )
+ == {"test_domain": []}
+ )
assert "ValueError: broken" in caplog.text
assert (
"Unknown error validating config for test_platform platform for test_domain component with PLATFORM_SCHEMA"
@@ -1025,7 +1052,11 @@ async def test_component_config_exceptions(hass, caplog):
),
("zone", vol.Schema({vol.Optional("zone"): int}), None),
("zone", vol.Schema({"zone": int}), None),
- ("not_existing", vol.Schema({vol.Optional("zone", default=dict): dict}), None,),
+ (
+ "not_existing",
+ vol.Schema({vol.Optional("zone", default=dict): dict}),
+ None,
+ ),
("non_existing", vol.Schema({"zone": int}), None),
("zone", vol.Schema({}), None),
("plex", vol.Schema(vol.All({"plex": {"host": str}})), "dict"),
diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py
index 6d513697daf..f13fac02850 100644
--- a/tests/test_config_entries.py
+++ b/tests/test_config_entries.py
@@ -53,6 +53,7 @@ async def test_call_setup_entry(hass):
"""Test we call .setup_entry."""
entry = MockConfigEntry(domain="comp")
entry.add_to_hass(hass)
+ assert not entry.supports_unload
mock_setup_entry = AsyncMock(return_value=True)
mock_migrate_entry = AsyncMock(return_value=True)
@@ -67,16 +68,49 @@ async def test_call_setup_entry(hass):
)
mock_entity_platform(hass, "config_flow.comp", None)
- result = await async_setup_component(hass, "comp", {})
+ with patch("homeassistant.config_entries.support_entry_unload", return_value=True):
+ result = await async_setup_component(hass, "comp", {})
+ await hass.async_block_till_done()
assert result
assert len(mock_migrate_entry.mock_calls) == 0
assert len(mock_setup_entry.mock_calls) == 1
assert entry.state == config_entries.ENTRY_STATE_LOADED
+ assert entry.supports_unload
+
+
+async def test_call_setup_entry_without_reload_support(hass):
+ """Test we call .setup_entry and the does not support unloading."""
+ entry = MockConfigEntry(domain="comp")
+ entry.add_to_hass(hass)
+ assert not entry.supports_unload
+
+ mock_setup_entry = AsyncMock(return_value=True)
+ mock_migrate_entry = AsyncMock(return_value=True)
+
+ mock_integration(
+ hass,
+ MockModule(
+ "comp",
+ async_setup_entry=mock_setup_entry,
+ async_migrate_entry=mock_migrate_entry,
+ ),
+ )
+ mock_entity_platform(hass, "config_flow.comp", None)
+
+ with patch("homeassistant.config_entries.support_entry_unload", return_value=False):
+ result = await async_setup_component(hass, "comp", {})
+ await hass.async_block_till_done()
+ assert result
+ assert len(mock_migrate_entry.mock_calls) == 0
+ assert len(mock_setup_entry.mock_calls) == 1
+ assert entry.state == config_entries.ENTRY_STATE_LOADED
+ assert not entry.supports_unload
async def test_call_async_migrate_entry(hass):
"""Test we call .async_migrate_entry when version mismatch."""
entry = MockConfigEntry(domain="comp")
+ assert not entry.supports_unload
entry.version = 2
entry.add_to_hass(hass)
@@ -93,11 +127,14 @@ async def test_call_async_migrate_entry(hass):
)
mock_entity_platform(hass, "config_flow.comp", None)
- result = await async_setup_component(hass, "comp", {})
+ with patch("homeassistant.config_entries.support_entry_unload", return_value=True):
+ result = await async_setup_component(hass, "comp", {})
+ await hass.async_block_till_done()
assert result
assert len(mock_migrate_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
assert entry.state == config_entries.ENTRY_STATE_LOADED
+ assert entry.supports_unload
async def test_call_async_migrate_entry_failure_false(hass):
@@ -105,6 +142,7 @@ async def test_call_async_migrate_entry_failure_false(hass):
entry = MockConfigEntry(domain="comp")
entry.version = 2
entry.add_to_hass(hass)
+ assert not entry.supports_unload
mock_migrate_entry = AsyncMock(return_value=False)
mock_setup_entry = AsyncMock(return_value=True)
@@ -124,6 +162,7 @@ async def test_call_async_migrate_entry_failure_false(hass):
assert len(mock_migrate_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 0
assert entry.state == config_entries.ENTRY_STATE_MIGRATION_ERROR
+ assert not entry.supports_unload
async def test_call_async_migrate_entry_failure_exception(hass):
@@ -131,6 +170,7 @@ async def test_call_async_migrate_entry_failure_exception(hass):
entry = MockConfigEntry(domain="comp")
entry.version = 2
entry.add_to_hass(hass)
+ assert not entry.supports_unload
mock_migrate_entry = AsyncMock(side_effect=Exception)
mock_setup_entry = AsyncMock(return_value=True)
@@ -150,6 +190,7 @@ async def test_call_async_migrate_entry_failure_exception(hass):
assert len(mock_migrate_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 0
assert entry.state == config_entries.ENTRY_STATE_MIGRATION_ERROR
+ assert not entry.supports_unload
async def test_call_async_migrate_entry_failure_not_bool(hass):
@@ -157,6 +198,7 @@ async def test_call_async_migrate_entry_failure_not_bool(hass):
entry = MockConfigEntry(domain="comp")
entry.version = 2
entry.add_to_hass(hass)
+ assert not entry.supports_unload
mock_migrate_entry = AsyncMock(return_value=None)
mock_setup_entry = AsyncMock(return_value=True)
@@ -176,6 +218,7 @@ async def test_call_async_migrate_entry_failure_not_bool(hass):
assert len(mock_migrate_entry.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 0
assert entry.state == config_entries.ENTRY_STATE_MIGRATION_ERROR
+ assert not entry.supports_unload
async def test_call_async_migrate_entry_failure_not_supported(hass):
@@ -183,6 +226,7 @@ async def test_call_async_migrate_entry_failure_not_supported(hass):
entry = MockConfigEntry(domain="comp")
entry.version = 2
entry.add_to_hass(hass)
+ assert not entry.supports_unload
mock_setup_entry = AsyncMock(return_value=True)
@@ -193,6 +237,7 @@ async def test_call_async_migrate_entry_failure_not_supported(hass):
assert result
assert len(mock_setup_entry.mock_calls) == 0
assert entry.state == config_entries.ENTRY_STATE_MIGRATION_ERROR
+ assert not entry.supports_unload
async def test_remove_entry(hass, manager):
@@ -624,10 +669,10 @@ async def test_updating_entry_data(manager):
)
entry.add_to_manager(manager)
- manager.async_update_entry(entry)
+ assert manager.async_update_entry(entry) is False
assert entry.data == {"first": True}
- manager.async_update_entry(entry, data={"second": True})
+ assert manager.async_update_entry(entry, data={"second": True}) is True
assert entry.data == {"second": True}
@@ -658,7 +703,7 @@ async def test_update_entry_options_and_trigger_listener(hass, manager):
entry.add_update_listener(update_listener)
- manager.async_update_entry(entry, options={"second": True})
+ assert manager.async_update_entry(entry, options={"second": True}) is True
assert entry.options == {"second": True}
@@ -958,7 +1003,8 @@ async def test_init_custom_integration(hass):
)
with pytest.raises(data_entry_flow.UnknownHandler):
with patch(
- "homeassistant.loader.async_get_integration", return_value=integration,
+ "homeassistant.loader.async_get_integration",
+ return_value=integration,
):
await hass.config_entries.flow.async_init("bla")
@@ -991,6 +1037,7 @@ async def test_reload_entry_entity_registry_works(hass):
config_entry = MockConfigEntry(
domain="comp", state=config_entries.ENTRY_STATE_LOADED
)
+ config_entry.supports_unload = True
config_entry.add_to_hass(hass)
mock_setup_entry = AsyncMock(return_value=True)
mock_unload_entry = AsyncMock(return_value=True)
@@ -1120,18 +1167,20 @@ async def test_unique_id_existing_entry(hass, manager):
assert len(async_remove_entry.mock_calls) == 1
-async def test_unique_id_update_existing_entry(hass, manager):
+async def test_unique_id_update_existing_entry_without_reload(hass, manager):
"""Test that we update an entry if there already is an entry with unique ID."""
hass.config.components.add("comp")
entry = MockConfigEntry(
domain="comp",
data={"additional": "data", "host": "0.0.0.0"},
unique_id="mock-unique-id",
+ state=config_entries.ENTRY_STATE_LOADED,
)
entry.add_to_hass(hass)
mock_integration(
- hass, MockModule("comp"),
+ hass,
+ MockModule("comp"),
)
mock_entity_platform(hass, "config_flow.comp", None)
@@ -1143,17 +1192,85 @@ async def test_unique_id_update_existing_entry(hass, manager):
async def async_step_user(self, user_input=None):
"""Test user step."""
await self.async_set_unique_id("mock-unique-id")
- await self._abort_if_unique_id_configured(updates={"host": "1.1.1.1"})
+ await self._abort_if_unique_id_configured(
+ updates={"host": "1.1.1.1"}, reload_on_update=False
+ )
- with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}):
+ with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch(
+ "homeassistant.config_entries.ConfigEntries.async_reload"
+ ) as async_reload:
result = await manager.flow.async_init(
"comp", context={"source": config_entries.SOURCE_USER}
)
+ await hass.async_block_till_done()
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
assert entry.data["host"] == "1.1.1.1"
assert entry.data["additional"] == "data"
+ assert len(async_reload.mock_calls) == 0
+
+
+async def test_unique_id_update_existing_entry_with_reload(hass, manager):
+ """Test that we update an entry if there already is an entry with unique ID and we reload on changes."""
+ hass.config.components.add("comp")
+ entry = MockConfigEntry(
+ domain="comp",
+ data={"additional": "data", "host": "0.0.0.0"},
+ unique_id="mock-unique-id",
+ state=config_entries.ENTRY_STATE_LOADED,
+ )
+ entry.add_to_hass(hass)
+
+ mock_integration(
+ hass,
+ MockModule("comp"),
+ )
+ mock_entity_platform(hass, "config_flow.comp", None)
+ updates = {"host": "1.1.1.1"}
+
+ class TestFlow(config_entries.ConfigFlow):
+ """Test flow."""
+
+ VERSION = 1
+
+ async def async_step_user(self, user_input=None):
+ """Test user step."""
+ await self.async_set_unique_id("mock-unique-id")
+ await self._abort_if_unique_id_configured(
+ updates=updates, reload_on_update=True
+ )
+
+ with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch(
+ "homeassistant.config_entries.ConfigEntries.async_reload"
+ ) as async_reload:
+ result = await manager.flow.async_init(
+ "comp", context={"source": config_entries.SOURCE_USER}
+ )
+ await hass.async_block_till_done()
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+ assert entry.data["host"] == "1.1.1.1"
+ assert entry.data["additional"] == "data"
+ assert len(async_reload.mock_calls) == 1
+
+ # Test we don't reload if entry not started
+ updates["host"] = "2.2.2.2"
+ entry.state = config_entries.ENTRY_STATE_NOT_LOADED
+ with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch(
+ "homeassistant.config_entries.ConfigEntries.async_reload"
+ ) as async_reload:
+ result = await manager.flow.async_init(
+ "comp", context={"source": config_entries.SOURCE_USER}
+ )
+ await hass.async_block_till_done()
+
+ assert result["type"] == "abort"
+ assert result["reason"] == "already_configured"
+ assert entry.data["host"] == "2.2.2.2"
+ assert entry.data["additional"] == "data"
+ assert len(async_reload.mock_calls) == 0
async def test_unique_id_not_update_existing_entry(hass, manager):
@@ -1167,7 +1284,8 @@ async def test_unique_id_not_update_existing_entry(hass, manager):
entry.add_to_hass(hass)
mock_integration(
- hass, MockModule("comp"),
+ hass,
+ MockModule("comp"),
)
mock_entity_platform(hass, "config_flow.comp", None)
@@ -1179,20 +1297,23 @@ async def test_unique_id_not_update_existing_entry(hass, manager):
async def async_step_user(self, user_input=None):
"""Test user step."""
await self.async_set_unique_id("mock-unique-id")
- await self._abort_if_unique_id_configured(updates={"host": "0.0.0.0"})
+ await self._abort_if_unique_id_configured(
+ updates={"host": "0.0.0.0"}, reload_on_update=True
+ )
with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}), patch(
- "homeassistant.config_entries.ConfigEntries.async_update_entry"
- ) as async_update_entry:
+ "homeassistant.config_entries.ConfigEntries.async_reload"
+ ) as async_reload:
result = await manager.flow.async_init(
"comp", context={"source": config_entries.SOURCE_USER}
)
+ await hass.async_block_till_done()
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
assert entry.data["host"] == "0.0.0.0"
assert entry.data["additional"] == "data"
- assert len(async_update_entry.mock_calls) == 0
+ assert len(async_reload.mock_calls) == 0
async def test_unique_id_in_progress(hass, manager):
@@ -1229,7 +1350,8 @@ async def test_unique_id_in_progress(hass, manager):
async def test_finish_flow_aborts_progress(hass, manager):
"""Test that when finishing a flow, we abort other flows in progress with unique ID."""
mock_integration(
- hass, MockModule("comp", async_setup_entry=AsyncMock(return_value=True)),
+ hass,
+ MockModule("comp", async_setup_entry=AsyncMock(return_value=True)),
)
mock_entity_platform(hass, "config_flow.comp", None)
@@ -1494,7 +1616,9 @@ async def test_async_setup_init_entry(hass):
"""Mock setup."""
hass.async_create_task(
hass.config_entries.flow.async_init(
- "comp", context={"source": config_entries.SOURCE_IMPORT}, data={},
+ "comp",
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={},
)
)
return True
@@ -1539,7 +1663,9 @@ async def test_async_setup_update_entry(hass):
"""Mock setup."""
hass.async_create_task(
hass.config_entries.flow.async_init(
- "comp", context={"source": config_entries.SOURCE_IMPORT}, data={},
+ "comp",
+ context={"source": config_entries.SOURCE_IMPORT},
+ data={},
)
)
return True
@@ -1567,8 +1693,11 @@ async def test_async_setup_update_entry(hass):
async def async_step_import(self, user_input):
"""Test import step updating existing entry."""
- self.hass.config_entries.async_update_entry(
- entry, data={"value": "updated"}
+ assert (
+ self.hass.config_entries.async_update_entry(
+ entry, data={"value": "updated"}
+ )
+ is True
)
return self.async_abort(reason="yo")
@@ -1594,7 +1723,8 @@ async def test_async_setup_update_entry(hass):
async def test_flow_with_default_discovery(hass, manager, discovery_source):
"""Test that finishing a default discovery flow removes the unique ID in the entry."""
mock_integration(
- hass, MockModule("comp", async_setup_entry=AsyncMock(return_value=True)),
+ hass,
+ MockModule("comp", async_setup_entry=AsyncMock(return_value=True)),
)
mock_entity_platform(hass, "config_flow.comp", None)
@@ -1758,3 +1888,60 @@ async def test_default_discovery_abort_on_new_unique_flow(hass, manager):
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
assert flows[0]["context"]["unique_id"] == "mock-unique-id"
+
+
+async def test_updating_entry_with_and_without_changes(manager):
+ """Test that we can update an entry data."""
+ entry = MockConfigEntry(
+ domain="test",
+ data={"first": True},
+ title="thetitle",
+ options={"option": True},
+ unique_id="abc123",
+ state=config_entries.ENTRY_STATE_SETUP_ERROR,
+ )
+ entry.add_to_manager(manager)
+
+ assert manager.async_update_entry(entry) is False
+ assert manager.async_update_entry(entry, data={"second": True}) is True
+ assert manager.async_update_entry(entry, data={"second": True}) is False
+ assert (
+ manager.async_update_entry(entry, data={"second": True, "third": 456}) is True
+ )
+ assert (
+ manager.async_update_entry(entry, data={"second": True, "third": 456}) is False
+ )
+ assert manager.async_update_entry(entry, options={"second": True}) is True
+ assert manager.async_update_entry(entry, options={"second": True}) is False
+ assert (
+ manager.async_update_entry(entry, options={"second": True, "third": "123"})
+ is True
+ )
+ assert (
+ manager.async_update_entry(entry, options={"second": True, "third": "123"})
+ is False
+ )
+ assert (
+ manager.async_update_entry(entry, system_options={"disable_new_entities": True})
+ is True
+ )
+ assert (
+ manager.async_update_entry(entry, system_options={"disable_new_entities": True})
+ is False
+ )
+ assert (
+ manager.async_update_entry(
+ entry, system_options={"disable_new_entities": False}
+ )
+ is True
+ )
+ assert (
+ manager.async_update_entry(
+ entry, system_options={"disable_new_entities": False}
+ )
+ is False
+ )
+ assert manager.async_update_entry(entry, title="thetitle") is False
+ assert manager.async_update_entry(entry, title="newtitle") is True
+ assert manager.async_update_entry(entry, unique_id="abc123") is False
+ assert manager.async_update_entry(entry, unique_id="abc1234") is True
diff --git a/tests/test_core.py b/tests/test_core.py
index 77baa502687..f5de9c5f1a1 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -174,120 +174,98 @@ def test_stage_shutdown():
assert len(test_all) == 2
-class TestHomeAssistant(unittest.TestCase):
- """Test the Home Assistant core classes."""
+async def test_pending_sheduler(hass):
+ """Add a coro to pending tasks."""
+ call_count = []
- # pylint: disable=invalid-name
- def setUp(self):
- """Set up things to be run when tests are started."""
- self.hass = get_test_home_assistant()
+ async def test_coro():
+ """Test Coro."""
+ call_count.append("call")
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
+ for _ in range(3):
+ hass.async_add_job(test_coro())
- def test_pending_sheduler(self):
- """Add a coro to pending tasks."""
- call_count = []
+ await asyncio.wait(hass._pending_tasks)
- @asyncio.coroutine
- def test_coro():
- """Test Coro."""
- call_count.append("call")
+ assert len(hass._pending_tasks) == 3
+ assert len(call_count) == 3
- for _ in range(3):
- self.hass.add_job(test_coro())
- asyncio.run_coroutine_threadsafe(
- asyncio.wait(self.hass._pending_tasks), loop=self.hass.loop
- ).result()
+async def test_async_add_job_pending_tasks_coro(hass):
+ """Add a coro to pending tasks."""
+ call_count = []
- assert len(self.hass._pending_tasks) == 3
- assert len(call_count) == 3
+ async def test_coro():
+ """Test Coro."""
+ call_count.append("call")
- def test_async_add_job_pending_tasks_coro(self):
- """Add a coro to pending tasks."""
- call_count = []
+ for _ in range(2):
+ hass.add_job(test_coro())
- @asyncio.coroutine
- def test_coro():
- """Test Coro."""
- call_count.append("call")
+ async def wait_finish_callback():
+ """Wait until all stuff is scheduled."""
+ await asyncio.sleep(0)
+ await asyncio.sleep(0)
- for _ in range(2):
- self.hass.add_job(test_coro())
+ await wait_finish_callback()
- @asyncio.coroutine
- def wait_finish_callback():
- """Wait until all stuff is scheduled."""
- yield from asyncio.sleep(0)
- yield from asyncio.sleep(0)
+ assert len(hass._pending_tasks) == 2
+ await hass.async_block_till_done()
+ assert len(call_count) == 2
- asyncio.run_coroutine_threadsafe(
- wait_finish_callback(), self.hass.loop
- ).result()
- assert len(self.hass._pending_tasks) == 2
- self.hass.block_till_done()
- assert len(call_count) == 2
+async def test_async_add_job_pending_tasks_executor(hass):
+ """Run an executor in pending tasks."""
+ call_count = []
- def test_async_add_job_pending_tasks_executor(self):
- """Run an executor in pending tasks."""
- call_count = []
+ def test_executor():
+ """Test executor."""
+ call_count.append("call")
- def test_executor():
- """Test executor."""
- call_count.append("call")
+ async def wait_finish_callback():
+ """Wait until all stuff is scheduled."""
+ await asyncio.sleep(0)
+ await asyncio.sleep(0)
- @asyncio.coroutine
- def wait_finish_callback():
- """Wait until all stuff is scheduled."""
- yield from asyncio.sleep(0)
- yield from asyncio.sleep(0)
+ for _ in range(2):
+ hass.async_add_job(test_executor)
- for _ in range(2):
- self.hass.add_job(test_executor)
+ await wait_finish_callback()
- asyncio.run_coroutine_threadsafe(
- wait_finish_callback(), self.hass.loop
- ).result()
+ assert len(hass._pending_tasks) == 2
+ await hass.async_block_till_done()
+ assert len(call_count) == 2
- assert len(self.hass._pending_tasks) == 2
- self.hass.block_till_done()
- assert len(call_count) == 2
- def test_async_add_job_pending_tasks_callback(self):
- """Run a callback in pending tasks."""
- call_count = []
+async def test_async_add_job_pending_tasks_callback(hass):
+ """Run a callback in pending tasks."""
+ call_count = []
- @ha.callback
- def test_callback():
- """Test callback."""
- call_count.append("call")
+ @ha.callback
+ def test_callback():
+ """Test callback."""
+ call_count.append("call")
- @asyncio.coroutine
- def wait_finish_callback():
- """Wait until all stuff is scheduled."""
- yield from asyncio.sleep(0)
- yield from asyncio.sleep(0)
+ async def wait_finish_callback():
+ """Wait until all stuff is scheduled."""
+ await asyncio.sleep(0)
+ await asyncio.sleep(0)
- for _ in range(2):
- self.hass.add_job(test_callback)
+ for _ in range(2):
+ hass.async_add_job(test_callback)
- asyncio.run_coroutine_threadsafe(
- wait_finish_callback(), self.hass.loop
- ).result()
+ await wait_finish_callback()
- self.hass.block_till_done()
+ await hass.async_block_till_done()
- assert len(self.hass._pending_tasks) == 0
- assert len(call_count) == 2
+ assert len(hass._pending_tasks) == 0
+ assert len(call_count) == 2
- def test_add_job_with_none(self):
- """Try to add a job with None as function."""
- with pytest.raises(ValueError):
- self.hass.add_job(None, "test_arg")
+
+async def test_add_job_with_none(hass):
+ """Try to add a job with None as function."""
+ with pytest.raises(ValueError):
+ hass.async_add_job(None, "test_arg")
class TestEvent(unittest.TestCase):
@@ -412,8 +390,7 @@ class TestEventBus(unittest.TestCase):
"""Test listen_once_event method."""
runs = []
- @asyncio.coroutine
- def event_handler(event):
+ async def event_handler(event):
runs.append(event)
self.bus.listen_once("test_event", event_handler)
@@ -470,8 +447,7 @@ class TestEventBus(unittest.TestCase):
"""Test coroutine event listener."""
coroutine_calls = []
- @asyncio.coroutine
- def coroutine_listener(event):
+ async def coroutine_listener(event):
coroutine_calls.append(event)
self.bus.listen("test_coroutine", coroutine_listener)
@@ -944,14 +920,14 @@ class TestConfig(unittest.TestCase):
self.config.allowlist_external_dirs = {"/home", "/var"}
- unvalid = [
+ invalid = [
"/hass/config/secure",
"/etc/passwd",
"/root/secure_file",
"/var/../etc/passwd",
test_file,
]
- for path in unvalid:
+ for path in invalid:
assert not self.config.is_allowed_path(path)
with pytest.raises(AssertionError):
@@ -1478,3 +1454,26 @@ async def test_chained_logging_misses_log_timeout(hass, caplog):
await hass.async_block_till_done()
assert "_task_chain_" not in caplog.text
+
+
+async def test_async_all(hass):
+ """Test async_all."""
+
+ hass.states.async_set("switch.link", "on")
+ hass.states.async_set("light.bowl", "on")
+ hass.states.async_set("light.frog", "on")
+ hass.states.async_set("vacuum.floor", "on")
+
+ assert {state.entity_id for state in hass.states.async_all()} == {
+ "switch.link",
+ "light.bowl",
+ "light.frog",
+ "vacuum.floor",
+ }
+ assert {state.entity_id for state in hass.states.async_all("light")} == {
+ "light.bowl",
+ "light.frog",
+ }
+ assert {
+ state.entity_id for state in hass.states.async_all(["light", "switch"])
+ } == {"light.bowl", "light.frog", "switch.link"}
diff --git a/tests/test_loader.py b/tests/test_loader.py
index 272b0453469..f5ba54ff269 100644
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -218,6 +218,23 @@ def test_integration_properties(hass):
assert integration.zeroconf is None
assert integration.ssdp is None
+ integration = loader.Integration(
+ hass,
+ "custom_components.hue",
+ None,
+ {
+ "name": "Philips Hue",
+ "domain": "hue",
+ "dependencies": ["test-dep"],
+ "zeroconf": [{"type": "_hue._tcp.local.", "name": "hue*"}],
+ "requirements": ["test-req==1.0.0"],
+ },
+ )
+ assert integration.is_built_in is False
+ assert integration.homekit is None
+ assert integration.zeroconf == [{"type": "_hue._tcp.local.", "name": "hue*"}]
+ assert integration.ssdp is None
+
async def test_integrations_only_once(hass):
"""Test that we load integrations only once."""
@@ -253,6 +270,25 @@ def _get_test_integration(hass, name, config_flow):
)
+def _get_test_integration_with_zeroconf_matcher(hass, name, config_flow):
+ """Return a generated test integration with a zeroconf matcher."""
+ return loader.Integration(
+ hass,
+ f"homeassistant.components.{name}",
+ None,
+ {
+ "name": name,
+ "domain": name,
+ "config_flow": config_flow,
+ "dependencies": [],
+ "requirements": [],
+ "zeroconf": [{"type": f"_{name}._tcp.local.", "name": f"{name}*"}],
+ "homekit": {"models": [name]},
+ "ssdp": [{"manufacturer": name, "modelName": name}],
+ },
+ )
+
+
async def test_get_custom_components(hass):
"""Verify that custom components are cached."""
test_1_integration = _get_test_integration(hass, "test_1", False)
@@ -289,7 +325,9 @@ async def test_get_config_flows(hass):
async def test_get_zeroconf(hass):
"""Verify that custom components with zeroconf are found."""
test_1_integration = _get_test_integration(hass, "test_1", True)
- test_2_integration = _get_test_integration(hass, "test_2", True)
+ test_2_integration = _get_test_integration_with_zeroconf_matcher(
+ hass, "test_2", True
+ )
with patch("homeassistant.loader.async_get_custom_components") as mock_get:
mock_get.return_value = {
@@ -297,8 +335,10 @@ async def test_get_zeroconf(hass):
"test_2": test_2_integration,
}
zeroconf = await loader.async_get_zeroconf(hass)
- assert zeroconf["_test_1._tcp.local."] == ["test_1"]
- assert zeroconf["_test_2._tcp.local."] == ["test_2"]
+ assert zeroconf["_test_1._tcp.local."] == [{"domain": "test_1"}]
+ assert zeroconf["_test_2._tcp.local."] == [
+ {"domain": "test_2", "name": "test_2*"}
+ ]
async def test_get_homekit(hass):
diff --git a/tests/test_requirements.py b/tests/test_requirements.py
index fcc2d571331..6297da0c2d5 100644
--- a/tests/test_requirements.py
+++ b/tests/test_requirements.py
@@ -216,7 +216,8 @@ async def test_discovery_requirements_zeroconf(hass, partial_manifest):
zeroconf = await loader.async_get_integration(hass, "zeroconf")
mock_integration(
- hass, MockModule("comp", partial_manifest=partial_manifest),
+ hass,
+ MockModule("comp", partial_manifest=partial_manifest),
)
with patch(
diff --git a/tests/test_setup.py b/tests/test_setup.py
index abd9cecd9ac..3d34d1e1383 100644
--- a/tests/test_setup.py
+++ b/tests/test_setup.py
@@ -577,9 +577,25 @@ async def test_parallel_entry_setup(hass):
return True
mock_integration(
- hass, MockModule("comp", async_setup_entry=mock_async_setup_entry,),
+ hass,
+ MockModule(
+ "comp",
+ async_setup_entry=mock_async_setup_entry,
+ ),
)
mock_entity_platform(hass, "config_flow.comp", None)
await setup.async_setup_component(hass, "comp", {})
assert calls == [1, 2, 1, 2]
+
+
+async def test_integration_disabled(hass, caplog):
+ """Test we can disable an integration."""
+ disabled_reason = "Dependency contains code that breaks Home Assistant"
+ mock_integration(
+ hass,
+ MockModule("test_component1", partial_manifest={"disabled": disabled_reason}),
+ )
+ result = await setup.async_setup_component(hass, "test_component1", {})
+ assert not result
+ assert disabled_reason in caplog.text
diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py
index 9b2a4380cae..71358cdd973 100644
--- a/tests/test_util/aiohttp.py
+++ b/tests/test_util/aiohttp.py
@@ -249,7 +249,7 @@ class AiohttpClientMockResponse:
"""Return mock response as a string."""
return self.response.decode(encoding)
- async def json(self, encoding="utf-8"):
+ async def json(self, encoding="utf-8", content_type=None):
"""Return mock response as a json."""
return _json.loads(self.response.decode(encoding))
diff --git a/tests/testing_config/custom_components/test/remote.py b/tests/testing_config/custom_components/test/remote.py
new file mode 100644
index 00000000000..c6f05b156f3
--- /dev/null
+++ b/tests/testing_config/custom_components/test/remote.py
@@ -0,0 +1,39 @@
+"""
+Provide a mock remote platform.
+
+Call init before using it in your tests to ensure clean test data.
+"""
+from homeassistant.components.remote import RemoteEntity
+from homeassistant.const import STATE_OFF, STATE_ON
+
+from tests.common import MockToggleEntity
+
+ENTITIES = []
+
+
+def init(empty=False):
+ """Initialize the platform with entities."""
+ global ENTITIES
+
+ ENTITIES = (
+ []
+ if empty
+ else [
+ MockRemote("TV", STATE_ON),
+ MockRemote("DVD", STATE_OFF),
+ MockRemote(None, STATE_OFF),
+ ]
+ )
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities_callback, discovery_info=None
+):
+ """Return mock entities."""
+ async_add_entities_callback(ENTITIES)
+
+
+class MockRemote(MockToggleEntity, RemoteEntity):
+ """Mock remote class."""
+
+ supported_features = 0
diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py
index 38bf9653938..8d0cdf6ac93 100644
--- a/tests/testing_config/custom_components/test/sensor.py
+++ b/tests/testing_config/custom_components/test/sensor.py
@@ -4,7 +4,7 @@ Provide a mock sensor platform.
Call init before using it in your tests to ensure clean test data.
"""
import homeassistant.components.sensor as sensor
-from homeassistant.const import UNIT_PERCENTAGE
+from homeassistant.const import PERCENTAGE
from tests.common import MockEntity
@@ -12,14 +12,18 @@ DEVICE_CLASSES = list(sensor.DEVICE_CLASSES)
DEVICE_CLASSES.append("none")
UNITS_OF_MEASUREMENT = {
- sensor.DEVICE_CLASS_BATTERY: UNIT_PERCENTAGE, # % of battery that is left
- sensor.DEVICE_CLASS_HUMIDITY: UNIT_PERCENTAGE, # % of humidity in the air
+ sensor.DEVICE_CLASS_BATTERY: PERCENTAGE, # % of battery that is left
+ sensor.DEVICE_CLASS_HUMIDITY: PERCENTAGE, # % of humidity in the air
sensor.DEVICE_CLASS_ILLUMINANCE: "lm", # current light level (lx/lm)
sensor.DEVICE_CLASS_SIGNAL_STRENGTH: "dB", # signal strength (dB/dBm)
sensor.DEVICE_CLASS_TEMPERATURE: "C", # temperature (C/F)
sensor.DEVICE_CLASS_TIMESTAMP: "hh:mm:ss", # timestamp (ISO8601)
sensor.DEVICE_CLASS_PRESSURE: "hPa", # pressure (hPa/mbar)
sensor.DEVICE_CLASS_POWER: "kW", # power (W/kW)
+ sensor.DEVICE_CLASS_CURRENT: "A", # current (A)
+ sensor.DEVICE_CLASS_ENERGY: "kWh", # energy (Wh/kWh)
+ sensor.DEVICE_CLASS_POWER_FACTOR: "%", # power factor (no unit, min: -1.0, max: 1.0)
+ sensor.DEVICE_CLASS_VOLTAGE: "V", # voltage (V)
}
ENTITIES = {}
diff --git a/tests/testing_config/media/not_media.txt b/tests/testing_config/media/not_media.txt
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/testing_config/media/test.mp3 b/tests/testing_config/media/test.mp3
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/tests/util/test_async.py b/tests/util/test_async.py
index b60b4097c52..460490eb783 100644
--- a/tests/util/test_async.py
+++ b/tests/util/test_async.py
@@ -1,7 +1,4 @@
"""Tests for async util methods from Python source."""
-import asyncio
-from unittest import TestCase
-
import pytest
from homeassistant.util import async_ as hasync
@@ -70,104 +67,6 @@ def test_run_callback_threadsafe_from_inside_event_loop(mock_ident, _):
assert len(loop.call_soon_threadsafe.mock_calls) == 2
-class RunThreadsafeTests(TestCase):
- """Test case for hasync.run_coroutine_threadsafe."""
-
- def setUp(self):
- """Test setup method."""
- self.loop = asyncio.new_event_loop()
-
- def tearDown(self):
- """Test teardown method."""
- executor = self.loop._default_executor
- if executor is not None:
- executor.shutdown(wait=True)
- self.loop.close()
-
- @staticmethod
- def run_briefly(loop):
- """Momentarily run a coroutine on the given loop."""
-
- @asyncio.coroutine
- def once():
- pass
-
- gen = once()
- t = loop.create_task(gen)
- try:
- loop.run_until_complete(t)
- finally:
- gen.close()
-
- def add_callback(self, a, b, fail, invalid):
- """Return a + b."""
- if fail:
- raise RuntimeError("Fail!")
- if invalid:
- raise ValueError("Invalid!")
- return a + b
-
- @asyncio.coroutine
- def add_coroutine(self, a, b, fail, invalid, cancel):
- """Wait 0.05 second and return a + b."""
- yield from asyncio.sleep(0.05, loop=self.loop)
- if cancel:
- asyncio.current_task(self.loop).cancel()
- yield
- return self.add_callback(a, b, fail, invalid)
-
- def target_callback(self, fail=False, invalid=False):
- """Run add callback in the event loop."""
- future = hasync.run_callback_threadsafe(
- self.loop, self.add_callback, 1, 2, fail, invalid
- )
- try:
- return future.result()
- finally:
- future.done() or future.cancel()
-
- def target_coroutine(
- self, fail=False, invalid=False, cancel=False, timeout=None, advance_coro=False
- ):
- """Run add coroutine in the event loop."""
- coro = self.add_coroutine(1, 2, fail, invalid, cancel)
- future = hasync.run_coroutine_threadsafe(coro, self.loop)
- if advance_coro:
- # this is for test_run_coroutine_threadsafe_task_factory_exception;
- # otherwise it spills errors and breaks **other** unittests, since
- # 'target_coroutine' is interacting with threads.
-
- # With this call, `coro` will be advanced, so that
- # CoroWrapper.__del__ won't do anything when asyncio tests run
- # in debug mode.
- self.loop.call_soon_threadsafe(coro.send, None)
- try:
- return future.result(timeout)
- finally:
- future.done() or future.cancel()
-
- def test_run_callback_threadsafe(self):
- """Test callback submission from a thread to an event loop."""
- future = self.loop.run_in_executor(None, self.target_callback)
- result = self.loop.run_until_complete(future)
- self.assertEqual(result, 3)
-
- def test_run_callback_threadsafe_with_exception(self):
- """Test callback submission from thread to event loop on exception."""
- future = self.loop.run_in_executor(None, self.target_callback, True)
- with self.assertRaises(RuntimeError) as exc_context:
- self.loop.run_until_complete(future)
- self.assertIn("Fail!", exc_context.exception.args)
-
- def test_run_callback_threadsafe_with_invalid(self):
- """Test callback submission from thread to event loop on invalid."""
- callback = lambda: self.target_callback(invalid=True) # noqa: E731
- future = self.loop.run_in_executor(None, callback)
- with self.assertRaises(ValueError) as exc_context:
- self.loop.run_until_complete(future)
- self.assertIn("Invalid!", exc_context.exception.args)
-
-
async def test_check_loop_async():
"""Test check_loop detects when called from event loop without integration context."""
with pytest.raises(RuntimeError):
diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py
index f693f3026c5..03d4ee53cbe 100644
--- a/tests/util/test_dt.py
+++ b/tests/util/test_dt.py
@@ -219,6 +219,10 @@ def test_find_next_time_expression_time_basic():
datetime(2018, 10, 7, 10, 30, 0), 5, 0, 0
)
+ assert find(datetime(2018, 10, 7, 10, 30, 0, 999999), "*", "/30", 0) == datetime(
+ 2018, 10, 7, 10, 30, 0
+ )
+
def test_find_next_time_expression_time_dst():
"""Test daylight saving time for find_next_time_expression_time."""
diff --git a/tests/util/test_json.py b/tests/util/test_json.py
index c2b6a428515..af858967150 100644
--- a/tests/util/test_json.py
+++ b/tests/util/test_json.py
@@ -149,12 +149,18 @@ def test_find_unserializable_data():
bad_data = object()
- assert find_paths_unserializable_data(
- [State("mock_domain.mock_entity", "on", {"bad": bad_data})],
- dump=partial(dumps, cls=MockJSONEncoder),
- ) == {"$[0](state: mock_domain.mock_entity).attributes.bad": bad_data}
+ assert (
+ find_paths_unserializable_data(
+ [State("mock_domain.mock_entity", "on", {"bad": bad_data})],
+ dump=partial(dumps, cls=MockJSONEncoder),
+ )
+ == {"$[0](state: mock_domain.mock_entity).attributes.bad": bad_data}
+ )
- assert find_paths_unserializable_data(
- [Event("bad_event", {"bad_attribute": bad_data})],
- dump=partial(dumps, cls=MockJSONEncoder),
- ) == {"$[0](event: bad_event).data.bad_attribute": bad_data}
+ assert (
+ find_paths_unserializable_data(
+ [Event("bad_event", {"bad_attribute": bad_data})],
+ dump=partial(dumps, cls=MockJSONEncoder),
+ )
+ == {"$[0](event: bad_event).data.bad_attribute": bad_data}
+ )
diff --git a/tests/util/test_package.py b/tests/util/test_package.py
index c9f5183249e..064d1379e08 100644
--- a/tests/util/test_package.py
+++ b/tests/util/test_package.py
@@ -216,8 +216,7 @@ def test_install_find_links(mock_sys, mock_popen, mock_env_copy, mock_venv):
assert mock_popen.return_value.communicate.call_count == 1
-@asyncio.coroutine
-def test_async_get_user_site(mock_env_copy):
+async def test_async_get_user_site(mock_env_copy):
"""Test async get user site directory."""
deps_dir = "/deps_dir"
env = mock_env_copy()
@@ -227,7 +226,7 @@ def test_async_get_user_site(mock_env_copy):
"homeassistant.util.package.asyncio.create_subprocess_exec",
return_value=mock_async_subprocess(),
) as popen_mock:
- ret = yield from package.async_get_user_site(deps_dir)
+ ret = await package.async_get_user_site(deps_dir)
assert popen_mock.call_count == 1
assert popen_mock.call_args == call(
*args,
diff --git a/tests/util/test_uuid.py b/tests/util/test_uuid.py
new file mode 100644
index 00000000000..0debb867341
--- /dev/null
+++ b/tests/util/test_uuid.py
@@ -0,0 +1,11 @@
+"""Test Home Assistant uuid util methods."""
+
+import uuid
+
+import homeassistant.util.uuid as uuid_util
+
+
+async def test_uuid_v1mc_hex():
+ """Verify we can generate a uuid_v1mc and return hex."""
+ assert len(uuid_util.uuid_v1mc_hex()) == 32
+ assert uuid.UUID(uuid_util.uuid_v1mc_hex())
diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py
index 4d6f4ce3ac9..96b6a86a27d 100644
--- a/tests/util/test_yaml.py
+++ b/tests/util/test_yaml.py
@@ -354,7 +354,7 @@ class TestSecrets(unittest.TestCase):
assert expected == self._yaml["component"]
def test_secrets_from_parent_folder(self):
- """Test loading secrets from parent foler."""
+ """Test loading secrets from parent folder."""
expected = {"api_password": "pwhttp"}
self._yaml = load_yaml(
os.path.join(self._sub_folder_path, "sub.yaml"),
diff --git a/tox.ini b/tox.ini
index 45759d624bf..7829a7a98d7 100644
--- a/tox.ini
+++ b/tox.ini
@@ -18,6 +18,7 @@ deps =
-r{toxinidir}/requirements_test_all.txt
[testenv:pylint]
+skip_install = True
ignore_errors = True
deps =
-r{toxinidir}/requirements_all.txt