From 8255bdf3d5e9dc4c45c5ef4f07d7f0a76f7d5845 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Wed, 19 Feb 2020 09:19:26 -0500 Subject: [PATCH 001/416] Bump ZHA quirks and add skip configuration support (#31982) * add skip configuration * Bump quirks * add skip configuration to FakeDevice --- homeassistant/components/zha/core/channels/__init__.py | 10 +++++----- homeassistant/components/zha/core/channels/security.py | 5 ++--- homeassistant/components/zha/core/device.py | 5 +++++ homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zha/common.py | 1 + 7 files changed, 16 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index a5ecf21e0c3..d899f51b487 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -194,8 +194,7 @@ class ZigbeeChannel(LogMixin): async def async_configure(self): """Set cluster binding and attribute reporting.""" - # Xiaomi devices don't need this and it disrupts pairing - if self._zha_device.manufacturer != "LUMI": + if not self._zha_device.skip_configuration: await self.bind() if self.cluster.is_server: for report_config in self._report_config: @@ -203,8 +202,9 @@ class ZigbeeChannel(LogMixin): report_config["attr"], report_config["config"] ) await asyncio.sleep(uniform(0.1, 0.5)) - - self.debug("finished channel configuration") + self.debug("finished channel configuration") + else: + self.debug("skipping channel configuration") self._status = ChannelStatus.CONFIGURED async def async_initialize(self, from_cache): @@ -264,7 +264,7 @@ class ZigbeeChannel(LogMixin): def log(self, level, msg, *args): """Log a message.""" msg = f"[%s:%s]: {msg}" - args = (self.device.nwk, self._id,) + args + args = (self.device.nwk, self._id) + args _LOGGER.log(level, msg, *args) def __getattr__(self, name): diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index 69e4ea1a27a..781738fc048 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -146,9 +146,8 @@ class IASZoneChannel(ZigbeeChannel): async def async_configure(self): """Configure IAS device.""" - # Xiaomi devices don't need this and it disrupts pairing - if self._zha_device.manufacturer == "LUMI": - self.debug("finished IASZoneChannel configuration") + if self._zha_device.skip_configuration: + self.debug("skipping IASZoneChannel configuration") return self.debug("started IASZoneChannel configuration") diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 8810fd77fe7..2e7c48c639f 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -213,6 +213,11 @@ class ZHADevice(LogMixin): if Groups.cluster_id in clusters: return True + @property + def skip_configuration(self): + """Return true if the device should not issue configuration related commands.""" + return self._zigpy_device.skip_configuration + @property def gateway(self): """Return the gateway for this device.""" diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index c0b7a0719f8..16c5604587d 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ "bellows-homeassistant==0.13.2", - "zha-quirks==0.0.32", + "zha-quirks==0.0.33", "zigpy-cc==0.1.0", "zigpy-deconz==0.7.0", "zigpy-homeassistant==0.13.2", diff --git a/requirements_all.txt b/requirements_all.txt index b72a61f57f3..9bde09768bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2130,7 +2130,7 @@ zengge==0.2 zeroconf==0.24.4 # homeassistant.components.zha -zha-quirks==0.0.32 +zha-quirks==0.0.33 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 48863e32db9..86f8e9f12cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -732,7 +732,7 @@ yahooweather==0.10 zeroconf==0.24.4 # homeassistant.components.zha -zha-quirks==0.0.32 +zha-quirks==0.0.33 # homeassistant.components.zha zigpy-cc==0.1.0 diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index a9f040eda68..03b6ed21148 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -72,6 +72,7 @@ class FakeDevice: self.last_seen = time.time() self.status = 2 self.initializing = False + self.skip_configuration = False self.manufacturer = manufacturer self.model = model self.node_desc = zigpy.zdo.types.NodeDescriptor() From 4e765398cc6400598a275e816b258d5cc7498e54 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 19 Feb 2020 06:56:46 -0800 Subject: [PATCH 002/416] =?UTF-8?q?Only=20check=20frontend=20for=20safe=20?= =?UTF-8?q?mode=20if=20frontend=20wanted=20to=20be=20loa=E2=80=A6=20(#3196?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Only check frontend for safe mode if frontend wanted to be loaded * Update test --- homeassistant/bootstrap.py | 7 +++++-- tests/test_bootstrap.py | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 723e3c512e2..7d4155257db 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -20,7 +20,7 @@ from homeassistant.const import ( REQUIRED_NEXT_PYTHON_VER, ) from homeassistant.exceptions import HomeAssistantError -from homeassistant.setup import async_setup_component +from homeassistant.setup import DATA_SETUP, async_setup_component from homeassistant.util.logging import AsyncHandler from homeassistant.util.package import async_get_user_site, is_virtual_env from homeassistant.util.yaml import clear_secret_cache @@ -102,7 +102,10 @@ async def async_setup_hass( _LOGGER.warning("Unable to set up core integrations. Activating safe mode") safe_mode = True - elif "frontend" not in hass.config.components: + elif ( + "frontend" in hass.data.get(DATA_SETUP, {}) + and "frontend" not in hass.config.components + ): _LOGGER.warning("Detected that frontend did not load. Activating safe mode") # Ask integrations to shut down. It's messy but we can't # do a clean stop without knowing what is broken diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index dac32b4728d..34704ddfb74 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -400,7 +400,8 @@ async def test_setup_safe_mode_if_no_frontend( log_no_color = Mock() with patch( - "homeassistant.config.async_hass_config_yaml", return_value={"browser": {}} + "homeassistant.config.async_hass_config_yaml", + return_value={"map": {}, "person": {"invalid": True}}, ): hass = await bootstrap.async_setup_hass( config_dir=get_test_config_dir(), From 6f8f23238ad2db3acfc949cb497dca33dd604ab4 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 19 Feb 2020 17:16:02 +0100 Subject: [PATCH 003/416] Nuki: add support for unique id (#31824) * Nuki support unique id and the battery level attribute * Fix isort * Address comments * Cache attribute * Cleanup * Restore false * Fix isort --- homeassistant/components/nuki/lock.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 7fda26b2900..943dbc02fbf 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -77,26 +77,28 @@ class NukiLock(LockDevice): def __init__(self, nuki_lock): """Initialize the lock.""" self._nuki_lock = nuki_lock - self._locked = nuki_lock.is_locked - self._name = nuki_lock.name - self._battery_critical = nuki_lock.battery_critical self._available = nuki_lock.state not in ERROR_STATES @property def name(self): """Return the name of the lock.""" - return self._name + return self._nuki_lock.name + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._nuki_lock.nuki_id @property def is_locked(self): """Return true if lock is locked.""" - return self._locked + return self._nuki_lock.is_locked @property def device_state_attributes(self): """Return the device specific state attributes.""" data = { - ATTR_BATTERY_CRITICAL: self._battery_critical, + ATTR_BATTERY_CRITICAL: self._nuki_lock.battery_critical, ATTR_NUKI_ID: self._nuki_lock.nuki_id, } return data @@ -119,17 +121,13 @@ class NukiLock(LockDevice): except RequestException: _LOGGER.warning("Network issues detect with %s", self.name) self._available = False - return + continue # If in error state, we force an update and repoll data self._available = self._nuki_lock.state not in ERROR_STATES if self._available: break - self._name = self._nuki_lock.name - self._locked = self._nuki_lock.is_locked - self._battery_critical = self._nuki_lock.battery_critical - def lock(self, **kwargs): """Lock the device.""" self._nuki_lock.lock() From 5839df39b5d84073f98f8c8b0fdb99b611bad0fb Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Thu, 20 Feb 2020 00:31:46 +0000 Subject: [PATCH 004/416] [ci skip] Translation update --- .../components/deconz/.translations/ca.json | 13 ++++++----- .../components/deconz/.translations/it.json | 3 ++- .../components/deconz/.translations/no.json | 3 ++- .../components/deconz/.translations/ru.json | 3 ++- .../deconz/.translations/zh-Hant.json | 3 ++- .../components/demo/.translations/es.json | 16 ++++++++++++++ .../components/demo/.translations/no.json | 17 ++++++++++++++ .../components/demo/.translations/ru.json | 17 ++++++++++++++ .../components/mqtt/.translations/ca.json | 22 +++++++++++++++++++ .../components/mqtt/.translations/es.json | 12 ++++++++++ .../components/mqtt/.translations/it.json | 22 +++++++++++++++++++ .../components/mqtt/.translations/no.json | 22 +++++++++++++++++++ .../components/mqtt/.translations/pl.json | 22 +++++++++++++++++++ .../components/mqtt/.translations/ru.json | 22 +++++++++++++++++++ .../mqtt/.translations/zh-Hant.json | 22 +++++++++++++++++++ .../components/plex/.translations/ca.json | 2 ++ .../components/plex/.translations/en.json | 2 ++ .../components/plex/.translations/es.json | 2 ++ .../components/plex/.translations/it.json | 2 ++ .../components/plex/.translations/no.json | 2 ++ .../components/plex/.translations/ru.json | 2 ++ .../plex/.translations/zh-Hant.json | 2 ++ .../components/unifi/.translations/ca.json | 9 ++++++-- .../components/unifi/.translations/es.json | 7 ++++-- .../components/unifi/.translations/it.json | 11 +++++++--- .../components/unifi/.translations/no.json | 9 ++++++-- .../components/unifi/.translations/ru.json | 11 +++++++--- .../unifi/.translations/zh-Hant.json | 11 +++++++--- .../components/vilfo/.translations/es.json | 2 +- .../components/zha/.translations/ca.json | 10 ++++----- .../components/zha/.translations/pl.json | 16 +++++++------- 31 files changed, 280 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/deconz/.translations/ca.json b/homeassistant/components/deconz/.translations/ca.json index 8a9ae15a7c1..e690d597dce 100644 --- a/homeassistant/components/deconz/.translations/ca.json +++ b/homeassistant/components/deconz/.translations/ca.json @@ -66,16 +66,16 @@ }, "trigger_type": { "remote_awakened": "Dispositiu despertat", - "remote_button_double_press": "Bot\u00f3 \"{subtype}\" clicat dues vegades consecutives", - "remote_button_long_press": "Bot\u00f3 \"{subtype}\" premut continuament", + "remote_button_double_press": "Bot\u00f3 \"{subtype}\" clicat dues vegades", + "remote_button_long_press": "Bot\u00f3 \"{subtype}\" premut cont\u00ednuament", "remote_button_long_release": "Bot\u00f3 \"{subtype}\" alliberat despr\u00e9s d'una estona premut", - "remote_button_quadruple_press": "Bot\u00f3 \"{subtype}\" clicat quatre vegades consecutives", - "remote_button_quintuple_press": "Bot\u00f3 \"{subtype}\" clicat cinc vegades consecutives", + "remote_button_quadruple_press": "Bot\u00f3 \"{subtype}\" clicat quatre vegades", + "remote_button_quintuple_press": "Bot\u00f3 \"{subtype}\" clicat cinc vegades", "remote_button_rotated": "Bot\u00f3 \"{subtype}\" girat", "remote_button_rotation_stopped": "La rotaci\u00f3 del bot\u00f3 \"{subtype}\" s'ha aturat", "remote_button_short_press": "Bot\u00f3 \"{subtype}\" premut", "remote_button_short_release": "Bot\u00f3 \"{subtype}\" alliberat", - "remote_button_triple_press": "Bot\u00f3 \"{subtype}\" clicat tres vegades consecutives", + "remote_button_triple_press": "Bot\u00f3 \"{subtype}\" clicat tres vegades", "remote_double_tap": "Dispositiu \"{subtype}\" tocat dues vegades", "remote_double_tap_any_side": "Dispositiu tocat dues vegades a alguna cara", "remote_falling": "Dispositiu en caiguda lliure", @@ -108,7 +108,8 @@ "allow_clip_sensor": "Permet sensors deCONZ CLIP", "allow_deconz_groups": "Permet grups de llums deCONZ" }, - "description": "Configura la visibilitat dels tipus dels dispositius deCONZ" + "description": "Configura la visibilitat dels tipus dels dispositius deCONZ", + "title": "Opcions de deCONZ" } } } diff --git a/homeassistant/components/deconz/.translations/it.json b/homeassistant/components/deconz/.translations/it.json index 980409d6987..f6223cec6c1 100644 --- a/homeassistant/components/deconz/.translations/it.json +++ b/homeassistant/components/deconz/.translations/it.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "Consentire sensori CLIP deCONZ", "allow_deconz_groups": "Consentire gruppi luce deCONZ" }, - "description": "Configurare la visibilit\u00e0 dei tipi di dispositivi deCONZ" + "description": "Configurare la visibilit\u00e0 dei tipi di dispositivi deCONZ", + "title": "Opzioni deCONZ" } } } diff --git a/homeassistant/components/deconz/.translations/no.json b/homeassistant/components/deconz/.translations/no.json index d6133542c64..3387c993ae0 100644 --- a/homeassistant/components/deconz/.translations/no.json +++ b/homeassistant/components/deconz/.translations/no.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "Tillat deCONZ CLIP-sensorer", "allow_deconz_groups": "Tillat deCONZ lys grupper" }, - "description": "Konfigurere synlighet av deCONZ enhetstyper" + "description": "Konfigurere synlighet av deCONZ enhetstyper", + "title": "deCONZ alternativer" } } } diff --git a/homeassistant/components/deconz/.translations/ru.json b/homeassistant/components/deconz/.translations/ru.json index 3c61e447bca..3024915f5b1 100644 --- a/homeassistant/components/deconz/.translations/ru.json +++ b/homeassistant/components/deconz/.translations/ru.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0434\u0430\u0442\u0447\u0438\u043a\u0438 deCONZ CLIP", "allow_deconz_groups": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0441\u0432\u0435\u0449\u0435\u043d\u0438\u044f deCONZ" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u0442\u0438\u043f\u043e\u0432 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 deCONZ" + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u0442\u0438\u043f\u043e\u0432 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 deCONZ", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 deCONZ" } } } diff --git a/homeassistant/components/deconz/.translations/zh-Hant.json b/homeassistant/components/deconz/.translations/zh-Hant.json index 96ab68a8dbb..073ebd784c6 100644 --- a/homeassistant/components/deconz/.translations/zh-Hant.json +++ b/homeassistant/components/deconz/.translations/zh-Hant.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "\u5141\u8a31 deCONZ CLIP \u611f\u61c9\u5668", "allow_deconz_groups": "\u5141\u8a31 deCONZ \u71c8\u5149\u7fa4\u7d44" }, - "description": "\u8a2d\u5b9a deCONZ \u53ef\u8996\u8a2d\u5099\u985e\u578b" + "description": "\u8a2d\u5b9a deCONZ \u53ef\u8996\u8a2d\u5099\u985e\u578b", + "title": "deCONZ \u9078\u9805" } } } diff --git a/homeassistant/components/demo/.translations/es.json b/homeassistant/components/demo/.translations/es.json index ef01fcb4f3c..29c99c94971 100644 --- a/homeassistant/components/demo/.translations/es.json +++ b/homeassistant/components/demo/.translations/es.json @@ -1,5 +1,21 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "Booleano opcional", + "int": "Entrada num\u00e9rica" + } + }, + "options_2": { + "data": { + "multi": "Multiselecci\u00f3n", + "select": "Selecciona una opci\u00f3n" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/no.json b/homeassistant/components/demo/.translations/no.json index ef01fcb4f3c..a46606621b9 100644 --- a/homeassistant/components/demo/.translations/no.json +++ b/homeassistant/components/demo/.translations/no.json @@ -1,5 +1,22 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "Valgfri boolean", + "int": "Numerisk inndata" + } + }, + "options_2": { + "data": { + "multi": "Flervalg", + "select": "Velg et alternativ", + "string": "Strengverdi" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/ru.json b/homeassistant/components/demo/.translations/ru.json index 0438252a429..22ea3d2e196 100644 --- a/homeassistant/components/demo/.translations/ru.json +++ b/homeassistant/components/demo/.translations/ru.json @@ -1,5 +1,22 @@ { "config": { "title": "\u0414\u0435\u043c\u043e" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "\u041b\u043e\u0433\u0438\u0447\u0435\u0441\u043a\u0438\u0439", + "int": "\u0427\u0438\u0441\u043b\u043e\u0432\u043e\u0439" + } + }, + "options_2": { + "data": { + "multi": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u043d\u0435\u0441\u043a\u043e\u043b\u044c\u043a\u043e", + "select": "\u0412\u044b\u0431\u0440\u0430\u0442\u044c \u043e\u043f\u0446\u0438\u044e", + "string": "\u0421\u0442\u0440\u043e\u043a\u043e\u0432\u043e\u0435 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u0435" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ca.json b/homeassistant/components/mqtt/.translations/ca.json index 47dc4d344bc..b8cce4bd808 100644 --- a/homeassistant/components/mqtt/.translations/ca.json +++ b/homeassistant/components/mqtt/.translations/ca.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "Primer bot\u00f3", + "button_2": "Segon bot\u00f3", + "button_3": "Tercer bot\u00f3", + "button_4": "Quart bot\u00f3", + "button_5": "Cinqu\u00e8 bot\u00f3", + "button_6": "Sis\u00e8 bot\u00f3", + "turn_off": "Desactiva", + "turn_on": "Activa" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" clicat dues vegades", + "button_long_press": "\"{subtype}\" premut cont\u00ednuament", + "button_long_release": "\"{subtype}\" alliberat despr\u00e9s d'una estona premut", + "button_quadruple_press": "\"{subtype}\" clicat quatre vegades", + "button_quintuple_press": "\"{subtype}\" clicat cinc vegades", + "button_short_press": "\"{subtype}\" premut", + "button_short_release": "\"{subtype}\" alliberat", + "button_triple_press": "\"{subtype}\" clicat tres vegades" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/es.json b/homeassistant/components/mqtt/.translations/es.json index e0c94ac621a..4870c0b129c 100644 --- a/homeassistant/components/mqtt/.translations/es.json +++ b/homeassistant/components/mqtt/.translations/es.json @@ -27,5 +27,17 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "Primer bot\u00f3n", + "button_2": "Segundo bot\u00f3n", + "button_3": "Tercer bot\u00f3n", + "button_4": "Cuarto bot\u00f3n", + "button_5": "Quinto bot\u00f3n", + "button_6": "Sexto bot\u00f3n", + "turn_off": "Apagar", + "turn_on": "Encender" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/it.json b/homeassistant/components/mqtt/.translations/it.json index cf2b3ddf7d5..45f7f8dcdb5 100644 --- a/homeassistant/components/mqtt/.translations/it.json +++ b/homeassistant/components/mqtt/.translations/it.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "Primo pulsante", + "button_2": "Secondo pulsante", + "button_3": "Terzo pulsante", + "button_4": "Quarto pulsante", + "button_5": "Quinto pulsante", + "button_6": "Sesto pulsante", + "turn_off": "Spegni", + "turn_on": "Accendi" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" cliccato due volte", + "button_long_press": "\"{subtype}\" premuto continuamente", + "button_long_release": "\"{subtype}\" rilasciato dopo una lunga pressione", + "button_quadruple_press": "\"{subtype}\" cliccato quattro volte", + "button_quintuple_press": "\"{subtype}\" cliccato cinque volte", + "button_short_press": "\"{subtype}\" premuto", + "button_short_release": "\"{subtype}\" rilasciato", + "button_triple_press": "\"{subtype}\" cliccato tre volte" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/no.json b/homeassistant/components/mqtt/.translations/no.json index 8dcc0bded9f..27a77a25226 100644 --- a/homeassistant/components/mqtt/.translations/no.json +++ b/homeassistant/components/mqtt/.translations/no.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "F\u00f8rste knapp", + "button_2": "Andre knapp", + "button_3": "Tredje knapp", + "button_4": "Fjerde knapp", + "button_5": "Femte knapp", + "button_6": "Sjette knapp", + "turn_off": "Skru av", + "turn_on": "Sl\u00e5 p\u00e5" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" dobbeltklikket", + "button_long_press": "{subtype}\" trykket kontinuerlig", + "button_long_release": "\"{subtype}\" utgitt etter lang trykk", + "button_quadruple_press": "\"{subtype}\" firedoblet klikket", + "button_quintuple_press": "\"{subtype}\" quintuple klikket", + "button_short_press": "{subtype}\u00bb trykket", + "button_short_release": "\"{subtype}\" utgitt", + "button_triple_press": "\"{subtype}\" trippel klikket" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/pl.json b/homeassistant/components/mqtt/.translations/pl.json index 24cdeb0f12e..86561f89d2b 100644 --- a/homeassistant/components/mqtt/.translations/pl.json +++ b/homeassistant/components/mqtt/.translations/pl.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "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", + "turn_off": "nast\u0105pi wy\u0142\u0105czenie", + "turn_on": "nast\u0105pi w\u0142\u0105czenie" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" zostanie podw\u00f3jnie naci\u015bni\u0119ty", + "button_long_press": "\"{subtype}\" zostanie naci\u015bni\u0119ty w spos\u00f3b ci\u0105g\u0142y", + "button_long_release": "\"{subtype}\" zostanie zwolniony po d\u0142ugim naci\u015bni\u0119ciu", + "button_quadruple_press": "\"{subtype}\" zostanie czterokrotnie naci\u015bni\u0119ty", + "button_quintuple_press": "\"{subtype}\" zostanie pi\u0119ciokrotnie naci\u015bni\u0119ty", + "button_short_press": "\"{subtype}\" zostanie naci\u015bni\u0119ty", + "button_short_release": "\"{subtype}\" zostanie zwolniony", + "button_triple_press": "\"{subtype}\" zostanie trzykrotnie naci\u015bni\u0119ty" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/ru.json b/homeassistant/components/mqtt/.translations/ru.json index 925b8cf5ab4..3559fcc6b2b 100644 --- a/homeassistant/components/mqtt/.translations/ru.json +++ b/homeassistant/components/mqtt/.translations/ru.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "\u041f\u0435\u0440\u0432\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_2": "\u0412\u0442\u043e\u0440\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_3": "\u0422\u0440\u0435\u0442\u044c\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_4": "\u0427\u0435\u0442\u0432\u0435\u0440\u0442\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_5": "\u041f\u044f\u0442\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_6": "\u0428\u0435\u0441\u0442\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "turn_off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0430\u0435\u0442\u0441\u044f" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0434\u0432\u0430 \u0440\u0430\u0437\u0430", + "button_long_press": "\"{subtype}\" \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e \u043d\u0430\u0436\u0430\u0442\u0430", + "button_long_release": "\"{subtype}\" \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430 \u043f\u043e\u0441\u043b\u0435 \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f", + "button_quadruple_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0447\u0435\u0442\u044b\u0440\u0435 \u0440\u0430\u0437\u0430", + "button_quintuple_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u043f\u044f\u0442\u044c \u0440\u0430\u0437", + "button_short_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430", + "button_short_release": "\"{subtype}\" \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430", + "button_triple_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0442\u0440\u0438 \u0440\u0430\u0437\u0430" + } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/zh-Hant.json b/homeassistant/components/mqtt/.translations/zh-Hant.json index 09f2f44a902..3c57e7b6bb0 100644 --- a/homeassistant/components/mqtt/.translations/zh-Hant.json +++ b/homeassistant/components/mqtt/.translations/zh-Hant.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "\u7b2c\u4e00\u500b\u6309\u9215", + "button_2": "\u7b2c\u4e8c\u500b\u6309\u9215", + "button_3": "\u7b2c\u4e09\u500b\u6309\u9215", + "button_4": "\u7b2c\u56db\u500b\u6309\u9215", + "button_5": "\u7b2c\u4e94\u500b\u6309\u9215", + "button_6": "\u7b2c\u516d\u500b\u6309\u9215", + "turn_off": "\u95dc\u9589", + "turn_on": "\u958b\u555f" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" \u96d9\u64ca", + "button_long_press": "\"{subtype}\" \u6301\u7e8c\u6309\u4e0b", + "button_long_release": "\"{subtype}\" \u9577\u6309\u5f8c\u91cb\u653e", + "button_quadruple_press": "\"{subtype}\" \u56db\u9023\u64ca", + "button_quintuple_press": "\"{subtype}\" \u4e94\u9023\u64ca", + "button_short_press": "\"{subtype}\" \u6309\u4e0b", + "button_short_release": "\"{subtype}\" \u91cb\u653e", + "button_triple_press": "\"{subtype}\" \u4e09\u9023\u64ca" + } } } \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/ca.json b/homeassistant/components/plex/.translations/ca.json index 63cf65b8d6c..d562d62b602 100644 --- a/homeassistant/components/plex/.translations/ca.json +++ b/homeassistant/components/plex/.translations/ca.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignora els nous usuaris gestionats/compartits", + "monitored_users": "Usuaris monitoritzats", "show_all_controls": "Mostra tots els controls", "use_episode_art": "Utilitza imatges de l'episodi" }, diff --git a/homeassistant/components/plex/.translations/en.json b/homeassistant/components/plex/.translations/en.json index b75589e3a81..4567171af77 100644 --- a/homeassistant/components/plex/.translations/en.json +++ b/homeassistant/components/plex/.translations/en.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignore new managed/shared users", + "monitored_users": "Monitored users", "show_all_controls": "Show all controls", "use_episode_art": "Use episode art" }, diff --git a/homeassistant/components/plex/.translations/es.json b/homeassistant/components/plex/.translations/es.json index 53dd3228288..24127a7332c 100644 --- a/homeassistant/components/plex/.translations/es.json +++ b/homeassistant/components/plex/.translations/es.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignorar nuevos usuarios administrados/compartidos", + "monitored_users": "Usuarios monitorizados", "show_all_controls": "Mostrar todos los controles", "use_episode_art": "Usar el arte de episodios" }, diff --git a/homeassistant/components/plex/.translations/it.json b/homeassistant/components/plex/.translations/it.json index 0cf7b943fd2..e5ff4e01dc0 100644 --- a/homeassistant/components/plex/.translations/it.json +++ b/homeassistant/components/plex/.translations/it.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignora nuovi utenti gestiti/condivisi", + "monitored_users": "Utenti monitorati", "show_all_controls": "Mostra tutti i controlli", "use_episode_art": "Usa la grafica dell'episodio" }, diff --git a/homeassistant/components/plex/.translations/no.json b/homeassistant/components/plex/.translations/no.json index 7d2b7cf2760..c80ba5f2e06 100644 --- a/homeassistant/components/plex/.translations/no.json +++ b/homeassistant/components/plex/.translations/no.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignorer nye administrerte/delte brukere", + "monitored_users": "Overv\u00e5kede brukere", "show_all_controls": "Vis alle kontroller", "use_episode_art": "Bruk episode bilde" }, diff --git a/homeassistant/components/plex/.translations/ru.json b/homeassistant/components/plex/.translations/ru.json index 334a4e353d4..2da10b1e8c4 100644 --- a/homeassistant/components/plex/.translations/ru.json +++ b/homeassistant/components/plex/.translations/ru.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "\u0418\u0433\u043d\u043e\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043d\u043e\u0432\u044b\u0445 \u0443\u043f\u0440\u0430\u0432\u043b\u044f\u0435\u043c\u044b\u0445/\u043e\u0431\u0449\u0438\u0445 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u0439", + "monitored_users": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0435\u043c\u044b\u0435 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438", "show_all_controls": "\u041f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u0432\u0441\u0435 \u044d\u043b\u0435\u043c\u0435\u043d\u0442\u044b \u0443\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f", "use_episode_art": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043e\u0431\u043b\u043e\u0436\u043a\u0438 \u044d\u043f\u0438\u0437\u043e\u0434\u043e\u0432" }, diff --git a/homeassistant/components/plex/.translations/zh-Hant.json b/homeassistant/components/plex/.translations/zh-Hant.json index 5c05d2104f9..436333b0a79 100644 --- a/homeassistant/components/plex/.translations/zh-Hant.json +++ b/homeassistant/components/plex/.translations/zh-Hant.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "\u5ffd\u7565\u65b0\u589e\u7ba1\u7406/\u5206\u4eab\u4f7f\u7528\u8005", + "monitored_users": "\u5df2\u76e3\u63a7\u4f7f\u7528\u8005", "show_all_controls": "\u986f\u793a\u6240\u6709\u63a7\u5236", "use_episode_art": "\u4f7f\u7528\u5f71\u96c6\u5287\u7167" }, diff --git a/homeassistant/components/unifi/.translations/ca.json b/homeassistant/components/unifi/.translations/ca.json index 899b532290e..89d299a2857 100644 --- a/homeassistant/components/unifi/.translations/ca.json +++ b/homeassistant/components/unifi/.translations/ca.json @@ -28,10 +28,13 @@ "device_tracker": { "data": { "detection_time": "Temps (en segons) des de s'ha vist per \u00faltima vegada fins que es considera a fora", + "ssid_filter": "Selecciona els SSID's on fer-hi el seguiment de clients", "track_clients": "Segueix clients de la xarxa", "track_devices": "Segueix dispositius de la xarxa (dispositius Ubiquiti)", "track_wired_clients": "Inclou clients de xarxa per cable" - } + }, + "description": "Configuraci\u00f3 de seguiment de dispositius", + "title": "Opcions d'UniFi" }, "init": { "data": { @@ -42,7 +45,9 @@ "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Crea sensors d'\u00fas d'ample de banda per a clients de la xarxa" - } + }, + "description": "Configuraci\u00f3 dels sensors d\u2019estad\u00edstiques", + "title": "Opcions d'UniFi" } } } diff --git a/homeassistant/components/unifi/.translations/es.json b/homeassistant/components/unifi/.translations/es.json index 677899c0958..9d749bdd08a 100644 --- a/homeassistant/components/unifi/.translations/es.json +++ b/homeassistant/components/unifi/.translations/es.json @@ -31,7 +31,9 @@ "track_clients": "Seguimiento de los clientes de red", "track_devices": "Rastree dispositivos de red (dispositivos Ubiquiti)", "track_wired_clients": "Incluir clientes de red cableada" - } + }, + "description": "Configurar dispositivo de seguimiento", + "title": "Opciones UniFi" }, "init": { "data": { @@ -42,7 +44,8 @@ "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Crear sensores para monitorizar uso de ancho de banda de clientes de red" - } + }, + "title": "Opciones UniFi" } } } diff --git a/homeassistant/components/unifi/.translations/it.json b/homeassistant/components/unifi/.translations/it.json index 80b546ebcf8..c1aa9afe54f 100644 --- a/homeassistant/components/unifi/.translations/it.json +++ b/homeassistant/components/unifi/.translations/it.json @@ -28,10 +28,13 @@ "device_tracker": { "data": { "detection_time": "Tempo in secondi dall'ultima volta che viene visto fino a quando non \u00e8 considerato lontano", + "ssid_filter": "Selezionare gli SSID su cui tracciare i client wireless", "track_clients": "Traccia i client di rete", "track_devices": "Tracciare i dispositivi di rete (dispositivi Ubiquiti)", "track_wired_clients": "Includi i client di rete cablata" - } + }, + "description": "Configurare il tracciamento del dispositivo", + "title": "Opzioni UniFi" }, "init": { "data": { @@ -41,8 +44,10 @@ }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "Creare sensori di utilizzo della larghezza di banda per i client di rete" - } + "allow_bandwidth_sensors": "Sensori di utilizzo della larghezza di banda per i client di rete" + }, + "description": "Configurare i sensori delle statistiche", + "title": "Opzioni UniFi" } } } diff --git a/homeassistant/components/unifi/.translations/no.json b/homeassistant/components/unifi/.translations/no.json index 3448c291325..1cf94b9e296 100644 --- a/homeassistant/components/unifi/.translations/no.json +++ b/homeassistant/components/unifi/.translations/no.json @@ -28,15 +28,20 @@ "device_tracker": { "data": { "detection_time": "Tid i sekunder fra sist sett til den ble ansett borte", + "ssid_filter": "Velg SSID-er for \u00e5 spore tr\u00e5dl\u00f8se klienter p\u00e5", "track_clients": "Spor nettverksklienter", "track_devices": "Spore nettverksenheter (Ubiquiti-enheter)", "track_wired_clients": "Inkluder kablede nettverksklienter" - } + }, + "description": "Konfigurere enhetssporing", + "title": "UniFi-alternativer" }, "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Opprett b\u00e5ndbreddesensorer for nettverksklienter" - } + }, + "description": "Konfigurer statistikk sensorer", + "title": "UniFi-alternativer" } } } diff --git a/homeassistant/components/unifi/.translations/ru.json b/homeassistant/components/unifi/.translations/ru.json index 3a67d483c0c..2cb66f2e374 100644 --- a/homeassistant/components/unifi/.translations/ru.json +++ b/homeassistant/components/unifi/.translations/ru.json @@ -28,10 +28,13 @@ "device_tracker": { "data": { "detection_time": "\u0412\u0440\u0435\u043c\u044f \u043e\u0442 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u0435\u0433\u043e \u0441\u0435\u0430\u043d\u0441\u0430 \u0441\u0432\u044f\u0437\u0438 \u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c (\u0441\u0435\u043a.), \u043f\u043e \u0438\u0441\u0442\u0435\u0447\u0435\u043d\u0438\u044e \u043a\u043e\u0442\u043e\u0440\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442 \u0441\u0442\u0430\u0442\u0443\u0441 \"\u041d\u0435 \u0434\u043e\u043c\u0430\".", + "ssid_filter": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 SSID \u0434\u043b\u044f \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u043d\u0438\u044f \u0431\u0435\u0441\u043f\u0440\u043e\u0432\u043e\u0434\u043d\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432", "track_clients": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u043d\u0438\u0435 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u0441\u0435\u0442\u0438", "track_devices": "\u041e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u043d\u0438\u0435 \u0441\u0435\u0442\u0435\u0432\u044b\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 (\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Ubiquiti)", "track_wired_clients": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432 \u043f\u0440\u043e\u0432\u043e\u0434\u043d\u043e\u0439 \u0441\u0435\u0442\u0438" - } + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u043d\u0438\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 UniFi" }, "init": { "data": { @@ -43,8 +46,10 @@ }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "\u0421\u043e\u0437\u0434\u0430\u0432\u0430\u0442\u044c \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u043b\u043e\u0441\u044b \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u043d\u0438\u044f \u0434\u043b\u044f \u0441\u0435\u0442\u0435\u0432\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432" - } + "allow_bandwidth_sensors": "\u0414\u0430\u0442\u0447\u0438\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u043b\u043e\u0441\u044b \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u043d\u0438\u044f \u0434\u043b\u044f \u0441\u0435\u0442\u0435\u0432\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432 \u0441\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043a\u0438", + "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 UniFi" } } } diff --git a/homeassistant/components/unifi/.translations/zh-Hant.json b/homeassistant/components/unifi/.translations/zh-Hant.json index 5e0b881af15..cce150a6765 100644 --- a/homeassistant/components/unifi/.translations/zh-Hant.json +++ b/homeassistant/components/unifi/.translations/zh-Hant.json @@ -28,15 +28,20 @@ "device_tracker": { "data": { "detection_time": "\u6700\u7d42\u51fa\u73fe\u5f8c\u8996\u70ba\u96e2\u958b\u7684\u6642\u9593\uff08\u4ee5\u79d2\u70ba\u55ae\u4f4d\uff09", + "ssid_filter": "\u9078\u64c7\u6240\u8981\u8ffd\u8e64\u7684\u7121\u7dda\u7db2\u8def", "track_clients": "\u8ffd\u8e64\u7db2\u8def\u5ba2\u6236\u7aef", "track_devices": "\u8ffd\u8e64\u7db2\u8def\u8a2d\u5099\uff08Ubiquiti \u8a2d\u5099\uff09", "track_wired_clients": "\u5305\u542b\u6709\u7dda\u7db2\u8def\u5ba2\u6236\u7aef" - } + }, + "description": "\u8a2d\u5b9a\u8a2d\u5099\u8ffd\u8e64", + "title": "UniFi \u9078\u9805" }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "\u65b0\u589e\u7db2\u8def\u5ba2\u6236\u7aef\u983b\u5bec\u7528\u91cf\u611f\u61c9\u5668" - } + "allow_bandwidth_sensors": "\u7db2\u8def\u5ba2\u6236\u7aef\u983b\u5bec\u7528\u91cf\u611f\u61c9\u5668" + }, + "description": "\u8a2d\u5b9a\u7d71\u8a08\u6578\u64da\u611f\u61c9\u5668", + "title": "UniFi \u9078\u9805" } } } diff --git a/homeassistant/components/vilfo/.translations/es.json b/homeassistant/components/vilfo/.translations/es.json index abf331a955a..07b1ca3b4ca 100644 --- a/homeassistant/components/vilfo/.translations/es.json +++ b/homeassistant/components/vilfo/.translations/es.json @@ -18,6 +18,6 @@ "title": "Conectar con el Router Vilfo" } }, - "title": "Vilfo Router" + "title": "Router Vilfo" } } \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/ca.json b/homeassistant/components/zha/.translations/ca.json index 2b8230ad689..e5181fb5106 100644 --- a/homeassistant/components/zha/.translations/ca.json +++ b/homeassistant/components/zha/.translations/ca.json @@ -54,14 +54,14 @@ "device_shaken": "Dispositiu sacsejat", "device_slid": "Dispositiu lliscat a \"{subtype}\"", "device_tilted": "Dispositiu inclinat", - "remote_button_double_press": "Bot\u00f3 \"{subtype}\" clicat dues vegades consecutives", - "remote_button_long_press": "Bot\u00f3 \"{subtype}\" premut continuament", + "remote_button_double_press": "Bot\u00f3 \"{subtype}\" clicat dues vegades", + "remote_button_long_press": "Bot\u00f3 \"{subtype}\" premut cont\u00ednuament", "remote_button_long_release": "Bot\u00f3 \"{subtype}\" alliberat despr\u00e9s d'una estona premut", - "remote_button_quadruple_press": "Bot\u00f3 \"{subtype}\" clicat quatre vegades consecutives", - "remote_button_quintuple_press": "Bot\u00f3 \"{subtype}\" clicat cinc vegades consecutives", + "remote_button_quadruple_press": "Bot\u00f3 \"{subtype}\" clicat quatre vegades", + "remote_button_quintuple_press": "Bot\u00f3 \"{subtype}\" clicat cinc vegades", "remote_button_short_press": "Bot\u00f3 \"{subtype}\" premut", "remote_button_short_release": "Bot\u00f3 \"{subtype}\" alliberat", - "remote_button_triple_press": "Bot\u00f3 \"{subtype}\" clicat tres vegades consecutives" + "remote_button_triple_press": "Bot\u00f3 \"{subtype}\" clicat tres vegades" } } } \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/pl.json b/homeassistant/components/zha/.translations/pl.json index 4189ea6d9be..4698a0a37ef 100644 --- a/homeassistant/components/zha/.translations/pl.json +++ b/homeassistant/components/zha/.translations/pl.json @@ -54,14 +54,14 @@ "device_shaken": "nast\u0105pi potrz\u0105\u015bni\u0119cie urz\u0105dzeniem", "device_slid": "nast\u0105pi przesuni\u0119cie urz\u0105dzenia \"{subtype}\"", "device_tilted": "nast\u0105pi przechylenie urz\u0105dzenia", - "remote_button_double_press": "przycisk \"{subtype}\" zostanie podw\u00f3jnie naci\u015bni\u0119ty", - "remote_button_long_press": "przycisk \"{subtype}\" zostanie naci\u015bni\u0119ty w spos\u00f3b ci\u0105g\u0142y", - "remote_button_long_release": "przycisk \"{subtype}\" zostanie zwolniony po d\u0142ugim naci\u015bni\u0119ciu", - "remote_button_quadruple_press": "przycisk \"{subtype}\" czterokrotnie naci\u015bni\u0119ty", - "remote_button_quintuple_press": "przycisk \"{subtype}\" zostanie pi\u0119ciokrotnie naci\u015bni\u0119ty", - "remote_button_short_press": "przycisk \"{subtype}\" zostanie naci\u015bni\u0119ty", - "remote_button_short_release": "przycisk \"{subtype}\" zostanie zwolniony", - "remote_button_triple_press": "przycisk \"{subtype}\" zostanie trzykrotnie naci\u015bni\u0119ty" + "remote_button_double_press": "\"{subtype}\" zostanie podw\u00f3jnie naci\u015bni\u0119ty", + "remote_button_long_press": "\"{subtype}\" zostanie naci\u015bni\u0119ty w spos\u00f3b ci\u0105g\u0142y", + "remote_button_long_release": "\"{subtype}\" zostanie zwolniony po d\u0142ugim naci\u015bni\u0119ciu", + "remote_button_quadruple_press": "\"{subtype}\" czterokrotnie naci\u015bni\u0119ty", + "remote_button_quintuple_press": "\"{subtype}\" zostanie pi\u0119ciokrotnie naci\u015bni\u0119ty", + "remote_button_short_press": "\"{subtype}\" zostanie naci\u015bni\u0119ty", + "remote_button_short_release": "\"{subtype}\" zostanie zwolniony", + "remote_button_triple_press": "\"{subtype}\" zostanie trzykrotnie naci\u015bni\u0119ty" } } } \ No newline at end of file From 832337f26c541ffc20b717c61d7613cfd47dd2ce Mon Sep 17 00:00:00 2001 From: Mark Coombes Date: Thu, 20 Feb 2020 00:05:22 -0500 Subject: [PATCH 005/416] Fix bug in ecobee integration (#32008) * Bump python-ecobee-api to 0.2.1 * Update log messages for clarity * Update requirements_all --- homeassistant/components/ecobee/__init__.py | 6 ++---- homeassistant/components/ecobee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py index 80c3be7954b..26bfbe5b3da 100644 --- a/homeassistant/components/ecobee/__init__.py +++ b/homeassistant/components/ecobee/__init__.py @@ -96,9 +96,7 @@ class EcobeeData: await self._hass.async_add_executor_job(self.ecobee.update) _LOGGER.debug("Updating ecobee") except ExpiredTokenError: - _LOGGER.warning( - "Ecobee update failed; attempting to refresh expired tokens" - ) + _LOGGER.debug("Refreshing expired ecobee tokens") await self.refresh() async def refresh(self) -> bool: @@ -113,7 +111,7 @@ class EcobeeData: }, ) return True - _LOGGER.error("Error updating ecobee tokens") + _LOGGER.error("Error refreshing ecobee tokens") return False diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index 5df89c3d90d..8e21b9931cd 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -4,6 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecobee", "dependencies": [], - "requirements": ["python-ecobee-api==0.1.4"], + "requirements": ["python-ecobee-api==0.2.1"], "codeowners": ["@marthoc"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9bde09768bb..3094bec74ec 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1559,7 +1559,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.13.2 # homeassistant.components.ecobee -python-ecobee-api==0.1.4 +python-ecobee-api==0.2.1 # homeassistant.components.eq3btsmart # python-eq3bt==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 86f8e9f12cc..893c50917d4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -560,7 +560,7 @@ pysonos==0.0.24 pyspcwebgw==0.4.0 # homeassistant.components.ecobee -python-ecobee-api==0.1.4 +python-ecobee-api==0.2.1 # homeassistant.components.darksky python-forecastio==1.4.0 From b6d60c36a54cc362643724e3c3d01c96cbb41a4e Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Thu, 20 Feb 2020 08:21:09 +0200 Subject: [PATCH 006/416] Don't return coroutine in DLNA/DMR service handler (#32011) Fixes #32010 --- homeassistant/components/dlna_dmr/media_player.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index fa6b60d0c19..1e3ba840d6f 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -99,10 +99,10 @@ def catch_request_errors(): """Call wrapper for decorator.""" @functools.wraps(func) - def wrapper(self, *args, **kwargs): + async def wrapper(self, *args, **kwargs): """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" try: - return func(self, *args, **kwargs) + return await func(self, *args, **kwargs) except (asyncio.TimeoutError, aiohttp.ClientError): _LOGGER.error("Error during call %s", func.__name__) From 136ed12ec5b8254345e76eb5ece5e71a928f1fa4 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Thu, 20 Feb 2020 07:27:50 +0100 Subject: [PATCH 007/416] Update pyhomematic to 0.1.65 (#32006) --- homeassistant/components/homematic/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homematic/manifest.json b/homeassistant/components/homematic/manifest.json index edf07c3e4d7..20ea0d6acb1 100644 --- a/homeassistant/components/homematic/manifest.json +++ b/homeassistant/components/homematic/manifest.json @@ -2,7 +2,7 @@ "domain": "homematic", "name": "Homematic", "documentation": "https://www.home-assistant.io/integrations/homematic", - "requirements": ["pyhomematic==0.1.64"], + "requirements": ["pyhomematic==0.1.65"], "dependencies": [], "codeowners": ["@pvizeli", "@danielperna84"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3094bec74ec..3c9172c8048 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1290,7 +1290,7 @@ pyhik==0.2.5 pyhiveapi==0.2.19.3 # homeassistant.components.homematic -pyhomematic==0.1.64 +pyhomematic==0.1.65 # homeassistant.components.homeworks pyhomeworks==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 893c50917d4..8fda5e5609e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -471,7 +471,7 @@ pyhaversion==3.2.0 pyheos==0.6.0 # homeassistant.components.homematic -pyhomematic==0.1.64 +pyhomematic==0.1.65 # homeassistant.components.icloud pyicloud==0.9.2 From 047111b00ffdd68a6f4f8ee77f396564c97d4537 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 20 Feb 2020 00:33:38 -0600 Subject: [PATCH 008/416] Fix Plex sensor title handling (#31973) * Fix Plex sensor title parsing * Revert episode year for now --- homeassistant/components/plex/sensor.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 1caf8ec5f75..b1e93aec8c0 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -109,18 +109,16 @@ class PlexSensor(Entity): now_playing_user = f"{user} - {device}" now_playing_title = "" - if sess.TYPE == "episode": + if sess.TYPE in ["clip", "episode"]: # example: - # "Supernatural (2005) - S01 · E13 - Route 666" + # "Supernatural (2005) - s01e13 - Route 666" season_title = sess.grandparentTitle if sess.show().year is not None: - season_title += " ({0})".format(sess.show().year) - season_episode = "S{0}".format(sess.parentIndex) - if sess.index is not None: - season_episode += f" · E{sess.index}" + season_title += f" ({sess.show().year!s})" + season_episode = sess.seasonEpisode episode_title = sess.title - now_playing_title = "{0} - {1} - {2}".format( - season_title, season_episode, episode_title + now_playing_title = ( + f"{season_title} - {season_episode} - {episode_title}" ) elif sess.TYPE == "track": # example: @@ -128,9 +126,7 @@ class PlexSensor(Entity): track_artist = sess.grandparentTitle track_album = sess.parentTitle track_title = sess.title - now_playing_title = "{0} - {1} - {2}".format( - track_artist, track_album, track_title - ) + now_playing_title = f"{track_artist} - {track_album} - {track_title}" else: # example: # "picture_of_last_summer_camp (2015)" From febd7e551bb125e95c901c720299079c9afbf01e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 20 Feb 2020 11:41:13 +0100 Subject: [PATCH 009/416] Upgrade requests to 2.23.0 (#32013) --- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- setup.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 1cfc92a6aab..796fd5681ff 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ pip>=8.0.3 python-slugify==4.0.0 pytz>=2019.03 pyyaml==5.3 -requests==2.22.0 +requests==2.23.0 ruamel.yaml==0.15.100 sqlalchemy==1.3.13 voluptuous-serialize==2.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index 3c9172c8048..1c41aab99ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -13,7 +13,7 @@ pip>=8.0.3 python-slugify==4.0.0 pytz>=2019.03 pyyaml==5.3 -requests==2.22.0 +requests==2.23.0 ruamel.yaml==0.15.100 voluptuous==0.11.7 voluptuous-serialize==2.3.0 diff --git a/setup.py b/setup.py index 7f9155d9a05..3c7909428be 100755 --- a/setup.py +++ b/setup.py @@ -47,7 +47,7 @@ REQUIRES = [ "python-slugify==4.0.0", "pytz>=2019.03", "pyyaml==5.3", - "requests==2.22.0", + "requests==2.23.0", "ruamel.yaml==0.15.100", "voluptuous==0.11.7", "voluptuous-serialize==2.3.0", From aae64dba628fb582bf2d646a997a783ebd4d344a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 20 Feb 2020 11:43:19 +0100 Subject: [PATCH 010/416] Bumped version to 0.107.0dev0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2f97f6968de..50ce3e084b8 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,6 +1,6 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 106 +MINOR_VERSION = 107 PATCH_VERSION = "0.dev0" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" From d6c185fdf43eb64d74435401427355c87066ac68 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 20 Feb 2020 16:01:03 +0100 Subject: [PATCH 011/416] Deprecate Xfinity Gateway integration (ADR-0004) (#32017) --- homeassistant/components/xfinity/device_tracker.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/xfinity/device_tracker.py b/homeassistant/components/xfinity/device_tracker.py index 20e13682979..832c8bb1d5d 100644 --- a/homeassistant/components/xfinity/device_tracker.py +++ b/homeassistant/components/xfinity/device_tracker.py @@ -24,6 +24,11 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def get_scanner(hass, config): """Validate the configuration and return an Xfinity Gateway scanner.""" + _LOGGER.warning( + "The Xfinity Gateway has been deprecated and will be removed from " + "Home Assistant in version 0.109. Please remove it from your " + "configuration. " + ) gateway = XfinityGateway(config[DOMAIN][CONF_HOST]) scanner = None From 6c9d4a6d15fce74fa70682fd92c1792b623d06ae Mon Sep 17 00:00:00 2001 From: MatthewFlamm <39341281+MatthewFlamm@users.noreply.github.com> Date: Thu, 20 Feb 2020 10:51:15 -0500 Subject: [PATCH 012/416] Add missing name to logging in DataUpdateCoordinator (#32023) --- homeassistant/helpers/update_coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 5f0490b6ea2..fe877fe9bb8 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -128,7 +128,7 @@ class DataUpdateCoordinator: else: if not self.last_update_success: self.last_update_success = True - self.logger.info("Fetching %s data recovered") + self.logger.info("Fetching %s data recovered", self.name) finally: self.logger.debug( From e71c7e1f5e38fdde972f5c2b6b3b0253d7d06fb7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 20 Feb 2020 08:11:27 -0800 Subject: [PATCH 013/416] Fix hue test --- homeassistant/components/hue/__init__.py | 8 +------- tests/components/hue/test_init.py | 3 +-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index dd2905c8783..7510ff22f16 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -46,13 +46,7 @@ CONFIG_SCHEMA = vol.Schema( DOMAIN: vol.Schema( { vol.Optional(CONF_BRIDGES): vol.All( - cv.ensure_list, - [ - vol.All( - cv.deprecated("filename", invalidation_version="0.106.0"), - BRIDGE_CONFIG_SCHEMA, - ), - ], + cv.ensure_list, [BRIDGE_CONFIG_SCHEMA], ) } ) diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py index 375d5da4456..d9131dad226 100644 --- a/tests/components/hue/test_init.py +++ b/tests/components/hue/test_init.py @@ -37,7 +37,7 @@ async def test_setup_defined_hosts_known_auth(hass): hue.CONF_ALLOW_HUE_GROUPS: False, hue.CONF_ALLOW_UNREACHABLE: True, }, - {hue.CONF_HOST: "1.1.1.1", "filename": "bla"}, + {hue.CONF_HOST: "1.1.1.1"}, ] } }, @@ -59,7 +59,6 @@ async def test_setup_defined_hosts_known_auth(hass): hue.CONF_HOST: "1.1.1.1", hue.CONF_ALLOW_HUE_GROUPS: True, hue.CONF_ALLOW_UNREACHABLE: False, - "filename": "bla", }, } From 2ec0a504da24e9e3882ee15348599bd501f50435 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 20 Feb 2020 17:18:50 +0100 Subject: [PATCH 014/416] Remove deprecated Hue options (fixes CI) (#32027) From 7e3841e172617954a25977fdc98e2e7d95135fe4 Mon Sep 17 00:00:00 2001 From: cgtobi Date: Thu, 20 Feb 2020 17:25:49 +0100 Subject: [PATCH 015/416] Bump pyatmo to 3.2.4 (#32018) * Bump pyatmo to 3.2.3 * Bump pyatmo version to 3.2.4 * Update requirements_test_all.txt --- homeassistant/components/netatmo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 14ec2e61b9c..6fe084cc885 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -3,7 +3,7 @@ "name": "Netatmo", "documentation": "https://www.home-assistant.io/integrations/netatmo", "requirements": [ - "pyatmo==3.2.2" + "pyatmo==3.2.4" ], "dependencies": [ "webhook" diff --git a/requirements_all.txt b/requirements_all.txt index 1c41aab99ed..0149c8781b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1145,7 +1145,7 @@ pyalmond==0.0.2 pyarlo==0.2.3 # homeassistant.components.netatmo -pyatmo==3.2.2 +pyatmo==3.2.4 # homeassistant.components.atome pyatome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8fda5e5609e..27f6b0a82dd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -425,7 +425,7 @@ pyalmond==0.0.2 pyarlo==0.2.3 # homeassistant.components.netatmo -pyatmo==3.2.2 +pyatmo==3.2.4 # homeassistant.components.blackbird pyblackbird==0.5 From 2ad1f7fd02f78d23fb59649d1a595c5c1d92a3a3 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 20 Feb 2020 17:26:41 +0100 Subject: [PATCH 016/416] Remove states UI options in group integration (#32021) --- homeassistant/components/group/__init__.py | 107 +------------------ homeassistant/components/group/services.yaml | 28 +---- tests/components/group/common.py | 47 +------- tests/components/group/test_init.py | 55 +--------- 4 files changed, 14 insertions(+), 223 deletions(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 7257959700f..7a5961180a5 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -30,7 +30,6 @@ from homeassistant.const import ( ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change @@ -44,26 +43,18 @@ DOMAIN = "group" ENTITY_ID_FORMAT = DOMAIN + ".{}" CONF_ENTITIES = "entities" -CONF_VIEW = "view" -CONF_CONTROL = "control" CONF_ALL = "all" ATTR_ADD_ENTITIES = "add_entities" ATTR_AUTO = "auto" -ATTR_CONTROL = "control" ATTR_ENTITIES = "entities" ATTR_OBJECT_ID = "object_id" ATTR_ORDER = "order" -ATTR_VIEW = "view" -ATTR_VISIBLE = "visible" ATTR_ALL = "all" -SERVICE_SET_VISIBILITY = "set_visibility" SERVICE_SET = "set" SERVICE_REMOVE = "remove" -CONTROL_TYPES = vol.In(["hidden", None]) - _LOGGER = logging.getLogger(__name__) @@ -76,15 +67,11 @@ def _conf_preprocess(value): GROUP_SCHEMA = vol.All( - cv.deprecated(CONF_CONTROL, invalidation_version="0.107.0"), - cv.deprecated(CONF_VIEW, invalidation_version="0.107.0"), vol.Schema( { vol.Optional(CONF_ENTITIES): vol.Any(cv.entity_ids, None), - CONF_VIEW: cv.boolean, CONF_NAME: cv.string, CONF_ICON: cv.icon, - CONF_CONTROL: CONTROL_TYPES, CONF_ALL: cv.boolean, } ), @@ -257,7 +244,7 @@ async def async_setup(hass, config): extra_arg = { attr: service.data[attr] - for attr in (ATTR_VISIBLE, ATTR_ICON, ATTR_VIEW, ATTR_CONTROL) + for attr in (ATTR_ICON) if service.data.get(attr) is not None } @@ -293,22 +280,10 @@ async def async_setup(hass, config): group.name = service.data[ATTR_NAME] need_update = True - if ATTR_VISIBLE in service.data: - group.visible = service.data[ATTR_VISIBLE] - need_update = True - if ATTR_ICON in service.data: group.icon = service.data[ATTR_ICON] need_update = True - if ATTR_CONTROL in service.data: - group.control = service.data[ATTR_CONTROL] - need_update = True - - if ATTR_VIEW in service.data: - group.view = service.data[ATTR_VIEW] - need_update = True - if ATTR_ALL in service.data: group.mode = all if service.data[ATTR_ALL] else any need_update = True @@ -327,17 +302,11 @@ async def async_setup(hass, config): SERVICE_SET, locked_service_handler, schema=vol.All( - cv.deprecated(ATTR_CONTROL, invalidation_version="0.107.0"), - cv.deprecated(ATTR_VIEW, invalidation_version="0.107.0"), - cv.deprecated(ATTR_VISIBLE, invalidation_version="0.107.0"), vol.Schema( { vol.Required(ATTR_OBJECT_ID): cv.slug, vol.Optional(ATTR_NAME): cv.string, - vol.Optional(ATTR_VIEW): cv.boolean, vol.Optional(ATTR_ICON): cv.string, - vol.Optional(ATTR_CONTROL): CONTROL_TYPES, - vol.Optional(ATTR_VISIBLE): cv.boolean, vol.Optional(ATTR_ALL): cv.boolean, vol.Exclusive(ATTR_ENTITIES, "entities"): cv.entity_ids, vol.Exclusive(ATTR_ADD_ENTITIES, "entities"): cv.entity_ids, @@ -353,32 +322,6 @@ async def async_setup(hass, config): schema=vol.Schema({vol.Required(ATTR_OBJECT_ID): cv.slug}), ) - async def visibility_service_handler(service): - """Change visibility of a group.""" - visible = service.data.get(ATTR_VISIBLE) - - _LOGGER.warning( - "The group.set_visibility service has been deprecated and will" - "be removed in Home Assistant 0.107.0." - ) - - tasks = [] - for group in await component.async_extract_from_service( - service, expand_group=False - ): - group.visible = visible - tasks.append(group.async_update_ha_state()) - - if tasks: - await asyncio.wait(tasks) - - hass.services.async_register( - DOMAIN, - SERVICE_SET_VISIBILITY, - visibility_service_handler, - schema=make_entity_service_schema({vol.Required(ATTR_VISIBLE): cv.boolean}), - ) - return True @@ -388,21 +331,12 @@ async def _async_process_config(hass, config, component): name = conf.get(CONF_NAME, object_id) entity_ids = conf.get(CONF_ENTITIES) or [] icon = conf.get(CONF_ICON) - view = conf.get(CONF_VIEW) - control = conf.get(CONF_CONTROL) mode = conf.get(CONF_ALL) # Don't create tasks and await them all. The order is important as # groups get a number based on creation order. await Group.async_create_group( - hass, - name, - entity_ids, - icon=icon, - view=view, - control=control, - object_id=object_id, - mode=mode, + hass, name, entity_ids, icon=icon, object_id=object_id, mode=mode, ) @@ -414,10 +348,7 @@ class Group(Entity): hass, name, order=None, - visible=True, icon=None, - view=False, - control=None, user_defined=True, entity_ids=None, mode=None, @@ -430,15 +361,12 @@ class Group(Entity): self._name = name self._state = STATE_UNKNOWN self._icon = icon - self.view = view if entity_ids: self.tracking = tuple(ent_id.lower() for ent_id in entity_ids) else: self.tracking = tuple() self.group_on = None self.group_off = None - self.visible = visible - self.control = control self.user_defined = user_defined self.mode = any if mode: @@ -453,26 +381,14 @@ class Group(Entity): name, entity_ids=None, user_defined=True, - visible=True, icon=None, - view=False, - control=None, object_id=None, mode=None, ): """Initialize a group.""" return asyncio.run_coroutine_threadsafe( Group.async_create_group( - hass, - name, - entity_ids, - user_defined, - visible, - icon, - view, - control, - object_id, - mode, + hass, name, entity_ids, user_defined, icon, object_id, mode, ), hass.loop, ).result() @@ -483,10 +399,7 @@ class Group(Entity): name, entity_ids=None, user_defined=True, - visible=True, icon=None, - view=False, - control=None, object_id=None, mode=None, ): @@ -498,10 +411,7 @@ class Group(Entity): hass, name, order=len(hass.states.async_entity_ids(DOMAIN)), - visible=visible, icon=icon, - view=view, - control=control, user_defined=user_defined, entity_ids=entity_ids, mode=mode, @@ -551,23 +461,12 @@ class Group(Entity): """Set Icon for group.""" self._icon = value - @property - def hidden(self): - """If group should be hidden or not.""" - if self.visible and not self.view: - return False - return True - @property def state_attributes(self): """Return the state attributes for the group.""" data = {ATTR_ENTITY_ID: self.tracking, ATTR_ORDER: self._order} if not self.user_defined: data[ATTR_AUTO] = True - if self.view: - data[ATTR_VIEW] = True - if self.control: - data[ATTR_CONTROL] = self.control return data @property diff --git a/homeassistant/components/group/services.yaml b/homeassistant/components/group/services.yaml index 68c2f04f064..98b0cef69c3 100644 --- a/homeassistant/components/group/services.yaml +++ b/homeassistant/components/group/services.yaml @@ -3,37 +3,18 @@ reload: description: Reload group configuration. -set_visibility: - description: Hide or show a group. - fields: - entity_id: - description: Name(s) of entities to set value. - example: 'group.travel' - visible: - description: True if group should be shown or False if it should be hidden. - example: True - set: description: Create/Update a user group. fields: object_id: description: Group id and part of entity id. - example: 'test_group' + example: "test_group" name: description: Name of group - example: 'My test group' - view: - description: Boolean for if the group is a view. - example: True + example: "My test group" icon: description: Name of icon for the group. - example: 'mdi:camera' - control: - description: Value for control the group control. - example: 'hidden' - visible: - description: If the group is visible on UI. - example: True + example: "mdi:camera" entities: description: List of all members in the group. Not compatible with 'delta'. example: domain.entity_id1, domain.entity_id2 @@ -49,5 +30,4 @@ remove: fields: object_id: description: Group id and part of entity id. - example: 'test_group' - + example: "test_group" diff --git a/tests/components/group/common.py b/tests/components/group/common.py index 9d86b41d77a..69de1cfee75 100644 --- a/tests/components/group/common.py +++ b/tests/components/group/common.py @@ -5,17 +5,13 @@ components. Instead call the service directly. """ from homeassistant.components.group import ( ATTR_ADD_ENTITIES, - ATTR_CONTROL, ATTR_ENTITIES, ATTR_OBJECT_ID, - ATTR_VIEW, - ATTR_VISIBLE, DOMAIN, SERVICE_REMOVE, SERVICE_SET, - SERVICE_SET_VISIBILITY, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON, ATTR_NAME, SERVICE_RELOAD +from homeassistant.const import ATTR_ICON, ATTR_NAME, SERVICE_RELOAD from homeassistant.core import callback from homeassistant.loader import bind_hass @@ -35,43 +31,18 @@ def async_reload(hass): @bind_hass def set_group( - hass, - object_id, - name=None, - entity_ids=None, - visible=None, - icon=None, - view=None, - control=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, - visible, - icon, - view, - control, - 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, - visible=None, - icon=None, - view=None, - control=None, - add=None, + hass, object_id, name=None, entity_ids=None, icon=None, add=None, ): """Create/Update a group.""" data = { @@ -80,10 +51,7 @@ def async_set_group( (ATTR_OBJECT_ID, object_id), (ATTR_NAME, name), (ATTR_ENTITIES, entity_ids), - (ATTR_VISIBLE, visible), (ATTR_ICON, icon), - (ATTR_VIEW, view), - (ATTR_CONTROL, control), (ATTR_ADD_ENTITIES, add), ] if value is not None @@ -98,10 +66,3 @@ def async_remove(hass, object_id): """Remove a user group.""" data = {ATTR_OBJECT_ID: object_id} hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_REMOVE, data)) - - -@bind_hass -def set_visibility(hass, entity_id=None, visible=True): - """Hide or shows a group.""" - data = {ATTR_ENTITY_ID: entity_id, ATTR_VISIBLE: visible} - hass.services.call(DOMAIN, SERVICE_SET_VISIBILITY, data) diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index febe261c9e4..9a45b0ec273 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -8,7 +8,6 @@ import homeassistant.components.group as group from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_FRIENDLY_NAME, - ATTR_HIDDEN, ATTR_ICON, STATE_HOME, STATE_NOT_HOME, @@ -291,8 +290,6 @@ class TestComponentsGroup(unittest.TestCase): group_conf["second_group"] = { "entities": "light.Bowl, " + test_group.entity_id, "icon": "mdi:work", - "view": True, - "control": "hidden", } group_conf["test_group"] = "hello.world,sensor.happy" group_conf["empty_group"] = {"name": "Empty Group", "entities": None} @@ -308,9 +305,6 @@ class TestComponentsGroup(unittest.TestCase): ) assert group_state.attributes.get(group.ATTR_AUTO) is None assert "mdi:work" == group_state.attributes.get(ATTR_ICON) - assert group_state.attributes.get(group.ATTR_VIEW) - assert "hidden" == group_state.attributes.get(group.ATTR_CONTROL) - assert group_state.attributes.get(ATTR_HIDDEN) assert 1 == group_state.attributes.get(group.ATTR_ORDER) group_state = self.hass.states.get(group.ENTITY_ID_FORMAT.format("test_group")) @@ -320,9 +314,6 @@ class TestComponentsGroup(unittest.TestCase): ) assert group_state.attributes.get(group.ATTR_AUTO) is None assert group_state.attributes.get(ATTR_ICON) is None - assert group_state.attributes.get(group.ATTR_VIEW) is None - assert group_state.attributes.get(group.ATTR_CONTROL) is None - assert group_state.attributes.get(ATTR_HIDDEN) is None assert 2 == group_state.attributes.get(group.ATTR_ORDER) def test_groups_get_unique_names(self): @@ -394,11 +385,7 @@ class TestComponentsGroup(unittest.TestCase): "group", { "group": { - "second_group": { - "entities": "light.Bowl", - "icon": "mdi:work", - "view": True, - }, + "second_group": {"entities": "light.Bowl", "icon": "mdi:work"}, "test_group": "hello.world,sensor.happy", "empty_group": {"name": "Empty Group", "entities": None}, } @@ -420,13 +407,7 @@ class TestComponentsGroup(unittest.TestCase): with patch( "homeassistant.config.load_yaml_config_file", return_value={ - "group": { - "hello": { - "entities": "light.Bowl", - "icon": "mdi:work", - "view": True, - } - } + "group": {"hello": {"entities": "light.Bowl", "icon": "mdi:work"}} }, ): common.reload(self.hass) @@ -438,26 +419,6 @@ class TestComponentsGroup(unittest.TestCase): ] assert self.hass.bus.listeners["state_changed"] == 2 - def test_changing_group_visibility(self): - """Test that a group can be hidden and shown.""" - assert setup_component( - self.hass, "group", {"group": {"test_group": "hello.world,sensor.happy"}} - ) - - group_entity_id = group.ENTITY_ID_FORMAT.format("test_group") - - # Hide the group - common.set_visibility(self.hass, group_entity_id, False) - self.hass.block_till_done() - group_state = self.hass.states.get(group_entity_id) - assert group_state.attributes.get(ATTR_HIDDEN) - - # Show it again - common.set_visibility(self.hass, group_entity_id, True) - self.hass.block_till_done() - group_state = self.hass.states.get(group_entity_id) - assert group_state.attributes.get(ATTR_HIDDEN) is None - def test_modify_group(self): """Test modifying a group.""" group_conf = OrderedDict() @@ -503,19 +464,13 @@ async def test_service_group_set_group_remove_group(hass): assert group_state.attributes["friendly_name"] == "Test" common.async_set_group( - hass, - "user_test_group", - view=True, - visible=False, - entity_ids=["test.entity_bla1"], + hass, "user_test_group", entity_ids=["test.entity_bla1"], ) await hass.async_block_till_done() group_state = hass.states.get("group.user_test_group") assert group_state - assert group_state.attributes[group.ATTR_VIEW] assert group_state.attributes[group.ATTR_AUTO] - assert group_state.attributes["hidden"] assert group_state.attributes["friendly_name"] == "Test" assert list(group_state.attributes["entity_id"]) == ["test.entity_bla1"] @@ -524,19 +479,15 @@ async def test_service_group_set_group_remove_group(hass): "user_test_group", icon="mdi:camera", name="Test2", - control="hidden", add=["test.entity_id2"], ) await hass.async_block_till_done() group_state = hass.states.get("group.user_test_group") assert group_state - assert group_state.attributes[group.ATTR_VIEW] assert group_state.attributes[group.ATTR_AUTO] - assert group_state.attributes["hidden"] assert group_state.attributes["friendly_name"] == "Test2" assert group_state.attributes["icon"] == "mdi:camera" - assert group_state.attributes[group.ATTR_CONTROL] == "hidden" assert sorted(list(group_state.attributes["entity_id"])) == sorted( ["test.entity_bla1", "test.entity_id2"] ) From 51b2d0b4f8e16b5892c1ca9d8e408270863335c8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 20 Feb 2020 08:30:24 -0800 Subject: [PATCH 017/416] Add entity ID to input_number warning (#32012) * Add entity ID to warning * Input Number flexibility --- .../components/input_number/__init__.py | 34 +++--------- .../input_number/reproduce_state.py | 12 +++-- tests/components/input_number/test_init.py | 52 +++++++++---------- 3 files changed, 41 insertions(+), 57 deletions(-) diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index f78fc485e40..eb781baf2ca 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -288,44 +288,22 @@ class InputNumber(RestoreEntity): async def async_set_value(self, value): """Set new value.""" num_value = float(value) + if num_value < self._minimum or num_value > self._maximum: - _LOGGER.warning( - "Invalid value: %s (range %s - %s)", - num_value, - self._minimum, - self._maximum, + raise vol.Invalid( + f"Invalid value for {self.entity_id}: {value} (range {self._minimum} - {self._maximum})" ) - return + self._current_value = num_value self.async_write_ha_state() async def async_increment(self): """Increment value.""" - new_value = self._current_value + self._step - if new_value > self._maximum: - _LOGGER.warning( - "Invalid value: %s (range %s - %s)", - new_value, - self._minimum, - self._maximum, - ) - return - self._current_value = new_value - self.async_write_ha_state() + await self.async_set_value(min(self._current_value + self._step, self._maximum)) async def async_decrement(self): """Decrement value.""" - new_value = self._current_value - self._step - if new_value < self._minimum: - _LOGGER.warning( - "Invalid value: %s (range %s - %s)", - new_value, - self._minimum, - self._maximum, - ) - return - self._current_value = new_value - self.async_write_ha_state() + await self.async_set_value(max(self._current_value - self._step, self._minimum)) async def async_update_config(self, config: typing.Dict) -> None: """Handle when the config is updated.""" diff --git a/homeassistant/components/input_number/reproduce_state.py b/homeassistant/components/input_number/reproduce_state.py index 22a91f74000..a81c7041607 100644 --- a/homeassistant/components/input_number/reproduce_state.py +++ b/homeassistant/components/input_number/reproduce_state.py @@ -3,6 +3,8 @@ import asyncio import logging from typing import Iterable, Optional +import voluptuous as vol + from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import Context, State from homeassistant.helpers.typing import HomeAssistantType @@ -37,9 +39,13 @@ async def _async_reproduce_state( service = SERVICE_SET_VALUE service_data = {ATTR_ENTITY_ID: state.entity_id, ATTR_VALUE: state.state} - await hass.services.async_call( - DOMAIN, service, service_data, context=context, blocking=True - ) + try: + await hass.services.async_call( + DOMAIN, service, service_data, context=context, blocking=True + ) + except vol.Invalid as err: + # If value out of range. + _LOGGER.warning("Unable to reproduce state for %s: %s", state.entity_id, err) async def async_reproduce_states( diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py index 8331e1374c8..28b9d27d23f 100644 --- a/tests/components/input_number/test_init.py +++ b/tests/components/input_number/test_init.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +import voluptuous as vol from homeassistant.components.input_number import ( ATTR_VALUE, @@ -21,7 +22,6 @@ from homeassistant.const import ( from homeassistant.core import Context, CoreState, State from homeassistant.exceptions import Unauthorized from homeassistant.helpers import entity_registry -from homeassistant.loader import bind_hass from homeassistant.setup import async_setup_component from tests.common import mock_restore_cache @@ -63,38 +63,36 @@ def storage_setup(hass, hass_storage): return _storage -@bind_hass -def set_value(hass, entity_id, value): +async def set_value(hass, entity_id, value): """Set input_number to value. This is a legacy helper method. Do not use it for new tests. """ - hass.async_create_task( - hass.services.async_call( - DOMAIN, SERVICE_SET_VALUE, {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value} - ) + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: entity_id, ATTR_VALUE: value}, + blocking=True, ) -@bind_hass -def increment(hass, entity_id): +async def increment(hass, entity_id): """Increment value of entity. This is a legacy helper method. Do not use it for new tests. """ - hass.async_create_task( - hass.services.async_call(DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id}) + await hass.services.async_call( + DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id}, blocking=True ) -@bind_hass -def decrement(hass, entity_id): +async def decrement(hass, entity_id): """Decrement value of entity. This is a legacy helper method. Do not use it for new tests. """ - hass.async_create_task( - hass.services.async_call(DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id}) + await hass.services.async_call( + DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id}, blocking=True ) @@ -110,7 +108,7 @@ async def test_config(hass): assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg}) -async def test_set_value(hass): +async def test_set_value(hass, caplog): """Test set_value method.""" assert await async_setup_component( hass, DOMAIN, {DOMAIN: {"test_1": {"initial": 50, "min": 0, "max": 100}}} @@ -120,20 +118,22 @@ async def test_set_value(hass): state = hass.states.get(entity_id) assert 50 == float(state.state) - set_value(hass, entity_id, "30.4") - await hass.async_block_till_done() + await set_value(hass, entity_id, "30.4") state = hass.states.get(entity_id) assert 30.4 == float(state.state) - set_value(hass, entity_id, "70") - await hass.async_block_till_done() + await set_value(hass, entity_id, "70") state = hass.states.get(entity_id) assert 70 == float(state.state) - set_value(hass, entity_id, "110") - await hass.async_block_till_done() + with pytest.raises(vol.Invalid) as excinfo: + await set_value(hass, entity_id, "110") + + assert "Invalid value for input_number.test_1: 110.0 (range 0.0 - 100.0)" in str( + excinfo.value + ) state = hass.states.get(entity_id) assert 70 == float(state.state) @@ -149,13 +149,13 @@ async def test_increment(hass): state = hass.states.get(entity_id) assert 50 == float(state.state) - increment(hass, entity_id) + await increment(hass, entity_id) await hass.async_block_till_done() state = hass.states.get(entity_id) assert 51 == float(state.state) - increment(hass, entity_id) + await increment(hass, entity_id) await hass.async_block_till_done() state = hass.states.get(entity_id) @@ -172,13 +172,13 @@ async def test_decrement(hass): state = hass.states.get(entity_id) assert 50 == float(state.state) - decrement(hass, entity_id) + await decrement(hass, entity_id) await hass.async_block_till_done() state = hass.states.get(entity_id) assert 49 == float(state.state) - decrement(hass, entity_id) + await decrement(hass, entity_id) await hass.async_block_till_done() state = hass.states.get(entity_id) From 7a6b13cb0dbdb75abe9042d35831c7d6bac64fc6 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 20 Feb 2020 11:43:29 -0500 Subject: [PATCH 018/416] Update vizio dependency and integration name (#31975) * bump pyvizio version and clean up code * fix requirements * update parameter to optional per review in docs PR --- homeassistant/components/vizio/config_flow.py | 11 +++--- homeassistant/components/vizio/manifest.json | 4 +-- .../components/vizio/media_player.py | 4 +-- homeassistant/components/vizio/strings.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vizio/conftest.py | 8 ++--- tests/components/vizio/const.py | 2 +- tests/components/vizio/test_config_flow.py | 4 +-- tests/components/vizio/test_media_player.py | 34 ++++++++++++++----- 10 files changed, 46 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 4fba0f06165..c62222f2d91 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_TYPE, ) from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import validate_auth from .const import ( @@ -30,8 +31,8 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -def _get_config_flow_schema(input_dict: Dict[str, Any] = None) -> vol.Schema: - """Return schema defaults based on user input/config dict. Retain info already provided for future form views by setting them as defaults in schema.""" +def _get_config_schema(input_dict: Dict[str, Any] = None) -> vol.Schema: + """Return schema defaults for config data based on user input/config dict. Retain info already provided for future form views by setting them as defaults in schema.""" if input_dict is None: input_dict = {} @@ -109,7 +110,7 @@ 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_flow_schema(user_input) + self._user_schema = _get_config_schema(user_input) # Check if new config entry matches any existing config entries for entry in self.hass.config_entries.async_entries(DOMAIN): @@ -131,6 +132,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_HOST], user_input.get(CONF_ACCESS_TOKEN), user_input[CONF_DEVICE_CLASS], + session=async_get_clientsession(self.hass, False), ): errors["base"] = "cant_connect" except vol.Invalid: @@ -148,6 +150,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): user_input[CONF_HOST], user_input.get(CONF_ACCESS_TOKEN), user_input[CONF_DEVICE_CLASS], + session=async_get_clientsession(self.hass, False), ) if await self.async_set_unique_id( @@ -162,7 +165,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) # Use user_input params as default values for schema if user_input is non-empty, otherwise use default schema - schema = self._user_schema or _get_config_flow_schema() + schema = self._user_schema or _get_config_schema() return self.async_show_form(step_id="user", data_schema=schema, errors=errors) diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index bf88ed9f437..08d442b803e 100644 --- a/homeassistant/components/vizio/manifest.json +++ b/homeassistant/components/vizio/manifest.json @@ -1,8 +1,8 @@ { "domain": "vizio", - "name": "Vizio SmartCast TV", + "name": "Vizio SmartCast", "documentation": "https://www.home-assistant.io/integrations/vizio", - "requirements": ["pyvizio==0.1.21"], + "requirements": ["pyvizio==0.1.26"], "dependencies": [], "codeowners": ["@raman325"], "config_flow": true, diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 349373017da..13554ab8f36 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -71,7 +71,7 @@ async def async_setup_entry( timeout=DEFAULT_TIMEOUT, ) - if not await device.can_connect(): + if not await device.can_connect_with_auth_check(): _LOGGER.warning("Failed to connect to %s", host) raise PlatformNotReady @@ -113,7 +113,7 @@ class VizioDevice(MediaPlayerDevice): async def async_update(self) -> None: """Retrieve latest state of the device.""" if not self._model: - self._model = await self._device.get_model() + self._model = await self._device.get_model_name() if not self._sw_version: self._sw_version = await self._device.get_version() diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 5a554b7e3db..d1890ee49ed 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -3,7 +3,7 @@ "title": "Vizio SmartCast", "step": { "user": { - "title": "Setup Vizio SmartCast Client", + "title": "Setup Vizio SmartCast Device", "data": { "name": "Name", "host": ":", diff --git a/requirements_all.txt b/requirements_all.txt index 0149c8781b8..1f16e8db895 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1698,7 +1698,7 @@ pyversasense==0.0.6 pyvesync==1.1.0 # homeassistant.components.vizio -pyvizio==0.1.21 +pyvizio==0.1.26 # homeassistant.components.velux pyvlx==0.2.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 27f6b0a82dd..ae4d9230afe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -596,7 +596,7 @@ pyvera==0.3.7 pyvesync==1.1.0 # homeassistant.components.vizio -pyvizio==0.1.21 +pyvizio==0.1.26 # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index 581ea7cdd5c..c42f03db064 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -53,7 +53,7 @@ def vizio_bypass_setup_fixture(): def vizio_bypass_update_fixture(): """Mock component update.""" with patch( - "homeassistant.components.vizio.media_player.VizioAsync.can_connect", + "homeassistant.components.vizio.media_player.VizioAsync.can_connect_with_auth_check", return_value=True, ), patch("homeassistant.components.vizio.media_player.VizioDevice.async_update"): yield @@ -71,7 +71,7 @@ def vizio_guess_device_type_fixture(): @pytest.fixture(name="vizio_cant_connect") def vizio_cant_connect_fixture(): - """Mock vizio device can't connect.""" + """Mock vizio device can't connect with valid auth.""" with patch( "homeassistant.components.vizio.config_flow.VizioAsync.validate_ha_config", return_value=False, @@ -83,7 +83,7 @@ def vizio_cant_connect_fixture(): def vizio_update_fixture(): """Mock valid updates to vizio device.""" with patch( - "homeassistant.components.vizio.media_player.VizioAsync.can_connect", + "homeassistant.components.vizio.media_player.VizioAsync.can_connect_with_auth_check", return_value=True, ), patch( "homeassistant.components.vizio.media_player.VizioAsync.get_current_volume", @@ -98,7 +98,7 @@ def vizio_update_fixture(): "homeassistant.components.vizio.media_player.VizioAsync.get_power_state", return_value=True, ), patch( - "homeassistant.components.vizio.media_player.VizioAsync.get_model", + "homeassistant.components.vizio.media_player.VizioAsync.get_model_name", return_value=MODEL, ), patch( "homeassistant.components.vizio.media_player.VizioAsync.get_version", diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index 537db445a85..c241394737e 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -48,7 +48,7 @@ MOCK_IMPORT_VALID_TV_CONFIG = { CONF_VOLUME_STEP: VOLUME_STEP, } -MOCK_INVALID_TV_CONFIG = { +MOCK_TV_CONFIG_NO_TOKEN = { CONF_NAME: NAME, CONF_HOST: HOST, CONF_DEVICE_CLASS: DEVICE_CLASS_TV, diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index 5a24e2d1d69..416617d4b9b 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -25,8 +25,8 @@ from .const import ( HOST, HOST2, MOCK_IMPORT_VALID_TV_CONFIG, - MOCK_INVALID_TV_CONFIG, MOCK_SPEAKER_CONFIG, + MOCK_TV_CONFIG_NO_TOKEN, MOCK_USER_VALID_TV_CONFIG, MOCK_ZEROCONF_SERVICE_INFO, NAME, @@ -219,7 +219,7 @@ async def test_user_error_on_tv_needs_token( ) -> None: """Test when config fails custom validation for non null access token when device_class = tv during user setup.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=MOCK_INVALID_TV_CONFIG + DOMAIN, context={"source": SOURCE_USER}, data=MOCK_TV_CONFIG_NO_TOKEN ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index bbbbca8c359..a94effa7433 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -97,7 +97,7 @@ async def _test_setup( async def _test_setup_failure(hass: HomeAssistantType, config: str) -> None: """Test generic Vizio entity setup failure.""" with patch( - "homeassistant.components.vizio.media_player.VizioAsync.can_connect", + "homeassistant.components.vizio.media_player.VizioAsync.can_connect_with_auth_check", return_value=False, ): config_entry = MockConfigEntry(domain=DOMAIN, data=config, unique_id=UNIQUE_ID) @@ -133,42 +133,54 @@ async def _test_service( async def test_speaker_on( - hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_update: pytest.fixture, ) -> None: """Test Vizio Speaker entity setup when on.""" await _test_setup(hass, DEVICE_CLASS_SPEAKER, True) async def test_speaker_off( - hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_update: pytest.fixture, ) -> None: """Test Vizio Speaker entity setup when off.""" await _test_setup(hass, DEVICE_CLASS_SPEAKER, False) async def test_speaker_unavailable( - hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_update: pytest.fixture, ) -> None: """Test Vizio Speaker entity setup when unavailable.""" await _test_setup(hass, DEVICE_CLASS_SPEAKER, None) async def test_init_tv_on( - hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_update: pytest.fixture, ) -> None: """Test Vizio TV entity setup when on.""" await _test_setup(hass, DEVICE_CLASS_TV, True) async def test_init_tv_off( - hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_update: pytest.fixture, ) -> None: """Test Vizio TV entity setup when off.""" await _test_setup(hass, DEVICE_CLASS_TV, False) async def test_init_tv_unavailable( - hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_update: pytest.fixture, ) -> None: """Test Vizio TV entity setup when unavailable.""" await _test_setup(hass, DEVICE_CLASS_TV, None) @@ -189,7 +201,9 @@ async def test_setup_failure_tv( async def test_services( - hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_update: pytest.fixture, ) -> None: """Test all Vizio media player entity services.""" await _test_setup(hass, DEVICE_CLASS_TV, True) @@ -218,7 +232,9 @@ async def test_services( async def test_options_update( - hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_update: pytest.fixture + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_update: pytest.fixture, ) -> None: """Test when config entry update event fires.""" await _test_setup(hass, DEVICE_CLASS_SPEAKER, True) From a7d5e898baf04218169caf485534af2eb5008f9c Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 20 Feb 2020 16:50:28 +0000 Subject: [PATCH 019/416] Add convert_to_pil_image to pil util (#31825) * Update pil.py * Update doods and tensorflow to use convert_to_pil_image * Update pil.py * Add log messages on bad data * Drop convert_to_pil_image Just perform conversion in the integrations without seperate convert_to_pil_image() --- homeassistant/components/doods/image_processing.py | 8 ++++++-- homeassistant/components/tensorflow/image_processing.py | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index 65a32938140..4130f67ec13 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -3,7 +3,7 @@ import io import logging import time -from PIL import Image, ImageDraw +from PIL import Image, ImageDraw, UnidentifiedImageError from pydoods import PyDOODS import voluptuous as vol @@ -274,7 +274,11 @@ class Doods(ImageProcessingEntity): def process_image(self, image): """Process the image.""" - img = Image.open(io.BytesIO(bytearray(image))) + try: + img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") + except UnidentifiedImageError: + _LOGGER.warning("Unable to process image, bad data") + return img_width, img_height = img.size if self._aspect and abs((img_width / img_height) - self._aspect) > 0.1: diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py index dee2a021829..26cf0fed5e8 100644 --- a/homeassistant/components/tensorflow/image_processing.py +++ b/homeassistant/components/tensorflow/image_processing.py @@ -4,7 +4,7 @@ import logging import os import sys -from PIL import Image, ImageDraw +from PIL import Image, ImageDraw, UnidentifiedImageError import numpy as np import voluptuous as vol @@ -287,7 +287,11 @@ class TensorFlowImageProcessor(ImageProcessingEntity): inp = img[:, :, [2, 1, 0]] # BGR->RGB inp_expanded = inp.reshape(1, inp.shape[0], inp.shape[1], 3) except ImportError: - img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") + try: + img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") + except UnidentifiedImageError: + _LOGGER.warning("Unable to process image, bad data") + return img.thumbnail((460, 460), Image.ANTIALIAS) img_width, img_height = img.size inp = ( From 5c5f839119deb888f2dd39fb9be3368fd2896f0a Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 20 Feb 2020 17:50:40 +0100 Subject: [PATCH 020/416] Remove weblink integration (#32024) --- CODEOWNERS | 1 - homeassistant/components/demo/__init__.py | 13 -- homeassistant/components/weblink/__init__.py | 78 ---------- .../components/weblink/manifest.json | 9 -- tests/components/weblink/__init__.py | 1 - tests/components/weblink/test_init.py | 143 ------------------ 6 files changed, 245 deletions(-) delete mode 100644 homeassistant/components/weblink/__init__.py delete mode 100644 homeassistant/components/weblink/manifest.json delete mode 100644 tests/components/weblink/__init__.py delete mode 100644 tests/components/weblink/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index a8057197827..b1d81bafa2a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -392,7 +392,6 @@ homeassistant/components/vlc_telnet/* @rodripf homeassistant/components/waqi/* @andrey-git homeassistant/components/watson_tts/* @rutkai homeassistant/components/weather/* @fabaff -homeassistant/components/weblink/* @home-assistant/core homeassistant/components/webostv/* @bendavid homeassistant/components/websocket_api/* @home-assistant/core homeassistant/components/wemo/* @sqldiablo diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index b6845d9d6a4..a5b8aa9db7f 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -124,19 +124,6 @@ async def async_setup(hass, config): ) ) - # Set up weblink - tasks.append( - bootstrap.async_setup_component( - hass, - "weblink", - { - "weblink": { - "entities": [{"name": "Router", "url": "http://192.168.1.1"}] - } - }, - ) - ) - results = await asyncio.gather(*tasks) if any(not result for result in results): diff --git a/homeassistant/components/weblink/__init__.py b/homeassistant/components/weblink/__init__.py deleted file mode 100644 index 8a770f916bd..00000000000 --- a/homeassistant/components/weblink/__init__.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Support for links to external web pages.""" -import logging - -import voluptuous as vol - -from homeassistant.const import CONF_ICON, CONF_NAME, CONF_URL -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.util import slugify - -_LOGGER = logging.getLogger(__name__) - -CONF_ENTITIES = "entities" -CONF_RELATIVE_URL_ERROR_MSG = "Invalid relative URL. Absolute path required." -CONF_RELATIVE_URL_REGEX = r"\A/" - -DOMAIN = "weblink" - -ENTITIES_SCHEMA = vol.Schema( - { - # pylint: disable=no-value-for-parameter - vol.Required(CONF_URL): vol.Any( - vol.Match(CONF_RELATIVE_URL_REGEX, msg=CONF_RELATIVE_URL_ERROR_MSG), - vol.Url(), - ), - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_ICON): cv.icon, - } -) - -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema({vol.Required(CONF_ENTITIES): [ENTITIES_SCHEMA]})}, - extra=vol.ALLOW_EXTRA, -) - - -def setup(hass, config): - """Set up the weblink component.""" - _LOGGER.warning( - "The weblink integration has been deprecated and is pending for removal " - "in Home Assistant 0.107.0. Please use this instead: " - "https://www.home-assistant.io/lovelace/entities/#weblink" - ) - - links = config.get(DOMAIN) - - for link in links.get(CONF_ENTITIES): - Link(hass, link.get(CONF_NAME), link.get(CONF_URL), link.get(CONF_ICON)) - - return True - - -class Link(Entity): - """Representation of a link.""" - - def __init__(self, hass, name, url, icon): - """Initialize the link.""" - self.hass = hass - self._name = name - self._url = url - self._icon = icon - self.entity_id = DOMAIN + ".%s" % slugify(name) - self.schedule_update_ha_state() - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return self._icon - - @property - def name(self): - """Return the name of the URL.""" - return self._name - - @property - def state(self): - """Return the URL.""" - return self._url diff --git a/homeassistant/components/weblink/manifest.json b/homeassistant/components/weblink/manifest.json deleted file mode 100644 index 28ffa581bb8..00000000000 --- a/homeassistant/components/weblink/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "weblink", - "name": "Weblink", - "documentation": "https://www.home-assistant.io/integrations/weblink", - "requirements": [], - "dependencies": [], - "codeowners": ["@home-assistant/core"], - "quality_scale": "internal" -} diff --git a/tests/components/weblink/__init__.py b/tests/components/weblink/__init__.py deleted file mode 100644 index 1d58e9c24d6..00000000000 --- a/tests/components/weblink/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the weblink component.""" diff --git a/tests/components/weblink/test_init.py b/tests/components/weblink/test_init.py deleted file mode 100644 index 5f803107c46..00000000000 --- a/tests/components/weblink/test_init.py +++ /dev/null @@ -1,143 +0,0 @@ -"""The tests for the weblink component.""" -import unittest - -from homeassistant.components import weblink -from homeassistant.setup import setup_component - -from tests.common import get_test_home_assistant - - -class TestComponentWeblink(unittest.TestCase): - """Test the Weblink component.""" - - def setUp(self): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - def tearDown(self): - """Stop everything that was started.""" - self.hass.stop() - - def test_bad_config(self): - """Test if new entity is created.""" - assert not setup_component( - self.hass, "weblink", {"weblink": {"entities": [{}]}} - ) - - def test_bad_config_relative_url(self): - """Test if new entity is created.""" - assert not setup_component( - self.hass, - "weblink", - { - "weblink": { - "entities": [ - { - weblink.CONF_NAME: "My router", - weblink.CONF_URL: "../states/group.bla", - } - ] - } - }, - ) - - def test_bad_config_relative_file(self): - """Test if new entity is created.""" - assert not setup_component( - self.hass, - "weblink", - { - "weblink": { - "entities": [ - {weblink.CONF_NAME: "My group", weblink.CONF_URL: "group.bla"} - ] - } - }, - ) - - def test_good_config_absolute_path(self): - """Test if new entity is created.""" - assert setup_component( - self.hass, - "weblink", - { - "weblink": { - "entities": [ - { - weblink.CONF_NAME: "My second URL", - weblink.CONF_URL: "/states/group.bla", - } - ] - } - }, - ) - - def test_good_config_path_short(self): - """Test if new entity is created.""" - assert setup_component( - self.hass, - "weblink", - { - "weblink": { - "entities": [ - {weblink.CONF_NAME: "My third URL", weblink.CONF_URL: "/states"} - ] - } - }, - ) - - def test_good_config_path_directory(self): - """Test if new entity is created.""" - assert setup_component( - self.hass, - "weblink", - { - "weblink": { - "entities": [ - { - weblink.CONF_NAME: "My last URL", - weblink.CONF_URL: "/states/bla/", - } - ] - } - }, - ) - - def test_good_config_ftp_link(self): - """Test if new entity is created.""" - assert setup_component( - self.hass, - "weblink", - { - "weblink": { - "entities": [ - { - weblink.CONF_NAME: "My FTP URL", - weblink.CONF_URL: "ftp://somehost/", - } - ] - } - }, - ) - - def test_entities_get_created(self): - """Test if new entity is created.""" - assert setup_component( - self.hass, - weblink.DOMAIN, - { - weblink.DOMAIN: { - "entities": [ - { - weblink.CONF_NAME: "My router", - weblink.CONF_URL: "http://127.0.0.1/", - } - ] - } - }, - ) - - state = self.hass.states.get("weblink.my_router") - - assert state is not None - assert state.state == "http://127.0.0.1/" From 1c2bce92927cd9354d894f3572b7a14d8edcd6f3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 20 Feb 2020 08:51:00 -0800 Subject: [PATCH 021/416] Fix recursion bug (#32009) * Fix recursion bug * Remove shield --- homeassistant/components/homeassistant/__init__.py | 12 +++++++++++- tests/components/homeassistant/test_init.py | 14 ++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 17ab9ba3b44..7a0ae33345a 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -55,6 +55,15 @@ async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]: tasks = [] for domain, ent_ids in by_domain: + # This leads to endless loop. + if domain == DOMAIN: + _LOGGER.warning( + "Called service homeassistant.%s with invalid entity IDs %s", + service.service, + ", ".join(ent_ids), + ) + continue + # We want to block for all calls and only return when all calls # have been processed. If a service does not exist it causes a 10 # second delay while we're blocking waiting for a response. @@ -73,7 +82,8 @@ async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]: hass.services.async_call(domain, service.service, data, blocking) ) - await asyncio.wait(tasks) + if tasks: + await asyncio.gather(*tasks) service_schema = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}, extra=vol.ALLOW_EXTRA) diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 0f7dc7ed309..38a76b7c3fb 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -372,3 +372,17 @@ async def test_turn_on_off_toggle_schema(hass, hass_read_only_user): context=ha.Context(user_id=hass_read_only_user.id), blocking=True, ) + + +async def test_not_allowing_recursion(hass, caplog): + """Test we do not allow recursion.""" + await async_setup_component(hass, "homeassistant", {}) + + 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, + ) + assert ( + f"Called service homeassistant.{service} with invalid entity IDs homeassistant.light" + in caplog.text + ), service From 1c81e8ad68d5fba4cf743c19a1a20195485b1854 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 20 Feb 2020 18:01:29 +0100 Subject: [PATCH 022/416] Remove history_graph integration (#32028) * Remove history_graph integration * Update codeowners file --- CODEOWNERS | 1 - homeassistant/components/demo/__init__.py | 16 ---- .../components/history_graph/__init__.py | 84 ------------------- .../components/history_graph/manifest.json | 9 -- tests/components/history_graph/__init__.py | 1 - tests/components/history_graph/test_init.py | 37 -------- 6 files changed, 148 deletions(-) delete mode 100644 homeassistant/components/history_graph/__init__.py delete mode 100644 homeassistant/components/history_graph/manifest.json delete mode 100644 tests/components/history_graph/__init__.py delete mode 100644 tests/components/history_graph/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index b1d81bafa2a..1add37b865c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -147,7 +147,6 @@ homeassistant/components/hikvision/* @mezz64 homeassistant/components/hikvisioncam/* @fbradyirl homeassistant/components/hisense_aehw4a1/* @bannhead homeassistant/components/history/* @home-assistant/core -homeassistant/components/history_graph/* @andrey-git homeassistant/components/hive/* @Rendili @KJonline homeassistant/components/homeassistant/* @home-assistant/core homeassistant/components/homekit_controller/* @Jc2k diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index a5b8aa9db7f..3ea05ff6ae8 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -196,22 +196,6 @@ async def finish_setup(hass, config): switches = sorted(hass.states.async_entity_ids("switch")) lights = sorted(hass.states.async_entity_ids("light")) - # Set up history graph - await bootstrap.async_setup_component( - hass, - "history_graph", - { - "history_graph": { - "switches": { - "name": "Recent Switches", - "entities": switches, - "hours_to_show": 1, - "refresh": 60, - } - } - }, - ) - # Set up scripts await bootstrap.async_setup_component( hass, diff --git a/homeassistant/components/history_graph/__init__.py b/homeassistant/components/history_graph/__init__.py deleted file mode 100644 index e132b1d5d4c..00000000000 --- a/homeassistant/components/history_graph/__init__.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Support to graphs card in the UI.""" -import logging - -import voluptuous as vol - -from homeassistant.const import ATTR_ENTITY_ID, CONF_ENTITIES, CONF_NAME -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.entity_component import EntityComponent - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "history_graph" - -CONF_HOURS_TO_SHOW = "hours_to_show" -CONF_REFRESH = "refresh" -ATTR_HOURS_TO_SHOW = CONF_HOURS_TO_SHOW -ATTR_REFRESH = CONF_REFRESH - - -GRAPH_SCHEMA = vol.Schema( - { - vol.Required(CONF_ENTITIES): cv.entity_ids, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_HOURS_TO_SHOW, default=24): vol.Range(min=1), - vol.Optional(CONF_REFRESH, default=0): vol.Range(min=0), - } -) - - -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: cv.schema_with_slug_keys(GRAPH_SCHEMA)}, extra=vol.ALLOW_EXTRA -) - - -async def async_setup(hass, config): - """Load graph configurations.""" - _LOGGER.warning( - "The history_graph integration has been deprecated and is pending for removal " - "in Home Assistant 0.107.0." - ) - - component = EntityComponent(_LOGGER, DOMAIN, hass) - graphs = [] - - for object_id, cfg in config[DOMAIN].items(): - name = cfg.get(CONF_NAME, object_id) - graph = HistoryGraphEntity(name, cfg) - graphs.append(graph) - - await component.async_add_entities(graphs) - - return True - - -class HistoryGraphEntity(Entity): - """Representation of a graph entity.""" - - def __init__(self, name, cfg): - """Initialize the graph.""" - self._name = name - self._hours = cfg[CONF_HOURS_TO_SHOW] - self._refresh = cfg[CONF_REFRESH] - self._entities = cfg[CONF_ENTITIES] - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the name of the entity.""" - return self._name - - @property - def state_attributes(self): - """Return the state attributes.""" - attrs = { - ATTR_HOURS_TO_SHOW: self._hours, - ATTR_REFRESH: self._refresh, - ATTR_ENTITY_ID: self._entities, - } - return attrs diff --git a/homeassistant/components/history_graph/manifest.json b/homeassistant/components/history_graph/manifest.json deleted file mode 100644 index e34907d05ce..00000000000 --- a/homeassistant/components/history_graph/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "history_graph", - "name": "History Graph", - "documentation": "https://www.home-assistant.io/integrations/history_graph", - "requirements": [], - "dependencies": ["history"], - "codeowners": ["@andrey-git"], - "quality_scale": "internal" -} diff --git a/tests/components/history_graph/__init__.py b/tests/components/history_graph/__init__.py deleted file mode 100644 index 2cb34499938..00000000000 --- a/tests/components/history_graph/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the history_graph component.""" diff --git a/tests/components/history_graph/test_init.py b/tests/components/history_graph/test_init.py deleted file mode 100644 index ef41f70aaa7..00000000000 --- a/tests/components/history_graph/test_init.py +++ /dev/null @@ -1,37 +0,0 @@ -"""The tests the Graph component.""" - -import unittest - -from homeassistant.setup import setup_component - -from tests.common import get_test_home_assistant, init_recorder_component - - -class TestGraph(unittest.TestCase): - """Test the Google component.""" - - def setUp(self): # pylint: disable=invalid-name - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - def tearDown(self): # pylint: disable=invalid-name - """Stop everything that was started.""" - self.hass.stop() - - def test_setup_component(self): - """Test setup component.""" - self.init_recorder() - config = {"history": {}, "history_graph": {"name_1": {"entities": "test.test"}}} - - assert setup_component(self.hass, "history_graph", config) - assert dict(self.hass.states.get("history_graph.name_1").attributes) == { - "entity_id": ["test.test"], - "friendly_name": "name_1", - "hours_to_show": 24, - "refresh": 0, - } - - def init_recorder(self): - """Initialize the recorder.""" - init_recorder_component(self.hass) - self.hass.start() From b2f2afaf0ce38c2a31e07f160bbcf1aa6bf81376 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 20 Feb 2020 18:34:30 +0100 Subject: [PATCH 023/416] Prevent committing to dev/master/rc directly (#32029) * Prevent committing to dev/master/rc directly * Skip pre-commit on version bump --- .pre-commit-config.yaml | 41 +++++++++++++++++++--------------- script/gen_requirements_all.py | 2 +- script/version_bump.py | 2 +- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a340aa7ae67..7d55224c335 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,53 +1,58 @@ repos: -- repo: https://github.com/psf/black + - repo: https://github.com/psf/black rev: 19.10b0 hooks: - - id: black + - id: black args: - --safe - --quiet files: ^((homeassistant|script|tests)/.+)?[^/]+\.py$ -- repo: https://github.com/codespell-project/codespell + - repo: https://github.com/codespell-project/codespell rev: v1.16.0 hooks: - - id: codespell + - id: codespell args: - --ignore-words-list=hass,alot,datas,dof,dur,farenheit,hist,iff,ines,ist,lightsensor,mut,nd,pres,referer,ser,serie,te,technik,ue,uint,visability,wan,wanna,withing - --skip="./.*,*.json" - --quiet-level=2 exclude_types: [json] -- repo: https://gitlab.com/pycqa/flake8 + - repo: https://gitlab.com/pycqa/flake8 rev: 3.7.9 hooks: - - id: flake8 + - id: flake8 additional_dependencies: - flake8-docstrings==1.5.0 - pydocstyle==5.0.2 files: ^(homeassistant|script|tests)/.+\.py$ -- repo: https://github.com/PyCQA/bandit + - repo: https://github.com/PyCQA/bandit rev: 1.6.2 hooks: - - id: bandit + - id: bandit args: - --quiet - --format=custom - --configfile=tests/bandit.yaml files: ^(homeassistant|script|tests)/.+\.py$ -- repo: https://github.com/pre-commit/mirrors-isort + - repo: https://github.com/pre-commit/mirrors-isort rev: v4.3.21 hooks: - - id: isort -- repo: https://github.com/pre-commit/pre-commit-hooks + - id: isort + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v2.4.0 hooks: - - id: check-json -- repo: local + - id: check-json + - id: no-commit-to-branch + args: + - --branch=dev + - --branch=master + - --branch=rc + - repo: local hooks: - # Run mypy through our wrapper script in order to get the possible - # pyenv and/or virtualenv activated; it may not have been e.g. if - # committing from a GUI tool that was not launched from an activated - # shell. - - id: mypy + # Run mypy through our wrapper script in order to get the possible + # pyenv and/or virtualenv activated; it may not have been e.g. if + # committing from a GUI tool that was not launched from an activated + # shell. + - id: mypy name: mypy entry: script/run-in-env.sh mypy language: script diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 1bf9031a536..457c14b3474 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -65,7 +65,7 @@ enum34==1000000000.0.0 pycrypto==1000000000.0.0 """ -IGNORE_PRE_COMMIT_HOOK_ID = ("check-json",) +IGNORE_PRE_COMMIT_HOOK_ID = ("check-json", "no-commit-to-branch") def has_tests(module: str): diff --git a/script/version_bump.py b/script/version_bump.py index 13dfe499f5e..f3ed5e99c55 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -140,7 +140,7 @@ def main(): if not arguments.commit: return - subprocess.run(["git", "commit", "-am", f"Bumped version to {bumped}"]) + subprocess.run(["git", "commit", "-nam", f"Bumped version to {bumped}"]) def test_bump_version(): From bf1092ec8064cc2e8d80d825f21056341e577d9e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 20 Feb 2020 19:19:09 +0100 Subject: [PATCH 024/416] Add minimal version contrain to urllib3 (#32031) --- homeassistant/package_constraints.txt | 3 +++ script/gen_requirements_all.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 796fd5681ff..9f774360cf4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,6 +28,9 @@ zeroconf==0.24.4 pycryptodome>=3.6.6 +# Constrain urllib3 to ensure we deal with CVE-2019-11236 & CVE-2019-11324 +urllib3>=1.24.3 + # Not needed for our supported Python versions enum34==1000000000.0.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 457c14b3474..2b7fe8226b2 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -58,6 +58,9 @@ CONSTRAINT_PATH = os.path.join( CONSTRAINT_BASE = """ pycryptodome>=3.6.6 +# Constrain urllib3 to ensure we deal with CVE-2019-11236 & CVE-2019-11324 +urllib3>=1.24.3 + # Not needed for our supported Python versions enum34==1000000000.0.0 From 20e3c91ebd45c58447e7b1c7d9b297765cfdbde1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 20 Feb 2020 11:01:20 -0800 Subject: [PATCH 025/416] Updated frontend to 20200220.0 (#32033) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index d35801d9177..04f0f45344d 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200219.0" + "home-assistant-frontend==20200220.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9f774360cf4..c11746e5608 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.31 -home-assistant-frontend==20200219.0 +home-assistant-frontend==20200220.0 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1f16e8db895..8e4c51b0533 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -683,7 +683,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200219.0 +home-assistant-frontend==20200220.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae4d9230afe..6b23a57be8e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -254,7 +254,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200219.0 +home-assistant-frontend==20200220.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 From 2e35190aff2654dfc7c788d76c132afb7d99ef43 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 20 Feb 2020 20:15:45 +0100 Subject: [PATCH 026/416] Fix extra arguments of group integration (#32032) --- homeassistant/components/group/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index 7a5961180a5..efd8dfdf0fa 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -244,7 +244,7 @@ async def async_setup(hass, config): extra_arg = { attr: service.data[attr] - for attr in (ATTR_ICON) + for attr in (ATTR_ICON,) if service.data.get(attr) is not None } From ce710f1e0bce31404bdd8627452504ee050ab40b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 20 Feb 2020 12:14:31 -0800 Subject: [PATCH 027/416] Template platform tweaks (#32037) * Remove unnecessary boolean return from platform setup * Fix template cover validation --- .../components/template/binary_sensor.py | 4 -- homeassistant/components/template/cover.py | 61 ++++++++----------- homeassistant/components/template/light.py | 5 -- homeassistant/components/template/switch.py | 5 -- tests/components/template/test_cover.py | 28 ++++----- 5 files changed, 39 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index 7de43ea0702..8991ce4c65b 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -103,12 +103,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= attribute_templates, ) ) - if not sensors: - _LOGGER.error("No sensors added") - return False async_add_entities(sensors) - return True class BinarySensorTemplate(BinarySensorDevice): diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 870e4035c2f..14fc6996378 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -65,30 +65,33 @@ TILT_FEATURES = ( | SUPPORT_SET_TILT_POSITION ) -COVER_SCHEMA = vol.Schema( - { - vol.Inclusive(OPEN_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA, - vol.Inclusive(CLOSE_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA, - vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, - vol.Exclusive( - CONF_POSITION_TEMPLATE, CONF_VALUE_OR_POSITION_TEMPLATE - ): cv.template, - vol.Exclusive( - CONF_VALUE_TEMPLATE, CONF_VALUE_OR_POSITION_TEMPLATE - ): cv.template, - vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template, - vol.Optional(CONF_POSITION_TEMPLATE): cv.template, - vol.Optional(CONF_TILT_TEMPLATE): cv.template, - vol.Optional(CONF_ICON_TEMPLATE): cv.template, - vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPTIMISTIC): cv.boolean, - vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, - vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_ENTITY_ID): cv.entity_ids, - } +COVER_SCHEMA = vol.All( + vol.Schema( + { + vol.Inclusive(OPEN_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA, + vol.Inclusive(CLOSE_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA, + vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA, + vol.Exclusive( + CONF_POSITION_TEMPLATE, CONF_VALUE_OR_POSITION_TEMPLATE + ): cv.template, + vol.Exclusive( + CONF_VALUE_TEMPLATE, CONF_VALUE_OR_POSITION_TEMPLATE + ): cv.template, + vol.Optional(CONF_AVAILABILITY_TEMPLATE): cv.template, + vol.Optional(CONF_POSITION_TEMPLATE): cv.template, + vol.Optional(CONF_TILT_TEMPLATE): cv.template, + vol.Optional(CONF_ICON_TEMPLATE): cv.template, + vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPTIMISTIC): cv.boolean, + vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean, + vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_FRIENDLY_NAME): cv.string, + vol.Optional(CONF_ENTITY_ID): cv.entity_ids, + } + ), + cv.has_at_least_one_key(OPEN_ACTION, POSITION_ACTION), ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -118,12 +121,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= optimistic = device_config.get(CONF_OPTIMISTIC) tilt_optimistic = device_config.get(CONF_TILT_OPTIMISTIC) - if position_action is None and open_action is None: - _LOGGER.error( - "Must specify at least one of %s" or "%s", OPEN_ACTION, POSITION_ACTION - ) - continue - templates = { CONF_VALUE_TEMPLATE: state_template, CONF_POSITION_TEMPLATE: position_template, @@ -160,12 +157,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= entity_ids, ) ) - if not covers: - _LOGGER.error("No covers added") - return False async_add_entities(covers) - return True class CoverTemplate(CoverDevice): diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index a6855a1654b..7948782479b 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -131,12 +131,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) ) - if not lights: - _LOGGER.error("No lights added") - return False - async_add_entities(lights) - return True class LightTemplate(Light): diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index c2d8e8158c1..f96ed5479b9 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -93,12 +93,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) ) - if not switches: - _LOGGER.error("No switches added") - return False - async_add_entities(switches) - return True class SwitchTemplate(SwitchDevice): diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py index 9980691085b..5109607d799 100644 --- a/tests/components/template/test_cover.py +++ b/tests/components/template/test_cover.py @@ -270,26 +270,22 @@ async def test_template_mutex(hass, calls): assert hass.states.async_all() == [] -async def test_template_open_or_position(hass, calls): +async def test_template_open_or_position(hass, caplog): """Test that at least one of open_cover or set_position is used.""" - with assert_setup_component(1, "cover"): - assert await setup.async_setup_component( - hass, - "cover", - { - "cover": { - "platform": "template", - "covers": { - "test_template_cover": {"value_template": "{{ 1 == 1 }}"} - }, - } - }, - ) - - await hass.async_start() + assert await setup.async_setup_component( + hass, + "cover", + { + "cover": { + "platform": "template", + "covers": {"test_template_cover": {"value_template": "{{ 1 == 1 }}"}}, + } + }, + ) await hass.async_block_till_done() assert hass.states.async_all() == [] + assert "Invalid config for [cover.template]" in caplog.text async def test_template_open_and_close(hass, calls): From f0d58ab7a7da98c5d012f4967dc8483ee462a6e3 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 20 Feb 2020 13:36:36 -0800 Subject: [PATCH 028/416] Change TTS codeowner to @pvizeli (#32041) --- CODEOWNERS | 2 +- homeassistant/components/tts/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 1add37b865c..cb254824039 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -364,7 +364,7 @@ homeassistant/components/traccar/* @ludeeus homeassistant/components/tradfri/* @ggravlingen homeassistant/components/trafikverket_train/* @endor-force homeassistant/components/transmission/* @engrbm87 @JPHutchins -homeassistant/components/tts/* @robbiet480 +homeassistant/components/tts/* @pvizeli homeassistant/components/twentemilieu/* @frenck homeassistant/components/twilio_call/* @robbiet480 homeassistant/components/twilio_sms/* @robbiet480 diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json index 215a16fd4cf..817ca00a818 100644 --- a/homeassistant/components/tts/manifest.json +++ b/homeassistant/components/tts/manifest.json @@ -5,5 +5,5 @@ "requirements": ["mutagen==1.43.0"], "dependencies": ["http"], "after_dependencies": ["media_player"], - "codeowners": ["@robbiet480"] + "codeowners": ["@pvizeli"] } From 51b5796916fd5bdd1ff13143296bf0428ee48eda Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 20 Feb 2020 14:51:27 -0800 Subject: [PATCH 029/416] Updated frontend to 20200220.1 (#32046) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 04f0f45344d..993d63ea29b 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200220.0" + "home-assistant-frontend==20200220.1" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c11746e5608..8c00b4502b1 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.31 -home-assistant-frontend==20200220.0 +home-assistant-frontend==20200220.1 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8e4c51b0533..2fef238a7ed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -683,7 +683,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200220.0 +home-assistant-frontend==20200220.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b23a57be8e..8c22db276bc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -254,7 +254,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200220.0 +home-assistant-frontend==20200220.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 From 1ee7c483a764b235b881c3918e8177b982a961e6 Mon Sep 17 00:00:00 2001 From: springstan <46536646+springstan@users.noreply.github.com> Date: Fri, 21 Feb 2020 00:29:46 +0100 Subject: [PATCH 030/416] Update file headers and outdated documentation links (#32022) * Update documentation links * Update file headers v2 --- .../components/dublin_bus_transport/sensor.py | 3 --- .../components/dwd_weather_warnings/sensor.py | 3 --- homeassistant/components/dyson/fan.py | 11 ++--------- homeassistant/components/ebox/sensor.py | 3 --- .../components/eddystone_temperature/sensor.py | 3 --- .../components/environment_canada/camera.py | 7 +------ .../components/environment_canada/sensor.py | 7 +------ .../components/environment_canada/weather.py | 7 +------ homeassistant/components/fail2ban/sensor.py | 8 +------- homeassistant/components/fido/sensor.py | 3 --- homeassistant/components/flexit/climate.py | 14 +------------- homeassistant/components/flux/switch.py | 3 --- homeassistant/components/geo_rss_events/sensor.py | 3 --- homeassistant/components/izone/__init__.py | 7 +------ homeassistant/components/kodi/notify.py | 2 +- homeassistant/components/lifx_legacy/light.py | 3 --- homeassistant/components/meraki/device_tracker.py | 8 +------- homeassistant/components/mobile_app/config_flow.py | 2 +- homeassistant/components/mqtt/light/__init__.py | 7 +------ .../components/mqtt/light/schema_basic.py | 7 +------ homeassistant/components/mqtt/light/schema_json.py | 7 +------ .../components/mqtt/light/schema_template.py | 7 +------ homeassistant/components/mqtt/vacuum/__init__.py | 7 +------ homeassistant/components/neato/config_flow.py | 2 +- homeassistant/components/nest/binary_sensor.py | 2 +- homeassistant/components/nest/sensor.py | 4 ++-- homeassistant/components/onvif/camera.py | 7 +------ homeassistant/components/pandora/media_player.py | 2 +- homeassistant/components/pencom/switch.py | 6 +----- homeassistant/components/rejseplanen/sensor.py | 3 --- homeassistant/components/smarthab/__init__.py | 7 +------ homeassistant/components/smarthab/cover.py | 7 +------ homeassistant/components/smarthab/light.py | 7 +------ homeassistant/components/somfy/__init__.py | 7 +------ .../components/synology_srm/device_tracker.py | 6 +----- .../components/thermoworks_smoke/sensor.py | 3 --- homeassistant/components/uk_transport/sensor.py | 6 +----- homeassistant/components/volumio/media_player.py | 3 --- homeassistant/components/zha/core/__init__.py | 7 +------ .../components/zha/core/channels/__init__.py | 7 +------ .../components/zha/core/channels/closures.py | 7 +------ .../components/zha/core/channels/general.py | 7 +------ .../components/zha/core/channels/homeautomation.py | 7 +------ homeassistant/components/zha/core/channels/hvac.py | 7 +------ .../components/zha/core/channels/lighting.py | 7 +------ .../components/zha/core/channels/lightlink.py | 7 +------ .../zha/core/channels/manufacturerspecific.py | 7 +------ .../components/zha/core/channels/measurement.py | 7 +------ .../components/zha/core/channels/protocol.py | 7 +------ .../components/zha/core/channels/security.py | 7 +------ .../components/zha/core/channels/smartenergy.py | 7 +------ homeassistant/components/zha/core/device.py | 7 +------ homeassistant/components/zha/core/discovery.py | 7 +------ homeassistant/components/zha/core/gateway.py | 7 +------ homeassistant/components/zha/core/group.py | 7 +------ homeassistant/components/zha/core/helpers.py | 7 +------ homeassistant/components/zha/core/patches.py | 7 +------ homeassistant/components/zha/core/registries.py | 7 +------ 58 files changed, 49 insertions(+), 295 deletions(-) diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index a5fe8fd6b30..b517ecf6466 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -3,9 +3,6 @@ Support for Dublin RTPI information from data.dublinked.ie. For more info on the API see : https://data.gov.ie/dataset/real-time-passenger-information-rtpi-for-dublin-bus-bus-eireann-luas-and-irish-rail/resource/4b9f2c4f-6bf5-4958-a43a-f12dab04cf61 - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.dublin_public_transport/ """ from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 695b839d18c..af358343d8b 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -1,9 +1,6 @@ """ Support for getting statistical data from a DWD Weather Warnings. -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.dwd_weather_warnings/ - Data is fetched from DWD: https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html diff --git a/homeassistant/components/dyson/fan.py b/homeassistant/components/dyson/fan.py index 2d41e6b828a..4ec23921c03 100644 --- a/homeassistant/components/dyson/fan.py +++ b/homeassistant/components/dyson/fan.py @@ -1,8 +1,4 @@ -"""Support for Dyson Pure Cool link fan. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/fan.dyson/ -""" +"""Support for Dyson Pure Cool link fan.""" import logging from libpurecool.const import FanMode, FanSpeed, NightMode, Oscillation @@ -157,10 +153,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ) hass.services.register( - DYSON_DOMAIN, - SERVICE_SET_AUTO_MODE, - service_handle, - schema=SET_AUTO_MODE_SCHEMA, + DYSON_DOMAIN, SERVICE_SET_AUTO_MODE, service_handle, schema=SET_AUTO_MODE_SCHEMA ) if has_purecool_devices: hass.services.register( diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index 54355ed3bb8..f1221bd1e77 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -2,9 +2,6 @@ Support for EBox. Get data from 'My Usage Page' page: https://client.ebox.ca/myusage - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.ebox/ """ from datetime import timedelta import logging diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py index 3b5aa95701f..1d6ff61bf59 100644 --- a/homeassistant/components/eddystone_temperature/sensor.py +++ b/homeassistant/components/eddystone_temperature/sensor.py @@ -3,9 +3,6 @@ Read temperature information from Eddystone beacons. Your beacons must be configured to transmit UID (for identification) and TLM (for temperature) frames. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.eddystone_temperature/ """ import logging diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py index 4ef3e17fc46..d51b69f5713 100644 --- a/homeassistant/components/environment_canada/camera.py +++ b/homeassistant/components/environment_canada/camera.py @@ -1,9 +1,4 @@ -""" -Support for the Environment Canada radar imagery. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.environment_canada/ -""" +"""Support for the Environment Canada radar imagery.""" import datetime import logging diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 1568ba19d6b..e6ea87fd946 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -1,9 +1,4 @@ -""" -Support for the Environment Canada weather service. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.environment_canada/ -""" +"""Support for the Environment Canada weather service.""" from datetime import datetime, timedelta import logging import re diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index 572543e39c4..10666b4a34e 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -1,9 +1,4 @@ -""" -Platform for retrieving meteorological data from Environment Canada. - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/weather.environmentcanada/ -""" +"""Platform for retrieving meteorological data from Environment Canada.""" import datetime import logging import re diff --git a/homeassistant/components/fail2ban/sensor.py b/homeassistant/components/fail2ban/sensor.py index 692b48d9db5..6e47cb45966 100644 --- a/homeassistant/components/fail2ban/sensor.py +++ b/homeassistant/components/fail2ban/sensor.py @@ -1,10 +1,4 @@ -""" -Support for displaying IPs banned by fail2ban. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.fail2ban/ - -""" +"""Support for displaying IPs banned by fail2ban.""" from datetime import timedelta import logging import os diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index f444abd25ee..9f2eeb9bd7c 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -3,9 +3,6 @@ Support for Fido. Get data from 'Usage Summary' page: https://www.fido.ca/pages/#/my-account/wireless - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.fido/ """ from datetime import timedelta import logging diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py index 34ddd9a8ffa..8720f67f396 100644 --- a/homeassistant/components/flexit/climate.py +++ b/homeassistant/components/flexit/climate.py @@ -1,16 +1,4 @@ -""" -Platform for Flexit AC units with CI66 Modbus adapter. - -Example configuration: - -climate: - - platform: flexit - name: Main AC - slave: 21 - -For more details about this platform, please refer to the documentation -https://home-assistant.io/components/climate.flexit/ -""" +"""Platform for Flexit AC units with CI66 Modbus adapter.""" import logging from typing import List diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py index f22b6335911..8ffd84a518f 100644 --- a/homeassistant/components/flux/switch.py +++ b/homeassistant/components/flux/switch.py @@ -2,9 +2,6 @@ Flux for Home-Assistant. The idea was taken from https://github.com/KpaBap/hue-flux/ - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/switch.flux/ """ import datetime import logging diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py index b8891cdef0d..3ac973f77a0 100644 --- a/homeassistant/components/geo_rss_events/sensor.py +++ b/homeassistant/components/geo_rss_events/sensor.py @@ -4,9 +4,6 @@ Generic GeoRSS events service. Retrieves current events (typically incidents or alerts) in GeoRSS format, and shows information on events filtered by distance to the HA instance's location and grouped by category. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.geo_rss_events/ """ from datetime import timedelta import logging diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index 0e5dcddbc48..1b4d2f19d1c 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -1,9 +1,4 @@ -""" -Platform for the iZone AC. - -For more details about this component, please refer to the documentation -https://home-assistant.io/integrations/izone/ -""" +"""Platform for the iZone AC.""" import logging import voluptuous as vol diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py index 6f370ffad98..fae5c856d9d 100644 --- a/homeassistant/components/kodi/notify.py +++ b/homeassistant/components/kodi/notify.py @@ -58,7 +58,7 @@ async def async_get_service(hass, config, discovery_info=None): _LOGGER.warning( "Kodi host name should no longer contain http:// See updated " "definitions here: " - "https://home-assistant.io/components/media_player.kodi/" + "https://home-assistant.io/integrations/media_player.kodi/" ) http_protocol = "https" if encryption else "http" diff --git a/homeassistant/components/lifx_legacy/light.py b/homeassistant/components/lifx_legacy/light.py index 8f767a2f559..7fb0e686b31 100644 --- a/homeassistant/components/lifx_legacy/light.py +++ b/homeassistant/components/lifx_legacy/light.py @@ -3,9 +3,6 @@ Support for the LIFX platform that implements lights. This is a legacy platform, included because the current lifx platform does not yet support Windows. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.lifx/ """ import logging diff --git a/homeassistant/components/meraki/device_tracker.py b/homeassistant/components/meraki/device_tracker.py index 1aa1485922e..614c2943530 100644 --- a/homeassistant/components/meraki/device_tracker.py +++ b/homeassistant/components/meraki/device_tracker.py @@ -1,10 +1,4 @@ -""" -Support for the Meraki CMX location service. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.meraki/ - -""" +"""Support for the Meraki CMX location service.""" import json import logging diff --git a/homeassistant/components/mobile_app/config_flow.py b/homeassistant/components/mobile_app/config_flow.py index 6fc4b342298..08fdecf364d 100644 --- a/homeassistant/components/mobile_app/config_flow.py +++ b/homeassistant/components/mobile_app/config_flow.py @@ -18,7 +18,7 @@ class MobileAppFlowHandler(config_entries.ConfigFlow): async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" placeholders = { - "apps_url": "https://www.home-assistant.io/components/mobile_app/#apps" + "apps_url": "https://www.home-assistant.io/integrations/mobile_app/#apps" } return self.async_abort( diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index a72008c059f..511ee6049df 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -1,9 +1,4 @@ -""" -Support for MQTT lights. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.mqtt/ -""" +"""Support for MQTT lights.""" import logging import voluptuous as vol diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index ff57db7c8c1..041c5e804dc 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -1,9 +1,4 @@ -""" -Support for MQTT lights. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.mqtt/ -""" +"""Support for MQTT lights.""" import logging import voluptuous as vol diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index c4de1edbc3c..373fbc1b3b2 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -1,9 +1,4 @@ -""" -Support for MQTT JSON lights. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.mqtt_json/ -""" +"""Support for MQTT JSON lights.""" import json import logging diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index dd69a8e87d6..82f0fa3c9d0 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -1,9 +1,4 @@ -""" -Support for MQTT Template lights. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.mqtt_template/ -""" +"""Support for MQTT Template lights.""" import logging import voluptuous as vol diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py index 84f564e5c7e..d33a23f3a6d 100644 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -1,9 +1,4 @@ -""" -Support for MQTT vacuums. - -For more details about this platform, please refer to the documentation at -https://www.home-assistant.io/components/vacuum.mqtt/ -""" +"""Support for MQTT vacuums.""" import logging import voluptuous as vol diff --git a/homeassistant/components/neato/config_flow.py b/homeassistant/components/neato/config_flow.py index 56fba9047e7..88a2085b339 100644 --- a/homeassistant/components/neato/config_flow.py +++ b/homeassistant/components/neato/config_flow.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME # pylint: disable=unused-import from .const import CONF_VENDOR, NEATO_DOMAIN, VALID_VENDORS -DOCS_URL = "https://www.home-assistant.io/components/neato" +DOCS_URL = "https://www.home-assistant.io/integrations/neato" DEFAULT_VENDOR = "neato" _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/nest/binary_sensor.py b/homeassistant/components/nest/binary_sensor.py index 05170a54ed1..1ee500c793c 100644 --- a/homeassistant/components/nest/binary_sensor.py +++ b/homeassistant/components/nest/binary_sensor.py @@ -71,7 +71,7 @@ async def async_setup_entry(hass, entry, async_add_entities): wstr = ( variable + " is no a longer supported " "monitored_conditions. See " - "https://home-assistant.io/components/binary_sensor.nest/ " + "https://home-assistant.io/integrations/binary_sensor.nest/ " "for valid options." ) _LOGGER.error(wstr) diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py index 6b1a198abbb..f8ae56f838c 100644 --- a/homeassistant/components/nest/sensor.py +++ b/homeassistant/components/nest/sensor.py @@ -90,14 +90,14 @@ async def async_setup_entry(hass, entry, async_add_entities): if variable in DEPRECATED_WEATHER_VARS: wstr = ( "Nest no longer provides weather data like %s. See " - "https://home-assistant.io/components/#weather " + "https://home-assistant.io/integrations/#weather " "for a list of other weather integrations to use." % variable ) else: wstr = ( variable + " is no a longer supported " "monitored_conditions. See " - "https://home-assistant.io/components/" + "https://home-assistant.io/integrations/" "binary_sensor.nest/ for valid options." ) _LOGGER.error(wstr) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 3f244530dca..58d125bf1f8 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -1,9 +1,4 @@ -""" -Support for ONVIF Cameras with FFmpeg as decoder. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/camera.onvif/ -""" +"""Support for ONVIF Cameras with FFmpeg as decoder.""" import asyncio import datetime as dt import logging diff --git a/homeassistant/components/pandora/media_player.py b/homeassistant/components/pandora/media_player.py index 07f697a5a46..4b3c25862a1 100644 --- a/homeassistant/components/pandora/media_player.py +++ b/homeassistant/components/pandora/media_player.py @@ -384,6 +384,6 @@ def _pianobar_exists(): _LOGGER.warning( "The Pandora integration depends on the Pianobar client, which " "cannot be found. Please install using instructions at " - "https://home-assistant.io/components/media_player.pandora/" + "https://home-assistant.io/integrations/media_player.pandora/" ) return False diff --git a/homeassistant/components/pencom/switch.py b/homeassistant/components/pencom/switch.py index 36266feaa6e..5cd1f826629 100644 --- a/homeassistant/components/pencom/switch.py +++ b/homeassistant/components/pencom/switch.py @@ -1,8 +1,4 @@ -"""Pencom relay control. - -For more details about this component, please refer to the documentation at -http://home-assistant.io/components/switch.pencom -""" +"""Pencom relay control.""" import logging from pencompy.pencompy import Pencompy diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index b7d36010714..419600ce562 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -3,9 +3,6 @@ Support for Rejseplanen information from rejseplanen.dk. For more info on the API see: https://help.rejseplanen.dk/hc/en-us/articles/214174465-Rejseplanen-s-API - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.rejseplanen/ """ from datetime import datetime, timedelta import logging diff --git a/homeassistant/components/smarthab/__init__.py b/homeassistant/components/smarthab/__init__.py index ef2da4e9a1d..778b5171ae4 100644 --- a/homeassistant/components/smarthab/__init__.py +++ b/homeassistant/components/smarthab/__init__.py @@ -1,9 +1,4 @@ -""" -Support for SmartHab device integration. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/smarthab/ -""" +"""Support for SmartHab device integration.""" import logging import pysmarthab diff --git a/homeassistant/components/smarthab/cover.py b/homeassistant/components/smarthab/cover.py index 9bcb89b7ab4..af55f2de7f9 100644 --- a/homeassistant/components/smarthab/cover.py +++ b/homeassistant/components/smarthab/cover.py @@ -1,9 +1,4 @@ -""" -Support for SmartHab device integration. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/smarthab/ -""" +"""Support for SmartHab device integration.""" from datetime import timedelta import logging diff --git a/homeassistant/components/smarthab/light.py b/homeassistant/components/smarthab/light.py index bc6eb31fd04..469d89011b8 100644 --- a/homeassistant/components/smarthab/light.py +++ b/homeassistant/components/smarthab/light.py @@ -1,9 +1,4 @@ -""" -Support for SmartHab device integration. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/smarthab/ -""" +"""Support for SmartHab device integration.""" from datetime import timedelta import logging diff --git a/homeassistant/components/somfy/__init__.py b/homeassistant/components/somfy/__init__.py index 365c6839300..aa288e13ac7 100644 --- a/homeassistant/components/somfy/__init__.py +++ b/homeassistant/components/somfy/__init__.py @@ -1,9 +1,4 @@ -""" -Support for Somfy hubs. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/somfy/ -""" +"""Support for Somfy hubs.""" import asyncio from datetime import timedelta import logging diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py index 36306efa93e..577a01c5148 100644 --- a/homeassistant/components/synology_srm/device_tracker.py +++ b/homeassistant/components/synology_srm/device_tracker.py @@ -1,8 +1,4 @@ -"""Device tracker for Synology SRM routers. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.synology_srm/ -""" +"""Device tracker for Synology SRM routers.""" import logging import synology_srm diff --git a/homeassistant/components/thermoworks_smoke/sensor.py b/homeassistant/components/thermoworks_smoke/sensor.py index d5af021108a..83a2fd12d24 100644 --- a/homeassistant/components/thermoworks_smoke/sensor.py +++ b/homeassistant/components/thermoworks_smoke/sensor.py @@ -2,9 +2,6 @@ Support for getting the state of a Thermoworks Smoke Thermometer. Requires Smoke Gateway Wifi with an internet connection. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.thermoworks_smoke/ """ import logging diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index e3c5440c450..77b1c1a6f11 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -1,8 +1,4 @@ -"""Support for UK public transport data provided by transportapi.com. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.uk_transport/ -""" +"""Support for UK public transport data provided by transportapi.com.""" from datetime import datetime, timedelta import logging import re diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 369b9c33c0d..90e62c0d951 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -1,9 +1,6 @@ """ Volumio Platform. -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/media_player.volumio/ - Volumio rest API: https://volumio.github.io/docs/API/REST_API.html """ import asyncio diff --git a/homeassistant/components/zha/core/__init__.py b/homeassistant/components/zha/core/__init__.py index 1873cd7dc55..a416ff2eebe 100644 --- a/homeassistant/components/zha/core/__init__.py +++ b/homeassistant/components/zha/core/__init__.py @@ -1,9 +1,4 @@ -""" -Core module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Core module for Zigbee Home Automation.""" # flake8: noqa from .device import ZHADevice diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index d899f51b487..1210ac9d32c 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -1,9 +1,4 @@ -""" -Channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Channels module for Zigbee Home Automation.""" import asyncio from concurrent.futures import TimeoutError as Timeout from enum import Enum diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/channels/closures.py index 03b1a8450db..0cf6f840070 100644 --- a/homeassistant/components/zha/core/channels/closures.py +++ b/homeassistant/components/zha/core/channels/closures.py @@ -1,9 +1,4 @@ -""" -Closures channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Closures channels module for Zigbee Home Automation.""" import logging import zigpy.zcl.clusters.closures as closures diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index c1701479a43..111b35e7e58 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -1,9 +1,4 @@ -""" -General channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""General channels module for Zigbee Home Automation.""" import logging import zigpy.zcl.clusters.general as general diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py index d9d8f57eaaf..8c2c2e57972 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -1,9 +1,4 @@ -""" -Home automation channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Home automation channels module for Zigbee Home Automation.""" import logging from typing import Optional diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py index db4745d51c3..b638259b4a1 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -1,9 +1,4 @@ -""" -HVAC channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""HVAC channels module for Zigbee Home Automation.""" import logging from zigpy.exceptions import DeliveryError diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index 272fa28905c..0a1e2048132 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -1,9 +1,4 @@ -""" -Lighting channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Lighting channels module for Zigbee Home Automation.""" import logging import zigpy.zcl.clusters.lighting as lighting diff --git a/homeassistant/components/zha/core/channels/lightlink.py b/homeassistant/components/zha/core/channels/lightlink.py index 7cd2134988d..5d0ac199185 100644 --- a/homeassistant/components/zha/core/channels/lightlink.py +++ b/homeassistant/components/zha/core/channels/lightlink.py @@ -1,9 +1,4 @@ -""" -Lightlink channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Lightlink channels module for Zigbee Home Automation.""" import logging import zigpy.zcl.clusters.lightlink as lightlink diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 39f45f6c4a2..e3d1e67439f 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -1,9 +1,4 @@ -""" -Manufacturer specific channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Manufacturer specific channels module for Zigbee Home Automation.""" import logging from homeassistant.core import callback diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/channels/measurement.py index 369ecb69aa1..dfb83224505 100644 --- a/homeassistant/components/zha/core/channels/measurement.py +++ b/homeassistant/components/zha/core/channels/measurement.py @@ -1,9 +1,4 @@ -""" -Measurement channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Measurement channels module for Zigbee Home Automation.""" import logging import zigpy.zcl.clusters.measurement as measurement diff --git a/homeassistant/components/zha/core/channels/protocol.py b/homeassistant/components/zha/core/channels/protocol.py index aa463392e55..20867553121 100644 --- a/homeassistant/components/zha/core/channels/protocol.py +++ b/homeassistant/components/zha/core/channels/protocol.py @@ -1,9 +1,4 @@ -""" -Protocol channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Protocol channels module for Zigbee Home Automation.""" import logging import zigpy.zcl.clusters.protocol as protocol diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index 781738fc048..a529ff69d32 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -1,9 +1,4 @@ -""" -Security channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Security channels module for Zigbee Home Automation.""" import logging from zigpy.exceptions import DeliveryError diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index c7de2943691..08feb328603 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -1,9 +1,4 @@ -""" -Smart energy channels module for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Smart energy channels module for Zigbee Home Automation.""" import logging import zigpy.zcl.clusters.smartenergy as smartenergy diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 2e7c48c639f..ffa264dde63 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -1,9 +1,4 @@ -""" -Device for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Device for Zigbee Home Automation.""" import asyncio from datetime import timedelta from enum import Enum diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index d128ed274c0..c8514e2937d 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -1,9 +1,4 @@ -""" -Device discovery functions for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Device discovery functions for Zigbee Home Automation.""" import logging diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 33faaa334cb..8a8f57764a6 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -1,9 +1,4 @@ -""" -Virtual gateway for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Virtual gateway for Zigbee Home Automation.""" import asyncio import collections diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py index 92ce1f75360..ca2cc0ff1d3 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -1,9 +1,4 @@ -""" -Group for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Group for Zigbee Home Automation.""" import asyncio import logging diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index e3ff446ba98..c0008b055db 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -1,9 +1,4 @@ -""" -Helpers for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Helpers for Zigbee Home Automation.""" import collections import logging diff --git a/homeassistant/components/zha/core/patches.py b/homeassistant/components/zha/core/patches.py index a4e84e83105..3d8c84e9bf3 100644 --- a/homeassistant/components/zha/core/patches.py +++ b/homeassistant/components/zha/core/patches.py @@ -1,9 +1,4 @@ -""" -Patch functions for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Patch functions for Zigbee Home Automation.""" def apply_application_controller_patch(zha_gateway): diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 4f5c6fc5c6b..bc788b39ee7 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -1,9 +1,4 @@ -""" -Mapping registries for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/integrations/zha/ -""" +"""Mapping registries for Zigbee Home Automation.""" import collections from typing import Callable, Set, Union From a12c4da0ca4c182ff1c6071f9880d66eac626adb Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Fri, 21 Feb 2020 00:33:51 +0000 Subject: [PATCH 031/416] [ci skip] Translation update --- .../components/deconz/.translations/es.json | 3 +- .../components/deconz/.translations/lb.json | 3 +- .../components/deconz/.translations/pl.json | 3 +- .../components/demo/.translations/es.json | 3 +- .../components/demo/.translations/lb.json | 9 +++ .../components/demo/.translations/pl.json | 25 +++++++ .../components/ipma/.translations/pl.json | 1 + .../konnected/.translations/pl.json | 70 +++++++++++++++++-- .../components/mqtt/.translations/es.json | 10 +++ .../components/mqtt/.translations/lb.json | 12 ++++ .../components/plex/.translations/lb.json | 2 + .../components/plex/.translations/pl.json | 2 + .../components/unifi/.translations/es.json | 2 + .../components/unifi/.translations/lb.json | 7 +- .../components/unifi/.translations/no.json | 2 +- .../components/unifi/.translations/pl.json | 9 ++- .../components/vilfo/.translations/es.json | 4 +- .../components/vilfo/.translations/pl.json | 18 ++++- .../components/vizio/.translations/en.json | 2 +- 19 files changed, 168 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/deconz/.translations/es.json b/homeassistant/components/deconz/.translations/es.json index 6f5513d9729..cfff05b1e02 100644 --- a/homeassistant/components/deconz/.translations/es.json +++ b/homeassistant/components/deconz/.translations/es.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "Permitir sensores deCONZ CLIP", "allow_deconz_groups": "Permitir grupos de luz deCONZ" }, - "description": "Configurar la visibilidad de los tipos de dispositivos deCONZ" + "description": "Configurar la visibilidad de los tipos de dispositivos deCONZ", + "title": "Opciones deCONZ" } } } diff --git a/homeassistant/components/deconz/.translations/lb.json b/homeassistant/components/deconz/.translations/lb.json index 4b04cfa03ce..42fd840524f 100644 --- a/homeassistant/components/deconz/.translations/lb.json +++ b/homeassistant/components/deconz/.translations/lb.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "deCONZ Clip Sensoren erlaben", "allow_deconz_groups": "deCONZ Luucht Gruppen erlaben" }, - "description": "Visibilit\u00e9it vun deCONZ Apparater konfigur\u00e9ieren" + "description": "Visibilit\u00e9it vun deCONZ Apparater konfigur\u00e9ieren", + "title": "deCONZ Optiounen" } } } diff --git a/homeassistant/components/deconz/.translations/pl.json b/homeassistant/components/deconz/.translations/pl.json index 65e858a626d..d12e633bf23 100644 --- a/homeassistant/components/deconz/.translations/pl.json +++ b/homeassistant/components/deconz/.translations/pl.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "Zezwalaj na czujniki deCONZ CLIP", "allow_deconz_groups": "Zezwalaj na grupy \u015bwiate\u0142 deCONZ" }, - "description": "Skonfiguruj widoczno\u015b\u0107 typ\u00f3w urz\u0105dze\u0144 deCONZ" + "description": "Skonfiguruj widoczno\u015b\u0107 typ\u00f3w urz\u0105dze\u0144 deCONZ", + "title": "Opcje deCONZ" } } } diff --git a/homeassistant/components/demo/.translations/es.json b/homeassistant/components/demo/.translations/es.json index 29c99c94971..73ed9809d65 100644 --- a/homeassistant/components/demo/.translations/es.json +++ b/homeassistant/components/demo/.translations/es.json @@ -13,7 +13,8 @@ "options_2": { "data": { "multi": "Multiselecci\u00f3n", - "select": "Selecciona una opci\u00f3n" + "select": "Selecciona una opci\u00f3n", + "string": "Valor de cadena" } } } diff --git a/homeassistant/components/demo/.translations/lb.json b/homeassistant/components/demo/.translations/lb.json index ef01fcb4f3c..d968b43af8b 100644 --- a/homeassistant/components/demo/.translations/lb.json +++ b/homeassistant/components/demo/.translations/lb.json @@ -1,5 +1,14 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "options_2": { + "data": { + "select": "Eng Optioun auswielen" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/pl.json b/homeassistant/components/demo/.translations/pl.json index ef01fcb4f3c..f224d100929 100644 --- a/homeassistant/components/demo/.translations/pl.json +++ b/homeassistant/components/demo/.translations/pl.json @@ -1,5 +1,30 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "init": { + "data": { + "few": "kilka", + "many": "wiele", + "one": "jedena", + "other": "inne" + } + }, + "options_1": { + "data": { + "bool": "Warto\u015b\u0107 logiczna", + "int": "Warto\u015b\u0107 numeryczna" + } + }, + "options_2": { + "data": { + "multi": "Wielokrotny wyb\u00f3r", + "select": "Wybierz opcj\u0119", + "string": "Warto\u015b\u0107 tekstowa" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/ipma/.translations/pl.json b/homeassistant/components/ipma/.translations/pl.json index 7eb8055e1a1..267b4e79137 100644 --- a/homeassistant/components/ipma/.translations/pl.json +++ b/homeassistant/components/ipma/.translations/pl.json @@ -8,6 +8,7 @@ "data": { "latitude": "Szeroko\u015b\u0107 geograficzna", "longitude": "D\u0142ugo\u015b\u0107 geograficzna", + "mode": "Tryb", "name": "Nazwa" }, "description": "Portugalski Instytut Morza i Atmosfery", diff --git a/homeassistant/components/konnected/.translations/pl.json b/homeassistant/components/konnected/.translations/pl.json index c2f992116a8..b0d721891c0 100644 --- a/homeassistant/components/konnected/.translations/pl.json +++ b/homeassistant/components/konnected/.translations/pl.json @@ -26,23 +26,81 @@ "title": "Konnected.io" }, "options": { + "abort": { + "not_konn_panel": "Nie rozpoznano urz\u0105dzenia Konnected.io" + }, + "error": { + "few": "kilka", + "many": "wiele", + "one": "jeden", + "other": "inny" + }, "step": { "options_binary": { "data": { - "name": "Nazwa (opcjonalnie)" - } + "inverse": "Odwr\u00f3\u0107 stan otwarty/zamkni\u0119ty", + "name": "Nazwa (opcjonalnie)", + "type": "Typ sensora binarnego" + }, + "description": "Wybierz opcje dla sensora binarnego powi\u0105zanego ze {zone}", + "title": "Konfiguracja sensora binarnego" }, "options_digital": { "data": { "name": "Nazwa (opcjonalnie)", + "poll_interval": "Cz\u0119stotliwo\u015b\u0107 aktualizacji (minuty) (opcjonalnie)", "type": "Typ sensora" - } + }, + "description": "Wybierz opcje dla cyfrowego sensora powi\u0105zanego ze {zone}", + "title": "Konfiguracja sensora cyfrowego" + }, + "options_io": { + "data": { + "1": "Strefa 1", + "2": "Strefa 2", + "3": "Strefa 3", + "4": "Strefa 4", + "5": "Strefa 5", + "6": "Strefa 6", + "7": "Strefa 7", + "out": "OUT" + }, + "description": "Wykryto {model} na ho\u015bcie {host}. Wybierz podstawow\u0105 konfiguracj\u0119 ka\u017cdego wej\u015bcia/wyj\u015bcia poni\u017cej \u2014 w zale\u017cno\u015bci od typu wej\u015b\u0107/wyj\u015b\u0107 mo\u017ce zastosowa\u0107 sensory binarne (otwarte/ amkni\u0119te), sensory cyfrowe (dht i ds18b20) lub prze\u0142\u0105czane wyj\u015bcia. B\u0119dziesz m\u00f3g\u0142 skonfigurowa\u0107 szczeg\u00f3\u0142owe opcje w kolejnych krokach.", + "title": "Konfiguracja wej\u015bcia/wyj\u015bcia" + }, + "options_io_ext": { + "data": { + "10": "Strefa 10", + "11": "Strefa 11", + "12": "Strefa 12", + "8": "Strefa 8", + "9": "Strefa 9", + "alarm1": "ALARM1", + "alarm2_out2": "OUT2/ALARM2", + "out1": "OUT1" + }, + "description": "Wybierz konfiguracj\u0119 pozosta\u0142ych wej\u015b\u0107/wyj\u015b\u0107 poni\u017cej. B\u0119dziesz m\u00f3g\u0142 skonfigurowa\u0107 szczeg\u00f3\u0142owe opcje w kolejnych krokach.", + "title": "Konfiguracja rozszerzonego wej\u015bcia/wyj\u015bcia" + }, + "options_misc": { + "data": { + "blink": "Miganie diody LED panelu podczas wysy\u0142ania zmiany stanu" + }, + "description": "Wybierz po\u017c\u0105dane zachowanie dla swojego panelu", + "title": "R\u00f3\u017cne opcje" }, "options_switch": { "data": { - "name": "Nazwa (opcjonalnie)" - } + "activation": "Wyj\u015bcie, gdy w\u0142\u0105czone", + "momentary": "Czas trwania impulsu (ms) (opcjonalnie)", + "name": "Nazwa (opcjonalnie)", + "pause": "Przerwa mi\u0119dzy impulsami (ms) (opcjonalnie)", + "repeat": "Ilo\u015b\u0107 powt\u00f3rze\u0144 (-1=niesko\u0144czenie) (opcjonalnie)" + }, + "description": "Wybierz opcje wyj\u015bcia dla {zone}", + "title": "Konfiguracja prze\u0142\u0105czalne wyj\u015bcie" } - } + }, + "title": "Opcje panelu alarmu Konnected" } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/es.json b/homeassistant/components/mqtt/.translations/es.json index 4870c0b129c..a705a885494 100644 --- a/homeassistant/components/mqtt/.translations/es.json +++ b/homeassistant/components/mqtt/.translations/es.json @@ -38,6 +38,16 @@ "button_6": "Sexto bot\u00f3n", "turn_off": "Apagar", "turn_on": "Encender" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" doble pulsaci\u00f3n", + "button_long_press": "\"{subtype}\" pulsado continuamente", + "button_long_release": "\"{subtype}\" soltado despu\u00e9s de pulsaci\u00f3n larga", + "button_quadruple_press": "\"{subtype}\" cu\u00e1druple pulsaci\u00f3n", + "button_quintuple_press": "\"{subtype}\" quintuple pulsaci\u00f3n", + "button_short_press": "\"{subtype}\" pulsado", + "button_short_release": "\"{subtype}\" soltado", + "button_triple_press": "\"{subtype}\" triple pulsaci\u00f3n" } } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/lb.json b/homeassistant/components/mqtt/.translations/lb.json index 9dcd9c58a3a..9467ab8a9a7 100644 --- a/homeassistant/components/mqtt/.translations/lb.json +++ b/homeassistant/components/mqtt/.translations/lb.json @@ -27,5 +27,17 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "\u00c9ischte Kn\u00e4ppchen", + "button_2": "Zweete Kn\u00e4ppchen", + "button_3": "Dr\u00ebtte Kn\u00e4ppchen", + "button_4": "V\u00e9ierte Kn\u00e4ppchen", + "button_5": "F\u00ebnnefte Kn\u00e4ppchen", + "button_6": "Sechste Kn\u00e4ppchen", + "turn_off": "Ausschalten", + "turn_on": "Uschalten" + } } } \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/lb.json b/homeassistant/components/plex/.translations/lb.json index c6fcabc40d7..6ed9d372fc1 100644 --- a/homeassistant/components/plex/.translations/lb.json +++ b/homeassistant/components/plex/.translations/lb.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Nei verwalt / gedeelt Benotzer ignor\u00e9ieren", + "monitored_users": "Iwwerwaachte Benotzer", "show_all_controls": "Weis all Kontrollen", "use_episode_art": "Benotz Biller vun der Episode" }, diff --git a/homeassistant/components/plex/.translations/pl.json b/homeassistant/components/plex/.translations/pl.json index d9ab9db8bc9..6531b552000 100644 --- a/homeassistant/components/plex/.translations/pl.json +++ b/homeassistant/components/plex/.translations/pl.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignoruj nowych zarz\u0105dzanych/wsp\u00f3\u0142dzielonych u\u017cytkownik\u00f3w", + "monitored_users": "Monitorowani u\u017cytkownicy", "show_all_controls": "Poka\u017c wszystkie elementy steruj\u0105ce", "use_episode_art": "U\u017cyj grafiki odcinka" }, diff --git a/homeassistant/components/unifi/.translations/es.json b/homeassistant/components/unifi/.translations/es.json index 9d749bdd08a..6c5e9d677c2 100644 --- a/homeassistant/components/unifi/.translations/es.json +++ b/homeassistant/components/unifi/.translations/es.json @@ -28,6 +28,7 @@ "device_tracker": { "data": { "detection_time": "Tiempo en segundos desde la \u00faltima vez que se vio hasta considerarlo desconectado", + "ssid_filter": "Seleccione los SSIDs para realizar seguimiento de clientes inal\u00e1mbricos", "track_clients": "Seguimiento de los clientes de red", "track_devices": "Rastree dispositivos de red (dispositivos Ubiquiti)", "track_wired_clients": "Incluir clientes de red cableada" @@ -45,6 +46,7 @@ "data": { "allow_bandwidth_sensors": "Crear sensores para monitorizar uso de ancho de banda de clientes de red" }, + "description": "Configurar estad\u00edsticas de los sensores", "title": "Opciones UniFi" } } diff --git a/homeassistant/components/unifi/.translations/lb.json b/homeassistant/components/unifi/.translations/lb.json index 4fa1f62c602..9707432540d 100644 --- a/homeassistant/components/unifi/.translations/lb.json +++ b/homeassistant/components/unifi/.translations/lb.json @@ -31,7 +31,8 @@ "track_clients": "Netzwierk Cliente verfollegen", "track_devices": "Netzwierk Apparater (Ubiquiti Apparater) verfollegen", "track_wired_clients": "Kabel Netzwierk Cliente abez\u00e9ien" - } + }, + "title": "UniFi Optiounen" }, "init": { "data": { @@ -42,7 +43,9 @@ "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Bandbreet Benotzung Sensore fir Netzwierk Cliente erstellen" - } + }, + "description": "Statistik Sensoren konfigur\u00e9ieren", + "title": "UniFi Optiounen" } } } diff --git a/homeassistant/components/unifi/.translations/no.json b/homeassistant/components/unifi/.translations/no.json index 1cf94b9e296..65730c7ab8b 100644 --- a/homeassistant/components/unifi/.translations/no.json +++ b/homeassistant/components/unifi/.translations/no.json @@ -38,7 +38,7 @@ }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "Opprett b\u00e5ndbreddesensorer for nettverksklienter" + "allow_bandwidth_sensors": "B\u00e5ndbreddebrukssensorer for nettverksklienter" }, "description": "Konfigurer statistikk sensorer", "title": "UniFi-alternativer" diff --git a/homeassistant/components/unifi/.translations/pl.json b/homeassistant/components/unifi/.translations/pl.json index 9c71279c444..e016fbc7cce 100644 --- a/homeassistant/components/unifi/.translations/pl.json +++ b/homeassistant/components/unifi/.translations/pl.json @@ -28,10 +28,13 @@ "device_tracker": { "data": { "detection_time": "Czas w sekundach od momentu, kiedy ostatnio widziano, a\u017c do momentu, kiedy uznano go za nieobecny.", + "ssid_filter": "Wybierz SSIDy do \u015bledzenia klient\u00f3w bezprzewodowych", "track_clients": "\u015aled\u017a klient\u00f3w sieciowych", "track_devices": "\u015aled\u017a urz\u0105dzenia sieciowe (urz\u0105dzenia Ubiquiti)", "track_wired_clients": "Uwzgl\u0119dnij klient\u00f3w sieci przewodowej" - } + }, + "description": "Konfiguracja \u015bledzenia urz\u0105dze\u0144", + "title": "Opcje UniFi" }, "init": { "data": { @@ -44,7 +47,9 @@ "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Stw\u00f3rz sensory wykorzystania przepustowo\u015bci przez klient\u00f3w sieciowych" - } + }, + "description": "Konfiguracja sensora statystyk", + "title": "Opcje UniFi" } } } diff --git a/homeassistant/components/vilfo/.translations/es.json b/homeassistant/components/vilfo/.translations/es.json index 07b1ca3b4ca..170faa197da 100644 --- a/homeassistant/components/vilfo/.translations/es.json +++ b/homeassistant/components/vilfo/.translations/es.json @@ -11,10 +11,10 @@ "step": { "user": { "data": { - "access_token": "Token de acceso para la API de Vilfo Router", + "access_token": "Token de acceso para la API del Router Vilfo", "host": "Nombre de host o IP del router" }, - "description": "Configure la integraci\u00f3n de Vilfo Router. Necesita su nombre de host/IP de Vilfo Router y un token de acceso a la API. Para obtener informaci\u00f3n adicional sobre esta integraci\u00f3n y c\u00f3mo obtener esos detalles, visite: https://www.home-assistant.io/integrations/vilfo", + "description": "Configure la integraci\u00f3n del Router Vilfo. Necesita su nombre de host/IP del Router Vilfo y un token de acceso a la API. Para obtener informaci\u00f3n adicional sobre esta integraci\u00f3n y c\u00f3mo obtener esos detalles, visite: https://www.home-assistant.io/integrations/vilfo", "title": "Conectar con el Router Vilfo" } }, diff --git a/homeassistant/components/vilfo/.translations/pl.json b/homeassistant/components/vilfo/.translations/pl.json index 9af4d104965..aef0c14703f 100644 --- a/homeassistant/components/vilfo/.translations/pl.json +++ b/homeassistant/components/vilfo/.translations/pl.json @@ -2,6 +2,22 @@ "config": { "abort": { "already_configured": "Ten router Vilfo jest ju\u017c skonfigurowany." - } + }, + "error": { + "cannot_connect": "Nie mo\u017cna nawi\u0105za\u0107 po\u0142\u0105czenia. Sprawd\u017a wprowadzone dane i spr\u00f3buj ponownie.", + "invalid_auth": "Nieudane uwierzytelnienie. Sprawd\u017a token dost\u0119pu i spr\u00f3buj ponownie.", + "unknown": "Wyst\u0105pi\u0142 nieoczekiwany b\u0142\u0105d podczas konfiguracji integracji." + }, + "step": { + "user": { + "data": { + "access_token": "Token dost\u0119pu do interfejsu API routera Vilfo", + "host": "Nazwa hosta lub adres IP routera" + }, + "description": "Skonfiguruj integracj\u0119 routera Vilfo. Potrzebujesz nazwy hosta/adresu IP routera Vilfo i tokena dost\u0119pu do interfejsu API. Aby uzyska\u0107 dodatkowe informacje na temat tej integracji i sposobu uzyskania niezb\u0119dnych danych do konfiguracji, odwied\u017a: https://www.home-assistant.io/integrations/vilfo", + "title": "Po\u0142\u0105cz si\u0119 z routerem Vilfo" + } + }, + "title": "Router Vilfo" } } \ No newline at end of file diff --git a/homeassistant/components/vizio/.translations/en.json b/homeassistant/components/vizio/.translations/en.json index 23b7c03d423..cee436c9647 100644 --- a/homeassistant/components/vizio/.translations/en.json +++ b/homeassistant/components/vizio/.translations/en.json @@ -24,7 +24,7 @@ "host": ":", "name": "Name" }, - "title": "Setup Vizio SmartCast Client" + "title": "Setup Vizio SmartCast Device" } }, "title": "Vizio SmartCast" From d4075fb262acd7bdd6ca23f6828c1c38532279e7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 20 Feb 2020 23:06:24 -0600 Subject: [PATCH 032/416] Significantly reduce code in august integration (#32030) * Significantly reduce code in august integration * Activity updates can now be processed by py-august this allows us to eliminate the activity sync code for the door sensors and locks * Lock and door state can now be consumed from the lock detail api which allows us to remove the status call apis and reduce the number of API calls to august * Refactor the testing method for locks (part #1) * Update homeassistant/components/august/binary_sensor.py Co-Authored-By: Paulus Schoutsen * Switch to asynctest instead of unittest for mock.patch Co-authored-by: Paulus Schoutsen --- homeassistant/components/august/__init__.py | 116 ++-------------- .../components/august/binary_sensor.py | 100 +++----------- homeassistant/components/august/lock.py | 45 +----- homeassistant/components/august/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/august/mocks.py | 127 ++++++++++++++--- tests/components/august/test_binary_sensor.py | 90 +----------- tests/components/august/test_init.py | 1 - tests/components/august/test_lock.py | 130 +++++------------- .../august/get_lock.doorsense_init.json | 103 ++++++++++++++ tests/fixtures/august/get_lock.offline.json | 68 +++++++++ tests/fixtures/august/get_lock.online.json | 103 ++++++++++++++ .../get_lock.online_with_doorsense.json | 51 +++++++ tests/fixtures/august/get_locks.json | 16 +++ tests/fixtures/august/lock_open.json | 26 ++++ tests/fixtures/august/unlock_closed.json | 26 ++++ 17 files changed, 579 insertions(+), 429 deletions(-) create mode 100644 tests/fixtures/august/get_lock.doorsense_init.json create mode 100644 tests/fixtures/august/get_lock.offline.json create mode 100644 tests/fixtures/august/get_lock.online.json create mode 100644 tests/fixtures/august/get_lock.online_with_doorsense.json create mode 100644 tests/fixtures/august/get_locks.json create mode 100644 tests/fixtures/august/lock_open.json create mode 100644 tests/fixtures/august/unlock_closed.json diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 67e177d11d9..7c7108943fb 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -18,7 +18,7 @@ from homeassistant.const import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle, dt +from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) @@ -45,11 +45,6 @@ DEFAULT_ENTITY_NAMESPACE = "august" # avoid hitting rate limits MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES = timedelta(seconds=1800) -# Limit locks status check to 900 seconds now that -# we get the state from the lock and unlock api calls -# and the lock and unlock activities are now captured -MIN_TIME_BETWEEN_LOCK_STATUS_UPDATES = timedelta(seconds=900) - # Doorbells need to update more frequently than locks # since we get an image from the doorbell api MIN_TIME_BETWEEN_DOORBELL_STATUS_UPDATES = timedelta(seconds=20) @@ -218,16 +213,11 @@ class AugustData: self._house_ids.add(device.house_id) self._doorbell_detail_by_id = {} - self._door_last_state_update_time_utc_by_id = {} - self._lock_last_status_update_time_utc_by_id = {} - self._lock_status_by_id = {} self._lock_detail_by_id = {} - self._door_state_by_id = {} self._activities_by_id = {} # We check the locks right away so we can # remove inoperative ones - self._update_locks_status() self._update_locks_detail() self._filter_inoperative_locks() @@ -344,8 +334,13 @@ class AugustData: This is called when newer activity is detected on the activity feed in order to keep the internal data in sync """ - self._door_state_by_id[lock_id] = door_state - self._door_last_state_update_time_utc_by_id[lock_id] = update_start_time_utc + # When syncing the door state became available via py-august, this + # function caused to be actively used. It will be again as we will + # update the door state from lock/unlock operations as the august api + # does report the door state on lock/unlock, however py-august does not + # expose this to us yet. + self._lock_detail_by_id[lock_id].door_state = door_state + self._lock_detail_by_id[lock_id].door_state_datetime = update_start_time_utc return True def update_lock_status(self, lock_id, lock_status, update_start_time_utc): @@ -355,8 +350,8 @@ class AugustData: or newer activity is detected on the activity feed in order to keep the internal data in sync """ - self._lock_status_by_id[lock_id] = lock_status - self._lock_last_status_update_time_utc_by_id[lock_id] = update_start_time_utc + self._lock_detail_by_id[lock_id].lock_status = lock_status + self._lock_detail_by_id[lock_id].lock_status_datetime = update_start_time_utc return True def lock_has_doorsense(self, lock_id): @@ -367,18 +362,10 @@ class AugustData: return False return self._lock_detail_by_id[lock_id].doorsense - async def async_get_lock_status(self, lock_id): - """Return status if the door is locked or unlocked. - - This is status for the lock itself. - """ - await self._async_update_locks() - return self._lock_status_by_id.get(lock_id) - async def async_get_lock_detail(self, lock_id): """Return lock detail.""" - await self._async_update_locks() - return self._lock_detail_by_id.get(lock_id) + await self._async_update_locks_detail() + return self._lock_detail_by_id[lock_id] def get_lock_name(self, device_id): """Return lock name as August has it stored.""" @@ -386,85 +373,6 @@ class AugustData: if lock.device_id == device_id: return lock.device_name - async def async_get_door_state(self, lock_id): - """Return status if the door is open or closed. - - This is the status from the door sensor. - """ - await self._async_update_locks_status() - return self._door_state_by_id.get(lock_id) - - async def _async_update_locks(self): - await self._async_update_locks_status() - await self._async_update_locks_detail() - - @Throttle(MIN_TIME_BETWEEN_LOCK_STATUS_UPDATES) - async def _async_update_locks_status(self): - await self._hass.async_add_executor_job(self._update_locks_status) - - def _update_locks_status(self): - status_by_id = {} - state_by_id = {} - lock_last_status_update_by_id = {} - door_last_state_update_by_id = {} - - _LOGGER.debug("Start retrieving lock and door status") - for lock in self._locks: - update_start_time_utc = dt.utcnow() - _LOGGER.debug("Updating lock and door status for %s", lock.device_name) - try: - ( - status_by_id[lock.device_id], - state_by_id[lock.device_id], - ) = self._api.get_lock_status( - self._access_token, lock.device_id, door_status=True - ) - # Since there is a a race condition between calling the - # lock and activity apis, we set the last update time - # BEFORE making the api call since we will compare this - # to activity later we want activity to win over stale lock/door - # state. - lock_last_status_update_by_id[lock.device_id] = update_start_time_utc - door_last_state_update_by_id[lock.device_id] = update_start_time_utc - except RequestException as ex: - _LOGGER.error( - "Request error trying to retrieve lock and door status for %s. %s", - lock.device_name, - ex, - ) - status_by_id[lock.device_id] = None - state_by_id[lock.device_id] = None - except Exception: - status_by_id[lock.device_id] = None - state_by_id[lock.device_id] = None - raise - - _LOGGER.debug("Completed retrieving lock and door status") - self._lock_status_by_id = status_by_id - self._door_state_by_id = state_by_id - self._door_last_state_update_time_utc_by_id = door_last_state_update_by_id - self._lock_last_status_update_time_utc_by_id = lock_last_status_update_by_id - - def get_last_lock_status_update_time_utc(self, lock_id): - """Return the last time that a lock status update was seen from the august API.""" - # Since the activity api is called more frequently than - # the lock api it is possible that the lock has not - # been updated yet - if lock_id not in self._lock_last_status_update_time_utc_by_id: - return dt.utc_from_timestamp(0) - - return self._lock_last_status_update_time_utc_by_id[lock_id] - - def get_last_door_state_update_time_utc(self, lock_id): - """Return the last time that a door status update was seen from the august API.""" - # Since the activity api is called more frequently than - # the lock api it is possible that the door has not - # been updated yet - if lock_id not in self._door_last_state_update_time_utc_by_id: - return dt.utc_from_timestamp(0) - - return self._door_last_state_update_time_utc_by_id[lock_id] - @Throttle(MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES) async def _async_update_locks_detail(self): await self._hass.async_add_executor_job(self._update_locks_detail) diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index aed1995d592..935642585fd 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -2,11 +2,11 @@ from datetime import datetime, timedelta import logging -from august.activity import ACTIVITY_ACTION_STATES, ActivityType +from august.activity import ActivityType from august.lock import LockDoorStatus +from august.util import update_lock_detail_from_activity from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.util import dt from . import DATA_AUGUST @@ -15,11 +15,6 @@ _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) -async def _async_retrieve_door_state(data, lock): - """Get the latest state of the DoorSense sensor.""" - return await data.async_get_door_state(lock.device_id) - - async def _async_retrieve_online_state(data, doorbell): """Get the latest state of the sensor.""" detail = await data.async_get_doorbell_detail(doorbell.device_id) @@ -61,8 +56,6 @@ SENSOR_DEVICE_CLASS = 1 SENSOR_STATE_PROVIDER = 2 # sensor_type: [name, device_class, async_state_provider] -SENSOR_TYPES_DOOR = {"door_open": ["Open", "door", _async_retrieve_door_state]} - SENSOR_TYPES_DOORBELL = { "doorbell_ding": ["Ding", "occupancy", _async_retrieve_ding_state], "doorbell_motion": ["Motion", "motion", _async_retrieve_motion_state], @@ -76,21 +69,16 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= devices = [] for door in data.locks: - for sensor_type in SENSOR_TYPES_DOOR: - if not data.lock_has_doorsense(door.device_id): - _LOGGER.debug( - "Not adding sensor class %s for lock %s ", - SENSOR_TYPES_DOOR[sensor_type][SENSOR_DEVICE_CLASS], - door.device_name, - ) - continue - + if not data.lock_has_doorsense(door.device_id): _LOGGER.debug( - "Adding sensor class %s for %s", - SENSOR_TYPES_DOOR[sensor_type][SENSOR_DEVICE_CLASS], - door.device_name, + "Not adding sensor class door for lock %s ", door.device_name, ) - devices.append(AugustDoorBinarySensor(data, sensor_type, door)) + continue + + _LOGGER.debug( + "Adding sensor class door for %s", door.device_name, + ) + devices.append(AugustDoorBinarySensor(data, "door_open", door)) for doorbell in data.doorbells: for sensor_type in SENSOR_TYPES_DOORBELL: @@ -127,81 +115,35 @@ class AugustDoorBinarySensor(BinarySensorDevice): @property def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return SENSOR_TYPES_DOOR[self._sensor_type][SENSOR_DEVICE_CLASS] + """Return the class of this device.""" + return "door" @property def name(self): """Return the name of the binary sensor.""" - return "{} {}".format( - self._door.device_name, SENSOR_TYPES_DOOR[self._sensor_type][SENSOR_NAME] - ) + return "{} Open".format(self._door.device_name) async def async_update(self): """Get the latest state of the sensor and update activity.""" - async_state_provider = SENSOR_TYPES_DOOR[self._sensor_type][ - SENSOR_STATE_PROVIDER - ] - lock_door_state = await async_state_provider(self._data, self._door) - self._available = ( - lock_door_state is not None and lock_door_state != LockDoorStatus.UNKNOWN - ) - self._state = lock_door_state == LockDoorStatus.OPEN - door_activity = await self._data.async_get_latest_device_activity( self._door.device_id, ActivityType.DOOR_OPERATION ) + detail = await self._data.async_get_lock_detail(self._door.device_id) if door_activity is not None: - self._sync_door_activity(door_activity) + update_lock_detail_from_activity(detail, door_activity) - def _update_door_state(self, door_state, update_start_time): - new_state = door_state == LockDoorStatus.OPEN - if self._state != new_state: - self._state = new_state - self._data.update_door_state( - self._door.device_id, door_state, update_start_time - ) + lock_door_state = None + if detail is not None: + lock_door_state = detail.door_state - def _sync_door_activity(self, door_activity): - """Check the activity for the latest door open/close activity (events). - - We use this to determine the door state in between calls to the lock - api as we update it more frequently - """ - last_door_state_update_time_utc = self._data.get_last_door_state_update_time_utc( - self._door.device_id - ) - activity_end_time_utc = dt.as_utc(door_activity.activity_end_time) - - if activity_end_time_utc > last_door_state_update_time_utc: - _LOGGER.debug( - "The activity log has new events for %s: [action=%s] [activity_end_time_utc=%s] > [last_door_state_update_time_utc=%s]", - self.name, - door_activity.action, - activity_end_time_utc, - last_door_state_update_time_utc, - ) - activity_start_time_utc = dt.as_utc(door_activity.activity_start_time) - if door_activity.action in ACTIVITY_ACTION_STATES: - self._update_door_state( - ACTIVITY_ACTION_STATES[door_activity.action], - activity_start_time_utc, - ) - else: - _LOGGER.info( - "Unhandled door activity action %s for %s", - door_activity.action, - self.name, - ) + self._available = lock_door_state != LockDoorStatus.UNKNOWN + self._state = lock_door_state == LockDoorStatus.OPEN @property def unique_id(self) -> str: """Get the unique of the door open binary sensor.""" - return "{:s}_{:s}".format( - self._door.device_id, - SENSOR_TYPES_DOOR[self._sensor_type][SENSOR_NAME].lower(), - ) + return f"{self._door.device_id}_open" class AugustDoorbellBinarySensor(BinarySensorDevice): diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 9d5df1192a7..0097b6029a0 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -2,8 +2,9 @@ from datetime import timedelta import logging -from august.activity import ACTIVITY_ACTION_STATES, ActivityType +from august.activity import ActivityType from august.lock import LockStatus +from august.util import update_lock_detail_from_activity from homeassistant.components.lock import LockDevice from homeassistant.const import ATTR_BATTERY_LEVEL @@ -13,7 +14,7 @@ from . import DATA_AUGUST _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=10) +SCAN_INTERVAL = timedelta(seconds=5) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -66,51 +67,19 @@ class AugustLock(LockDevice): async def async_update(self): """Get the latest state of the sensor and update activity.""" - self._lock_status = await self._data.async_get_lock_status(self._lock.device_id) - self._available = ( - self._lock_status is not None and self._lock_status != LockStatus.UNKNOWN - ) self._lock_detail = await self._data.async_get_lock_detail(self._lock.device_id) - lock_activity = await self._data.async_get_latest_device_activity( self._lock.device_id, ActivityType.LOCK_OPERATION ) if lock_activity is not None: self._changed_by = lock_activity.operated_by - self._sync_lock_activity(lock_activity) + update_lock_detail_from_activity(self._lock_detail, lock_activity) - def _sync_lock_activity(self, lock_activity): - """Check the activity for the latest lock/unlock activity (events). - - We use this to determine the lock state in between calls to the lock - api as we update it more frequently - """ - last_lock_status_update_time_utc = self._data.get_last_lock_status_update_time_utc( - self._lock.device_id + self._lock_status = self._lock_detail.lock_status + self._available = ( + self._lock_status is not None and self._lock_status != LockStatus.UNKNOWN ) - activity_end_time_utc = dt.as_utc(lock_activity.activity_end_time) - - if activity_end_time_utc > last_lock_status_update_time_utc: - _LOGGER.debug( - "The activity log has new events for %s: [action=%s] [activity_end_time_utc=%s] > [last_lock_status_update_time_utc=%s]", - self.name, - lock_activity.action, - activity_end_time_utc, - last_lock_status_update_time_utc, - ) - activity_start_time_utc = dt.as_utc(lock_activity.activity_start_time) - if lock_activity.action in ACTIVITY_ACTION_STATES: - self._update_lock_status( - ACTIVITY_ACTION_STATES[lock_activity.action], - activity_start_time_utc, - ) - else: - _LOGGER.info( - "Unhandled lock activity action %s for %s", - lock_activity.action, - self.name, - ) @property def name(self): diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 7afa742f3ca..53bbdaaa33f 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -2,7 +2,7 @@ "domain": "august", "name": "August", "documentation": "https://www.home-assistant.io/integrations/august", - "requirements": ["py-august==0.14.0"], + "requirements": ["py-august==0.17.0"], "dependencies": ["configurator"], "codeowners": ["@bdraco"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2fef238a7ed..c58a841fcb3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1075,7 +1075,7 @@ pushover_complete==1.1.1 pwmled==1.5.0 # homeassistant.components.august -py-august==0.14.0 +py-august==0.17.0 # homeassistant.components.canary py-canary==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8c22db276bc..4add9755229 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -391,7 +391,7 @@ pure-python-adb==0.2.2.dev0 pushbullet.py==0.11.0 # homeassistant.components.august -py-august==0.14.0 +py-august==0.17.0 # homeassistant.components.canary py-canary==0.5.0 diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py index 9be8f697b8b..30269bec11e 100644 --- a/tests/components/august/mocks.py +++ b/tests/components/august/mocks.py @@ -1,17 +1,83 @@ """Mocks for the august component.""" import datetime +import json +import os from unittest.mock import MagicMock, PropertyMock +from asynctest import mock from august.activity import Activity from august.api import Api +from august.authenticator import AuthenticationState +from august.doorbell import Doorbell, DoorbellDetail from august.exceptions import AugustApiHTTPError -from august.lock import Lock, LockDetail +from august.lock import Lock, LockDetail, LockStatus -from homeassistant.components.august import AugustData +from homeassistant.components.august import ( + CONF_LOGIN_METHOD, + CONF_PASSWORD, + CONF_USERNAME, + DOMAIN, + AugustData, +) from homeassistant.components.august.binary_sensor import AugustDoorBinarySensor -from homeassistant.components.august.lock import AugustLock +from homeassistant.setup import async_setup_component from homeassistant.util import dt +from tests.common import load_fixture + + +def _mock_get_config(): + """Return a default august config.""" + return { + DOMAIN: { + CONF_LOGIN_METHOD: "email", + CONF_USERNAME: "mocked_username", + CONF_PASSWORD: "mocked_password", + } + } + + +@mock.patch("homeassistant.components.august.Api") +@mock.patch("homeassistant.components.august.Authenticator.authenticate") +async def _mock_setup_august(hass, api_mocks_callback, authenticate_mock, api_mock): + """Set up august integration.""" + authenticate_mock.side_effect = MagicMock( + return_value=_mock_august_authentication("original_token", 1234) + ) + api_mocks_callback(api_mock) + assert await async_setup_component(hass, DOMAIN, _mock_get_config()) + await hass.async_block_till_done() + return True + + +async def _create_august_with_devices(hass, lock_details=[], doorbell_details=[]): + locks = [] + doorbells = [] + for lock in lock_details: + if isinstance(lock, LockDetail): + locks.append(_mock_august_lock(lock.device_id)) + for doorbell in doorbell_details: + if isinstance(lock, DoorbellDetail): + doorbells.append(_mock_august_doorbell(doorbell.device_id)) + + def api_mocks_callback(api): + def get_lock_detail_side_effect(access_token, device_id): + for lock in lock_details: + if isinstance(lock, LockDetail) and lock.device_id == device_id: + return lock + + api_instance = MagicMock() + api_instance.get_lock_detail.side_effect = get_lock_detail_side_effect + api_instance.get_operable_locks.return_value = locks + api_instance.get_doorbells.return_value = doorbells + api_instance.lock.return_value = LockStatus.LOCKED + api_instance.unlock.return_value = LockStatus.UNLOCKED + api.return_value = api_instance + + await _mock_setup_august(hass, api_mocks_callback) + + return True + class MockAugustApiFailing(Api): """A mock for py-august Api class that always has an AugustApiHTTPError.""" @@ -61,21 +127,6 @@ class MockAugustComponentDoorBinarySensor(AugustDoorBinarySensor): self.last_update_door_state["activity_start_time_utc"] = activity_start_time_utc -class MockAugustComponentLock(AugustLock): - """A mock for august component AugustLock class.""" - - def _update_lock_status(self, lock_status, activity_start_time_utc): - """Mock updating the lock status.""" - self._data.set_last_lock_status_update_time_utc( - self._lock.device_id, activity_start_time_utc - ) - self.last_update_lock_status = {} - self.last_update_lock_status["lock_status"] = lock_status - self.last_update_lock_status[ - "activity_start_time_utc" - ] = activity_start_time_utc - - class MockAugustComponentData(AugustData): """A wrapper to mock AugustData.""" @@ -143,6 +194,9 @@ def _mock_august_authenticator(): def _mock_august_authentication(token_text, token_timestamp): authentication = MagicMock(name="august.authentication") + type(authentication).state = PropertyMock( + return_value=AuthenticationState.AUTHENTICATED + ) type(authentication).access_token = PropertyMock(return_value=token_text) type(authentication).access_token_expires = PropertyMock( return_value=token_timestamp @@ -154,6 +208,31 @@ def _mock_august_lock(lockid="mocklockid1", houseid="mockhouseid1"): return Lock(lockid, _mock_august_lock_data(lockid=lockid, houseid=houseid)) +def _mock_august_doorbell(deviceid="mockdeviceid1", houseid="mockhouseid1"): + return Doorbell( + deviceid, _mock_august_doorbell_data(device=deviceid, houseid=houseid) + ) + + +def _mock_august_doorbell_data(deviceid="mockdeviceid1", houseid="mockhouseid1"): + return { + "_id": deviceid, + "DeviceID": deviceid, + "DeviceName": deviceid + " Name", + "HouseID": houseid, + "UserType": "owner", + "SerialNumber": "mockserial", + "battery": 90, + "currentFirmwareVersion": "mockfirmware", + "Bridge": { + "_id": "bridgeid1", + "firmwareVersion": "mockfirm", + "operative": True, + }, + "LockStatus": {"doorState": "open"}, + } + + def _mock_august_lock_data(lockid="mocklockid1", houseid="mockhouseid1"): return { "_id": lockid, @@ -189,6 +268,18 @@ def _mock_doorsense_enabled_august_lock_detail(lockid): return LockDetail(doorsense_lock_detail_data) +async def _mock_lock_from_fixture(hass, path): + json_dict = await _load_json_fixture(hass, path) + return LockDetail(json_dict) + + +async def _load_json_fixture(hass, path): + fixture = await hass.async_add_executor_job( + load_fixture, os.path.join("august", path) + ) + return json.loads(fixture) + + def _mock_doorsense_missing_august_lock_detail(lockid): doorsense_lock_detail_data = _mock_august_lock_data(lockid=lockid) del doorsense_lock_detail_data["LockStatus"]["doorState"] diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 0fbd120ea8b..5988e21ebac 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -1,89 +1 @@ -"""The lock tests for the august platform.""" - -import datetime - -from august.activity import ACTION_DOOR_CLOSED, ACTION_DOOR_OPEN -from august.lock import LockDoorStatus - -from homeassistant.util import dt - -from tests.components.august.mocks import ( - MockActivity, - MockAugustComponentData, - MockAugustComponentDoorBinarySensor, - _mock_august_lock, -) - - -def test__sync_door_activity_doored_via_dooropen(): - """Test _sync_door_activity dooropen.""" - data = MockAugustComponentData(last_door_state_update_timestamp=1) - lock = _mock_august_lock() - data.set_mocked_locks([lock]) - door = MockAugustComponentDoorBinarySensor(data, "door_open", lock) - door_activity_start_timestamp = 1234 - door_activity = MockActivity( - action=ACTION_DOOR_OPEN, - activity_start_timestamp=door_activity_start_timestamp, - activity_end_timestamp=5678, - ) - door._sync_door_activity(door_activity) - assert door.last_update_door_state["door_state"] == LockDoorStatus.OPEN - assert door.last_update_door_state["activity_start_time_utc"] == dt.as_utc( - datetime.datetime.fromtimestamp(door_activity_start_timestamp) - ) - - -def test__sync_door_activity_doorclosed(): - """Test _sync_door_activity doorclosed.""" - data = MockAugustComponentData(last_door_state_update_timestamp=1) - lock = _mock_august_lock() - data.set_mocked_locks([lock]) - door = MockAugustComponentDoorBinarySensor(data, "door_open", lock) - door_activity_timestamp = 1234 - door_activity = MockActivity( - action=ACTION_DOOR_CLOSED, - activity_start_timestamp=door_activity_timestamp, - activity_end_timestamp=door_activity_timestamp, - ) - door._sync_door_activity(door_activity) - assert door.last_update_door_state["door_state"] == LockDoorStatus.CLOSED - assert door.last_update_door_state["activity_start_time_utc"] == dt.as_utc( - datetime.datetime.fromtimestamp(door_activity_timestamp) - ) - - -def test__sync_door_activity_ignores_old_data(): - """Test _sync_door_activity dooropen then expired doorclosed.""" - data = MockAugustComponentData(last_door_state_update_timestamp=1) - lock = _mock_august_lock() - data.set_mocked_locks([lock]) - door = MockAugustComponentDoorBinarySensor(data, "door_open", lock) - first_door_activity_timestamp = 1234 - door_activity = MockActivity( - action=ACTION_DOOR_OPEN, - activity_start_timestamp=first_door_activity_timestamp, - activity_end_timestamp=first_door_activity_timestamp, - ) - door._sync_door_activity(door_activity) - assert door.last_update_door_state["door_state"] == LockDoorStatus.OPEN - assert door.last_update_door_state["activity_start_time_utc"] == dt.as_utc( - datetime.datetime.fromtimestamp(first_door_activity_timestamp) - ) - - # Now we do the update with an older start time to - # make sure it ignored - data.set_last_door_state_update_time_utc( - lock.device_id, dt.as_utc(datetime.datetime.fromtimestamp(1000)) - ) - door_activity_timestamp = 2 - door_activity = MockActivity( - action=ACTION_DOOR_CLOSED, - activity_start_timestamp=door_activity_timestamp, - activity_end_timestamp=door_activity_timestamp, - ) - door._sync_door_activity(door_activity) - assert door.last_update_door_state["door_state"] == LockDoorStatus.OPEN - assert door.last_update_door_state["activity_start_time_utc"] == dt.as_utc( - datetime.datetime.fromtimestamp(first_door_activity_timestamp) - ) +"""The binary_sensor tests for the august platform.""" diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index 3a43a0a841a..eb50e37561e 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -128,7 +128,6 @@ def _create_august_data_with_lock_details(lock_details): authenticator = _mock_august_authenticator() token_refresh_lock = MagicMock() api = MagicMock() - api.get_lock_status = MagicMock(return_value=(MagicMock(), MagicMock())) api.get_lock_detail = MagicMock(side_effect=lock_details) api.get_operable_locks = MagicMock(return_value=locks) api.get_doorbells = MagicMock(return_value=[]) diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 8b036861899..518cf22b5ba 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -1,110 +1,46 @@ """The lock tests for the august platform.""" -import datetime - -from august.activity import ( - ACTION_LOCK_LOCK, - ACTION_LOCK_ONETOUCHLOCK, - ACTION_LOCK_UNLOCK, +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_UNLOCK, + STATE_LOCKED, + STATE_ON, + STATE_UNLOCKED, ) -from august.lock import LockStatus - -from homeassistant.util import dt from tests.components.august.mocks import ( - MockActivity, - MockAugustComponentData, - MockAugustComponentLock, - _mock_august_lock, + _create_august_with_devices, + _mock_lock_from_fixture, ) -def test__sync_lock_activity_locked_via_onetouchlock(): - """Test _sync_lock_activity locking.""" - lock = _mocked_august_component_lock() - lock_activity_start_timestamp = 1234 - lock_activity = MockActivity( - action=ACTION_LOCK_ONETOUCHLOCK, - activity_start_timestamp=lock_activity_start_timestamp, - activity_end_timestamp=5678, +async def test_one_lock_unlock_happy_path(hass): + """Test creation of a lock with doorsense and bridge.""" + lock_one = await _mock_lock_from_fixture( + hass, "get_lock.online_with_doorsense.json" ) - lock._sync_lock_activity(lock_activity) - assert lock.last_update_lock_status["lock_status"] == LockStatus.LOCKED - assert lock.last_update_lock_status["activity_start_time_utc"] == dt.as_utc( - datetime.datetime.fromtimestamp(lock_activity_start_timestamp) + lock_details = [lock_one] + await _create_august_with_devices(hass, lock_details=lock_details) + + lock_abc_name = hass.states.get("lock.abc_name") + + assert lock_abc_name.state == STATE_LOCKED + + assert lock_abc_name.attributes.get("battery_level") == 92 + assert lock_abc_name.attributes.get("friendly_name") == "ABC Name" + + data = {} + data[ATTR_ENTITY_ID] = "lock.abc_name" + assert await hass.services.async_call( + LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True ) + lock_abc_name = hass.states.get("lock.abc_name") + assert lock_abc_name.state == STATE_UNLOCKED -def test__sync_lock_activity_locked_via_lock(): - """Test _sync_lock_activity locking.""" - lock = _mocked_august_component_lock() - lock_activity_start_timestamp = 1234 - lock_activity = MockActivity( - action=ACTION_LOCK_LOCK, - activity_start_timestamp=lock_activity_start_timestamp, - activity_end_timestamp=5678, - ) - lock._sync_lock_activity(lock_activity) - assert lock.last_update_lock_status["lock_status"] == LockStatus.LOCKED - assert lock.last_update_lock_status["activity_start_time_utc"] == dt.as_utc( - datetime.datetime.fromtimestamp(lock_activity_start_timestamp) - ) + assert lock_abc_name.attributes.get("battery_level") == 92 + assert lock_abc_name.attributes.get("friendly_name") == "ABC Name" - -def test__sync_lock_activity_unlocked(): - """Test _sync_lock_activity unlocking.""" - lock = _mocked_august_component_lock() - lock_activity_timestamp = 1234 - lock_activity = MockActivity( - action=ACTION_LOCK_UNLOCK, - activity_start_timestamp=lock_activity_timestamp, - activity_end_timestamp=lock_activity_timestamp, - ) - lock._sync_lock_activity(lock_activity) - assert lock.last_update_lock_status["lock_status"] == LockStatus.UNLOCKED - assert lock.last_update_lock_status["activity_start_time_utc"] == dt.as_utc( - datetime.datetime.fromtimestamp(lock_activity_timestamp) - ) - - -def test__sync_lock_activity_ignores_old_data(): - """Test _sync_lock_activity unlocking.""" - data = MockAugustComponentData(last_lock_status_update_timestamp=1) - august_lock = _mock_august_lock() - data.set_mocked_locks([august_lock]) - lock = MockAugustComponentLock(data, august_lock) - first_lock_activity_timestamp = 1234 - lock_activity = MockActivity( - action=ACTION_LOCK_UNLOCK, - activity_start_timestamp=first_lock_activity_timestamp, - activity_end_timestamp=first_lock_activity_timestamp, - ) - lock._sync_lock_activity(lock_activity) - assert lock.last_update_lock_status["lock_status"] == LockStatus.UNLOCKED - assert lock.last_update_lock_status["activity_start_time_utc"] == dt.as_utc( - datetime.datetime.fromtimestamp(first_lock_activity_timestamp) - ) - - # Now we do the update with an older start time to - # make sure it ignored - data.set_last_lock_status_update_time_utc( - august_lock.device_id, dt.as_utc(datetime.datetime.fromtimestamp(1000)) - ) - lock_activity_timestamp = 2 - lock_activity = MockActivity( - action=ACTION_LOCK_LOCK, - activity_start_timestamp=lock_activity_timestamp, - activity_end_timestamp=lock_activity_timestamp, - ) - lock._sync_lock_activity(lock_activity) - assert lock.last_update_lock_status["lock_status"] == LockStatus.UNLOCKED - assert lock.last_update_lock_status["activity_start_time_utc"] == dt.as_utc( - datetime.datetime.fromtimestamp(first_lock_activity_timestamp) - ) - - -def _mocked_august_component_lock(): - data = MockAugustComponentData(last_lock_status_update_timestamp=1) - august_lock = _mock_august_lock() - data.set_mocked_locks([august_lock]) - return MockAugustComponentLock(data, august_lock) + binary_sensor_abc_name = hass.states.get("binary_sensor.abc_name_open") + assert binary_sensor_abc_name.state == STATE_ON diff --git a/tests/fixtures/august/get_lock.doorsense_init.json b/tests/fixtures/august/get_lock.doorsense_init.json new file mode 100644 index 00000000000..be60bbe6236 --- /dev/null +++ b/tests/fixtures/august/get_lock.doorsense_init.json @@ -0,0 +1,103 @@ +{ + "LockName": "Front Door Lock", + "Type": 2, + "Created": "2017-12-10T03:12:09.210Z", + "Updated": "2017-12-10T03:12:09.210Z", + "LockID": "A6697750D607098BAE8D6BAA11EF8063", + "HouseID": "000000000000", + "HouseName": "My House", + "Calibrated": false, + "skuNumber": "AUG-SL02-M02-S02", + "timeZone": "America/Vancouver", + "battery": 0.88, + "SerialNumber": "X2FSW05DGA", + "LockStatus": { + "status": "locked", + "doorState": "init", + "dateTime": "2017-12-10T04:48:30.272Z", + "isLockStatusChanged": false, + "valid": true + }, + "currentFirmwareVersion": "109717e9-3.0.44-3.0.30", + "homeKitEnabled": false, + "zWaveEnabled": false, + "isGalileo": false, + "Bridge": { + "_id": "aaacab87f7efxa0015884999", + "mfgBridgeID": "AAGPP102XX", + "deviceModel": "august-doorbell", + "firmwareVersion": "2.3.0-RC153+201711151527", + "operative": true + }, + "keypad": { + "_id": "5bc65c24e6ef2a263e1450a8", + "serialNumber": "K1GXB0054Z", + "lockID": "92412D1B44004595B5DEB134E151A8D3", + "currentFirmwareVersion": "2.27.0", + "battery": {}, + "batteryLevel": "Medium", + "batteryRaw": 170 + }, + "OfflineKeys": { + "created": [], + "loaded": [ + { + "UserID": "cccca94e-373e-aaaa-bbbb-333396827777", + "slot": 1, + "key": "kkk01d4300c1dcxxx1c330f794941111", + "created": "2017-12-10T03:12:09.215Z", + "loaded": "2017-12-10T03:12:54.391Z" + } + ], + "deleted": [], + "loadedhk": [ + { + "key": "kkk01d4300c1dcxxx1c330f794941222", + "slot": 256, + "UserID": "cccca94e-373e-aaaa-bbbb-333396827777", + "created": "2017-12-10T03:12:09.218Z", + "loaded": "2017-12-10T03:12:55.563Z" + } + ] + }, + "parametersToSet": {}, + "users": { + "cccca94e-373e-aaaa-bbbb-333396827777": { + "UserType": "superuser", + "FirstName": "Foo", + "LastName": "Bar", + "identifiers": [ + "email:foo@bar.com", + "phone:+177777777777" + ], + "imageInfo": { + "original": { + "width": 948, + "height": 949, + "format": "jpg", + "url": "http://www.image.com/foo.jpeg", + "secure_url": "https://www.image.com/foo.jpeg" + }, + "thumbnail": { + "width": 128, + "height": 128, + "format": "jpg", + "url": "http://www.image.com/foo.jpeg", + "secure_url": "https://www.image.com/foo.jpeg" + } + } + } + }, + "pubsubChannel": "3333a674-ffff-aaaa-b351-b3a4473f3333", + "ruleHash": {}, + "cameras": [], + "geofenceLimits": { + "ios": { + "debounceInterval": 90, + "gpsAccuracyMultiplier": 2.5, + "maximumGeofence": 5000, + "minimumGeofence": 100, + "minGPSAccuracyRequired": 80 + } + } +} diff --git a/tests/fixtures/august/get_lock.offline.json b/tests/fixtures/august/get_lock.offline.json new file mode 100644 index 00000000000..502a78674e9 --- /dev/null +++ b/tests/fixtures/august/get_lock.offline.json @@ -0,0 +1,68 @@ +{ + "Calibrated" : false, + "Created" : "2000-00-00T00:00:00.447Z", + "HouseID" : "houseid", + "HouseName" : "MockName", + "LockID" : "ABC", + "LockName" : "Test", + "LockStatus" : { + "status" : "unknown" + }, + "OfflineKeys" : { + "created" : [], + "createdhk" : [ + { + "UserID" : "mock-user-id", + "created" : "2000-00-00T00:00:00.447Z", + "key" : "mockkey", + "slot" : 12 + } + ], + "deleted" : [], + "loaded" : [ + { + "UserID" : "userid", + "created" : "2000-00-00T00:00:00.447Z", + "key" : "key", + "loaded" : "2000-00-00T00:00:00.447Z", + "slot" : 1 + } + ] + }, + "SerialNumber" : "ABC", + "Type" : 3, + "Updated" : "2000-00-00T00:00:00.447Z", + "battery" : -1, + "cameras" : [], + "currentFirmwareVersion" : "undefined-1.59.0-1.13.2", + "geofenceLimits" : { + "ios" : { + "debounceInterval" : 90, + "gpsAccuracyMultiplier" : 2.5, + "maximumGeofence" : 5000, + "minGPSAccuracyRequired" : 80, + "minimumGeofence" : 100 + } + }, + "homeKitEnabled" : false, + "isGalileo" : false, + "macAddress" : "a:b:c", + "parametersToSet" : {}, + "pubsubChannel" : "mockpubsub", + "ruleHash" : {}, + "skuNumber" : "AUG-X", + "supportsEntryCodes" : false, + "users" : { + "mockuserid" : { + "FirstName" : "MockName", + "LastName" : "House", + "UserType" : "superuser", + "identifiers" : [ + "phone:+15558675309", + "email:mockme@mock.org" + ] + } + }, + "zWaveDSK" : "1-2-3-4", + "zWaveEnabled" : true +} diff --git a/tests/fixtures/august/get_lock.online.json b/tests/fixtures/august/get_lock.online.json new file mode 100644 index 00000000000..8003359e589 --- /dev/null +++ b/tests/fixtures/august/get_lock.online.json @@ -0,0 +1,103 @@ +{ + "LockName": "Front Door Lock", + "Type": 2, + "Created": "2017-12-10T03:12:09.210Z", + "Updated": "2017-12-10T03:12:09.210Z", + "LockID": "A6697750D607098BAE8D6BAA11EF8063", + "HouseID": "000000000000", + "HouseName": "My House", + "Calibrated": false, + "skuNumber": "AUG-SL02-M02-S02", + "timeZone": "America/Vancouver", + "battery": 0.88, + "SerialNumber": "X2FSW05DGA", + "LockStatus": { + "status": "locked", + "doorState": "closed", + "dateTime": "2017-12-10T04:48:30.272Z", + "isLockStatusChanged": true, + "valid": true + }, + "currentFirmwareVersion": "109717e9-3.0.44-3.0.30", + "homeKitEnabled": false, + "zWaveEnabled": false, + "isGalileo": false, + "Bridge": { + "_id": "aaacab87f7efxa0015884999", + "mfgBridgeID": "AAGPP102XX", + "deviceModel": "august-doorbell", + "firmwareVersion": "2.3.0-RC153+201711151527", + "operative": true + }, + "keypad": { + "_id": "5bc65c24e6ef2a263e1450a8", + "serialNumber": "K1GXB0054Z", + "lockID": "92412D1B44004595B5DEB134E151A8D3", + "currentFirmwareVersion": "2.27.0", + "battery": {}, + "batteryLevel": "Medium", + "batteryRaw": 170 + }, + "OfflineKeys": { + "created": [], + "loaded": [ + { + "UserID": "cccca94e-373e-aaaa-bbbb-333396827777", + "slot": 1, + "key": "kkk01d4300c1dcxxx1c330f794941111", + "created": "2017-12-10T03:12:09.215Z", + "loaded": "2017-12-10T03:12:54.391Z" + } + ], + "deleted": [], + "loadedhk": [ + { + "key": "kkk01d4300c1dcxxx1c330f794941222", + "slot": 256, + "UserID": "cccca94e-373e-aaaa-bbbb-333396827777", + "created": "2017-12-10T03:12:09.218Z", + "loaded": "2017-12-10T03:12:55.563Z" + } + ] + }, + "parametersToSet": {}, + "users": { + "cccca94e-373e-aaaa-bbbb-333396827777": { + "UserType": "superuser", + "FirstName": "Foo", + "LastName": "Bar", + "identifiers": [ + "email:foo@bar.com", + "phone:+177777777777" + ], + "imageInfo": { + "original": { + "width": 948, + "height": 949, + "format": "jpg", + "url": "http://www.image.com/foo.jpeg", + "secure_url": "https://www.image.com/foo.jpeg" + }, + "thumbnail": { + "width": 128, + "height": 128, + "format": "jpg", + "url": "http://www.image.com/foo.jpeg", + "secure_url": "https://www.image.com/foo.jpeg" + } + } + } + }, + "pubsubChannel": "3333a674-ffff-aaaa-b351-b3a4473f3333", + "ruleHash": {}, + "cameras": [], + "geofenceLimits": { + "ios": { + "debounceInterval": 90, + "gpsAccuracyMultiplier": 2.5, + "maximumGeofence": 5000, + "minimumGeofence": 100, + "minGPSAccuracyRequired": 80 + } + } +} diff --git a/tests/fixtures/august/get_lock.online_with_doorsense.json b/tests/fixtures/august/get_lock.online_with_doorsense.json new file mode 100644 index 00000000000..b0f9475c009 --- /dev/null +++ b/tests/fixtures/august/get_lock.online_with_doorsense.json @@ -0,0 +1,51 @@ +{ + "Bridge" : { + "_id" : "bridgeid", + "deviceModel" : "august-connect", + "firmwareVersion" : "2.2.1", + "hyperBridge" : true, + "mfgBridgeID" : "C5WY200WSH", + "operative" : true, + "status" : { + "current" : "online", + "lastOffline" : "2000-00-00T00:00:00.447Z", + "lastOnline" : "2000-00-00T00:00:00.447Z", + "updated" : "2000-00-00T00:00:00.447Z" + } + }, + "Calibrated" : false, + "Created" : "2000-00-00T00:00:00.447Z", + "HouseID" : "123", + "HouseName" : "Test", + "LockID" : "ABC", + "LockName" : "Online door with doorsense", + "LockStatus" : { + "dateTime" : "2017-12-10T04:48:30.272Z", + "doorState" : "open", + "isLockStatusChanged" : false, + "status" : "locked", + "valid" : true + }, + "SerialNumber" : "XY", + "Type" : 1001, + "Updated" : "2000-00-00T00:00:00.447Z", + "battery" : 0.922, + "currentFirmwareVersion" : "undefined-4.3.0-1.8.14", + "homeKitEnabled" : true, + "hostLockInfo" : { + "manufacturer" : "yale", + "productID" : 1536, + "productTypeID" : 32770, + "serialNumber" : "ABC" + }, + "isGalileo" : false, + "macAddress" : "12:22", + "pins" : { + "created" : [], + "loaded" : [] + }, + "skuNumber" : "AUG-MD01", + "supportsEntryCodes" : true, + "timeZone" : "Pacific/Hawaii", + "zWaveEnabled" : false +} diff --git a/tests/fixtures/august/get_locks.json b/tests/fixtures/august/get_locks.json new file mode 100644 index 00000000000..3fab55f82c9 --- /dev/null +++ b/tests/fixtures/august/get_locks.json @@ -0,0 +1,16 @@ +{ + "A6697750D607098BAE8D6BAA11EF8063": { + "LockName": "Front Door Lock", + "UserType": "superuser", + "macAddress": "2E:BA:C4:14:3F:09", + "HouseID": "000000000000", + "HouseName": "A House" + }, + "A6697750D607098BAE8D6BAA11EF9999": { + "LockName": "Back Door Lock", + "UserType": "user", + "macAddress": "2E:BA:C4:14:3F:88", + "HouseID": "000000000011", + "HouseName": "A House" + } +} diff --git a/tests/fixtures/august/lock_open.json b/tests/fixtures/august/lock_open.json new file mode 100644 index 00000000000..67e3ccfbf15 --- /dev/null +++ b/tests/fixtures/august/lock_open.json @@ -0,0 +1,26 @@ +{ + "status" : "kAugLockState_Locked", + "resultsFromOperationCache" : false, + "retryCount" : 1, + "info" : { + "wlanRSSI" : -54, + "lockType" : "lock_version_1001", + "lockStatusChanged" : false, + "serialNumber" : "ABC", + "serial" : "123", + "action" : "lock", + "context" : { + "startDate" : "2020-02-19T01:59:39.516Z", + "retryCount" : 1, + "transactionID" : "mock" + }, + "bridgeID" : "mock", + "wlanSNR" : 41, + "startTime" : "2020-02-19T01:59:39.517Z", + "duration" : 5149, + "lockID" : "ABC", + "rssi" : -77 + }, + "totalTime" : 5162, + "doorState" : "kAugDoorState_Open" +} diff --git a/tests/fixtures/august/unlock_closed.json b/tests/fixtures/august/unlock_closed.json new file mode 100644 index 00000000000..57b712f55e1 --- /dev/null +++ b/tests/fixtures/august/unlock_closed.json @@ -0,0 +1,26 @@ +{ + "status" : "kAugLockState_Unlocked", + "resultsFromOperationCache" : false, + "retryCount" : 1, + "info" : { + "wlanRSSI" : -54, + "lockType" : "lock_version_1001", + "lockStatusChanged" : false, + "serialNumber" : "ABC", + "serial" : "123", + "action" : "lock", + "context" : { + "startDate" : "2020-02-19T01:59:39.516Z", + "retryCount" : 1, + "transactionID" : "mock" + }, + "bridgeID" : "mock", + "wlanSNR" : 41, + "startTime" : "2020-02-19T01:59:39.517Z", + "duration" : 5149, + "lockID" : "ABC", + "rssi" : -77 + }, + "totalTime" : 5162, + "doorState" : "kAugDoorState_Closed" +} From 6e74ee7b644691d0d2732cf11a0a9e3d2d0fd019 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 21 Feb 2020 00:39:07 -0600 Subject: [PATCH 033/416] Fix i/o in august camera async image update (#32044) * Fix i/o in august camera image update * Address review comments --- homeassistant/components/august/camera.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index afc22716421..5426d9574dc 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -64,12 +64,17 @@ class AugustCamera(Camera): if self._image_url is not latest.image_url: self._image_url = latest.image_url - self._image_content = requests.get( - self._image_url, timeout=self._timeout - ).content + self._image_content = await self.hass.async_add_executor_job( + self._camera_image + ) return self._image_content + def _camera_image(self): + """Return bytes of camera image via http get.""" + # Move this to py-august: see issue#32048 + return requests.get(self._image_url, timeout=self._timeout).content + @property def unique_id(self) -> str: """Get the unique id of the camera.""" From dd8597cb46c5e788995e1ea56b87dfbaf43ca51c Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Fri, 21 Feb 2020 10:32:48 +0100 Subject: [PATCH 034/416] Bump brother to 0.1.6 (#32054) --- homeassistant/components/brother/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index e63fb9b0d7c..51e6c3284ff 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/brother", "dependencies": [], "codeowners": ["@bieniu"], - "requirements": ["brother==0.1.4"], + "requirements": ["brother==0.1.6"], "zeroconf": ["_printer._tcp.local."], "config_flow": true } diff --git a/requirements_all.txt b/requirements_all.txt index c58a841fcb3..4a95d388ffb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -343,7 +343,7 @@ bravia-tv==1.0 broadlink==0.12.0 # homeassistant.components.brother -brother==0.1.4 +brother==0.1.6 # homeassistant.components.brottsplatskartan brottsplatskartan==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4add9755229..3b5c47c55cf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -124,7 +124,7 @@ bomradarloop==0.1.3 broadlink==0.12.0 # homeassistant.components.brother -brother==0.1.4 +brother==0.1.6 # homeassistant.components.buienradar buienradar==1.0.1 From f32411e39475de1508f37c8edcc7eaa15e7b7c87 Mon Sep 17 00:00:00 2001 From: Patrick Kishino Date: Sat, 22 Feb 2020 01:01:57 +0900 Subject: [PATCH 035/416] Add asuswrt interface and dnsmasq location configuration (#29834) * Added interface and dnsmasq location configuration * Updated aioasuswrt requirement to latest 1.2.1 release * Updated to working aioasus library and fixed correct conf formatting --- .coveragerc | 1 - homeassistant/components/asuswrt/__init__.py | 17 +++- .../components/asuswrt/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/asuswrt/test_device_tracker.py | 90 +++++++++++++++---- tests/components/asuswrt/test_sensor.py | 42 +++++++++ 7 files changed, 130 insertions(+), 26 deletions(-) create mode 100644 tests/components/asuswrt/test_sensor.py diff --git a/.coveragerc b/.coveragerc index 35c47de4160..bf980a40c92 100644 --- a/.coveragerc +++ b/.coveragerc @@ -58,7 +58,6 @@ omit = homeassistant/components/arwn/sensor.py homeassistant/components/asterisk_cdr/mailbox.py homeassistant/components/asterisk_mbox/* - homeassistant/components/asuswrt/device_tracker.py homeassistant/components/aten_pe/* homeassistant/components/atome/* homeassistant/components/august/* diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py index 897258c6299..f2d7a72e54d 100644 --- a/homeassistant/components/asuswrt/__init__.py +++ b/homeassistant/components/asuswrt/__init__.py @@ -17,6 +17,8 @@ from homeassistant.helpers.discovery import async_load_platform _LOGGER = logging.getLogger(__name__) +CONF_DNSMASQ = "dnsmasq" +CONF_INTERFACE = "interface" CONF_PUB_KEY = "pub_key" CONF_REQUIRE_IP = "require_ip" CONF_SENSORS = "sensors" @@ -24,7 +26,10 @@ CONF_SSH_KEY = "ssh_key" DOMAIN = "asuswrt" DATA_ASUSWRT = DOMAIN + DEFAULT_SSH_PORT = 22 +DEFAULT_INTERFACE = "eth0" +DEFAULT_DNSMASQ = "/var/lib/misc" SECRET_GROUP = "Password or SSH Key" SENSOR_TYPES = ["upload_speed", "download_speed", "download", "upload"] @@ -45,6 +50,8 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_SENSORS): vol.All( cv.ensure_list, [vol.In(SENSOR_TYPES)] ), + vol.Optional(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, + vol.Optional(CONF_DNSMASQ, default=DEFAULT_DNSMASQ): cv.isdir, } ) }, @@ -59,13 +66,15 @@ async def async_setup(hass, config): api = AsusWrt( conf[CONF_HOST], - conf.get(CONF_PORT), - conf.get(CONF_PROTOCOL) == "telnet", + conf[CONF_PORT], + conf[CONF_PROTOCOL] == "telnet", conf[CONF_USERNAME], conf.get(CONF_PASSWORD, ""), conf.get("ssh_key", conf.get("pub_key", "")), - conf.get(CONF_MODE), - conf.get(CONF_REQUIRE_IP), + conf[CONF_MODE], + conf[CONF_REQUIRE_IP], + conf[CONF_INTERFACE], + conf[CONF_DNSMASQ], ) await api.connection.async_connect() diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json index 416144b450c..c161dc4f536 100644 --- a/homeassistant/components/asuswrt/manifest.json +++ b/homeassistant/components/asuswrt/manifest.json @@ -2,7 +2,7 @@ "domain": "asuswrt", "name": "ASUSWRT", "documentation": "https://www.home-assistant.io/integrations/asuswrt", - "requirements": ["aioasuswrt==1.1.22"], + "requirements": ["aioasuswrt==1.2.2"], "dependencies": [], "codeowners": ["@kennedyshead"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4a95d388ffb..03aa1089d35 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -138,7 +138,7 @@ aio_georss_gdacs==0.3 aioambient==1.0.2 # homeassistant.components.asuswrt -aioasuswrt==1.1.22 +aioasuswrt==1.2.2 # homeassistant.components.automatic aioautomatic==0.6.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3b5c47c55cf..7f82944b575 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -50,7 +50,7 @@ aio_georss_gdacs==0.3 aioambient==1.0.2 # homeassistant.components.asuswrt -aioasuswrt==1.1.22 +aioasuswrt==1.2.2 # homeassistant.components.automatic aioautomatic==0.6.5 diff --git a/tests/components/asuswrt/test_device_tracker.py b/tests/components/asuswrt/test_device_tracker.py index 2ecab9c1d37..095b7b76d60 100644 --- a/tests/components/asuswrt/test_device_tracker.py +++ b/tests/components/asuswrt/test_device_tracker.py @@ -2,30 +2,16 @@ from unittest.mock import patch from homeassistant.components.asuswrt import ( - CONF_MODE, - CONF_PORT, - CONF_PROTOCOL, + CONF_DNSMASQ, + CONF_INTERFACE, DATA_ASUSWRT, DOMAIN, ) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PLATFORM, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.setup import async_setup_component from tests.common import mock_coro_func -FAKEFILE = None - -VALID_CONFIG_ROUTER_SSH = { - DOMAIN: { - CONF_PLATFORM: "asuswrt", - CONF_HOST: "fake_host", - CONF_USERNAME: "fake_user", - CONF_PROTOCOL: "ssh", - CONF_MODE: "router", - CONF_PORT: "22", - } -} - async def test_password_or_pub_key_required(hass): """Test creating an AsusWRT scanner without a pass or pubkey.""" @@ -33,7 +19,9 @@ async def test_password_or_pub_key_required(hass): AsusWrt().connection.async_connect = mock_coro_func() AsusWrt().is_connected = False result = await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_HOST: "fake_host", CONF_USERNAME: "fake_user"}} + hass, + DOMAIN, + {DOMAIN: {CONF_HOST: "fake_host", CONF_USERNAME: "fake_user"}}, ) assert not result @@ -53,8 +41,74 @@ async def test_get_scanner_with_password_no_pubkey(hass): CONF_HOST: "fake_host", CONF_USERNAME: "fake_user", CONF_PASSWORD: "4321", + CONF_DNSMASQ: "/", } }, ) assert result assert hass.data[DATA_ASUSWRT] is not None + + +async def test_specify_non_directory_path_for_dnsmasq(hass): + """Test creating an AsusWRT scanner with a dnsmasq location which is not a valid directory.""" + with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt: + AsusWrt().connection.async_connect = mock_coro_func() + AsusWrt().is_connected = False + result = await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_user", + CONF_PASSWORD: "4321", + CONF_DNSMASQ: "?non_directory?", + } + }, + ) + assert not result + + +async def test_interface(hass): + """Test creating an AsusWRT scanner using interface eth1.""" + with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt: + AsusWrt().connection.async_connect = mock_coro_func() + AsusWrt().connection.async_get_connected_devices = mock_coro_func( + return_value={} + ) + result = await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_user", + CONF_PASSWORD: "4321", + CONF_DNSMASQ: "/", + CONF_INTERFACE: "eth1", + } + }, + ) + assert result + assert hass.data[DATA_ASUSWRT] is not None + + +async def test_no_interface(hass): + """Test creating an AsusWRT scanner using no interface.""" + with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt: + AsusWrt().connection.async_connect = mock_coro_func() + AsusWrt().is_connected = False + result = await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_HOST: "fake_host", + CONF_USERNAME: "fake_user", + CONF_PASSWORD: "4321", + CONF_DNSMASQ: "/", + CONF_INTERFACE: None, + } + }, + ) + assert not result diff --git a/tests/components/asuswrt/test_sensor.py b/tests/components/asuswrt/test_sensor.py new file mode 100644 index 00000000000..39443c3fef8 --- /dev/null +++ b/tests/components/asuswrt/test_sensor.py @@ -0,0 +1,42 @@ +"""The tests for the ASUSWRT sensor platform.""" +from unittest.mock import patch + +# import homeassistant.components.sensor as sensor +from homeassistant.components.asuswrt import ( + CONF_DNSMASQ, + CONF_INTERFACE, + CONF_MODE, + CONF_PORT, + CONF_PROTOCOL, + CONF_SENSORS, + DATA_ASUSWRT, + DOMAIN, +) +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.setup import async_setup_component + +from tests.common import mock_coro_func + +VALID_CONFIG_ROUTER_SSH = { + DOMAIN: { + CONF_DNSMASQ: "/", + CONF_HOST: "fake_host", + CONF_INTERFACE: "eth0", + CONF_MODE: "router", + CONF_PORT: "22", + CONF_PROTOCOL: "ssh", + CONF_USERNAME: "fake_user", + CONF_PASSWORD: "fake_pass", + CONF_SENSORS: "upload", + } +} + + +async def test_default_sensor_setup(hass): + """Test creating an AsusWRT sensor.""" + with patch("homeassistant.components.asuswrt.AsusWrt") as AsusWrt: + AsusWrt().connection.async_connect = mock_coro_func() + + result = await async_setup_component(hass, DOMAIN, VALID_CONFIG_ROUTER_SSH) + assert result + assert hass.data[DATA_ASUSWRT] is not None From 2fb66fd866b39f210f48edb14d3e8b21f62ff974 Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Fri, 21 Feb 2020 15:11:25 -0500 Subject: [PATCH 036/416] Add additional logging to rest sensor (#32068) --- homeassistant/components/rest/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 36fd27c29a5..70424325241 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -201,11 +201,13 @@ class RestSensor(Entity): self.rest.update() value = self.rest.data + _LOGGER.debug("Data fetched from resource: %s", value) content_type = self.rest.headers.get("content-type") if content_type and content_type.startswith("text/xml"): try: value = json.dumps(xmltodict.parse(value)) + _LOGGER.debug("JSON converted from XML: %s", value) except ExpatError: _LOGGER.warning( "REST xml result could not be parsed and converted to JSON." From 9cc47ca7372879aef987e0e2c48b052b6366ac03 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Fri, 21 Feb 2020 15:07:26 -0600 Subject: [PATCH 037/416] Add ConfigEntryNotReady exception to Plex (#32071) --- homeassistant/components/plex/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 0f1873fc86f..c9b120f75f6 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -19,6 +19,7 @@ from homeassistant.const import ( CONF_VERIFY_SSL, EVENT_HOMEASSISTANT_STOP, ) +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -129,7 +130,7 @@ async def async_setup_entry(hass, entry): server_config[CONF_URL], error, ) - return False + raise ConfigEntryNotReady except ( plexapi.exceptions.BadRequest, plexapi.exceptions.Unauthorized, From 36db302cc83484e72c625cb88b147972e549b68e Mon Sep 17 00:00:00 2001 From: Ziv <16467659+ziv1234@users.noreply.github.com> Date: Sat, 22 Feb 2020 00:29:59 +0200 Subject: [PATCH 038/416] Enhance Dynalite Integration after review (#31760) * fixes per Martin Hjelmare * pylint fix * final fixes per request * fixed unit tests for new config flow * Added unit-tests to increase coverage. at 97% now * Added unit tests for 100% coverage of component * removed configured_host function and updated config_flow unit tests * added a pylint directive since it tells me by mistake DOMAIN is not used * fixed path (removed __init__) * Update homeassistant/components/dynalite/light.py Co-Authored-By: Martin Hjelmare * Update homeassistant/components/dynalite/light.py Co-Authored-By: Martin Hjelmare * fixed the test as we moved from schedule_update_... to async_schedule * Update homeassistant/components/dynalite/bridge.py Co-Authored-By: Martin Hjelmare * removed context from config_flow changed test_init to use the core methods * moved test_light to also use the core interfaces * moved to config_entries.async_unload * additional fixes for the tests * pylint fix and removed unnecessary code * Update tests/components/dynalite/test_light.py Co-Authored-By: Martin Hjelmare * Update tests/components/dynalite/test_light.py Co-Authored-By: Martin Hjelmare * Update tests/components/dynalite/test_light.py Co-Authored-By: Martin Hjelmare * Update tests/components/dynalite/test_light.py Co-Authored-By: Martin Hjelmare * Update tests/components/dynalite/test_light.py Co-Authored-By: Martin Hjelmare * Update tests/components/dynalite/test_light.py Co-Authored-By: Martin Hjelmare * Update tests/components/dynalite/test_light.py Co-Authored-By: Martin Hjelmare * Update tests/components/dynalite/test_light.py Co-Authored-By: Martin Hjelmare * Update tests/components/dynalite/test_light.py Co-Authored-By: Martin Hjelmare * added break in loop * removed last mock_coro reference pylint fix * added coverage for try_connect * added check for a successful connection before bridge.async_setup succeeds also added a "nowait" config option (default False) that avoids this check * changed log level * fixed accidental chmod I did * fixed accidental change * not storing config in bridge * not patching asyncio * moved CONFIG_SCHEMA into component * moved all logs to start capitalized (and revised some of them) * moved test_config_flow to not patch the DynaliteBridge * also took DynaliteBridge patching out of test_init * removed NO_WAIT * fixes to SCHEMA * changed _ in multi-word CONF moved imports to component const.py * removed tries * removed redundant tests * fixed some small change i broke in the library. only version update * fixed rewuirements * Update tests/components/dynalite/test_config_flow.py Co-Authored-By: Martin Hjelmare * Update tests/components/dynalite/test_light.py Co-Authored-By: Martin Hjelmare * Update tests/components/dynalite/test_config_flow.py Co-Authored-By: Martin Hjelmare * removed HIDDEN_ENTITY removed hass in test fixture * black fixes * removed final piece of hidden_entity from light fix in the library updated config flow so if the entry is already set but with a different config, calls async_update_entry * removed DATA_CONFIGS - no longer necessary * pylint fixes * added coverage * use abort in config_flow * test update * removed logs * test that update actually updates the entry Co-authored-by: Martin Hjelmare --- homeassistant/components/dynalite/__init__.py | 109 ++++++++---- homeassistant/components/dynalite/bridge.py | 128 +++++--------- .../components/dynalite/config_flow.py | 53 ++---- homeassistant/components/dynalite/const.py | 12 +- homeassistant/components/dynalite/light.py | 48 ++++-- .../components/dynalite/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/dynalite/test_bridge.py | 157 ++++++------------ tests/components/dynalite/test_config_flow.py | 116 +++++++++---- tests/components/dynalite/test_init.py | 116 ++++++------- tests/components/dynalite/test_light.py | 110 +++++++----- 12 files changed, 442 insertions(+), 413 deletions(-) diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index cb6b52483b7..f4fc65b8261 100755 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -1,24 +1,75 @@ """Support for the Dynalite networks.""" -from dynalite_devices_lib import BRIDGE_CONFIG_SCHEMA import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_HOST +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv # Loading the config flow file will register the flow from .bridge import DynaliteBridge -from .config_flow import configured_hosts -from .const import CONF_BRIDGES, DATA_CONFIGS, DOMAIN, LOGGER +from .const import ( + CONF_ACTIVE, + CONF_AREA, + CONF_AUTO_DISCOVER, + CONF_BRIDGES, + CONF_CHANNEL, + CONF_DEFAULT, + CONF_FADE, + CONF_NAME, + CONF_POLLTIMER, + CONF_PORT, + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, + LOGGER, +) + + +def num_string(value): + """Test if value is a string of digits, aka an integer.""" + new_value = str(value) + if new_value.isdigit(): + return new_value + raise vol.Invalid("Not a string with numbers") + + +CHANNEL_DATA_SCHEMA = vol.Schema( + {vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_FADE): vol.Coerce(float)} +) + +CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA}) + +AREA_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, + }, +) + +AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)}) + +PLATFORM_DEFAULTS_SCHEMA = vol.Schema({vol.Optional(CONF_FADE): vol.Coerce(float)}) + + +BRIDGE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool), + vol.Optional(CONF_POLLTIMER, default=1.0): vol.Coerce(float), + vol.Optional(CONF_AREA): AREA_SCHEMA, + vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA, + vol.Optional(CONF_ACTIVE, default=False): vol.Coerce(bool), + } +) CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( - { - vol.Optional(CONF_BRIDGES): vol.All( - cv.ensure_list, [BRIDGE_CONFIG_SCHEMA] - ) - } + {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])} ) }, extra=vol.ALLOW_EXTRA, @@ -35,9 +86,6 @@ async def async_setup(hass, config): conf = {} hass.data[DOMAIN] = {} - hass.data[DOMAIN][DATA_CONFIGS] = {} - - configured = configured_hosts(hass) # User has configured bridges if CONF_BRIDGES not in conf: @@ -47,20 +95,13 @@ async def async_setup(hass, config): for bridge_conf in bridges: host = bridge_conf[CONF_HOST] - LOGGER.debug("async_setup host=%s conf=%s", host, bridge_conf) - - # Store config in hass.data so the config entry can find it - hass.data[DOMAIN][DATA_CONFIGS][host] = bridge_conf - - if host in configured: - LOGGER.debug("async_setup host=%s already configured", host) - continue + LOGGER.debug("Starting config entry flow host=%s conf=%s", host, bridge_conf) hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_HOST: bridge_conf[CONF_HOST]}, + data=bridge_conf, ) ) @@ -69,25 +110,29 @@ async def async_setup(hass, config): async def async_setup_entry(hass, entry): """Set up a bridge from a config entry.""" - LOGGER.debug("__init async_setup_entry %s", entry.data) - host = entry.data[CONF_HOST] - config = hass.data[DOMAIN][DATA_CONFIGS].get(host) + LOGGER.debug("Setting up entry %s", entry.data) - if config is None: - LOGGER.error("__init async_setup_entry empty config for host %s", host) - return False - - bridge = DynaliteBridge(hass, entry) + bridge = DynaliteBridge(hass, entry.data) if not await bridge.async_setup(): - LOGGER.error("bridge.async_setup failed") + LOGGER.error("Could not set up bridge for entry %s", entry.data) return False + + if not await bridge.try_connection(): + LOGGER.errot("Could not connect with entry %s", entry) + raise ConfigEntryNotReady + hass.data[DOMAIN][entry.entry_id] = bridge + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "light") + ) return True async def async_unload_entry(hass, entry): """Unload a config entry.""" - LOGGER.error("async_unload_entry %s", entry.data) - bridge = hass.data[DOMAIN].pop(entry.entry_id) - return await bridge.async_reset() + LOGGER.debug("Unloading entry %s", entry.data) + hass.data[DOMAIN].pop(entry.entry_id) + result = await hass.config_entries.async_forward_entry_unload(entry, "light") + return result diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index 1bf86001cc5..cbe08fdadb5 100755 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -1,118 +1,82 @@ """Code to handle a Dynalite bridge.""" +import asyncio + from dynalite_devices_lib import DynaliteDevices -from dynalite_lib import CONF_ALL -from homeassistant.const import CONF_HOST from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import DATA_CONFIGS, DOMAIN, LOGGER -from .light import DynaliteLight +from .const import CONF_ALL, CONF_HOST, LOGGER - -class BridgeError(Exception): - """Class to throw exceptions from DynaliteBridge.""" - - def __init__(self, message): - """Initialize the exception.""" - super().__init__() - self.message = message +CONNECT_TIMEOUT = 30 +CONNECT_INTERVAL = 1 class DynaliteBridge: """Manages a single Dynalite bridge.""" - def __init__(self, hass, config_entry): + def __init__(self, hass, config): """Initialize the system based on host parameter.""" - self.config_entry = config_entry self.hass = hass self.area = {} - self.async_add_entities = None - self.waiting_entities = [] - self.all_entities = {} - self.config = None - self.host = config_entry.data[CONF_HOST] - if self.host not in hass.data[DOMAIN][DATA_CONFIGS]: - LOGGER.info("invalid host - %s", self.host) - raise BridgeError(f"invalid host - {self.host}") - self.config = hass.data[DOMAIN][DATA_CONFIGS][self.host] + self.async_add_devices = None + self.waiting_devices = [] + self.host = config[CONF_HOST] # Configure the dynalite devices self.dynalite_devices = DynaliteDevices( - config=self.config, - newDeviceFunc=self.add_devices, + config=config, + newDeviceFunc=self.add_devices_when_registered, updateDeviceFunc=self.update_device, ) - async def async_setup(self, tries=0): + async def async_setup(self): """Set up a Dynalite bridge.""" # Configure the dynalite devices - await self.dynalite_devices.async_setup() + return await self.dynalite_devices.async_setup() - self.hass.async_create_task( - self.hass.config_entries.async_forward_entry_setup( - self.config_entry, "light" - ) - ) - - return True - - @callback - def add_devices(self, devices): - """Call when devices should be added to home assistant.""" - added_entities = [] - - for device in devices: - if device.category == "light": - entity = DynaliteLight(device, self) - else: - LOGGER.debug("Illegal device category %s", device.category) - continue - added_entities.append(entity) - self.all_entities[entity.unique_id] = entity - - if added_entities: - self.add_entities_when_registered(added_entities) + def update_signal(self, device=None): + """Create signal to use to trigger entity update.""" + if device: + signal = f"dynalite-update-{self.host}-{device.unique_id}" + else: + signal = f"dynalite-update-{self.host}" + return signal @callback def update_device(self, device): """Call when a device or all devices should be updated.""" if device == CONF_ALL: # This is used to signal connection or disconnection, so all devices may become available or not. - if self.dynalite_devices.available: - LOGGER.info("Connected to dynalite host") - else: - LOGGER.info("Disconnected from dynalite host") - for uid in self.all_entities: - self.all_entities[uid].try_schedule_ha() + log_string = ( + "Connected" if self.dynalite_devices.available else "Disconnected" + ) + LOGGER.info("%s to dynalite host", log_string) + async_dispatcher_send(self.hass, self.update_signal()) else: - uid = device.unique_id - if uid in self.all_entities: - self.all_entities[uid].try_schedule_ha() + async_dispatcher_send(self.hass, self.update_signal(device)) + + async def try_connection(self): + """Try to connect to dynalite with timeout.""" + # Currently by polling. Future - will need to change the library to be proactive + for _ in range(0, CONNECT_TIMEOUT): + if self.dynalite_devices.available: + return True + await asyncio.sleep(CONNECT_INTERVAL) + return False @callback - def register_add_entities(self, async_add_entities): + def register_add_devices(self, async_add_devices): """Add an async_add_entities for a category.""" - self.async_add_entities = async_add_entities - if self.waiting_entities: - self.async_add_entities(self.waiting_entities) + self.async_add_devices = async_add_devices + if self.waiting_devices: + self.async_add_devices(self.waiting_devices) - def add_entities_when_registered(self, entities): - """Add the entities to HA if async_add_entities was registered, otherwise queue until it is.""" - if not entities: + def add_devices_when_registered(self, devices): + """Add the devices to HA if the add devices callback was registered, otherwise queue until it is.""" + if not devices: return - if self.async_add_entities: - self.async_add_entities(entities) + if self.async_add_devices: + self.async_add_devices(devices) else: # handle it later when it is registered - self.waiting_entities.extend(entities) - - async def async_reset(self): - """Reset this bridge to default state. - - Will cancel any scheduled setup retry and will unload - the config entry. - """ - result = await self.hass.config_entries.async_forward_entry_unload( - self.config_entry, "light" - ) - # None and True are OK - return result + self.waiting_devices.extend(devices) diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index 9aaaee00717..aac42172181 100755 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -1,19 +1,9 @@ """Config flow to configure Dynalite hub.""" -import asyncio - from homeassistant import config_entries from homeassistant.const import CONF_HOST -from homeassistant.core import callback -from .const import DOMAIN, LOGGER - - -@callback -def configured_hosts(hass): - """Return a set of the configured hosts.""" - return set( - entry.data[CONF_HOST] for entry in hass.config_entries.async_entries(DOMAIN) - ) +from .bridge import DynaliteBridge +from .const import DOMAIN, LOGGER # pylint: disable=unused-import class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -30,29 +20,16 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_info): """Import a new bridge as a config entry.""" - LOGGER.debug("async_step_import - %s", import_info) - host = self.context[CONF_HOST] = import_info[CONF_HOST] - return await self._entry_from_bridge(host) - - async def _entry_from_bridge(self, host): - """Return a config entry from an initialized bridge.""" - LOGGER.debug("entry_from_bridge - %s", host) - # Remove all other entries of hubs with same ID or host - - same_hub_entries = [ - entry.entry_id - for entry in self.hass.config_entries.async_entries(DOMAIN) - if entry.data[CONF_HOST] == host - ] - - LOGGER.debug("entry_from_bridge same_hub - %s", same_hub_entries) - - if same_hub_entries: - await asyncio.wait( - [ - self.hass.config_entries.async_remove(entry_id) - for entry_id in same_hub_entries - ] - ) - - return self.async_create_entry(title=host, data={CONF_HOST: host}) + LOGGER.debug("Starting async_step_import - %s", import_info) + host = import_info[CONF_HOST] + await self.async_set_unique_id(host) + self._abort_if_unique_id_configured(import_info) + # New entry + bridge = DynaliteBridge(self.hass, import_info) + if not await bridge.async_setup(): + LOGGER.error("Unable to setup bridge - import info=%s", import_info) + return self.async_abort(reason="bridge_setup_failed") + if not await bridge.try_connection(): + return self.async_abort(reason="no_connection") + LOGGER.debug("Creating entry for the bridge - %s", import_info) + return self.async_create_entry(title=host, data=import_info) diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py index f433214913a..f7795554465 100755 --- a/homeassistant/components/dynalite/const.py +++ b/homeassistant/components/dynalite/const.py @@ -3,9 +3,19 @@ import logging LOGGER = logging.getLogger(__package__) DOMAIN = "dynalite" -DATA_CONFIGS = "dynalite_configs" +CONF_ACTIVE = "active" +CONF_ALL = "ALL" +CONF_AREA = "area" +CONF_AUTO_DISCOVER = "autodiscover" CONF_BRIDGES = "bridges" +CONF_CHANNEL = "channel" +CONF_DEFAULT = "default" +CONF_FADE = "fade" +CONF_HOST = "host" +CONF_NAME = "name" +CONF_POLLTIMER = "polltimer" +CONF_PORT = "port" DEFAULT_NAME = "dynalite" DEFAULT_PORT = 12345 diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py index d3263941f9f..652a6178705 100755 --- a/homeassistant/components/dynalite/light.py +++ b/homeassistant/components/dynalite/light.py @@ -1,15 +1,26 @@ """Support for Dynalite channels as lights.""" from homeassistant.components.light import SUPPORT_BRIGHTNESS, Light from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import DOMAIN, LOGGER async def async_setup_entry(hass, config_entry, async_add_entities): """Record the async_add_entities function to add them later when received from Dynalite.""" - LOGGER.debug("async_setup_entry light entry = %s", config_entry.data) + LOGGER.debug("Setting up light entry = %s", config_entry.data) bridge = hass.data[DOMAIN][config_entry.entry_id] - bridge.register_add_entities(async_add_entities) + + @callback + def async_add_lights(devices): + added_lights = [] + for device in devices: + if device.category == "light": + added_lights.append(DynaliteLight(device, bridge)) + if added_lights: + async_add_entities(added_lights) + + bridge.register_add_devices(async_add_lights) class DynaliteLight(Light): @@ -20,11 +31,6 @@ class DynaliteLight(Light): self._device = device self._bridge = bridge - @property - def device(self): - """Return the underlying device - mostly for testing.""" - return self._device - @property def name(self): """Return the name of the entity.""" @@ -40,11 +46,6 @@ class DynaliteLight(Light): """Return if entity is available.""" return self._device.available - @property - def hidden(self): - """Return true if this entity should be hidden from UI.""" - return self._device.hidden - async def async_update(self): """Update the entity.""" return @@ -52,7 +53,11 @@ class DynaliteLight(Light): @property def device_info(self): """Device info for this entity.""" - return self._device.device_info + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Dynalite", + } @property def brightness(self): @@ -77,8 +82,15 @@ class DynaliteLight(Light): """Flag supported features.""" return SUPPORT_BRIGHTNESS - @callback - def try_schedule_ha(self): - """Schedule update HA state if configured.""" - if self.hass: - self.schedule_update_ha_state() + async def async_added_to_hass(self): + """Added to hass so need to register to dispatch.""" + # register for device specific update + async_dispatcher_connect( + self.hass, + self._bridge.update_signal(self._device), + self.async_schedule_update_ha_state, + ) + # register for wide update + async_dispatcher_connect( + self.hass, self._bridge.update_signal(), self.async_schedule_update_ha_state + ) diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json index 4df580c16a2..95667733d38 100755 --- a/homeassistant/components/dynalite/manifest.json +++ b/homeassistant/components/dynalite/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/dynalite", "dependencies": [], "codeowners": ["@ziv1234"], - "requirements": ["dynalite_devices==0.1.17"] + "requirements": ["dynalite_devices==0.1.22"] } diff --git a/requirements_all.txt b/requirements_all.txt index 03aa1089d35..d87baa2644b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -456,7 +456,7 @@ dsmr_parser==0.18 dweepy==0.3.0 # homeassistant.components.dynalite -dynalite_devices==0.1.17 +dynalite_devices==0.1.22 # homeassistant.components.rainforest_eagle eagle200_reader==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7f82944b575..2e9ad49678e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -168,7 +168,7 @@ distro==1.4.0 dsmr_parser==0.18 # homeassistant.components.dynalite -dynalite_devices==0.1.17 +dynalite_devices==0.1.22 # homeassistant.components.ee_brightbox eebrightbox==0.0.4 diff --git a/tests/components/dynalite/test_bridge.py b/tests/components/dynalite/test_bridge.py index c0aa2b3c143..133e03d9f3d 100755 --- a/tests/components/dynalite/test_bridge.py +++ b/tests/components/dynalite/test_bridge.py @@ -1,136 +1,81 @@ """Test Dynalite bridge.""" -from unittest.mock import Mock, call, patch +from unittest.mock import Mock, call +from asynctest import patch from dynalite_lib import CONF_ALL import pytest -from homeassistant.components.dynalite import DATA_CONFIGS, DOMAIN -from homeassistant.components.dynalite.bridge import BridgeError, DynaliteBridge - -from tests.common import mock_coro +from homeassistant.components import dynalite -async def test_bridge_setup(): +@pytest.fixture +def dyn_bridge(): + """Define a basic mock bridge.""" + hass = Mock() + host = "1.2.3.4" + bridge = dynalite.DynaliteBridge(hass, {dynalite.CONF_HOST: host}) + return bridge + + +async def test_update_device(dyn_bridge): """Test a successful setup.""" - hass = Mock() - entry = Mock() - host = "1.2.3.4" - entry.data = {"host": host} - hass.data = {DOMAIN: {DATA_CONFIGS: {host: {}}}} - dyn_bridge = DynaliteBridge(hass, entry) + async_dispatch = Mock() - with patch.object( - dyn_bridge.dynalite_devices, "async_setup", return_value=mock_coro(True) + with patch( + "homeassistant.components.dynalite.bridge.async_dispatcher_send", async_dispatch ): - assert await dyn_bridge.async_setup() is True - - forward_entries = set( - c[1][1] for c in hass.config_entries.async_forward_entry_setup.mock_calls - ) - hass.config_entries.async_forward_entry_setup.assert_called_once() - assert forward_entries == set(["light"]) + dyn_bridge.update_device(CONF_ALL) + async_dispatch.assert_called_once() + assert async_dispatch.mock_calls[0] == call( + dyn_bridge.hass, f"dynalite-update-{dyn_bridge.host}" + ) + async_dispatch.reset_mock() + device = Mock + device.unique_id = "abcdef" + dyn_bridge.update_device(device) + async_dispatch.assert_called_once() + assert async_dispatch.mock_calls[0] == call( + dyn_bridge.hass, f"dynalite-update-{dyn_bridge.host}-{device.unique_id}" + ) -async def test_invalid_host(): - """Test without host in hass.data.""" - hass = Mock() - entry = Mock() - host = "1.2.3.4" - entry.data = {"host": host} - hass.data = {DOMAIN: {DATA_CONFIGS: {}}} - - dyn_bridge = None - with pytest.raises(BridgeError): - dyn_bridge = DynaliteBridge(hass, entry) - assert dyn_bridge is None - - -async def test_add_devices_then_register(): +async def test_add_devices_then_register(dyn_bridge): """Test that add_devices work.""" - hass = Mock() - entry = Mock() - host = "1.2.3.4" - entry.data = {"host": host} - hass.data = {DOMAIN: {DATA_CONFIGS: {host: {}}}} - dyn_bridge = DynaliteBridge(hass, entry) - + # First test empty + dyn_bridge.add_devices_when_registered([]) + assert not dyn_bridge.waiting_devices + # Now with devices device1 = Mock() device1.category = "light" device2 = Mock() device2.category = "switch" - dyn_bridge.add_devices([device1, device2]) + dyn_bridge.add_devices_when_registered([device1, device2]) reg_func = Mock() - dyn_bridge.register_add_entities(reg_func) + dyn_bridge.register_add_devices(reg_func) reg_func.assert_called_once() - assert reg_func.mock_calls[0][1][0][0].device is device1 + assert reg_func.mock_calls[0][1][0][0] is device1 -async def test_register_then_add_devices(): +async def test_register_then_add_devices(dyn_bridge): """Test that add_devices work after register_add_entities.""" - hass = Mock() - entry = Mock() - host = "1.2.3.4" - entry.data = {"host": host} - hass.data = {DOMAIN: {DATA_CONFIGS: {host: {}}}} - dyn_bridge = DynaliteBridge(hass, entry) - device1 = Mock() device1.category = "light" device2 = Mock() device2.category = "switch" reg_func = Mock() - dyn_bridge.register_add_entities(reg_func) - dyn_bridge.add_devices([device1, device2]) + dyn_bridge.register_add_devices(reg_func) + dyn_bridge.add_devices_when_registered([device1, device2]) reg_func.assert_called_once() - assert reg_func.mock_calls[0][1][0][0].device is device1 + assert reg_func.mock_calls[0][1][0][0] is device1 -async def test_update_device(): - """Test the update_device callback.""" - hass = Mock() - entry = Mock() - host = "1.2.3.4" - entry.data = {"host": host} - hass.data = {DOMAIN: {DATA_CONFIGS: {host: {}}}} - dyn_bridge = DynaliteBridge(hass, entry) - with patch.object(dyn_bridge, "dynalite_devices") as devices_mock: - # Single device update - device1 = Mock() - device1.unique_id = "testing1" - device2 = Mock() - device2.unique_id = "testing2" - dyn_bridge.all_entities = { - device1.unique_id: device1, - device2.unique_id: device2, - } - dyn_bridge.update_device(device1) - device1.try_schedule_ha.assert_called_once() - device2.try_schedule_ha.assert_not_called() - # connected to network - all devices update - devices_mock.available = True - dyn_bridge.update_device(CONF_ALL) - assert device1.try_schedule_ha.call_count == 2 - device2.try_schedule_ha.assert_called_once() - # disconnected from network - all devices update - devices_mock.available = False - dyn_bridge.update_device(CONF_ALL) - assert device1.try_schedule_ha.call_count == 3 - assert device2.try_schedule_ha.call_count == 2 - - -async def test_async_reset(): - """Test async_reset.""" - hass = Mock() - hass.config_entries.async_forward_entry_unload = Mock( - return_value=mock_coro(Mock()) - ) - entry = Mock() - host = "1.2.3.4" - entry.data = {"host": host} - hass.data = {DOMAIN: {DATA_CONFIGS: {host: {}}}} - dyn_bridge = DynaliteBridge(hass, entry) - await dyn_bridge.async_reset() - hass.config_entries.async_forward_entry_unload.assert_called_once() - assert hass.config_entries.async_forward_entry_unload.mock_calls[0] == call( - entry, "light" - ) +async def test_try_connection(dyn_bridge): + """Test that try connection works.""" + # successful + with patch.object(dyn_bridge.dynalite_devices, "connected", True): + assert await dyn_bridge.try_connection() + # unsuccessful + with patch.object(dyn_bridge.dynalite_devices, "connected", False), patch( + "homeassistant.components.dynalite.bridge.CONNECT_INTERVAL", 0 + ): + assert not await dyn_bridge.try_connection() diff --git a/tests/components/dynalite/test_config_flow.py b/tests/components/dynalite/test_config_flow.py index 1cf82143f1b..1f8be61f646 100755 --- a/tests/components/dynalite/test_config_flow.py +++ b/tests/components/dynalite/test_config_flow.py @@ -1,36 +1,90 @@ """Test Dynalite config flow.""" -from unittest.mock import Mock, call, patch +from asynctest import patch -from homeassistant.components.dynalite import config_flow +from homeassistant import config_entries +from homeassistant.components import dynalite -from tests.common import mock_coro +from tests.common import MockConfigEntry -async def test_step_import(): - """Test a successful setup.""" - flow_handler = config_flow.DynaliteFlowHandler() - with patch.object(flow_handler, "context", create=True): - with patch.object(flow_handler, "hass", create=True) as mock_hass: - with patch.object( - flow_handler, "async_create_entry", create=True - ) as mock_create: - host = "1.2.3.4" - entry1 = Mock() - entry1.data = {"host": host} - entry2 = Mock() - entry2.data = {"host": "5.5"} - mock_hass.config_entries.async_entries = Mock( - return_value=[entry1, entry2] - ) - mock_hass.config_entries.async_remove = Mock( - return_value=mock_coro(Mock()) - ) - await flow_handler.async_step_import({"host": "1.2.3.4"}) - mock_hass.config_entries.async_remove.assert_called_once() - assert mock_hass.config_entries.async_remove.mock_calls[0] == call( - entry1.entry_id - ) - mock_create.assert_called_once() - assert mock_create.mock_calls[0] == call( - title=host, data={"host": host} - ) +async def run_flow(hass, setup, connection): + """Run a flow with or without errors and return result.""" + host = "1.2.3.4" + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=setup, + ), patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.available", connection + ), patch( + "homeassistant.components.dynalite.bridge.CONNECT_INTERVAL", 0 + ): + result = await hass.config_entries.flow.async_init( + dynalite.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={dynalite.CONF_HOST: host}, + ) + return result + + +async def test_flow_works(hass): + """Test a successful config flow.""" + result = await run_flow(hass, True, True) + assert result["type"] == "create_entry" + + +async def test_flow_setup_fails(hass): + """Test a flow where async_setup fails.""" + result = await run_flow(hass, False, True) + assert result["type"] == "abort" + assert result["reason"] == "bridge_setup_failed" + + +async def test_flow_no_connection(hass): + """Test a flow where connection times out.""" + result = await run_flow(hass, True, False) + assert result["type"] == "abort" + assert result["reason"] == "no_connection" + + +async def test_existing(hass): + """Test when the entry exists with the same config.""" + host = "1.2.3.4" + MockConfigEntry( + domain=dynalite.DOMAIN, unique_id=host, data={dynalite.CONF_HOST: host} + ).add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ), patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.available", True + ): + result = await hass.config_entries.flow.async_init( + dynalite.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={dynalite.CONF_HOST: host}, + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_existing_update(hass): + """Test when the entry exists with the same config.""" + host = "1.2.3.4" + mock_entry = MockConfigEntry( + domain=dynalite.DOMAIN, unique_id=host, data={dynalite.CONF_HOST: host} + ) + mock_entry.add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ), patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.available", True + ): + result = await hass.config_entries.flow.async_init( + dynalite.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={dynalite.CONF_HOST: host, "aaa": "bbb"}, + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + assert mock_entry.data.get("aaa") == "bbb" diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py index beb96a5e78f..d8ef0d7d259 100755 --- a/tests/components/dynalite/test_init.py +++ b/tests/components/dynalite/test_init.py @@ -1,74 +1,62 @@ """Test Dynalite __init__.""" -from unittest.mock import Mock, call, patch -from homeassistant.components.dynalite import DATA_CONFIGS, DOMAIN, LOGGER -from homeassistant.components.dynalite.__init__ import ( - async_setup, - async_setup_entry, - async_unload_entry, -) +from asynctest import patch -from tests.common import mock_coro +from homeassistant.components import dynalite +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry -async def test_async_setup(): +async def test_empty_config(hass): + """Test with an empty config.""" + assert await async_setup_component(hass, dynalite.DOMAIN, {}) is True + assert len(hass.config_entries.flow.async_progress()) == 0 + assert hass.data[dynalite.DOMAIN] == {} + + +async def test_async_setup(hass): """Test a successful setup.""" - new_host = "1.2.3.4" - old_host = "5.6.7.8" - hass = Mock() - hass.data = {} - config = {DOMAIN: {"bridges": [{"host": old_host}, {"host": new_host}]}} - mock_conf_host = Mock(return_value=[old_host]) - with patch( - "homeassistant.components.dynalite.__init__.configured_hosts", mock_conf_host - ): - await async_setup(hass, config) - mock_conf_host.assert_called_once() - assert mock_conf_host.mock_calls[0] == call(hass) - assert hass.data[DOMAIN][DATA_CONFIGS] == { - new_host: {"host": new_host}, - old_host: {"host": old_host}, - } - hass.async_create_task.assert_called_once() - - -async def test_async_setup_entry(): - """Test setup of an entry.""" - - def async_mock(mock): - """Return the return value of a mock from async.""" - - async def async_func(*args, **kwargs): - return mock() - - return async_func - host = "1.2.3.4" - hass = Mock() - entry = Mock() - entry.data = {"host": host} - hass.data = {} - hass.data[DOMAIN] = {} - hass.data[DOMAIN][DATA_CONFIGS] = {host: {}} - mock_async_setup = Mock(return_value=True) with patch( - "homeassistant.components.dynalite.__init__.DynaliteBridge.async_setup", - async_mock(mock_async_setup), - ): - assert await async_setup_entry(hass, entry) - mock_async_setup.assert_called_once() + "dynalite_devices_lib.DynaliteDevices.async_setup", return_value=True + ), patch("dynalite_devices_lib.DynaliteDevices.available", True): + assert await async_setup_component( + hass, + dynalite.DOMAIN, + {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}}, + ) + + assert len(hass.data[dynalite.DOMAIN]) == 1 -async def test_async_unload_entry(): - """Test unloading of an entry.""" - hass = Mock() - mock_bridge = Mock() - mock_bridge.async_reset.return_value = mock_coro(True) - entry = Mock() - hass.data = {} - hass.data[DOMAIN] = {} - hass.data[DOMAIN][entry.entry_id] = mock_bridge - await async_unload_entry(hass, entry) - LOGGER.error("XXX calls=%s", mock_bridge.mock_calls) - mock_bridge.async_reset.assert_called_once() - assert mock_bridge.mock_calls[0] == call.async_reset() +async def test_async_setup_failed(hass): + """Test a setup when DynaliteBridge.async_setup fails.""" + host = "1.2.3.4" + with patch("dynalite_devices_lib.DynaliteDevices.async_setup", return_value=False): + assert await async_setup_component( + hass, + dynalite.DOMAIN, + {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}}, + ) + assert hass.data[dynalite.DOMAIN] == {} + + +async def test_unload_entry(hass): + """Test being able to unload an entry.""" + host = "1.2.3.4" + entry = MockConfigEntry(domain=dynalite.DOMAIN, data={"host": host}) + entry.add_to_hass(hass) + + with patch( + "dynalite_devices_lib.DynaliteDevices.async_setup", return_value=True + ), patch("dynalite_devices_lib.DynaliteDevices.available", True): + assert await async_setup_component( + hass, + dynalite.DOMAIN, + {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}}, + ) + assert hass.data[dynalite.DOMAIN].get(entry.entry_id) + + assert await hass.config_entries.async_unload(entry.entry_id) + assert not hass.data[dynalite.DOMAIN].get(entry.entry_id) diff --git a/tests/components/dynalite/test_light.py b/tests/components/dynalite/test_light.py index cfc9d42d0e4..9934bac8720 100755 --- a/tests/components/dynalite/test_light.py +++ b/tests/components/dynalite/test_light.py @@ -1,44 +1,78 @@ """Test Dynalite light.""" -from unittest.mock import Mock, call, patch +from unittest.mock import Mock -from homeassistant.components.dynalite import DOMAIN -from homeassistant.components.dynalite.light import DynaliteLight, async_setup_entry +from asynctest import CoroutineMock, patch +import pytest -from tests.common import mock_coro +from homeassistant.components import dynalite +from homeassistant.components.light import SUPPORT_BRIGHTNESS +from homeassistant.setup import async_setup_component -async def test_light_setup(): - """Test a successful setup.""" - hass = Mock() - entry = Mock() - async_add = Mock() - bridge = Mock() - hass.data = {DOMAIN: {entry.entry_id: bridge}} - await async_setup_entry(hass, entry, async_add) - bridge.register_add_entities.assert_called_once() - assert bridge.register_add_entities.mock_calls[0] == call(async_add) - - -async def test_light(): - """Test the light entity.""" +@pytest.fixture +def mock_device(): + """Mock a Dynalite device.""" device = Mock() - device.async_turn_on = Mock(return_value=mock_coro(Mock())) - device.async_turn_off = Mock(return_value=mock_coro(Mock())) - bridge = Mock() - dyn_light = DynaliteLight(device, bridge) - assert dyn_light.name is device.name - assert dyn_light.unique_id is device.unique_id - assert dyn_light.available is device.available - assert dyn_light.hidden is device.hidden - await dyn_light.async_update() # does nothing - assert dyn_light.device_info is device.device_info - assert dyn_light.brightness is device.brightness - assert dyn_light.is_on is device.is_on - await dyn_light.async_turn_on(aaa="bbb") - assert device.async_turn_on.mock_calls[0] == call(aaa="bbb") - await dyn_light.async_turn_off(ccc="ddd") - assert device.async_turn_off.mock_calls[0] == call(ccc="ddd") - with patch.object(dyn_light, "hass"): - with patch.object(dyn_light, "schedule_update_ha_state") as update_ha: - dyn_light.try_schedule_ha() - update_ha.assert_called_once() + device.category = "light" + device.unique_id = "UNIQUE" + device.name = "NAME" + device.device_info = { + "identifiers": {(dynalite.DOMAIN, device.unique_id)}, + "name": device.name, + "manufacturer": "Dynalite", + } + return device + + +async def create_light_from_device(hass, device): + """Set up the component and platform and create a light based on the device provided.""" + host = "1.2.3.4" + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ), patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.available", True + ): + assert await async_setup_component( + hass, + dynalite.DOMAIN, + {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}}, + ) + await hass.async_block_till_done() + # Find the bridge + bridge = None + assert len(hass.data[dynalite.DOMAIN]) == 1 + key = next(iter(hass.data[dynalite.DOMAIN])) + bridge = hass.data[dynalite.DOMAIN][key] + bridge.dynalite_devices.newDeviceFunc([device]) + await hass.async_block_till_done() + + +async def test_light_setup(hass, mock_device): + """Test a successful setup.""" + await create_light_from_device(hass, mock_device) + entity_state = hass.states.get("light.name") + assert entity_state.attributes["brightness"] == mock_device.brightness + assert entity_state.attributes["supported_features"] == SUPPORT_BRIGHTNESS + + +async def test_turn_on(hass, mock_device): + """Test turning a light on.""" + mock_device.async_turn_on = CoroutineMock(return_value=True) + await create_light_from_device(hass, mock_device) + await hass.services.async_call( + "light", "turn_on", {"entity_id": "light.name"}, blocking=True + ) + await hass.async_block_till_done() + mock_device.async_turn_on.assert_awaited_once() + + +async def test_turn_off(hass, mock_device): + """Test turning a light off.""" + mock_device.async_turn_off = CoroutineMock(return_value=True) + await create_light_from_device(hass, mock_device) + await hass.services.async_call( + "light", "turn_off", {"entity_id": "light.name"}, blocking=True + ) + await hass.async_block_till_done() + mock_device.async_turn_off.assert_awaited_once() From 3385893b7724af50f22ac5d49ce9bacbe283b5f6 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Fri, 21 Feb 2020 18:06:57 -0500 Subject: [PATCH 039/416] ZHA device channel refactoring (#31971) * Add ZHA core typing helper. * Add aux_channels to ZHA rule matching. * Add match rule claim_channels() method. * Expose underlying zigpy device. * Not sure we need this one. * Move "base" channels. * Framework for channel discovery. * Make DEVICE_CLASS and REMOTE_DEVICE_TYPE default dicts. * Remove attribute reporting configuration registry. * Refactor channels. - Refactor zha events - Use compound IDs and unique_ids - Refactor signal dispatching on attribute updates * Use unique id compatible with entities unique ids. * Refactor ZHA Entity registry. Let match rule to check for the match. * Refactor discovery to use new channels. * Cleanup ZDO channel. Remove unused zha store call. * Handle channel configuration and initialization. * Refactor ZHA Device to use new channels. * Refactor ZHA Gateway to use new discovery framework. Use hass.data for entity info intermediate store. * Don't keep entities in hass.data. * ZHA gateway new discovery framework. * Refactor ZHA platform loading. * Don't update ZHA entities, when restoring from zigpy. * ZHA entity discover tests. * Add AnalogInput sensor. * Remove 0xFC02 based entity from Keen smart vents. * Clean up IAS channels. * Refactor entity restoration. * Fix lumi.router entities name. * Rename EndpointsChannel to ChannelPool. * Make Channels.pools a list. * Fix cover test. * Fix FakeDevice class. * Fix device actions. * Fix channels typing. * Revert update_before_add=False * Refactor channel class matching. * Use a helper function for adding entities. * Make Pylint happy. * Rebase cleanup. * Update coverage for ZHA device type overrides. * Use cluster_id for single output cluster registry. * Remove ZHA typing from coverage. * Fix tests. * Address comments. * Address comments. --- .coveragerc | 1 + homeassistant/components/zha/__init__.py | 26 +- homeassistant/components/zha/binary_sensor.py | 39 +- .../components/zha/core/channels/__init__.py | 683 ++++++++---------- .../components/zha/core/channels/base.py | 383 ++++++++++ .../components/zha/core/channels/closures.py | 19 +- .../components/zha/core/channels/general.py | 43 +- .../zha/core/channels/homeautomation.py | 16 +- .../components/zha/core/channels/hvac.py | 11 +- .../components/zha/core/channels/lighting.py | 10 +- .../components/zha/core/channels/lightlink.py | 2 +- .../zha/core/channels/manufacturerspecific.py | 33 +- .../zha/core/channels/measurement.py | 2 +- .../components/zha/core/channels/protocol.py | 2 +- .../components/zha/core/channels/security.py | 43 +- .../zha/core/channels/smartenergy.py | 10 +- homeassistant/components/zha/core/const.py | 7 + homeassistant/components/zha/core/device.py | 152 ++-- .../components/zha/core/discovery.py | 358 +++------ homeassistant/components/zha/core/gateway.py | 113 ++- .../components/zha/core/registries.py | 162 +++-- homeassistant/components/zha/core/typing.py | 41 ++ homeassistant/components/zha/cover.py | 39 +- homeassistant/components/zha/device_action.py | 7 +- .../components/zha/device_tracker.py | 45 +- homeassistant/components/zha/entity.py | 1 - homeassistant/components/zha/fan.py | 39 +- homeassistant/components/zha/light.py | 41 +- homeassistant/components/zha/lock.py | 39 +- homeassistant/components/zha/sensor.py | 52 +- homeassistant/components/zha/switch.py | 43 +- tests/components/zha/common.py | 1 + tests/components/zha/conftest.py | 44 +- tests/components/zha/test_channels.py | 233 +++++- tests/components/zha/test_cover.py | 2 +- tests/components/zha/test_device_action.py | 5 +- tests/components/zha/test_device_trigger.py | 5 +- tests/components/zha/test_discover.py | 357 ++++++++- tests/components/zha/test_registries.py | 65 +- tests/components/zha/zha_devices_list.py | 69 +- 40 files changed, 1918 insertions(+), 1325 deletions(-) create mode 100644 homeassistant/components/zha/core/channels/base.py create mode 100644 homeassistant/components/zha/core/typing.py diff --git a/.coveragerc b/.coveragerc index bf980a40c92..e51f4de886d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -841,6 +841,7 @@ omit = homeassistant/components/zha/core/helpers.py homeassistant/components/zha/core/patches.py homeassistant/components/zha/core/registries.py + homeassistant/components/zha/core/typing.py homeassistant/components/zha/entity.py homeassistant/components/zha/light.py homeassistant/components/zha/sensor.py diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 377c77bf601..0d4ceed829b 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -1,5 +1,6 @@ """Support for Zigbee Home Automation devices.""" +import asyncio import logging import voluptuous as vol @@ -22,6 +23,7 @@ from .core.const import ( DATA_ZHA_CONFIG, DATA_ZHA_DISPATCHERS, DATA_ZHA_GATEWAY, + DATA_ZHA_PLATFORM_LOADED, DEFAULT_BAUDRATE, DEFAULT_RADIO_TYPE, DOMAIN, @@ -87,11 +89,23 @@ async def async_setup_entry(hass, config_entry): Will automatically load components to support devices found on the network. """ - for component in COMPONENTS: - hass.data[DATA_ZHA][component] = hass.data[DATA_ZHA].get(component, {}) - hass.data[DATA_ZHA] = hass.data.get(DATA_ZHA, {}) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS] = [] + hass.data[DATA_ZHA][DATA_ZHA_PLATFORM_LOADED] = asyncio.Event() + platforms = [] + for component in COMPONENTS: + platforms.append( + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + ) + + async def _platforms_loaded(): + await asyncio.gather(*platforms) + hass.data[DATA_ZHA][DATA_ZHA_PLATFORM_LOADED].set() + + hass.async_create_task(_platforms_loaded()) + config = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) if config.get(CONF_ENABLE_QUIRKS, True): @@ -112,11 +126,6 @@ async def async_setup_entry(hass, config_entry): model=zha_gateway.radio_description, ) - for component in COMPONENTS: - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(config_entry, component) - ) - api.async_load_api(hass) async def async_zha_shutdown(event): @@ -125,6 +134,7 @@ async def async_setup_entry(hass, config_entry): await hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].async_update_device_storage() hass.bus.async_listen_once(ha_const.EVENT_HOMEASSISTANT_STOP, async_zha_shutdown) + hass.async_create_task(zha_gateway.async_load_devices()) return True diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 58b671a340f..93baf8e111b 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import STATE_ON from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .core import discovery from .core.const import ( CHANNEL_ACCELEROMETER, CHANNEL_OCCUPANCY, @@ -25,8 +26,8 @@ from .core.const import ( CHANNEL_ZONE, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, - ZHA_DISCOVERY_NEW, ) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -48,41 +49,17 @@ STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation binary sensor from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - binary_sensors = hass.data.get(DATA_ZHA, {}).get(DOMAIN) - if binary_sensors is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, binary_sensors.values() - ) - del hass.data[DATA_ZHA][DOMAIN] - - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA binary sensors.""" - entities = [] - for discovery_info in discovery_infos: - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - - entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, BinarySensor) - if entity: - entities.append(entity(**discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - class BinarySensor(ZhaEntity, BinarySensorDevice): """ZHA BinarySensor.""" diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 1210ac9d32c..ea838a05665 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -1,394 +1,13 @@ """Channels module for Zigbee Home Automation.""" import asyncio -from concurrent.futures import TimeoutError as Timeout -from enum import Enum -from functools import wraps import logging -from random import uniform - -import zigpy.exceptions +from typing import Any, Dict, List, Optional, Union from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send -from ..const import ( - CHANNEL_EVENT_RELAY, - CHANNEL_ZDO, - REPORT_CONFIG_DEFAULT, - REPORT_CONFIG_MAX_INT, - REPORT_CONFIG_MIN_INT, - REPORT_CONFIG_RPT_CHANGE, - SIGNAL_ATTR_UPDATED, -) -from ..helpers import LogMixin, get_attr_id_by_name, safe_read -from ..registries import CLUSTER_REPORT_CONFIGS - -_LOGGER = logging.getLogger(__name__) - - -def parse_and_log_command(channel, tsn, command_id, args): - """Parse and log a zigbee cluster command.""" - cmd = channel.cluster.server_commands.get(command_id, [command_id])[0] - channel.debug( - "received '%s' command with %s args on cluster_id '%s' tsn '%s'", - cmd, - args, - channel.cluster.cluster_id, - tsn, - ) - return cmd - - -def decorate_command(channel, command): - """Wrap a cluster command to make it safe.""" - - @wraps(command) - async def wrapper(*args, **kwds): - try: - result = await command(*args, **kwds) - channel.debug( - "executed command: %s %s %s %s", - command.__name__, - "{}: {}".format("with args", args), - "{}: {}".format("with kwargs", kwds), - "{}: {}".format("and result", result), - ) - return result - - except (zigpy.exceptions.DeliveryError, Timeout) as ex: - channel.debug("command failed: %s exception: %s", command.__name__, str(ex)) - return ex - - return wrapper - - -class ChannelStatus(Enum): - """Status of a channel.""" - - CREATED = 1 - CONFIGURED = 2 - INITIALIZED = 3 - - -class ZigbeeChannel(LogMixin): - """Base channel for a Zigbee cluster.""" - - CHANNEL_NAME = None - REPORT_CONFIG = () - - def __init__(self, cluster, device): - """Initialize ZigbeeChannel.""" - self._channel_name = cluster.ep_attribute - if self.CHANNEL_NAME: - self._channel_name = self.CHANNEL_NAME - self._generic_id = f"channel_0x{cluster.cluster_id:04x}" - self._cluster = cluster - self._zha_device = device - self._id = f"{cluster.endpoint.endpoint_id}:0x{cluster.cluster_id:04x}" - self._unique_id = f"{str(device.ieee)}:{self._id}" - self._report_config = CLUSTER_REPORT_CONFIGS.get( - self._cluster.cluster_id, self.REPORT_CONFIG - ) - self._status = ChannelStatus.CREATED - self._cluster.add_listener(self) - - @property - def id(self) -> str: - """Return channel id unique for this device only.""" - return self._id - - @property - def generic_id(self): - """Return the generic id for this channel.""" - return self._generic_id - - @property - def unique_id(self): - """Return the unique id for this channel.""" - return self._unique_id - - @property - def cluster(self): - """Return the zigpy cluster for this channel.""" - return self._cluster - - @property - def device(self): - """Return the device this channel is linked to.""" - return self._zha_device - - @property - def name(self) -> str: - """Return friendly name.""" - return self._channel_name - - @property - def status(self): - """Return the status of the channel.""" - return self._status - - def set_report_config(self, report_config): - """Set the reporting configuration.""" - self._report_config = report_config - - async def bind(self): - """Bind a zigbee cluster. - - This also swallows DeliveryError exceptions that are thrown when - devices are unreachable. - """ - try: - res = await self.cluster.bind() - self.debug("bound '%s' cluster: %s", self.cluster.ep_attribute, res[0]) - except (zigpy.exceptions.DeliveryError, Timeout) as ex: - self.debug( - "Failed to bind '%s' cluster: %s", self.cluster.ep_attribute, str(ex) - ) - - async def configure_reporting( - self, - attr, - report_config=( - REPORT_CONFIG_MIN_INT, - REPORT_CONFIG_MAX_INT, - REPORT_CONFIG_RPT_CHANGE, - ), - ): - """Configure attribute reporting for a cluster. - - This also swallows DeliveryError exceptions that are thrown when - devices are unreachable. - """ - attr_name = self.cluster.attributes.get(attr, [attr])[0] - - kwargs = {} - if self.cluster.cluster_id >= 0xFC00 and self.device.manufacturer_code: - kwargs["manufacturer"] = self.device.manufacturer_code - - min_report_int, max_report_int, reportable_change = report_config - try: - res = await self.cluster.configure_reporting( - attr, min_report_int, max_report_int, reportable_change, **kwargs - ) - self.debug( - "reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'", - attr_name, - self.cluster.ep_attribute, - min_report_int, - max_report_int, - reportable_change, - res, - ) - except (zigpy.exceptions.DeliveryError, Timeout) as ex: - self.debug( - "failed to set reporting for '%s' attr on '%s' cluster: %s", - attr_name, - self.cluster.ep_attribute, - str(ex), - ) - - async def async_configure(self): - """Set cluster binding and attribute reporting.""" - if not self._zha_device.skip_configuration: - await self.bind() - if self.cluster.is_server: - for report_config in self._report_config: - await self.configure_reporting( - report_config["attr"], report_config["config"] - ) - await asyncio.sleep(uniform(0.1, 0.5)) - self.debug("finished channel configuration") - else: - self.debug("skipping channel configuration") - self._status = ChannelStatus.CONFIGURED - - async def async_initialize(self, from_cache): - """Initialize channel.""" - self.debug("initializing channel: from_cache: %s", from_cache) - self._status = ChannelStatus.INITIALIZED - - @callback - def cluster_command(self, tsn, command_id, args): - """Handle commands received to this cluster.""" - pass - - @callback - def attribute_updated(self, attrid, value): - """Handle attribute updates on this cluster.""" - pass - - @callback - def zdo_command(self, *args, **kwargs): - """Handle ZDO commands on this cluster.""" - pass - - @callback - def zha_send_event(self, cluster, command, args): - """Relay events to hass.""" - self._zha_device.hass.bus.async_fire( - "zha_event", - { - "unique_id": self._unique_id, - "device_ieee": str(self._zha_device.ieee), - "endpoint_id": cluster.endpoint.endpoint_id, - "cluster_id": cluster.cluster_id, - "command": command, - "args": args, - }, - ) - - async def async_update(self): - """Retrieve latest state from cluster.""" - pass - - async def get_attribute_value(self, attribute, from_cache=True): - """Get the value for an attribute.""" - manufacturer = None - manufacturer_code = self._zha_device.manufacturer_code - if self.cluster.cluster_id >= 0xFC00 and manufacturer_code: - manufacturer = manufacturer_code - result = await safe_read( - self._cluster, - [attribute], - allow_cache=from_cache, - only_cache=from_cache, - manufacturer=manufacturer, - ) - return result.get(attribute) - - def log(self, level, msg, *args): - """Log a message.""" - msg = f"[%s:%s]: {msg}" - args = (self.device.nwk, self._id) + args - _LOGGER.log(level, msg, *args) - - def __getattr__(self, name): - """Get attribute or a decorated cluster command.""" - if hasattr(self._cluster, name) and callable(getattr(self._cluster, name)): - command = getattr(self._cluster, name) - command.__name__ = name - return decorate_command(self, command) - return self.__getattribute__(name) - - -class AttributeListeningChannel(ZigbeeChannel): - """Channel for attribute reports from the cluster.""" - - REPORT_CONFIG = [{"attr": 0, "config": REPORT_CONFIG_DEFAULT}] - - def __init__(self, cluster, device): - """Initialize AttributeListeningChannel.""" - super().__init__(cluster, device) - attr = self._report_config[0].get("attr") - if isinstance(attr, str): - self.value_attribute = get_attr_id_by_name(self.cluster, attr) - else: - self.value_attribute = attr - - @callback - def attribute_updated(self, attrid, value): - """Handle attribute updates on this cluster.""" - if attrid == self.value_attribute: - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) - - async def async_initialize(self, from_cache): - """Initialize listener.""" - await self.get_attribute_value( - self._report_config[0].get("attr"), from_cache=from_cache - ) - await super().async_initialize(from_cache) - - -class ZDOChannel(LogMixin): - """Channel for ZDO events.""" - - def __init__(self, cluster, device): - """Initialize ZDOChannel.""" - self.name = CHANNEL_ZDO - self._cluster = cluster - self._zha_device = device - self._status = ChannelStatus.CREATED - self._unique_id = "{}:{}_ZDO".format(str(device.ieee), device.name) - self._cluster.add_listener(self) - - @property - def unique_id(self): - """Return the unique id for this channel.""" - return self._unique_id - - @property - def cluster(self): - """Return the aigpy cluster for this channel.""" - return self._cluster - - @property - def status(self): - """Return the status of the channel.""" - return self._status - - @callback - def device_announce(self, zigpy_device): - """Device announce handler.""" - pass - - @callback - def permit_duration(self, duration): - """Permit handler.""" - pass - - async def async_initialize(self, from_cache): - """Initialize channel.""" - entry = self._zha_device.gateway.zha_storage.async_get_or_create( - self._zha_device - ) - self.debug("entry loaded from storage: %s", entry) - self._status = ChannelStatus.INITIALIZED - - async def async_configure(self): - """Configure channel.""" - self._status = ChannelStatus.CONFIGURED - - def log(self, level, msg, *args): - """Log a message.""" - msg = f"[%s:ZDO](%s): {msg}" - args = (self._zha_device.nwk, self._zha_device.model) + args - _LOGGER.log(level, msg, *args) - - -class EventRelayChannel(ZigbeeChannel): - """Event relay that can be attached to zigbee clusters.""" - - CHANNEL_NAME = CHANNEL_EVENT_RELAY - - @callback - def attribute_updated(self, attrid, value): - """Handle an attribute updated on this cluster.""" - self.zha_send_event( - self._cluster, - SIGNAL_ATTR_UPDATED, - { - "attribute_id": attrid, - "attribute_name": self._cluster.attributes.get(attrid, ["Unknown"])[0], - "value": value, - }, - ) - - @callback - def cluster_command(self, tsn, command_id, args): - """Handle a cluster command received on this cluster.""" - if ( - self._cluster.server_commands is not None - and self._cluster.server_commands.get(command_id) is not None - ): - self.zha_send_event( - self._cluster, self._cluster.server_commands.get(command_id)[0], args - ) - - -# pylint: disable=wrong-import-position, import-outside-toplevel -from . import ( # noqa: F401 isort:skip +from . import ( # noqa: F401 # pylint: disable=unused-import + base, closures, general, homeautomation, @@ -401,3 +20,299 @@ from . import ( # noqa: F401 isort:skip security, smartenergy, ) +from .. import ( + const, + device as zha_core_device, + discovery as zha_disc, + registries as zha_regs, + typing as zha_typing, +) + +_LOGGER = logging.getLogger(__name__) +ChannelsDict = Dict[str, zha_typing.ChannelType] + + +class Channels: + """All discovered channels of a device.""" + + def __init__(self, zha_device: zha_typing.ZhaDeviceType) -> None: + """Initialize instance.""" + self._pools: List[zha_typing.ChannelPoolType] = [] + self._power_config = None + self._semaphore = asyncio.Semaphore(3) + self._unique_id = str(zha_device.ieee) + self._zdo_channel = base.ZDOChannel(zha_device.device.endpoints[0], zha_device) + self._zha_device = zha_device + + @property + def pools(self) -> List["ChannelPool"]: + """Return channel pools list.""" + return self._pools + + @property + def power_configuration_ch(self) -> zha_typing.ChannelType: + """Return power configuration channel.""" + return self._power_config + + @power_configuration_ch.setter + def power_configuration_ch(self, channel: zha_typing.ChannelType) -> None: + """Power configuration channel setter.""" + if self._power_config is None: + self._power_config = channel + + @property + def semaphore(self) -> asyncio.Semaphore: + """Return semaphore for concurrent tasks.""" + return self._semaphore + + @property + def zdo_channel(self) -> zha_typing.ZDOChannelType: + """Return ZDO channel.""" + return self._zdo_channel + + @property + def zha_device(self) -> zha_typing.ZhaDeviceType: + """Return parent zha device.""" + return self._zha_device + + @property + def unique_id(self): + """Return the unique id for this channel.""" + return self._unique_id + + @classmethod + def new(cls, zha_device: zha_typing.ZhaDeviceType) -> "Channels": + """Create new instance.""" + channels = cls(zha_device) + for ep_id in sorted(zha_device.device.endpoints): + channels.add_pool(ep_id) + return channels + + def add_pool(self, ep_id: int) -> None: + """Add channels for a specific endpoint.""" + if ep_id == 0: + return + self._pools.append(ChannelPool.new(self, ep_id)) + + async def async_initialize(self, from_cache: bool = False) -> None: + """Initialize claimed channels.""" + await self.zdo_channel.async_initialize(from_cache) + self.zdo_channel.debug("'async_initialize' stage succeeded") + await asyncio.gather( + *(pool.async_initialize(from_cache) for pool in self.pools) + ) + + async def async_configure(self) -> None: + """Configure claimed channels.""" + await self.zdo_channel.async_configure() + self.zdo_channel.debug("'async_configure' stage succeeded") + await asyncio.gather(*(pool.async_configure() for pool in self.pools)) + + @callback + def async_new_entity( + self, + component: str, + entity_class: zha_typing.CALLABLE_T, + unique_id: str, + channels: List[zha_typing.ChannelType], + ): + """Signal new entity addition.""" + if self.zha_device.status == zha_core_device.DeviceStatus.INITIALIZED: + return + + self.zha_device.hass.data[const.DATA_ZHA][component].append( + (entity_class, (unique_id, self.zha_device, channels)) + ) + + @callback + def async_send_signal(self, signal: str, *args: Any) -> None: + """Send a signal through hass dispatcher.""" + async_dispatcher_send(self.zha_device.hass, signal, *args) + + @callback + def zha_send_event(self, event_data: Dict[str, Union[str, int]]) -> None: + """Relay events to hass.""" + self.zha_device.hass.bus.async_fire( + "zha_event", + { + const.ATTR_DEVICE_IEEE: str(self.zha_device.ieee), + const.ATTR_UNIQUE_ID: self.unique_id, + **event_data, + }, + ) + + +class ChannelPool: + """All channels of an endpoint.""" + + def __init__(self, channels: Channels, ep_id: int): + """Initialize instance.""" + self._all_channels: ChannelsDict = {} + self._channels: Channels = channels + self._claimed_channels: ChannelsDict = {} + self._id: int = ep_id + self._relay_channels: Dict[str, zha_typing.EventRelayChannelType] = {} + self._unique_id: str = f"{channels.unique_id}-{ep_id}" + + @property + def all_channels(self) -> ChannelsDict: + """All channels of an endpoint.""" + return self._all_channels + + @property + def claimed_channels(self) -> ChannelsDict: + """Channels in use.""" + return self._claimed_channels + + @property + def endpoint(self) -> zha_typing.ZigpyEndpointType: + """Return endpoint of zigpy device.""" + return self._channels.zha_device.device.endpoints[self.id] + + @property + def id(self) -> int: + """Return endpoint id.""" + return self._id + + @property + def nwk(self) -> int: + """Device NWK for logging.""" + return self._channels.zha_device.nwk + + @property + def manufacturer(self) -> Optional[str]: + """Return device manufacturer.""" + return self._channels.zha_device.manufacturer + + @property + def manufacturer_code(self) -> Optional[int]: + """Return device manufacturer.""" + return self._channels.zha_device.manufacturer_code + + @property + def model(self) -> Optional[str]: + """Return device model.""" + return self._channels.zha_device.model + + @property + def relay_channels(self) -> Dict[str, zha_typing.EventRelayChannelType]: + """Return a dict of event relay channels.""" + return self._relay_channels + + @property + def skip_configuration(self) -> bool: + """Return True if device does not require channel configuration.""" + return self._channels.zha_device.skip_configuration + + @property + def unique_id(self): + """Return the unique id for this channel.""" + return self._unique_id + + @classmethod + def new(cls, channels: Channels, ep_id: int) -> "ChannelPool": + """Create new channels for an endpoint.""" + pool = cls(channels, ep_id) + pool.add_all_channels() + pool.add_relay_channels() + zha_disc.PROBE.discover_entities(pool) + return pool + + @callback + def add_all_channels(self) -> None: + """Create and add channels for all input clusters.""" + for cluster_id, cluster in self.endpoint.in_clusters.items(): + channel_class = zha_regs.ZIGBEE_CHANNEL_REGISTRY.get( + cluster_id, base.AttributeListeningChannel + ) + # really ugly hack to deal with xiaomi using the door lock cluster + # incorrectly. + if ( + hasattr(cluster, "ep_attribute") + and cluster.ep_attribute == "multistate_input" + ): + channel_class = base.AttributeListeningChannel + # end of ugly hack + channel = channel_class(cluster, self) + if channel.name == const.CHANNEL_POWER_CONFIGURATION: + if ( + self._channels.power_configuration_ch + or self._channels.zha_device.is_mains_powered + ): + # on power configuration channel per device + continue + self._channels.power_configuration_ch = channel + + self.all_channels[channel.id] = channel + + @callback + def add_relay_channels(self) -> None: + """Create relay channels for all output clusters if in the registry.""" + for cluster_id in zha_regs.EVENT_RELAY_CLUSTERS: + cluster = self.endpoint.out_clusters.get(cluster_id) + if cluster is not None: + channel = base.EventRelayChannel(cluster, self) + self.relay_channels[channel.id] = channel + + async def async_initialize(self, from_cache: bool = False) -> None: + """Initialize claimed channels.""" + await self._execute_channel_tasks("async_initialize", from_cache) + + async def async_configure(self) -> None: + """Configure claimed channels.""" + await self._execute_channel_tasks("async_configure") + + async def _execute_channel_tasks(self, func_name: str, *args: Any) -> None: + """Add a throttled channel task and swallow exceptions.""" + + async def _throttle(coro): + async with self._channels.semaphore: + return await coro + + channels = [*self.claimed_channels.values(), *self.relay_channels.values()] + tasks = [_throttle(getattr(ch, func_name)(*args)) for ch in channels] + results = await asyncio.gather(*tasks, return_exceptions=True) + for channel, outcome in zip(channels, results): + if isinstance(outcome, Exception): + channel.warning("'%s' stage failed: %s", func_name, str(outcome)) + continue + channel.debug("'%s' stage succeeded", func_name) + + @callback + def async_new_entity( + self, + component: str, + entity_class: zha_typing.CALLABLE_T, + unique_id: str, + channels: List[zha_typing.ChannelType], + ): + """Signal new entity addition.""" + self._channels.async_new_entity(component, entity_class, unique_id, channels) + + @callback + def async_send_signal(self, signal: str, *args: Any) -> None: + """Send a signal through hass dispatcher.""" + self._channels.async_send_signal(signal, *args) + + @callback + def claim_channels(self, channels: List[zha_typing.ChannelType]) -> None: + """Claim a channel.""" + self.claimed_channels.update({ch.id: ch for ch in channels}) + + @callback + def unclaimed_channels(self) -> List[zha_typing.ChannelType]: + """Return a list of available (unclaimed) channels.""" + claimed = set(self.claimed_channels) + available = set(self.all_channels) + return [self.all_channels[chan_id] for chan_id in (available - claimed)] + + @callback + def zha_send_event(self, event_data: Dict[str, Union[str, int]]) -> None: + """Relay events to hass.""" + self._channels.zha_send_event( + { + const.ATTR_UNIQUE_ID: self.unique_id, + const.ATTR_ENDPOINT_ID: self.id, + **event_data, + } + ) diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py new file mode 100644 index 00000000000..7bb2ad7b57e --- /dev/null +++ b/homeassistant/components/zha/core/channels/base.py @@ -0,0 +1,383 @@ +"""Base classes for channels.""" + +import asyncio +from enum import Enum +from functools import wraps +import logging +from random import uniform +from typing import Any, Union + +import zigpy.exceptions + +from homeassistant.core import callback + +from .. import typing as zha_typing +from ..const import ( + ATTR_ARGS, + ATTR_ATTRIBUTE_ID, + ATTR_ATTRIBUTE_NAME, + ATTR_CLUSTER_ID, + ATTR_COMMAND, + ATTR_UNIQUE_ID, + ATTR_VALUE, + CHANNEL_EVENT_RELAY, + CHANNEL_ZDO, + REPORT_CONFIG_DEFAULT, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_MIN_INT, + REPORT_CONFIG_RPT_CHANGE, + SIGNAL_ATTR_UPDATED, +) +from ..helpers import LogMixin, get_attr_id_by_name, safe_read + +_LOGGER = logging.getLogger(__name__) + + +def parse_and_log_command(channel, tsn, command_id, args): + """Parse and log a zigbee cluster command.""" + cmd = channel.cluster.server_commands.get(command_id, [command_id])[0] + channel.debug( + "received '%s' command with %s args on cluster_id '%s' tsn '%s'", + cmd, + args, + channel.cluster.cluster_id, + tsn, + ) + return cmd + + +def decorate_command(channel, command): + """Wrap a cluster command to make it safe.""" + + @wraps(command) + async def wrapper(*args, **kwds): + try: + result = await command(*args, **kwds) + channel.debug( + "executed '%s' command with args: '%s' kwargs: '%s' result: %s", + command.__name__, + args, + kwds, + result, + ) + return result + + except (zigpy.exceptions.DeliveryError, asyncio.TimeoutError) as ex: + channel.debug("command failed: %s exception: %s", command.__name__, str(ex)) + return ex + + return wrapper + + +class ChannelStatus(Enum): + """Status of a channel.""" + + CREATED = 1 + CONFIGURED = 2 + INITIALIZED = 3 + + +class ZigbeeChannel(LogMixin): + """Base channel for a Zigbee cluster.""" + + CHANNEL_NAME = None + REPORT_CONFIG = () + + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + ) -> None: + """Initialize ZigbeeChannel.""" + self._channel_name = cluster.ep_attribute + if self.CHANNEL_NAME: + self._channel_name = self.CHANNEL_NAME + self._ch_pool = ch_pool + self._generic_id = f"channel_0x{cluster.cluster_id:04x}" + self._cluster = cluster + self._id = f"{ch_pool.id}:0x{cluster.cluster_id:04x}" + unique_id = ch_pool.unique_id.replace("-", ":") + self._unique_id = f"{unique_id}:0x{cluster.cluster_id:04x}" + self._report_config = self.REPORT_CONFIG + self._status = ChannelStatus.CREATED + self._cluster.add_listener(self) + + @property + def id(self) -> str: + """Return channel id unique for this device only.""" + return self._id + + @property + def generic_id(self): + """Return the generic id for this channel.""" + return self._generic_id + + @property + def unique_id(self): + """Return the unique id for this channel.""" + return self._unique_id + + @property + def cluster(self): + """Return the zigpy cluster for this channel.""" + return self._cluster + + @property + def name(self) -> str: + """Return friendly name.""" + return self._channel_name + + @property + def status(self): + """Return the status of the channel.""" + return self._status + + @callback + def async_send_signal(self, signal: str, *args: Any) -> None: + """Send a signal through hass dispatcher.""" + self._ch_pool.async_send_signal(signal, *args) + + async def bind(self): + """Bind a zigbee cluster. + + This also swallows DeliveryError exceptions that are thrown when + devices are unreachable. + """ + try: + res = await self.cluster.bind() + self.debug("bound '%s' cluster: %s", self.cluster.ep_attribute, res[0]) + except (zigpy.exceptions.DeliveryError, asyncio.TimeoutError) as ex: + self.debug( + "Failed to bind '%s' cluster: %s", self.cluster.ep_attribute, str(ex) + ) + + async def configure_reporting( + self, + attr, + report_config=( + REPORT_CONFIG_MIN_INT, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE, + ), + ): + """Configure attribute reporting for a cluster. + + This also swallows DeliveryError exceptions that are thrown when + devices are unreachable. + """ + attr_name = self.cluster.attributes.get(attr, [attr])[0] + + kwargs = {} + if self.cluster.cluster_id >= 0xFC00 and self._ch_pool.manufacturer_code: + kwargs["manufacturer"] = self._ch_pool.manufacturer_code + + min_report_int, max_report_int, reportable_change = report_config + try: + res = await self.cluster.configure_reporting( + attr, min_report_int, max_report_int, reportable_change, **kwargs + ) + self.debug( + "reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'", + attr_name, + self.cluster.ep_attribute, + min_report_int, + max_report_int, + reportable_change, + res, + ) + except (zigpy.exceptions.DeliveryError, asyncio.TimeoutError) as ex: + self.debug( + "failed to set reporting for '%s' attr on '%s' cluster: %s", + attr_name, + self.cluster.ep_attribute, + str(ex), + ) + + async def async_configure(self): + """Set cluster binding and attribute reporting.""" + if not self._ch_pool.skip_configuration: + await self.bind() + if self.cluster.is_server: + for report_config in self._report_config: + await self.configure_reporting( + report_config["attr"], report_config["config"] + ) + await asyncio.sleep(uniform(0.1, 0.5)) + self.debug("finished channel configuration") + else: + self.debug("skipping channel configuration") + self._status = ChannelStatus.CONFIGURED + + async def async_initialize(self, from_cache): + """Initialize channel.""" + self.debug("initializing channel: from_cache: %s", from_cache) + self._status = ChannelStatus.INITIALIZED + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle commands received to this cluster.""" + pass + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute updates on this cluster.""" + pass + + @callback + def zdo_command(self, *args, **kwargs): + """Handle ZDO commands on this cluster.""" + pass + + @callback + def zha_send_event(self, command: str, args: Union[int, dict]) -> None: + """Relay events to hass.""" + self._ch_pool.zha_send_event( + { + ATTR_UNIQUE_ID: self.unique_id, + ATTR_CLUSTER_ID: self.cluster.cluster_id, + ATTR_COMMAND: command, + ATTR_ARGS: args, + } + ) + + async def async_update(self): + """Retrieve latest state from cluster.""" + pass + + async def get_attribute_value(self, attribute, from_cache=True): + """Get the value for an attribute.""" + manufacturer = None + manufacturer_code = self._ch_pool.manufacturer_code + if self.cluster.cluster_id >= 0xFC00 and manufacturer_code: + manufacturer = manufacturer_code + result = await safe_read( + self._cluster, + [attribute], + allow_cache=from_cache, + only_cache=from_cache, + manufacturer=manufacturer, + ) + return result.get(attribute) + + def log(self, level, msg, *args): + """Log a message.""" + msg = f"[%s:%s]: {msg}" + args = (self._ch_pool.nwk, self._id) + args + _LOGGER.log(level, msg, *args) + + def __getattr__(self, name): + """Get attribute or a decorated cluster command.""" + if hasattr(self._cluster, name) and callable(getattr(self._cluster, name)): + command = getattr(self._cluster, name) + command.__name__ = name + return decorate_command(self, command) + return self.__getattribute__(name) + + +class AttributeListeningChannel(ZigbeeChannel): + """Channel for attribute reports from the cluster.""" + + REPORT_CONFIG = [{"attr": 0, "config": REPORT_CONFIG_DEFAULT}] + + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + ) -> None: + """Initialize AttributeListeningChannel.""" + super().__init__(cluster, ch_pool) + attr = self._report_config[0].get("attr") + if isinstance(attr, str): + self.value_attribute = get_attr_id_by_name(self.cluster, attr) + else: + self.value_attribute = attr + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute updates on this cluster.""" + if attrid == self.value_attribute: + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) + + async def async_initialize(self, from_cache): + """Initialize listener.""" + await self.get_attribute_value( + self._report_config[0].get("attr"), from_cache=from_cache + ) + await super().async_initialize(from_cache) + + +class ZDOChannel(LogMixin): + """Channel for ZDO events.""" + + def __init__(self, cluster, device): + """Initialize ZDOChannel.""" + self.name = CHANNEL_ZDO + self._cluster = cluster + self._zha_device = device + self._status = ChannelStatus.CREATED + self._unique_id = "{}:{}_ZDO".format(str(device.ieee), device.name) + self._cluster.add_listener(self) + + @property + def unique_id(self): + """Return the unique id for this channel.""" + return self._unique_id + + @property + def cluster(self): + """Return the aigpy cluster for this channel.""" + return self._cluster + + @property + def status(self): + """Return the status of the channel.""" + return self._status + + @callback + def device_announce(self, zigpy_device): + """Device announce handler.""" + pass + + @callback + def permit_duration(self, duration): + """Permit handler.""" + pass + + async def async_initialize(self, from_cache): + """Initialize channel.""" + self._status = ChannelStatus.INITIALIZED + + async def async_configure(self): + """Configure channel.""" + self._status = ChannelStatus.CONFIGURED + + def log(self, level, msg, *args): + """Log a message.""" + msg = f"[%s:ZDO](%s): {msg}" + args = (self._zha_device.nwk, self._zha_device.model) + args + _LOGGER.log(level, msg, *args) + + +class EventRelayChannel(ZigbeeChannel): + """Event relay that can be attached to zigbee clusters.""" + + CHANNEL_NAME = CHANNEL_EVENT_RELAY + + @callback + def attribute_updated(self, attrid, value): + """Handle an attribute updated on this cluster.""" + self.zha_send_event( + SIGNAL_ATTR_UPDATED, + { + ATTR_ATTRIBUTE_ID: attrid, + ATTR_ATTRIBUTE_NAME: self._cluster.attributes.get(attrid, ["Unknown"])[ + 0 + ], + ATTR_VALUE: value, + }, + ) + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle a cluster command received on this cluster.""" + if ( + self._cluster.server_commands is not None + and self._cluster.server_commands.get(command_id) is not None + ): + self.zha_send_event(self._cluster.server_commands.get(command_id)[0], args) diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/channels/closures.py index 0cf6f840070..e25c2253bb3 100644 --- a/homeassistant/components/zha/core/channels/closures.py +++ b/homeassistant/components/zha/core/channels/closures.py @@ -4,11 +4,10 @@ import logging import zigpy.zcl.clusters.closures as closures from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send -from . import ZigbeeChannel from .. import registries from ..const import REPORT_CONFIG_IMMEDIATE, SIGNAL_ATTR_UPDATED +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -24,9 +23,7 @@ class DoorLockChannel(ZigbeeChannel): """Retrieve latest state.""" result = await self.get_attribute_value("lock_state", from_cache=True) - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result) @callback def attribute_updated(self, attrid, value): @@ -36,9 +33,7 @@ class DoorLockChannel(ZigbeeChannel): "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) if attrid == self._value_attribute: - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) async def async_initialize(self, from_cache): """Initialize channel.""" @@ -69,9 +64,7 @@ class WindowCovering(ZigbeeChannel): ) self.debug("read current position: %s", result) - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result) @callback def attribute_updated(self, attrid, value): @@ -81,9 +74,7 @@ class WindowCovering(ZigbeeChannel): "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) if attrid == self._value_attribute: - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) async def async_initialize(self, from_cache): """Initialize channel.""" diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 111b35e7e58..3e41e961f0a 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -4,11 +4,9 @@ import logging import zigpy.zcl.clusters.general as general from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later -from . import AttributeListeningChannel, ZigbeeChannel, parse_and_log_command -from .. import registries +from .. import registries, typing as zha_typing from ..const import ( REPORT_CONFIG_ASAP, REPORT_CONFIG_BATTERY_SAVE, @@ -20,6 +18,7 @@ from ..const import ( SIGNAL_STATE_ATTR, ) from ..helpers import get_attr_id_by_name +from .base import AttributeListeningChannel, ZigbeeChannel, parse_and_log_command _LOGGER = logging.getLogger(__name__) @@ -77,9 +76,11 @@ class BasicChannel(ZigbeeChannel): 6: "Emergency mains and transfer switch", } - def __init__(self, cluster, device): + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + ) -> None: """Initialize BasicChannel.""" - super().__init__(cluster, device) + super().__init__(cluster, ch_pool) self._power_source = None async def async_configure(self): @@ -193,9 +194,7 @@ class LevelControlChannel(ZigbeeChannel): def dispatch_level_change(self, command, level): """Dispatch level change.""" - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{command}", level - ) + self.async_send_signal(f"{self.unique_id}_{command}", level) async def async_initialize(self, from_cache): """Initialize channel.""" @@ -236,9 +235,11 @@ class OnOffChannel(ZigbeeChannel): ON_OFF = 0 REPORT_CONFIG = ({"attr": "on_off", "config": REPORT_CONFIG_IMMEDIATE},) - def __init__(self, cluster, device): + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + ) -> None: """Initialize OnOffChannel.""" - super().__init__(cluster, device) + super().__init__(cluster, ch_pool) self._state = None self._off_listener = None @@ -279,9 +280,7 @@ class OnOffChannel(ZigbeeChannel): def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" if attrid == self.ON_OFF: - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) self._state = bool(value) async def async_initialize(self, from_cache): @@ -293,10 +292,11 @@ class OnOffChannel(ZigbeeChannel): async def async_update(self): """Initialize channel.""" - from_cache = not self.device.is_mains_powered - self.debug("attempting to update onoff state - from cache: %s", from_cache) + if self.cluster.is_client: + return + self.debug("attempting to update onoff state - from cache: False") self._state = bool( - await self.get_attribute_value(self.ON_OFF, from_cache=from_cache) + await self.get_attribute_value(self.ON_OFF, from_cache=False) ) await super().async_update() @@ -348,16 +348,11 @@ class PowerConfigurationChannel(ZigbeeChannel): else: attr_id = attr if attrid == attr_id: - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) return attr_name = self.cluster.attributes.get(attrid, [attrid])[0] - async_dispatcher_send( - self._zha_device.hass, - f"{self.unique_id}_{SIGNAL_STATE_ATTR}", - attr_name, - value, + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_STATE_ATTR}", attr_name, value ) async def async_initialize(self, from_cache): diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py index 8c2c2e57972..e47aca5eafd 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -4,15 +4,13 @@ from typing import Optional import zigpy.zcl.clusters.homeautomation as homeautomation -from homeassistant.helpers.dispatcher import async_dispatcher_send - -from . import AttributeListeningChannel, ZigbeeChannel -from .. import registries +from .. import registries, typing as zha_typing from ..const import ( CHANNEL_ELECTRICAL_MEASUREMENT, REPORT_CONFIG_DEFAULT, SIGNAL_ATTR_UPDATED, ) +from .base import AttributeListeningChannel, ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -61,9 +59,11 @@ class ElectricalMeasurementChannel(AttributeListeningChannel): REPORT_CONFIG = ({"attr": "active_power", "config": REPORT_CONFIG_DEFAULT},) - def __init__(self, cluster, device): + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + ) -> None: """Initialize Metering.""" - super().__init__(cluster, device) + super().__init__(cluster, ch_pool) self._divisor = None self._multiplier = None @@ -73,9 +73,7 @@ class ElectricalMeasurementChannel(AttributeListeningChannel): # This is a polling channel. Don't allow cache. result = await self.get_attribute_value("active_power", from_cache=False) - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result) async def async_initialize(self, from_cache): """Initialize channel.""" diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py index b638259b4a1..e4519d5cb2c 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -5,11 +5,10 @@ from zigpy.exceptions import DeliveryError import zigpy.zcl.clusters.hvac as hvac from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send -from . import ZigbeeChannel from .. import registries from ..const import REPORT_CONFIG_OP, SIGNAL_ATTR_UPDATED +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -42,9 +41,7 @@ class FanChannel(ZigbeeChannel): """Retrieve latest state.""" result = await self.get_attribute_value("fan_mode", from_cache=True) - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result) @callback def attribute_updated(self, attrid, value): @@ -54,9 +51,7 @@ class FanChannel(ZigbeeChannel): "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) if attrid == self._value_attribute: - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) async def async_initialize(self, from_cache): """Initialize channel.""" diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index 0a1e2048132..c87235d9ec0 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -3,9 +3,9 @@ import logging import zigpy.zcl.clusters.lighting as lighting -from . import ZigbeeChannel -from .. import registries +from .. import registries, typing as zha_typing from ..const import REPORT_CONFIG_DEFAULT +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -33,9 +33,11 @@ class ColorChannel(ZigbeeChannel): {"attr": "color_temperature", "config": REPORT_CONFIG_DEFAULT}, ) - def __init__(self, cluster, device): + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + ) -> None: """Initialize ColorChannel.""" - super().__init__(cluster, device) + super().__init__(cluster, ch_pool) self._color_capabilities = None def get_color_capabilities(self): diff --git a/homeassistant/components/zha/core/channels/lightlink.py b/homeassistant/components/zha/core/channels/lightlink.py index 5d0ac199185..af0248c9713 100644 --- a/homeassistant/components/zha/core/channels/lightlink.py +++ b/homeassistant/components/zha/core/channels/lightlink.py @@ -3,8 +3,8 @@ import logging import zigpy.zcl.clusters.lightlink as lightlink -from . import ZigbeeChannel from .. import registries +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index e3d1e67439f..90f81513ec4 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -2,16 +2,19 @@ import logging from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send -from . import AttributeListeningChannel, ZigbeeChannel from .. import registries from ..const import ( + ATTR_ATTRIBUTE_ID, + ATTR_ATTRIBUTE_NAME, + ATTR_VALUE, REPORT_CONFIG_ASAP, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, SIGNAL_ATTR_UPDATED, + UNKNOWN, ) +from .base import AttributeListeningChannel, ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -53,18 +56,14 @@ class SmartThingsAcceleration(AttributeListeningChannel): def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" if attrid == self.value_attribute: - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) - else: - self.zha_send_event( - self._cluster, - SIGNAL_ATTR_UPDATED, - { - "attribute_id": attrid, - "attribute_name": self._cluster.attributes.get(attrid, ["Unknown"])[ - 0 - ], - "value": value, - }, - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) + return + + self.zha_send_event( + SIGNAL_ATTR_UPDATED, + { + ATTR_ATTRIBUTE_ID: attrid, + ATTR_ATTRIBUTE_NAME: self._cluster.attributes.get(attrid, [UNKNOWN])[0], + ATTR_VALUE: value, + }, + ) diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/channels/measurement.py index dfb83224505..68952c64e8d 100644 --- a/homeassistant/components/zha/core/channels/measurement.py +++ b/homeassistant/components/zha/core/channels/measurement.py @@ -3,7 +3,6 @@ import logging import zigpy.zcl.clusters.measurement as measurement -from . import AttributeListeningChannel from .. import registries from ..const import ( REPORT_CONFIG_DEFAULT, @@ -11,6 +10,7 @@ from ..const import ( REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, ) +from .base import AttributeListeningChannel _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/core/channels/protocol.py b/homeassistant/components/zha/core/channels/protocol.py index 20867553121..db7488e9a7f 100644 --- a/homeassistant/components/zha/core/channels/protocol.py +++ b/homeassistant/components/zha/core/channels/protocol.py @@ -4,7 +4,7 @@ import logging import zigpy.zcl.clusters.protocol as protocol from .. import registries -from ..channels import ZigbeeChannel +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index a529ff69d32..20390c018d8 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -1,16 +1,19 @@ -"""Security channels module for Zigbee Home Automation.""" +""" +Security channels module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/integrations/zha/ +""" +import asyncio import logging from zigpy.exceptions import DeliveryError import zigpy.zcl.clusters.security as security from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send -from . import ZigbeeChannel from .. import registries from ..const import ( - CLUSTER_COMMAND_SERVER, SIGNAL_ATTR_UPDATED, WARNING_DEVICE_MODE_EMERGENCY, WARNING_DEVICE_SOUND_HIGH, @@ -18,6 +21,7 @@ from ..const import ( WARNING_DEVICE_STROBE_HIGH, WARNING_DEVICE_STROBE_YES, ) +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -70,13 +74,7 @@ class IasWd(ZigbeeChannel): value = IasWd.set_bit(value, 6, mode, 2) value = IasWd.set_bit(value, 7, mode, 3) - await self.device.issue_cluster_command( - self.cluster.endpoint.endpoint_id, - self.cluster.cluster_id, - 0x0001, - CLUSTER_COMMAND_SERVER, - [value], - ) + await self.squawk(value) async def start_warning( self, @@ -111,12 +109,8 @@ class IasWd(ZigbeeChannel): value = IasWd.set_bit(value, 6, mode, 2) value = IasWd.set_bit(value, 7, mode, 3) - await self.device.issue_cluster_command( - self.cluster.endpoint.endpoint_id, - self.cluster.cluster_id, - 0x0000, - CLUSTER_COMMAND_SERVER, - [value, warning_duration, strobe_duty_cycle, strobe_intensity], + await self.start_warning( + value, warning_duration, strobe_duty_cycle, strobe_intensity ) @@ -130,18 +124,17 @@ class IASZoneChannel(ZigbeeChannel): """Handle commands received to this cluster.""" if command_id == 0: state = args[0] & 3 - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", state - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", state) self.debug("Updated alarm state: %s", state) elif command_id == 1: self.debug("Enroll requested") res = self._cluster.enroll_response(0, 0) - self._zha_device.hass.async_create_task(res) + asyncio.create_task(res) async def async_configure(self): """Configure IAS device.""" - if self._zha_device.skip_configuration: + await self.get_attribute_value("zone_type", from_cache=False) + if self._ch_pool.skip_configuration: self.debug("skipping IASZoneChannel configuration") return @@ -167,16 +160,12 @@ class IASZoneChannel(ZigbeeChannel): ) self.debug("finished IASZoneChannel configuration") - await self.get_attribute_value("zone_type", from_cache=False) - @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" if attrid == 2: value = value & 3 - async_dispatcher_send( - self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value - ) + self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) async def async_initialize(self, from_cache): """Initialize channel.""" diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index 08feb328603..b738b665e80 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -5,9 +5,9 @@ import zigpy.zcl.clusters.smartenergy as smartenergy from homeassistant.core import callback -from .. import registries -from ..channels import AttributeListeningChannel, ZigbeeChannel +from .. import registries, typing as zha_typing from ..const import REPORT_CONFIG_DEFAULT +from .base import AttributeListeningChannel, ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -90,9 +90,11 @@ class Metering(AttributeListeningChannel): 0x0C: "MJ/s", } - def __init__(self, cluster, device): + def __init__( + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + ) -> None: """Initialize Metering.""" - super().__init__(cluster, device) + super().__init__(cluster, ch_pool) self._divisor = None self._multiplier = None self._unit_enum = None diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index f4cccfa4e52..cb0ac2182ec 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -13,11 +13,14 @@ from homeassistant.components.switch import DOMAIN as SWITCH ATTR_ARGS = "args" ATTR_ATTRIBUTE = "attribute" +ATTR_ATTRIBUTE_ID = "attribute_id" +ATTR_ATTRIBUTE_NAME = "attribute_name" ATTR_AVAILABLE = "available" ATTR_CLUSTER_ID = "cluster_id" ATTR_CLUSTER_TYPE = "cluster_type" ATTR_COMMAND = "command" ATTR_COMMAND_TYPE = "command_type" +ATTR_DEVICE_IEEE = "device_ieee" ATTR_DEVICE_TYPE = "device_type" ATTR_ENDPOINT_ID = "endpoint_id" ATTR_IEEE = "ieee" @@ -36,6 +39,7 @@ ATTR_QUIRK_CLASS = "quirk_class" ATTR_RSSI = "rssi" ATTR_SIGNATURE = "signature" ATTR_TYPE = "type" +ATTR_UNIQUE_ID = "unique_id" ATTR_VALUE = "value" ATTR_WARNING_DEVICE_DURATION = "duration" ATTR_WARNING_DEVICE_MODE = "mode" @@ -47,6 +51,7 @@ BAUD_RATES = [2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 2560 BINDINGS = "bindings" CHANNEL_ACCELEROMETER = "accelerometer" +CHANNEL_ANALOG_INPUT = "analog_input" CHANNEL_ATTRIBUTE = "attribute" CHANNEL_BASIC = "basic" CHANNEL_COLOR = "light_color" @@ -92,6 +97,7 @@ DATA_ZHA_BRIDGE_ID = "zha_bridge_id" DATA_ZHA_CORE_EVENTS = "zha_core_events" DATA_ZHA_DISPATCHERS = "zha_dispatchers" DATA_ZHA_GATEWAY = "zha_gateway" +DATA_ZHA_PLATFORM_LOADED = "platform_loaded" DEBUG_COMP_BELLOWS = "bellows" DEBUG_COMP_ZHA = "homeassistant.components.zha" @@ -192,6 +198,7 @@ SENSOR_PRESSURE = CHANNEL_PRESSURE SENSOR_TEMPERATURE = CHANNEL_TEMPERATURE SENSOR_TYPE = "sensor_type" +SIGNAL_ADD_ENTITIES = "zha_add_new_entities" SIGNAL_ATTR_UPDATED = "attribute_updated" SIGNAL_AVAILABLE = "available" SIGNAL_MOVE_LEVEL = "move_level" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index ffa264dde63..54c1bbe49a8 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -18,8 +18,9 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType -from .channels import EventRelayChannel +from . import channels, typing as zha_typing from .const import ( ATTR_ARGS, ATTR_ATTRIBUTE, @@ -42,9 +43,6 @@ from .const import ( ATTR_QUIRK_CLASS, ATTR_RSSI, ATTR_VALUE, - CHANNEL_BASIC, - CHANNEL_POWER_CONFIGURATION, - CHANNEL_ZDO, CLUSTER_COMMAND_SERVER, CLUSTER_COMMANDS_CLIENT, CLUSTER_COMMANDS_SERVER, @@ -75,14 +73,16 @@ class DeviceStatus(Enum): class ZHADevice(LogMixin): """ZHA Zigbee device object.""" - def __init__(self, hass, zigpy_device, zha_gateway): + def __init__( + self, + hass: HomeAssistantType, + zigpy_device: zha_typing.ZigpyDeviceType, + zha_gateway: zha_typing.ZhaGatewayType, + ): """Initialize the gateway.""" self.hass = hass self._zigpy_device = zigpy_device self._zha_gateway = zha_gateway - self.cluster_channels = {} - self._relay_channels = {} - self._all_channels = [] self._available = False self._available_signal = "{}_{}_{}".format( self.name, self.ieee, SIGNAL_AVAILABLE @@ -101,6 +101,7 @@ class ZHADevice(LogMixin): ) self._ha_device_id = None self.status = DeviceStatus.CREATED + self._channels = channels.Channels(self) @property def device_id(self): @@ -111,6 +112,22 @@ class ZHADevice(LogMixin): """Set the HA device registry device id.""" self._ha_device_id = device_id + @property + def device(self) -> zha_typing.ZigpyDeviceType: + """Return underlying Zigpy device.""" + return self._zigpy_device + + @property + def channels(self) -> zha_typing.ChannelsType: + """Return ZHA channels.""" + return self._channels + + @channels.setter + def channels(self, value: zha_typing.ChannelsType) -> None: + """Channels setter.""" + assert isinstance(value, channels.Channels) + self._channels = value + @property def name(self): """Return device name.""" @@ -218,11 +235,6 @@ class ZHADevice(LogMixin): """Return the gateway for this device.""" return self._zha_gateway - @property - def all_channels(self): - """Return cluster channels and relay channels for device.""" - return self._all_channels - @property def device_automation_triggers(self): """Return the device automation triggers for this device.""" @@ -244,6 +256,19 @@ class ZHADevice(LogMixin): """Set availability from restore and prevent signals.""" self._available = available + @classmethod + def new( + cls, + hass: HomeAssistantType, + zigpy_dev: zha_typing.ZigpyDeviceType, + gateway: zha_typing.ZhaGatewayType, + restored: bool = False, + ): + """Create new device.""" + zha_dev = cls(hass, zigpy_dev, gateway) + zha_dev.channels = channels.Channels.new(zha_dev) + return zha_dev + def _check_available(self, *_): if self.last_seen is None: self.update_available(False) @@ -252,16 +277,17 @@ class ZHADevice(LogMixin): if difference > _KEEP_ALIVE_INTERVAL: if self._checkins_missed_count < _CHECKIN_GRACE_PERIODS: self._checkins_missed_count += 1 - if ( - CHANNEL_BASIC in self.cluster_channels - and self.manufacturer != "LUMI" - ): + if self.manufacturer != "LUMI": self.debug( "Attempting to checkin with device - missed checkins: %s", self._checkins_missed_count, ) + if not self._channels.pools: + return + pool = self._channels.pools[0] + basic_ch = pool.all_channels[f"{pool.id}:0"] self.hass.async_create_task( - self.cluster_channels[CHANNEL_BASIC].get_attribute_value( + basic_ch.get_attribute_value( ATTR_MANUFACTURER, from_cache=False ) ) @@ -304,66 +330,10 @@ class ZHADevice(LogMixin): ATTR_DEVICE_TYPE: self.device_type, } - def add_cluster_channel(self, cluster_channel): - """Add cluster channel to device.""" - # only keep 1 power configuration channel - if ( - cluster_channel.name is CHANNEL_POWER_CONFIGURATION - and CHANNEL_POWER_CONFIGURATION in self.cluster_channels - ): - return - - if isinstance(cluster_channel, EventRelayChannel): - self._relay_channels[cluster_channel.unique_id] = cluster_channel - self._all_channels.append(cluster_channel) - else: - self.cluster_channels[cluster_channel.name] = cluster_channel - self._all_channels.append(cluster_channel) - - def get_channels_to_configure(self): - """Get a deduped list of channels for configuration. - - This goes through all channels and gets a unique list of channels to - configure. It first assembles a unique list of channels that are part - of entities while stashing relay channels off to the side. It then - takse the stashed relay channels and adds them to the list of channels - that will be returned if there isn't a channel in the list for that - cluster already. This is done to ensure each cluster is only configured - once. - """ - channel_keys = [] - channels = [] - relay_channels = self._relay_channels.values() - - def get_key(channel): - channel_key = "ZDO" - if hasattr(channel.cluster, "cluster_id"): - channel_key = "{}_{}".format( - channel.cluster.endpoint.endpoint_id, channel.cluster.cluster_id - ) - return channel_key - - # first we get all unique non event channels - for channel in self.all_channels: - c_key = get_key(channel) - if c_key not in channel_keys and channel not in relay_channels: - channel_keys.append(c_key) - channels.append(channel) - - # now we get event channels that still need their cluster configured - for channel in relay_channels: - channel_key = get_key(channel) - if channel_key not in channel_keys: - channel_keys.append(channel_key) - channels.append(channel) - return channels - async def async_configure(self): """Configure the device.""" self.debug("started configuration") - await self._execute_channel_tasks( - self.get_channels_to_configure(), "async_configure" - ) + await self._channels.async_configure() self.debug("completed configuration") entry = self.gateway.zha_storage.async_create_or_update(self) self.debug("stored in registry: %s", entry) @@ -371,41 +341,11 @@ class ZHADevice(LogMixin): async def async_initialize(self, from_cache=False): """Initialize channels.""" self.debug("started initialization") - await self._execute_channel_tasks( - self.all_channels, "async_initialize", from_cache - ) + await self._channels.async_initialize(from_cache) self.debug("power source: %s", self.power_source) self.status = DeviceStatus.INITIALIZED self.debug("completed initialization") - async def _execute_channel_tasks(self, channels, task_name, *args): - """Gather and execute a set of CHANNEL tasks.""" - channel_tasks = [] - semaphore = asyncio.Semaphore(3) - zdo_task = None - for channel in channels: - if channel.name == CHANNEL_ZDO: - if zdo_task is None: # We only want to do this once - zdo_task = self._async_create_task( - semaphore, channel, task_name, *args - ) - else: - channel_tasks.append( - self._async_create_task(semaphore, channel, task_name, *args) - ) - if zdo_task is not None: - await zdo_task - await asyncio.gather(*channel_tasks) - - async def _async_create_task(self, semaphore, channel, func_name, *args): - """Configure a single channel on this device.""" - try: - async with semaphore: - await getattr(channel, func_name)(*args) - channel.debug("channel: '%s' stage succeeded", func_name) - except Exception as ex: # pylint: disable=broad-except - channel.warning("channel: '%s' stage failed ex: %s", func_name, ex) - @callback def async_unsub_dispatcher(self): """Unsubscribe the dispatcher.""" diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index c8514e2937d..e6b844b9c43 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -1,268 +1,150 @@ """Device discovery functions for Zigbee Home Automation.""" import logging - -import zigpy.profiles -from zigpy.zcl.clusters.general import OnOff, PowerConfiguration +from typing import Callable, List, Tuple from homeassistant import const as ha_const from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.typing import HomeAssistantType -from .channels import AttributeListeningChannel, EventRelayChannel, ZDOChannel -from .const import COMPONENTS, CONF_DEVICE_CONFIG, DATA_ZHA, ZHA_DISCOVERY_NEW -from .registries import ( - CHANNEL_ONLY_CLUSTERS, - COMPONENT_CLUSTERS, - DEVICE_CLASS, - EVENT_RELAY_CLUSTERS, - OUTPUT_CHANNEL_ONLY_CLUSTERS, - REMOTE_DEVICE_TYPES, - SINGLE_INPUT_CLUSTER_DEVICE_CLASS, - SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, - ZIGBEE_CHANNEL_REGISTRY, -) +from . import const as zha_const, registries as zha_regs, typing as zha_typing +from .channels import base _LOGGER = logging.getLogger(__name__) @callback -def async_process_endpoint( - hass, - config, - endpoint_id, - endpoint, - discovery_infos, - device, - zha_device, - is_new_join, -): - """Process an endpoint on a zigpy device.""" - if endpoint_id == 0: # ZDO - _async_create_cluster_channel( - endpoint, zha_device, is_new_join, channel_class=ZDOChannel - ) +async def async_add_entities( + _async_add_entities: Callable, + entities: List[ + Tuple[ + zha_typing.ZhaEntityType, + Tuple[str, zha_typing.ZhaDeviceType, List[zha_typing.ChannelType]], + ] + ], +) -> None: + """Add entities helper.""" + if not entities: return + to_add = [ent_cls(*args) for ent_cls, args in entities] + _async_add_entities(to_add, update_before_add=True) + entities.clear() - component = None - profile_clusters = [] - device_key = f"{device.ieee}-{endpoint_id}" - node_config = {} - if CONF_DEVICE_CONFIG in config: - node_config = config[CONF_DEVICE_CONFIG].get(device_key, {}) - if endpoint.profile_id in zigpy.profiles.PROFILES: - if DEVICE_CLASS.get(endpoint.profile_id, {}).get(endpoint.device_type, None): - profile_info = DEVICE_CLASS[endpoint.profile_id] - component = profile_info[endpoint.device_type] +class ProbeEndpoint: + """All discovered channels and entities of an endpoint.""" - if ha_const.CONF_TYPE in node_config: - component = node_config[ha_const.CONF_TYPE] + def __init__(self): + """Initialize instance.""" + self._device_configs = {} - if component and component in COMPONENTS and component in COMPONENT_CLUSTERS: - profile_clusters = COMPONENT_CLUSTERS[component] - if profile_clusters: - profile_match = _async_handle_profile_match( - hass, - endpoint, - profile_clusters, - zha_device, - component, - device_key, - is_new_join, + @callback + def discover_entities(self, channel_pool: zha_typing.ChannelPoolType) -> None: + """Process an endpoint on a zigpy device.""" + self.discover_by_device_type(channel_pool) + self.discover_by_cluster_id(channel_pool) + + @callback + def discover_by_device_type(self, channel_pool: zha_typing.ChannelPoolType) -> None: + """Process an endpoint on a zigpy device.""" + + unique_id = channel_pool.unique_id + + component = self._device_configs.get(unique_id, {}).get(ha_const.CONF_TYPE) + if component is None: + ep_profile_id = channel_pool.endpoint.profile_id + ep_device_type = channel_pool.endpoint.device_type + component = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) + + if component and component in zha_const.COMPONENTS: + channels = channel_pool.unclaimed_channels() + entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( + component, channel_pool.manufacturer, channel_pool.model, channels ) - discovery_infos.append(profile_match) + if entity_class is None: + return + channel_pool.claim_channels(claimed) + channel_pool.async_new_entity(component, entity_class, unique_id, claimed) - discovery_infos.extend( - _async_handle_single_cluster_matches( - hass, endpoint, zha_device, profile_clusters, device_key, is_new_join - ) - ) + @callback + def discover_by_cluster_id(self, channel_pool: zha_typing.ChannelPoolType) -> None: + """Process an endpoint on a zigpy device.""" - -@callback -def _async_create_cluster_channel( - cluster, zha_device, is_new_join, channels=None, channel_class=None -): - """Create a cluster channel and attach it to a device.""" - # really ugly hack to deal with xiaomi using the door lock cluster - # incorrectly. - if hasattr(cluster, "ep_attribute") and cluster.ep_attribute == "multistate_input": - channel_class = AttributeListeningChannel - # end of ugly hack - if channel_class is None: - channel_class = ZIGBEE_CHANNEL_REGISTRY.get( - cluster.cluster_id, AttributeListeningChannel - ) - channel = channel_class(cluster, zha_device) - zha_device.add_cluster_channel(channel) - if channels is not None: - channels.append(channel) - - -@callback -def async_dispatch_discovery_info(hass, is_new_join, discovery_info): - """Dispatch or store discovery information.""" - if not discovery_info["channels"]: - _LOGGER.warning( - "there are no channels in the discovery info: %s", discovery_info - ) - return - component = discovery_info["component"] - if is_new_join: - async_dispatcher_send(hass, ZHA_DISCOVERY_NEW.format(component), discovery_info) - else: - hass.data[DATA_ZHA][component][discovery_info["unique_id"]] = discovery_info - - -@callback -def _async_handle_profile_match( - hass, endpoint, profile_clusters, zha_device, component, device_key, is_new_join -): - """Dispatch a profile match to the appropriate HA component.""" - in_clusters = [ - endpoint.in_clusters[c] for c in profile_clusters if c in endpoint.in_clusters - ] - out_clusters = [ - endpoint.out_clusters[c] for c in profile_clusters if c in endpoint.out_clusters - ] - - channels = [] - - for cluster in in_clusters: - _async_create_cluster_channel( - cluster, zha_device, is_new_join, channels=channels - ) - - for cluster in out_clusters: - _async_create_cluster_channel( - cluster, zha_device, is_new_join, channels=channels - ) - - discovery_info = { - "unique_id": device_key, - "zha_device": zha_device, - "channels": channels, - "component": component, - } - - return discovery_info - - -@callback -def _async_handle_single_cluster_matches( - hass, endpoint, zha_device, profile_clusters, device_key, is_new_join -): - """Dispatch single cluster matches to HA components.""" - cluster_matches = [] - cluster_match_results = [] - matched_power_configuration = False - for cluster in endpoint.in_clusters.values(): - if cluster.cluster_id in CHANNEL_ONLY_CLUSTERS: - cluster_match_results.append( - _async_handle_channel_only_cluster_match( - zha_device, cluster, is_new_join - ) - ) - continue - - if cluster.cluster_id not in profile_clusters: - # Only create one battery sensor per device - if cluster.cluster_id == PowerConfiguration.cluster_id and ( - zha_device.is_mains_powered or matched_power_configuration - ): + items = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.items() + single_input_clusters = { + cluster_class: match + for cluster_class, match in items + if not isinstance(cluster_class, int) + } + remaining_channels = channel_pool.unclaimed_channels() + for channel in remaining_channels: + if channel.cluster.cluster_id in zha_regs.CHANNEL_ONLY_CLUSTERS: + channel_pool.claim_channels([channel]) continue - if ( - cluster.cluster_id == PowerConfiguration.cluster_id - and not zha_device.is_mains_powered - ): - matched_power_configuration = True - - cluster_match_results.append( - _async_handle_single_cluster_match( - hass, - zha_device, - cluster, - device_key, - SINGLE_INPUT_CLUSTER_DEVICE_CLASS, - is_new_join, - ) + component = zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS.get( + channel.cluster.cluster_id ) + if component is None: + for cluster_class, match in single_input_clusters.items(): + if isinstance(channel.cluster, cluster_class): + component = match + break - for cluster in endpoint.out_clusters.values(): - if cluster.cluster_id in OUTPUT_CHANNEL_ONLY_CLUSTERS: - cluster_match_results.append( - _async_handle_channel_only_cluster_match( - zha_device, cluster, is_new_join - ) + self.probe_single_cluster(component, channel, channel_pool) + + # until we can get rid off registries + self.handle_on_off_output_cluster_exception(channel_pool) + + @staticmethod + def probe_single_cluster( + component: str, + channel: zha_typing.ChannelType, + ep_channels: zha_typing.ChannelPoolType, + ) -> None: + """Probe specified cluster for specific component.""" + if component is None or component not in zha_const.COMPONENTS: + return + channel_list = [channel] + unique_id = f"{ep_channels.unique_id}-{channel.cluster.cluster_id}" + + entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( + component, ep_channels.manufacturer, ep_channels.model, channel_list + ) + if entity_class is None: + return + ep_channels.claim_channels(claimed) + ep_channels.async_new_entity(component, entity_class, unique_id, claimed) + + def handle_on_off_output_cluster_exception( + self, ep_channels: zha_typing.ChannelPoolType + ) -> None: + """Process output clusters of the endpoint.""" + + profile_id = ep_channels.endpoint.profile_id + device_type = ep_channels.endpoint.device_type + if device_type in zha_regs.REMOTE_DEVICE_TYPES.get(profile_id, []): + return + + for cluster_id, cluster in ep_channels.endpoint.out_clusters.items(): + component = zha_regs.SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.get( + cluster.cluster_id ) - continue + if component is None: + continue - device_type = cluster.endpoint.device_type - profile_id = cluster.endpoint.profile_id - - if cluster.cluster_id not in profile_clusters: - # prevent remotes and controllers from getting entities - if not ( - cluster.cluster_id == OnOff.cluster_id - and profile_id in REMOTE_DEVICE_TYPES - and device_type in REMOTE_DEVICE_TYPES[profile_id] - ): - cluster_match_results.append( - _async_handle_single_cluster_match( - hass, - zha_device, - cluster, - device_key, - SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, - is_new_join, - ) - ) - - if cluster.cluster_id in EVENT_RELAY_CLUSTERS: - _async_create_cluster_channel( - cluster, zha_device, is_new_join, channel_class=EventRelayChannel + channel_class = zha_regs.ZIGBEE_CHANNEL_REGISTRY.get( + cluster_id, base.AttributeListeningChannel ) + channel = channel_class(cluster, ep_channels) + self.probe_single_cluster(component, channel, ep_channels) - for cluster_match in cluster_match_results: - if cluster_match is not None: - cluster_matches.append(cluster_match) - return cluster_matches + def initialize(self, hass: HomeAssistantType) -> None: + """Update device overrides config.""" + zha_config = hass.data[zha_const.DATA_ZHA].get(zha_const.DATA_ZHA_CONFIG, {}) + overrides = zha_config.get(zha_const.CONF_DEVICE_CONFIG) + if overrides: + self._device_configs.update(overrides) -@callback -def _async_handle_channel_only_cluster_match(zha_device, cluster, is_new_join): - """Handle a channel only cluster match.""" - _async_create_cluster_channel(cluster, zha_device, is_new_join) - - -@callback -def _async_handle_single_cluster_match( - hass, zha_device, cluster, device_key, device_classes, is_new_join -): - """Dispatch a single cluster match to a HA component.""" - component = None # sub_component = None - for cluster_type, candidate_component in device_classes.items(): - if isinstance(cluster_type, int): - if cluster.cluster_id == cluster_type: - component = candidate_component - elif isinstance(cluster, cluster_type): - component = candidate_component - break - - if component is None or component not in COMPONENTS: - return - channels = [] - _async_create_cluster_channel(cluster, zha_device, is_new_join, channels=channels) - - cluster_key = f"{device_key}-{cluster.cluster_id}" - discovery_info = { - "unique_id": cluster_key, - "zha_device": zha_device, - "channels": channels, - "entity_suffix": f"_{cluster.cluster_id}", - "component": component, - } - - return discovery_info +PROBE = ProbeEndpoint() diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 8a8f57764a6..90d8165c640 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -18,6 +18,7 @@ from homeassistant.helpers.device_registry import ( from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.entity_registry import async_get_registry as get_ent_reg +from . import discovery, typing as zha_typing from .const import ( ATTR_IEEE, ATTR_MANUFACTURER, @@ -33,6 +34,7 @@ from .const import ( DATA_ZHA, DATA_ZHA_BRIDGE_ID, DATA_ZHA_GATEWAY, + DATA_ZHA_PLATFORM_LOADED, DEBUG_COMP_BELLOWS, DEBUG_COMP_ZHA, DEBUG_COMP_ZIGPY, @@ -47,6 +49,7 @@ from .const import ( DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, DOMAIN, + SIGNAL_ADD_ENTITIES, SIGNAL_REMOVE, UNKNOWN_MANUFACTURER, UNKNOWN_MODEL, @@ -67,7 +70,6 @@ from .const import ( ZHA_GW_RADIO_DESCRIPTION, ) from .device import DeviceStatus, ZHADevice -from .discovery import async_dispatch_discovery_info, async_process_endpoint from .group import ZHAGroup from .patches import apply_application_controller_patch from .registries import RADIO_TYPES @@ -107,6 +109,8 @@ class ZHAGateway: async def async_initialize(self): """Initialize controller and connect radio.""" + discovery.PROBE.initialize(self._hass) + self.zha_storage = await async_get_registry(self._hass) self.ha_device_registry = await get_dev_reg(self._hass) self.ha_entity_registry = await get_ent_reg(self._hass) @@ -133,22 +137,34 @@ class ZHAGateway: self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str( self.application_controller.ieee ) + self._initialize_groups() + + async def async_load_devices(self) -> None: + """Restore ZHA devices from zigpy application state.""" + await self._hass.data[DATA_ZHA][DATA_ZHA_PLATFORM_LOADED].wait() - init_tasks = [] semaphore = asyncio.Semaphore(2) - async def init_with_semaphore(coro, semaphore): - """Don't flood the zigbee network during initialization.""" + async def _throttle(device: zha_typing.ZigpyDeviceType): async with semaphore: - await coro + await self.async_device_restored(device) - for device in self.application_controller.devices.values(): - init_tasks.append( - init_with_semaphore(self.async_device_restored(device), semaphore) - ) - await asyncio.gather(*init_tasks) + zigpy_devices = self.application_controller.devices.values() + _LOGGER.debug("Loading battery powered devices") + await asyncio.gather( + *[ + _throttle(dev) + for dev in zigpy_devices + if not dev.node_desc.is_mains_powered + ] + ) + async_dispatcher_send(self._hass, SIGNAL_ADD_ENTITIES) - self._initialize_groups() + _LOGGER.debug("Loading mains powered devices") + await asyncio.gather( + *[_throttle(dev) for dev in zigpy_devices if dev.node_desc.is_mains_powered] + ) + async_dispatcher_send(self._hass, SIGNAL_ADD_ENTITIES) def device_joined(self, device): """Handle device joined. @@ -356,11 +372,13 @@ class ZHAGateway: self._async_get_or_create_group(group) @callback - def _async_get_or_create_device(self, zigpy_device): + def _async_get_or_create_device( + self, zigpy_device: zha_typing.ZigpyDeviceType, restored: bool = False + ): """Get or create a ZHA device.""" zha_device = self._devices.get(zigpy_device.ieee) if zha_device is None: - zha_device = ZHADevice(self._hass, zigpy_device, self) + zha_device = ZHADevice.new(self._hass, zigpy_device, self, restored) self._devices[zigpy_device.ieee] = zha_device device_registry_device = self.ha_device_registry.async_get_or_create( config_entry_id=self._config_entry.entry_id, @@ -406,13 +424,14 @@ class ZHAGateway: self.zha_storage.async_update(device) await self.zha_storage.async_save() - async def async_device_initialized(self, device): + async def async_device_initialized(self, device: zha_typing.ZigpyDeviceType): """Handle device joined and basic information discovered (async).""" zha_device = self._async_get_or_create_device(device) _LOGGER.debug( - "device - %s entering async_device_initialized - is_new_join: %s", - f"0x{device.nwk:04x}:{device.ieee}", + "device - %s:%s entering async_device_initialized - is_new_join: %s", + device.nwk, + device.ieee, zha_device.status is not DeviceStatus.INITIALIZED, ) @@ -420,16 +439,18 @@ class ZHAGateway: # ZHA already has an initialized device so either the device was assigned a # new nwk or device was physically reset and added again without being removed _LOGGER.debug( - "device - %s has been reset and re-added or its nwk address changed", - f"0x{device.nwk:04x}:{device.ieee}", + "device - %s:%s has been reset and re-added or its nwk address changed", + device.nwk, + device.ieee, ) await self._async_device_rejoined(zha_device) else: _LOGGER.debug( - "device - %s has joined the ZHA zigbee network", - f"0x{device.nwk:04x}:{device.ieee}", + "device - %s:%s has joined the ZHA zigbee network", + device.nwk, + device.ieee, ) - await self._async_device_joined(device, zha_device) + await self._async_device_joined(zha_device) device_info = zha_device.async_get_info() @@ -442,64 +463,36 @@ class ZHAGateway: }, ) - async def _async_device_joined(self, device, zha_device): - discovery_infos = [] - for endpoint_id, endpoint in device.endpoints.items(): - async_process_endpoint( - self._hass, - self._config, - endpoint_id, - endpoint, - discovery_infos, - device, - zha_device, - True, - ) - + async def _async_device_joined(self, zha_device: zha_typing.ZhaDeviceType) -> None: await zha_device.async_configure() # will cause async_init to fire so don't explicitly call it zha_device.update_available(True) - - for discovery_info in discovery_infos: - async_dispatch_discovery_info(self._hass, True, discovery_info) + async_dispatcher_send(self._hass, SIGNAL_ADD_ENTITIES) # only public for testing - async def async_device_restored(self, device): + async def async_device_restored(self, device: zha_typing.ZigpyDeviceType): """Add an existing device to the ZHA zigbee network when ZHA first starts.""" - zha_device = self._async_get_or_create_device(device) - discovery_infos = [] - for endpoint_id, endpoint in device.endpoints.items(): - async_process_endpoint( - self._hass, - self._config, - endpoint_id, - endpoint, - discovery_infos, - device, - zha_device, - False, - ) + zha_device = self._async_get_or_create_device(device, restored=True) if zha_device.is_mains_powered: # the device isn't a battery powered device so we should be able # to update it now _LOGGER.debug( - "attempting to request fresh state for device - %s %s %s", - f"0x{zha_device.nwk:04x}:{zha_device.ieee}", + "attempting to request fresh state for device - %s:%s %s with power source %s", + zha_device.nwk, + zha_device.ieee, zha_device.name, - f"with power source: {zha_device.power_source}", + zha_device.power_source, ) await zha_device.async_initialize(from_cache=False) else: await zha_device.async_initialize(from_cache=True) - for discovery_info in discovery_infos: - async_dispatch_discovery_info(self._hass, False, discovery_info) - async def _async_device_rejoined(self, zha_device): _LOGGER.debug( - "skipping discovery for previously discovered device - %s", - f"0x{zha_device.nwk:04x}:{zha_device.ieee}", + "skipping discovery for previously discovered device - %s:%s", + zha_device.nwk, + zha_device.ieee, ) # we don't have to do this on a nwk swap but we don't have a way to tell currently await zha_device.async_configure() diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index bc788b39ee7..3b08d1acd37 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -1,6 +1,6 @@ """Mapping registries for Zigbee Home Automation.""" import collections -from typing import Callable, Set, Union +from typing import Callable, Dict, List, Set, Tuple, Union import attr import bellows.ezsp @@ -27,9 +27,10 @@ from homeassistant.components.sensor import DOMAIN as SENSOR from homeassistant.components.switch import DOMAIN as SWITCH # importing channels updates registries -from . import channels # noqa: F401 pylint: disable=unused-import +from . import channels as zha_channels # noqa: F401 pylint: disable=unused-import from .const import CONTROLLER, ZHA_GW_RADIO, ZHA_GW_RADIO_DESCRIPTION, RadioType from .decorators import CALLABLE_T, DictRegistry, SetRegistry +from .typing import ChannelType SMARTTHINGS_ACCELERATION_CLUSTER = 0xFC02 SMARTTHINGS_ARRIVAL_SENSOR_DEVICE_TYPE = 0x8000 @@ -57,30 +58,33 @@ REMOTE_DEVICE_TYPES = { zigpy.profiles.zll.DeviceType.SCENE_CONTROLLER, ], } +REMOTE_DEVICE_TYPES = collections.defaultdict(list, REMOTE_DEVICE_TYPES) SINGLE_INPUT_CLUSTER_DEVICE_CLASS = { # this works for now but if we hit conflicts we can break it out to # a different dict that is keyed by manufacturer SMARTTHINGS_ACCELERATION_CLUSTER: BINARY_SENSOR, SMARTTHINGS_HUMIDITY_CLUSTER: SENSOR, - zcl.clusters.closures.DoorLock: LOCK, - zcl.clusters.closures.WindowCovering: COVER, + zcl.clusters.closures.DoorLock.cluster_id: LOCK, + zcl.clusters.closures.WindowCovering.cluster_id: COVER, zcl.clusters.general.AnalogInput.cluster_id: SENSOR, zcl.clusters.general.MultistateInput.cluster_id: SENSOR, - zcl.clusters.general.OnOff: SWITCH, - zcl.clusters.general.PowerConfiguration: SENSOR, - zcl.clusters.homeautomation.ElectricalMeasurement: SENSOR, - zcl.clusters.hvac.Fan: FAN, - zcl.clusters.measurement.IlluminanceMeasurement: SENSOR, - zcl.clusters.measurement.OccupancySensing: BINARY_SENSOR, - zcl.clusters.measurement.PressureMeasurement: SENSOR, - zcl.clusters.measurement.RelativeHumidity: SENSOR, - zcl.clusters.measurement.TemperatureMeasurement: SENSOR, - zcl.clusters.security.IasZone: BINARY_SENSOR, - zcl.clusters.smartenergy.Metering: SENSOR, + zcl.clusters.general.OnOff.cluster_id: SWITCH, + zcl.clusters.general.PowerConfiguration.cluster_id: SENSOR, + zcl.clusters.homeautomation.ElectricalMeasurement.cluster_id: SENSOR, + zcl.clusters.hvac.Fan.cluster_id: FAN, + zcl.clusters.measurement.IlluminanceMeasurement.cluster_id: SENSOR, + zcl.clusters.measurement.OccupancySensing.cluster_id: BINARY_SENSOR, + zcl.clusters.measurement.PressureMeasurement.cluster_id: SENSOR, + zcl.clusters.measurement.RelativeHumidity.cluster_id: SENSOR, + zcl.clusters.measurement.TemperatureMeasurement.cluster_id: SENSOR, + zcl.clusters.security.IasZone.cluster_id: BINARY_SENSOR, + zcl.clusters.smartenergy.Metering.cluster_id: SENSOR, } -SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = {zcl.clusters.general.OnOff: BINARY_SENSOR} +SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = { + zcl.clusters.general.OnOff.cluster_id: BINARY_SENSOR +} SWITCH_CLUSTERS = SetRegistry() @@ -89,7 +93,6 @@ BINARY_SENSOR_CLUSTERS.add(SMARTTHINGS_ACCELERATION_CLUSTER) BINDABLE_CLUSTERS = SetRegistry() CHANNEL_ONLY_CLUSTERS = SetRegistry() -CLUSTER_REPORT_CONFIGS = {} CUSTOM_CLUSTER_MAPPINGS = {} DEVICE_CLASS = { @@ -117,6 +120,7 @@ DEVICE_CLASS = { zigpy.profiles.zll.DeviceType.ON_OFF_PLUGIN_UNIT: SWITCH, }, } +DEVICE_CLASS = collections.defaultdict(dict, DEVICE_CLASS) DEVICE_TRACKER_CLUSTERS = SetRegistry() EVENT_RELAY_CLUSTERS = SetRegistry() @@ -188,6 +192,63 @@ class MatchRule: models: Union[Callable, Set[str], str] = attr.ib( factory=frozenset, converter=set_or_callable ) + aux_channels: Union[Callable, Set[str], str] = attr.ib( + factory=frozenset, converter=set_or_callable + ) + + def claim_channels(self, channel_pool: List[ChannelType]) -> List[ChannelType]: + """Return a list of channels this rule matches + aux channels.""" + claimed = [] + if isinstance(self.channel_names, frozenset): + claimed.extend([ch for ch in channel_pool if ch.name in self.channel_names]) + if isinstance(self.generic_ids, frozenset): + claimed.extend( + [ch for ch in channel_pool if ch.generic_id in self.generic_ids] + ) + if isinstance(self.aux_channels, frozenset): + claimed.extend([ch for ch in channel_pool if ch.name in self.aux_channels]) + return claimed + + def strict_matched(self, manufacturer: str, model: str, channels: List) -> bool: + """Return True if this device matches the criteria.""" + return all(self._matched(manufacturer, model, channels)) + + def loose_matched(self, manufacturer: str, model: str, channels: List) -> bool: + """Return True if this device matches the criteria.""" + return any(self._matched(manufacturer, model, channels)) + + def _matched(self, manufacturer: str, model: str, channels: List) -> list: + """Return a list of field matches.""" + if not any(attr.asdict(self).values()): + return [False] + + matches = [] + if self.channel_names: + channel_names = {ch.name for ch in channels} + matches.append(self.channel_names.issubset(channel_names)) + + if self.generic_ids: + all_generic_ids = {ch.generic_id for ch in channels} + matches.append(self.generic_ids.issubset(all_generic_ids)) + + if self.manufacturers: + if callable(self.manufacturers): + matches.append(self.manufacturers(manufacturer)) + else: + matches.append(manufacturer in self.manufacturers) + + if self.models: + if callable(self.models): + matches.append(self.models(model)) + else: + matches.append(model in self.models) + + return matches + + +RegistryDictType = Dict[ + str, Dict[MatchRule, CALLABLE_T] +] # pylint: disable=invalid-name class ZHAEntityRegistry: @@ -195,18 +256,24 @@ class ZHAEntityRegistry: def __init__(self): """Initialize Registry instance.""" - self._strict_registry = collections.defaultdict(dict) - self._loose_registry = collections.defaultdict(dict) + self._strict_registry: RegistryDictType = collections.defaultdict(dict) + self._loose_registry: RegistryDictType = collections.defaultdict(dict) def get_entity( - self, component: str, zha_device, chnls: dict, default: CALLABLE_T = None - ) -> CALLABLE_T: + self, + component: str, + manufacturer: str, + model: str, + channels: List[ChannelType], + default: CALLABLE_T = None, + ) -> Tuple[CALLABLE_T, List[ChannelType]]: """Match a ZHA Channels to a ZHA Entity class.""" for match in self._strict_registry[component]: - if self._strict_matched(zha_device, chnls, match): - return self._strict_registry[component][match] + if match.strict_matched(manufacturer, model, channels): + claimed = match.claim_channels(channels) + return self._strict_registry[component][match], claimed - return default + return default, [] def strict_match( self, @@ -215,10 +282,13 @@ class ZHAEntityRegistry: generic_ids: Union[Callable, Set[str], str] = None, manufacturers: Union[Callable, Set[str], str] = None, models: Union[Callable, Set[str], str] = None, + aux_channels: Union[Callable, Set[str], str] = None, ) -> Callable[[CALLABLE_T], CALLABLE_T]: """Decorate a strict match rule.""" - rule = MatchRule(channel_names, generic_ids, manufacturers, models) + rule = MatchRule( + channel_names, generic_ids, manufacturers, models, aux_channels + ) def decorator(zha_ent: CALLABLE_T) -> CALLABLE_T: """Register a strict match rule. @@ -237,10 +307,13 @@ class ZHAEntityRegistry: generic_ids: Union[Callable, Set[str], str] = None, manufacturers: Union[Callable, Set[str], str] = None, models: Union[Callable, Set[str], str] = None, + aux_channels: Union[Callable, Set[str], str] = None, ) -> Callable[[CALLABLE_T], CALLABLE_T]: """Decorate a loose match rule.""" - rule = MatchRule(channel_names, generic_ids, manufacturers, models) + rule = MatchRule( + channel_names, generic_ids, manufacturers, models, aux_channels + ) def decorator(zha_entity: CALLABLE_T) -> CALLABLE_T: """Register a loose match rule. @@ -252,42 +325,5 @@ class ZHAEntityRegistry: return decorator - def _strict_matched(self, zha_device, chnls: dict, rule: MatchRule) -> bool: - """Return True if this device matches the criteria.""" - return all(self._matched(zha_device, chnls, rule)) - - def _loose_matched(self, zha_device, chnls: dict, rule: MatchRule) -> bool: - """Return True if this device matches the criteria.""" - return any(self._matched(zha_device, chnls, rule)) - - @staticmethod - def _matched(zha_device, chnls: dict, rule: MatchRule) -> list: - """Return a list of field matches.""" - if not any(attr.asdict(rule).values()): - return [False] - - matches = [] - if rule.channel_names: - channel_names = {ch.name for ch in chnls} - matches.append(rule.channel_names.issubset(channel_names)) - - if rule.generic_ids: - all_generic_ids = {ch.generic_id for ch in chnls} - matches.append(rule.generic_ids.issubset(all_generic_ids)) - - if rule.manufacturers: - if callable(rule.manufacturers): - matches.append(rule.manufacturers(zha_device.manufacturer)) - else: - matches.append(zha_device.manufacturer in rule.manufacturers) - - if rule.models: - if callable(rule.models): - matches.append(rule.models(zha_device.model)) - else: - matches.append(zha_device.model in rule.models) - - return matches - ZHA_ENTITIES = ZHAEntityRegistry() diff --git a/homeassistant/components/zha/core/typing.py b/homeassistant/components/zha/core/typing.py new file mode 100644 index 00000000000..3d10912d165 --- /dev/null +++ b/homeassistant/components/zha/core/typing.py @@ -0,0 +1,41 @@ +"""Typing helpers for ZHA component.""" + +from typing import TYPE_CHECKING, Callable, TypeVar + +import zigpy.device +import zigpy.endpoint +import zigpy.zcl +import zigpy.zdo + +# pylint: disable=invalid-name +CALLABLE_T = TypeVar("CALLABLE_T", bound=Callable) +ChannelType = "ZigbeeChannel" +ChannelsType = "Channels" +ChannelPoolType = "ChannelPool" +EventRelayChannelType = "EventRelayChannel" +ZDOChannelType = "ZDOChannel" +ZhaDeviceType = "ZHADevice" +ZhaEntityType = "ZHAEntity" +ZhaGatewayType = "ZHAGateway" +ZigpyClusterType = zigpy.zcl.Cluster +ZigpyDeviceType = zigpy.device.Device +ZigpyEndpointType = zigpy.endpoint.Endpoint +ZigpyZdoType = zigpy.zdo.ZDO + +if TYPE_CHECKING: + 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.entity + import homeassistant.components.zha.core.channels + + # pylint: disable=invalid-name + ChannelType = base_channels.ZigbeeChannel + ChannelsType = channels.Channels + ChannelPoolType = channels.ChannelPool + EventRelayChannelType = base_channels.EventRelayChannel + ZDOChannelType = base_channels.ZDOChannel + ZhaDeviceType = homeassistant.components.zha.core.device.ZHADevice + ZhaEntityType = homeassistant.components.zha.entity.ZhaEntity + ZhaGatewayType = homeassistant.components.zha.core.gateway.ZHAGateway diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 3eeb73a23fd..13de445cf37 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -10,12 +10,13 @@ from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_O from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .core import discovery from .core.const import ( CHANNEL_COVER, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, - ZHA_DISCOVERY_NEW, ) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -28,41 +29,17 @@ STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation cover from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - covers = hass.data.get(DATA_ZHA, {}).get(DOMAIN) - if covers is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, covers.values() - ) - del hass.data[DATA_ZHA][DOMAIN] - - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA covers.""" - entities = [] - for discovery_info in discovery_infos: - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - - entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, ZhaCover) - if entity: - entities.append(entity(**discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - @STRICT_MATCH(channel_names=CHANNEL_COVER) class ZhaCover(ZhaEntity, CoverDevice): diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 60cfa0eec00..5a2e0c40881 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -57,11 +57,16 @@ async def async_call_action_from_config( async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: """List device actions.""" zha_device = await async_get_zha_device(hass, device_id) + cluster_channels = [ + ch.name + for pool in zha_device.channels.pools + for ch in pool.claimed_channels.values() + ] actions = [ action for channel in DEVICE_ACTIONS for action in DEVICE_ACTIONS[channel] - if channel in zha_device.cluster_channels + if channel in cluster_channels ] for action in actions: action[CONF_DEVICE_ID] = device_id diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index 76548935814..5481ec70f52 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -8,12 +8,13 @@ from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .core import discovery from .core.const import ( CHANNEL_POWER_CONFIGURATION, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, - ZHA_DISCOVERY_NEW, ) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -25,51 +26,25 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation device tracker from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - device_trackers = hass.data.get(DATA_ZHA, {}).get(DOMAIN) - if device_trackers is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, device_trackers.values() - ) - del hass.data[DATA_ZHA][DOMAIN] - - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA device trackers.""" - entities = [] - for discovery_info in discovery_infos: - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - - entity = ZHA_ENTITIES.get_entity( - DOMAIN, zha_dev, channels, ZHADeviceScannerEntity - ) - if entity: - entities.append(entity(**discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - @STRICT_MATCH(channel_names=CHANNEL_POWER_CONFIGURATION) class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): """Represent a tracked device.""" - def __init__(self, **kwargs): + def __init__(self, unique_id, zha_device, channels, **kwargs): """Initialize the ZHA device tracker.""" - super().__init__(**kwargs) + super().__init__(unique_id, zha_device, channels, **kwargs) self._battery_channel = self.cluster_channels.get(CHANNEL_POWER_CONFIGURATION) self._connected = False self._keepalive_interval = 60 diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 6a9dfc63432..76d0908000b 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -44,7 +44,6 @@ class ZhaEntity(RestoreEntity, LogMixin, entity.Entity): self._zha_device = zha_device self.cluster_channels = {} self._available = False - self._component = kwargs["component"] self._unsubs = [] self.remove_future = None for channel in channels: diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 6ad13d1c802..59a6bfb9c47 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -14,12 +14,13 @@ from homeassistant.components.fan import ( from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .core import discovery from .core.const import ( CHANNEL_FAN, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, - ZHA_DISCOVERY_NEW, ) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -52,41 +53,17 @@ STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation fan from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - fans = hass.data.get(DATA_ZHA, {}).get(DOMAIN) - if fans is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, fans.values() - ) - del hass.data[DATA_ZHA][DOMAIN] - - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA fans.""" - entities = [] - for discovery_info in discovery_infos: - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - - entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, ZhaFan) - if entity: - entities.append(entity(**discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - @STRICT_MATCH(channel_names=CHANNEL_FAN) class ZhaFan(ZhaEntity, FanEntity): diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 409cd339122..dc2e156dbf5 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -12,15 +12,16 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_time_interval import homeassistant.util.color as color_util +from .core import discovery from .core.const import ( CHANNEL_COLOR, CHANNEL_LEVEL, CHANNEL_ON_OFF, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL, - ZHA_DISCOVERY_NEW, ) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -44,43 +45,19 @@ PARALLEL_UPDATES = 5 async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation light from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][light.DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(light.DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - lights = hass.data.get(DATA_ZHA, {}).get(light.DOMAIN) - if lights is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, lights.values() - ) - del hass.data[DATA_ZHA][light.DOMAIN] - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA lights.""" - entities = [] - for discovery_info in discovery_infos: - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - - entity = ZHA_ENTITIES.get_entity(light.DOMAIN, zha_dev, channels, Light) - if entity: - entities.append(entity(**discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - - -@STRICT_MATCH(channel_names=CHANNEL_ON_OFF) +@STRICT_MATCH(channel_names=CHANNEL_ON_OFF, aux_channels={CHANNEL_COLOR, CHANNEL_LEVEL}) class Light(ZhaEntity, light.Light): """Representation of a ZHA or ZLL light.""" diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index b173c166a77..7ba31158fc3 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -13,12 +13,13 @@ from homeassistant.components.lock import ( from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .core import discovery from .core.const import ( CHANNEL_DOORLOCK, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, - ZHA_DISCOVERY_NEW, ) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -35,41 +36,17 @@ VALUE_TO_STATE = dict(enumerate(STATE_LIST)) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation Door Lock from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - locks = hass.data.get(DATA_ZHA, {}).get(DOMAIN) - if locks is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, locks.values() - ) - del hass.data[DATA_ZHA][DOMAIN] - - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA locks.""" - entities = [] - for discovery_info in discovery_infos: - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - - entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, ZhaDoorLock) - if entity: - entities.append(entity(**discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - @STRICT_MATCH(channel_names=CHANNEL_DOORLOCK) class ZhaDoorLock(ZhaEntity, LockDevice): diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 8b7dd894973..b98c50d1fa4 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -22,7 +22,9 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.temperature import fahrenheit_to_celsius +from .core import discovery from .core.const import ( + CHANNEL_ANALOG_INPUT, CHANNEL_ELECTRICAL_MEASUREMENT, CHANNEL_HUMIDITY, CHANNEL_ILLUMINANCE, @@ -33,9 +35,9 @@ from .core.const import ( CHANNEL_TEMPERATURE, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, SIGNAL_STATE_ATTR, - ZHA_DISCOVERY_NEW, ) from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES from .entity import ZhaEntity @@ -65,46 +67,17 @@ STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation sensor from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - sensors = hass.data.get(DATA_ZHA, {}).get(DOMAIN) - if sensors is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, sensors.values() - ) - del hass.data[DATA_ZHA][DOMAIN] - - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA sensors.""" - entities = [] - for discovery_info in discovery_infos: - entities.append(await make_sensor(discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - - -async def make_sensor(discovery_info): - """Create ZHA sensors factory.""" - - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - - entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, Sensor) - return entity(**discovery_info) - class Sensor(ZhaEntity): """Base ZHA sensor.""" @@ -176,6 +149,13 @@ class Sensor(ZhaEntity): return round(float(value * self._multiplier) / self._divisor) +@STRICT_MATCH(channel_names=CHANNEL_ANALOG_INPUT) +class AnalogInput(Sensor): + """Sensor that displays analog input values.""" + + pass + + @STRICT_MATCH(channel_names=CHANNEL_POWER_CONFIGURATION) class Battery(Sensor): """Battery sensor of power configuration cluster.""" diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 1280ace34dc..e6a82fe0270 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -9,12 +9,13 @@ from homeassistant.const import STATE_ON from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .core import discovery from .core.const import ( CHANNEL_ON_OFF, DATA_ZHA, DATA_ZHA_DISPATCHERS, + SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, - ZHA_DISCOVERY_NEW, ) from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -25,49 +26,25 @@ STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, DOMAIN) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Zigbee Home Automation switch from config entry.""" - - async def async_discover(discovery_info): - await _async_setup_entities( - hass, config_entry, async_add_entities, [discovery_info] - ) + entities_to_create = hass.data[DATA_ZHA][DOMAIN] = [] unsub = async_dispatcher_connect( - hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover + hass, + SIGNAL_ADD_ENTITIES, + functools.partial( + discovery.async_add_entities, async_add_entities, entities_to_create + ), ) hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub) - switches = hass.data.get(DATA_ZHA, {}).get(DOMAIN) - if switches is not None: - await _async_setup_entities( - hass, config_entry, async_add_entities, switches.values() - ) - del hass.data[DATA_ZHA][DOMAIN] - - -async def _async_setup_entities( - hass, config_entry, async_add_entities, discovery_infos -): - """Set up the ZHA switches.""" - entities = [] - for discovery_info in discovery_infos: - zha_dev = discovery_info["zha_device"] - channels = discovery_info["channels"] - - entity = ZHA_ENTITIES.get_entity(DOMAIN, zha_dev, channels, Switch) - if entity: - entities.append(entity(**discovery_info)) - - if entities: - async_add_entities(entities, update_before_add=True) - @STRICT_MATCH(channel_names=CHANNEL_ON_OFF) class Switch(ZhaEntity, SwitchDevice): """ZHA switch.""" - def __init__(self, **kwargs): + def __init__(self, unique_id, zha_device, channels, **kwargs): """Initialize the ZHA switch.""" - super().__init__(**kwargs) + super().__init__(unique_id, zha_device, channels, **kwargs) self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF) @property diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 03b6ed21148..dfa0c455649 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -63,6 +63,7 @@ class FakeDevice: def __init__(self, app, ieee, manufacturer, model, node_desc=None): """Init fake device.""" self._application = app + self.application = app self.ieee = zigpy.types.EUI64.convert(ieee) self.nwk = 0xB79C self.zdo = Mock() diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 26dd2b5da5c..e3a8f6bf4dc 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -9,6 +9,7 @@ import zigpy.group import zigpy.types import homeassistant.components.zha.core.const as zha_const +import homeassistant.components.zha.core.device as zha_core_device import homeassistant.components.zha.core.registries as zha_regs from homeassistant.setup import async_setup_component @@ -63,7 +64,7 @@ async def config_entry_fixture(hass): @pytest.fixture def setup_zha(hass, config_entry, zigpy_app_controller, zigpy_radio): """Set up ZHA component.""" - zha_config = {zha_const.DOMAIN: {zha_const.CONF_ENABLE_QUIRKS: False}} + zha_config = {zha_const.CONF_ENABLE_QUIRKS: False} radio_details = { zha_const.ZHA_GW_RADIO: mock.MagicMock(return_value=zigpy_radio), @@ -71,9 +72,12 @@ def setup_zha(hass, config_entry, zigpy_app_controller, zigpy_radio): zha_const.ZHA_GW_RADIO_DESCRIPTION: "mock radio", } - async def _setup(): + async def _setup(config=None): + config = config or {} with mock.patch.dict(zha_regs.RADIO_TYPES, {"MockRadio": radio_details}): - status = await async_setup_component(hass, zha_const.DOMAIN, zha_config) + status = await async_setup_component( + hass, zha_const.DOMAIN, {zha_const.DOMAIN: {**zha_config, **config}} + ) assert status is True await hass.async_block_till_done() @@ -153,6 +157,7 @@ def zha_device_restored(hass, zigpy_app_controller, setup_zha): zigpy_app_controller.devices[zigpy_dev.ieee] = zigpy_dev await setup_zha() zha_gateway = hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] + await zha_gateway.async_load_devices() return zha_gateway.get_device(zigpy_dev.ieee) return _zha_device @@ -162,3 +167,36 @@ def zha_device_restored(hass, zigpy_app_controller, setup_zha): def zha_device_joined_restored(request): """Join or restore ZHA device.""" return request.getfixturevalue(request.param) + + +@pytest.fixture +def zha_device_mock(hass, zigpy_device_mock): + """Return a zha Device factory.""" + + def _zha_device( + endpoints=None, + ieee="00:11:22:33:44:55:66:77", + manufacturer="mock manufacturer", + model="mock model", + node_desc=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00", + ): + if endpoints is None: + endpoints = { + 1: { + "in_clusters": [0, 1, 8, 768], + "out_clusters": [0x19], + "device_type": 0x0105, + }, + 2: { + "in_clusters": [0], + "out_clusters": [6, 8, 0x19, 768], + "device_type": 0x0810, + }, + } + zigpy_device = zigpy_device_mock( + endpoints, ieee, manufacturer, model, node_desc + ) + zha_device = zha_core_device.ZHADevice(hass, zigpy_device, mock.MagicMock()) + return zha_device + + return _zha_device diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py index ee493ca01a7..3f38108cf89 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_channels.py @@ -1,9 +1,14 @@ """Test ZHA Core channels.""" +import asyncio +from unittest import mock + +import asynctest import pytest import zigpy.types as t -import homeassistant.components.zha.core.channels as channels -import homeassistant.components.zha.core.device as zha_device +import homeassistant.components.zha.core.channels as zha_channels +import homeassistant.components.zha.core.channels.base as base_channels +import homeassistant.components.zha.core.const as zha_const import homeassistant.components.zha.core.registries as registries from .common import get_zha_gateway @@ -28,6 +33,15 @@ async def zha_gateway(hass, setup_zha): return get_zha_gateway(hass) +@pytest.fixture +def channel_pool(): + """Endpoint Channels fixture.""" + ch_pool_mock = mock.MagicMock(spec_set=zha_channels.ChannelPool) + type(ch_pool_mock).skip_configuration = mock.PropertyMock(return_value=False) + ch_pool_mock.id = 1 + return ch_pool_mock + + @pytest.mark.parametrize( "cluster_id, bind_count, attrs", [ @@ -72,7 +86,7 @@ async def zha_gateway(hass, setup_zha): ], ) async def test_in_channel_config( - cluster_id, bind_count, attrs, hass, zigpy_device_mock, zha_gateway + cluster_id, bind_count, attrs, channel_pool, zigpy_device_mock, zha_gateway ): """Test ZHA core channel configuration for input clusters.""" zigpy_dev = zigpy_device_mock( @@ -81,13 +95,12 @@ async def test_in_channel_config( "test manufacturer", "test model", ) - zha_dev = zha_device.ZHADevice(hass, zigpy_dev, zha_gateway) cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id] channel_class = registries.ZIGBEE_CHANNEL_REGISTRY.get( - cluster_id, channels.AttributeListeningChannel + cluster_id, base_channels.AttributeListeningChannel ) - channel = channel_class(cluster, zha_dev) + channel = channel_class(cluster, channel_pool) await channel.async_configure() @@ -130,7 +143,7 @@ async def test_in_channel_config( ], ) async def test_out_channel_config( - cluster_id, bind_count, zha_gateway, hass, zigpy_device_mock + cluster_id, bind_count, channel_pool, zigpy_device_mock, zha_gateway ): """Test ZHA core channel configuration for output clusters.""" zigpy_dev = zigpy_device_mock( @@ -139,14 +152,13 @@ async def test_out_channel_config( "test manufacturer", "test model", ) - zha_dev = zha_device.ZHADevice(hass, zigpy_dev, zha_gateway) cluster = zigpy_dev.endpoints[1].out_clusters[cluster_id] cluster.bind_only = True channel_class = registries.ZIGBEE_CHANNEL_REGISTRY.get( - cluster_id, channels.AttributeListeningChannel + cluster_id, base_channels.AttributeListeningChannel ) - channel = channel_class(cluster, zha_dev) + channel = channel_class(cluster, channel_pool) await channel.async_configure() @@ -159,4 +171,203 @@ def test_channel_registry(): for (cluster_id, channel) in registries.ZIGBEE_CHANNEL_REGISTRY.items(): assert isinstance(cluster_id, int) assert 0 <= cluster_id <= 0xFFFF - assert issubclass(channel, channels.ZigbeeChannel) + assert issubclass(channel, base_channels.ZigbeeChannel) + + +def test_epch_unclaimed_channels(channel): + """Test unclaimed channels.""" + + ch_1 = channel(zha_const.CHANNEL_ON_OFF, 6) + ch_2 = channel(zha_const.CHANNEL_LEVEL, 8) + ch_3 = channel(zha_const.CHANNEL_COLOR, 768) + + ep_channels = zha_channels.ChannelPool( + mock.MagicMock(spec_set=zha_channels.Channels), mock.sentinel.ep + ) + all_channels = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3} + with mock.patch.dict(ep_channels.all_channels, all_channels, clear=True): + available = ep_channels.unclaimed_channels() + assert ch_1 in available + assert ch_2 in available + assert ch_3 in available + + ep_channels.claimed_channels[ch_2.id] = ch_2 + available = ep_channels.unclaimed_channels() + assert ch_1 in available + assert ch_2 not in available + assert ch_3 in available + + ep_channels.claimed_channels[ch_1.id] = ch_1 + available = ep_channels.unclaimed_channels() + assert ch_1 not in available + assert ch_2 not in available + assert ch_3 in available + + ep_channels.claimed_channels[ch_3.id] = ch_3 + available = ep_channels.unclaimed_channels() + assert ch_1 not in available + assert ch_2 not in available + assert ch_3 not in available + + +def test_epch_claim_channels(channel): + """Test channel claiming.""" + + ch_1 = channel(zha_const.CHANNEL_ON_OFF, 6) + ch_2 = channel(zha_const.CHANNEL_LEVEL, 8) + ch_3 = channel(zha_const.CHANNEL_COLOR, 768) + + ep_channels = zha_channels.ChannelPool( + mock.MagicMock(spec_set=zha_channels.Channels), mock.sentinel.ep + ) + all_channels = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3} + with mock.patch.dict(ep_channels.all_channels, all_channels, clear=True): + assert ch_1.id not in ep_channels.claimed_channels + assert ch_2.id not in ep_channels.claimed_channels + assert ch_3.id not in ep_channels.claimed_channels + + ep_channels.claim_channels([ch_2]) + assert ch_1.id not in ep_channels.claimed_channels + assert ch_2.id in ep_channels.claimed_channels + assert ep_channels.claimed_channels[ch_2.id] is ch_2 + assert ch_3.id not in ep_channels.claimed_channels + + ep_channels.claim_channels([ch_3, ch_1]) + assert ch_1.id in ep_channels.claimed_channels + assert ep_channels.claimed_channels[ch_1.id] is ch_1 + assert ch_2.id in ep_channels.claimed_channels + assert ep_channels.claimed_channels[ch_2.id] is ch_2 + assert ch_3.id in ep_channels.claimed_channels + assert ep_channels.claimed_channels[ch_3.id] is ch_3 + assert "1:0x0300" in ep_channels.claimed_channels + + +@mock.patch("homeassistant.components.zha.core.channels.ChannelPool.add_relay_channels") +@mock.patch( + "homeassistant.components.zha.core.discovery.PROBE.discover_entities", + mock.MagicMock(), +) +def test_ep_channels_all_channels(m1, zha_device_mock): + """Test EndpointChannels adding all channels.""" + zha_device = zha_device_mock( + { + 1: {"in_clusters": [0, 1, 6, 8], "out_clusters": [], "device_type": 0x0000}, + 2: { + "in_clusters": [0, 1, 6, 8, 768], + "out_clusters": [], + "device_type": 0x0000, + }, + } + ) + channels = zha_channels.Channels(zha_device) + + ep_channels = zha_channels.ChannelPool.new(channels, 1) + assert "1:0x0000" in ep_channels.all_channels + assert "1:0x0001" in ep_channels.all_channels + assert "1:0x0006" in ep_channels.all_channels + assert "1:0x0008" in ep_channels.all_channels + assert "1:0x0300" not in ep_channels.all_channels + assert "2:0x0000" not in ep_channels.all_channels + assert "2:0x0001" not in ep_channels.all_channels + assert "2:0x0006" not in ep_channels.all_channels + assert "2:0x0008" not in ep_channels.all_channels + assert "2:0x0300" not in ep_channels.all_channels + + channels = zha_channels.Channels(zha_device) + ep_channels = zha_channels.ChannelPool.new(channels, 2) + assert "1:0x0000" not in ep_channels.all_channels + assert "1:0x0001" not in ep_channels.all_channels + assert "1:0x0006" not in ep_channels.all_channels + assert "1:0x0008" not in ep_channels.all_channels + assert "1:0x0300" not in ep_channels.all_channels + assert "2:0x0000" in ep_channels.all_channels + assert "2:0x0001" in ep_channels.all_channels + assert "2:0x0006" in ep_channels.all_channels + assert "2:0x0008" in ep_channels.all_channels + assert "2:0x0300" in ep_channels.all_channels + + +@mock.patch("homeassistant.components.zha.core.channels.ChannelPool.add_relay_channels") +@mock.patch( + "homeassistant.components.zha.core.discovery.PROBE.discover_entities", + mock.MagicMock(), +) +def test_channel_power_config(m1, zha_device_mock): + """Test that channels only get a single power channel.""" + in_clusters = [0, 1, 6, 8] + zha_device = zha_device_mock( + { + 1: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0x0000}, + 2: { + "in_clusters": [*in_clusters, 768], + "out_clusters": [], + "device_type": 0x0000, + }, + } + ) + channels = zha_channels.Channels.new(zha_device) + pools = {pool.id: pool for pool in channels.pools} + assert "1:0x0000" in pools[1].all_channels + assert "1:0x0001" in pools[1].all_channels + assert "1:0x0006" in pools[1].all_channels + assert "1:0x0008" in pools[1].all_channels + assert "1:0x0300" not in pools[1].all_channels + assert "2:0x0000" in pools[2].all_channels + assert "2:0x0001" not in pools[2].all_channels + assert "2:0x0006" in pools[2].all_channels + assert "2:0x0008" in pools[2].all_channels + assert "2:0x0300" in pools[2].all_channels + + zha_device = zha_device_mock( + { + 1: {"in_clusters": [], "out_clusters": [], "device_type": 0x0000}, + 2: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0x0000}, + } + ) + channels = zha_channels.Channels.new(zha_device) + pools = {pool.id: pool for pool in channels.pools} + assert "1:0x0001" not in pools[1].all_channels + assert "2:0x0001" in pools[2].all_channels + + zha_device = zha_device_mock( + {2: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0x0000}} + ) + channels = zha_channels.Channels.new(zha_device) + pools = {pool.id: pool for pool in channels.pools} + assert "2:0x0001" in pools[2].all_channels + + +async def test_ep_channels_configure(channel): + """Test unclaimed channels.""" + + ch_1 = channel(zha_const.CHANNEL_ON_OFF, 6) + ch_2 = channel(zha_const.CHANNEL_LEVEL, 8) + ch_3 = channel(zha_const.CHANNEL_COLOR, 768) + ch_3.async_configure = asynctest.CoroutineMock(side_effect=asyncio.TimeoutError) + ch_3.async_initialize = asynctest.CoroutineMock(side_effect=asyncio.TimeoutError) + ch_4 = channel(zha_const.CHANNEL_ON_OFF, 6) + ch_5 = channel(zha_const.CHANNEL_LEVEL, 8) + ch_5.async_configure = asynctest.CoroutineMock(side_effect=asyncio.TimeoutError) + ch_5.async_initialize = asynctest.CoroutineMock(side_effect=asyncio.TimeoutError) + + channels = mock.MagicMock(spec_set=zha_channels.Channels) + type(channels).semaphore = mock.PropertyMock(return_value=asyncio.Semaphore(3)) + ep_channels = zha_channels.ChannelPool(channels, mock.sentinel.ep) + + claimed = {ch_1.id: ch_1, ch_2.id: ch_2, ch_3.id: ch_3} + relay = {ch_4.id: ch_4, ch_5.id: ch_5} + + with mock.patch.dict(ep_channels.claimed_channels, claimed, clear=True): + with mock.patch.dict(ep_channels.relay_channels, relay, clear=True): + await ep_channels.async_configure() + await ep_channels.async_initialize(mock.sentinel.from_cache) + + for ch in [*claimed.values(), *relay.values()]: + assert ch.async_initialize.call_count == 1 + assert ch.async_initialize.await_count == 1 + assert ch.async_initialize.call_args[0][0] is mock.sentinel.from_cache + assert ch.async_configure.call_count == 1 + assert ch.async_configure.await_count == 1 + + assert ch_3.warning.call_count == 2 + assert ch_5.warning.call_count == 2 diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index e5883605e34..4fbabf4485a 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -45,7 +45,7 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device): return 100 with patch( - "homeassistant.components.zha.core.channels.ZigbeeChannel.get_attribute_value", + "homeassistant.components.zha.core.channels.base.ZigbeeChannel.get_attribute_value", new=MagicMock(side_effect=get_chan_attr), ) as get_attr_mock: # load up cover domain diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 8866e6cff55..c779dda6cf8 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -11,7 +11,6 @@ from homeassistant.components.device_automation import ( _async_get_device_automations as async_get_device_automations, ) from homeassistant.components.zha import DOMAIN -from homeassistant.components.zha.core.const import CHANNEL_EVENT_RELAY from homeassistant.helpers.device_registry import async_get_registry from homeassistant.setup import async_setup_component @@ -104,8 +103,8 @@ async def test_action(hass, device_ias): await hass.async_block_till_done() calls = async_mock_service(hass, DOMAIN, "warning_device_warn") - channel = {ch.name: ch for ch in zha_device.all_channels}[CHANNEL_EVENT_RELAY] - channel.zha_send_event(channel.cluster, COMMAND_SINGLE, []) + channel = zha_device.channels.pools[0].relay_channels["1:0x0006"] + channel.zha_send_event(COMMAND_SINGLE, []) await hass.async_block_till_done() assert len(calls) == 1 diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 4bb7567d1e6..9b69ba06e4f 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -3,7 +3,6 @@ import pytest import zigpy.zcl.clusters.general as general import homeassistant.components.automation as automation -from homeassistant.components.zha.core.const import CHANNEL_EVENT_RELAY from homeassistant.helpers.device_registry import async_get_registry from homeassistant.setup import async_setup_component @@ -173,8 +172,8 @@ async def test_if_fires_on_event(hass, mock_devices, calls): await hass.async_block_till_done() - channel = {ch.name: ch for ch in zha_device.all_channels}[CHANNEL_EVENT_RELAY] - channel.zha_send_event(channel.cluster, COMMAND_SINGLE, []) + channel = zha_device.channels.pools[0].relay_channels["1:0x0006"] + channel.zha_send_event(COMMAND_SINGLE, []) await hass.async_block_till_done() assert len(calls) == 1 diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index a194453bd65..c8f2eb0dd7c 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -4,10 +4,24 @@ import re from unittest import mock import pytest +import zigpy.quirks +import zigpy.zcl.clusters.closures +import zigpy.zcl.clusters.general +import zigpy.zcl.clusters.security +import homeassistant.components.zha.binary_sensor +import homeassistant.components.zha.core.channels as zha_channels +import homeassistant.components.zha.core.channels.base as base_channels import homeassistant.components.zha.core.const as zha_const import homeassistant.components.zha.core.discovery as disc -import homeassistant.components.zha.core.gateway as core_zha_gw +import homeassistant.components.zha.core.registries as zha_regs +import homeassistant.components.zha.cover +import homeassistant.components.zha.device_tracker +import homeassistant.components.zha.fan +import homeassistant.components.zha.light +import homeassistant.components.zha.lock +import homeassistant.components.zha.sensor +import homeassistant.components.zha.switch import homeassistant.helpers.entity_registry from .common import get_zha_gateway @@ -16,12 +30,34 @@ from .zha_devices_list import DEVICES NO_TAIL_ID = re.compile("_\\d$") +@pytest.fixture +def channels_mock(zha_device_mock): + """Channels mock factory.""" + + def _mock( + endpoints, + ieee="00:11:22:33:44:55:66:77", + manufacturer="mock manufacturer", + model="mock model", + node_desc=b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00", + ): + zha_dev = zha_device_mock(endpoints, ieee, manufacturer, model, node_desc) + channels = zha_channels.Channels.new(zha_dev) + return channels + + return _mock + + @pytest.mark.parametrize("device", DEVICES) async def test_devices( device, hass, zigpy_device_mock, monkeypatch, zha_device_joined_restored ): """Test device discovery.""" + entity_registry = await homeassistant.helpers.entity_registry.async_get_registry( + hass + ) + zigpy_device = zigpy_device_mock( device["endpoints"], "00:11:22:33:44:55:66:77", @@ -30,45 +66,298 @@ async def test_devices( node_descriptor=device["node_descriptor"], ) - _dispatch = mock.MagicMock(wraps=disc.async_dispatch_discovery_info) - monkeypatch.setattr(core_zha_gw, "async_dispatch_discovery_info", _dispatch) - entity_registry = await homeassistant.helpers.entity_registry.async_get_registry( - hass + orig_new_entity = zha_channels.ChannelPool.async_new_entity + _dispatch = mock.MagicMock(wraps=orig_new_entity) + try: + zha_channels.ChannelPool.async_new_entity = lambda *a, **kw: _dispatch(*a, **kw) + zha_dev = await zha_device_joined_restored(zigpy_device) + await hass.async_block_till_done() + finally: + zha_channels.ChannelPool.async_new_entity = orig_new_entity + + entity_ids = hass.states.async_entity_ids() + await hass.async_block_till_done() + zha_entity_ids = { + ent for ent in entity_ids if ent.split(".")[0] in zha_const.COMPONENTS + } + + event_channels = { + ch.id for pool in zha_dev.channels.pools for ch in pool.relay_channels.values() + } + + entity_map = device["entity_map"] + assert zha_entity_ids == set( + [ + e["entity_id"] + for e in entity_map.values() + if not e.get("default_match", False) + ] ) + assert event_channels == set(device["event_channels"]) + + for call in _dispatch.call_args_list: + _, component, entity_cls, unique_id, channels = call[0] + key = (component, unique_id) + entity_id = entity_registry.async_get_entity_id(component, "zha", unique_id) + + assert key in entity_map + assert entity_id is not None + no_tail_id = NO_TAIL_ID.sub("", entity_map[key]["entity_id"]) + assert entity_id.startswith(no_tail_id) + assert set([ch.name for ch in channels]) == set(entity_map[key]["channels"]) + assert entity_cls.__name__ == entity_map[key]["entity_class"] + + +@mock.patch( + "homeassistant.components.zha.core.discovery.ProbeEndpoint.discover_by_device_type" +) +@mock.patch( + "homeassistant.components.zha.core.discovery.ProbeEndpoint.discover_by_cluster_id" +) +def test_discover_entities(m1, m2): + """Test discover endpoint class method.""" + ep_channels = mock.MagicMock() + disc.PROBE.discover_entities(ep_channels) + assert m1.call_count == 1 + assert m1.call_args[0][0] is ep_channels + assert m2.call_count == 1 + assert m2.call_args[0][0] is ep_channels + + +@pytest.mark.parametrize( + "device_type, component, hit", + [ + (0x0100, zha_const.LIGHT, True), + (0x0108, zha_const.SWITCH, True), + (0x0051, zha_const.SWITCH, True), + (0xFFFF, None, False), + ], +) +def test_discover_by_device_type(device_type, component, hit): + """Test entity discovery by device type.""" + + ep_channels = mock.MagicMock(spec_set=zha_channels.ChannelPool) + ep_mock = mock.PropertyMock() + ep_mock.return_value.profile_id = 0x0104 + ep_mock.return_value.device_type = device_type + type(ep_channels).endpoint = ep_mock + + get_entity_mock = mock.MagicMock( + return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed) + ) + with mock.patch( + "homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity", + get_entity_mock, + ): + disc.PROBE.discover_by_device_type(ep_channels) + if hit: + assert get_entity_mock.call_count == 1 + assert ep_channels.claim_channels.call_count == 1 + assert ep_channels.claim_channels.call_args[0][0] is mock.sentinel.claimed + assert ep_channels.async_new_entity.call_count == 1 + assert ep_channels.async_new_entity.call_args[0][0] == component + assert ep_channels.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls + + +def test_discover_by_device_type_override(): + """Test entity discovery by device type overriding.""" + + ep_channels = mock.MagicMock(spec_set=zha_channels.ChannelPool) + ep_mock = mock.PropertyMock() + ep_mock.return_value.profile_id = 0x0104 + ep_mock.return_value.device_type = 0x0100 + type(ep_channels).endpoint = ep_mock + + overrides = {ep_channels.unique_id: {"type": zha_const.SWITCH}} + get_entity_mock = mock.MagicMock( + return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed) + ) + with mock.patch( + "homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity", + get_entity_mock, + ): + with mock.patch.dict(disc.PROBE._device_configs, overrides, clear=True): + disc.PROBE.discover_by_device_type(ep_channels) + assert get_entity_mock.call_count == 1 + assert ep_channels.claim_channels.call_count == 1 + assert ep_channels.claim_channels.call_args[0][0] is mock.sentinel.claimed + assert ep_channels.async_new_entity.call_count == 1 + assert ep_channels.async_new_entity.call_args[0][0] == zha_const.SWITCH + assert ( + ep_channels.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls + ) + + +def test_discover_probe_single_cluster(): + """Test entity discovery by single cluster.""" + + ep_channels = mock.MagicMock(spec_set=zha_channels.ChannelPool) + ep_mock = mock.PropertyMock() + ep_mock.return_value.profile_id = 0x0104 + ep_mock.return_value.device_type = 0x0100 + type(ep_channels).endpoint = ep_mock + + get_entity_mock = mock.MagicMock( + return_value=(mock.sentinel.entity_cls, mock.sentinel.claimed) + ) + channel_mock = mock.MagicMock(spec_set=base_channels.ZigbeeChannel) + with mock.patch( + "homeassistant.components.zha.core.registries.ZHA_ENTITIES.get_entity", + get_entity_mock, + ): + disc.PROBE.probe_single_cluster(zha_const.SWITCH, channel_mock, ep_channels) + + assert get_entity_mock.call_count == 1 + assert ep_channels.claim_channels.call_count == 1 + assert ep_channels.claim_channels.call_args[0][0] is mock.sentinel.claimed + assert ep_channels.async_new_entity.call_count == 1 + assert ep_channels.async_new_entity.call_args[0][0] == zha_const.SWITCH + assert ep_channels.async_new_entity.call_args[0][1] == mock.sentinel.entity_cls + assert ep_channels.async_new_entity.call_args[0][3] == mock.sentinel.claimed + + +@pytest.mark.parametrize("device_info", DEVICES) +async def test_discover_endpoint(device_info, channels_mock, hass): + """Test device discovery.""" with mock.patch( - "homeassistant.components.zha.core.discovery._async_create_cluster_channel", - wraps=disc._async_create_cluster_channel, + "homeassistant.components.zha.core.channels.Channels.async_new_entity" + ) as new_ent: + channels = channels_mock( + device_info["endpoints"], + manufacturer=device_info["manufacturer"], + model=device_info["model"], + node_desc=device_info["node_descriptor"], + ) + + assert device_info["event_channels"] == sorted( + [ch.id for pool in channels.pools for ch in pool.relay_channels.values()] + ) + assert new_ent.call_count == len( + [ + device_info + for device_info in device_info["entity_map"].values() + if not device_info.get("default_match", False) + ] + ) + + for call_args in new_ent.call_args_list: + comp, ent_cls, unique_id, channels = call_args[0] + map_id = (comp, unique_id) + assert map_id in device_info["entity_map"] + entity_info = device_info["entity_map"][map_id] + assert set([ch.name for ch in channels]) == set(entity_info["channels"]) + assert ent_cls.__name__ == entity_info["entity_class"] + + +def _ch_mock(cluster): + """Return mock of a channel with a cluster.""" + channel = mock.MagicMock() + type(channel).cluster = mock.PropertyMock(return_value=cluster(mock.MagicMock())) + return channel + + +@mock.patch( + "homeassistant.components.zha.core.discovery.ProbeEndpoint" + ".handle_on_off_output_cluster_exception", + new=mock.MagicMock(), +) +@mock.patch( + "homeassistant.components.zha.core.discovery.ProbeEndpoint.probe_single_cluster" +) +def _test_single_input_cluster_device_class(probe_mock): + """Test SINGLE_INPUT_CLUSTER_DEVICE_CLASS matching by cluster id or class.""" + + door_ch = _ch_mock(zigpy.zcl.clusters.closures.DoorLock) + cover_ch = _ch_mock(zigpy.zcl.clusters.closures.WindowCovering) + multistate_ch = _ch_mock(zigpy.zcl.clusters.general.MultistateInput) + + class QuirkedIAS(zigpy.quirks.CustomCluster, zigpy.zcl.clusters.security.IasZone): + pass + + ias_ch = _ch_mock(QuirkedIAS) + + class _Analog(zigpy.quirks.CustomCluster, zigpy.zcl.clusters.general.AnalogInput): + pass + + analog_ch = _ch_mock(_Analog) + + ch_pool = mock.MagicMock(spec_set=zha_channels.ChannelPool) + ch_pool.unclaimed_channels.return_value = [ + door_ch, + cover_ch, + multistate_ch, + ias_ch, + analog_ch, + ] + + disc.ProbeEndpoint().discover_by_cluster_id(ch_pool) + assert probe_mock.call_count == len(ch_pool.unclaimed_channels()) + probes = ( + (zha_const.LOCK, door_ch), + (zha_const.COVER, cover_ch), + (zha_const.SENSOR, multistate_ch), + (zha_const.BINARY_SENSOR, ias_ch), + (zha_const.SENSOR, analog_ch), + ) + for call, details in zip(probe_mock.call_args_list, probes): + component, ch = details + assert call[0][0] == component + assert call[0][1] == ch + + +def test_single_input_cluster_device_class(): + """Test SINGLE_INPUT_CLUSTER_DEVICE_CLASS matching by cluster id or class.""" + _test_single_input_cluster_device_class() + + +def test_single_input_cluster_device_class_by_cluster_class(): + """Test SINGLE_INPUT_CLUSTER_DEVICE_CLASS matching by cluster id or class.""" + mock_reg = { + zigpy.zcl.clusters.closures.DoorLock.cluster_id: zha_const.LOCK, + zigpy.zcl.clusters.closures.WindowCovering.cluster_id: zha_const.COVER, + zigpy.zcl.clusters.general.AnalogInput: zha_const.SENSOR, + zigpy.zcl.clusters.general.MultistateInput: zha_const.SENSOR, + zigpy.zcl.clusters.security.IasZone: zha_const.BINARY_SENSOR, + } + + with mock.patch.dict( + zha_regs.SINGLE_INPUT_CLUSTER_DEVICE_CLASS, mock_reg, clear=True ): - await zha_device_joined_restored(zigpy_device) - await hass.async_block_till_done() + _test_single_input_cluster_device_class() - entity_ids = hass.states.async_entity_ids() - await hass.async_block_till_done() - zha_entities = { - ent for ent in entity_ids if ent.split(".")[0] in zha_const.COMPONENTS - } - zha_gateway = get_zha_gateway(hass) - zha_dev = zha_gateway.get_device(zigpy_device.ieee) - event_channels = { # pylint: disable=protected-access - ch.id for ch in zha_dev._relay_channels.values() - } +@pytest.mark.parametrize( + "override, entity_id", + [ + (None, "light.manufacturer_model_77665544_level_light_color_on_off"), + ("switch", "switch.manufacturer_model_77665544_on_off"), + ], +) +async def test_device_override(hass, zigpy_device_mock, setup_zha, override, entity_id): + """Test device discovery override.""" - assert zha_entities == set(device["entities"]) - assert event_channels == set(device["event_channels"]) + zigpy_device = zigpy_device_mock( + { + 1: { + "device_type": 258, + "endpoint_id": 1, + "in_clusters": [0, 3, 4, 5, 6, 8, 768, 2821, 64513], + "out_clusters": [25], + "profile_id": 260, + } + }, + "00:11:22:33:44:55:66:77", + "manufacturer", + "model", + ) - entity_map = device["entity_map"] - for calls in _dispatch.call_args_list: - discovery_info = calls[0][2] - unique_id = discovery_info["unique_id"] - channels = discovery_info["channels"] - component = discovery_info["component"] - key = (component, unique_id) - entity_id = entity_registry.async_get_entity_id(component, "zha", unique_id) + if override is not None: + override = {"device_config": {"00:11:22:33:44:55:66:77-1": {"type": override}}} - assert key in entity_map - assert entity_id is not None - no_tail_id = NO_TAIL_ID.sub("", entity_map[key]["entity_id"]) - assert entity_id.startswith(no_tail_id) - assert set([ch.name for ch in channels]) == set(entity_map[key]["channels"]) + await setup_zha(override) + assert hass.states.get(entity_id) is None + zha_gateway = get_zha_gateway(hass) + await zha_gateway.async_device_initialized(zigpy_device) + await hass.async_block_till_done() + assert hass.states.get(entity_id) is not None diff --git a/tests/components/zha/test_registries.py b/tests/components/zha/test_registries.py index 383b61e6c66..fc41a409518 100644 --- a/tests/components/zha/test_registries.py +++ b/tests/components/zha/test_registries.py @@ -55,8 +55,20 @@ def channels(channel): # manufacturer matching (registries.MatchRule(manufacturers="no match"), False), (registries.MatchRule(manufacturers=MANUFACTURER), True), + ( + registries.MatchRule(manufacturers="no match", aux_channels="aux_channel"), + False, + ), + ( + registries.MatchRule( + manufacturers=MANUFACTURER, aux_channels="aux_channel" + ), + True, + ), (registries.MatchRule(models=MODEL), True), (registries.MatchRule(models="no match"), False), + (registries.MatchRule(models=MODEL, aux_channels="aux_channel"), True), + (registries.MatchRule(models="no match", aux_channels="aux_channel"), False), # match everything ( registries.MatchRule( @@ -113,10 +125,9 @@ def channels(channel): ), ], ) -def test_registry_matching(rule, matched, zha_device, channels): +def test_registry_matching(rule, matched, channels): """Test strict rule matching.""" - reg = registries.ZHAEntityRegistry() - assert reg._strict_matched(zha_device, channels, rule) is matched + assert rule.strict_matched(MANUFACTURER, MODEL, channels) is matched @pytest.mark.parametrize( @@ -197,7 +208,49 @@ def test_registry_matching(rule, matched, zha_device, channels): ), ], ) -def test_registry_loose_matching(rule, matched, zha_device, channels): +def test_registry_loose_matching(rule, matched, channels): """Test loose rule matching.""" - reg = registries.ZHAEntityRegistry() - assert reg._loose_matched(zha_device, channels, rule) is matched + assert rule.loose_matched(MANUFACTURER, MODEL, channels) is matched + + +def test_match_rule_claim_channels_color(channel): + """Test channel claiming.""" + ch_color = channel("color", 0x300) + ch_level = channel("level", 8) + ch_onoff = channel("on_off", 6) + + rule = registries.MatchRule(channel_names="on_off", aux_channels={"color", "level"}) + claimed = rule.claim_channels([ch_color, ch_level, ch_onoff]) + assert {"color", "level", "on_off"} == set([ch.name for ch in claimed]) + + +@pytest.mark.parametrize( + "rule, match", + [ + (registries.MatchRule(channel_names={"level"}), {"level"}), + (registries.MatchRule(channel_names={"level", "no match"}), {"level"}), + (registries.MatchRule(channel_names={"on_off"}), {"on_off"}), + (registries.MatchRule(generic_ids="channel_0x0000"), {"basic"}), + ( + registries.MatchRule(channel_names="level", generic_ids="channel_0x0000"), + {"basic", "level"}, + ), + (registries.MatchRule(channel_names={"level", "power"}), {"level", "power"}), + ( + registries.MatchRule( + channel_names={"level", "on_off"}, aux_channels={"basic", "power"} + ), + {"basic", "level", "on_off", "power"}, + ), + (registries.MatchRule(channel_names={"color"}), set()), + ], +) +def test_match_rule_claim_channels(rule, match, channel, channels): + """Test channel claiming.""" + ch_basic = channel("basic", 0) + channels.append(ch_basic) + ch_power = channel("power", 1) + channels.append(ch_power) + + claimed = rule.claim_channels(channels) + assert match == set([ch.name for ch in claimed]) diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index a8c83406435..a3dc4f1d780 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -523,7 +523,7 @@ DEVICES = [ "channels": ["ias_zone"], "entity_class": "IASZone", "entity_id": "binary_sensor.heiman_co_v16_77665544_ias_zone", - }, + } }, "event_channels": [], "manufacturer": "Heiman", @@ -547,7 +547,7 @@ DEVICES = [ "channels": ["ias_zone"], "entity_class": "IASZone", "entity_id": "binary_sensor.heiman_warningdevice_77665544_ias_zone", - }, + } }, "event_channels": [], "manufacturer": "Heiman", @@ -1036,7 +1036,6 @@ DEVICES = [ } }, "entities": [ - "binary_sensor.keen_home_inc_sv02_610_mp_1_3_77665544_manufacturer_specific", "light.keen_home_inc_sv02_610_mp_1_3_77665544_level_on_off", "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_power", "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure", @@ -1063,12 +1062,6 @@ DEVICES = [ "entity_class": "Pressure", "entity_id": "sensor.keen_home_inc_sv02_610_mp_1_3_77665544_pressure", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-64514"): { - "channels": ["manufacturer_specific"], - "entity_class": "BinarySensor", - "entity_id": "binary_sensor.keen_home_inc_sv02_610_mp_1_3_77665544_manufacturer_specific", - "default_match": True, - }, }, "event_channels": [], "manufacturer": "Keen Home Inc", @@ -1101,7 +1094,6 @@ DEVICES = [ } }, "entities": [ - "binary_sensor.keen_home_inc_sv02_612_mp_1_2_77665544_manufacturer_specific", "light.keen_home_inc_sv02_612_mp_1_2_77665544_level_on_off", "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_power", "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure", @@ -1128,12 +1120,6 @@ DEVICES = [ "entity_class": "Pressure", "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_2_77665544_pressure", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-64514"): { - "channels": ["manufacturer_specific"], - "entity_class": "BinarySensor", - "entity_id": "binary_sensor.keen_home_inc_sv02_612_mp_1_2_77665544_manufacturer_specific", - "default_match": True, - }, }, "event_channels": [], "manufacturer": "Keen Home Inc", @@ -1166,7 +1152,6 @@ DEVICES = [ } }, "entities": [ - "binary_sensor.keen_home_inc_sv02_612_mp_1_3_77665544_manufacturer_specific", "light.keen_home_inc_sv02_612_mp_1_3_77665544_level_on_off", "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_power", "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure", @@ -1193,12 +1178,6 @@ DEVICES = [ "entity_class": "Pressure", "entity_id": "sensor.keen_home_inc_sv02_612_mp_1_3_77665544_pressure", }, - ("binary_sensor", "00:11:22:33:44:55:66:77-1-64514"): { - "channels": ["manufacturer_specific"], - "entity_class": "BinarySensor", - "entity_id": "binary_sensor.keen_home_inc_sv02_612_mp_1_3_77665544_manufacturer_specific", - "default_match": True, - }, }, "event_channels": [], "manufacturer": "Keen Home Inc", @@ -1784,13 +1763,21 @@ DEVICES = [ "profile_id": 260, } }, - "entities": ["light.lumi_lumi_router_77665544_on_off_on_off"], + "entities": [ + "binary_sensor.lumi_lumi_router_77665544_on_off", + "light.lumi_lumi_router_77665544_on_off", + ], "entity_map": { + ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { + "channels": ["on_off", "on_off"], + "entity_class": "Opening", + "entity_id": "binary_sensor.lumi_lumi_router_77665544_on_off", + }, ("light", "00:11:22:33:44:55:66:77-8"): { "channels": ["on_off", "on_off"], "entity_class": "Light", - "entity_id": "light.lumi_lumi_router_77665544_on_off_on_off", - } + "entity_id": "light.lumi_lumi_router_77665544_on_off", + }, }, "event_channels": ["8:0x0006"], "manufacturer": "LUMI", @@ -1808,13 +1795,21 @@ DEVICES = [ "profile_id": 260, } }, - "entities": ["light.lumi_lumi_router_77665544_on_off_on_off"], + "entities": [ + "binary_sensor.lumi_lumi_router_77665544_on_off", + "light.lumi_lumi_router_77665544_on_off", + ], "entity_map": { + ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { + "channels": ["on_off", "on_off"], + "entity_class": "Opening", + "entity_id": "binary_sensor.lumi_lumi_router_77665544_on_off", + }, ("light", "00:11:22:33:44:55:66:77-8"): { "channels": ["on_off", "on_off"], "entity_class": "Light", - "entity_id": "light.lumi_lumi_router_77665544_on_off_on_off", - } + "entity_id": "light.lumi_lumi_router_77665544_on_off", + }, }, "event_channels": ["8:0x0006"], "manufacturer": "LUMI", @@ -1832,13 +1827,21 @@ DEVICES = [ "profile_id": 260, } }, - "entities": ["light.lumi_lumi_router_77665544_on_off_on_off"], + "entities": [ + "binary_sensor.lumi_lumi_router_77665544_on_off", + "light.lumi_lumi_router_77665544_on_off", + ], "entity_map": { + ("binary_sensor", "00:11:22:33:44:55:66:77-8-6"): { + "channels": ["on_off", "on_off"], + "entity_class": "Opening", + "entity_id": "binary_sensor.lumi_lumi_router_77665544_on_off", + }, ("light", "00:11:22:33:44:55:66:77-8"): { "channels": ["on_off", "on_off"], "entity_class": "Light", - "entity_id": "light.lumi_lumi_router_77665544_on_off_on_off", - } + "entity_id": "light.lumi_lumi_router_77665544_on_off", + }, }, "event_channels": ["8:0x0006"], "manufacturer": "LUMI", @@ -1862,7 +1865,7 @@ DEVICES = [ "channels": ["illuminance"], "entity_class": "Illuminance", "entity_id": "sensor.lumi_lumi_sen_ill_mgl01_77665544_illuminance", - }, + } }, "event_channels": [], "manufacturer": "LUMI", From b41cbe9885e19832384188b657f166dd52c6c75a Mon Sep 17 00:00:00 2001 From: springstan <46536646+springstan@users.noreply.github.com> Date: Sat, 22 Feb 2020 01:10:02 +0100 Subject: [PATCH 040/416] Add www. to all documentation links (#32063) * Add www. to all documentation links * Fix broken redirect uri test --- homeassistant/components/auth/indieauth.py | 4 ++-- homeassistant/components/cast/media_player.py | 2 +- homeassistant/components/honeywell/climate.py | 2 +- homeassistant/components/kodi/notify.py | 2 +- homeassistant/components/nest/binary_sensor.py | 2 +- homeassistant/components/nest/sensor.py | 4 ++-- homeassistant/components/pandora/media_player.py | 4 ++-- homeassistant/components/sun/__init__.py | 2 +- homeassistant/config.py | 2 +- setup.py | 2 +- tests/components/auth/test_indieauth.py | 3 ++- 11 files changed, 15 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index 3266ae65d7a..a2d015c279b 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -33,8 +33,8 @@ async def verify_redirect_uri(hass, client_id, redirect_uri): # Whitelist the iOS and Android callbacks so that people can link apps # without being connected to the internet. if redirect_uri == "homeassistant://auth-callback" and client_id in ( - "https://home-assistant.io/android", - "https://home-assistant.io/iOS", + "https://www.home-assistant.io/android", + "https://www.home-assistant.io/iOS", ): return True diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 03174134502..4e259038f14 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -66,7 +66,7 @@ from .helpers import ( _LOGGER = logging.getLogger(__name__) CONF_IGNORE_CEC = "ignore_cec" -CAST_SPLASH = "https://home-assistant.io/images/cast/splash.png" +CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png" SUPPORT_CAST = ( SUPPORT_PAUSE diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index f8537bfe96a..ece8257a713 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -141,7 +141,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.warning( "The honeywell component has been deprecated for EU (i.e. non-US) " "systems. For EU-based systems, use the evohome component, " - "see: https://home-assistant.io/integrations/evohome" + "see: https://www.home-assistant.io/integrations/evohome" ) diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py index fae5c856d9d..aa3fe0610a7 100644 --- a/homeassistant/components/kodi/notify.py +++ b/homeassistant/components/kodi/notify.py @@ -58,7 +58,7 @@ async def async_get_service(hass, config, discovery_info=None): _LOGGER.warning( "Kodi host name should no longer contain http:// See updated " "definitions here: " - "https://home-assistant.io/integrations/media_player.kodi/" + "https://www.home-assistant.io/integrations/media_player.kodi/" ) http_protocol = "https" if encryption else "http" diff --git a/homeassistant/components/nest/binary_sensor.py b/homeassistant/components/nest/binary_sensor.py index 1ee500c793c..a029fcfe7d6 100644 --- a/homeassistant/components/nest/binary_sensor.py +++ b/homeassistant/components/nest/binary_sensor.py @@ -71,7 +71,7 @@ async def async_setup_entry(hass, entry, async_add_entities): wstr = ( variable + " is no a longer supported " "monitored_conditions. See " - "https://home-assistant.io/integrations/binary_sensor.nest/ " + "https://www.home-assistant.io/integrations/binary_sensor.nest/ " "for valid options." ) _LOGGER.error(wstr) diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py index f8ae56f838c..d52df4c6586 100644 --- a/homeassistant/components/nest/sensor.py +++ b/homeassistant/components/nest/sensor.py @@ -90,14 +90,14 @@ async def async_setup_entry(hass, entry, async_add_entities): if variable in DEPRECATED_WEATHER_VARS: wstr = ( "Nest no longer provides weather data like %s. See " - "https://home-assistant.io/integrations/#weather " + "https://www.home-assistant.io/integrations/#weather " "for a list of other weather integrations to use." % variable ) else: wstr = ( variable + " is no a longer supported " "monitored_conditions. See " - "https://home-assistant.io/integrations/" + "https://www.home-assistant.io/integrations/" "binary_sensor.nest/ for valid options." ) _LOGGER.error(wstr) diff --git a/homeassistant/components/pandora/media_player.py b/homeassistant/components/pandora/media_player.py index 4b3c25862a1..322765ac082 100644 --- a/homeassistant/components/pandora/media_player.py +++ b/homeassistant/components/pandora/media_player.py @@ -120,7 +120,7 @@ class PandoraMediaPlayer(MediaPlayerDevice): _LOGGER.warning( "The pianobar client is not configured to log in. " "Please create a configuration file for it as described at " - "https://home-assistant.io/integrations/pandora/" + "https://www.home-assistant.io/integrations/pandora/" ) # pass through the email/password prompts to quit cleanly self._pianobar.sendcontrol("m") @@ -384,6 +384,6 @@ def _pianobar_exists(): _LOGGER.warning( "The Pandora integration depends on the Pianobar client, which " "cannot be found. Please install using instructions at " - "https://home-assistant.io/integrations/media_player.pandora/" + "https://www.home-assistant.io/integrations/media_player.pandora/" ) return False diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index 213952bead3..9529a9c0cad 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -77,7 +77,7 @@ async def async_setup(hass, config): if config.get(CONF_ELEVATION) is not None: _LOGGER.warning( "Elevation is now configured in Home Assistant core. " - "See https://home-assistant.io/docs/configuration/basic/" + "See https://www.home-assistant.io/docs/configuration/basic/" ) Sun(hass) return True diff --git a/homeassistant/config.py b/homeassistant/config.py index 6ff571f0d6b..abb8511cab0 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -89,7 +89,7 @@ scene: !include {SCENE_CONFIG_PATH} """ DEFAULT_SECRETS = """ # Use this file to store secrets like usernames and passwords. -# Learn more at https://home-assistant.io/docs/configuration/secrets/ +# Learn more at https://www.home-assistant.io/docs/configuration/secrets/ some_password: welcome """ TTS_PRE_92 = """ diff --git a/setup.py b/setup.py index 3c7909428be..eb360c93cf8 100755 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ PROJECT_PACKAGE_NAME = "homeassistant" PROJECT_LICENSE = "Apache License 2.0" PROJECT_AUTHOR = "The Home Assistant Authors" PROJECT_COPYRIGHT = " 2013-{}, {}".format(dt.now().year, PROJECT_AUTHOR) -PROJECT_URL = "https://home-assistant.io/" +PROJECT_URL = "https://www.home-assistant.io/" PROJECT_EMAIL = "hello@home-assistant.io" PROJECT_GITHUB_USERNAME = "home-assistant" diff --git a/tests/components/auth/test_indieauth.py b/tests/components/auth/test_indieauth.py index ce8edae1466..b359144ab97 100644 --- a/tests/components/auth/test_indieauth.py +++ b/tests/components/auth/test_indieauth.py @@ -169,7 +169,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://www.home-assistant.io/android", "https://www.home-assistant.io/iOS"], ) async def test_verify_redirect_uri_android_ios(client_id): """Test that we verify redirect uri correctly for Android/iOS.""" From dc15b9c28e3f22c8d66199136e2ed9671b978e60 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Sat, 22 Feb 2020 00:31:49 +0000 Subject: [PATCH 041/416] [ci skip] Translation update --- .../components/deconz/.translations/ko.json | 3 ++- .../components/deconz/.translations/ru.json | 8 +++---- .../components/demo/.translations/ko.json | 17 ++++++++++++++ .../components/gios/.translations/ru.json | 2 +- .../konnected/.translations/ru.json | 14 ++++++------ .../components/local_ip/.translations/ru.json | 2 +- .../luftdaten/.translations/ru.json | 6 ++--- .../components/mqtt/.translations/ko.json | 22 +++++++++++++++++++ .../components/plex/.translations/ko.json | 2 ++ .../components/solarlog/.translations/ru.json | 2 +- .../components/unifi/.translations/ko.json | 9 ++++++-- .../components/unifi/.translations/ru.json | 4 ++-- .../components/upnp/.translations/ru.json | 4 ++-- .../components/vizio/.translations/no.json | 2 +- .../vizio/.translations/zh-Hant.json | 2 +- 15 files changed, 73 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/deconz/.translations/ko.json b/homeassistant/components/deconz/.translations/ko.json index d526d706a8b..1b72545bc09 100644 --- a/homeassistant/components/deconz/.translations/ko.json +++ b/homeassistant/components/deconz/.translations/ko.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "deCONZ CLIP \uc13c\uc11c \ud5c8\uc6a9", "allow_deconz_groups": "deCONZ \ub77c\uc774\ud2b8 \uadf8\ub8f9 \ud5c8\uc6a9" }, - "description": "deCONZ \uae30\uae30 \uc720\ud615\uc758 \ud45c\uc2dc \uc5ec\ubd80 \uad6c\uc131" + "description": "deCONZ \uae30\uae30 \uc720\ud615\uc758 \ud45c\uc2dc \uc5ec\ubd80 \uad6c\uc131", + "title": "deCONZ \uc635\uc158" } } } diff --git a/homeassistant/components/deconz/.translations/ru.json b/homeassistant/components/deconz/.translations/ru.json index 3024915f5b1..054c85f595a 100644 --- a/homeassistant/components/deconz/.translations/ru.json +++ b/homeassistant/components/deconz/.translations/ru.json @@ -15,7 +15,7 @@ "step": { "hassio_confirm": { "data": { - "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u044b\u0445 \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432", + "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u044b\u0445 \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432", "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0433\u0440\u0443\u043f\u043f deCONZ" }, "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a deCONZ (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io \"{addon}\")?", @@ -34,7 +34,7 @@ }, "options": { "data": { - "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u044b\u0445 \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432", + "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u044b\u0445 \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432", "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0433\u0440\u0443\u043f\u043f deCONZ" }, "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 deCONZ" @@ -98,14 +98,14 @@ "step": { "async_step_deconz_devices": { "data": { - "allow_clip_sensor": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0434\u0430\u0442\u0447\u0438\u043a\u0438 deCONZ CLIP", + "allow_clip_sensor": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0441\u0435\u043d\u0441\u043e\u0440\u044b deCONZ CLIP", "allow_deconz_groups": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0441\u0432\u0435\u0449\u0435\u043d\u0438\u044f deCONZ" }, "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u0442\u0438\u043f\u043e\u0432 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 deCONZ" }, "deconz_devices": { "data": { - "allow_clip_sensor": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0434\u0430\u0442\u0447\u0438\u043a\u0438 deCONZ CLIP", + "allow_clip_sensor": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0441\u0435\u043d\u0441\u043e\u0440\u044b deCONZ CLIP", "allow_deconz_groups": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0441\u0432\u0435\u0449\u0435\u043d\u0438\u044f deCONZ" }, "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u0442\u0438\u043f\u043e\u0432 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 deCONZ", diff --git a/homeassistant/components/demo/.translations/ko.json b/homeassistant/components/demo/.translations/ko.json index d20943c7b36..efe69b575fb 100644 --- a/homeassistant/components/demo/.translations/ko.json +++ b/homeassistant/components/demo/.translations/ko.json @@ -1,5 +1,22 @@ { "config": { "title": "\ub370\ubaa8" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "\ub17c\ub9ac \uc120\ud0dd", + "int": "\uc22b\uc790 \uc785\ub825" + } + }, + "options_2": { + "data": { + "multi": "\ub2e4\uc911 \uc120\ud0dd", + "select": "\uc635\uc158 \uc120\ud0dd", + "string": "\ubb38\uc790\uc5f4 \uac12" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/gios/.translations/ru.json b/homeassistant/components/gios/.translations/ru.json index 69ffff98517..0045b08cec8 100644 --- a/homeassistant/components/gios/.translations/ru.json +++ b/homeassistant/components/gios/.translations/ru.json @@ -5,7 +5,7 @@ }, "error": { "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 GIO\u015a.", - "invalid_sensors_data": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432 \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439 \u0441\u0442\u0430\u043d\u0446\u0438\u0438.", + "invalid_sensors_data": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432 \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439 \u0441\u0442\u0430\u043d\u0446\u0438\u0438.", "wrong_station_id": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 ID \u0438\u0437\u043c\u0435\u0440\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0439 \u0441\u0442\u0430\u043d\u0446\u0438\u0438." }, "step": { diff --git a/homeassistant/components/konnected/.translations/ru.json b/homeassistant/components/konnected/.translations/ru.json index 59d4a199576..25cb03b1578 100644 --- a/homeassistant/components/konnected/.translations/ru.json +++ b/homeassistant/components/konnected/.translations/ru.json @@ -34,19 +34,19 @@ "data": { "inverse": "\u0418\u043d\u0432\u0435\u0440\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u043e\u0442\u043a\u0440\u044b\u0442\u043e\u0435/\u0437\u0430\u043a\u0440\u044b\u0442\u043e\u0435 \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0435", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", - "type": "\u0422\u0438\u043f \u0431\u0438\u043d\u0430\u0440\u043d\u043e\u0433\u043e \u0434\u0430\u0442\u0447\u0438\u043a\u0430" + "type": "\u0422\u0438\u043f \u0431\u0438\u043d\u0430\u0440\u043d\u043e\u0433\u043e \u0441\u0435\u043d\u0441\u043e\u0440\u0430" }, - "description": "\u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0431\u0438\u043d\u0430\u0440\u043d\u043e\u0433\u043e \u0434\u0430\u0442\u0447\u0438\u043a\u0430, \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u0433\u043e \u043a {zone}.", - "title": "\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0431\u0438\u043d\u0430\u0440\u043d\u043e\u0433\u043e \u0434\u0430\u0442\u0447\u0438\u043a\u0430" + "description": "\u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0431\u0438\u043d\u0430\u0440\u043d\u043e\u0433\u043e \u0441\u0435\u043d\u0441\u043e\u0440\u0430, \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u0433\u043e \u043a {zone}.", + "title": "\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0431\u0438\u043d\u0430\u0440\u043d\u043e\u0433\u043e \u0441\u0435\u043d\u0441\u043e\u0440\u0430" }, "options_digital": { "data": { "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", "poll_interval": "\u0418\u043d\u0442\u0435\u0440\u0432\u0430\u043b \u043e\u043f\u0440\u043e\u0441\u0430 \u0432 \u043c\u0438\u043d\u0443\u0442\u0430\u0445 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)", - "type": "\u0422\u0438\u043f \u0434\u0430\u0442\u0447\u0438\u043a\u0430" + "type": "\u0422\u0438\u043f \u0441\u0435\u043d\u0441\u043e\u0440\u0430" }, - "description": "\u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0434\u0430\u0442\u0447\u0438\u043a\u0430, \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u0433\u043e \u043a {zone}.", - "title": "\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0434\u0430\u0442\u0447\u0438\u043a\u0430" + "description": "\u0423\u043a\u0430\u0436\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0441\u0435\u043d\u0441\u043e\u0440\u0430, \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u043e\u0433\u043e \u043a {zone}.", + "title": "\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0441\u0435\u043d\u0441\u043e\u0440\u0430" }, "options_io": { "data": { @@ -59,7 +59,7 @@ "7": "\u0417\u043e\u043d\u0430 7", "out": "\u0412\u042b\u0425\u041e\u0414" }, - "description": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e {model} \u0441 \u0430\u0434\u0440\u0435\u0441\u043e\u043c {host}. \u0412 \u0437\u0430\u0432\u0438\u0441\u0438\u043c\u043e\u0441\u0442\u0438 \u043e\u0442 \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u043e\u0439 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0432\u0445\u043e\u0434\u043e\u0432/\u0432\u044b\u0445\u043e\u0434\u043e\u0432, \u043a \u043f\u0430\u043d\u0435\u043b\u0438 \u043c\u043e\u0433\u0443\u0442 \u0431\u044b\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u044b \u0431\u0438\u043d\u0430\u0440\u043d\u044b\u0435 \u0434\u0430\u0442\u0447\u0438\u043a\u0438 (\u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f/\u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044f), \u0446\u0438\u0444\u0440\u043e\u0432\u044b\u0435 \u0434\u0430\u0442\u0447\u0438\u043a\u0438 (dht \u0438 ds18b20) \u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0435\u043c\u044b\u0435 \u0432\u044b\u0445\u043e\u0434\u044b. \u0411\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0431\u0443\u0434\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u043d\u0430 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0445 \u0448\u0430\u0433\u0430\u0445.", + "description": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e {model} \u0441 \u0430\u0434\u0440\u0435\u0441\u043e\u043c {host}. \u0412 \u0437\u0430\u0432\u0438\u0441\u0438\u043c\u043e\u0441\u0442\u0438 \u043e\u0442 \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u043e\u0439 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0432\u0445\u043e\u0434\u043e\u0432/\u0432\u044b\u0445\u043e\u0434\u043e\u0432, \u043a \u043f\u0430\u043d\u0435\u043b\u0438 \u043c\u043e\u0433\u0443\u0442 \u0431\u044b\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u044b \u0431\u0438\u043d\u0430\u0440\u043d\u044b\u0435 \u0441\u0435\u043d\u0441\u043e\u0440\u044b (\u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u043e\u0442\u043a\u0440\u044b\u0442\u0438\u044f/\u0437\u0430\u043a\u0440\u044b\u0442\u0438\u044f), \u0446\u0438\u0444\u0440\u043e\u0432\u044b\u0435 \u0441\u0435\u043d\u0441\u043e\u0440\u044b (dht \u0438 ds18b20) \u0438\u043b\u0438 \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0430\u0435\u043c\u044b\u0435 \u0432\u044b\u0445\u043e\u0434\u044b. \u0411\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u0430\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0431\u0443\u0434\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u043d\u0430 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0438\u0445 \u0448\u0430\u0433\u0430\u0445.", "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0432\u0445\u043e\u0434\u043e\u0432/\u0432\u044b\u0445\u043e\u0434\u043e\u0432" }, "options_io_ext": { diff --git a/homeassistant/components/local_ip/.translations/ru.json b/homeassistant/components/local_ip/.translations/ru.json index de92b9680f0..2cf8791e505 100644 --- a/homeassistant/components/local_ip/.translations/ru.json +++ b/homeassistant/components/local_ip/.translations/ru.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430 \u0441 \u0442\u0430\u043a\u0438\u043c \u0436\u0435 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043c \u0434\u0430\u0442\u0447\u0438\u043a\u0430." + "already_configured": "\u0418\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430 \u0441 \u0442\u0430\u043a\u0438\u043c \u0436\u0435 \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u0435\u043c." }, "step": { "user": { diff --git a/homeassistant/components/luftdaten/.translations/ru.json b/homeassistant/components/luftdaten/.translations/ru.json index 1a05137f82d..759fd926bdc 100644 --- a/homeassistant/components/luftdaten/.translations/ru.json +++ b/homeassistant/components/luftdaten/.translations/ru.json @@ -2,14 +2,14 @@ "config": { "error": { "communication_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a API Luftdaten.", - "invalid_sensor": "\u0414\u0430\u0442\u0447\u0438\u043a \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u043b\u0438 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d.", - "sensor_exists": "\u0414\u0430\u0442\u0447\u0438\u043a \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d." + "invalid_sensor": "\u0421\u0435\u043d\u0441\u043e\u0440 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u043b\u0438 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d.", + "sensor_exists": "\u0421\u0435\u043d\u0441\u043e\u0440 \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d." }, "step": { "user": { "data": { "show_on_map": "\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0430 \u043a\u0430\u0440\u0442\u0435", - "station_id": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0434\u0430\u0442\u0447\u0438\u043a\u0430 Luftdaten" + "station_id": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0441\u0435\u043d\u0441\u043e\u0440\u0430 Luftdaten" }, "title": "Luftdaten" } diff --git a/homeassistant/components/mqtt/.translations/ko.json b/homeassistant/components/mqtt/.translations/ko.json index 307a6aaadeb..8a0243013d9 100644 --- a/homeassistant/components/mqtt/.translations/ko.json +++ b/homeassistant/components/mqtt/.translations/ko.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "\uccab \ubc88\uc9f8 \ubc84\ud2bc", + "button_2": "\ub450 \ubc88\uc9f8 \ubc84\ud2bc", + "button_3": "\uc138 \ubc88\uc9f8 \ubc84\ud2bc", + "button_4": "\ub124 \ubc88\uc9f8 \ubc84\ud2bc", + "button_5": "\ub2e4\uc12f \ubc88\uc9f8 \ubc84\ud2bc", + "button_6": "\uc5ec\uc12f \ubc88\uc9f8 \ubc84\ud2bc", + "turn_off": "\ub044\uae30", + "turn_on": "\ucf1c\uae30" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" \uc774 \ub450 \ubc88 \ub20c\ub9b4 \ub54c", + "button_long_press": "\"{subtype}\" \uc774 \uacc4\uc18d \ub20c\ub824\uc9c8 \ub54c", + "button_long_release": "\"{subtype}\" \uc774 \uae38\uac8c \ub20c\ub838\ub2e4\uac00 \uc190\uc744 \ub5c4 \ub54c", + "button_quadruple_press": "\"{subtype}\" \uc774 \ub124 \ubc88 \ub20c\ub9b4 \ub54c", + "button_quintuple_press": "\"{subtype}\" \uc774 \ub2e4\uc12f \ubc88 \ub20c\ub9b4 \ub54c", + "button_short_press": "\"{subtype}\" \uc774 \ub20c\ub9b4 \ub54c", + "button_short_release": "\"{subtype}\" \uc5d0\uc11c \uc190\uc744 \ub5c4 \ub54c", + "button_triple_press": "\"{subtype}\" \uc774 \uc138 \ubc88 \ub20c\ub9b4 \ub54c" + } } } \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/ko.json b/homeassistant/components/plex/.translations/ko.json index cf5a7946b9d..3292fab0a8e 100644 --- a/homeassistant/components/plex/.translations/ko.json +++ b/homeassistant/components/plex/.translations/ko.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "\uc0c8\ub85c\uc6b4 \uad00\ub9ac/\uacf5\uc720 \uc0ac\uc6a9\uc790 \ubb34\uc2dc", + "monitored_users": "\ubaa8\ub2c8\ud130\ub9c1\ub418\ub294 \uc0ac\uc6a9\uc790", "show_all_controls": "\ubaa8\ub4e0 \ucee8\ud2b8\ub864 \ud45c\uc2dc\ud558\uae30", "use_episode_art": "\uc5d0\ud53c\uc18c\ub4dc \uc544\ud2b8 \uc0ac\uc6a9" }, diff --git a/homeassistant/components/solarlog/.translations/ru.json b/homeassistant/components/solarlog/.translations/ru.json index b64496c4591..3333d5c0d5f 100644 --- a/homeassistant/components/solarlog/.translations/ru.json +++ b/homeassistant/components/solarlog/.translations/ru.json @@ -11,7 +11,7 @@ "user": { "data": { "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441", - "name": "\u041f\u0440\u0435\u0444\u0438\u043a\u0441, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0431\u0443\u0434\u0435\u0442 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0434\u043b\u044f \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432 Solar-Log" + "name": "\u041f\u0440\u0435\u0444\u0438\u043a\u0441, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0431\u0443\u0434\u0435\u0442 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0434\u043b\u044f \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432 Solar-Log" }, "title": "Solar-Log" } diff --git a/homeassistant/components/unifi/.translations/ko.json b/homeassistant/components/unifi/.translations/ko.json index 295430b7284..8d8d0a2a382 100644 --- a/homeassistant/components/unifi/.translations/ko.json +++ b/homeassistant/components/unifi/.translations/ko.json @@ -28,15 +28,20 @@ "device_tracker": { "data": { "detection_time": "\ub9c8\uc9c0\ub9c9\uc73c\ub85c \ud655\uc778\ub41c \uc2dc\uac04\ubd80\ud130 \uc678\ucd9c \uc0c1\ud0dc\ub85c \uac04\uc8fc\ub418\ub294 \uc2dc\uac04 (\ucd08)", + "ssid_filter": "\ubb34\uc120 \ud074\ub77c\uc774\uc5b8\ud2b8\ub97c \ucd94\uc801\ud558\ub824\uba74 SSID\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694", "track_clients": "\ub124\ud2b8\uc6cc\ud06c \ud074\ub77c\uc774\uc5b8\ud2b8 \ucd94\uc801 \ub300\uc0c1", "track_devices": "\ub124\ud2b8\uc6cc\ud06c \uae30\uae30 \ucd94\uc801 (Ubiquiti \uae30\uae30)", "track_wired_clients": "\uc720\uc120 \ub124\ud2b8\uc6cc\ud06c \ud074\ub77c\uc774\uc5b8\ud2b8 \ud3ec\ud568" - } + }, + "description": "\uc7a5\uce58 \ucd94\uc801 \uad6c\uc131", + "title": "UniFi \uc635\uc158" }, "statistics_sensors": { "data": { "allow_bandwidth_sensors": "\ub124\ud2b8\uc6cc\ud06c \ud074\ub77c\uc774\uc5b8\ud2b8\ub97c \uc704\ud55c \ub300\uc5ed\ud3ed \uc0ac\uc6a9\ub7c9 \uc13c\uc11c \uc0dd\uc131\ud558\uae30" - } + }, + "description": "\ud1b5\uacc4 \uc13c\uc11c \uad6c\uc131", + "title": "UniFi \uc635\uc158" } } } diff --git a/homeassistant/components/unifi/.translations/ru.json b/homeassistant/components/unifi/.translations/ru.json index 2cb66f2e374..0080474cf64 100644 --- a/homeassistant/components/unifi/.translations/ru.json +++ b/homeassistant/components/unifi/.translations/ru.json @@ -46,9 +46,9 @@ }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "\u0414\u0430\u0442\u0447\u0438\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u044f \u043f\u043e\u043b\u043e\u0441\u044b \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u043d\u0438\u044f \u0434\u043b\u044f \u0441\u0435\u0442\u0435\u0432\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432" + "allow_bandwidth_sensors": "\u0421\u0435\u043d\u0441\u043e\u0440\u044b \u043f\u043e\u043b\u043e\u0441\u044b \u043f\u0440\u043e\u043f\u0443\u0441\u043a\u0430\u043d\u0438\u044f \u0434\u043b\u044f \u0441\u0435\u0442\u0435\u0432\u044b\u0445 \u043a\u043b\u0438\u0435\u043d\u0442\u043e\u0432" }, - "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432 \u0441\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043a\u0438", + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0441\u0435\u043d\u0441\u043e\u0440\u043e\u0432 \u0441\u0442\u0430\u0442\u0438\u0441\u0442\u0438\u043a\u0438", "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 UniFi" } } diff --git a/homeassistant/components/upnp/.translations/ru.json b/homeassistant/components/upnp/.translations/ru.json index 6dce1b3d76c..b0a7b7e7b65 100644 --- a/homeassistant/components/upnp/.translations/ru.json +++ b/homeassistant/components/upnp/.translations/ru.json @@ -5,7 +5,7 @@ "incomplete_device": "\u0418\u0433\u043d\u043e\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u043d\u0435\u043f\u043e\u043b\u043d\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 UPnP.", "no_devices_discovered": "\u041d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e UPnP / IGD.", "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 UPnP / IGD \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", - "no_sensors_or_port_mapping": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0438\u043b\u0438 \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u043e\u0440\u0442\u043e\u0432.", + "no_sensors_or_port_mapping": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0441\u0435\u043d\u0441\u043e\u0440\u044b \u0438\u043b\u0438 \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u043e\u0440\u0442\u043e\u0432.", "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." }, "error": { @@ -25,7 +25,7 @@ "user": { "data": { "enable_port_mapping": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u043e\u0440\u0442\u043e\u0432 \u0434\u043b\u044f Home Assistant", - "enable_sensors": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0441\u0435\u0442\u0435\u0432\u043e\u0433\u043e \u0442\u0440\u0430\u0444\u0438\u043a\u0430", + "enable_sensors": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0441\u0435\u043d\u0441\u043e\u0440\u044b \u0441\u0435\u0442\u0435\u0432\u043e\u0433\u043e \u0442\u0440\u0430\u0444\u0438\u043a\u0430", "igd": "UPnP / IGD" }, "title": "UPnP / IGD" diff --git a/homeassistant/components/vizio/.translations/no.json b/homeassistant/components/vizio/.translations/no.json index fd81f0b7c3c..0b92497a5e7 100644 --- a/homeassistant/components/vizio/.translations/no.json +++ b/homeassistant/components/vizio/.translations/no.json @@ -24,7 +24,7 @@ "host": ":", "name": "Navn" }, - "title": "Oppsett Vizio SmartCast Client" + "title": "Sett opp Vizio SmartCast-enhet" } }, "title": "Vizio SmartCast" diff --git a/homeassistant/components/vizio/.translations/zh-Hant.json b/homeassistant/components/vizio/.translations/zh-Hant.json index cd859977551..24128bb1b9e 100644 --- a/homeassistant/components/vizio/.translations/zh-Hant.json +++ b/homeassistant/components/vizio/.translations/zh-Hant.json @@ -24,7 +24,7 @@ "host": "<\u4e3b\u6a5f\u7aef/IP>:", "name": "\u540d\u7a31" }, - "title": "\u8a2d\u5b9a Vizio SmartCast \u5ba2\u6236\u7aef" + "title": "\u8a2d\u5b9a Vizio SmartCast \u8a2d\u5099" } }, "title": "Vizio SmartCast" From ced870c588bf319daa96791a715ea4a875ccd2a1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 21 Feb 2020 17:36:19 -0800 Subject: [PATCH 042/416] Remove YAML config from Ring integration (#32039) --- homeassistant/components/ring/__init__.py | 27 +------------------- homeassistant/components/ring/config_flow.py | 7 ----- tests/components/ring/test_init.py | 24 ----------------- 3 files changed, 1 insertion(+), 57 deletions(-) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 34aa9f6b0ec..0d54db5993f 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -9,12 +9,9 @@ from typing import Optional from oauthlib.oauth2 import AccessDeniedError import requests from ring_doorbell import Auth, Ring -import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, __version__ +from homeassistant.const import __version__ from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.async_ import run_callback_threadsafe @@ -30,18 +27,6 @@ DEFAULT_ENTITY_NAMESPACE = "ring" PLATFORMS = ("binary_sensor", "light", "sensor", "switch", "camera") -CONFIG_SCHEMA = vol.Schema( - { - vol.Optional(DOMAIN): vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - async def async_setup(hass, config): """Set up the Ring component.""" @@ -56,16 +41,6 @@ async def async_setup(hass, config): await hass.async_add_executor_job(legacy_cleanup) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - "username": config[DOMAIN]["username"], - "password": config[DOMAIN]["password"], - }, - ) - ) return True diff --git a/homeassistant/components/ring/config_flow.py b/homeassistant/components/ring/config_flow.py index a25e0283753..fd9dbe0a17e 100644 --- a/homeassistant/components/ring/config_flow.py +++ b/homeassistant/components/ring/config_flow.py @@ -75,13 +75,6 @@ class RingConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="2fa", data_schema=vol.Schema({"2fa": str}), ) - async def async_step_import(self, user_input): - """Handle import.""" - if self._async_current_entries(): - return self.async_abort(reason="already_configured") - - return await self.async_step_user(user_input) - class Require2FA(exceptions.HomeAssistantError): """Error to indicate we require 2FA.""" diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py index 809c71562c0..39d2c63ffdd 100644 --- a/tests/components/ring/test_init.py +++ b/tests/components/ring/test_init.py @@ -1,12 +1,10 @@ """The tests for the Ring component.""" from asyncio import run_coroutine_threadsafe -from copy import deepcopy from datetime import timedelta import unittest import requests_mock -from homeassistant import setup import homeassistant.components.ring as ring from tests.common import get_test_home_assistant, load_fixture @@ -57,25 +55,3 @@ class TestRing(unittest.TestCase): ).result() assert response - - @requests_mock.Mocker() - def test_setup_component_no_login(self, mock): - """Test the setup when no login is configured.""" - mock.post( - "https://api.ring.com/clients_api/session", - text=load_fixture("ring_session.json"), - ) - conf = deepcopy(VALID_CONFIG) - del conf["ring"]["username"] - assert not setup.setup_component(self.hass, ring.DOMAIN, conf) - - @requests_mock.Mocker() - def test_setup_component_no_pwd(self, mock): - """Test the setup when no password is configured.""" - mock.post( - "https://api.ring.com/clients_api/session", - text=load_fixture("ring_session.json"), - ) - conf = deepcopy(VALID_CONFIG) - del conf["ring"]["password"] - assert not setup.setup_component(self.hass, ring.DOMAIN, conf) From edfb967b103d00d2d969faa274822bc9fb769307 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 22 Feb 2020 22:29:49 +0100 Subject: [PATCH 043/416] Add unique ID to ONVIF camera entities (#32093) --- homeassistant/components/onvif/camera.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 58d125bf1f8..acedf229bdb 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -3,6 +3,7 @@ import asyncio import datetime as dt import logging import os +from typing import Optional from aiohttp.client_exceptions import ClientConnectionError, ServerDisconnectedError from haffmpeg.camera import CameraMjpeg @@ -136,6 +137,7 @@ class ONVIFHassCamera(Camera): self._profile_index = config.get(CONF_PROFILE) self._ptz_service = None self._input = None + self._mac = None _LOGGER.debug( "Setting up the ONVIF camera device @ '%s:%s'", self._host, self._port @@ -160,6 +162,7 @@ class ONVIFHassCamera(Camera): _LOGGER.debug("Updating service addresses") await self._camera.update_xaddrs() + await self.async_obtain_mac_address() await self.async_check_date_and_time() await self.async_obtain_input_uri() self.setup_ptz() @@ -178,6 +181,14 @@ class ONVIFHassCamera(Camera): err, ) + async def async_obtain_mac_address(self): + """Obtain the MAC address of the camera to use as the unique ID.""" + devicemgmt = self._camera.create_devicemgmt_service() + network_interfaces = await devicemgmt.GetNetworkInterfaces() + for interface in network_interfaces: + if interface.Enabled: + self._mac = interface.Info.HwAddress + async def async_check_date_and_time(self): """Warns if camera and system date not synced.""" _LOGGER.debug("Setting up the ONVIF device management service") @@ -398,3 +409,8 @@ class ONVIFHassCamera(Camera): def name(self): """Return the name of this camera.""" return self._name + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return self._mac From fcaabb3d33c2f9dbc785c972dbc0ae7cdd09222d Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sun, 23 Feb 2020 00:18:10 +0100 Subject: [PATCH 044/416] =?UTF-8?q?Change=20get=5Fentity=20to=20return=20a?= =?UTF-8?q?=20extended=20entry,=20add=20inputs=20to=20de=E2=80=A6=20(#3208?= =?UTF-8?q?3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Change get_entity to return a extended entry + add inputs to default config For https://github.com/home-assistant/home-assistant-polymer/pull/4940 * Fix tests, simplify code, update to return extended Co-authored-by: Paulus Schoutsen --- .../components/config/entity_registry.py | 19 +++++++++++++++---- .../components/default_config/manifest.json | 7 ++++++- .../components/config/test_entity_registry.py | 16 ++++++++++++---- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index a7993017116..f024f146a60 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -57,7 +57,9 @@ async def websocket_get_entity(hass, connection, msg): ) return - connection.send_message(websocket_api.result_message(msg["id"], _entry_dict(entry))) + connection.send_message( + websocket_api.result_message(msg["id"], _entry_ext_dict(entry)) + ) @require_admin @@ -112,7 +114,7 @@ async def websocket_update_entity(hass, connection, msg): ) else: connection.send_message( - websocket_api.result_message(msg["id"], _entry_dict(entry)) + websocket_api.result_message(msg["id"], _entry_ext_dict(entry)) ) @@ -152,6 +154,15 @@ def _entry_dict(entry): "name": entry.name, "icon": entry.icon, "platform": entry.platform, - "original_name": entry.original_name, - "original_icon": entry.original_icon, } + + +@callback +def _entry_ext_dict(entry): + """Convert entry to API format.""" + data = _entry_dict(entry) + data["original_name"] = entry.original_name + data["original_icon"] = entry.original_icon + data["unique_id"] = entry.unique_id + data["capabilities"] = entry.capabilities + return data diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index e19b1262b74..be9cb8dcc97 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -19,7 +19,12 @@ "system_health", "updater", "zeroconf", - "zone" + "zone", + "input_boolean", + "input_datetime", + "input_text", + "input_number", + "input_select" ], "codeowners": [] } diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index 8fe7e8fdbe4..2a696624e0c 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -42,8 +42,6 @@ async def test_list_entities(hass, client): "entity_id": "test_domain.name", "name": "Hello World", "icon": None, - "original_name": None, - "original_icon": None, "platform": "test_platform", }, { @@ -53,8 +51,6 @@ async def test_list_entities(hass, client): "entity_id": "test_domain.no_name", "name": None, "icon": None, - "original_name": None, - "original_icon": None, "platform": "test_platform", }, ] @@ -94,6 +90,8 @@ async def test_get_entity(hass, client): "icon": None, "original_name": None, "original_icon": None, + "capabilities": None, + "unique_id": "1234", } await client.send_json( @@ -115,6 +113,8 @@ async def test_get_entity(hass, client): "icon": None, "original_name": None, "original_icon": None, + "capabilities": None, + "unique_id": "6789", } @@ -165,6 +165,8 @@ async def test_update_entity(hass, client): "icon": "icon:after update", "original_name": None, "original_icon": None, + "capabilities": None, + "unique_id": "1234", } state = hass.states.get("test_domain.world") @@ -208,6 +210,8 @@ async def test_update_entity(hass, client): "icon": "icon:after update", "original_name": None, "original_icon": None, + "capabilities": None, + "unique_id": "1234", } @@ -254,6 +258,8 @@ async def test_update_entity_no_changes(hass, client): "icon": None, "original_name": None, "original_icon": None, + "capabilities": None, + "unique_id": "1234", } state = hass.states.get("test_domain.world") @@ -329,6 +335,8 @@ async def test_update_entity_id(hass, client): "icon": None, "original_name": None, "original_icon": None, + "capabilities": None, + "unique_id": "1234", } assert hass.states.get("test_domain.world") is None From 82571655624a4a7a5805c2cc8b0a38223a9b8111 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Sun, 23 Feb 2020 00:31:50 +0000 Subject: [PATCH 045/416] [ci skip] Translation update --- .../deconz/.translations/zh-Hans.json | 7 ++++++ .../demo/.translations/zh-Hans.json | 19 ++++++++++++++++ .../esphome/.translations/zh-Hans.json | 1 + .../components/hue/.translations/zh-Hans.json | 2 +- .../ipma/.translations/zh-Hans.json | 1 + .../konnected/.translations/zh-Hans.json | 9 ++++++++ .../linky/.translations/zh-Hans.json | 3 +++ .../mikrotik/.translations/zh-Hans.json | 16 ++++++++++++++ .../mqtt/.translations/zh-Hans.json | 22 +++++++++++++++++++ .../nest/.translations/zh-Hans.json | 8 +++---- .../plex/.translations/zh-Hans.json | 12 ++++++++++ .../sonos/.translations/zh-Hans.json | 2 +- .../components/unifi/.translations/ko.json | 2 +- .../unifi/.translations/zh-Hans.json | 9 +++++++- .../vilfo/.translations/zh-Hans.json | 19 ++++++++++++++++ .../components/vizio/.translations/ko.json | 2 +- .../components/zha/.translations/ru.json | 4 ++-- .../zone/.translations/zh-Hans.json | 4 ++-- 18 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/demo/.translations/zh-Hans.json create mode 100644 homeassistant/components/konnected/.translations/zh-Hans.json create mode 100644 homeassistant/components/mikrotik/.translations/zh-Hans.json create mode 100644 homeassistant/components/plex/.translations/zh-Hans.json create mode 100644 homeassistant/components/vilfo/.translations/zh-Hans.json diff --git a/homeassistant/components/deconz/.translations/zh-Hans.json b/homeassistant/components/deconz/.translations/zh-Hans.json index 37b82cff29c..ce51a54ac77 100644 --- a/homeassistant/components/deconz/.translations/zh-Hans.json +++ b/homeassistant/components/deconz/.translations/zh-Hans.json @@ -52,5 +52,12 @@ "remote_rotate_from_side_5": "\u8bbe\u5907\u4ece\u201c\u7b2c 5 \u9762\u201d\u7ffb\u8f6c\u5230\u201c{subtype}\u201d", "remote_rotate_from_side_6": "\u8bbe\u5907\u4ece\u201c\u7b2c 6 \u9762\u201d\u7ffb\u8f6c\u5230\u201c{subtype}\u201d" } + }, + "options": { + "step": { + "deconz_devices": { + "title": "deCONZ \u9009\u9879" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/zh-Hans.json b/homeassistant/components/demo/.translations/zh-Hans.json new file mode 100644 index 00000000000..9155b5066c5 --- /dev/null +++ b/homeassistant/components/demo/.translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "options": { + "step": { + "options_1": { + "data": { + "bool": "\u5e03\u5c14\u9009\u9879", + "int": "\u6570\u503c\u8f93\u5165" + } + }, + "options_2": { + "data": { + "multi": "\u591a\u91cd\u9009\u62e9", + "select": "\u9009\u62e9\u9009\u9879", + "string": "\u5b57\u7b26\u4e32\u503c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/zh-Hans.json b/homeassistant/components/esphome/.translations/zh-Hans.json index 46790868aba..4839167785d 100644 --- a/homeassistant/components/esphome/.translations/zh-Hans.json +++ b/homeassistant/components/esphome/.translations/zh-Hans.json @@ -8,6 +8,7 @@ "invalid_password": "\u65e0\u6548\u7684\u5bc6\u7801\uff01", "resolve_error": "\u65e0\u6cd5\u89e3\u6790 ESP \u7684\u5730\u5740\u3002\u5982\u679c\u6b64\u9519\u8bef\u6301\u7eed\u5b58\u5728\uff0c\u8bf7\u8bbe\u7f6e\u9759\u6001IP\u5730\u5740\uff1ahttps://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "ESPHome: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/hue/.translations/zh-Hans.json b/homeassistant/components/hue/.translations/zh-Hans.json index 1c6d78f9343..5e2f35bfea8 100644 --- a/homeassistant/components/hue/.translations/zh-Hans.json +++ b/homeassistant/components/hue/.translations/zh-Hans.json @@ -5,7 +5,7 @@ "already_configured": "\u98de\u5229\u6d66 Hue Bridge \u5df2\u914d\u7f6e\u5b8c\u6210", "already_in_progress": "\u7f51\u6865\u7684\u914d\u7f6e\u6d41\u5df2\u5728\u8fdb\u884c\u4e2d\u3002", "cannot_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230 \u98de\u5229\u6d66 Hue Bridge", - "discover_timeout": "\u65e0\u6cd5\u55c5\u63a2 Hue \u6865\u63a5\u5668", + "discover_timeout": "\u65e0\u6cd5\u55c5\u63a2\u5230 Hue \u6865\u63a5\u5668", "no_bridges": "\u672a\u53d1\u73b0\u98de\u5229\u6d66 Hue Bridge", "unknown": "\u51fa\u73b0\u672a\u77e5\u7684\u9519\u8bef" }, diff --git a/homeassistant/components/ipma/.translations/zh-Hans.json b/homeassistant/components/ipma/.translations/zh-Hans.json index 6c5654b6388..10d51832964 100644 --- a/homeassistant/components/ipma/.translations/zh-Hans.json +++ b/homeassistant/components/ipma/.translations/zh-Hans.json @@ -8,6 +8,7 @@ "data": { "latitude": "\u7eac\u5ea6", "longitude": "\u7ecf\u5ea6", + "mode": "\u6a21\u5f0f", "name": "\u540d\u79f0" }, "description": "\u8461\u8404\u7259\u56fd\u5bb6\u5927\u6c14\u7814\u7a76\u6240", diff --git a/homeassistant/components/konnected/.translations/zh-Hans.json b/homeassistant/components/konnected/.translations/zh-Hans.json new file mode 100644 index 00000000000..2bba1260764 --- /dev/null +++ b/homeassistant/components/konnected/.translations/zh-Hans.json @@ -0,0 +1,9 @@ +{ + "options": { + "step": { + "options_switch": { + "description": "\u8bf7\u9009\u62e9 {zone}\u8f93\u51fa\u9009\u9879" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/zh-Hans.json b/homeassistant/components/linky/.translations/zh-Hans.json index 2c6b3ba34b5..62138856078 100644 --- a/homeassistant/components/linky/.translations/zh-Hans.json +++ b/homeassistant/components/linky/.translations/zh-Hans.json @@ -1,5 +1,8 @@ { "config": { + "error": { + "wrong_login": "\u767b\u5f55\u51fa\u9519\uff1a\u8bf7\u68c0\u67e5\u60a8\u7684\u7535\u5b50\u90ae\u7bb1\u548c\u5bc6\u7801" + }, "step": { "user": { "data": { diff --git a/homeassistant/components/mikrotik/.translations/zh-Hans.json b/homeassistant/components/mikrotik/.translations/zh-Hans.json new file mode 100644 index 00000000000..9604af53495 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "\u4e3b\u673a", + "name": "\u540d\u5b57", + "password": "\u5bc6\u7801", + "port": "\u7aef\u53e3", + "username": "\u7528\u6237\u540d", + "verify_ssl": "\u4f7f\u7528 ssl" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/zh-Hans.json b/homeassistant/components/mqtt/.translations/zh-Hans.json index f30e1bf10b4..c12004236bd 100644 --- a/homeassistant/components/mqtt/.translations/zh-Hans.json +++ b/homeassistant/components/mqtt/.translations/zh-Hans.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "\u7b2c\u4e00\u4e2a\u6309\u94ae", + "button_2": "\u7b2c\u4e8c\u4e2a\u6309\u94ae", + "button_3": "\u7b2c\u4e09\u4e2a\u6309\u94ae", + "button_4": "\u7b2c\u56db\u4e2a\u6309\u94ae", + "button_5": "\u7b2c\u4e94\u4e2a\u6309\u94ae", + "button_6": "\u7b2c\u516d\u4e2a\u6309\u94ae", + "turn_off": "\u5173\u95ed", + "turn_on": "\u6253\u5f00" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" \u53cc\u51fb", + "button_long_press": "\"{subtype}\" \u6301\u7eed\u6309\u4e0b", + "button_long_release": "\"{subtype}\" \u957f\u6309\u540e\u91ca\u653e", + "button_quadruple_press": "\"{subtype}\" \u56db\u8fde\u51fb", + "button_quintuple_press": "\"{subtype}\" \u4e94\u8fde\u51fb", + "button_short_press": "\"{subtype}\" \u6309\u4e0b", + "button_short_release": "\"{subtype}\" \u91ca\u653e", + "button_triple_press": "\"{subtype}\" \u4e09\u8fde\u51fb" + } } } \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/zh-Hans.json b/homeassistant/components/nest/.translations/zh-Hans.json index 0b5cbc989fd..0825fdfdc79 100644 --- a/homeassistant/components/nest/.translations/zh-Hans.json +++ b/homeassistant/components/nest/.translations/zh-Hans.json @@ -8,14 +8,14 @@ }, "error": { "internal_error": "\u9a8c\u8bc1\u4ee3\u7801\u65f6\u53d1\u751f\u5185\u90e8\u9519\u8bef", - "invalid_code": "\u65e0\u6548\u4ee3\u7801", - "timeout": "\u4ee3\u7801\u9a8c\u8bc1\u8d85\u65f6", - "unknown": "\u9a8c\u8bc1\u4ee3\u7801\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef" + "invalid_code": "\u9a8c\u8bc1\u7801\u65e0\u6548", + "timeout": "\u9a8c\u8bc1\u7801\u8d85\u65f6", + "unknown": "\u9a8c\u8bc1\u7801\u672a\u77e5\u9519\u8bef" }, "step": { "init": { "data": { - "flow_impl": "\u63d0\u4f9b\u8005" + "flow_impl": "\u8ba4\u8bc1\u63d0\u4f9b\u8005" }, "description": "\u9009\u62e9\u60a8\u60f3\u901a\u8fc7\u54ea\u4e2a\u6388\u6743\u63d0\u4f9b\u8005\u4e0e Nest \u8fdb\u884c\u6388\u6743\u3002", "title": "\u6388\u6743\u63d0\u4f9b\u8005" diff --git a/homeassistant/components/plex/.translations/zh-Hans.json b/homeassistant/components/plex/.translations/zh-Hans.json new file mode 100644 index 00000000000..614f83e3cc0 --- /dev/null +++ b/homeassistant/components/plex/.translations/zh-Hans.json @@ -0,0 +1,12 @@ +{ + "options": { + "step": { + "plex_mp_settings": { + "data": { + "ignore_new_shared_users": "\u5ffd\u7565\u65b0\u589e\u7ba1\u7406/\u5171\u4eab\u4f7f\u7528\u8005", + "monitored_users": "\u53d7\u76d1\u89c6\u7684\u7528\u6237" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/zh-Hans.json b/homeassistant/components/sonos/.translations/zh-Hans.json index 17c1e78d3e8..de2609f4a71 100644 --- a/homeassistant/components/sonos/.translations/zh-Hans.json +++ b/homeassistant/components/sonos/.translations/zh-Hans.json @@ -2,7 +2,7 @@ "config": { "abort": { "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 Sonos \u8bbe\u5907\u3002", - "single_instance_allowed": "\u53ea\u6709\u4e00\u6b21 Sonos \u914d\u7f6e\u662f\u5fc5\u8981\u7684\u3002" + "single_instance_allowed": "\u53ea\u9700\u8bbe\u7f6e\u4e00\u6b21 Sonos \u5373\u53ef\u3002" }, "step": { "confirm": { diff --git a/homeassistant/components/unifi/.translations/ko.json b/homeassistant/components/unifi/.translations/ko.json index 8d8d0a2a382..dbcd4d7feee 100644 --- a/homeassistant/components/unifi/.translations/ko.json +++ b/homeassistant/components/unifi/.translations/ko.json @@ -38,7 +38,7 @@ }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "\ub124\ud2b8\uc6cc\ud06c \ud074\ub77c\uc774\uc5b8\ud2b8\ub97c \uc704\ud55c \ub300\uc5ed\ud3ed \uc0ac\uc6a9\ub7c9 \uc13c\uc11c \uc0dd\uc131\ud558\uae30" + "allow_bandwidth_sensors": "\ub124\ud2b8\uc6cc\ud06c \ud074\ub77c\uc774\uc5b8\ud2b8 \ub300\uc5ed\ud3ed \uc0ac\uc6a9\ub7c9 \uc13c\uc11c" }, "description": "\ud1b5\uacc4 \uc13c\uc11c \uad6c\uc131", "title": "UniFi \uc635\uc158" diff --git a/homeassistant/components/unifi/.translations/zh-Hans.json b/homeassistant/components/unifi/.translations/zh-Hans.json index 2bc6bda37e4..ebed653732f 100644 --- a/homeassistant/components/unifi/.translations/zh-Hans.json +++ b/homeassistant/components/unifi/.translations/zh-Hans.json @@ -28,10 +28,17 @@ "device_tracker": { "data": { "detection_time": "\u8ddd\u79bb\u4e0a\u6b21\u53d1\u73b0\u591a\u5c11\u79d2\u540e\u8ba4\u4e3a\u79bb\u5f00", + "ssid_filter": "\u9009\u62e9\u6240\u8981\u8ffd\u8e2a\u7684\u65e0\u7ebf\u7f51\u7edcSSID", "track_clients": "\u8ddf\u8e2a\u7f51\u7edc\u5ba2\u6237\u7aef", "track_devices": "\u8ddf\u8e2a\u7f51\u7edc\u8bbe\u5907\uff08Ubiquiti \u8bbe\u5907\uff09", "track_wired_clients": "\u5305\u62ec\u6709\u7ebf\u7f51\u7edc\u5ba2\u6237\u7aef" - } + }, + "description": "\u914d\u7f6e\u8bbe\u5907\u8ddf\u8e2a", + "title": "UniFi \u9009\u9879" + }, + "statistics_sensors": { + "description": "\u914d\u7f6e\u7edf\u8ba1\u4f20\u611f\u5668", + "title": "UniFi \u9009\u9879" } } } diff --git a/homeassistant/components/vilfo/.translations/zh-Hans.json b/homeassistant/components/vilfo/.translations/zh-Hans.json new file mode 100644 index 00000000000..788f85b9382 --- /dev/null +++ b/homeassistant/components/vilfo/.translations/zh-Hans.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "cannot_connect": "\u8fde\u63a5\u5931\u8d25\u3002\u8bf7\u68c0\u67e5\u8f93\u5165\u4fe1\u606f\u540e\uff0c\u518d\u8bd5\u4e00\u6b21\u3002", + "unknown": "\u8bbe\u7f6e\u6574\u5408\u65f6\u53d1\u751f\u610f\u5916\u9519\u8bef\u3002" + }, + "step": { + "user": { + "data": { + "access_token": "Vilfo \u8def\u7531\u5668 API \u5b58\u53d6\u5bc6\u94a5", + "host": "\u8def\u7531\u5668\u4e3b\u673a\u540d\u6216 IP \u5730\u5740" + }, + "description": "\u8bbe\u7f6e Vilfo \u8def\u7531\u5668\u6574\u5408\u3002\u60a8\u9700\u8981\u8f93\u5165 Vilfo \u8def\u7531\u5668\u4e3b\u673a\u540d/IP \u5730\u5740\u3001API\u5b58\u53d6\u5bc6\u94a5\u3002\u5176\u4ed6\u6574\u5408\u7684\u76f8\u5173\u4fe1\u606f\uff0c\u8bf7\u8bbf\u95ee\uff1ahttps://www.home-assistant.io/integrations/vilfo", + "title": "\u8fde\u63a5\u5230 Vilfo \u8def\u7531\u5668" + } + }, + "title": "Vilfo \u8def\u7531\u5668" + } +} \ No newline at end of file diff --git a/homeassistant/components/vizio/.translations/ko.json b/homeassistant/components/vizio/.translations/ko.json index 4c0460ec0e1..64c0887b3f8 100644 --- a/homeassistant/components/vizio/.translations/ko.json +++ b/homeassistant/components/vizio/.translations/ko.json @@ -24,7 +24,7 @@ "host": "<\ud638\uc2a4\ud2b8/ip>:", "name": "\uc774\ub984" }, - "title": "Vizio SmartCast \ud074\ub77c\uc774\uc5b8\ud2b8 \uc124\uc815" + "title": "Vizio SmartCast \uae30\uae30 \uc124\uc815" } }, "title": "Vizio SmartCast" diff --git a/homeassistant/components/zha/.translations/ru.json b/homeassistant/components/zha/.translations/ru.json index 8850fdfc07a..38b0aa8359c 100644 --- a/homeassistant/components/zha/.translations/ru.json +++ b/homeassistant/components/zha/.translations/ru.json @@ -12,10 +12,10 @@ "radio_type": "\u0422\u0438\u043f \u0420\u0430\u0434\u0438\u043e", "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443" }, - "title": "Zigbee Home Automation (ZHA)" + "title": "Zigbee Home Automation" } }, - "title": "Zigbee Home Automation" + "title": "Zigbee Home Automation (ZHA)" }, "device_automation": { "action_type": { diff --git a/homeassistant/components/zone/.translations/zh-Hans.json b/homeassistant/components/zone/.translations/zh-Hans.json index 6d06b68dad8..6972b2946e4 100644 --- a/homeassistant/components/zone/.translations/zh-Hans.json +++ b/homeassistant/components/zone/.translations/zh-Hans.json @@ -1,7 +1,7 @@ { "config": { "error": { - "name_exists": "\u540d\u79f0\u5df2\u5b58\u5728" + "name_exists": "\u8be5\u540d\u79f0\u5df2\u5b58\u5728" }, "step": { "init": { @@ -13,7 +13,7 @@ "passive": "\u88ab\u52a8", "radius": "\u534a\u5f84" }, - "title": "\u5b9a\u4e49\u533a\u57df\u76f8\u5173\u53d8\u91cf" + "title": "\u5b9a\u4e49\u533a\u57df\u53c2\u6570" } }, "title": "\u533a\u57df" From bd00453cef63bd057342160cc520f9028ff8b6cb Mon Sep 17 00:00:00 2001 From: michaeldavie Date: Sat, 22 Feb 2020 23:32:19 -0500 Subject: [PATCH 046/416] Bump env_canada to 0.0.35 to address issue 31924 (#32077) * Bump env_canada to 0.0.35, remove details from alerts * Black --- homeassistant/components/environment_canada/manifest.json | 2 +- homeassistant/components/environment_canada/sensor.py | 6 +----- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index a9d97dc6271..9b208c452e5 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -2,7 +2,7 @@ "domain": "environment_canada", "name": "Environment Canada", "documentation": "https://www.home-assistant.io/integrations/environment_canada", - "requirements": ["env_canada==0.0.34"], + "requirements": ["env_canada==0.0.35"], "dependencies": [], "codeowners": ["@michaeldavie"] } diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index e6ea87fd946..f32de07e4fb 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -23,7 +23,6 @@ SCAN_INTERVAL = timedelta(minutes=10) ATTR_UPDATED = "updated" ATTR_STATION = "station" -ATTR_DETAIL = "alert detail" ATTR_TIME = "alert time" CONF_ATTRIBUTION = "Data provided by Environment Canada" @@ -122,10 +121,7 @@ class ECSensor(Entity): if isinstance(value, list): self._state = " | ".join([str(s.get("title")) for s in value])[:255] self._attr.update( - { - ATTR_DETAIL: " | ".join([str(s.get("detail")) for s in value]), - ATTR_TIME: " | ".join([str(s.get("date")) for s in value]), - } + {ATTR_TIME: " | ".join([str(s.get("date")) for s in value])} ) elif self.sensor_type == "tendency": self._state = str(value).capitalize() diff --git a/requirements_all.txt b/requirements_all.txt index d87baa2644b..7907330b100 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -489,7 +489,7 @@ enocean==0.50 enturclient==0.2.1 # homeassistant.components.environment_canada -env_canada==0.0.34 +env_canada==0.0.35 # homeassistant.components.envirophat # envirophat==0.0.6 From a678c6fd0b009a59612214acc997e676b5b3b07e Mon Sep 17 00:00:00 2001 From: jezcooke <61294178+jezcooke@users.noreply.github.com> Date: Sun, 23 Feb 2020 04:33:42 +0000 Subject: [PATCH 047/416] Fix Frontier Silicon player state (#32082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The player would report itself as ‘off’ when in certain modes (e.g ‘Music player’ or ‘Spotify’) which meant HA would lose all control (it can’t change input or set volume etc. as it thinks it’s off). Now reports STATE_IDLE in these cases and only STATE_OFF if it is actually off. This fixes issue #20728. --- .../frontier_silicon/media_player.py | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 627c3c079b9..2336a9e0e06 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -26,6 +26,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_PORT, + STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, @@ -186,14 +187,17 @@ class AFSAPIDevice(MediaPlayerDevice): if not self._source_list: self._source_list = await fs_device.get_mode_list() - status = await fs_device.get_play_status() - self._state = { - "playing": STATE_PLAYING, - "paused": STATE_PAUSED, - "stopped": STATE_OFF, - "unknown": STATE_UNKNOWN, - None: STATE_OFF, - }.get(status, STATE_UNKNOWN) + if await fs_device.get_power(): + status = await fs_device.get_play_status() + self._state = { + "playing": STATE_PLAYING, + "paused": STATE_PAUSED, + "stopped": STATE_IDLE, + "unknown": STATE_UNKNOWN, + None: STATE_IDLE, + }.get(status, STATE_UNKNOWN) + else: + self._state = STATE_OFF if self._state != STATE_OFF: info_name = await fs_device.get_play_name() From f975654ae7fa03dbec42b49835c08db414d0c738 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 23 Feb 2020 03:23:21 -0800 Subject: [PATCH 048/416] Mock setup_entry in oauth2 tests (#32102) * Mock setup_entry in oauth2 tests * Fix scaffold constant --- .../tests/test_config_flow.py | 8 +++++++- tests/components/almond/test_config_flow.py | 17 ++++++++++++++--- tests/components/netatmo/test_config_flow.py | 8 +++++++- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py index ec332de13e2..8a543a04af3 100644 --- a/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py +++ b/script/scaffold/templates/config_flow_oauth2/tests/test_config_flow.py @@ -1,4 +1,6 @@ """Test the NEW_NAME config flow.""" +from asynctest import patch + from homeassistant import config_entries, setup from homeassistant.components.NEW_DOMAIN.const import ( DOMAIN, @@ -48,6 +50,10 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock): }, ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + with patch( + "homeassistant.components.NEW_DOMAIN.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/almond/test_config_flow.py b/tests/components/almond/test_config_flow.py index 0b402ed407d..0b4869ee2a6 100644 --- a/tests/components/almond/test_config_flow.py +++ b/tests/components/almond/test_config_flow.py @@ -1,6 +1,7 @@ """Test the Almond config flow.""" import asyncio -from unittest.mock import patch + +from asynctest import patch from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.almond import config_flow @@ -55,7 +56,12 @@ async def test_hassio(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "hassio_confirm" - result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + with patch( + "homeassistant.components.almond.async_setup_entry", return_value=True + ) as mock_setup: + result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert len(mock_setup.mock_calls) == 1 assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -128,7 +134,12 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock): }, ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + with patch( + "homeassistant.components.almond.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(mock_setup.mock_calls) == 1 assert len(hass.config_entries.async_entries(DOMAIN)) == 1 entry = hass.config_entries.async_entries(DOMAIN)[0] diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index d76578d277c..c9a663991cb 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -1,4 +1,6 @@ """Test the Netatmo config flow.""" +from asynctest import patch + from homeassistant import config_entries, data_entry_flow, setup from homeassistant.components.netatmo import config_flow from homeassistant.components.netatmo.const import ( @@ -88,6 +90,10 @@ async def test_full_flow(hass, aiohttp_client, aioclient_mock): }, ) - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + with patch( + "homeassistant.components.netatmo.async_setup_entry", return_value=True + ) as mock_setup: + await hass.config_entries.flow.async_configure(result["flow_id"]) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 From d22ee7179ddd1cd2bb9e7590c85b58ff68b08b07 Mon Sep 17 00:00:00 2001 From: Tom Date: Sun, 23 Feb 2020 17:02:42 +0100 Subject: [PATCH 049/416] Fix Plugwise climate issues for new firmware #32080 (#32109) * Fix Plugwise climate issue firmare 3.1.11 (#32080) * Submit checklist actions --- homeassistant/components/plugwise/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index 5fc3d189b69..601f017d42f 100644 --- a/homeassistant/components/plugwise/manifest.json +++ b/homeassistant/components/plugwise/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/integrations/plugwise", "dependencies": [], "codeowners": ["@laetificat", "@CoMPaTech", "@bouwew"], - "requirements": ["haanna==0.14.1"] + "requirements": ["haanna==0.14.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7907330b100..171acac351b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -644,7 +644,7 @@ ha-ffmpeg==2.0 ha-philipsjs==0.0.8 # homeassistant.components.plugwise -haanna==0.14.1 +haanna==0.14.3 # homeassistant.components.habitica habitipy==0.2.0 From 458e47f9815684a41ee5a988a1c4da37bd9c512d Mon Sep 17 00:00:00 2001 From: jezcooke <61294178+jezcooke@users.noreply.github.com> Date: Sun, 23 Feb 2020 16:05:24 +0000 Subject: [PATCH 050/416] Add name option for Frontier Silicon devices (#32085) * Added the option to specify the name of the device in confirguration.yaml * Adding missing default name parameter in auto-discovery FSAPIDevice constructor. * Fixed Black formatting. * Removed DEFAULT_NAME constant. * Apply suggestions from code review Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> Co-authored-by: springstan <46536646+springstan@users.noreply.github.com> --- .../components/frontier_silicon/media_player.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 2336a9e0e06..711a8940493 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -24,6 +24,7 @@ from homeassistant.components.media_player.const import ( ) from homeassistant.const import ( CONF_HOST, + CONF_NAME, CONF_PASSWORD, CONF_PORT, STATE_IDLE, @@ -61,6 +62,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_NAME): cv.string, } ) @@ -69,17 +71,19 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Set up the Frontier Silicon platform.""" if discovery_info is not None: async_add_entities( - [AFSAPIDevice(discovery_info["ssdp_description"], DEFAULT_PASSWORD)], True + [AFSAPIDevice(discovery_info["ssdp_description"], DEFAULT_PASSWORD, None)], + True, ) return True host = config.get(CONF_HOST) port = config.get(CONF_PORT) password = config.get(CONF_PASSWORD) + name = config.get(CONF_NAME) try: async_add_entities( - [AFSAPIDevice(DEVICE_URL.format(host, port), password)], True + [AFSAPIDevice(DEVICE_URL.format(host, port), password, name)], True ) _LOGGER.debug("FSAPI device %s:%s -> %s", host, port, password) return True @@ -94,13 +98,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class AFSAPIDevice(MediaPlayerDevice): """Representation of a Frontier Silicon device on the network.""" - def __init__(self, device_url, password): + def __init__(self, device_url, password, name): """Initialize the Frontier Silicon API device.""" self._device_url = device_url self._password = password self._state = None - self._name = None + self._name = name self._title = None self._artist = None self._album_name = None From a533b7a7466711e196f8528ce6075f3ea0b3baa4 Mon Sep 17 00:00:00 2001 From: jezcooke <61294178+jezcooke@users.noreply.github.com> Date: Sun, 23 Feb 2020 16:37:31 +0000 Subject: [PATCH 051/416] Fix volume control for Frontier Silicon media players (#32040) * Fixed volume control for Frontier Silicon media players. * Removed unnecessary else which caused pylint test to fail. * Removed whitespace on empty line. * Tweaks from on springstan's suggestions and other fixes * Apply suggestions from code review Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> * Comment to explain why 1 is subtracted from volume_Steps Also reformatted using black after code review changes. * Split up the set volume functions into steps rather than all in-line as suggested. * Renamed _volume_steps to _max_volume. * Prevent asnyc_update from failing if we can't get the volume steps Co-authored-by: springstan <46536646+springstan@users.noreply.github.com> --- .../frontier_silicon/media_player.py | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 711a8940493..82ed14c4336 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -112,6 +112,8 @@ class AFSAPIDevice(MediaPlayerDevice): self._source = None self._source_list = None self._media_image_url = None + self._max_volume = None + self._volume_level = None # Properties @property @@ -181,6 +183,11 @@ class AFSAPIDevice(MediaPlayerDevice): """Image url of current playing media.""" return self._media_image_url + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._volume_level + async def async_update(self): """Get the latest date and update device state.""" fs_device = self.fs_device @@ -191,6 +198,12 @@ class AFSAPIDevice(MediaPlayerDevice): if not self._source_list: self._source_list = await fs_device.get_mode_list() + # The API seems to include 'zero' in the number of steps (e.g. if the range is + # 0-40 then get_volume_steps returns 41) subtract one to get the max volume. + # If call to get_volume fails set to 0 and try again next time. + if not self._max_volume: + self._max_volume = int(await fs_device.get_volume_steps() or 1) - 1 + if await fs_device.get_power(): status = await fs_device.get_play_status() self._state = { @@ -214,6 +227,11 @@ class AFSAPIDevice(MediaPlayerDevice): self._source = await fs_device.get_mode() self._mute = await fs_device.get_mute() self._media_image_url = await fs_device.get_play_graphic() + + volume = await self.fs_device.get_volume() + + # Prevent division by zero if max_volume not known yet + self._volume_level = float(volume or 0) / (self._max_volume or 1) else: self._title = None self._artist = None @@ -223,6 +241,8 @@ class AFSAPIDevice(MediaPlayerDevice): self._mute = None self._media_image_url = None + self._volume_level = None + # Management actions # power control async def async_turn_on(self): @@ -274,16 +294,20 @@ class AFSAPIDevice(MediaPlayerDevice): async def async_volume_up(self): """Send volume up command.""" volume = await self.fs_device.get_volume() - await self.fs_device.set_volume(volume + 1) + volume = int(volume or 0) + 1 + await self.fs_device.set_volume(min(volume, self._max_volume)) async def async_volume_down(self): """Send volume down command.""" volume = await self.fs_device.get_volume() - await self.fs_device.set_volume(volume - 1) + volume = int(volume or 0) - 1 + await self.fs_device.set_volume(max(volume, 0)) async def async_set_volume_level(self, volume): """Set volume command.""" - await self.fs_device.set_volume(int(volume * 20)) + if self._max_volume: # Can't do anything sensible if not set + volume = int(volume * self._max_volume) + await self.fs_device.set_volume(volume) async def async_select_source(self, source): """Select input source.""" From f6fbecf963d91dab9ce44cf26f219c2f53956c2f Mon Sep 17 00:00:00 2001 From: Martin Long Date: Sun, 23 Feb 2020 16:40:50 +0000 Subject: [PATCH 052/416] Add boost support for Hive TRVs (#31261) * Add boost support for TRVs * Updated pyhiveapi dependency to 0.2.20.1 --- homeassistant/components/hive/__init__.py | 2 +- homeassistant/components/hive/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py index 976821513b6..edd3388e74f 100644 --- a/homeassistant/components/hive/__init__.py +++ b/homeassistant/components/hive/__init__.py @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "hive" DATA_HIVE = "data_hive" -SERVICES = ["Heating", "HotWater"] +SERVICES = ["Heating", "HotWater", "TRV"] SERVICE_BOOST_HOT_WATER = "boost_hot_water" SERVICE_BOOST_HEATING = "boost_heating" ATTR_TIME_PERIOD = "time_period" diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json index 6572b0dbda2..96563d5ab3d 100644 --- a/homeassistant/components/hive/manifest.json +++ b/homeassistant/components/hive/manifest.json @@ -2,7 +2,7 @@ "domain": "hive", "name": "Hive", "documentation": "https://www.home-assistant.io/integrations/hive", - "requirements": ["pyhiveapi==0.2.19.3"], + "requirements": ["pyhiveapi==0.2.20.1"], "dependencies": [], "codeowners": ["@Rendili", "@KJonline"] } diff --git a/requirements_all.txt b/requirements_all.txt index 171acac351b..4e301df4998 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1287,7 +1287,7 @@ pyheos==0.6.0 pyhik==0.2.5 # homeassistant.components.hive -pyhiveapi==0.2.19.3 +pyhiveapi==0.2.20.1 # homeassistant.components.homematic pyhomematic==0.1.65 From 4cc4f070f5f6c54e9932c90a1d63fbc0a54e60f7 Mon Sep 17 00:00:00 2001 From: Robin Date: Sun, 23 Feb 2020 18:11:05 +0000 Subject: [PATCH 053/416] Add sighthound save image (#32103) * Adds save_image * Update test_image_processing.py * Update test * Tidy test * update image_processing with reviewer comments * Update test_image_processing.py * Ammend tests not passing * Patch convert * remove join * Use valid_config_save_file --- .../components/sighthound/image_processing.py | 30 +++++++++++++++++-- .../sighthound/test_image_processing.py | 24 +++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sighthound/image_processing.py b/homeassistant/components/sighthound/image_processing.py index 175b1edc4c6..ff67749b192 100644 --- a/homeassistant/components/sighthound/image_processing.py +++ b/homeassistant/components/sighthound/image_processing.py @@ -1,6 +1,9 @@ """Person detection using Sighthound cloud service.""" +import io import logging +from pathlib import Path +from PIL import Image, ImageDraw import simplehound.core as hound import voluptuous as vol @@ -14,6 +17,7 @@ from homeassistant.components.image_processing import ( from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv +from homeassistant.util.pil import draw_box _LOGGER = logging.getLogger(__name__) @@ -22,6 +26,7 @@ EVENT_PERSON_DETECTED = "sighthound.person_detected" ATTR_BOUNDING_BOX = "bounding_box" ATTR_PEOPLE = "people" CONF_ACCOUNT_TYPE = "account_type" +CONF_SAVE_FILE_FOLDER = "save_file_folder" DEV = "dev" PROD = "prod" @@ -29,6 +34,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_ACCOUNT_TYPE, default=DEV): vol.In([DEV, PROD]), + vol.Optional(CONF_SAVE_FILE_FOLDER): cv.isdir, } ) @@ -45,10 +51,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("Sighthound error %s setup aborted", exc) return + save_file_folder = config.get(CONF_SAVE_FILE_FOLDER) + if save_file_folder: + save_file_folder = Path(save_file_folder) + entities = [] for camera in config[CONF_SOURCE]: sighthound = SighthoundEntity( - api, camera[CONF_ENTITY_ID], camera.get(CONF_NAME) + api, camera[CONF_ENTITY_ID], camera.get(CONF_NAME), save_file_folder ) entities.append(sighthound) add_entities(entities) @@ -57,7 +67,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class SighthoundEntity(ImageProcessingEntity): """Create a sighthound entity.""" - def __init__(self, api, camera_entity, name): + def __init__(self, api, camera_entity, name, save_file_folder): """Init.""" self._api = api self._camera = camera_entity @@ -69,6 +79,7 @@ class SighthoundEntity(ImageProcessingEntity): self._state = None self._image_width = None self._image_height = None + self._save_file_folder = save_file_folder def process_image(self, image): """Process an image.""" @@ -81,6 +92,8 @@ class SighthoundEntity(ImageProcessingEntity): self._image_height = metadata["image_height"] for person in people: self.fire_person_detected_event(person) + if self._save_file_folder and self._state > 0: + self.save_image(image, people, self._save_file_folder) def fire_person_detected_event(self, person): """Send event with detected total_persons.""" @@ -94,6 +107,19 @@ class SighthoundEntity(ImageProcessingEntity): }, ) + def save_image(self, image, people, directory): + """Save a timestamped image with bounding boxes around targets.""" + img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") + draw = ImageDraw.Draw(img) + + for person in people: + box = hound.bbox_to_tf_style( + person["boundingBox"], self._image_width, self._image_height + ) + draw_box(draw, box, self._image_width, self._image_height) + latest_save_path = directory / f"{self._name}_latest.jpg" + img.save(latest_save_path) + @property def camera_entity(self): """Return camera entity id from process pictures.""" diff --git a/tests/components/sighthound/test_image_processing.py b/tests/components/sighthound/test_image_processing.py index 4548a3a6a35..3c0d10bd5b3 100644 --- a/tests/components/sighthound/test_image_processing.py +++ b/tests/components/sighthound/test_image_processing.py @@ -1,4 +1,6 @@ """Tests for the Sighthound integration.""" +from copy import deepcopy +import os from unittest.mock import patch import pytest @@ -10,6 +12,8 @@ from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY from homeassistant.core import callback from homeassistant.setup import async_setup_component +TEST_DIR = os.path.dirname(__file__) + VALID_CONFIG = { ip.DOMAIN: { "platform": "sighthound", @@ -91,3 +95,23 @@ async def test_process_image(hass, mock_image, mock_detections): state = hass.states.get(VALID_ENTITY_ID) assert state.state == "2" assert len(person_events) == 2 + + +async def test_save_image(hass, mock_image, mock_detections): + """Save a processed image.""" + valid_config_save_file = deepcopy(VALID_CONFIG) + valid_config_save_file[ip.DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR}) + await async_setup_component(hass, ip.DOMAIN, valid_config_save_file) + assert hass.states.get(VALID_ENTITY_ID) + + with patch( + "homeassistant.components.sighthound.image_processing.Image.open" + ) as pil_img_open: + pil_img = pil_img_open.return_value + pil_img = pil_img.convert.return_value + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} + await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) + await hass.async_block_till_done() + state = hass.states.get(VALID_ENTITY_ID) + assert state.state == "2" + assert pil_img.save.call_count == 1 From 1007283da5072b9f44fe3d6debd45af162028258 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sun, 23 Feb 2020 19:17:26 +0100 Subject: [PATCH 054/416] UniFi - Add unit of measurement to bandwidth sensors (#32114) --- homeassistant/components/unifi/sensor.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 942b0ef6779..1b6667f2e80 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -2,6 +2,7 @@ import logging from homeassistant.components.unifi.config_flow import get_controller_from_config_entry +from homeassistant.const import DATA_BYTES from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -9,9 +10,6 @@ from .unifi_client import UniFiClient LOGGER = logging.getLogger(__name__) -ATTR_RECEIVING = "receiving" -ATTR_TRANSMITTING = "transmitting" - async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Sensor platform doesn't support configuration through configuration.yaml.""" @@ -115,6 +113,11 @@ class UniFiRxBandwidthSensor(UniFiClient): """Return a unique identifier for this bandwidth sensor.""" return f"rx-{self.client.mac}" + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return DATA_BYTES + class UniFiTxBandwidthSensor(UniFiRxBandwidthSensor): """Transmitting bandwidth sensor.""" From 8dd80e0e3cdd4631257f14521392101690c5efc4 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sun, 23 Feb 2020 12:26:34 -0600 Subject: [PATCH 055/416] Add unique_id to directv entities (#31838) * add unique_id to directv entities. * add addiitional debug and fix tests. * fix lint error. * rework unique_id and flow a bit. * review adjustments. * review adjustments * review adjustments * review adjustments. * review adjustments * review adjustments * review adjustments * review adjustments * lint * use serial number for host unit and mac address for client units * fix elsif * update test with realistic client id * lint --- .../components/directv/media_player.py | 76 ++++++++++++------- tests/components/directv/test_media_player.py | 48 +++++++++++- 2 files changed, 94 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index cd4f910c707..603d0127fe6 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -77,23 +77,24 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the DirecTV platform.""" known_devices = hass.data.get(DATA_DIRECTV, set()) - hosts = [] + entities = [] if CONF_HOST in config: + name = config[CONF_NAME] + host = config[CONF_HOST] + port = config[CONF_PORT] + device = config[CONF_DEVICE] + _LOGGER.debug( - "Adding configured device %s with client address %s ", - config.get(CONF_NAME), - config.get(CONF_DEVICE), - ) - hosts.append( - [ - config.get(CONF_NAME), - config.get(CONF_HOST), - config.get(CONF_PORT), - config.get(CONF_DEVICE), - ] + "Adding configured device %s with client address %s", name, device, ) + dtv = DIRECTV(host, port, device) + dtv_version = _get_receiver_version(dtv) + + entities.append(DirecTvDevice(name, device, dtv, dtv_version,)) + known_devices.add((host, device)) + elif discovery_info: host = discovery_info.get("host") name = "DirecTV_{}".format(discovery_info.get("serial", "")) @@ -102,7 +103,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.debug("Doing discovery of DirecTV devices on %s", host) dtv = DIRECTV(host, DEFAULT_PORT) + try: + dtv_version = _get_receiver_version(dtv) resp = dtv.get_locations() except requests.exceptions.RequestException as ex: # Bail out and just go forward with uPnP data @@ -116,6 +119,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if "locationName" not in loc or "clientAddr" not in loc: continue + loc_name = str.title(loc["locationName"]) + # Make sure that this device is not already configured # Comparing based on host (IP) and clientAddr. if (host, loc["clientAddr"]) in known_devices: @@ -123,42 +128,47 @@ def setup_platform(hass, config, add_entities, discovery_info=None): "Discovered device %s on host %s with " "client address %s is already " "configured", - str.title(loc["locationName"]), + loc_name, host, loc["clientAddr"], ) else: _LOGGER.debug( "Adding discovered device %s with client address %s", - str.title(loc["locationName"]), + loc_name, loc["clientAddr"], ) - hosts.append( - [ - str.title(loc["locationName"]), - host, - DEFAULT_PORT, + + entities.append( + DirecTvDevice( + loc_name, loc["clientAddr"], - ] + DIRECTV(host, DEFAULT_PORT, loc["clientAddr"]), + dtv_version, + ) ) + known_devices.add((host, loc["clientAddr"])) - dtvs = [] + add_entities(entities) - for host in hosts: - dtvs.append(DirecTvDevice(*host)) - hass.data.setdefault(DATA_DIRECTV, set()).add((host[1], host[3])) - add_entities(dtvs) +def _get_receiver_version(client): + """Return the version of the DirectTV receiver.""" + try: + return client.get_version() + except requests.exceptions.RequestException as ex: + _LOGGER.debug("Request exception %s trying to get receiver version", ex) + return None class DirecTvDevice(MediaPlayerDevice): """Representation of a DirecTV receiver on the network.""" - def __init__(self, name, host, port, device): + def __init__(self, name, device, dtv, version_info=None): """Initialize the device.""" - - self.dtv = DIRECTV(host, port, device) + self.dtv = dtv self._name = name + self._unique_id = None self._is_standby = True self._current = None self._last_update = None @@ -170,6 +180,11 @@ class DirecTvDevice(MediaPlayerDevice): self._available = False self._first_error_timestamp = None + if device != "0": + self._unique_id = device + elif version_info: + self._unique_id = "".join(version_info.get("receiverId").split()) + if self._is_client: _LOGGER.debug("Created DirecTV client %s for device %s", self._name, device) else: @@ -257,6 +272,11 @@ class DirecTvDevice(MediaPlayerDevice): """Return the name of the device.""" return self._name + @property + def unique_id(self): + """Return a unique ID to use for this media player.""" + return self._unique_id + # MediaPlayerDevice properties and methods @property def state(self): diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py index 449147c3648..be805d837f5 100644 --- a/tests/components/directv/test_media_player.py +++ b/tests/components/directv/test_media_player.py @@ -60,6 +60,7 @@ import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed +ATTR_UNIQUE_ID = "unique_id" CLIENT_ENTITY_ID = "media_player.client_dvr" MAIN_ENTITY_ID = "media_player.main_dvr" IP_ADDRESS = "127.0.0.1" @@ -138,7 +139,7 @@ def main_dtv(): def dtv_side_effect(client_dtv, main_dtv): """Fixture to create DIRECTV instance for main and client.""" - def mock_dtv(ip, port, client_addr): + def mock_dtv(ip, port, client_addr="0"): if client_addr != "0": mocked_dtv = client_dtv else: @@ -174,7 +175,7 @@ def platforms(hass, dtv_side_effect, mock_now): "name": "Client DVR", "host": IP_ADDRESS, "port": DEFAULT_PORT, - "device": "1", + "device": "2CA17D1CD30X", }, ] } @@ -272,6 +273,20 @@ class MockDirectvClass: return test_locations + def get_serial_num(self): + """Mock for get_serial_num method.""" + test_serial_num = { + "serialNum": "9999999999", + "status": { + "code": 200, + "commandResult": 0, + "msg": "OK.", + "query": "/info/getSerialNum", + }, + } + + return test_serial_num + def get_standby(self): """Mock for get_standby method.""" return self._standby @@ -290,6 +305,24 @@ class MockDirectvClass: } return test_attributes + def get_version(self): + """Mock for get_version method.""" + test_version = { + "accessCardId": "0021-1495-6572", + "receiverId": "0288 7745 5858", + "status": { + "code": 200, + "commandResult": 0, + "msg": "OK.", + "query": "/info/getVersion", + }, + "stbSoftwareVersion": "0x4ed7", + "systemTime": 1281625203, + "version": "1.2", + } + + return test_version + def key_press(self, keypress): """Mock for key_press method.""" if keypress == "poweron": @@ -391,6 +424,17 @@ async def test_setup_platform_discover_client(hass): assert len(hass.states.async_entity_ids("media_player")) == 3 +async def test_unique_id(hass, platforms): + """Test unique id.""" + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + main = entity_registry.async_get(MAIN_ENTITY_ID) + assert main.unique_id == "028877455858" + + client = entity_registry.async_get(CLIENT_ENTITY_ID) + assert client.unique_id == "2CA17D1CD30X" + + async def test_supported_features(hass, platforms): """Test supported features.""" # Features supported for main DVR From a85808e3257c8b5ae906ecc7e816ffea19ce52b2 Mon Sep 17 00:00:00 2001 From: springstan <46536646+springstan@users.noreply.github.com> Date: Sun, 23 Feb 2020 21:09:24 +0100 Subject: [PATCH 056/416] Add and use time related constants (#32065) * Add and use time related constants * Sort time constants and reuse them in data rate constants * Fix greeneyemonitor import * Fix greeneyemonitor import V2 * Fix failing here_travel_time test * Add TIME_MONTHS and TIME_YEARS * Use TIME_MINUTES in opentherm_gw and isy994 * Add and use TIME_MILLISECONDS * Change inconsistent ones * Add TIME_MICROSECONDS and TIME_WEEKS * Use constants in apcupsd and travisci * Fix import error in upnp sensor.py * Fix isy994 sensor.py using TIME_YEARS * Fix dyson tests * Use TIME_SECONDS in more places * Use TIME_DAYS in google wifi --- homeassistant/components/adguard/sensor.py | 3 +- homeassistant/components/apcupsd/sensor.py | 12 +++- homeassistant/components/bitcoin/sensor.py | 12 +++- homeassistant/components/bizkaibus/sensor.py | 4 +- .../components/bmw_connected_drive/sensor.py | 5 +- homeassistant/components/bom/sensor.py | 5 +- homeassistant/components/brother/const.py | 5 +- homeassistant/components/buienradar/sensor.py | 19 ++--- .../components/cert_expiry/sensor.py | 3 +- .../components/comfoconnect/sensor.py | 12 ++-- homeassistant/components/darksky/sensor.py | 26 +++---- homeassistant/components/derivative/sensor.py | 13 +++- homeassistant/components/dsmr/sensor.py | 9 ++- .../components/dublin_bus_transport/sensor.py | 4 +- homeassistant/components/dyson/sensor.py | 4 +- homeassistant/components/ebox/sensor.py | 4 +- homeassistant/components/ebusd/const.py | 8 ++- .../entur_public_transport/sensor.py | 3 +- homeassistant/components/fido/sensor.py | 14 ++-- homeassistant/components/fitbit/sensor.py | 43 +++++++----- homeassistant/components/foobot/sensor.py | 3 +- .../components/garmin_connect/const.py | 70 +++++++++++++------ .../components/google_travel_time/sensor.py | 3 +- .../components/google_wifi/sensor.py | 3 +- .../components/greeneye_monitor/__init__.py | 11 ++- .../components/greeneye_monitor/sensor.py | 18 +++-- .../components/here_travel_time/sensor.py | 5 +- .../components/history_stats/sensor.py | 3 +- homeassistant/components/homematic/sensor.py | 3 +- .../components/homematicip_cloud/sensor.py | 3 +- homeassistant/components/huawei_lte/const.py | 2 - homeassistant/components/huawei_lte/sensor.py | 7 +- .../components/hydrawise/__init__.py | 9 ++- .../components/integration/sensor.py | 13 +++- .../components/irish_rail_transport/sensor.py | 4 +- homeassistant/components/isy994/sensor.py | 44 +++++++----- homeassistant/components/juicenet/sensor.py | 4 +- homeassistant/components/lcn/services.py | 5 +- homeassistant/components/lyft/sensor.py | 3 +- .../components/meteo_france/const.py | 6 +- .../components/minecraft_server/const.py | 1 - .../components/minecraft_server/sensor.py | 4 +- homeassistant/components/mvglive/sensor.py | 4 +- homeassistant/components/netatmo/sensor.py | 5 +- homeassistant/components/nmbs/sensor.py | 3 +- homeassistant/components/nut/sensor.py | 28 ++++---- homeassistant/components/nzbget/sensor.py | 8 ++- .../components/octoprint/__init__.py | 11 ++- homeassistant/components/openevse/sensor.py | 3 +- .../components/opentherm_gw/const.py | 18 +++-- homeassistant/components/openuv/sensor.py | 13 ++-- .../components/openweathermap/sensor.py | 3 +- .../components/raincloud/__init__.py | 6 +- .../components/rejseplanen/sensor.py | 4 +- .../components/rmvtransport/sensor.py | 4 +- homeassistant/components/saj/sensor.py | 5 +- .../components/speedtestdotnet/const.py | 4 +- .../components/tellduslive/sensor.py | 8 ++- homeassistant/components/tmb/sensor.py | 4 +- .../trafikverket_weatherstation/sensor.py | 9 ++- .../components/transport_nsw/sensor.py | 10 ++- homeassistant/components/travisci/sensor.py | 3 +- .../components/uk_transport/sensor.py | 4 +- homeassistant/components/upnp/sensor.py | 3 +- .../components/viaggiatreno/sensor.py | 4 +- .../components/waze_travel_time/sensor.py | 3 +- homeassistant/components/whois/sensor.py | 4 +- homeassistant/components/withings/const.py | 3 +- homeassistant/components/withings/sensor.py | 13 ++-- homeassistant/components/wsdot/sensor.py | 3 +- homeassistant/components/yr/sensor.py | 5 +- homeassistant/components/zamg/sensor.py | 10 ++- .../zha/core/channels/smartenergy.py | 19 ++--- homeassistant/const.py | 33 ++++++--- tests/components/derivative/test_sensor.py | 40 ++++++++--- tests/components/dsmr/test_sensor.py | 7 +- tests/components/dyson/test_sensor.py | 6 +- .../here_travel_time/test_sensor.py | 14 ++-- .../homematicip_cloud/test_sensor.py | 3 +- tests/components/integration/test_sensor.py | 3 +- tests/components/yr/test_sensor.py | 5 +- 81 files changed, 480 insertions(+), 287 deletions(-) diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index e5618282a97..9d0d5245d80 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -11,6 +11,7 @@ from homeassistant.components.adguard.const import ( DOMAIN, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TIME_MILLISECONDS from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.typing import HomeAssistantType @@ -206,7 +207,7 @@ class AdGuardHomeAverageProcessingTimeSensor(AdGuardHomeSensor): "AdGuard Average Processing Speed", "mdi:speedometer", "average_speed", - "ms", + TIME_MILLISECONDS, ) async def _adguard_update(self) -> None: diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 255eb1624ff..7947ba75999 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -6,7 +6,13 @@ import voluptuous as vol from homeassistant.components import apcupsd from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_RESOURCES, POWER_WATT, TEMP_CELSIUS +from homeassistant.const import ( + CONF_RESOURCES, + POWER_WATT, + TEMP_CELSIUS, + TIME_MINUTES, + TIME_SECONDS, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -84,8 +90,8 @@ SENSOR_TYPES = { SPECIFIC_UNITS = {"ITEMP": TEMP_CELSIUS} INFERRED_UNITS = { - " Minutes": "min", - " Seconds": "sec", + " Minutes": TIME_MINUTES, + " Seconds": TIME_SECONDS, " Percent": "%", " Volts": "V", " Ampere": "A", diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py index 6a8651be6dc..cf2b76bf25f 100644 --- a/homeassistant/components/bitcoin/sensor.py +++ b/homeassistant/components/bitcoin/sensor.py @@ -6,7 +6,13 @@ from blockchain import exchangerates, statistics import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_CURRENCY, CONF_DISPLAY_OPTIONS +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_CURRENCY, + CONF_DISPLAY_OPTIONS, + TIME_MINUTES, + TIME_SECONDS, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -27,9 +33,9 @@ OPTION_TYPES = { "btc_mined": ["Mined", "BTC"], "trade_volume_usd": ["Trade volume", "USD"], "difficulty": ["Difficulty", None], - "minutes_between_blocks": ["Time between Blocks", "min"], + "minutes_between_blocks": ["Time between Blocks", TIME_MINUTES], "number_of_transactions": ["No. of Transactions", None], - "hash_rate": ["Hash rate", "PH/s"], + "hash_rate": ["Hash rate", f"PH/{TIME_SECONDS}"], "timestamp": ["Timestamp", None], "mined_blocks": ["Mined Blocks", None], "blocks_size": ["Block size", None], diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py index 931fbbb834d..c58873473d5 100644 --- a/homeassistant/components/bizkaibus/sensor.py +++ b/homeassistant/components/bizkaibus/sensor.py @@ -6,7 +6,7 @@ from bizkaibus.bizkaibus import BizkaibusData import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -62,7 +62,7 @@ class BizkaibusSensor(Entity): @property def unit_of_measurement(self): """Return the unit of measurement of the sensor.""" - return "minutes" + return TIME_MINUTES def update(self): """Get the latest data from the webservice.""" diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 3c40900bed8..7fb3da8b883 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -7,6 +7,7 @@ from homeassistant.const import ( CONF_UNIT_SYSTEM_IMPERIAL, LENGTH_KILOMETERS, LENGTH_MILES, + TIME_HOURS, VOLUME_GALLONS, VOLUME_LITERS, ) @@ -24,7 +25,7 @@ ATTR_TO_HA_METRIC = { "remaining_range_fuel": ["mdi:map-marker-distance", LENGTH_KILOMETERS], "max_range_electric": ["mdi:map-marker-distance", LENGTH_KILOMETERS], "remaining_fuel": ["mdi:gas-station", VOLUME_LITERS], - "charging_time_remaining": ["mdi:update", "h"], + "charging_time_remaining": ["mdi:update", TIME_HOURS], "charging_status": ["mdi:battery-charging", None], # No icon as this is dealt with directly as a special case in icon() "charging_level_hv": [None, "%"], @@ -37,7 +38,7 @@ ATTR_TO_HA_IMPERIAL = { "remaining_range_fuel": ["mdi:map-marker-distance", LENGTH_MILES], "max_range_electric": ["mdi:map-marker-distance", LENGTH_MILES], "remaining_fuel": ["mdi:gas-station", VOLUME_GALLONS], - "charging_time_remaining": ["mdi:update", "h"], + "charging_time_remaining": ["mdi:update", TIME_HOURS], "charging_status": ["mdi:battery-charging", None], # No icon as this is dealt with directly as a special case in icon() "charging_level_hv": [None, "%"], diff --git a/homeassistant/components/bom/sensor.py b/homeassistant/components/bom/sensor.py index bd57e20edaa..0981f1b0a86 100644 --- a/homeassistant/components/bom/sensor.py +++ b/homeassistant/components/bom/sensor.py @@ -20,6 +20,7 @@ from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_CELSIUS, + TIME_HOURS, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -59,7 +60,7 @@ SENSOR_TYPES = { "cloud_type_id": ["Cloud Type ID", None], "cloud_type": ["Cloud Type", None], "delta_t": ["Delta Temp C", TEMP_CELSIUS], - "gust_kmh": ["Wind Gust kmh", "km/h"], + "gust_kmh": ["Wind Gust kmh", f"km/{TIME_HOURS}"], "gust_kt": ["Wind Gust kt", "kt"], "air_temp": ["Air Temp C", TEMP_CELSIUS], "dewpt": ["Dew Point C", TEMP_CELSIUS], @@ -76,7 +77,7 @@ SENSOR_TYPES = { "vis_km": ["Visability km", "km"], "weather": ["Weather", None], "wind_dir": ["Wind Direction", None], - "wind_spd_kmh": ["Wind Speed kmh", "km/h"], + "wind_spd_kmh": ["Wind Speed kmh", f"km/{TIME_HOURS}"], "wind_spd_kt": ["Wind Speed kt", "kt"], } diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py index fdb7cd82b9c..d3b7c5e2a78 100644 --- a/homeassistant/components/brother/const.py +++ b/homeassistant/components/brother/const.py @@ -1,4 +1,6 @@ """Constants for Brother integration.""" +from homeassistant.const import TIME_DAYS + ATTR_BELT_UNIT_REMAINING_LIFE = "belt_unit_remaining_life" ATTR_BLACK_INK_REMAINING = "black_ink_remaining" ATTR_BLACK_TONER_REMAINING = "black_toner_remaining" @@ -28,7 +30,6 @@ ATTR_YELLOW_TONER_REMAINING = "yellow_toner_remaining" DOMAIN = "brother" UNIT_PAGES = "p" -UNIT_DAYS = "days" UNIT_PERCENT = "%" PRINTER_TYPES = ["laser", "ink"] @@ -127,6 +128,6 @@ SENSOR_TYPES = { ATTR_UPTIME: { ATTR_ICON: "mdi:timer", ATTR_LABEL: ATTR_UPTIME.title(), - ATTR_UNIT: UNIT_DAYS, + ATTR_UNIT: TIME_DAYS, }, } diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index f642fc2e249..fa3e7fd343b 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -28,6 +28,7 @@ from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_CELSIUS, + TIME_HOURS, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -67,18 +68,18 @@ SENSOR_TYPES = { "humidity": ["Humidity", "%", "mdi:water-percent"], "temperature": ["Temperature", TEMP_CELSIUS, "mdi:thermometer"], "groundtemperature": ["Ground temperature", TEMP_CELSIUS, "mdi:thermometer"], - "windspeed": ["Wind speed", "km/h", "mdi:weather-windy"], + "windspeed": ["Wind speed", f"km/{TIME_HOURS}", "mdi:weather-windy"], "windforce": ["Wind force", "Bft", "mdi:weather-windy"], "winddirection": ["Wind direction", None, "mdi:compass-outline"], "windazimuth": ["Wind direction azimuth", "°", "mdi:compass-outline"], "pressure": ["Pressure", "hPa", "mdi:gauge"], "visibility": ["Visibility", "km", None], - "windgust": ["Wind gust", "km/h", "mdi:weather-windy"], - "precipitation": ["Precipitation", "mm/h", "mdi:weather-pouring"], + "windgust": ["Wind gust", f"km/{TIME_HOURS}", "mdi:weather-windy"], + "precipitation": ["Precipitation", f"mm/{TIME_HOURS}", "mdi:weather-pouring"], "irradiance": ["Irradiance", "W/m2", "mdi:sunglasses"], "precipitation_forecast_average": [ "Precipitation forecast average", - "mm/h", + f"mm/{TIME_HOURS}", "mdi:weather-pouring", ], "precipitation_forecast_total": [ @@ -132,11 +133,11 @@ SENSOR_TYPES = { "windforce_3d": ["Wind force 3d", "Bft", "mdi:weather-windy"], "windforce_4d": ["Wind force 4d", "Bft", "mdi:weather-windy"], "windforce_5d": ["Wind force 5d", "Bft", "mdi:weather-windy"], - "windspeed_1d": ["Wind speed 1d", "km/h", "mdi:weather-windy"], - "windspeed_2d": ["Wind speed 2d", "km/h", "mdi:weather-windy"], - "windspeed_3d": ["Wind speed 3d", "km/h", "mdi:weather-windy"], - "windspeed_4d": ["Wind speed 4d", "km/h", "mdi:weather-windy"], - "windspeed_5d": ["Wind speed 5d", "km/h", "mdi:weather-windy"], + "windspeed_1d": ["Wind speed 1d", f"km/{TIME_HOURS}", "mdi:weather-windy"], + "windspeed_2d": ["Wind speed 2d", f"km/{TIME_HOURS}", "mdi:weather-windy"], + "windspeed_3d": ["Wind speed 3d", f"km/{TIME_HOURS}", "mdi:weather-windy"], + "windspeed_4d": ["Wind speed 4d", f"km/{TIME_HOURS}", "mdi:weather-windy"], + "windspeed_5d": ["Wind speed 5d", f"km/{TIME_HOURS}", "mdi:weather-windy"], "winddirection_1d": ["Wind direction 1d", None, "mdi:compass-outline"], "winddirection_2d": ["Wind direction 2d", None, "mdi:compass-outline"], "winddirection_3d": ["Wind direction 3d", None, "mdi:compass-outline"], diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index 3a76575dfdd..b4437ca5834 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_START, + TIME_DAYS, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -85,7 +86,7 @@ class SSLCertificate(Entity): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "days" + return TIME_DAYS @property def state(self): diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index 3e3507ea48d..1d189508960 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -31,6 +31,8 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, POWER_WATT, TEMP_CELSIUS, + TIME_DAYS, + TIME_HOURS, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -156,14 +158,14 @@ SENSOR_TYPES = { ATTR_AIR_FLOW_SUPPLY: { ATTR_DEVICE_CLASS: None, ATTR_LABEL: "Supply airflow", - ATTR_UNIT: "m³/h", + ATTR_UNIT: f"m³/{TIME_HOURS}", ATTR_ICON: "mdi:fan", ATTR_ID: SENSOR_FAN_SUPPLY_FLOW, }, ATTR_AIR_FLOW_EXHAUST: { ATTR_DEVICE_CLASS: None, ATTR_LABEL: "Exhaust airflow", - ATTR_UNIT: "m³/h", + ATTR_UNIT: f"m³/{TIME_HOURS}", ATTR_ICON: "mdi:fan", ATTR_ID: SENSOR_FAN_EXHAUST_FLOW, }, @@ -177,7 +179,7 @@ SENSOR_TYPES = { ATTR_DAYS_TO_REPLACE_FILTER: { ATTR_DEVICE_CLASS: None, ATTR_LABEL: "Days to replace filter", - ATTR_UNIT: "days", + ATTR_UNIT: TIME_DAYS, ATTR_ICON: "mdi:calendar", ATTR_ID: SENSOR_DAYS_TO_REPLACE_FILTER, }, @@ -194,7 +196,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_RESOURCES, default=[]): vol.All( cv.ensure_list, [vol.In(SENSOR_TYPES)] - ), + ) } ) @@ -229,7 +231,7 @@ class ComfoConnectSensor(Entity): async def async_added_to_hass(self): """Register for sensor updates.""" _LOGGER.debug( - "Registering for sensor %s (%d)", self._sensor_type, self._sensor_id, + "Registering for sensor %s (%d)", self._sensor_type, self._sensor_id ) async_dispatcher_connect( self.hass, diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py index 9f99b37a201..cecff626813 100644 --- a/homeassistant/components/darksky/sensor.py +++ b/homeassistant/components/darksky/sensor.py @@ -15,6 +15,8 @@ from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_SCAN_INTERVAL, + TIME_HOURS, + TIME_SECONDS, UNIT_UV_INDEX, ) import homeassistant.helpers.config_validation as cv @@ -99,11 +101,11 @@ SENSOR_TYPES = { ], "precip_intensity": [ "Precip Intensity", - "mm/h", + f"mm/{TIME_HOURS}", "in", - "mm/h", - "mm/h", - "mm/h", + f"mm/{TIME_HOURS}", + f"mm/{TIME_HOURS}", + f"mm/{TIME_HOURS}", "mdi:weather-rainy", ["currently", "minutely", "hourly", "daily"], ], @@ -159,9 +161,9 @@ SENSOR_TYPES = { ], "wind_speed": [ "Wind Speed", - "m/s", + f"m/{TIME_SECONDS}", "mph", - "km/h", + f"km/{TIME_HOURS}", "mph", "mph", "mdi:weather-windy", @@ -179,9 +181,9 @@ SENSOR_TYPES = { ], "wind_gust": [ "Wind Gust", - "m/s", + f"m/{TIME_SECONDS}", "mph", - "km/h", + f"km/{TIME_HOURS}", "mph", "mph", "mdi:weather-windy-variant", @@ -319,11 +321,11 @@ SENSOR_TYPES = { ], "precip_intensity_max": [ "Daily Max Precip Intensity", - "mm/h", + f"mm/{TIME_HOURS}", "in", - "mm/h", - "mm/h", - "mm/h", + f"mm/{TIME_HOURS}", + f"mm/{TIME_HOURS}", + f"mm/{TIME_HOURS}", "mdi:thermometer", ["daily"], ], diff --git a/homeassistant/components/derivative/sensor.py b/homeassistant/components/derivative/sensor.py index 5e68b268685..202c5885887 100644 --- a/homeassistant/components/derivative/sensor.py +++ b/homeassistant/components/derivative/sensor.py @@ -11,6 +11,10 @@ from homeassistant.const import ( CONF_SOURCE, STATE_UNAVAILABLE, STATE_UNKNOWN, + TIME_DAYS, + TIME_HOURS, + TIME_MINUTES, + TIME_SECONDS, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -42,7 +46,12 @@ UNIT_PREFIXES = { } # SI Time prefixes -UNIT_TIME = {"s": 1, "min": 60, "h": 60 * 60, "d": 24 * 60 * 60} +UNIT_TIME = { + TIME_SECONDS: 1, + TIME_MINUTES: 60, + TIME_HOURS: 60 * 60, + TIME_DAYS: 24 * 60 * 60, +} ICON = "mdi:chart-line" @@ -55,7 +64,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_SOURCE): cv.entity_id, vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int), vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), - vol.Optional(CONF_UNIT_TIME, default="h"): vol.In(UNIT_TIME), + vol.Optional(CONF_UNIT_TIME, default=TIME_HOURS): vol.In(UNIT_TIME), vol.Optional(CONF_UNIT): cv.string, vol.Optional(CONF_TIME_WINDOW, default=DEFAULT_TIME_WINDOW): cv.time_period, } diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 54c8e3e29b2..6ffc4a3106c 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -9,7 +9,12 @@ import serial import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, + TIME_HOURS, +) from homeassistant.core import CoreState import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -303,4 +308,4 @@ class DerivativeDSMREntity(DSMREntity): """Return the unit of measurement of this entity, per hour, if any.""" unit = self.get_dsmr_object_attr("unit") if unit: - return unit + "/h" + return f"{unit}/{TIME_HOURS}" diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py index b517ecf6466..5de0b62a4a9 100644 --- a/homeassistant/components/dublin_bus_transport/sensor.py +++ b/homeassistant/components/dublin_bus_transport/sensor.py @@ -11,7 +11,7 @@ import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util @@ -109,7 +109,7 @@ class DublinPublicTransportSensor(Entity): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES @property def icon(self): diff --git a/homeassistant/components/dyson/sensor.py b/homeassistant/components/dyson/sensor.py index 2fdd3cd6c1f..870086e7550 100644 --- a/homeassistant/components/dyson/sensor.py +++ b/homeassistant/components/dyson/sensor.py @@ -4,7 +4,7 @@ import logging from libpurecool.dyson_pure_cool import DysonPureCool from libpurecool.dyson_pure_cool_link import DysonPureCoolLink -from homeassistant.const import STATE_OFF, TEMP_CELSIUS +from homeassistant.const import STATE_OFF, TEMP_CELSIUS, TIME_HOURS from homeassistant.helpers.entity import Entity from . import DYSON_DEVICES @@ -12,7 +12,7 @@ from . import DYSON_DEVICES SENSOR_UNITS = { "air_quality": None, "dust": None, - "filter_life": "hours", + "filter_life": TIME_HOURS, "humidity": "%", } diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index f1221bd1e77..208ffb99543 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, DATA_GIGABITS, + TIME_DAYS, ) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -26,7 +27,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) PRICE = "CAD" -DAYS = "days" PERCENT = "%" DEFAULT_NAME = "EBox" @@ -39,7 +39,7 @@ SENSOR_TYPES = { "usage": ["Usage", PERCENT, "mdi:percent"], "balance": ["Balance", PRICE, "mdi:square-inc-cash"], "limit": ["Data limit", DATA_GIGABITS, "mdi:download"], - "days_left": ["Days left", DAYS, "mdi:calendar-today"], + "days_left": ["Days left", TIME_DAYS, "mdi:calendar-today"], "before_offpeak_download": [ "Download before offpeak", DATA_GIGABITS, diff --git a/homeassistant/components/ebusd/const.py b/homeassistant/components/ebusd/const.py index ec097a153c9..10ed0b68e87 100644 --- a/homeassistant/components/ebusd/const.py +++ b/homeassistant/components/ebusd/const.py @@ -1,8 +1,12 @@ """Constants for ebus component.""" -from homeassistant.const import ENERGY_KILO_WATT_HOUR, PRESSURE_BAR, TEMP_CELSIUS +from homeassistant.const import ( + ENERGY_KILO_WATT_HOUR, + PRESSURE_BAR, + TEMP_CELSIUS, + TIME_SECONDS, +) DOMAIN = "ebusd" -TIME_SECONDS = "seconds" # SensorTypes from ebusdpy module : # 0='decimal', 1='time-schedule', 2='switch', 3='string', 4='value;status' diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index 2ecae21824e..156a0e601b4 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_NAME, CONF_SHOW_ON_MAP, + TIME_MINUTES, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -183,7 +184,7 @@ class EnturPublicTransportSensor(Entity): @property def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES @property def icon(self) -> str: diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index 9f2eeb9bd7c..951d13dadb4 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, DATA_KILOBITS, + TIME_MINUTES, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -27,7 +28,6 @@ _LOGGER = logging.getLogger(__name__) PRICE = "CAD" MESSAGES = "messages" -MINUTES = "minutes" DEFAULT_NAME = "Fido" @@ -49,12 +49,12 @@ SENSOR_TYPES = { "text_int_used": ["International text used", MESSAGES, "mdi:message-alert"], "text_int_limit": ["International text limit", MESSAGES, "mdi:message-alert"], "text_int_remaining": ["International remaining", MESSAGES, "mdi:message-alert"], - "talk_used": ["Talk used", MINUTES, "mdi:cellphone"], - "talk_limit": ["Talk limit", MINUTES, "mdi:cellphone"], - "talk_remaining": ["Talk remaining", MINUTES, "mdi:cellphone"], - "other_talk_used": ["Other Talk used", MINUTES, "mdi:cellphone"], - "other_talk_limit": ["Other Talk limit", MINUTES, "mdi:cellphone"], - "other_talk_remaining": ["Other Talk remaining", MINUTES, "mdi:cellphone"], + "talk_used": ["Talk used", TIME_MINUTES, "mdi:cellphone"], + "talk_limit": ["Talk limit", TIME_MINUTES, "mdi:cellphone"], + "talk_remaining": ["Talk remaining", TIME_MINUTES, "mdi:cellphone"], + "other_talk_used": ["Other Talk used", TIME_MINUTES, "mdi:cellphone"], + "other_talk_limit": ["Other Talk limit", TIME_MINUTES, "mdi:cellphone"], + "other_talk_remaining": ["Other Talk remaining", TIME_MINUTES, "mdi:cellphone"], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 5ddb63ef899..8c17e3b9c4c 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -11,7 +11,12 @@ import voluptuous as vol from homeassistant.components.http import HomeAssistantView from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_UNIT_SYSTEM +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_UNIT_SYSTEM, + TIME_MILLISECONDS, + TIME_MINUTES, +) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -48,14 +53,14 @@ FITBIT_RESOURCES_LIST = { "activities/elevation": ["Elevation", "", "walk"], "activities/floors": ["Floors", "floors", "walk"], "activities/heart": ["Resting Heart Rate", "bpm", "heart-pulse"], - "activities/minutesFairlyActive": ["Minutes Fairly Active", "minutes", "walk"], - "activities/minutesLightlyActive": ["Minutes Lightly Active", "minutes", "walk"], + "activities/minutesFairlyActive": ["Minutes Fairly Active", TIME_MINUTES, "walk"], + "activities/minutesLightlyActive": ["Minutes Lightly Active", TIME_MINUTES, "walk"], "activities/minutesSedentary": [ "Minutes Sedentary", - "minutes", + TIME_MINUTES, "seat-recline-normal", ], - "activities/minutesVeryActive": ["Minutes Very Active", "minutes", "run"], + "activities/minutesVeryActive": ["Minutes Very Active", TIME_MINUTES, "run"], "activities/steps": ["Steps", "steps", "walk"], "activities/tracker/activityCalories": ["Tracker Activity Calories", "cal", "fire"], "activities/tracker/calories": ["Tracker Calories", "cal", "fire"], @@ -64,22 +69,22 @@ FITBIT_RESOURCES_LIST = { "activities/tracker/floors": ["Tracker Floors", "floors", "walk"], "activities/tracker/minutesFairlyActive": [ "Tracker Minutes Fairly Active", - "minutes", + TIME_MINUTES, "walk", ], "activities/tracker/minutesLightlyActive": [ "Tracker Minutes Lightly Active", - "minutes", + TIME_MINUTES, "walk", ], "activities/tracker/minutesSedentary": [ "Tracker Minutes Sedentary", - "minutes", + TIME_MINUTES, "seat-recline-normal", ], "activities/tracker/minutesVeryActive": [ "Tracker Minutes Very Active", - "minutes", + TIME_MINUTES, "run", ], "activities/tracker/steps": ["Tracker Steps", "steps", "walk"], @@ -89,17 +94,21 @@ FITBIT_RESOURCES_LIST = { "devices/battery": ["Battery", None, None], "sleep/awakeningsCount": ["Awakenings Count", "times awaken", "sleep"], "sleep/efficiency": ["Sleep Efficiency", "%", "sleep"], - "sleep/minutesAfterWakeup": ["Minutes After Wakeup", "minutes", "sleep"], - "sleep/minutesAsleep": ["Sleep Minutes Asleep", "minutes", "sleep"], - "sleep/minutesAwake": ["Sleep Minutes Awake", "minutes", "sleep"], - "sleep/minutesToFallAsleep": ["Sleep Minutes to Fall Asleep", "minutes", "sleep"], + "sleep/minutesAfterWakeup": ["Minutes After Wakeup", TIME_MINUTES, "sleep"], + "sleep/minutesAsleep": ["Sleep Minutes Asleep", TIME_MINUTES, "sleep"], + "sleep/minutesAwake": ["Sleep Minutes Awake", TIME_MINUTES, "sleep"], + "sleep/minutesToFallAsleep": [ + "Sleep Minutes to Fall Asleep", + TIME_MINUTES, + "sleep", + ], "sleep/startTime": ["Sleep Start Time", None, "clock"], - "sleep/timeInBed": ["Sleep Time in Bed", "minutes", "hotel"], + "sleep/timeInBed": ["Sleep Time in Bed", TIME_MINUTES, "hotel"], } FITBIT_MEASUREMENTS = { "en_US": { - "duration": "ms", + "duration": TIME_MILLISECONDS, "distance": "mi", "elevation": "ft", "height": "in", @@ -110,7 +119,7 @@ FITBIT_MEASUREMENTS = { "battery": "", }, "en_GB": { - "duration": "milliseconds", + "duration": TIME_MILLISECONDS, "distance": "kilometers", "elevation": "meters", "height": "centimeters", @@ -121,7 +130,7 @@ FITBIT_MEASUREMENTS = { "battery": "", }, "metric": { - "duration": "milliseconds", + "duration": TIME_MILLISECONDS, "distance": "kilometers", "elevation": "meters", "height": "centimeters", diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py index efb74e2cc9a..656631f0774 100644 --- a/homeassistant/components/foobot/sensor.py +++ b/homeassistant/components/foobot/sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_TOKEN, CONF_USERNAME, TEMP_CELSIUS, + TIME_SECONDS, ) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -30,7 +31,7 @@ ATTR_VOLATILE_ORGANIC_COMPOUNDS = "VOC" ATTR_FOOBOT_INDEX = "index" SENSOR_TYPES = { - "time": [ATTR_TIME, "s"], + "time": [ATTR_TIME, TIME_SECONDS], "pm": [ATTR_PM2_5, "µg/m3", "mdi:cloud"], "tmp": [ATTR_TEMPERATURE, TEMP_CELSIUS, "mdi:thermometer"], "hum": [ATTR_HUMIDITY, "%", "mdi:water-percent"], diff --git a/homeassistant/components/garmin_connect/const.py b/homeassistant/components/garmin_connect/const.py index b5faeab77b4..c01f11464a1 100644 --- a/homeassistant/components/garmin_connect/const.py +++ b/homeassistant/components/garmin_connect/const.py @@ -1,5 +1,5 @@ """Constants for the Garmin Connect integration.""" -from homeassistant.const import DEVICE_CLASS_TIMESTAMP +from homeassistant.const import DEVICE_CLASS_TIMESTAMP, TIME_MINUTES DOMAIN = "garmin_connect" ATTRIBUTION = "Data provided by garmin.com" @@ -52,20 +52,32 @@ GARMIN_ENTITY_LIST = { False, ], "wellnessKilocalories": ["Wellness KiloCalories", "kcal", "mdi:food", None, False], - "highlyActiveSeconds": ["Highly Active Time", "min", "mdi:fire", None, False], - "activeSeconds": ["Active Time", "min", "mdi:fire", None, True], - "sedentarySeconds": ["Sedentary Time", "min", "mdi:seat", None, True], - "sleepingSeconds": ["Sleeping Time", "min", "mdi:sleep", None, True], - "measurableAwakeDuration": ["Awake Duration", "min", "mdi:sleep", None, True], - "measurableAsleepDuration": ["Sleep Duration", "min", "mdi:sleep", None, True], - "floorsAscendedInMeters": ["Floors Ascended Mtr", "m", "mdi:stairs", None, False], - "floorsDescendedInMeters": [ - "Floors Descended Mtr", - "m", - "mdi:stairs", + "highlyActiveSeconds": [ + "Highly Active Time", + TIME_MINUTES, + "mdi:fire", None, False, ], + "activeSeconds": ["Active Time", TIME_MINUTES, "mdi:fire", None, True], + "sedentarySeconds": ["Sedentary Time", TIME_MINUTES, "mdi:seat", None, True], + "sleepingSeconds": ["Sleeping Time", TIME_MINUTES, "mdi:sleep", None, True], + "measurableAwakeDuration": [ + "Awake Duration", + TIME_MINUTES, + "mdi:sleep", + None, + True, + ], + "measurableAsleepDuration": [ + "Sleep Duration", + TIME_MINUTES, + "mdi:sleep", + None, + True, + ], + "floorsAscendedInMeters": ["Floors Ascended Mtr", "m", "mdi:stairs", None, False], + "floorsDescendedInMeters": ["Floors Descended Mtr", "m", "mdi:stairs", None, False], "floorsAscended": ["Floors Ascended", "floors", "mdi:stairs", None, True], "floorsDescended": ["Floors Descended", "floors", "mdi:stairs", None, True], "userFloorsAscendedGoal": [ @@ -97,46 +109,52 @@ GARMIN_ENTITY_LIST = { "averageStressLevel": ["Avg Stress Level", "", "mdi:flash-alert", None, True], "maxStressLevel": ["Max Stress Level", "", "mdi:flash-alert", None, True], "stressQualifier": ["Stress Qualifier", "", "mdi:flash-alert", None, False], - "stressDuration": ["Stress Duration", "min", "mdi:flash-alert", None, False], + "stressDuration": ["Stress Duration", TIME_MINUTES, "mdi:flash-alert", None, False], "restStressDuration": [ "Rest Stress Duration", - "min", + TIME_MINUTES, "mdi:flash-alert", None, True, ], "activityStressDuration": [ "Activity Stress Duration", - "min", + TIME_MINUTES, "mdi:flash-alert", None, True, ], "uncategorizedStressDuration": [ "Uncat. Stress Duration", - "min", + TIME_MINUTES, "mdi:flash-alert", None, True, ], "totalStressDuration": [ "Total Stress Duration", - "min", + TIME_MINUTES, + "mdi:flash-alert", + None, + True, + ], + "lowStressDuration": [ + "Low Stress Duration", + TIME_MINUTES, "mdi:flash-alert", None, True, ], - "lowStressDuration": ["Low Stress Duration", "min", "mdi:flash-alert", None, True], "mediumStressDuration": [ "Medium Stress Duration", - "min", + TIME_MINUTES, "mdi:flash-alert", None, True, ], "highStressDuration": [ "High Stress Duration", - "min", + TIME_MINUTES, "mdi:flash-alert", None, True, @@ -186,19 +204,25 @@ GARMIN_ENTITY_LIST = { ], "moderateIntensityMinutes": [ "Moderate Intensity", - "min", + TIME_MINUTES, "mdi:flash-alert", None, False, ], "vigorousIntensityMinutes": [ "Vigorous Intensity", - "min", + TIME_MINUTES, + "mdi:run-fast", + None, + False, + ], + "intensityMinutesGoal": [ + "Intensity Goal", + TIME_MINUTES, "mdi:run-fast", None, False, ], - "intensityMinutesGoal": ["Intensity Goal", "min", "mdi:run-fast", None, False], "bodyBatteryChargedValue": [ "Body Battery Charged", "%", diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index 3ee72928fc1..7f106bc7e3e 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_MODE, CONF_NAME, EVENT_HOMEASSISTANT_START, + TIME_MINUTES, ) from homeassistant.helpers import location import homeassistant.helpers.config_validation as cv @@ -188,7 +189,7 @@ class GoogleTravelTimeSensor(Entity): self._hass = hass self._name = name self._options = options - self._unit_of_measurement = "min" + self._unit_of_measurement = TIME_MINUTES self._matrix = None self.valid_api_connection = True diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 9d6f3ea3d58..9dfa26fab75 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -11,6 +11,7 @@ from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, STATE_UNKNOWN, + TIME_DAYS, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -39,7 +40,7 @@ MONITORED_CONDITIONS = { "mdi:checkbox-marked-circle-outline", ], ATTR_NEW_VERSION: [["software", "updateNewVersion"], None, "mdi:update"], - ATTR_UPTIME: [["system", "uptime"], "days", "mdi:timelapse"], + ATTR_UPTIME: [["system", "uptime"], TIME_DAYS, "mdi:timelapse"], ATTR_LAST_RESTART: [["system", "uptime"], None, "mdi:restart"], ATTR_LOCAL_IP: [["wan", "localIpAddress"], None, "mdi:access-point-network"], ATTR_STATUS: [["wan", "online"], None, "mdi:google"], diff --git a/homeassistant/components/greeneye_monitor/__init__.py b/homeassistant/components/greeneye_monitor/__init__.py index dcd383a7463..697a96649ab 100644 --- a/homeassistant/components/greeneye_monitor/__init__.py +++ b/homeassistant/components/greeneye_monitor/__init__.py @@ -9,6 +9,9 @@ from homeassistant.const import ( CONF_PORT, CONF_TEMPERATURE_UNIT, EVENT_HOMEASSISTANT_STOP, + TIME_HOURS, + TIME_MINUTES, + TIME_SECONDS, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import async_load_platform @@ -40,10 +43,6 @@ SENSOR_TYPE_VOLTAGE = "voltage_sensor" TEMPERATURE_UNIT_CELSIUS = "C" -TIME_UNIT_SECOND = "s" -TIME_UNIT_MINUTE = "min" -TIME_UNIT_HOUR = "h" - TEMPERATURE_SENSOR_SCHEMA = vol.Schema( {vol.Required(CONF_NUMBER): vol.Range(1, 8), vol.Required(CONF_NAME): cv.string} ) @@ -69,8 +68,8 @@ PULSE_COUNTER_SCHEMA = vol.Schema( vol.Required(CONF_NAME): cv.string, vol.Required(CONF_COUNTED_QUANTITY): cv.string, vol.Optional(CONF_COUNTED_QUANTITY_PER_PULSE, default=1.0): vol.Coerce(float), - vol.Optional(CONF_TIME_UNIT, default=TIME_UNIT_SECOND): vol.Any( - TIME_UNIT_SECOND, TIME_UNIT_MINUTE, TIME_UNIT_HOUR + vol.Optional(CONF_TIME_UNIT, default=TIME_SECONDS): vol.Any( + TIME_SECONDS, TIME_MINUTES, TIME_HOURS ), } ) diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index 2640c701f92..b88b2567750 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -1,7 +1,14 @@ """Support for the sensors in a GreenEye Monitor.""" import logging -from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, POWER_WATT +from homeassistant.const import ( + CONF_NAME, + CONF_TEMPERATURE_UNIT, + POWER_WATT, + TIME_HOURS, + TIME_MINUTES, + TIME_SECONDS, +) from homeassistant.helpers.entity import Entity from . import ( @@ -17,9 +24,6 @@ from . import ( SENSOR_TYPE_PULSE_COUNTER, SENSOR_TYPE_TEMPERATURE, SENSOR_TYPE_VOLTAGE, - TIME_UNIT_HOUR, - TIME_UNIT_MINUTE, - TIME_UNIT_SECOND, ) _LOGGER = logging.getLogger(__name__) @@ -235,11 +239,11 @@ class PulseCounter(GEMSensor): @property def _seconds_per_time_unit(self): """Return the number of seconds in the given display time unit.""" - if self._time_unit == TIME_UNIT_SECOND: + if self._time_unit == TIME_SECONDS: return 1 - if self._time_unit == TIME_UNIT_MINUTE: + if self._time_unit == TIME_MINUTES: return 60 - if self._time_unit == TIME_UNIT_HOUR: + if self._time_unit == TIME_HOURS: return 3600 @property diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 316e73dc096..8113548b5ca 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, EVENT_HOMEASSISTANT_START, + TIME_MINUTES, ) from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import location @@ -85,8 +86,6 @@ ATTR_DURATION_IN_TRAFFIC = "duration_in_traffic" ATTR_ORIGIN_NAME = "origin_name" ATTR_DESTINATION_NAME = "destination_name" -UNIT_OF_MEASUREMENT = "min" - SCAN_INTERVAL = timedelta(minutes=5) NO_ROUTE_ERROR_MESSAGE = "HERE could not find a route based on the input" @@ -209,7 +208,7 @@ class HERETravelTimeSensor(Entity): self._origin_entity_id = origin_entity_id self._destination_entity_id = destination_entity_id self._here_data = here_data - self._unit_of_measurement = UNIT_OF_MEASUREMENT + self._unit_of_measurement = TIME_MINUTES self._attrs = { ATTR_UNIT_SYSTEM: self._here_data.units, ATTR_MODE: self._here_data.travel_mode, diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 3eb604b3957..0b1289ab05f 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_STATE, CONF_TYPE, EVENT_HOMEASSISTANT_START, + TIME_HOURS, ) from homeassistant.core import callback from homeassistant.exceptions import TemplateError @@ -35,7 +36,7 @@ CONF_TYPE_COUNT = "count" CONF_TYPE_KEYS = [CONF_TYPE_TIME, CONF_TYPE_RATIO, CONF_TYPE_COUNT] DEFAULT_NAME = "unnamed statistics" -UNITS = {CONF_TYPE_TIME: "h", CONF_TYPE_RATIO: "%", CONF_TYPE_COUNT: ""} +UNITS = {CONF_TYPE_TIME: TIME_HOURS, CONF_TYPE_RATIO: "%", CONF_TYPE_COUNT: ""} ICON = "mdi:chart-line" ATTR_VALUE = "value" diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index bba8325650d..e7ab5ddfefc 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -8,6 +8,7 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, ENERGY_WATT_HOUR, POWER_WATT, + TIME_HOURS, ) from .const import ATTR_DISCOVER_DEVICES @@ -47,7 +48,7 @@ HM_UNIT_HA_CAST = { "LOWEST_ILLUMINATION": "lx", "HIGHEST_ILLUMINATION": "lx", "RAIN_COUNTER": "mm", - "WIND_SPEED": "km/h", + "WIND_SPEED": f"km/{TIME_HOURS}", "WIND_DIRECTION": "°", "WIND_DIRECTION_RANGE": "°", "SUNSHINEDURATION": "#", diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index d6a226a83dc..f38eea840c6 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -32,6 +32,7 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, POWER_WATT, TEMP_CELSIUS, + TIME_HOURS, ) from homeassistant.helpers.typing import HomeAssistantType @@ -332,7 +333,7 @@ class HomematicipWindspeedSensor(HomematicipGenericDevice): @property def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" - return "km/h" + return f"km/{TIME_HOURS}" @property def device_state_attributes(self) -> Dict[str, Any]: diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index 6d699420283..41814d5ae10 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -8,8 +8,6 @@ DEFAULT_NOTIFY_SERVICE_NAME = DOMAIN UPDATE_SIGNAL = f"{DOMAIN}_update" UPDATE_OPTIONS_SIGNAL = f"{DOMAIN}_options_update" -UNIT_SECONDS = "s" - CONNECTION_TIMEOUT = 10 NOTIFY_SUPPRESS_TIMEOUT = 30 diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 54c5441c6e2..8ca5e02dcdd 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -10,7 +10,7 @@ from homeassistant.components.sensor import ( DEVICE_CLASS_SIGNAL_STRENGTH, DOMAIN as SENSOR_DOMAIN, ) -from homeassistant.const import CONF_URL, DATA_BYTES, STATE_UNKNOWN +from homeassistant.const import CONF_URL, DATA_BYTES, STATE_UNKNOWN, TIME_SECONDS from . import HuaweiLteBaseEntity from .const import ( @@ -18,7 +18,6 @@ from .const import ( KEY_DEVICE_INFORMATION, KEY_DEVICE_SIGNAL, KEY_MONITORING_TRAFFIC_STATISTICS, - UNIT_SECONDS, ) _LOGGER = logging.getLogger(__name__) @@ -122,7 +121,7 @@ SENSOR_META = { exclude=re.compile(r"^showtraffic$", re.IGNORECASE) ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentConnectTime"): dict( - name="Current connection duration", unit=UNIT_SECONDS, icon="mdi:timer" + name="Current connection duration", unit=TIME_SECONDS, icon="mdi:timer" ), (KEY_MONITORING_TRAFFIC_STATISTICS, "CurrentDownload"): dict( name="Current connection download", unit=DATA_BYTES, icon="mdi:download" @@ -131,7 +130,7 @@ SENSOR_META = { name="Current connection upload", unit=DATA_BYTES, icon="mdi:upload" ), (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalConnectTime"): dict( - name="Total connected duration", unit=UNIT_SECONDS, icon="mdi:timer" + name="Total connected duration", unit=TIME_SECONDS, icon="mdi:timer" ), (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalDownload"): dict( name="Total download", unit=DATA_BYTES, icon="mdi:download" diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index 57ed29d9780..b8ed596d286 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -6,7 +6,12 @@ from hydrawiser.core import Hydrawiser from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol -from homeassistant.const import ATTR_ATTRIBUTION, CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL +from homeassistant.const import ( + ATTR_ATTRIBUTION, + CONF_ACCESS_TOKEN, + CONF_SCAN_INTERVAL, + TIME_MINUTES, +) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send @@ -40,7 +45,7 @@ DEVICE_MAP = { "manual_watering": ["Manual Watering", "mdi:water-pump", "", ""], "next_cycle": ["Next Cycle", "mdi:calendar-clock", "", ""], "status": ["Status", "", "connectivity", ""], - "watering_time": ["Watering Time", "mdi:water-pump", "", "min"], + "watering_time": ["Watering Time", "mdi:water-pump", "", TIME_MINUTES], "rain_sensor": ["Rain Sensor", "", "moisture", ""], } diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 560a7cbd33c..dea7a5083dc 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -10,6 +10,10 @@ from homeassistant.const import ( CONF_NAME, STATE_UNAVAILABLE, STATE_UNKNOWN, + TIME_DAYS, + TIME_HOURS, + TIME_MINUTES, + TIME_SECONDS, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -38,7 +42,12 @@ INTEGRATION_METHOD = [TRAPEZOIDAL_METHOD, LEFT_METHOD, RIGHT_METHOD] UNIT_PREFIXES = {None: 1, "k": 10 ** 3, "G": 10 ** 6, "T": 10 ** 9} # SI Time prefixes -UNIT_TIME = {"s": 1, "min": 60, "h": 60 * 60, "d": 24 * 60 * 60} +UNIT_TIME = { + TIME_SECONDS: 1, + TIME_MINUTES: 60, + TIME_HOURS: 60 * 60, + TIME_DAYS: 24 * 60 * 60, +} ICON = "mdi:chart-histogram" @@ -50,7 +59,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int), vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES), - vol.Optional(CONF_UNIT_TIME, default="h"): vol.In(UNIT_TIME), + vol.Optional(CONF_UNIT_TIME, default=TIME_HOURS): vol.In(UNIT_TIME), vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_METHOD, default=TRAPEZOIDAL_METHOD): vol.In( INTEGRATION_METHOD diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py index 883f4ed7b39..3bb7da52e22 100644 --- a/homeassistant/components/irish_rail_transport/sensor.py +++ b/homeassistant/components/irish_rail_transport/sensor.py @@ -6,7 +6,7 @@ from pyirishrail.pyirishrail import IrishRailRTPI import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -118,7 +118,7 @@ class IrishRailTransportSensor(Entity): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES @property def icon(self): diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index a9746b004d0..563f292c489 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -3,7 +3,19 @@ import logging from typing import Callable from homeassistant.components.sensor import DOMAIN -from homeassistant.const import POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_UV_INDEX +from homeassistant.const import ( + POWER_WATT, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TIME_DAYS, + TIME_HOURS, + TIME_MILLISECONDS, + TIME_MINUTES, + TIME_MONTHS, + TIME_SECONDS, + TIME_YEARS, + UNIT_UV_INDEX, +) from homeassistant.helpers.typing import ConfigType from . import ISY994_NODES, ISY994_WEATHER, ISYDevice @@ -12,22 +24,22 @@ _LOGGER = logging.getLogger(__name__) UOM_FRIENDLY_NAME = { "1": "amp", - "3": "btu/h", + "3": f"btu/{TIME_HOURS}", "4": TEMP_CELSIUS, "5": "cm", "6": "ft³", - "7": "ft³/min", + "7": f"ft³/{TIME_MINUTES}", "8": "m³", - "9": "day", - "10": "days", + "9": TIME_DAYS, + "10": TIME_DAYS, "12": "dB", "13": "dB A", "14": "°", "16": "macroseismic", "17": TEMP_FAHRENHEIT, "18": "ft", - "19": "hour", - "20": "hours", + "19": TIME_HOURS, + "20": TIME_HOURS, "21": "abs. humidity (%)", "22": "rel. humidity (%)", "23": "inHg", @@ -47,24 +59,24 @@ UOM_FRIENDLY_NAME = { "37": "mercalli", "38": "m", "39": "m³/hr", - "40": "m/s", + "40": f"m/{TIME_SECONDS}", "41": "mA", - "42": "ms", + "42": TIME_MILLISECONDS, "43": "mV", - "44": "min", - "45": "min", + "44": TIME_MINUTES, + "45": TIME_MINUTES, "46": "mm/hr", - "47": "month", + "47": TIME_MONTHS, "48": "MPH", - "49": "m/s", + "49": f"m/{TIME_SECONDS}", "50": "ohm", "51": "%", "52": "lb", "53": "power factor", "54": "ppm", "55": "pulse count", - "57": "s", - "58": "s", + "57": TIME_SECONDS, + "58": TIME_SECONDS, "59": "seimens/m", "60": "body wave magnitude scale", "61": "Ricter scale", @@ -79,7 +91,7 @@ UOM_FRIENDLY_NAME = { "74": "W/m²", "75": "weekday", "76": "Wind Direction (°)", - "77": "year", + "77": TIME_YEARS, "82": "mm", "83": "km", "85": "ohm", diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index 9a0431ef2d8..67a04d39556 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -1,7 +1,7 @@ """Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors.""" import logging -from homeassistant.const import ENERGY_WATT_HOUR, POWER_WATT, TEMP_CELSIUS +from homeassistant.const import ENERGY_WATT_HOUR, POWER_WATT, TEMP_CELSIUS, TIME_SECONDS from homeassistant.helpers.entity import Entity from . import DOMAIN, JuicenetDevice @@ -14,7 +14,7 @@ SENSOR_TYPES = { "voltage": ["Voltage", "V"], "amps": ["Amps", "A"], "watts": ["Watts", POWER_WATT], - "charge_time": ["Charge time", "s"], + "charge_time": ["Charge time", TIME_SECONDS], "energy_added": ["Energy added", ENERGY_WATT_HOUR], } diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py index ba9d52b7721..1a5d4475b0e 100644 --- a/homeassistant/components/lcn/services.py +++ b/homeassistant/components/lcn/services.py @@ -7,6 +7,7 @@ from homeassistant.const import ( CONF_BRIGHTNESS, CONF_STATE, CONF_UNIT_OF_MEASUREMENT, + TIME_SECONDS, ) import homeassistant.helpers.config_validation as cv @@ -281,7 +282,7 @@ class SendKeys(LcnServiceCall): vol.Upper, vol.In(SENDKEYCOMMANDS) ), vol.Optional(CONF_TIME, default=0): vol.All(int, vol.Range(min=0)), - vol.Optional(CONF_TIME_UNIT, default="s"): vol.All( + vol.Optional(CONF_TIME_UNIT, default=TIME_SECONDS): vol.All( vol.Upper, vol.In(TIME_UNITS) ), } @@ -324,7 +325,7 @@ class LockKeys(LcnServiceCall): ), vol.Required(CONF_STATE): is_key_lock_states_string, vol.Optional(CONF_TIME, default=0): vol.All(int, vol.Range(min=0)), - vol.Optional(CONF_TIME_UNIT, default="s"): vol.All( + vol.Optional(CONF_TIME_UNIT, default=TIME_SECONDS): vol.All( vol.Upper, vol.In(TIME_UNITS) ), } diff --git a/homeassistant/components/lyft/sensor.py b/homeassistant/components/lyft/sensor.py index 1b90d66398e..d76fe9f0dc5 100644 --- a/homeassistant/components/lyft/sensor.py +++ b/homeassistant/components/lyft/sensor.py @@ -8,6 +8,7 @@ from lyft_rides.errors import APIError import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -88,7 +89,7 @@ class LyftSensor(Entity): if "lyft" not in self._name.lower(): self._name = f"Lyft{self._name}" if self._sensortype == "time": - self._unit_of_measurement = "min" + self._unit_of_measurement = TIME_MINUTES elif self._sensortype == "price": estimate = self._product["estimate"] if estimate is not None: diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index fae2000b19a..d797f610dca 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -1,6 +1,6 @@ """Meteo-France component constants.""" -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, TIME_HOURS, TIME_MINUTES DOMAIN = "meteo_france" PLATFORMS = ["sensor", "weather"] @@ -47,13 +47,13 @@ SENSOR_TYPES = { }, "wind_speed": { SENSOR_TYPE_NAME: "Wind Speed", - SENSOR_TYPE_UNIT: "km/h", + SENSOR_TYPE_UNIT: f"km/{TIME_HOURS}", SENSOR_TYPE_ICON: "mdi:weather-windy", SENSOR_TYPE_CLASS: None, }, "next_rain": { SENSOR_TYPE_NAME: "Next rain", - SENSOR_TYPE_UNIT: "min", + SENSOR_TYPE_UNIT: TIME_MINUTES, SENSOR_TYPE_ICON: "mdi:weather-rainy", SENSOR_TYPE_CLASS: None, }, diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index c3ab6615481..d86faf23a81 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -30,7 +30,6 @@ SCAN_INTERVAL = 60 SIGNAL_NAME_PREFIX = f"signal_{DOMAIN}" -UNIT_LATENCY_TIME = "ms" UNIT_PLAYERS_MAX = "players" UNIT_PLAYERS_ONLINE = "players" UNIT_PROTOCOL_VERSION = None diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 0b37a7d979b..20f9e98e530 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -4,6 +4,7 @@ import logging from typing import Any, Dict from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TIME_MILLISECONDS from homeassistant.helpers.typing import HomeAssistantType from . import MinecraftServer, MinecraftServerEntity @@ -20,7 +21,6 @@ from .const import ( NAME_PLAYERS_ONLINE, NAME_PROTOCOL_VERSION, NAME_VERSION, - UNIT_LATENCY_TIME, UNIT_PLAYERS_MAX, UNIT_PLAYERS_ONLINE, UNIT_PROTOCOL_VERSION, @@ -121,7 +121,7 @@ class MinecraftServerLatencyTimeSensor(MinecraftServerSensorEntity): server=server, type_name=NAME_LATENCY_TIME, icon=ICON_LATENCY_TIME, - unit=UNIT_LATENCY_TIME, + unit=TIME_MILLISECONDS, ) async def async_update(self) -> None: diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py index da1db0e02aa..2ceca024a6f 100644 --- a/homeassistant/components/mvglive/sensor.py +++ b/homeassistant/components/mvglive/sensor.py @@ -7,7 +7,7 @@ import MVGLive import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -131,7 +131,7 @@ class MVGLiveSensor(Entity): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES def update(self): """Get the latest data and update the state.""" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 818662ee69c..b5da37f012f 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -9,6 +9,7 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + TIME_HOURS, ) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -66,10 +67,10 @@ SENSOR_TYPES = { "max_temp": ["Max Temp.", TEMP_CELSIUS, "mdi:thermometer", None], "windangle": ["Angle", "", "mdi:compass", None], "windangle_value": ["Angle Value", "º", "mdi:compass", None], - "windstrength": ["Wind Strength", "km/h", "mdi:weather-windy", None], + "windstrength": ["Wind Strength", f"km/{TIME_HOURS}", "mdi:weather-windy", None], "gustangle": ["Gust Angle", "", "mdi:compass", None], "gustangle_value": ["Gust Angle Value", "º", "mdi:compass", None], - "guststrength": ["Gust Strength", "km/h", "mdi:weather-windy", None], + "guststrength": ["Gust Strength", f"km/{TIME_HOURS}", "mdi:weather-windy", None], "reachable": ["Reachability", "", "mdi:signal", None], "rf_status": ["Radio", "", "mdi:signal", None], "rf_status_lvl": ["Radio_lvl", "", "mdi:signal", None], diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py index 4865b0a9839..dfa43c35952 100644 --- a/homeassistant/components/nmbs/sensor.py +++ b/homeassistant/components/nmbs/sensor.py @@ -11,6 +11,7 @@ from homeassistant.const import ( ATTR_LONGITUDE, CONF_NAME, CONF_SHOW_ON_MAP, + TIME_MINUTES, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -184,7 +185,7 @@ class NMBSSensor(Entity): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return "min" + return TIME_MINUTES @property def icon(self): diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index bdf0eaafc99..96db220f5ea 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( POWER_WATT, STATE_UNKNOWN, TEMP_CELSIUS, + TIME_SECONDS, ) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -53,13 +54,13 @@ SENSOR_TYPES = { "ups.load": ["Load", "%", "mdi:gauge"], "ups.load.high": ["Overload Setting", "%", "mdi:gauge"], "ups.id": ["System identifier", "", "mdi:information-outline"], - "ups.delay.start": ["Load Restart Delay", "s", "mdi:timer"], - "ups.delay.reboot": ["UPS Reboot Delay", "s", "mdi:timer"], - "ups.delay.shutdown": ["UPS Shutdown Delay", "s", "mdi:timer"], - "ups.timer.start": ["Load Start Timer", "s", "mdi:timer"], - "ups.timer.reboot": ["Load Reboot Timer", "s", "mdi:timer"], - "ups.timer.shutdown": ["Load Shutdown Timer", "s", "mdi:timer"], - "ups.test.interval": ["Self-Test Interval", "s", "mdi:timer"], + "ups.delay.start": ["Load Restart Delay", TIME_SECONDS, "mdi:timer"], + "ups.delay.reboot": ["UPS Reboot Delay", TIME_SECONDS, "mdi:timer"], + "ups.delay.shutdown": ["UPS Shutdown Delay", TIME_SECONDS, "mdi:timer"], + "ups.timer.start": ["Load Start Timer", TIME_SECONDS, "mdi:timer"], + "ups.timer.reboot": ["Load Reboot Timer", TIME_SECONDS, "mdi:timer"], + "ups.timer.shutdown": ["Load Shutdown Timer", TIME_SECONDS, "mdi:timer"], + "ups.test.interval": ["Self-Test Interval", TIME_SECONDS, "mdi:timer"], "ups.test.result": ["Self-Test Result", "", "mdi:information-outline"], "ups.test.date": ["Self-Test Date", "", "mdi:calendar"], "ups.display.language": ["Language", "", "mdi:information-outline"], @@ -89,9 +90,13 @@ SENSOR_TYPES = { "battery.current": ["Battery Current", "A", "mdi:flash"], "battery.current.total": ["Total Battery Current", "A", "mdi:flash"], "battery.temperature": ["Battery Temperature", TEMP_CELSIUS, "mdi:thermometer"], - "battery.runtime": ["Battery Runtime", "s", "mdi:timer"], - "battery.runtime.low": ["Low Battery Runtime", "s", "mdi:timer"], - "battery.runtime.restart": ["Minimum Battery Runtime to Start", "s", "mdi:timer"], + "battery.runtime": ["Battery Runtime", TIME_SECONDS, "mdi:timer"], + "battery.runtime.low": ["Low Battery Runtime", TIME_SECONDS, "mdi:timer"], + "battery.runtime.restart": [ + "Minimum Battery Runtime to Start", + TIME_SECONDS, + "mdi:timer", + ], "battery.alarm.threshold": [ "Battery Alarm Threshold", "", @@ -189,8 +194,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): data.update(no_throttle=True) except data.pynuterror as err: _LOGGER.error( - "Failure while testing NUT status retrieval. Cannot continue setup: %s", - err, + "Failure while testing NUT status retrieval. Cannot continue setup: %s", err ) raise PlatformNotReady diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 24ef18ab985..89d2c1c01da 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -1,7 +1,11 @@ """Monitor the NZBGet API.""" import logging -from homeassistant.const import DATA_MEGABYTES, DATA_RATE_MEGABYTES_PER_SECOND +from homeassistant.const import ( + DATA_MEGABYTES, + DATA_RATE_MEGABYTES_PER_SECOND, + TIME_MINUTES, +) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -26,7 +30,7 @@ SENSOR_TYPES = { "post_job_count": ["PostJobCount", "Post Processing Jobs", "Jobs"], "post_paused": ["PostPaused", "Post Processing Paused", None], "remaining_size": ["RemainingSizeMB", "Queue Size", DATA_MEGABYTES], - "uptime": ["UpTimeSec", "Uptime", "min"], + "uptime": ["UpTimeSec", "Uptime", TIME_MINUTES], } diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index f73e525efe3..06a8ae44f1f 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -19,6 +19,7 @@ from homeassistant.const import ( CONF_SSL, CONTENT_TYPE_JSON, TEMP_CELSIUS, + TIME_SECONDS, ) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv @@ -71,8 +72,14 @@ SENSOR_TYPES = { "Temperatures": ["printer", "temperature", "*", TEMP_CELSIUS], "Current State": ["printer", "state", "text", None, "mdi:printer-3d"], "Job Percentage": ["job", "progress", "completion", "%", "mdi:file-percent"], - "Time Remaining": ["job", "progress", "printTimeLeft", "seconds", "mdi:clock-end"], - "Time Elapsed": ["job", "progress", "printTime", "seconds", "mdi:clock-start"], + "Time Remaining": [ + "job", + "progress", + "printTimeLeft", + TIME_SECONDS, + "mdi:clock-end", + ], + "Time Elapsed": ["job", "progress", "printTime", TIME_SECONDS, "mdi:clock-start"], } SENSOR_SCHEMA = vol.Schema( diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py index 0ac655cd448..e0f21f6946d 100644 --- a/homeassistant/components/openevse/sensor.py +++ b/homeassistant/components/openevse/sensor.py @@ -11,6 +11,7 @@ from homeassistant.const import ( CONF_MONITORED_VARIABLES, ENERGY_KILO_WATT_HOUR, TEMP_CELSIUS, + TIME_MINUTES, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -19,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = { "status": ["Charging Status", None], - "charge_time": ["Charge Time Elapsed", "minutes"], + "charge_time": ["Charge Time Elapsed", TIME_MINUTES], "ambient_temp": ["Ambient Temperature", TEMP_CELSIUS], "ir_temp": ["IR Temperature", TEMP_CELSIUS], "rtc_temp": ["RTC Temperature", TEMP_CELSIUS], diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index bd9b372de33..580f9f7b1a4 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -1,7 +1,12 @@ """Constants for the opentherm_gw integration.""" import pyotgw.vars as gw_vars -from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS +from homeassistant.const import ( + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + TIME_HOURS, + TIME_MINUTES, +) ATTR_GW_ID = "gateway_id" ATTR_LEVEL = "level" @@ -31,9 +36,8 @@ SERVICE_SET_OAT = "set_outside_temperature" SERVICE_SET_SB_TEMP = "set_setback_temperature" UNIT_BAR = "bar" -UNIT_HOUR = "h" UNIT_KW = "kW" -UNIT_L_MIN = "L/min" +UNIT_L_MIN = f"L/{TIME_MINUTES}" UNIT_PERCENT = "%" BINARY_SENSOR_INFO = { @@ -237,10 +241,10 @@ SENSOR_INFO = { gw_vars.DATA_CH_PUMP_STARTS: [None, None, "Central Heating Pump Starts {}"], gw_vars.DATA_DHW_PUMP_STARTS: [None, None, "Hot Water Pump Starts {}"], gw_vars.DATA_DHW_BURNER_STARTS: [None, None, "Hot Water Burner Starts {}"], - gw_vars.DATA_TOTAL_BURNER_HOURS: [None, UNIT_HOUR, "Total Burner Hours {}"], - gw_vars.DATA_CH_PUMP_HOURS: [None, UNIT_HOUR, "Central Heating Pump Hours {}"], - gw_vars.DATA_DHW_PUMP_HOURS: [None, UNIT_HOUR, "Hot Water Pump Hours {}"], - gw_vars.DATA_DHW_BURNER_HOURS: [None, UNIT_HOUR, "Hot Water Burner Hours {}"], + gw_vars.DATA_TOTAL_BURNER_HOURS: [None, TIME_HOURS, "Total Burner Hours {}"], + gw_vars.DATA_CH_PUMP_HOURS: [None, TIME_HOURS, "Central Heating Pump Hours {}"], + gw_vars.DATA_DHW_PUMP_HOURS: [None, TIME_HOURS, "Hot Water Pump Hours {}"], + gw_vars.DATA_DHW_BURNER_HOURS: [None, TIME_HOURS, "Hot Water Burner Hours {}"], gw_vars.DATA_MASTER_OT_VERSION: [None, None, "Thermostat OpenTherm Version {}"], gw_vars.DATA_SLAVE_OT_VERSION: [None, None, "Boiler OpenTherm Version {}"], gw_vars.DATA_MASTER_PRODUCT_TYPE: [None, None, "Thermostat Product Type {}"], diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py index 2df62bcc09f..a375cfa10d7 100644 --- a/homeassistant/components/openuv/sensor.py +++ b/homeassistant/components/openuv/sensor.py @@ -1,6 +1,7 @@ """Support for OpenUV sensors.""" import logging +from homeassistant.const import TIME_MINUTES from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.dt import as_local, parse_datetime @@ -50,32 +51,32 @@ SENSORS = { TYPE_SAFE_EXPOSURE_TIME_1: ( "Skin Type 1 Safe Exposure Time", "mdi:timer", - "minutes", + TIME_MINUTES, ), TYPE_SAFE_EXPOSURE_TIME_2: ( "Skin Type 2 Safe Exposure Time", "mdi:timer", - "minutes", + TIME_MINUTES, ), TYPE_SAFE_EXPOSURE_TIME_3: ( "Skin Type 3 Safe Exposure Time", "mdi:timer", - "minutes", + TIME_MINUTES, ), TYPE_SAFE_EXPOSURE_TIME_4: ( "Skin Type 4 Safe Exposure Time", "mdi:timer", - "minutes", + TIME_MINUTES, ), TYPE_SAFE_EXPOSURE_TIME_5: ( "Skin Type 5 Safe Exposure Time", "mdi:timer", - "minutes", + TIME_MINUTES, ), TYPE_SAFE_EXPOSURE_TIME_6: ( "Skin Type 6 Safe Exposure Time", "mdi:timer", - "minutes", + TIME_MINUTES, ), } diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 23f88f59aad..0fead516b7e 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT, + TIME_SECONDS, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -33,7 +34,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) SENSOR_TYPES = { "weather": ["Condition", None], "temperature": ["Temperature", None], - "wind_speed": ["Wind speed", "m/s"], + "wind_speed": ["Wind speed", f"m/{TIME_SECONDS}"], "wind_bearing": ["Wind bearing", "°"], "humidity": ["Humidity", "%"], "pressure": ["Pressure", "mbar"], diff --git a/homeassistant/components/raincloud/__init__.py b/homeassistant/components/raincloud/__init__.py index dd851c0b3e3..41fefbe8fca 100644 --- a/homeassistant/components/raincloud/__init__.py +++ b/homeassistant/components/raincloud/__init__.py @@ -11,6 +11,8 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME, + TIME_DAYS, + TIME_MINUTES, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send @@ -60,9 +62,9 @@ UNIT_OF_MEASUREMENT_MAP = { "is_watering": "", "manual_watering": "", "next_cycle": "", - "rain_delay": "days", + "rain_delay": TIME_DAYS, "status": "", - "watering_time": "min", + "watering_time": TIME_MINUTES, } BINARY_SENSORS = ["is_watering", "status"] diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py index 419600ce562..8fdd1f2f858 100644 --- a/homeassistant/components/rejseplanen/sensor.py +++ b/homeassistant/components/rejseplanen/sensor.py @@ -12,7 +12,7 @@ import rjpl import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util @@ -130,7 +130,7 @@ class RejseplanenTransportSensor(Entity): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES @property def icon(self): diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py index 507e3c133cc..704bde67a5c 100644 --- a/homeassistant/components/rmvtransport/sensor.py +++ b/homeassistant/components/rmvtransport/sensor.py @@ -8,7 +8,7 @@ from RMVtransport.rmvtransport import RMVtransportApiConnectionError import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -177,7 +177,7 @@ class RMVDepartureSensor(Entity): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES async def async_update(self): """Get the latest data and update the state.""" diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index 797780d562a..55c2371aabb 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -22,6 +22,7 @@ from homeassistant.const import ( POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT, + TIME_HOURS, ) from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.exceptions import PlatformNotReady @@ -34,13 +35,11 @@ _LOGGER = logging.getLogger(__name__) MIN_INTERVAL = 5 MAX_INTERVAL = 300 -UNIT_OF_MEASUREMENT_HOURS = "h" - INVERTER_TYPES = ["ethernet", "wifi"] SAJ_UNIT_MAPPINGS = { "": None, - "h": UNIT_OF_MEASUREMENT_HOURS, + "h": TIME_HOURS, "kg": MASS_KILOGRAMS, "kWh": ENERGY_KILO_WATT_HOUR, "W": POWER_WATT, diff --git a/homeassistant/components/speedtestdotnet/const.py b/homeassistant/components/speedtestdotnet/const.py index a08c9421c76..2fed2609fb3 100644 --- a/homeassistant/components/speedtestdotnet/const.py +++ b/homeassistant/components/speedtestdotnet/const.py @@ -1,12 +1,12 @@ """Consts used by Speedtest.net.""" -from homeassistant.const import DATA_RATE_MEGABITS_PER_SECOND +from homeassistant.const import DATA_RATE_MEGABITS_PER_SECOND, TIME_MILLISECONDS DOMAIN = "speedtestdotnet" DATA_UPDATED = f"{DOMAIN}_data_updated" SENSOR_TYPES = { - "ping": ["Ping", "ms"], + "ping": ["Ping", TIME_MILLISECONDS], "download": ["Download", DATA_RATE_MEGABITS_PER_SECOND], "upload": ["Upload", DATA_RATE_MEGABITS_PER_SECOND], } diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index 7d9f940f391..e3cb1e48c37 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -8,6 +8,8 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, POWER_WATT, TEMP_CELSIUS, + TIME_HOURS, + TIME_SECONDS, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -36,11 +38,11 @@ SENSOR_TYPES = { DEVICE_CLASS_TEMPERATURE, ], SENSOR_TYPE_HUMIDITY: ["Humidity", "%", None, DEVICE_CLASS_HUMIDITY], - SENSOR_TYPE_RAINRATE: ["Rain rate", "mm/h", "mdi:water", None], + 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], - SENSOR_TYPE_WINDAVERAGE: ["Wind average", "m/s", "", None], - SENSOR_TYPE_WINDGUST: ["Wind gust", "m/s", "", None], + SENSOR_TYPE_WINDAVERAGE: ["Wind average", f"m/{TIME_SECONDS}", "", None], + SENSOR_TYPE_WINDGUST: ["Wind gust", f"m/{TIME_SECONDS}", "", None], SENSOR_TYPE_UV: ["UV", "UV", "", None], SENSOR_TYPE_WATT: ["Power", POWER_WATT, "", None], SENSOR_TYPE_LUMINANCE: ["Luminance", "lx", None, DEVICE_CLASS_ILLUMINANCE], diff --git a/homeassistant/components/tmb/sensor.py b/homeassistant/components/tmb/sensor.py index 6d8bdc7eac7..8eb0673aa73 100644 --- a/homeassistant/components/tmb/sensor.py +++ b/homeassistant/components/tmb/sensor.py @@ -7,7 +7,7 @@ from tmb import IBus import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -72,7 +72,7 @@ class TMBSensor(Entity): self._stop = stop self._line = line.upper() self._name = name - self._unit = "minutes" + self._unit = TIME_MINUTES self._state = None @property diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 802bb897b96..b57a24d0ced 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + TIME_SECONDS, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -71,7 +72,13 @@ SENSOR_TYPES = { "mdi:flag-triangle", None, ], - "wind_speed": ["Wind speed", "m/s", "windforce", "mdi:weather-windy", None], + "wind_speed": [ + "Wind speed", + f"m/{TIME_SECONDS}", + "windforce", + "mdi:weather-windy", + None, + ], "humidity": [ "Humidity", "%", diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py index 7c6990de085..e877e2d2e43 100644 --- a/homeassistant/components/transport_nsw/sensor.py +++ b/homeassistant/components/transport_nsw/sensor.py @@ -6,7 +6,13 @@ from TransportNSW import TransportNSW import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_MODE, CONF_API_KEY, CONF_NAME +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_MODE, + CONF_API_KEY, + CONF_NAME, + TIME_MINUTES, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -101,7 +107,7 @@ class TransportNSWSensor(Entity): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES @property def icon(self): diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py index ba698c2b64d..ffbe5239cc9 100644 --- a/homeassistant/components/travisci/sensor.py +++ b/homeassistant/components/travisci/sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL, + TIME_SECONDS, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -30,7 +31,7 @@ SCAN_INTERVAL = timedelta(seconds=30) # sensor_type [ description, unit, icon ] SENSOR_TYPES = { "last_build_id": ["Last Build ID", "", "mdi:account-card-details"], - "last_build_duration": ["Last Build Duration", "sec", "mdi:timelapse"], + "last_build_duration": ["Last Build Duration", TIME_SECONDS, "mdi:timelapse"], "last_build_finished_at": ["Last Build Finished At", "", "mdi:timetable"], "last_build_started_at": ["Last Build Started At", "", "mdi:timetable"], "last_build_state": ["Last Build State", "", "mdi:github-circle"], diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index 77b1c1a6f11..77929436283 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -7,7 +7,7 @@ import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_MODE +from homeassistant.const import CONF_MODE, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -119,7 +119,7 @@ class UkTransportSensor(Entity): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES @property def icon(self): diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index db121678d93..fdb5bc5100a 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -1,6 +1,7 @@ """Support for UPnP/IGD Sensors.""" import logging +from homeassistant.const import TIME_SECONDS from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -192,7 +193,7 @@ class PerSecondUPnPIGDSensor(UpnpSensor): @property def unit_of_measurement(self) -> str: """Return the unit of measurement of this entity, if any.""" - return f"{self.unit}/s" + return f"{self.unit}/{TIME_SECONDS}" def _is_overflowed(self, new_value) -> bool: """Check if value has overflowed.""" diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py index ef05bcf2adb..783581a0755 100644 --- a/homeassistant/components/viaggiatreno/sensor.py +++ b/homeassistant/components/viaggiatreno/sensor.py @@ -7,7 +7,7 @@ import async_timeout import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.const import ATTR_ATTRIBUTION, TIME_MINUTES import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -177,5 +177,5 @@ class ViaggiaTrenoSensor(Entity): self._unit = "" else: self._state = res.get("ritardo") - self._unit = "min" + self._unit = TIME_MINUTES self._icon = ICON diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index b9ca64c0970..0357825cb12 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, EVENT_HOMEASSISTANT_START, + TIME_MINUTES, ) from homeassistant.helpers import location import homeassistant.helpers.config_validation as cv @@ -167,7 +168,7 @@ class WazeTravelTime(Entity): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return "min" + return TIME_MINUTES @property def icon(self): diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index dc9da1100f0..7ec5c3dac5e 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -6,7 +6,7 @@ import voluptuous as vol import whois from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, TIME_DAYS import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -75,7 +75,7 @@ class WhoisSensor(Entity): @property def unit_of_measurement(self): """Return the unit of measurement to present the value in.""" - return "days" + return TIME_DAYS @property def state(self): diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 856f50ce9ad..82081eee2fa 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -61,10 +61,9 @@ MEAS_WEIGHT_KG = "weight_kg" UOM_BEATS_PER_MINUTE = "bpm" UOM_BREATHS_PER_MINUTE = "br/m" UOM_FREQUENCY = "times" -UOM_METERS_PER_SECOND = "m/s" +UOM_METERS_PER_SECOND = f"m/{const.TIME_SECONDS}" UOM_MMHG = "mmhg" UOM_PERCENT = "%" UOM_LENGTH_M = const.LENGTH_METERS UOM_MASS_KG = const.MASS_KILOGRAMS -UOM_SECONDS = "seconds" UOM_TEMP_C = const.TEMP_CELSIUS diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index ea570569fa6..c690580ffa4 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -13,6 +13,7 @@ from withings_api.common import ( ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TIME_SECONDS from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.entity import Entity @@ -197,28 +198,28 @@ WITHINGS_ATTRIBUTES = [ const.MEAS_SLEEP_WAKEUP_DURATION_SECONDS, GetSleepSummaryField.WAKEUP_DURATION.value, "Wakeup time", - const.UOM_SECONDS, + TIME_SECONDS, "mdi:sleep-off", ), WithingsSleepSummaryAttribute( const.MEAS_SLEEP_LIGHT_DURATION_SECONDS, GetSleepSummaryField.LIGHT_SLEEP_DURATION.value, "Light sleep", - const.UOM_SECONDS, + TIME_SECONDS, "mdi:sleep", ), WithingsSleepSummaryAttribute( const.MEAS_SLEEP_DEEP_DURATION_SECONDS, GetSleepSummaryField.DEEP_SLEEP_DURATION.value, "Deep sleep", - const.UOM_SECONDS, + TIME_SECONDS, "mdi:sleep", ), WithingsSleepSummaryAttribute( const.MEAS_SLEEP_REM_DURATION_SECONDS, GetSleepSummaryField.REM_SLEEP_DURATION.value, "REM sleep", - const.UOM_SECONDS, + TIME_SECONDS, "mdi:sleep", ), WithingsSleepSummaryAttribute( @@ -232,14 +233,14 @@ WITHINGS_ATTRIBUTES = [ const.MEAS_SLEEP_TOSLEEP_DURATION_SECONDS, GetSleepSummaryField.DURATION_TO_SLEEP.value, "Time to sleep", - const.UOM_SECONDS, + TIME_SECONDS, "mdi:sleep", ), WithingsSleepSummaryAttribute( const.MEAS_SLEEP_TOWAKEUP_DURATION_SECONDS, GetSleepSummaryField.DURATION_TO_WAKEUP.value, "Time to wakeup", - const.UOM_SECONDS, + TIME_SECONDS, "mdi:sleep-off", ), WithingsSleepSummaryAttribute( diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py index 5afa3a3efcf..6ee55aa387f 100644 --- a/homeassistant/components/wsdot/sensor.py +++ b/homeassistant/components/wsdot/sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_API_KEY, CONF_ID, CONF_NAME, + TIME_MINUTES, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -137,7 +138,7 @@ class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return TIME_MINUTES def _parse_wsdot_timestamp(timestamp): diff --git a/homeassistant/components/yr/sensor.py b/homeassistant/components/yr/sensor.py index c9392561fc8..63fe4012fe5 100644 --- a/homeassistant/components/yr/sensor.py +++ b/homeassistant/components/yr/sensor.py @@ -22,6 +22,7 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, PRESSURE_HPA, TEMP_CELSIUS, + TIME_SECONDS, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -41,8 +42,8 @@ SENSOR_TYPES = { "symbol": ["Symbol", None, None], "precipitation": ["Precipitation", "mm", None], "temperature": ["Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - "windSpeed": ["Wind speed", "m/s", None], - "windGust": ["Wind gust", "m/s", None], + "windSpeed": ["Wind speed", f"m/{TIME_SECONDS}", None], + "windGust": ["Wind gust", f"m/{TIME_SECONDS}", None], "pressure": ["Pressure", PRESSURE_HPA, DEVICE_CLASS_PRESSURE], "windDirection": ["Wind direction", "°", None], "humidity": ["Humidity", "%", DEVICE_CLASS_HUMIDITY], diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 44c216eb1be..6e96d28e176 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_NAME, + TIME_HOURS, __version__, ) import homeassistant.helpers.config_validation as cv @@ -39,9 +40,14 @@ SENSOR_TYPES = { "pressure": ("Pressure", "hPa", "LDstat hPa", float), "pressure_sealevel": ("Pressure at Sea Level", "hPa", "LDred hPa", float), "humidity": ("Humidity", "%", "RF %", int), - "wind_speed": ("Wind Speed", "km/h", "WG km/h", float), + "wind_speed": ("Wind Speed", f"km/{TIME_HOURS}", f"WG km/{TIME_HOURS}", float), "wind_bearing": ("Wind Bearing", "°", "WR °", int), - "wind_max_speed": ("Top Wind Speed", "km/h", "WSG km/h", float), + "wind_max_speed": ( + "Top Wind Speed", + f"km/{TIME_HOURS}", + f"WSG km/{TIME_HOURS}", + float, + ), "wind_max_bearing": ("Top Wind Bearing", "°", "WSR °", int), "sun_last_hour": ("Sun Last Hour", "%", "SO %", int), "temperature": ("Temperature", "°C", "T °C", float), diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index b738b665e80..c7cad5e455d 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -3,6 +3,7 @@ import logging import zigpy.zcl.clusters.smartenergy as smartenergy +from homeassistant.const import TIME_HOURS, TIME_SECONDS from homeassistant.core import callback from .. import registries, typing as zha_typing @@ -76,18 +77,18 @@ class Metering(AttributeListeningChannel): unit_of_measure_map = { 0x00: "kW", - 0x01: "m³/h", - 0x02: "ft³/h", - 0x03: "ccf/h", - 0x04: "US gal/h", - 0x05: "IMP gal/h", - 0x06: "BTU/h", - 0x07: "l/h", + 0x01: f"m³/{TIME_HOURS}", + 0x02: f"ft³/{TIME_HOURS}", + 0x03: f"ccf/{TIME_HOURS}", + 0x04: f"US gal/{TIME_HOURS}", + 0x05: f"IMP gal/{TIME_HOURS}", + 0x06: f"BTU/{TIME_HOURS}", + 0x07: f"l/{TIME_HOURS}", 0x08: "kPa", 0x09: "kPa", - 0x0A: "mcf/h", + 0x0A: f"mcf/{TIME_HOURS}", 0x0B: "unitless", - 0x0C: "MJ/s", + 0x0C: f"MJ/{TIME_SECONDS}", } def __init__( diff --git a/homeassistant/const.py b/homeassistant/const.py index 50ce3e084b8..32dfac973a2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -343,6 +343,17 @@ ENERGY_WATT_HOUR = "Wh" TEMP_CELSIUS = "°C" TEMP_FAHRENHEIT = "°F" +# Time units +TIME_MICROSECONDS = "μs" +TIME_MILLISECONDS = "ms" +TIME_SECONDS = "s" +TIME_MINUTES = "min" +TIME_HOURS = "h" +TIME_DAYS = "d" +TIME_WEEKS = "w" +TIME_MONTHS = "m" +TIME_YEARS = "y" + # Length units LENGTH_CENTIMETERS: str = "cm" LENGTH_METERS: str = "m" @@ -400,17 +411,17 @@ DATA_PEBIBYTES = "PiB" DATA_EXBIBYTES = "EiB" DATA_ZEBIBYTES = "ZiB" DATA_YOBIBYTES = "YiB" -DATA_RATE_BITS_PER_SECOND = f"{DATA_BITS}/s" -DATA_RATE_KILOBITS_PER_SECOND = f"{DATA_KILOBITS}/s" -DATA_RATE_MEGABITS_PER_SECOND = f"{DATA_MEGABITS}/s" -DATA_RATE_GIGABITS_PER_SECOND = f"{DATA_GIGABITS}/s" -DATA_RATE_BYTES_PER_SECOND = f"{DATA_BYTES}/s" -DATA_RATE_KILOBYTES_PER_SECOND = f"{DATA_KILOBYTES}/s" -DATA_RATE_MEGABYTES_PER_SECOND = f"{DATA_MEGABYTES}/s" -DATA_RATE_GIGABYTES_PER_SECOND = f"{DATA_GIGABYTES}/s" -DATA_RATE_KIBIBYTES_PER_SECOND = f"{DATA_KIBIBYTES}/s" -DATA_RATE_MEBIBYTES_PER_SECOND = f"{DATA_MEBIBYTES}/s" -DATA_RATE_GIBIBYTES_PER_SECOND = f"{DATA_GIBIBYTES}/s" +DATA_RATE_BITS_PER_SECOND = f"{DATA_BITS}/{TIME_SECONDS}" +DATA_RATE_KILOBITS_PER_SECOND = f"{DATA_KILOBITS}/{TIME_SECONDS}" +DATA_RATE_MEGABITS_PER_SECOND = f"{DATA_MEGABITS}/{TIME_SECONDS}" +DATA_RATE_GIGABITS_PER_SECOND = f"{DATA_GIGABITS}/{TIME_SECONDS}" +DATA_RATE_BYTES_PER_SECOND = f"{DATA_BYTES}/{TIME_SECONDS}" +DATA_RATE_KILOBYTES_PER_SECOND = f"{DATA_KILOBYTES}/{TIME_SECONDS}" +DATA_RATE_MEGABYTES_PER_SECOND = f"{DATA_MEGABYTES}/{TIME_SECONDS}" +DATA_RATE_GIGABYTES_PER_SECOND = f"{DATA_GIGABYTES}/{TIME_SECONDS}" +DATA_RATE_KIBIBYTES_PER_SECOND = f"{DATA_KIBIBYTES}/{TIME_SECONDS}" +DATA_RATE_MEBIBYTES_PER_SECOND = f"{DATA_MEBIBYTES}/{TIME_SECONDS}" +DATA_RATE_GIBIBYTES_PER_SECOND = f"{DATA_GIBIBYTES}/{TIME_SECONDS}" # #### SERVICES #### SERVICE_HOMEASSISTANT_STOP = "stop" diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 9a26d2de5ce..dc160b283ad 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import patch +from homeassistant.const import TIME_HOURS, TIME_MINUTES, TIME_SECONDS from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -79,7 +80,7 @@ async def test_dataSet1(hass): """Test derivative sensor state.""" await setup_tests( hass, - {"unit_time": "s"}, + {"unit_time": TIME_SECONDS}, times=[20, 30, 40, 50], values=[10, 30, 5, 0], expected_state=-0.5, @@ -89,30 +90,46 @@ async def test_dataSet1(hass): async def test_dataSet2(hass): """Test derivative sensor state.""" await setup_tests( - hass, {"unit_time": "s"}, times=[20, 30], values=[5, 0], expected_state=-0.5 + hass, + {"unit_time": TIME_SECONDS}, + times=[20, 30], + values=[5, 0], + expected_state=-0.5, ) async def test_dataSet3(hass): """Test derivative sensor state.""" state = await setup_tests( - hass, {"unit_time": "s"}, times=[20, 30], values=[5, 10], expected_state=0.5 + hass, + {"unit_time": TIME_SECONDS}, + times=[20, 30], + values=[5, 10], + expected_state=0.5, ) - assert state.attributes.get("unit_of_measurement") == "/s" + assert state.attributes.get("unit_of_measurement") == f"/{TIME_SECONDS}" async def test_dataSet4(hass): """Test derivative sensor state.""" await setup_tests( - hass, {"unit_time": "s"}, times=[20, 30], values=[5, 5], expected_state=0 + hass, + {"unit_time": TIME_SECONDS}, + times=[20, 30], + values=[5, 5], + expected_state=0, ) async def test_dataSet5(hass): """Test derivative sensor state.""" await setup_tests( - hass, {"unit_time": "s"}, times=[20, 30], values=[10, -10], expected_state=-2 + hass, + {"unit_time": TIME_SECONDS}, + times=[20, 30], + values=[10, -10], + expected_state=-2, ) @@ -137,7 +154,12 @@ async def test_data_moving_average_for_discrete_sensor(hass): times = list(range(0, 1800 + 30, 30)) config, entity_id = await _setup_sensor( - hass, {"time_window": {"seconds": time_window}, "unit_time": "min", "round": 1} + hass, + { + "time_window": {"seconds": time_window}, + "unit_time": TIME_MINUTES, + "round": 1, + }, ) # two minute window for time, value in zip(times, temperature_values): @@ -186,7 +208,7 @@ async def test_prefix(hass): # Testing a power sensor at 1000 Watts for 1hour = 0kW/h assert round(float(state.state), config["sensor"]["round"]) == 0.0 - assert state.attributes.get("unit_of_measurement") == "kW/h" + assert state.attributes.get("unit_of_measurement") == f"kW/{TIME_HOURS}" async def test_suffix(hass): @@ -198,7 +220,7 @@ async def test_suffix(hass): "source": "sensor.bytes_per_second", "round": 2, "unit_prefix": "k", - "unit_time": "s", + "unit_time": TIME_SECONDS, } } diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index c881f4b9168..e3d5e62f435 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -15,6 +15,7 @@ import pytest from homeassistant.bootstrap import async_setup_component from homeassistant.components.dsmr.sensor import DerivativeDSMREntity +from homeassistant.const import TIME_HOURS from tests.common import assert_setup_component @@ -140,7 +141,7 @@ async def test_derivative(): abs(entity.state - 0.033) < 0.00001 ), "state should be hourly usage calculated from first and second update" - assert entity.unit_of_measurement == "m3/h" + assert entity.unit_of_measurement == f"m3/{TIME_HOURS}" async def test_v4_meter(hass, mock_connection_factory): @@ -240,9 +241,7 @@ async def test_belgian_meter_low(hass, mock_connection_factory): config = {"platform": "dsmr", "dsmr_version": "5B"} - telegram = { - ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0002", "unit": ""}]), - } + telegram = {ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0002", "unit": ""}])} with assert_setup_component(1): await async_setup_component(hass, "sensor", {"sensor": config}) diff --git a/tests/components/dyson/test_sensor.py b/tests/components/dyson/test_sensor.py index 442ea913b46..e540d304731 100644 --- a/tests/components/dyson/test_sensor.py +++ b/tests/components/dyson/test_sensor.py @@ -8,7 +8,7 @@ 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 STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, TIME_HOURS from homeassistant.helpers import discovery from homeassistant.setup import async_setup_component @@ -123,7 +123,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 == "hours" + assert sensor.unit_of_measurement == TIME_HOURS assert sensor.name == "Device_name Filter Life" assert sensor.entity_id == "sensor.dyson_1" sensor.on_message("message") @@ -135,7 +135,7 @@ class DysonTest(unittest.TestCase): sensor.entity_id = "sensor.dyson_1" assert not sensor.should_poll assert sensor.state == 100 - assert sensor.unit_of_measurement == "hours" + assert sensor.unit_of_measurement == TIME_HOURS assert sensor.name == "Device_name Filter Life" assert sensor.entity_id == "sensor.dyson_1" sensor.on_message("message") diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py index 4456b256f6e..fcae8bd1f8c 100644 --- a/tests/components/here_travel_time/test_sensor.py +++ b/tests/components/here_travel_time/test_sensor.py @@ -28,6 +28,7 @@ from homeassistant.components.here_travel_time.sensor import ( ROUTE_MODE_FASTEST, ROUTE_MODE_SHORTEST, SCAN_INTERVAL, + TIME_MINUTES, TRAFFIC_MODE_DISABLED, TRAFFIC_MODE_ENABLED, TRAVEL_MODE_BICYCLE, @@ -36,7 +37,6 @@ from homeassistant.components.here_travel_time.sensor import ( TRAVEL_MODE_PUBLIC, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAVEL_MODE_TRUCK, - UNIT_OF_MEASUREMENT, ) from homeassistant.const import ATTR_ICON, EVENT_HOMEASSISTANT_START from homeassistant.setup import async_setup_component @@ -83,7 +83,7 @@ def _build_mock_url(origin, destination, modes, api_key, departure): def _assert_truck_sensor(sensor): """Assert that states and attributes are correct for truck_response.""" assert sensor.state == "14" - assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT + assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES assert sensor.attributes.get(ATTR_ATTRIBUTION) is None assert sensor.attributes.get(ATTR_DURATION) == 13.533333333333333 @@ -177,7 +177,7 @@ async def test_car(hass, requests_mock_car_disabled_response): sensor = hass.states.get("sensor.test") assert sensor.state == "30" - assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT + assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES assert sensor.attributes.get(ATTR_ATTRIBUTION) is None assert sensor.attributes.get(ATTR_DURATION) == 30.05 assert sensor.attributes.get(ATTR_DISTANCE) == 23.903 @@ -381,7 +381,7 @@ async def test_public_transport(hass, requests_mock_credentials_check): sensor = hass.states.get("sensor.test") assert sensor.state == "89" - assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT + assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES assert sensor.attributes.get(ATTR_ATTRIBUTION) is None assert sensor.attributes.get(ATTR_DURATION) == 89.16666666666667 @@ -431,7 +431,7 @@ async def test_public_transport_time_table(hass, requests_mock_credentials_check sensor = hass.states.get("sensor.test") assert sensor.state == "80" - assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT + assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES assert sensor.attributes.get(ATTR_ATTRIBUTION) is None assert sensor.attributes.get(ATTR_DURATION) == 79.73333333333333 @@ -481,7 +481,7 @@ async def test_pedestrian(hass, requests_mock_credentials_check): sensor = hass.states.get("sensor.test") assert sensor.state == "211" - assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT + assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES assert sensor.attributes.get(ATTR_ATTRIBUTION) is None assert sensor.attributes.get(ATTR_DURATION) == 210.51666666666668 @@ -532,7 +532,7 @@ async def test_bicycle(hass, requests_mock_credentials_check): sensor = hass.states.get("sensor.test") assert sensor.state == "55" - assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT + assert sensor.attributes.get("unit_of_measurement") == TIME_MINUTES assert sensor.attributes.get(ATTR_ATTRIBUTION) is None assert sensor.attributes.get(ATTR_DURATION) == 54.86666666666667 diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index 2ca36e228cc..e3b0f8dd366 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -20,6 +20,7 @@ from homeassistant.components.homematicip_cloud.sensor import ( ATTR_TEMPERATURE_OFFSET, ATTR_WIND_DIRECTION, ATTR_WIND_DIRECTION_VARIATION, + TIME_HOURS, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, POWER_WATT, TEMP_CELSIUS @@ -284,7 +285,7 @@ async def test_hmip_windspeed_sensor(hass, default_mock_hap_factory): ) assert ha_state.state == "2.6" - assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == "km/h" + assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == f"km/{TIME_HOURS}" await async_manipulate_test_data(hass, hmip_device, "windSpeed", 9.4) ha_state = hass.states.get(entity_id) assert ha_state.state == "9.4" diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index c65ca720235..b598d7ddbc2 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -2,6 +2,7 @@ from datetime import timedelta from unittest.mock import patch +from homeassistant.const import TIME_SECONDS from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -181,7 +182,7 @@ async def test_suffix(hass): "source": "sensor.bytes_per_second", "round": 2, "unit_prefix": "k", - "unit_time": "s", + "unit_time": TIME_SECONDS, } } diff --git a/tests/components/yr/test_sensor.py b/tests/components/yr/test_sensor.py index 161a7cef66b..8161c8c0faa 100644 --- a/tests/components/yr/test_sensor.py +++ b/tests/components/yr/test_sensor.py @@ -3,6 +3,7 @@ from datetime import datetime from unittest.mock import patch from homeassistant.bootstrap import async_setup_component +from homeassistant.const import TIME_SECONDS import homeassistant.util.dt as dt_util from tests.common import assert_setup_component, load_fixture @@ -70,7 +71,7 @@ async def test_custom_setup(hass, aioclient_mock): assert state.state == "0.0" state = hass.states.get("sensor.yr_wind_speed") - assert state.attributes.get("unit_of_measurement") == "m/s" + assert state.attributes.get("unit_of_measurement") == f"m/{TIME_SECONDS}" assert state.state == "3.5" @@ -116,5 +117,5 @@ async def test_forecast_setup(hass, aioclient_mock): assert state.state == "0.0" state = hass.states.get("sensor.yr_wind_speed") - assert state.attributes.get("unit_of_measurement") == "m/s" + assert state.attributes.get("unit_of_measurement") == f"m/{TIME_SECONDS}" assert state.state == "3.6" From 524a1a75877ee86f58209368d2dd5bc71c650143 Mon Sep 17 00:00:00 2001 From: springstan <46536646+springstan@users.noreply.github.com> Date: Sun, 23 Feb 2020 22:38:05 +0100 Subject: [PATCH 057/416] Use f-strings in integrations starting with "A" (#32110) * Use f-strings in integrations starting with A * Use f-strings in tests for integrations starting with A * Fix pylint by renaming variable * Fix nested for loop in f-string for aprs device_tracker * Break long lines into multiple short lines * Break long lines into multiple short lines v2 --- homeassistant/components/abode/__init__.py | 12 +--- .../components/abode/binary_sensor.py | 4 +- homeassistant/components/abode/camera.py | 4 +- homeassistant/components/abode/const.py | 3 - homeassistant/components/abode/sensor.py | 4 +- homeassistant/components/airvisual/sensor.py | 2 +- .../components/aladdin_connect/cover.py | 4 +- homeassistant/components/alert/__init__.py | 3 +- homeassistant/components/alexa/handlers.py | 14 ++-- .../components/ambient_station/__init__.py | 7 +- .../components/ambient_station/const.py | 2 - .../components/amcrest/binary_sensor.py | 2 +- homeassistant/components/amcrest/camera.py | 4 +- homeassistant/components/amcrest/helpers.py | 2 +- homeassistant/components/amcrest/sensor.py | 18 +++-- .../components/anel_pwrctrl/switch.py | 4 +- homeassistant/components/api/__init__.py | 3 +- homeassistant/components/apple_tv/__init__.py | 13 ++-- .../components/aprs/device_tracker.py | 2 +- homeassistant/components/aqualogic/sensor.py | 2 +- homeassistant/components/aqualogic/switch.py | 2 +- .../components/arcam_fmj/__init__.py | 6 +- homeassistant/components/arest/sensor.py | 5 +- homeassistant/components/arest/switch.py | 2 +- homeassistant/components/arlo/__init__.py | 4 +- homeassistant/components/arlo/camera.py | 5 +- homeassistant/components/arlo/sensor.py | 13 ++-- .../components/aruba/device_tracker.py | 4 +- homeassistant/components/arwn/sensor.py | 2 +- .../components/asterisk_cdr/mailbox.py | 6 +- homeassistant/components/august/__init__.py | 7 +- .../components/august/binary_sensor.py | 21 ++---- .../components/aurora_abb_powerone/sensor.py | 1 - .../components/automatic/device_tracker.py | 3 +- .../components/automation/__init__.py | 10 +-- homeassistant/components/automation/config.py | 2 +- .../components/axis/binary_sensor.py | 4 +- homeassistant/components/axis/camera.py | 28 ++++---- homeassistant/components/axis/switch.py | 4 +- .../components/azure_event_hub/__init__.py | 5 +- tests/components/adguard/test_config_flow.py | 24 +++---- .../test_device_trigger.py | 35 +++++----- tests/components/alert/test_init.py | 2 +- .../components/alexa/test_flash_briefings.py | 2 +- tests/components/api/test_init.py | 67 +++++++------------ tests/components/auth/test_init.py | 4 +- tests/components/auth/test_init_link_user.py | 8 +-- tests/components/auth/test_login_flow.py | 6 +- 48 files changed, 162 insertions(+), 229 deletions(-) diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index a5f3e6116a4..4ce9a4faca0 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -24,13 +24,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity import Entity -from .const import ( - ATTRIBUTION, - DEFAULT_CACHEDB, - DOMAIN, - SIGNAL_CAPTURE_IMAGE, - SIGNAL_TRIGGER_QUICK_ACTION, -) +from .const import ATTRIBUTION, DEFAULT_CACHEDB, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -193,7 +187,7 @@ def setup_hass_services(hass): ] for entity_id in target_entities: - signal = SIGNAL_CAPTURE_IMAGE.format(entity_id) + signal = f"abode_camera_capture_{entity_id}" dispatcher_send(hass, signal) def trigger_quick_action(call): @@ -207,7 +201,7 @@ def setup_hass_services(hass): ] for entity_id in target_entities: - signal = SIGNAL_TRIGGER_QUICK_ACTION.format(entity_id) + signal = f"abode_trigger_quick_action_{entity_id}" dispatcher_send(hass, signal) hass.services.register( diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py index c27357ca076..7d4474437e9 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import AbodeAutomation, AbodeDevice -from .const import DOMAIN, SIGNAL_TRIGGER_QUICK_ACTION +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -60,7 +60,7 @@ class AbodeQuickActionBinarySensor(AbodeAutomation, BinarySensorDevice): async def async_added_to_hass(self): """Subscribe Abode events.""" await super().async_added_to_hass() - signal = SIGNAL_TRIGGER_QUICK_ACTION.format(self.entity_id) + signal = f"abode_trigger_quick_action_{self.entity_id}" async_dispatcher_connect(self.hass, signal, self.trigger) def trigger(self): diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py index 1742a0a5d6c..edf29c4a198 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -11,7 +11,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import Throttle from . import AbodeDevice -from .const import DOMAIN, SIGNAL_CAPTURE_IMAGE +from .const import DOMAIN MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) @@ -50,7 +50,7 @@ class AbodeCamera(AbodeDevice, Camera): self._capture_callback, ) - signal = SIGNAL_CAPTURE_IMAGE.format(self.entity_id) + signal = f"abode_camera_capture_{self.entity_id}" async_dispatcher_connect(self.hass, signal, self.capture) def capture(self): diff --git a/homeassistant/components/abode/const.py b/homeassistant/components/abode/const.py index 267eb04f72e..092843ba212 100644 --- a/homeassistant/components/abode/const.py +++ b/homeassistant/components/abode/const.py @@ -3,6 +3,3 @@ DOMAIN = "abode" ATTRIBUTION = "Data provided by goabode.com" DEFAULT_CACHEDB = "abodepy_cache.pickle" - -SIGNAL_CAPTURE_IMAGE = "abode_camera_capture_{}" -SIGNAL_TRIGGER_QUICK_ACTION = "abode_trigger_quick_action_{}" diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index dc622cb1a38..afa5e372222 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -44,9 +44,7 @@ class AbodeSensor(AbodeDevice): """Initialize a sensor for an Abode device.""" super().__init__(data, device) self._sensor_type = sensor_type - self._name = "{0} {1}".format( - self._device.name, SENSOR_TYPES[self._sensor_type][0] - ) + self._name = f"{self._device.name} {SENSOR_TYPES[self._sensor_type][0]}" self._device_class = SENSOR_TYPES[self._sensor_type][1] @property diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 3b177c4ce67..1c0109c0d68 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -185,7 +185,7 @@ class AirVisualSensor(Entity): @property def name(self): """Return the name.""" - return "{0} {1}".format(SENSOR_LOCALES[self._locale], self._name) + return f"{SENSOR_LOCALES[self._locale]} {self._name}" @property def state(self): diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 4cfcd5403dd..351703c5cb3 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -53,9 +53,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except (TypeError, KeyError, NameError, ValueError) as ex: _LOGGER.error("%s", ex) hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), + "Error: {ex}
You will need to restart hass after fixing.", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index 3a473b17f17..9aa3c62e76c 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -31,7 +31,6 @@ from homeassistant.util.dt import now _LOGGER = logging.getLogger(__name__) DOMAIN = "alert" -ENTITY_ID_FORMAT = DOMAIN + ".{}" CONF_CAN_ACK = "can_acknowledge" CONF_NOTIFIERS = "notifiers" @@ -200,7 +199,7 @@ class Alert(ToggleEntity): self._ack = False self._cancel = None self._send_done_message = False - self.entity_id = ENTITY_ID_FORMAT.format(entity_id) + self.entity_id = f"{DOMAIN}.{entity_id}" event.async_track_state_change( hass, watched_entity_id, self.watched_entity_change diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 03c5acd42fa..b771a8fc50c 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -478,8 +478,8 @@ async def async_api_select_input(hass, config, directive, context): media_input = source break else: - msg = "failed to map input {} to a media source on {}".format( - media_input, entity.entity_id + msg = ( + f"failed to map input {media_input} to a media source on {entity.entity_id}" ) raise AlexaInvalidValueError(msg) @@ -1225,7 +1225,7 @@ async def async_api_adjust_range(hass, config, directive, context): service = SERVICE_SET_COVER_POSITION current = entity.attributes.get(cover.ATTR_POSITION) if not current: - msg = "Unable to determine {} current position".format(entity.entity_id) + msg = f"Unable to determine {entity.entity_id} current position" raise AlexaInvalidValueError(msg) position = response_value = min(100, max(0, range_delta + current)) if position == 100: @@ -1241,9 +1241,7 @@ async def async_api_adjust_range(hass, config, directive, context): service = SERVICE_SET_COVER_TILT_POSITION current = entity.attributes.get(cover.ATTR_TILT_POSITION) if not current: - msg = "Unable to determine {} current tilt position".format( - entity.entity_id - ) + msg = f"Unable to determine {entity.entity_id} current tilt position" raise AlexaInvalidValueError(msg) tilt_position = response_value = min(100, max(0, range_delta + current)) if tilt_position == 100: @@ -1439,9 +1437,7 @@ async def async_api_set_eq_mode(hass, config, directive, context): if sound_mode_list and mode.lower() in sound_mode_list: data[media_player.const.ATTR_SOUND_MODE] = mode.lower() else: - msg = "failed to map sound mode {} to a mode on {}".format( - mode, entity.entity_id - ) + msg = f"failed to map sound mode {mode} to a mode on {entity.entity_id}" raise AlexaInvalidValueError(msg) await hass.services.async_call( diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 0bbb7a760fe..9e8ff4b9f8d 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -30,7 +30,6 @@ from .const import ( CONF_APP_KEY, DATA_CLIENT, DOMAIN, - TOPIC_UPDATE, TYPE_BINARY_SENSOR, TYPE_SENSOR, ) @@ -378,7 +377,9 @@ class AmbientStation: if data != self.stations[mac_address][ATTR_LAST_DATA]: _LOGGER.debug("New data received: %s", data) self.stations[mac_address][ATTR_LAST_DATA] = data - async_dispatcher_send(self._hass, TOPIC_UPDATE.format(mac_address)) + async_dispatcher_send( + self._hass, f"ambient_station_data_update_{mac_address}" + ) _LOGGER.debug("Resetting watchdog") self._watchdog_listener() @@ -518,7 +519,7 @@ class AmbientWeatherEntity(Entity): self.async_schedule_update_ha_state(True) self._async_unsub_dispatcher_connect = async_dispatcher_connect( - self.hass, TOPIC_UPDATE.format(self._mac_address), update + self.hass, f"ambient_station_data_update_{self._mac_address}", update ) async def async_will_remove_from_hass(self): diff --git a/homeassistant/components/ambient_station/const.py b/homeassistant/components/ambient_station/const.py index 4f94e1cfe88..3b1990ae837 100644 --- a/homeassistant/components/ambient_station/const.py +++ b/homeassistant/components/ambient_station/const.py @@ -8,7 +8,5 @@ CONF_APP_KEY = "app_key" DATA_CLIENT = "data_client" -TOPIC_UPDATE = "ambient_station_data_update_{0}" - TYPE_BINARY_SENSOR = "binary_sensor" TYPE_SENSOR = "sensor" diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py index a99901f54a3..809b448876c 100644 --- a/homeassistant/components/amcrest/binary_sensor.py +++ b/homeassistant/components/amcrest/binary_sensor.py @@ -54,7 +54,7 @@ class AmcrestBinarySensor(BinarySensorDevice): def __init__(self, name, device, sensor_type): """Initialize entity.""" - self._name = "{} {}".format(name, BINARY_SENSORS[sensor_type][0]) + self._name = f"{name} {BINARY_SENSORS[sensor_type][0]}" self._signal_name = name self._api = device.api self._sensor_type = sensor_type diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 0e64d4fefc9..f9515256403 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -491,9 +491,7 @@ class AmcrestCam(Camera): """Enable or disable indicator light.""" try: self._api.command( - "configManager.cgi?action=setConfig&LightGlobal[0].Enable={}".format( - str(enable).lower() - ) + f"configManager.cgi?action=setConfig&LightGlobal[0].Enable={str(enable).lower()}" ) except AmcrestError as error: log_update_error( diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py index a40d6ace50a..57d1a73c97e 100644 --- a/homeassistant/components/amcrest/helpers.py +++ b/homeassistant/components/amcrest/helpers.py @@ -6,7 +6,7 @@ def service_signal(service, ident=None): """Encode service and identifier into signal.""" signal = f"{DOMAIN}_{service}" if ident: - signal += "_{}".format(ident.replace(".", "_")) + signal += f"_{ident.replace('.', '_')}" return signal diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index 04436cd95ab..bcff1775879 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -45,7 +45,7 @@ class AmcrestSensor(Entity): def __init__(self, name, device, sensor_type): """Initialize a sensor for Amcrest camera.""" - self._name = "{} {}".format(name, SENSORS[sensor_type][0]) + self._name = f"{name} {SENSORS[sensor_type][0]}" self._signal_name = name self._api = device.api self._sensor_type = sensor_type @@ -98,15 +98,21 @@ class AmcrestSensor(Entity): elif self._sensor_type == SENSOR_SDCARD: storage = self._api.storage_all try: - self._attrs["Total"] = "{:.2f} {}".format(*storage["total"]) + self._attrs[ + "Total" + ] = f"{storage['total'][0]:.2f} {storage['total'][1]}" except ValueError: - self._attrs["Total"] = "{} {}".format(*storage["total"]) + self._attrs[ + "Total" + ] = f"{storage['total'][0]} {storage['total'][1]}" try: - self._attrs["Used"] = "{:.2f} {}".format(*storage["used"]) + self._attrs[ + "Used" + ] = f"{storage['used'][0]:.2f} {storage['used'][1]}" except ValueError: - self._attrs["Used"] = "{} {}".format(*storage["used"]) + self._attrs["Used"] = f"{storage['used'][0]} {storage['used'][1]}" try: - self._state = "{:.2f}".format(storage["used_percent"]) + self._state = f"{storage['used_percent']:.2f}" except ValueError: self._state = storage["used_percent"] except AmcrestError as error: diff --git a/homeassistant/components/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py index 3c181d7d04b..19a0cc7c6ad 100644 --- a/homeassistant/components/anel_pwrctrl/switch.py +++ b/homeassistant/components/anel_pwrctrl/switch.py @@ -75,9 +75,7 @@ class PwrCtrlSwitch(SwitchDevice): @property def unique_id(self): """Return the unique ID of the device.""" - return "{device}-{switch_idx}".format( - device=self._port.device.host, switch_idx=self._port.get_index() - ) + return f"{self._port.device.host}-{self._port.get_index()}" @property def name(self): diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index b9638d44d2b..e11bc5e61f9 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -26,7 +26,6 @@ from homeassistant.const import ( URL_API_EVENTS, URL_API_SERVICES, URL_API_STATES, - URL_API_STATES_ENTITY, URL_API_STREAM, URL_API_TEMPLATE, __version__, @@ -254,7 +253,7 @@ class APIEntityStateView(HomeAssistantView): status_code = HTTP_CREATED if is_new_state else 200 resp = self.json(hass.states.get(entity_id), status_code) - resp.headers.add("Location", URL_API_STATES_ENTITY.format(entity_id)) + resp.headers.add("Location", f"/api/states/{entity_id}") return resp diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py index e11b246fd5e..52e02cfaf72 100644 --- a/homeassistant/components/apple_tv/__init__.py +++ b/homeassistant/components/apple_tv/__init__.py @@ -88,16 +88,15 @@ def request_configuration(hass, config, atv, credentials): try: await atv.airplay.finish_authentication(pin) hass.components.persistent_notification.async_create( - "Authentication succeeded!

Add the following " - "to credentials: in your apple_tv configuration:

" - "{0}".format(credentials), + f"Authentication succeeded!

" + f"Add the following to credentials: " + f"in your apple_tv configuration:

{credentials}", title=NOTIFICATION_AUTH_TITLE, notification_id=NOTIFICATION_AUTH_ID, ) except DeviceAuthenticationError as ex: hass.components.persistent_notification.async_create( - "Authentication failed! Did you enter correct PIN?

" - "Details: {0}".format(ex), + f"Authentication failed! Did you enter correct PIN?

Details: {ex}", title=NOTIFICATION_AUTH_TITLE, notification_id=NOTIFICATION_AUTH_ID, ) @@ -124,9 +123,7 @@ async def scan_apple_tvs(hass): if login_id is None: login_id = "Home Sharing disabled" devices.append( - "Name: {0}
Host: {1}
Login ID: {2}".format( - atv.name, atv.address, login_id - ) + f"Name: {atv.name}
Host: {atv.address}
Login ID: {login_id}" ) if not devices: diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py index 6258b470ebb..fb29a0ac8c7 100644 --- a/homeassistant/components/aprs/device_tracker.py +++ b/homeassistant/components/aprs/device_tracker.py @@ -57,7 +57,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def make_filter(callsigns: list) -> str: """Make a server-side filter from a list of callsigns.""" - return " ".join("b/{0}".format(cs.upper()) for cs in callsigns) + return " ".join(f"b/{sign.upper()}" for sign in callsigns) def gps_accuracy(gps, posambiguity: int) -> int: diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 1cc06fc446f..7fff009baa5 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -70,7 +70,7 @@ class AquaLogicSensor(Entity): @property def name(self): """Return the name of the sensor.""" - return "AquaLogic {}".format(SENSOR_TYPES[self._type][0]) + return f"AquaLogic {SENSOR_TYPES[self._type][0]}" @property def unit_of_measurement(self): diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py index 74f1a9d9f9a..6950929ee80 100644 --- a/homeassistant/components/aqualogic/switch.py +++ b/homeassistant/components/aqualogic/switch.py @@ -70,7 +70,7 @@ class AquaLogicSwitch(SwitchDevice): @property def name(self): """Return the name of the switch.""" - return "AquaLogic {}".format(SWITCH_TYPES[self._type]) + return f"AquaLogic {SWITCH_TYPES[self._type]}" @property def should_poll(self): diff --git a/homeassistant/components/arcam_fmj/__init__.py b/homeassistant/components/arcam_fmj/__init__.py index d818414753f..59bcd08a641 100644 --- a/homeassistant/components/arcam_fmj/__init__.py +++ b/homeassistant/components/arcam_fmj/__init__.py @@ -44,9 +44,9 @@ def _optional_zone(value): def _zone_name_validator(config): for zone, zone_config in config[CONF_ZONE].items(): if CONF_NAME not in zone_config: - zone_config[CONF_NAME] = "{} ({}:{}) - {}".format( - DEFAULT_NAME, config[CONF_HOST], config[CONF_PORT], zone - ) + zone_config[ + CONF_NAME + ] = f"{DEFAULT_NAME} ({config[CONF_HOST]}:{config[CONF_PORT]}) - {zone}" return config diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py index 2533ce3619e..1bb34a11693 100644 --- a/homeassistant/components/arest/sensor.py +++ b/homeassistant/components/arest/sensor.py @@ -140,7 +140,7 @@ class ArestSensor(Entity): """Initialize the sensor.""" self.arest = arest self._resource = resource - self._name = "{} {}".format(location.title(), name.title()) + self._name = f"{location.title()} {name.title()}" self._variable = variable self._pin = pin self._state = None @@ -204,8 +204,7 @@ class ArestData: try: if str(self._pin[0]) == "A": response = requests.get( - "{}/analog/{}".format(self._resource, self._pin[1:]), - timeout=10, + f"{self._resource,}/analog/{self._pin[1:]}", timeout=10 ) self.data = {"value": response.json()["return_value"]} except TypeError: diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index ccc2c5d8bf5..d3a51391627 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -86,7 +86,7 @@ class ArestSwitchBase(SwitchDevice): def __init__(self, resource, location, name): """Initialize the switch.""" self._resource = resource - self._name = "{} {}".format(location.title(), name.title()) + self._name = f"{location.title()} {name.title()}" self._state = None self._available = True diff --git a/homeassistant/components/arlo/__init__.py b/homeassistant/components/arlo/__init__.py index df24bdd1a92..40d75d557bb 100644 --- a/homeassistant/components/arlo/__init__.py +++ b/homeassistant/components/arlo/__init__.py @@ -67,9 +67,7 @@ def setup(hass, config): except (ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Netgear Arlo: %s", str(ex)) hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), + f"Error: {ex}
You will need to restart hass after fixing.", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) diff --git a/homeassistant/components/arlo/camera.py b/homeassistant/components/arlo/camera.py index 958c383765a..f52c22fced2 100644 --- a/homeassistant/components/arlo/camera.py +++ b/homeassistant/components/arlo/camera.py @@ -81,8 +81,9 @@ class ArloCam(Camera): video = self._camera.last_video if not video: - error_msg = "Video not found for {0}. Is it older than {1} days?".format( - self.name, self._camera.min_days_vdo_cache + error_msg = ( + f"Video not found for {self.name}. " + f"Is it older than {self._camera.min_days_vdo_cache} days?" ) _LOGGER.error(error_msg) return diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py index aadd5a48d37..959fe9916df 100644 --- a/homeassistant/components/arlo/sensor.py +++ b/homeassistant/components/arlo/sensor.py @@ -57,7 +57,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if sensor_type in ("temperature", "humidity", "air_quality"): continue - name = "{0} {1}".format(SENSOR_TYPES[sensor_type][0], camera.name) + name = f"{SENSOR_TYPES[sensor_type][0]} {camera.name}" sensors.append(ArloSensor(name, camera, sensor_type)) for base_station in arlo.base_stations: @@ -65,9 +65,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): sensor_type in ("temperature", "humidity", "air_quality") and base_station.model_id == "ABC1000" ): - name = "{0} {1}".format( - SENSOR_TYPES[sensor_type][0], base_station.name - ) + name = f"{SENSOR_TYPES[sensor_type][0]} {base_station.name}" sensors.append(ArloSensor(name, base_station, sensor_type)) add_entities(sensors, True) @@ -83,7 +81,7 @@ class ArloSensor(Entity): self._data = device self._sensor_type = sensor_type self._state = None - self._icon = "mdi:{}".format(SENSOR_TYPES.get(self._sensor_type)[2]) + self._icon = f"mdi:{SENSOR_TYPES.get(self._sensor_type)[2]}" @property def name(self): @@ -141,8 +139,9 @@ class ArloSensor(Entity): video = self._data.last_video self._state = video.created_at_pretty("%m-%d-%Y %H:%M:%S") except (AttributeError, IndexError): - error_msg = "Video not found for {0}. Older than {1} days?".format( - self.name, self._data.min_days_vdo_cache + error_msg = ( + f"Video not found for {self.name}. " + f"Older than {self._data.min_days_vdo_cache} days?" ) _LOGGER.debug(error_msg) self._state = None diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py index 485c731ff6a..355bcad3aaf 100644 --- a/homeassistant/components/aruba/device_tracker.py +++ b/homeassistant/components/aruba/device_tracker.py @@ -84,8 +84,8 @@ class ArubaDeviceScanner(DeviceScanner): def get_aruba_data(self): """Retrieve data from Aruba Access Point and return parsed result.""" - connect = "ssh {}@{}" - ssh = pexpect.spawn(connect.format(self.username, self.host)) + connect = f"ssh {self.username}@{self.host}" + ssh = pexpect.spawn(connect) query = ssh.expect( [ "password:", diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index 685e5d90f53..014c46fd73c 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -50,7 +50,7 @@ def discover_sensors(topic, payload): def _slug(name): - return "sensor.arwn_{}".format(slugify(name)) + return f"sensor.arwn_{slugify(name)}" async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): diff --git a/homeassistant/components/asterisk_cdr/mailbox.py b/homeassistant/components/asterisk_cdr/mailbox.py index 0bae6ebf3ad..12587e531d7 100644 --- a/homeassistant/components/asterisk_cdr/mailbox.py +++ b/homeassistant/components/asterisk_cdr/mailbox.py @@ -49,8 +49,10 @@ class AsteriskCDR(Mailbox): "duration": entry["duration"], } sha = hashlib.sha256(str(entry).encode("utf-8")).hexdigest() - msg = "Destination: {}\nApplication: {}\n Context: {}".format( - entry["dest"], entry["application"], entry["context"] + msg = ( + f"Destination: {entry['dest']}\n" + f"Application: {entry['application']}\n " + f"Context: {entry['context']}" ) cdr.append({"info": info, "sha": sha, "text": msg}) self.cdr = cdr diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 7c7108943fb..f49cae7b107 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -102,8 +102,7 @@ def request_configuration(hass, config, api, authenticator, token_refresh_lock): _CONFIGURING[DOMAIN] = configurator.request_config( NOTIFICATION_TITLE, august_configuration_callback, - description="Please check your {} ({}) and enter the verification " - "code below".format(login_method, username), + description=f"Please check your {login_method} ({username}) and enter the verification code below", submit_caption="Verify", fields=[ {"id": "verification_code", "name": "Verification code", "type": "string"} @@ -121,9 +120,7 @@ def setup_august(hass, config, api, authenticator, token_refresh_lock): _LOGGER.error("Unable to connect to August service: %s", str(ex)) hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), + "Error: {ex}
You will need to restart hass after fixing.", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 935642585fd..41bf820319c 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -70,14 +70,10 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= for door in data.locks: if not data.lock_has_doorsense(door.device_id): - _LOGGER.debug( - "Not adding sensor class door for lock %s ", door.device_name, - ) + _LOGGER.debug("Not adding sensor class door for lock %s ", door.device_name) continue - _LOGGER.debug( - "Adding sensor class door for %s", door.device_name, - ) + _LOGGER.debug("Adding sensor class door for %s", door.device_name) devices.append(AugustDoorBinarySensor(data, "door_open", door)) for doorbell in data.doorbells: @@ -121,7 +117,7 @@ class AugustDoorBinarySensor(BinarySensorDevice): @property def name(self): """Return the name of the binary sensor.""" - return "{} Open".format(self._door.device_name) + return f"{self._door.device_name} Open" async def async_update(self): """Get the latest state of the sensor and update activity.""" @@ -175,10 +171,7 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): @property def name(self): """Return the name of the binary sensor.""" - return "{} {}".format( - self._doorbell.device_name, - SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME], - ) + return f"{self._doorbell.device_name} {SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME]}" async def async_update(self): """Get the latest state of the sensor.""" @@ -194,7 +187,7 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): @property def unique_id(self) -> str: """Get the unique id of the doorbell sensor.""" - return "{:s}_{:s}".format( - self._doorbell.device_id, - SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME].lower(), + return ( + f"{self._doorbell.device_id}_" + f"{SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME].lower()}" ) diff --git a/homeassistant/components/aurora_abb_powerone/sensor.py b/homeassistant/components/aurora_abb_powerone/sensor.py index a2645e5d7cb..69a513dd8fb 100644 --- a/homeassistant/components/aurora_abb_powerone/sensor.py +++ b/homeassistant/components/aurora_abb_powerone/sensor.py @@ -96,7 +96,6 @@ class AuroraABBSolarPVMonitorSensor(Entity): if "No response after" in str(error): _LOGGER.debug("No response from inverter (could be dark)") else: - # print("Exception!!: {}".format(str(e))) raise error self._state = None finally: diff --git a/homeassistant/components/automatic/device_tracker.py b/homeassistant/components/automatic/device_tracker.py index 3c9e33cdc84..0fc747ffaa9 100644 --- a/homeassistant/components/automatic/device_tracker.py +++ b/homeassistant/components/automatic/device_tracker.py @@ -28,7 +28,6 @@ from homeassistant.helpers.event import async_track_time_interval _LOGGER = logging.getLogger(__name__) ATTR_FUEL_LEVEL = "fuel_level" -AUTOMATIC_CONFIG_FILE = ".automatic/session-{}.json" CONF_CLIENT_ID = "client_id" CONF_CURRENT_LOCATION = "current_location" @@ -95,7 +94,7 @@ def async_setup_scanner(hass, config, async_see, discovery_info=None): request_kwargs={"timeout": DEFAULT_TIMEOUT}, ) - filename = AUTOMATIC_CONFIG_FILE.format(config[CONF_CLIENT_ID]) + filename = f".automatic/session-{config[CONF_CLIENT_ID]}.json" refresh_token = yield from hass.async_add_job( _get_refresh_token_from_file, hass, filename ) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 1a73de885c0..b1d6c24d303 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -72,9 +72,7 @@ AutomationActionType = Callable[[HomeAssistant, TemplateVarsType], Awaitable[Non def _platform_validator(config): """Validate it is a valid platform.""" try: - platform = importlib.import_module( - ".{}".format(config[CONF_PLATFORM]), __name__ - ) + platform = importlib.import_module(f".{config[CONF_PLATFORM]}", __name__) except ImportError: raise vol.Invalid("Invalid platform specified") from None @@ -221,7 +219,7 @@ async def async_setup(hass, config): await _async_process_config(hass, conf, component) async_register_admin_service( - hass, DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}), + hass, DOMAIN, SERVICE_RELOAD, reload_service_handler, schema=vol.Schema({}) ) return True @@ -456,9 +454,7 @@ class AutomationEntity(ToggleEntity, RestoreEntity): info = {"name": self._name} for conf in self._trigger_config: - platform = importlib.import_module( - ".{}".format(conf[CONF_PLATFORM]), __name__ - ) + platform = importlib.import_module(f".{conf[CONF_PLATFORM]}", __name__) remove = await platform.async_attach_trigger( self.hass, conf, self.async_trigger, info diff --git a/homeassistant/components/automation/config.py b/homeassistant/components/automation/config.py index d11472a2128..d29a561f378 100644 --- a/homeassistant/components/automation/config.py +++ b/homeassistant/components/automation/config.py @@ -26,7 +26,7 @@ async def async_validate_config_item(hass, config, full_config=None): triggers = [] for trigger in config[CONF_TRIGGER]: trigger_platform = importlib.import_module( - "..{}".format(trigger[CONF_PLATFORM]), __name__ + f"..{trigger[CONF_PLATFORM]}", __name__ ) if hasattr(trigger_platform, "async_validate_trigger_config"): trigger = await trigger_platform.async_validate_trigger_config( diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py index b3593179ffc..d7551abebc1 100644 --- a/homeassistant/components/axis/binary_sensor.py +++ b/homeassistant/components/axis/binary_sensor.py @@ -79,8 +79,8 @@ class AxisBinarySensor(AxisEventBase, BinarySensorDevice): and self.event.id and self.device.api.vapix.ports[self.event.id].name ): - return "{} {}".format( - self.device.name, self.device.api.vapix.ports[self.event.id].name + return ( + f"{self.device.name} {self.device.api.vapix.ports[self.event.id].name}" ) return super().name diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py index 51d6b6805cc..3cf84ce2288 100644 --- a/homeassistant/components/axis/camera.py +++ b/homeassistant/components/axis/camera.py @@ -21,10 +21,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from .axis_base import AxisEntityBase from .const import DOMAIN as AXIS_DOMAIN -AXIS_IMAGE = "http://{}:{}/axis-cgi/jpg/image.cgi" -AXIS_VIDEO = "http://{}:{}/axis-cgi/mjpg/video.cgi" -AXIS_STREAM = "rtsp://{}:{}@{}/axis-media/media.amp?videocodec=h264" - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Axis camera video stream.""" @@ -36,11 +32,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): CONF_NAME: config_entry.data[CONF_NAME], CONF_USERNAME: config_entry.data[CONF_USERNAME], CONF_PASSWORD: config_entry.data[CONF_PASSWORD], - CONF_MJPEG_URL: AXIS_VIDEO.format( - config_entry.data[CONF_HOST], config_entry.data[CONF_PORT], + CONF_MJPEG_URL: ( + f"http://{config_entry.data[CONF_HOST]}" + f":{config_entry.data[CONF_PORT]}/axis-cgi/mjpg/video.cgi" ), - CONF_STILL_IMAGE_URL: AXIS_IMAGE.format( - config_entry.data[CONF_HOST], config_entry.data[CONF_PORT], + CONF_STILL_IMAGE_URL: ( + f"http://{config_entry.data[CONF_HOST]}" + f":{config_entry.data[CONF_PORT]}/axis-cgi/jpg/image.cgi" ), CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION, } @@ -72,17 +70,19 @@ class AxisCamera(AxisEntityBase, MjpegCamera): async def stream_source(self): """Return the stream source.""" - return AXIS_STREAM.format( - self.device.config_entry.data[CONF_USERNAME], - self.device.config_entry.data[CONF_PASSWORD], - self.device.host, + return ( + f"rtsp://{self.device.config_entry.data[CONF_USERNAME]}´" + f":{self.device.config_entry.data[CONF_PASSWORD]}" + f"@{self.device.host}/axis-media/media.amp?videocodec=h264" ) def _new_address(self): """Set new device address for video stream.""" port = self.device.config_entry.data[CONF_PORT] - self._mjpeg_url = AXIS_VIDEO.format(self.device.host, port) - self._still_image_url = AXIS_IMAGE.format(self.device.host, port) + self._mjpeg_url = (f"http://{self.device.host}:{port}/axis-cgi/mjpg/video.cgi",) + self._still_image_url = ( + f"http://{self.device.host}:{port}/axis-cgi/jpg/image.cgi" + ) @property def unique_id(self): diff --git a/homeassistant/components/axis/switch.py b/homeassistant/components/axis/switch.py index a83460bc529..ed822543a00 100644 --- a/homeassistant/components/axis/switch.py +++ b/homeassistant/components/axis/switch.py @@ -53,8 +53,8 @@ class AxisSwitch(AxisEventBase, SwitchDevice): def name(self): """Return the name of the event.""" if self.event.id and self.device.api.vapix.ports[self.event.id].name: - return "{} {}".format( - self.device.name, self.device.api.vapix.ports[self.event.id].name + return ( + f"{self.device.name} {self.device.api.vapix.ports[self.event.id].name}" ) return super().name diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index 7e141cd8060..cc59790b646 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -47,8 +47,9 @@ async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]): """Activate Azure EH component.""" config = yaml_config[DOMAIN] - event_hub_address = "amqps://{}.servicebus.windows.net/{}".format( - config[CONF_EVENT_HUB_NAMESPACE], config[CONF_EVENT_HUB_INSTANCE_NAME] + event_hub_address = ( + f"amqps://{config[CONF_EVENT_HUB_NAMESPACE]}" + f".servicebus.windows.net/{config[CONF_EVENT_HUB_INSTANCE_NAME]}" ) entities_filter = config[CONF_FILTER] diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py index 903314ab1b7..a0d575deac0 100644 --- a/tests/components/adguard/test_config_flow.py +++ b/tests/components/adguard/test_config_flow.py @@ -40,11 +40,9 @@ async def test_show_authenticate_form(hass): async def test_connection_error(hass, aioclient_mock): """Test we show user form on AdGuard Home connection error.""" aioclient_mock.get( - "{}://{}:{}/control/status".format( - "https" if FIXTURE_USER_INPUT[CONF_SSL] else "http", - FIXTURE_USER_INPUT[CONF_HOST], - FIXTURE_USER_INPUT[CONF_PORT], - ), + f"{'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http'}" + f"://{FIXTURE_USER_INPUT[CONF_HOST]}" + f":{FIXTURE_USER_INPUT[CONF_PORT]}/control/status", exc=aiohttp.ClientError, ) @@ -60,11 +58,9 @@ async def test_connection_error(hass, aioclient_mock): async def test_full_flow_implementation(hass, aioclient_mock): """Test registering an integration and finishing flow works.""" aioclient_mock.get( - "{}://{}:{}/control/status".format( - "https" if FIXTURE_USER_INPUT[CONF_SSL] else "http", - FIXTURE_USER_INPUT[CONF_HOST], - FIXTURE_USER_INPUT[CONF_PORT], - ), + f"{'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http'}" + f"://{FIXTURE_USER_INPUT[CONF_HOST]}" + f":{FIXTURE_USER_INPUT[CONF_PORT]}/control/status", json={"version": "v0.99.0"}, headers={"Content-Type": "application/json"}, ) @@ -244,11 +240,9 @@ async def test_hassio_connection_error(hass, aioclient_mock): async def test_outdated_adguard_version(hass, aioclient_mock): """Test we show abort when connecting with unsupported AdGuard version.""" aioclient_mock.get( - "{}://{}:{}/control/status".format( - "https" if FIXTURE_USER_INPUT[CONF_SSL] else "http", - FIXTURE_USER_INPUT[CONF_HOST], - FIXTURE_USER_INPUT[CONF_PORT], - ), + f"{'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http'}" + f"://{FIXTURE_USER_INPUT[CONF_HOST]}" + f":{FIXTURE_USER_INPUT[CONF_PORT]}/control/status", json={"version": "v0.98.0"}, headers={"Content-Type": "application/json"}, ) diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index ec14cefc291..9b890aa4d25 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -207,20 +207,18 @@ async def test_if_fires_on_state_change(hass, calls): hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_TRIGGERED) await hass.async_block_till_done() assert len(calls) == 1 - assert calls[0].data[ - "some" - ] == "triggered - device - {} - pending - triggered - None".format( - "alarm_control_panel.entity" + assert ( + calls[0].data["some"] + == "triggered - device - alarm_control_panel.entity - pending - triggered - None" ) # Fake that the entity is disarmed. hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_DISARMED) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[1].data[ - "some" - ] == "disarmed - device - {} - triggered - disarmed - None".format( - "alarm_control_panel.entity" + assert ( + calls[1].data["some"] + == "disarmed - device - alarm_control_panel.entity - triggered - disarmed - None" ) # Fake that the entity is armed home. @@ -228,10 +226,9 @@ async def test_if_fires_on_state_change(hass, calls): hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_HOME) await hass.async_block_till_done() assert len(calls) == 3 - assert calls[2].data[ - "some" - ] == "armed_home - device - {} - pending - armed_home - None".format( - "alarm_control_panel.entity" + assert ( + calls[2].data["some"] + == "armed_home - device - alarm_control_panel.entity - pending - armed_home - None" ) # Fake that the entity is armed away. @@ -239,10 +236,9 @@ async def test_if_fires_on_state_change(hass, calls): hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_AWAY) await hass.async_block_till_done() assert len(calls) == 4 - assert calls[3].data[ - "some" - ] == "armed_away - device - {} - pending - armed_away - None".format( - "alarm_control_panel.entity" + assert ( + calls[3].data["some"] + == "armed_away - device - alarm_control_panel.entity - pending - armed_away - None" ) # Fake that the entity is armed night. @@ -250,8 +246,7 @@ async def test_if_fires_on_state_change(hass, calls): hass.states.async_set("alarm_control_panel.entity", STATE_ALARM_ARMED_NIGHT) await hass.async_block_till_done() assert len(calls) == 5 - assert calls[4].data[ - "some" - ] == "armed_night - device - {} - pending - armed_night - None".format( - "alarm_control_panel.entity" + assert ( + calls[4].data["some"] + == "armed_night - device - alarm_control_panel.entity - pending - armed_night - None" ) diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py index 55a3112c32f..d4de97f3b46 100644 --- a/tests/components/alert/test_init.py +++ b/tests/components/alert/test_init.py @@ -60,7 +60,7 @@ TEST_NOACK = [ None, None, ] -ENTITY_ID = alert.ENTITY_ID_FORMAT.format(NAME) +ENTITY_ID = f"{alert.DOMAIN}.{NAME}" def turn_on(hass, entity_id): diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py index d3fe28d227d..d459ee2cc32 100644 --- a/tests/components/alexa/test_flash_briefings.py +++ b/tests/components/alexa/test_flash_briefings.py @@ -63,7 +63,7 @@ def alexa_client(loop, hass, hass_client): def _flash_briefing_req(client, briefing_id): - return client.get("/api/alexa/flash_briefings/{}".format(briefing_id)) + return client.get(f"/api/alexa/flash_briefings/{briefing_id}") async def test_flash_briefing_invalid_id(alexa_client): diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 01c8b27bfcc..4417f3d4f91 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -36,7 +36,7 @@ async def test_api_list_state_entities(hass, mock_api_client): async def test_api_get_state(hass, mock_api_client): """Test if the debug interface allows us to get a state.""" hass.states.async_set("hello.world", "nice", {"attr": 1}) - resp = await mock_api_client.get(const.URL_API_STATES_ENTITY.format("hello.world")) + resp = await mock_api_client.get("/api/states/hello.world") assert resp.status == 200 json = await resp.json() @@ -51,9 +51,7 @@ async def test_api_get_state(hass, mock_api_client): async def test_api_get_non_existing_state(hass, mock_api_client): """Test if the debug interface allows us to get a state.""" - resp = await mock_api_client.get( - const.URL_API_STATES_ENTITY.format("does_not_exist") - ) + resp = await mock_api_client.get("/api/states/does_not_exist") assert resp.status == 404 @@ -62,8 +60,7 @@ async def test_api_state_change(hass, mock_api_client): hass.states.async_set("test.test", "not_to_be_set") await mock_api_client.post( - const.URL_API_STATES_ENTITY.format("test.test"), - json={"state": "debug_state_change2"}, + "/api/states/test.test", json={"state": "debug_state_change2"} ) assert hass.states.get("test.test").state == "debug_state_change2" @@ -75,8 +72,7 @@ async def test_api_state_change_of_non_existing_entity(hass, mock_api_client): new_state = "debug_state_change" resp = await mock_api_client.post( - const.URL_API_STATES_ENTITY.format("test_entity.that_does_not_exist"), - json={"state": new_state}, + "/api/states/test_entity.that_does_not_exist", json={"state": new_state} ) assert resp.status == 201 @@ -88,7 +84,7 @@ async def test_api_state_change_of_non_existing_entity(hass, mock_api_client): async def test_api_state_change_with_bad_data(hass, mock_api_client): """Test if API sends appropriate error if we omit state.""" resp = await mock_api_client.post( - const.URL_API_STATES_ENTITY.format("test_entity.that_does_not_exist"), json={} + "/api/states/test_entity.that_does_not_exist", json={} ) assert resp.status == 400 @@ -98,15 +94,13 @@ async def test_api_state_change_with_bad_data(hass, mock_api_client): async def test_api_state_change_to_zero_value(hass, mock_api_client): """Test if changing a state to a zero value is possible.""" resp = await mock_api_client.post( - const.URL_API_STATES_ENTITY.format("test_entity.with_zero_state"), - json={"state": 0}, + "/api/states/test_entity.with_zero_state", json={"state": 0} ) assert resp.status == 201 resp = await mock_api_client.post( - const.URL_API_STATES_ENTITY.format("test_entity.with_zero_state"), - json={"state": 0.0}, + "/api/states/test_entity.with_zero_state", json={"state": 0.0} ) assert resp.status == 200 @@ -126,15 +120,12 @@ async def test_api_state_change_push(hass, mock_api_client): hass.bus.async_listen(const.EVENT_STATE_CHANGED, event_listener) - await mock_api_client.post( - const.URL_API_STATES_ENTITY.format("test.test"), json={"state": "not_to_be_set"} - ) + await mock_api_client.post("/api/states/test.test", json={"state": "not_to_be_set"}) await hass.async_block_till_done() assert len(events) == 0 await mock_api_client.post( - const.URL_API_STATES_ENTITY.format("test.test"), - json={"state": "not_to_be_set", "force_update": True}, + "/api/states/test.test", json={"state": "not_to_be_set", "force_update": True} ) await hass.async_block_till_done() assert len(events) == 1 @@ -152,7 +143,7 @@ async def test_api_fire_event_with_no_data(hass, mock_api_client): hass.bus.async_listen_once("test.event_no_data", listener) - await mock_api_client.post(const.URL_API_EVENTS_EVENT.format("test.event_no_data")) + await mock_api_client.post("/api/events/test.event_no_data") await hass.async_block_till_done() assert len(test_value) == 1 @@ -174,9 +165,7 @@ async def test_api_fire_event_with_data(hass, mock_api_client): hass.bus.async_listen_once("test_event_with_data", listener) - await mock_api_client.post( - const.URL_API_EVENTS_EVENT.format("test_event_with_data"), json={"test": 1} - ) + await mock_api_client.post("/api/events/test_event_with_data", json={"test": 1}) await hass.async_block_till_done() @@ -196,8 +185,7 @@ async def test_api_fire_event_with_invalid_json(hass, mock_api_client): hass.bus.async_listen_once("test_event_bad_data", listener) resp = await mock_api_client.post( - const.URL_API_EVENTS_EVENT.format("test_event_bad_data"), - data=json.dumps("not an object"), + "/api/events/test_event_bad_data", data=json.dumps("not an object") ) await hass.async_block_till_done() @@ -207,8 +195,7 @@ async def test_api_fire_event_with_invalid_json(hass, mock_api_client): # Try now with valid but unusable JSON resp = await mock_api_client.post( - const.URL_API_EVENTS_EVENT.format("test_event_bad_data"), - data=json.dumps([1, 2, 3]), + "/api/events/test_event_bad_data", data=json.dumps([1, 2, 3]) ) await hass.async_block_till_done() @@ -272,9 +259,7 @@ async def test_api_call_service_no_data(hass, mock_api_client): hass.services.async_register("test_domain", "test_service", listener) - await mock_api_client.post( - const.URL_API_SERVICES_SERVICE.format("test_domain", "test_service") - ) + await mock_api_client.post("/api/services/test_domain/test_service") await hass.async_block_till_done() assert len(test_value) == 1 @@ -295,8 +280,7 @@ async def test_api_call_service_with_data(hass, mock_api_client): hass.services.async_register("test_domain", "test_service", listener) await mock_api_client.post( - const.URL_API_SERVICES_SERVICE.format("test_domain", "test_service"), - json={"test": 1}, + "/api/services/test_domain/test_service", json={"test": 1} ) await hass.async_block_till_done() @@ -348,7 +332,7 @@ async def test_stream_with_restricted(hass, mock_api_client): listen_count = _listen_count(hass) resp = await mock_api_client.get( - "{}?restrict=test_event1,test_event3".format(const.URL_API_STREAM) + f"{const.URL_API_STREAM}?restrict=test_event1,test_event3" ) assert resp.status == 200 assert listen_count + 1 == _listen_count(hass) @@ -403,7 +387,7 @@ async def test_api_error_log(hass, aiohttp_client, hass_access_token, hass_admin ) as mock_file: resp = await client.get( const.URL_API_ERROR_LOG, - headers={"Authorization": "Bearer {}".format(hass_access_token)}, + headers={"Authorization": f"Bearer {hass_access_token}"}, ) assert len(mock_file.mock_calls) == 1 @@ -415,7 +399,7 @@ async def test_api_error_log(hass, aiohttp_client, hass_access_token, hass_admin hass_admin_user.groups = [] resp = await client.get( const.URL_API_ERROR_LOG, - headers={"Authorization": "Bearer {}".format(hass_access_token)}, + headers={"Authorization": f"Bearer {hass_access_token}"}, ) assert resp.status == 401 @@ -432,8 +416,8 @@ async def test_api_fire_event_context(hass, mock_api_client, hass_access_token): hass.bus.async_listen("test.event", listener) await mock_api_client.post( - const.URL_API_EVENTS_EVENT.format("test.event"), - headers={"authorization": "Bearer {}".format(hass_access_token)}, + "/api/events/test.event", + headers={"authorization": f"Bearer {hass_access_token}"}, ) await hass.async_block_till_done() @@ -449,7 +433,7 @@ async def test_api_call_service_context(hass, mock_api_client, hass_access_token await mock_api_client.post( "/api/services/test_domain/test_service", - headers={"authorization": "Bearer {}".format(hass_access_token)}, + headers={"authorization": f"Bearer {hass_access_token}"}, ) await hass.async_block_till_done() @@ -464,7 +448,7 @@ async def test_api_set_state_context(hass, mock_api_client, hass_access_token): await mock_api_client.post( "/api/states/light.kitchen", json={"state": "on"}, - headers={"authorization": "Bearer {}".format(hass_access_token)}, + headers={"authorization": f"Bearer {hass_access_token}"}, ) refresh_token = await hass.auth.async_validate_access_token(hass_access_token) @@ -542,9 +526,7 @@ async def test_rendering_template_legacy_user( async def test_api_call_service_not_found(hass, mock_api_client): """Test if the API fails 400 if unknown service.""" - resp = await mock_api_client.post( - const.URL_API_SERVICES_SERVICE.format("test_domain", "test_service") - ) + resp = await mock_api_client.post("/api/services/test_domain/test_service") assert resp.status == 400 @@ -562,7 +544,6 @@ async def test_api_call_service_bad_data(hass, mock_api_client): ) resp = await mock_api_client.post( - const.URL_API_SERVICES_SERVICE.format("test_domain", "test_service"), - json={"hello": 5}, + "/api/services/test_domain/test_service", json={"hello": 5} ) assert resp.status == 400 diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 96d497c3dae..2c9a39c6fb6 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -28,7 +28,7 @@ async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): step = await resp.json() resp = await client.post( - "/auth/login_flow/{}".format(step["flow_id"]), + f"/auth/login_flow/{step['flow_id']}", json={"client_id": CLIENT_ID, "username": "test-user", "password": "test-pass"}, ) @@ -71,7 +71,7 @@ async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): assert resp.status == 401 resp = await client.get( - "/api/", headers={"authorization": "Bearer {}".format(tokens["access_token"])} + "/api/", headers={"authorization": f"Bearer {tokens['access_token']}"} ) assert resp.status == 200 diff --git a/tests/components/auth/test_init_link_user.py b/tests/components/auth/test_init_link_user.py index 2aa6b0d9f8d..3f0e9bce063 100644 --- a/tests/components/auth/test_init_link_user.py +++ b/tests/components/auth/test_init_link_user.py @@ -42,7 +42,7 @@ async def async_get_code(hass, aiohttp_client): step = await resp.json() resp = await client.post( - "/auth/login_flow/{}".format(step["flow_id"]), + f"/auth/login_flow/{step['flow_id']}", json={"client_id": CLIENT_ID, "username": "2nd-user", "password": "2nd-pass"}, ) @@ -67,7 +67,7 @@ async def test_link_user(hass, aiohttp_client): resp = await client.post( "/auth/link_user", json={"client_id": CLIENT_ID, "code": code}, - headers={"authorization": "Bearer {}".format(info["access_token"])}, + headers={"authorization": f"Bearer {info['access_token']}"}, ) assert resp.status == 200 @@ -84,7 +84,7 @@ async def test_link_user_invalid_client_id(hass, aiohttp_client): resp = await client.post( "/auth/link_user", json={"client_id": "invalid", "code": code}, - headers={"authorization": "Bearer {}".format(info["access_token"])}, + headers={"authorization": f"Bearer {info['access_token']}"}, ) assert resp.status == 400 @@ -100,7 +100,7 @@ async def test_link_user_invalid_code(hass, aiohttp_client): resp = await client.post( "/auth/link_user", json={"client_id": CLIENT_ID, "code": "invalid"}, - headers={"authorization": "Bearer {}".format(info["access_token"])}, + headers={"authorization": f"Bearer {info['access_token']}"}, ) assert resp.status == 400 diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py index d7bb5448938..e6e5281d601 100644 --- a/tests/components/auth/test_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -54,7 +54,7 @@ async def test_invalid_username_password(hass, aiohttp_client): # Incorrect username resp = await client.post( - "/auth/login_flow/{}".format(step["flow_id"]), + f"/auth/login_flow/{step['flow_id']}", json={ "client_id": CLIENT_ID, "username": "wrong-user", @@ -70,7 +70,7 @@ async def test_invalid_username_password(hass, aiohttp_client): # Incorrect password resp = await client.post( - "/auth/login_flow/{}".format(step["flow_id"]), + f"/auth/login_flow/{step['flow_id']}", json={ "client_id": CLIENT_ID, "username": "test-user", @@ -105,7 +105,7 @@ async def test_login_exist_user(hass, aiohttp_client): step = await resp.json() resp = await client.post( - "/auth/login_flow/{}".format(step["flow_id"]), + f"/auth/login_flow/{step['flow_id']}", json={"client_id": CLIENT_ID, "username": "test-user", "password": "test-pass"}, ) From d2d788631eab5b2766ebb86330a0883c838e899f Mon Sep 17 00:00:00 2001 From: Kit Klein <33464407+kit-klein@users.noreply.github.com> Date: Sun, 23 Feb 2020 16:54:18 -0500 Subject: [PATCH 058/416] 0.106 Beta - provide correctly formatted placeholders (#32119) --- .../components/konnected/config_flow.py | 14 ++-- .../components/konnected/test_config_flow.py | 15 ++++ tests/components/konnected/test_init.py | 68 +++++++++++++++++++ 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py index 447211308ae..b6e0c00c465 100644 --- a/homeassistant/components/konnected/config_flow.py +++ b/homeassistant/components/konnected/config_flow.py @@ -270,6 +270,10 @@ class KonnectedFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", + description_placeholders={ + "host": self.data.get(CONF_HOST, "Unknown"), + "port": self.data.get(CONF_PORT, "Unknown"), + }, data_schema=vol.Schema( { vol.Required(CONF_HOST, default=self.data.get(CONF_HOST)): str, @@ -556,7 +560,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): } ), description_placeholders={ - "zone": "Zone {self.active_cfg}" + "zone": f"Zone {self.active_cfg}" if len(self.active_cfg) < 3 else self.active_cfg.upper }, @@ -594,7 +598,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): } ), description_placeholders={ - "zone": "Zone {self.active_cfg}" + "zone": f"Zone {self.active_cfg}" if len(self.active_cfg) < 3 else self.active_cfg.upper() }, @@ -624,7 +628,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): } ), description_placeholders={ - "zone": "Zone {self.active_cfg}" + "zone": f"Zone {self.active_cfg}" if len(self.active_cfg) < 3 else self.active_cfg.upper() }, @@ -671,7 +675,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): } ), description_placeholders={ - "zone": "Zone {self.active_cfg}" + "zone": f"Zone {self.active_cfg}" if len(self.active_cfg) < 3 else self.active_cfg.upper() }, @@ -710,7 +714,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): } ), description_placeholders={ - "zone": "Zone {self.active_cfg}" + "zone": f"Zone {self.active_cfg}" if len(self.active_cfg) < 3 else self.active_cfg.upper() }, diff --git a/tests/components/konnected/test_config_flow.py b/tests/components/konnected/test_config_flow.py index 9b7a498731d..8dfead58659 100644 --- a/tests/components/konnected/test_config_flow.py +++ b/tests/components/konnected/test_config_flow.py @@ -597,6 +597,9 @@ async def test_option_flow(hass, mock_panel): ) assert result["type"] == "form" assert result["step_id"] == "options_binary" + assert result["description_placeholders"] == { + "zone": "Zone 2", + } # zone 2 result = await hass.config_entries.options.async_configure( @@ -604,6 +607,9 @@ async def test_option_flow(hass, mock_panel): ) assert result["type"] == "form" assert result["step_id"] == "options_binary" + assert result["description_placeholders"] == { + "zone": "Zone 6", + } # zone 6 result = await hass.config_entries.options.async_configure( @@ -612,6 +618,9 @@ async def test_option_flow(hass, mock_panel): ) assert result["type"] == "form" assert result["step_id"] == "options_digital" + assert result["description_placeholders"] == { + "zone": "Zone 3", + } # zone 3 result = await hass.config_entries.options.async_configure( @@ -619,6 +628,9 @@ async def test_option_flow(hass, mock_panel): ) assert result["type"] == "form" assert result["step_id"] == "options_switch" + assert result["description_placeholders"] == { + "zone": "Zone 4", + } # zone 4 result = await hass.config_entries.options.async_configure( @@ -626,6 +638,9 @@ async def test_option_flow(hass, mock_panel): ) assert result["type"] == "form" assert result["step_id"] == "options_switch" + assert result["description_placeholders"] == { + "zone": "OUT", + } # zone out result = await hass.config_entries.options.async_configure( diff --git a/tests/components/konnected/test_init.py b/tests/components/konnected/test_init.py index e1a1d2e72f8..907f83cd981 100644 --- a/tests/components/konnected/test_init.py +++ b/tests/components/konnected/test_init.py @@ -250,6 +250,74 @@ async def test_setup_defined_hosts_no_known_auth(hass): assert len(hass.config_entries.flow.async_progress()) == 1 +async def test_setup_multiple(hass): + """Test we initiate config entry for multiple panels.""" + assert ( + await async_setup_component( + hass, + konnected.DOMAIN, + { + konnected.DOMAIN: { + konnected.CONF_ACCESS_TOKEN: "arandomstringvalue", + konnected.CONF_API_HOST: "http://192.168.86.32:8123", + konnected.CONF_DEVICES: [ + { + konnected.CONF_ID: "aabbccddeeff", + "binary_sensors": [ + { + "zone": 4, + "type": "motion", + "name": "Hallway Motion", + }, + { + "zone": 5, + "type": "window", + "name": "Master Bedroom Window", + }, + { + "zone": 6, + "type": "window", + "name": "Downstairs Windows", + }, + ], + "switches": [{"zone": "out", "name": "siren"}], + }, + { + konnected.CONF_ID: "445566778899", + "binary_sensors": [ + {"zone": 1, "type": "motion", "name": "Front"}, + {"zone": 2, "type": "window", "name": "Back"}, + ], + "switches": [ + { + "zone": "out", + "name": "Buzzer", + "momentary": 65, + "pause": 55, + "repeat": 4, + }, + ], + }, + ], + } + }, + ) + is True + ) + + # Flow started for discovered bridge + assert len(hass.config_entries.flow.async_progress()) == 2 + + # Globals saved + assert ( + hass.data[konnected.DOMAIN][konnected.CONF_ACCESS_TOKEN] == "arandomstringvalue" + ) + assert ( + hass.data[konnected.DOMAIN][konnected.CONF_API_HOST] + == "http://192.168.86.32:8123" + ) + + async def test_config_passed_to_config_entry(hass): """Test that configured options for a host are loaded via config entry.""" entry = MockConfigEntry( From 693441e56f81345d71a0341b5c354ef162c19943 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 23 Feb 2020 11:54:35 -1000 Subject: [PATCH 059/416] Deduplicate code in the august integration (#32101) * Deduplicate code in the august integration * Add additional tests for august (more coming) * Door state is now updated when a lock or unlock call returns as the state is contained in the response which avoids the confusing out of sync state * revert * document known issue with doorsense and lock getting out of sync (pre-existing) * Address review comments * Additional review comments --- homeassistant/components/august/__init__.py | 118 ++++------- homeassistant/components/august/lock.py | 38 ++-- tests/components/august/mocks.py | 184 +++++++++++++----- tests/components/august/test_binary_sensor.py | 70 +++++++ tests/components/august/test_camera.py | 18 ++ tests/components/august/test_lock.py | 14 +- tests/fixtures/august/get_doorbell.json | 83 ++++++++ 7 files changed, 379 insertions(+), 146 deletions(-) create mode 100644 tests/components/august/test_camera.py create mode 100644 tests/fixtures/august/get_doorbell.json diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index f49cae7b107..95206b5bee1 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -40,18 +40,20 @@ DATA_AUGUST = "august" DOMAIN = "august" DEFAULT_ENTITY_NAMESPACE = "august" -# Limit battery and hardware updates to 1800 seconds +# Limit battery, online, and hardware updates to 1800 seconds # in order to reduce the number of api requests and # avoid hitting rate limits MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES = timedelta(seconds=1800) # Doorbells need to update more frequently than locks -# since we get an image from the doorbell api -MIN_TIME_BETWEEN_DOORBELL_STATUS_UPDATES = timedelta(seconds=20) +# since we get an image from the doorbell api. Once +# py-august 0.18.0 is released doorbell status updates +# can be reduced in the same was as locks have been +MIN_TIME_BETWEEN_DOORBELL_DETAIL_UPDATES = timedelta(seconds=20) # Activity needs to be checked more frequently as the # doorbell motion and rings are included here -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) +MIN_TIME_BETWEEN_ACTIVITY_UPDATES = timedelta(seconds=10) DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) @@ -265,7 +267,7 @@ class AugustData: activities = await self.async_get_device_activities(device_id, *activity_types) return next(iter(activities or []), None) - @Throttle(MIN_TIME_BETWEEN_UPDATES) + @Throttle(MIN_TIME_BETWEEN_ACTIVITY_UPDATES) async def _async_update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): """Update data object with latest from August API.""" @@ -292,77 +294,32 @@ class AugustData: _LOGGER.debug("Completed retrieving device activities") - async def async_get_doorbell_detail(self, doorbell_id): + async def async_get_doorbell_detail(self, device_id): """Return doorbell detail.""" - await self._async_update_doorbells() - return self._doorbell_detail_by_id.get(doorbell_id) + await self._async_update_doorbells_detail() + return self._doorbell_detail_by_id.get(device_id) - @Throttle(MIN_TIME_BETWEEN_DOORBELL_STATUS_UPDATES) - async def _async_update_doorbells(self): - await self._hass.async_add_executor_job(self._update_doorbells) + @Throttle(MIN_TIME_BETWEEN_DOORBELL_DETAIL_UPDATES) + async def _async_update_doorbells_detail(self): + await self._hass.async_add_executor_job(self._update_doorbells_detail) - def _update_doorbells(self): - detail_by_id = {} + def _update_doorbells_detail(self): + self._doorbell_detail_by_id = self._update_device_detail( + "doorbell", self._doorbells, self._api.get_doorbell_detail + ) - _LOGGER.debug("Start retrieving doorbell details") - for doorbell in self._doorbells: - _LOGGER.debug("Updating doorbell status for %s", doorbell.device_name) - try: - detail_by_id[doorbell.device_id] = self._api.get_doorbell_detail( - self._access_token, doorbell.device_id - ) - except RequestException as ex: - _LOGGER.error( - "Request error trying to retrieve doorbell status for %s. %s", - doorbell.device_name, - ex, - ) - detail_by_id[doorbell.device_id] = None - except Exception: - detail_by_id[doorbell.device_id] = None - raise - - _LOGGER.debug("Completed retrieving doorbell details") - self._doorbell_detail_by_id = detail_by_id - - def update_door_state(self, lock_id, door_state, update_start_time_utc): - """Set the door status and last status update time. - - This is called when newer activity is detected on the activity feed - in order to keep the internal data in sync - """ - # When syncing the door state became available via py-august, this - # function caused to be actively used. It will be again as we will - # update the door state from lock/unlock operations as the august api - # does report the door state on lock/unlock, however py-august does not - # expose this to us yet. - self._lock_detail_by_id[lock_id].door_state = door_state - self._lock_detail_by_id[lock_id].door_state_datetime = update_start_time_utc - return True - - def update_lock_status(self, lock_id, lock_status, update_start_time_utc): - """Set the lock status and last status update time. - - This is used when the lock, unlock apis are called - or newer activity is detected on the activity feed - in order to keep the internal data in sync - """ - self._lock_detail_by_id[lock_id].lock_status = lock_status - self._lock_detail_by_id[lock_id].lock_status_datetime = update_start_time_utc - return True - - def lock_has_doorsense(self, lock_id): + def lock_has_doorsense(self, device_id): """Determine if a lock has doorsense installed and can tell when the door is open or closed.""" # We do not update here since this is not expected # to change until restart - if self._lock_detail_by_id[lock_id] is None: + if self._lock_detail_by_id[device_id] is None: return False - return self._lock_detail_by_id[lock_id].doorsense + return self._lock_detail_by_id[device_id].doorsense - async def async_get_lock_detail(self, lock_id): + async def async_get_lock_detail(self, device_id): """Return lock detail.""" await self._async_update_locks_detail() - return self._lock_detail_by_id[lock_id] + return self._lock_detail_by_id[device_id] def get_lock_name(self, device_id): """Return lock name as August has it stored.""" @@ -375,34 +332,39 @@ class AugustData: await self._hass.async_add_executor_job(self._update_locks_detail) def _update_locks_detail(self): + self._lock_detail_by_id = self._update_device_detail( + "lock", self._locks, self._api.get_lock_detail + ) + + def _update_device_detail(self, device_type, devices, api_call): detail_by_id = {} - _LOGGER.debug("Start retrieving locks detail") - for lock in self._locks: + _LOGGER.debug("Start retrieving %s detail", device_type) + for device in devices: + device_id = device.device_id try: - detail_by_id[lock.device_id] = self._api.get_lock_detail( - self._access_token, lock.device_id - ) + detail_by_id[device_id] = api_call(self._access_token, device_id) except RequestException as ex: _LOGGER.error( - "Request error trying to retrieve door details for %s. %s", - lock.device_name, + "Request error trying to retrieve %s details for %s. %s", + device_type, + device.device_name, ex, ) - detail_by_id[lock.device_id] = None + detail_by_id[device_id] = None except Exception: - detail_by_id[lock.device_id] = None + detail_by_id[device_id] = None raise - _LOGGER.debug("Completed retrieving locks detail") - self._lock_detail_by_id = detail_by_id + _LOGGER.debug("Completed retrieving %s detail", device_type) + return detail_by_id def lock(self, device_id): """Lock the device.""" return _call_api_operation_that_requires_bridge( self.get_lock_name(device_id), "lock", - self._api.lock, + self._api.lock_return_activities, self._access_token, device_id, ) @@ -412,7 +374,7 @@ class AugustData: return _call_api_operation_that_requires_bridge( self.get_lock_name(device_id), "unlock", - self._api.unlock, + self._api.unlock_return_activities, self._access_token, device_id, ) diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 0097b6029a0..a805fa2657a 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -8,7 +8,6 @@ from august.util import update_lock_detail_from_activity from homeassistant.components.lock import LockDevice from homeassistant.const import ATTR_BATTERY_LEVEL -from homeassistant.util import dt from . import DATA_AUGUST @@ -43,27 +42,31 @@ class AugustLock(LockDevice): async def async_lock(self, **kwargs): """Lock the device.""" - update_start_time_utc = dt.utcnow() - lock_status = await self.hass.async_add_executor_job( - self._data.lock, self._lock.device_id - ) - self._update_lock_status(lock_status, update_start_time_utc) + await self._call_lock_operation(self._data.lock) async def async_unlock(self, **kwargs): """Unlock the device.""" - update_start_time_utc = dt.utcnow() - lock_status = await self.hass.async_add_executor_job( - self._data.unlock, self._lock.device_id - ) - self._update_lock_status(lock_status, update_start_time_utc) + await self._call_lock_operation(self._data.unlock) - def _update_lock_status(self, lock_status, update_start_time_utc): + async def _call_lock_operation(self, lock_operation): + activities = await self.hass.async_add_executor_job( + lock_operation, self._lock.device_id + ) + for lock_activity in activities: + update_lock_detail_from_activity(self._lock_detail, lock_activity) + + if self._update_lock_status_from_detail(): + self.schedule_update_ha_state() + + def _update_lock_status_from_detail(self): + lock_status = self._lock_detail.lock_status if self._lock_status != lock_status: self._lock_status = lock_status - self._data.update_lock_status( - self._lock.device_id, lock_status, update_start_time_utc + self._available = ( + lock_status is not None and lock_status != LockStatus.UNKNOWN ) - self.schedule_update_ha_state() + return True + return False async def async_update(self): """Get the latest state of the sensor and update activity.""" @@ -76,10 +79,7 @@ class AugustLock(LockDevice): self._changed_by = lock_activity.operated_by update_lock_detail_from_activity(self._lock_detail, lock_activity) - self._lock_status = self._lock_detail.lock_status - self._available = ( - self._lock_status is not None and self._lock_status != LockStatus.UNKNOWN - ) + self._update_lock_status_from_detail() @property def name(self): diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py index 30269bec11e..d1c3efb00e8 100644 --- a/tests/components/august/mocks.py +++ b/tests/components/august/mocks.py @@ -2,15 +2,16 @@ import datetime import json import os +import time from unittest.mock import MagicMock, PropertyMock from asynctest import mock -from august.activity import Activity +from august.activity import Activity, DoorOperationActivity, LockOperationActivity from august.api import Api from august.authenticator import AuthenticationState from august.doorbell import Doorbell, DoorbellDetail from august.exceptions import AugustApiHTTPError -from august.lock import Lock, LockDetail, LockStatus +from august.lock import Lock, LockDetail from homeassistant.components.august import ( CONF_LOGIN_METHOD, @@ -19,7 +20,6 @@ from homeassistant.components.august import ( DOMAIN, AugustData, ) -from homeassistant.components.august.binary_sensor import AugustDoorBinarySensor from homeassistant.setup import async_setup_component from homeassistant.util import dt @@ -39,44 +39,125 @@ def _mock_get_config(): @mock.patch("homeassistant.components.august.Api") @mock.patch("homeassistant.components.august.Authenticator.authenticate") -async def _mock_setup_august(hass, api_mocks_callback, authenticate_mock, api_mock): +async def _mock_setup_august(hass, api_instance, authenticate_mock, api_mock): """Set up august integration.""" authenticate_mock.side_effect = MagicMock( return_value=_mock_august_authentication("original_token", 1234) ) - api_mocks_callback(api_mock) + api_mock.return_value = api_instance assert await async_setup_component(hass, DOMAIN, _mock_get_config()) await hass.async_block_till_done() return True -async def _create_august_with_devices(hass, lock_details=[], doorbell_details=[]): - locks = [] - doorbells = [] - for lock in lock_details: - if isinstance(lock, LockDetail): - locks.append(_mock_august_lock(lock.device_id)) - for doorbell in doorbell_details: - if isinstance(lock, DoorbellDetail): - doorbells.append(_mock_august_doorbell(doorbell.device_id)) +async def _create_august_with_devices(hass, devices, api_call_side_effects=None): + if api_call_side_effects is None: + api_call_side_effects = {} + device_data = { + "doorbells": [], + "locks": [], + } + for device in devices: + if isinstance(device, LockDetail): + device_data["locks"].append( + {"base": _mock_august_lock(device.device_id), "detail": device} + ) + elif isinstance(device, DoorbellDetail): + device_data["doorbells"].append( + {"base": _mock_august_doorbell(device.device_id), "detail": device} + ) + else: + raise ValueError - def api_mocks_callback(api): - def get_lock_detail_side_effect(access_token, device_id): - for lock in lock_details: - if isinstance(lock, LockDetail) and lock.device_id == device_id: - return lock + def _get_device_detail(device_type, device_id): + for device in device_data[device_type]: + if device["detail"].device_id == device_id: + return device["detail"] + raise ValueError - api_instance = MagicMock() - api_instance.get_lock_detail.side_effect = get_lock_detail_side_effect - api_instance.get_operable_locks.return_value = locks - api_instance.get_doorbells.return_value = doorbells - api_instance.lock.return_value = LockStatus.LOCKED - api_instance.unlock.return_value = LockStatus.UNLOCKED - api.return_value = api_instance + def _get_base_devices(device_type): + base_devices = [] + for device in device_data[device_type]: + base_devices.append(device["base"]) + return base_devices - await _mock_setup_august(hass, api_mocks_callback) + def get_lock_detail_side_effect(access_token, device_id): + return _get_device_detail("locks", device_id) - return True + def get_operable_locks_side_effect(access_token): + return _get_base_devices("locks") + + def get_doorbells_side_effect(access_token): + return _get_base_devices("doorbells") + + def get_house_activities_side_effect(access_token, house_id, limit=10): + return [] + + def lock_return_activities_side_effect(access_token, device_id): + lock = _get_device_detail("locks", device_id) + return [ + _mock_lock_operation_activity(lock, "lock"), + _mock_door_operation_activity(lock, "doorclosed"), + ] + + def unlock_return_activities_side_effect(access_token, device_id): + lock = _get_device_detail("locks", device_id) + return [ + _mock_lock_operation_activity(lock, "unlock"), + _mock_door_operation_activity(lock, "dooropen"), + ] + + if "get_lock_detail" not in api_call_side_effects: + api_call_side_effects["get_lock_detail"] = get_lock_detail_side_effect + if "get_operable_locks" not in api_call_side_effects: + api_call_side_effects["get_operable_locks"] = get_operable_locks_side_effect + if "get_doorbells" not in api_call_side_effects: + api_call_side_effects["get_doorbells"] = get_doorbells_side_effect + if "get_house_activities" not in api_call_side_effects: + api_call_side_effects["get_house_activities"] = get_house_activities_side_effect + if "lock_return_activities" not in api_call_side_effects: + api_call_side_effects[ + "lock_return_activities" + ] = lock_return_activities_side_effect + if "unlock_return_activities" not in api_call_side_effects: + api_call_side_effects[ + "unlock_return_activities" + ] = unlock_return_activities_side_effect + + return await _mock_setup_august_with_api_side_effects(hass, api_call_side_effects) + + +async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects): + api_instance = MagicMock(name="Api") + + if api_call_side_effects["get_lock_detail"]: + api_instance.get_lock_detail.side_effect = api_call_side_effects[ + "get_lock_detail" + ] + + if api_call_side_effects["get_operable_locks"]: + api_instance.get_operable_locks.side_effect = api_call_side_effects[ + "get_operable_locks" + ] + + if api_call_side_effects["get_doorbells"]: + api_instance.get_doorbells.side_effect = api_call_side_effects["get_doorbells"] + + if api_call_side_effects["get_house_activities"]: + api_instance.get_house_activities.side_effect = api_call_side_effects[ + "get_house_activities" + ] + + if api_call_side_effects["lock_return_activities"]: + api_instance.lock_return_activities.side_effect = api_call_side_effects[ + "lock_return_activities" + ] + + if api_call_side_effects["unlock_return_activities"]: + api_instance.unlock_return_activities.side_effect = api_call_side_effects[ + "unlock_return_activities" + ] + return await _mock_setup_august(hass, api_instance) class MockAugustApiFailing(Api): @@ -114,19 +195,6 @@ class MockActivity(Activity): return self._action -class MockAugustComponentDoorBinarySensor(AugustDoorBinarySensor): - """A mock for august component AugustDoorBinarySensor class.""" - - def _update_door_state(self, door_state, activity_start_time_utc): - """Mock updating the lock status.""" - self._data.set_last_door_state_update_time_utc( - self._door.device_id, activity_start_time_utc - ) - self.last_update_door_state = {} - self.last_update_door_state["door_state"] = door_state - self.last_update_door_state["activity_start_time_utc"] = activity_start_time_utc - - class MockAugustComponentData(AugustData): """A wrapper to mock AugustData.""" @@ -210,7 +278,7 @@ def _mock_august_lock(lockid="mocklockid1", houseid="mockhouseid1"): def _mock_august_doorbell(deviceid="mockdeviceid1", houseid="mockhouseid1"): return Doorbell( - deviceid, _mock_august_doorbell_data(device=deviceid, houseid=houseid) + deviceid, _mock_august_doorbell_data(deviceid=deviceid, houseid=houseid) ) @@ -218,11 +286,12 @@ def _mock_august_doorbell_data(deviceid="mockdeviceid1", houseid="mockhouseid1") return { "_id": deviceid, "DeviceID": deviceid, - "DeviceName": deviceid + " Name", + "name": deviceid + " Name", "HouseID": houseid, "UserType": "owner", - "SerialNumber": "mockserial", + "serialNumber": "mockserial", "battery": 90, + "status": "standby", "currentFirmwareVersion": "mockfirmware", "Bridge": { "_id": "bridgeid1", @@ -273,6 +342,11 @@ async def _mock_lock_from_fixture(hass, path): return LockDetail(json_dict) +async def _mock_doorbell_from_fixture(hass, path): + json_dict = await _load_json_fixture(hass, path) + return DoorbellDetail(json_dict) + + async def _load_json_fixture(hass, path): fixture = await hass.async_add_executor_job( load_fixture, os.path.join("august", path) @@ -284,3 +358,25 @@ def _mock_doorsense_missing_august_lock_detail(lockid): doorsense_lock_detail_data = _mock_august_lock_data(lockid=lockid) del doorsense_lock_detail_data["LockStatus"]["doorState"] return LockDetail(doorsense_lock_detail_data) + + +def _mock_lock_operation_activity(lock, action): + return LockOperationActivity( + { + "dateTime": time.time() * 1000, + "deviceID": lock.device_id, + "deviceType": "lock", + "action": action, + } + ) + + +def _mock_door_operation_activity(lock, action): + return DoorOperationActivity( + { + "dateTime": time.time() * 1000, + "deviceID": lock.device_id, + "deviceType": "lock", + "action": action, + } + ) diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 5988e21ebac..47acfb59c72 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -1 +1,71 @@ """The binary_sensor tests for the august platform.""" + +import pytest + +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_LOCK, + SERVICE_UNLOCK, + STATE_OFF, + STATE_ON, +) + +from tests.components.august.mocks import ( + _create_august_with_devices, + _mock_doorbell_from_fixture, + _mock_lock_from_fixture, +) + + +@pytest.mark.skip( + reason="The lock and doorsense can get out of sync due to update intervals, " + + "this is an existing bug which will be fixed with dispatcher events to tell " + + "all linked devices to update." +) +async def test_doorsense(hass): + """Test creation of a lock with doorsense and bridge.""" + lock_one = await _mock_lock_from_fixture( + hass, "get_lock.online_with_doorsense.json" + ) + lock_details = [lock_one] + await _create_august_with_devices(hass, lock_details) + + binary_sensor_abc_name = hass.states.get("binary_sensor.abc_name_open") + assert binary_sensor_abc_name.state == STATE_ON + + data = {} + data[ATTR_ENTITY_ID] = "lock.abc_name" + assert await hass.services.async_call( + LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True + ) + + binary_sensor_abc_name = hass.states.get("binary_sensor.abc_name_open") + assert binary_sensor_abc_name.state == STATE_ON + + assert await hass.services.async_call( + LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True + ) + + binary_sensor_abc_name = hass.states.get("binary_sensor.abc_name_open") + assert binary_sensor_abc_name.state == STATE_OFF + + +async def test_create_doorbell(hass): + """Test creation of a doorbell.""" + doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") + doorbell_details = [doorbell_one] + await _create_august_with_devices(hass, doorbell_details) + + binary_sensor_k98gidt45gul_name_motion = hass.states.get( + "binary_sensor.k98gidt45gul_name_motion" + ) + assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF + binary_sensor_k98gidt45gul_name_online = hass.states.get( + "binary_sensor.k98gidt45gul_name_online" + ) + assert binary_sensor_k98gidt45gul_name_online.state == STATE_ON + binary_sensor_k98gidt45gul_name_ding = hass.states.get( + "binary_sensor.k98gidt45gul_name_ding" + ) + assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF diff --git a/tests/components/august/test_camera.py b/tests/components/august/test_camera.py new file mode 100644 index 00000000000..9ed97ecbc29 --- /dev/null +++ b/tests/components/august/test_camera.py @@ -0,0 +1,18 @@ +"""The camera tests for the august platform.""" + +from homeassistant.const import STATE_IDLE + +from tests.components.august.mocks import ( + _create_august_with_devices, + _mock_doorbell_from_fixture, +) + + +async def test_create_doorbell(hass): + """Test creation of a doorbell.""" + doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") + doorbell_details = [doorbell_one] + await _create_august_with_devices(hass, doorbell_details) + + camera_k98gidt45gul_name = hass.states.get("camera.k98gidt45gul_name") + assert camera_k98gidt45gul_name.state == STATE_IDLE diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 518cf22b5ba..b0b298690a5 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -3,9 +3,9 @@ from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, + SERVICE_LOCK, SERVICE_UNLOCK, STATE_LOCKED, - STATE_ON, STATE_UNLOCKED, ) @@ -15,13 +15,13 @@ from tests.components.august.mocks import ( ) -async def test_one_lock_unlock_happy_path(hass): +async def test_one_lock_operation(hass): """Test creation of a lock with doorsense and bridge.""" lock_one = await _mock_lock_from_fixture( hass, "get_lock.online_with_doorsense.json" ) lock_details = [lock_one] - await _create_august_with_devices(hass, lock_details=lock_details) + await _create_august_with_devices(hass, lock_details) lock_abc_name = hass.states.get("lock.abc_name") @@ -42,5 +42,9 @@ async def test_one_lock_unlock_happy_path(hass): assert lock_abc_name.attributes.get("battery_level") == 92 assert lock_abc_name.attributes.get("friendly_name") == "ABC Name" - binary_sensor_abc_name = hass.states.get("binary_sensor.abc_name_open") - assert binary_sensor_abc_name.state == STATE_ON + assert await hass.services.async_call( + LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True + ) + + lock_abc_name = hass.states.get("lock.abc_name") + assert lock_abc_name.state == STATE_LOCKED diff --git a/tests/fixtures/august/get_doorbell.json b/tests/fixtures/august/get_doorbell.json new file mode 100644 index 00000000000..abe6e37b1e3 --- /dev/null +++ b/tests/fixtures/august/get_doorbell.json @@ -0,0 +1,83 @@ +{ + "status_timestamp" : 1512811834532, + "appID" : "august-iphone", + "LockID" : "BBBB1F5F11114C24CCCC97571DD6AAAA", + "recentImage" : { + "original_filename" : "file", + "placeholder" : false, + "bytes" : 24476, + "height" : 640, + "format" : "jpg", + "width" : 480, + "version" : 1512892814, + "resource_type" : "image", + "etag" : "54966926be2e93f77d498a55f247661f", + "tags" : [], + "public_id" : "qqqqt4ctmxwsysylaaaa", + "url" : "http://image.com/vmk16naaaa7ibuey7sar.jpg", + "created_at" : "2017-12-10T08:01:35Z", + "signature" : "75z47ca21b5e8ffda21d2134e478a2307c4625da", + "secure_url" : "https://image.com/vmk16naaaa7ibuey7sar.jpg", + "type" : "upload" + }, + "settings" : { + "keepEncoderRunning" : true, + "videoResolution" : "640x480", + "minACNoScaling" : 40, + "irConfiguration" : 8448272, + "directLink" : true, + "overlayEnabled" : true, + "notify_when_offline" : true, + "micVolume" : 100, + "bitrateCeiling" : 512000, + "initialBitrate" : 384000, + "IVAEnabled" : false, + "turnOffCamera" : false, + "ringSoundEnabled" : true, + "JPGQuality" : 70, + "motion_notifications" : true, + "speakerVolume" : 92, + "buttonpush_notifications" : true, + "ABREnabled" : true, + "debug" : false, + "batteryLowThreshold" : 3.1, + "batteryRun" : false, + "IREnabled" : true, + "batteryUseThreshold" : 3.4 + }, + "doorbellServerURL" : "https://doorbells.august.com", + "name" : "Front Door", + "createdAt" : "2016-11-26T22:27:11.176Z", + "installDate" : "2016-11-26T22:27:11.176Z", + "serialNumber" : "tBXZR0Z35E", + "dvrSubscriptionSetupDone" : true, + "caps" : [ + "reconnect" + ], + "doorbellID" : "K98GiDT45GUL", + "HouseID" : "3dd2accaea08", + "telemetry" : { + "signal_level" : -56, + "date" : "2017-12-10 08:05:12", + "battery_soc" : 96, + "battery" : 4.061763, + "steady_ac_in" : 22.196405, + "BSSID" : "88:ee:00:dd:aa:11", + "SSID" : "foo_ssid", + "updated_at" : "2017-12-10T08:05:13.650Z", + "temperature" : 28.25, + "wifi_freq" : 5745, + "load_average" : "0.50 0.47 0.35 1/154 9345", + "link_quality" : 54, + "battery_soh" : 95, + "uptime" : "16168.75 13830.49", + "ip_addr" : "10.0.1.11", + "doorbell_low_battery" : false, + "ac_in" : 23.856874 + }, + "installUserID" : "c3b2a94e-373e-aaaa-bbbb-36e996827777", + "status" : "doorbell_call_status_online", + "firmwareVersion" : "2.3.0-RC153+201711151527", + "pubsubChannel" : "7c7a6672-59c8-3333-ffff-dcd98705cccc", + "updatedAt" : "2017-12-10T08:05:13.650Z" +} From 5ec7d072831bc01a3d045884f86fad13850c231d Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Mon, 24 Feb 2020 00:31:49 +0000 Subject: [PATCH 060/416] [ci skip] Translation update --- homeassistant/components/mqtt/.translations/ro.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/.translations/ro.json b/homeassistant/components/mqtt/.translations/ro.json index bcd150e3063..0bbf39315d9 100644 --- a/homeassistant/components/mqtt/.translations/ro.json +++ b/homeassistant/components/mqtt/.translations/ro.json @@ -22,7 +22,7 @@ "data": { "discovery": "Activa\u021bi descoperirea" }, - "description": "Dori\u021bi s\u0103 configura\u021bi Home Assistant pentru a v\u0103 conecta la brokerul MQTT furnizat de addon-ul {addon} ?", + "description": "Dori\u021bi s\u0103 configura\u021bi Home Assistant pentru a se conecta la brokerul MQTT furnizat de addon-ul {addon} ?", "title": "MQTT Broker, prin intermediul Hass.io add-on" } }, From 6e6625e1ab17634b505344b4ac1ee3a73d2e2d23 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 23 Feb 2020 20:47:19 -0700 Subject: [PATCH 061/416] Remove no-longer-needed SimpliSafe websocket watchdog (#32129) * Fix SimpliSafe reconnection issue upon websocket error * Rename listener cancelation properties * Remove watchdog --- .../components/simplisafe/__init__.py | 59 ++----------------- 1 file changed, 5 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 83ed7d22351..71cfe077d5f 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -3,7 +3,7 @@ import asyncio import logging from simplipy import API -from simplipy.errors import InvalidCredentialsError, SimplipyError, WebsocketError +from simplipy.errors import InvalidCredentialsError, SimplipyError from simplipy.websocket import ( EVENT_CAMERA_MOTION_DETECTED, EVENT_DOORBELL_DETECTED, @@ -34,7 +34,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import async_call_later, async_track_time_interval +from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service import ( async_register_admin_service, verify_domain_control, @@ -68,7 +68,6 @@ EVENT_SIMPLISAFE_EVENT = "SIMPLISAFE_EVENT" EVENT_SIMPLISAFE_NOTIFICATION = "SIMPLISAFE_NOTIFICATION" DEFAULT_SOCKET_MIN_RETRY = 15 -DEFAULT_WATCHDOG_SECONDS = 5 * 60 WEBSOCKET_EVENTS_REQUIRING_SERIAL = [EVENT_LOCK_LOCKED, EVENT_LOCK_UNLOCKED] WEBSOCKET_EVENTS_TO_TRIGGER_HASS_EVENT = [ @@ -332,46 +331,12 @@ class SimpliSafeWebsocket: """Initialize.""" self._hass = hass self._websocket = websocket - self._websocket_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY - self._websocket_reconnect_underway = False - self._websocket_watchdog_listener = None self.last_events = {} - async def _async_attempt_websocket_connect(self): - """Attempt to connect to the websocket (retrying later on fail).""" - self._websocket_reconnect_underway = True - - try: - await self._websocket.async_connect() - except WebsocketError as err: - _LOGGER.error("Error with the websocket connection: %s", err) - self._websocket_reconnect_delay = min( - 2 * self._websocket_reconnect_delay, 480 - ) - async_call_later( - self._hass, - self._websocket_reconnect_delay, - self.async_websocket_connect, - ) - else: - self._websocket_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY - self._websocket_reconnect_underway = False - - async def _async_websocket_reconnect(self, event_time): - """Forcibly disconnect from and reconnect to the websocket.""" - _LOGGER.debug("Websocket watchdog expired; forcing socket reconnection") - await self.async_websocket_disconnect() - await self._async_attempt_websocket_connect() - - def _on_connect(self): + @staticmethod + def _on_connect(): """Define a handler to fire when the websocket is connected.""" _LOGGER.info("Connected to websocket") - _LOGGER.debug("Websocket watchdog starting") - if self._websocket_watchdog_listener is not None: - self._websocket_watchdog_listener() - self._websocket_watchdog_listener = async_call_later( - self._hass, DEFAULT_WATCHDOG_SECONDS, self._async_websocket_reconnect - ) @staticmethod def _on_disconnect(): @@ -384,13 +349,6 @@ class SimpliSafeWebsocket: self.last_events[event.system_id] = event async_dispatcher_send(self._hass, TOPIC_UPDATE.format(event.system_id)) - _LOGGER.debug("Resetting websocket watchdog") - self._websocket_watchdog_listener() - self._websocket_watchdog_listener = async_call_later( - self._hass, DEFAULT_WATCHDOG_SECONDS, self._async_websocket_reconnect - ) - self._websocket_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY - if event.event_type not in WEBSOCKET_EVENTS_TO_TRIGGER_HASS_EVENT: return @@ -415,18 +373,11 @@ class SimpliSafeWebsocket: async def async_websocket_connect(self): """Register handlers and connect to the websocket.""" - if self._websocket_reconnect_underway: - return - self._websocket.on_connect(self._on_connect) self._websocket.on_disconnect(self._on_disconnect) self._websocket.on_event(self._on_event) - await self._async_attempt_websocket_connect() - - async def async_websocket_disconnect(self): - """Disconnect from the websocket.""" - await self._websocket.async_disconnect() + await self._websocket.async_connect() class SimpliSafe: From 270758417b23f021805857d25f62c4083677b70d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Feb 2020 00:03:33 -0800 Subject: [PATCH 062/416] =?UTF-8?q?Properly=20define=20depenency=20for=20S?= =?UTF-8?q?crape=20integration=20on=20Rest=20integ=E2=80=A6=20(#32136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/scrape/manifest.json | 1 + script/hassfest/dependencies.py | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json index e0800cdef27..90352bbd108 100644 --- a/homeassistant/components/scrape/manifest.json +++ b/homeassistant/components/scrape/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/scrape", "requirements": ["beautifulsoup4==4.8.2"], "dependencies": [], + "after_dependencies": ["rest"], "codeowners": ["@fabaff"] } diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 4d8fbb226f8..c909b6216a9 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -133,8 +133,6 @@ IGNORE_VIOLATIONS = { # These should be extracted to external package "pvoutput", "dwd_weather_warnings", - # Should be rewritten to use own data fetcher - "scrape", } From ca01e9a537b9259a0b7029668ed334c2eb63afbd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Feb 2020 00:59:34 -0800 Subject: [PATCH 063/416] Improve condition validation error msg (#32135) --- homeassistant/helpers/config_validation.py | 47 +++++++++++++++++----- tests/helpers/test_config_validation.py | 33 +++++++++++++++ 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 1ff2644fa58..8e4454751bf 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -704,6 +704,30 @@ def deprecated( return validator +def key_value_schemas( + key: str, value_schemas: Dict[str, vol.Schema] +) -> Callable[[Any], Dict[str, Any]]: + """Create a validator that validates based on a value for specific key. + + This gives better error messages. + """ + + def key_value_validator(value: Any) -> Dict[str, Any]: + if not isinstance(value, dict): + raise vol.Invalid("Expected a dictionary") + + key_value = value.get(key) + + if key_value not in value_schemas: + raise vol.Invalid( + f"Unexpected key {key_value}. Expected {', '.join(value_schemas)}" + ) + + return cast(Dict[str, Any], value_schemas[key_value](value)) + + return key_value_validator + + # Validator helpers @@ -899,16 +923,19 @@ DEVICE_CONDITION_BASE_SCHEMA = vol.Schema( DEVICE_CONDITION_SCHEMA = DEVICE_CONDITION_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) -CONDITION_SCHEMA: vol.Schema = vol.Any( - NUMERIC_STATE_CONDITION_SCHEMA, - STATE_CONDITION_SCHEMA, - SUN_CONDITION_SCHEMA, - TEMPLATE_CONDITION_SCHEMA, - TIME_CONDITION_SCHEMA, - ZONE_CONDITION_SCHEMA, - AND_CONDITION_SCHEMA, - OR_CONDITION_SCHEMA, - DEVICE_CONDITION_SCHEMA, +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, + "device": DEVICE_CONDITION_SCHEMA, + }, ) _SCRIPT_DELAY_SCHEMA = vol.Schema( diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 9b6aa6b812d..e94fa202ce6 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -987,3 +987,36 @@ def test_uuid4_hex(caplog): _hex = uuid.uuid4().hex assert schema(_hex) == _hex assert schema(_hex.upper()) == _hex + + +def test_key_value_schemas(): + """Test key value schemas.""" + schema = vol.Schema( + cv.key_value_schemas( + "mode", + { + "number": vol.Schema({"mode": "number", "data": int}), + "string": vol.Schema({"mode": "string", "data": str}), + }, + ) + ) + + with pytest.raises(vol.Invalid) as excinfo: + schema(True) + assert str(excinfo.value) == "Expected a dictionary" + + for mode in None, "invalid": + with pytest.raises(vol.Invalid) as excinfo: + schema({"mode": mode}) + assert str(excinfo.value) == f"Unexpected key {mode}. Expected number, string" + + with pytest.raises(vol.Invalid) as excinfo: + schema({"mode": "number", "data": "string-value"}) + assert str(excinfo.value) == "expected int for dictionary value @ data['data']" + + with pytest.raises(vol.Invalid) as excinfo: + schema({"mode": "string", "data": 1}) + assert str(excinfo.value) == "expected str for dictionary value @ data['data']" + + for mode, data in (("number", 1), ("string", "hello")): + schema({"mode": mode, "data": data}) From a1a835cf542ed7ece0adf1419d283072d8d82012 Mon Sep 17 00:00:00 2001 From: Pierre <3458055+BaQs@users.noreply.github.com> Date: Mon, 24 Feb 2020 10:39:55 +0100 Subject: [PATCH 064/416] Add platform Ezviz (#30378) * hookid : isort fix * New platform: Ezviz * updated CODEOWNERS for ezviz * proper test requirements * resolved conflict * regenerated requirements * removed stale comments, only one call to add_entities, removed unnecessary attributes * setup is sync, not async. Removed stale comments * Compatible with pyezviz 0.1.4 * pyezviz 0.1.4 is now requiredf * Added PTZ + switch management * added services.yaml * proper requirement * PTZ working in async mode * Now updates the entity * Compatible with pyezviz 0.1.5.1 * Fixed switch ir service registering * now requires pyezviz 0.1.5.2 * now requires pyezviz 0.1.5.2 * Revert "regenerated requirements" This reverts commit 848b317cf9f9df296c3a6ab9e128ec330c4fd365. * Rollbacked to a simpler version * snake_case names everywhere, logging sanatizing, voluptuous proper check * pyezviz 0.1.5, reworked the PR so that it's intelligible * no need for services.yaml for now * proper voluptuous validation * Removed stale code, use proper conf variable, describe attributes * regenerated requirements * stale * removed status from attributes, since we use it for available we don't need it here then. * Fixed log message --- .coveragerc | 1 + CODEOWNERS | 1 + homeassistant/components/ezviz/__init__.py | 1 + homeassistant/components/ezviz/camera.py | 237 +++++++++++++++++++ homeassistant/components/ezviz/manifest.json | 8 + requirements_all.txt | 3 + 6 files changed, 251 insertions(+) create mode 100644 homeassistant/components/ezviz/__init__.py create mode 100644 homeassistant/components/ezviz/camera.py create mode 100644 homeassistant/components/ezviz/manifest.json diff --git a/.coveragerc b/.coveragerc index e51f4de886d..6dcd20f6ad6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -217,6 +217,7 @@ omit = homeassistant/components/eufy/* homeassistant/components/everlights/light.py homeassistant/components/evohome/* + homeassistant/components/ezviz/* homeassistant/components/familyhub/camera.py homeassistant/components/fastdotcom/* homeassistant/components/ffmpeg/camera.py diff --git a/CODEOWNERS b/CODEOWNERS index cb254824039..411c615e857 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -103,6 +103,7 @@ homeassistant/components/eq3btsmart/* @rytilahti homeassistant/components/esphome/* @OttoWinter homeassistant/components/essent/* @TheLastProject homeassistant/components/evohome/* @zxdavb +homeassistant/components/ezviz/* @baqs homeassistant/components/fastdotcom/* @rohankapoorcom homeassistant/components/file/* @fabaff homeassistant/components/filter/* @dgomes diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py new file mode 100644 index 00000000000..96891e8b291 --- /dev/null +++ b/homeassistant/components/ezviz/__init__.py @@ -0,0 +1 @@ +"""Support for Ezviz devices via Ezviz Cloud API.""" diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py new file mode 100644 index 00000000000..b8ede42a508 --- /dev/null +++ b/homeassistant/components/ezviz/camera.py @@ -0,0 +1,237 @@ +"""This component provides basic support for Ezviz IP cameras.""" +import asyncio +import logging + +from haffmpeg.tools import IMAGE_JPEG, ImageFrame +from pyezviz.camera import EzvizCamera +from pyezviz.client import EzvizClient, PyEzvizError +import voluptuous as vol + +from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_CAMERAS = "cameras" + +DEFAULT_CAMERA_USERNAME = "admin" +DEFAULT_RTSP_PORT = "554" + +DATA_FFMPEG = "ffmpeg" + +EZVIZ_DATA = "ezviz" +ENTITIES = "entities" + +CAMERA_SCHEMA = vol.Schema( + {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_CAMERAS, default={}): {cv.string: CAMERA_SCHEMA}, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Ezviz IP Cameras.""" + + conf_cameras = config[CONF_CAMERAS] + + account = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + + try: + ezviz_client = EzvizClient(account, password) + ezviz_client.login() + cameras = ezviz_client.load_cameras() + + except PyEzvizError as exp: + _LOGGER.error(exp) + return + + # now, let's build the HASS devices + camera_entities = [] + + # Add the cameras as devices in HASS + for camera in cameras: + + camera_username = DEFAULT_CAMERA_USERNAME + camera_password = "" + camera_rtsp_stream = "" + camera_serial = camera["serial"] + + # There seem to be a bug related to localRtspPort in Ezviz API... + local_rtsp_port = DEFAULT_RTSP_PORT + if camera["local_rtsp_port"] and camera["local_rtsp_port"] != 0: + local_rtsp_port = camera["local_rtsp_port"] + + if camera_serial in conf_cameras: + camera_username = conf_cameras[camera_serial][CONF_USERNAME] + camera_password = conf_cameras[camera_serial][CONF_PASSWORD] + camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{camera['local_ip']}:{local_rtsp_port}" + _LOGGER.debug( + "Camera %s source stream: %s", camera["serial"], camera_rtsp_stream + ) + + else: + _LOGGER.info( + "Found camera with serial %s without configuration. Add it to configuration.yaml to see the camera stream", + camera_serial, + ) + + camera["username"] = camera_username + camera["password"] = camera_password + camera["rtsp_stream"] = camera_rtsp_stream + + camera["ezviz_camera"] = EzvizCamera(ezviz_client, camera_serial) + + camera_entities.append(HassEzvizCamera(**camera)) + + add_entities(camera_entities) + + +class HassEzvizCamera(Camera): + """An implementation of a Foscam IP camera.""" + + def __init__(self, **data): + """Initialize an Ezviz camera.""" + super().__init__() + + self._username = data["username"] + self._password = data["password"] + self._rtsp_stream = data["rtsp_stream"] + + self._ezviz_camera = data["ezviz_camera"] + self._serial = data["serial"] + self._name = data["name"] + self._status = data["status"] + self._privacy = data["privacy"] + self._audio = data["audio"] + self._ir_led = data["ir_led"] + self._state_led = data["state_led"] + self._follow_move = data["follow_move"] + self._alarm_notify = data["alarm_notify"] + self._alarm_sound_mod = data["alarm_sound_mod"] + self._encrypted = data["encrypted"] + self._local_ip = data["local_ip"] + self._detection_sensibility = data["detection_sensibility"] + self._device_sub_category = data["device_sub_category"] + self._local_rtsp_port = data["local_rtsp_port"] + + self._ffmpeg = None + + def update(self): + """Update the camera states.""" + + data = self._ezviz_camera.status() + + self._name = data["name"] + self._status = data["status"] + self._privacy = data["privacy"] + self._audio = data["audio"] + self._ir_led = data["ir_led"] + self._state_led = data["state_led"] + self._follow_move = data["follow_move"] + self._alarm_notify = data["alarm_notify"] + self._alarm_sound_mod = data["alarm_sound_mod"] + self._encrypted = data["encrypted"] + self._local_ip = data["local_ip"] + self._detection_sensibility = data["detection_sensibility"] + self._device_sub_category = data["device_sub_category"] + self._local_rtsp_port = data["local_rtsp_port"] + + async def async_added_to_hass(self): + """Subscribe to ffmpeg and add camera to list.""" + self._ffmpeg = self.hass.data[DATA_FFMPEG] + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return True + + @property + def device_state_attributes(self): + """Return the Ezviz-specific camera state attributes.""" + return { + # if privacy == true, the device closed the lid or did a 180° tilt + "privacy": self._privacy, + # is the camera listening ? + "audio": self._audio, + # infrared led on ? + "ir_led": self._ir_led, + # state led on ? + "state_led": self._state_led, + # if true, the camera will move automatically to follow movements + "follow_move": self._follow_move, + # if true, if some movement is detected, the app is notified + "alarm_notify": self._alarm_notify, + # if true, if some movement is detected, the camera makes some sound + "alarm_sound_mod": self._alarm_sound_mod, + # are the camera's stored videos/images encrypted? + "encrypted": self._encrypted, + # camera's local ip on local network + "local_ip": self._local_ip, + # from 1 to 9, the higher is the sensibility, the more it will detect small movements + "detection_sensibility": self._detection_sensibility, + } + + @property + def available(self): + """Return True if entity is available.""" + return self._status + + @property + def brand(self): + """Return the camera brand.""" + return "Ezviz" + + @property + def supported_features(self): + """Return supported features.""" + if self._rtsp_stream: + return SUPPORT_STREAM + return 0 + + @property + def model(self): + """Return the camera model.""" + return self._device_sub_category + + @property + def is_on(self): + """Return true if on.""" + return self._status + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + async def async_camera_image(self): + """Return a frame from the camera stream.""" + ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop) + + image = await asyncio.shield( + ffmpeg.get_image(self._rtsp_stream, output_format=IMAGE_JPEG,) + ) + return image + + async def stream_source(self): + """Return the stream source.""" + if self._local_rtsp_port: + rtsp_stream_source = "rtsp://{}:{}@{}:{}".format( + self._username, self._password, self._local_ip, self._local_rtsp_port + ) + _LOGGER.debug( + "Camera %s source stream: %s", self._serial, rtsp_stream_source + ) + self._rtsp_stream = rtsp_stream_source + return rtsp_stream_source + return None diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json new file mode 100644 index 00000000000..167f063c0f7 --- /dev/null +++ b/homeassistant/components/ezviz/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ezviz", + "name": "Ezviz", + "documentation": "https://www.home-assistant.io/integrations/ezviz", + "dependencies": [], + "codeowners": ["@baqs"], + "requirements": ["pyezviz==0.1.5"] +} diff --git a/requirements_all.txt b/requirements_all.txt index 4e301df4998..0e79b3a35a8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1237,6 +1237,9 @@ pyephember==0.3.1 # homeassistant.components.everlights pyeverlights==0.1.0 +# homeassistant.components.ezviz +pyezviz==0.1.5 + # homeassistant.components.fortigate pyfgt==0.5.1 From df9363610cde6e4f072a1f14d3ab6a51b6d875fa Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 24 Feb 2020 09:55:33 +0000 Subject: [PATCH 065/416] Refactor homekit_controller to be fully asynchronous (#32111) * Port homekit_controller to aiohomekit * Remove succeed() test helper * Remove fail() test helper --- .../components/homekit_controller/__init__.py | 9 +- .../homekit_controller/air_quality.py | 2 +- .../homekit_controller/alarm_control_panel.py | 2 +- .../homekit_controller/binary_sensor.py | 2 +- .../components/homekit_controller/climate.py | 2 +- .../homekit_controller/config_flow.py | 66 ++- .../homekit_controller/connection.py | 22 +- .../components/homekit_controller/cover.py | 2 +- .../components/homekit_controller/fan.py | 2 +- .../components/homekit_controller/light.py | 2 +- .../components/homekit_controller/lock.py | 2 +- .../homekit_controller/manifest.json | 2 +- .../components/homekit_controller/sensor.py | 2 +- .../components/homekit_controller/switch.py | 2 +- requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- tests/components/homekit_controller/common.py | 104 +---- .../specific_devices/test_ecobee3.py | 2 +- .../specific_devices/test_koogeek_ls1.py | 2 +- .../homekit_controller/test_air_quality.py | 28 +- .../test_alarm_control_panel.py | 23 +- .../homekit_controller/test_binary_sensor.py | 38 +- .../homekit_controller/test_climate.py | 63 +-- .../homekit_controller/test_config_flow.py | 383 +++++++++++------- .../homekit_controller/test_cover.py | 92 ++--- .../components/homekit_controller/test_fan.py | 89 ++-- .../homekit_controller/test_light.py | 45 +- .../homekit_controller/test_lock.py | 21 +- .../homekit_controller/test_sensor.py | 82 ++-- .../homekit_controller/test_storage.py | 17 +- .../homekit_controller/test_switch.py | 23 +- 31 files changed, 560 insertions(+), 583 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index b2275282293..630a75e496b 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -1,8 +1,8 @@ """Support for Homekit device discovery.""" import logging -import homekit -from homekit.model.characteristics import CharacteristicsTypes +import aiohomekit +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady @@ -94,7 +94,8 @@ class HomeKitEntity(Entity): def _setup_characteristic(self, char): """Configure an entity based on a HomeKit characteristics metadata.""" # Build up a list of (aid, iid) tuples to poll on update() - self.pollable_characteristics.append((self._aid, char["iid"])) + if "pr" in char["perms"]: + self.pollable_characteristics.append((self._aid, char["iid"])) # Build a map of ctype -> iid short_name = CharacteristicsTypes.get_short(char["type"]) @@ -223,7 +224,7 @@ async def async_setup(hass, config): map_storage = hass.data[ENTITY_MAP] = EntityMapStorage(hass) await map_storage.async_initialize() - hass.data[CONTROLLER] = homekit.Controller() + hass.data[CONTROLLER] = aiohomekit.Controller() hass.data[KNOWN_DEVICES] = {} return True diff --git a/homeassistant/components/homekit_controller/air_quality.py b/homeassistant/components/homekit_controller/air_quality.py index 41194cb340c..b2145887cef 100644 --- a/homeassistant/components/homekit_controller/air_quality.py +++ b/homeassistant/components/homekit_controller/air_quality.py @@ -1,5 +1,5 @@ """Support for HomeKit Controller air quality sensors.""" -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.air_quality import AirQualityEntity from homeassistant.core import callback diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index f4f89507fca..d0ddd8ae816 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -1,7 +1,7 @@ """Support for Homekit Alarm Control Panel.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.alarm_control_panel import AlarmControlPanel from homeassistant.components.alarm_control_panel.const import ( diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py index 0a6a3fca1cf..467a9567676 100644 --- a/homeassistant/components/homekit_controller/binary_sensor.py +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -1,7 +1,7 @@ """Support for Homekit motion sensors.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.binary_sensor import ( DEVICE_CLASS_SMOKE, diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index bbef10d3204..b294bb9bb71 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -1,7 +1,7 @@ """Support for Homekit climate devices.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.climate import ( DEFAULT_MAX_HUMIDITY, diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 559e0b4a997..c4101140aaf 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -4,8 +4,9 @@ import logging import os import re -import homekit -from homekit.controller.ip_implementation import IpPairing +import aiohomekit +from aiohomekit import Controller +from aiohomekit.controller.ip import IpPairing import voluptuous as vol from homeassistant import config_entries @@ -72,7 +73,7 @@ def ensure_pin_format(pin): """ match = PIN_FORMAT.search(pin) if not match: - raise homekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}") + raise aiohomekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}") return "{}-{}-{}".format(*match.groups()) @@ -88,7 +89,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): self.model = None self.hkid = None self.devices = {} - self.controller = homekit.Controller() + self.controller = Controller() self.finish_pairing = None async def async_step_user(self, user_input=None): @@ -97,22 +98,22 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): if user_input is not None: key = user_input["device"] - self.hkid = self.devices[key]["id"] - self.model = self.devices[key]["md"] + self.hkid = self.devices[key].device_id + self.model = self.devices[key].info["md"] await self.async_set_unique_id( normalize_hkid(self.hkid), raise_on_progress=False ) return await self.async_step_pair() - all_hosts = await self.hass.async_add_executor_job(self.controller.discover, 5) + all_hosts = await self.controller.discover_ip() self.devices = {} for host in all_hosts: - status_flags = int(host["sf"]) + status_flags = int(host.info["sf"]) paired = not status_flags & 0x01 if paired: continue - self.devices[host["name"]] = host + self.devices[host.info["name"]] = host if not self.devices: return self.async_abort(reason="no_devices") @@ -130,10 +131,11 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): unique_id = user_input["unique_id"] await self.async_set_unique_id(unique_id) - records = await self.hass.async_add_executor_job(self.controller.discover, 5) - for record in records: - if normalize_hkid(record["id"]) != unique_id: + devices = await self.controller.discover_ip(5) + for device in devices: + if normalize_hkid(device.device_id) != unique_id: continue + record = device.info return await self.async_step_zeroconf( { "host": record["address"], @@ -295,55 +297,49 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): code = pair_info["pairing_code"] try: code = ensure_pin_format(code) - - await self.hass.async_add_executor_job(self.finish_pairing, code) - - pairing = self.controller.pairings.get(self.hkid) - if pairing: - return await self._entry_from_accessory(pairing) - - errors["pairing_code"] = "unable_to_pair" - except homekit.exceptions.MalformedPinError: + pairing = await self.finish_pairing(code) + return await self._entry_from_accessory(pairing) + except aiohomekit.exceptions.MalformedPinError: # Library claimed pin was invalid before even making an API call errors["pairing_code"] = "authentication_error" - except homekit.AuthenticationError: + except aiohomekit.AuthenticationError: # PairSetup M4 - SRP proof failed # PairSetup M6 - Ed25519 signature verification failed # PairVerify M4 - Decryption failed # PairVerify M4 - Device not recognised # PairVerify M4 - Ed25519 signature verification failed errors["pairing_code"] = "authentication_error" - except homekit.UnknownError: + except aiohomekit.UnknownError: # An error occurred on the device whilst performing this # operation. errors["pairing_code"] = "unknown_error" - except homekit.MaxPeersError: + except aiohomekit.MaxPeersError: # The device can't pair with any more accessories. errors["pairing_code"] = "max_peers_error" - except homekit.AccessoryNotFoundError: + except aiohomekit.AccessoryNotFoundError: # Can no longer find the device on the network return self.async_abort(reason="accessory_not_found_error") except Exception: # pylint: disable=broad-except _LOGGER.exception("Pairing attempt failed with an unhandled exception") errors["pairing_code"] = "pairing_failed" - start_pairing = self.controller.start_pairing + discovery = await self.controller.find_ip_by_device_id(self.hkid) + try: - self.finish_pairing = await self.hass.async_add_executor_job( - start_pairing, self.hkid, self.hkid - ) - except homekit.BusyError: + self.finish_pairing = await discovery.start_pairing(self.hkid) + + except aiohomekit.BusyError: # Already performing a pair setup operation with a different # controller errors["pairing_code"] = "busy_error" - except homekit.MaxTriesError: + except aiohomekit.MaxTriesError: # The accessory has received more than 100 unsuccessful auth # attempts. errors["pairing_code"] = "max_tries_error" - except homekit.UnavailableError: + except aiohomekit.UnavailableError: # The accessory is already paired - cannot try to pair again. return self.async_abort(reason="already_paired") - except homekit.AccessoryNotFoundError: + except aiohomekit.AccessoryNotFoundError: # Can no longer find the device on the network return self.async_abort(reason="accessory_not_found_error") except Exception: # pylint: disable=broad-except @@ -376,9 +372,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): # the same time. accessories = pairing_data.pop("accessories", None) if not accessories: - accessories = await self.hass.async_add_executor_job( - pairing.list_accessories_and_characteristics - ) + accessories = await pairing.list_accessories_and_characteristics() bridge_info = get_bridge_information(accessories) name = get_accessory_name(bridge_info) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 11cb607842a..154f9955779 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -3,14 +3,14 @@ import asyncio import datetime import logging -from homekit.controller.ip_implementation import IpPairing -from homekit.exceptions import ( +from aiohomekit.controller.ip import IpPairing +from aiohomekit.exceptions import ( AccessoryDisconnectedError, AccessoryNotFoundError, EncryptionError, ) -from homekit.model.characteristics import CharacteristicsTypes -from homekit.model.services import ServicesTypes +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes from homeassistant.core import callback from homeassistant.helpers.event import async_track_time_interval @@ -186,10 +186,7 @@ class HKDevice: async def async_refresh_entity_map(self, config_num): """Handle setup of a HomeKit accessory.""" try: - async with self.pairing_lock: - self.accessories = await self.hass.async_add_executor_job( - self.pairing.list_accessories_and_characteristics - ) + self.accessories = await self.pairing.list_accessories_and_characteristics() except AccessoryDisconnectedError: # If we fail to refresh this data then we will naturally retry # later when Bonjour spots c# is still not up to date. @@ -305,10 +302,7 @@ class HKDevice: async def get_characteristics(self, *args, **kwargs): """Read latest state from homekit accessory.""" async with self.pairing_lock: - chars = await self.hass.async_add_executor_job( - self.pairing.get_characteristics, *args, **kwargs - ) - return chars + return await self.pairing.get_characteristics(*args, **kwargs) async def put_characteristics(self, characteristics): """Control a HomeKit device state from Home Assistant.""" @@ -317,9 +311,7 @@ class HKDevice: chars.append((row["aid"], row["iid"], row["value"])) async with self.pairing_lock: - results = await self.hass.async_add_executor_job( - self.pairing.put_characteristics, chars - ) + results = await self.pairing.put_characteristics(chars) # Feed characteristics back into HA and update the current state # results will only contain failures, so anythin in characteristics diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 191405a9355..2799d1d76a6 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -1,7 +1,7 @@ """Support for Homekit covers.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.cover import ( ATTR_POSITION, diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 694ae8a2c09..24bb5b96503 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -1,7 +1,7 @@ """Support for Homekit fans.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.fan import ( DIRECTION_FORWARD, diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index e7d1e4d3273..5978455cf6f 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -1,7 +1,7 @@ """Support for Homekit lights.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.light import ( ATTR_BRIGHTNESS, diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index 1799d30d8c8..fc046c704b9 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -1,7 +1,7 @@ """Support for HomeKit Controller locks.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.lock import LockDevice from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_LOCKED, STATE_UNLOCKED diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index c7eb02a479c..618b6274253 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["homekit[IP]==0.15.0"], + "requirements": ["aiohomekit[IP]==0.2.10"], "dependencies": [], "zeroconf": ["_hap._tcp.local."], "codeowners": ["@Jc2k"] diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index e59dda007d4..dd062fb982f 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -1,5 +1,5 @@ """Support for Homekit sensors.""" -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.const import DEVICE_CLASS_BATTERY, TEMP_CELSIUS from homeassistant.core import callback diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index 60b16c8ddab..9f12d59204d 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -1,7 +1,7 @@ """Support for Homekit switches.""" import logging -from homekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.switch import SwitchDevice from homeassistant.core import callback diff --git a/requirements_all.txt b/requirements_all.txt index 0e79b3a35a8..57b8672cfaf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -161,6 +161,9 @@ aioftp==0.12.0 # homeassistant.components.harmony aioharmony==0.1.13 +# homeassistant.components.homekit_controller +aiohomekit[IP]==0.2.10 + # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 @@ -688,9 +691,6 @@ home-assistant-frontend==20200220.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 -# homeassistant.components.homekit_controller -homekit[IP]==0.15.0 - # homeassistant.components.homematicip_cloud homematicip==0.10.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2e9ad49678e..cfb9afdff5c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -61,6 +61,9 @@ aiobotocore==0.11.1 # homeassistant.components.esphome aioesphomeapi==2.6.1 +# homeassistant.components.homekit_controller +aiohomekit[IP]==0.2.10 + # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 @@ -259,9 +262,6 @@ home-assistant-frontend==20200220.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 -# homeassistant.components.homekit_controller -homekit[IP]==0.15.0 - # homeassistant.components.homematicip_cloud homematicip==0.10.17 diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 025c8c565f2..fa55b605cf3 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -4,14 +4,10 @@ import json import os from unittest import mock -from homekit.exceptions import AccessoryNotFoundError -from homekit.model import Accessory, get_id -from homekit.model.characteristics import ( - AbstractCharacteristic, - CharacteristicPermissions, - CharacteristicsTypes, -) -from homekit.model.services import AbstractService, ServicesTypes +from aiohomekit.exceptions import AccessoryNotFoundError +from aiohomekit.model import Accessory +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes from homeassistant import config_entries from homeassistant.components.homekit_controller import config_flow @@ -40,14 +36,14 @@ class FakePairing: self.pairing_data = {} self.available = True - def list_accessories_and_characteristics(self): + async def list_accessories_and_characteristics(self): """Fake implementation of list_accessories_and_characteristics.""" accessories = [a.to_accessory_and_service_list() for a in self.accessories] # replicate what happens upstream right now self.pairing_data["accessories"] = accessories return accessories - def get_characteristics(self, characteristics): + async def get_characteristics(self, characteristics): """Fake implementation of get_characteristics.""" if not self.available: raise AccessoryNotFoundError("Accessory not found") @@ -64,7 +60,7 @@ class FakePairing: results[(aid, cid)] = {"value": char.get_value()} return results - def put_characteristics(self, characteristics): + async def put_characteristics(self, characteristics): """Fake implementation of put_characteristics.""" for aid, cid, new_val in characteristics: for accessory in self.accessories: @@ -124,45 +120,6 @@ class Helper: return state -class FakeCharacteristic(AbstractCharacteristic): - """ - A model of a generic HomeKit characteristic. - - Base is abstract and can't be instanced directly so this subclass is - needed even though it doesn't add any methods. - """ - - def to_accessory_and_service_list(self): - """Serialize the characteristic.""" - # Upstream doesn't correctly serialize valid_values - # This fix will be upstreamed and this function removed when it - # is fixed. - record = super().to_accessory_and_service_list() - if self.valid_values: - record["valid-values"] = self.valid_values - return record - - -class FakeService(AbstractService): - """A model of a generic HomeKit service.""" - - def __init__(self, service_name): - """Create a fake service by its short form HAP spec name.""" - char_type = ServicesTypes.get_uuid(service_name) - super().__init__(char_type, get_id()) - - def add_characteristic(self, name): - """Add a characteristic to this service by name.""" - full_name = "public.hap.characteristic." + name - char = FakeCharacteristic(get_id(), full_name, None) - char.perms = [ - CharacteristicPermissions.paired_read, - CharacteristicPermissions.paired_write, - ] - self.characteristics.append(char) - return char - - async def time_changed(hass, seconds): """Trigger time changed.""" next_update = dt_util.utcnow() + timedelta(seconds) @@ -176,40 +133,7 @@ async def setup_accessories_from_file(hass, path): load_fixture, os.path.join("homekit_controller", path) ) accessories_json = json.loads(accessories_fixture) - - accessories = [] - - for accessory_data in accessories_json: - accessory = Accessory("Name", "Mfr", "Model", "0001", "0.1") - accessory.services = [] - accessory.aid = accessory_data["aid"] - for service_data in accessory_data["services"]: - service = FakeService("public.hap.service.accessory-information") - service.type = service_data["type"] - service.iid = service_data["iid"] - - for char_data in service_data["characteristics"]: - char = FakeCharacteristic(1, "23", None) - char.type = char_data["type"] - char.iid = char_data["iid"] - char.perms = char_data["perms"] - char.format = char_data["format"] - if "description" in char_data: - char.description = char_data["description"] - if "value" in char_data: - char.value = char_data["value"] - if "minValue" in char_data: - char.minValue = char_data["minValue"] - if "maxValue" in char_data: - char.maxValue = char_data["maxValue"] - if "valid-values" in char_data: - char.valid_values = char_data["valid-values"] - service.characteristics.append(char) - - accessory.services.append(service) - - accessories.append(accessory) - + accessories = Accessory.setup_accessories_from_list(accessories_json) return accessories @@ -217,7 +141,7 @@ async def setup_platform(hass): """Load the platform but with a fake Controller API.""" config = {"discovery": {}} - with mock.patch("homekit.Controller") as controller: + with mock.patch("aiohomekit.Controller") as controller: fake_controller = controller.return_value = FakeController() await async_setup_component(hass, DOMAIN, config) @@ -293,15 +217,18 @@ async def device_config_changed(hass, accessories): await hass.async_block_till_done() -async def setup_test_component(hass, services, capitalize=False, suffix=None): +async def setup_test_component(hass, setup_accessory, capitalize=False, suffix=None): """Load a fake homekit accessory based on a homekit accessory model. If capitalize is True, property names will be in upper case. If suffix is set, entityId will include the suffix """ + accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1") + setup_accessory(accessory) + domain = None - for service in services: + for service in accessory.services: service_name = ServicesTypes.get_short(service.type) if service_name in HOMEKIT_ACCESSORY_DISPATCH: domain = HOMEKIT_ACCESSORY_DISPATCH[service_name] @@ -309,9 +236,6 @@ async def setup_test_component(hass, services, capitalize=False, suffix=None): assert domain, "Cannot map test homekit services to Home Assistant domain" - accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1") - accessory.services.extend(services) - config_entry, pairing = await setup_test_accessories(hass, [accessory]) entity = "testdevice" if suffix is None else "testdevice_{}".format(suffix) return Helper(hass, ".".join((domain, entity)), pairing, accessory, config_entry) diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index bb7695840f0..8b869f881d5 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -6,7 +6,7 @@ https://github.com/home-assistant/home-assistant/issues/15336 from unittest import mock -from homekit import AccessoryDisconnectedError +from aiohomekit import AccessoryDisconnectedError from homeassistant.components.climate.const import ( SUPPORT_TARGET_HUMIDITY, diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index 52339bb6635..33647f85a0b 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -3,7 +3,7 @@ from datetime import timedelta from unittest import mock -from homekit.exceptions import AccessoryDisconnectedError, EncryptionError +from aiohomekit.exceptions import AccessoryDisconnectedError, EncryptionError import pytest from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_COLOR diff --git a/tests/components/homekit_controller/test_air_quality.py b/tests/components/homekit_controller/test_air_quality.py index 41f39d7d7a3..52c79f2b28a 100644 --- a/tests/components/homekit_controller/test_air_quality.py +++ b/tests/components/homekit_controller/test_air_quality.py @@ -1,39 +1,39 @@ """Basic checks for HomeKit air quality sensor.""" -from tests.components.homekit_controller.common import FakeService, setup_test_component +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + +from tests.components.homekit_controller.common import setup_test_component -def create_air_quality_sensor_service(): +def create_air_quality_sensor_service(accessory): """Define temperature characteristics.""" - service = FakeService("public.hap.service.sensor.air-quality") + service = accessory.add_service(ServicesTypes.AIR_QUALITY_SENSOR) - cur_state = service.add_characteristic("air-quality") + cur_state = service.add_char(CharacteristicsTypes.AIR_QUALITY) cur_state.value = 5 - cur_state = service.add_characteristic("density.ozone") + cur_state = service.add_char(CharacteristicsTypes.DENSITY_OZONE) cur_state.value = 1111 - cur_state = service.add_characteristic("density.no2") + cur_state = service.add_char(CharacteristicsTypes.DENSITY_NO2) cur_state.value = 2222 - cur_state = service.add_characteristic("density.so2") + cur_state = service.add_char(CharacteristicsTypes.DENSITY_SO2) cur_state.value = 3333 - cur_state = service.add_characteristic("density.pm25") + cur_state = service.add_char(CharacteristicsTypes.DENSITY_PM25) cur_state.value = 4444 - cur_state = service.add_characteristic("density.pm10") + cur_state = service.add_char(CharacteristicsTypes.DENSITY_PM10) cur_state.value = 5555 - cur_state = service.add_characteristic("density.voc") + cur_state = service.add_char(CharacteristicsTypes.DENSITY_VOC) cur_state.value = 6666 - return service - async def test_air_quality_sensor_read_state(hass, utcnow): """Test reading the state of a HomeKit temperature sensor accessory.""" - sensor = create_air_quality_sensor_service() - helper = await setup_test_component(hass, [sensor]) + helper = await setup_test_component(hass, create_air_quality_sensor_service) state = await helper.poll_and_get_state() assert state.state == "4444" diff --git a/tests/components/homekit_controller/test_alarm_control_panel.py b/tests/components/homekit_controller/test_alarm_control_panel.py index 3b0662dc16d..5694be5f955 100644 --- a/tests/components/homekit_controller/test_alarm_control_panel.py +++ b/tests/components/homekit_controller/test_alarm_control_panel.py @@ -1,34 +1,34 @@ """Basic checks for HomeKitalarm_control_panel.""" -from tests.components.homekit_controller.common import FakeService, setup_test_component +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + +from tests.components.homekit_controller.common import setup_test_component CURRENT_STATE = ("security-system", "security-system-state.current") TARGET_STATE = ("security-system", "security-system-state.target") -def create_security_system_service(): +def create_security_system_service(accessory): """Define a security-system characteristics as per page 219 of HAP spec.""" - service = FakeService("public.hap.service.security-system") + service = accessory.add_service(ServicesTypes.SECURITY_SYSTEM) - cur_state = service.add_characteristic("security-system-state.current") + cur_state = service.add_char(CharacteristicsTypes.SECURITY_SYSTEM_STATE_CURRENT) cur_state.value = 0 - targ_state = service.add_characteristic("security-system-state.target") + targ_state = service.add_char(CharacteristicsTypes.SECURITY_SYSTEM_STATE_TARGET) targ_state.value = 0 # According to the spec, a battery-level characteristic is normally # part of a separate service. However as the code was written (which # predates this test) the battery level would have to be part of the lock # service as it is here. - targ_state = service.add_characteristic("battery-level") + targ_state = service.add_char(CharacteristicsTypes.BATTERY_LEVEL) targ_state.value = 50 - return service - async def test_switch_change_alarm_state(hass, utcnow): """Test that we can turn a HomeKit alarm on and off again.""" - alarm_control_panel = create_security_system_service() - helper = await setup_test_component(hass, [alarm_control_panel]) + helper = await setup_test_component(hass, create_security_system_service) await hass.services.async_call( "alarm_control_panel", @@ -65,8 +65,7 @@ async def test_switch_change_alarm_state(hass, utcnow): async def test_switch_read_alarm_state(hass, utcnow): """Test that we can read the state of a HomeKit alarm accessory.""" - alarm_control_panel = create_security_system_service() - helper = await setup_test_component(hass, [alarm_control_panel]) + helper = await setup_test_component(hass, create_security_system_service) helper.characteristics[CURRENT_STATE].value = 0 state = await helper.poll_and_get_state() diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py index f472ac38d1d..2809ab860be 100644 --- a/tests/components/homekit_controller/test_binary_sensor.py +++ b/tests/components/homekit_controller/test_binary_sensor.py @@ -1,25 +1,25 @@ """Basic checks for HomeKit motion sensors and contact sensors.""" -from tests.components.homekit_controller.common import FakeService, setup_test_component +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + +from tests.components.homekit_controller.common import setup_test_component MOTION_DETECTED = ("motion", "motion-detected") CONTACT_STATE = ("contact", "contact-state") SMOKE_DETECTED = ("smoke", "smoke-detected") -def create_motion_sensor_service(): +def create_motion_sensor_service(accessory): """Define motion characteristics as per page 225 of HAP spec.""" - service = FakeService("public.hap.service.sensor.motion") + service = accessory.add_service(ServicesTypes.MOTION_SENSOR) - cur_state = service.add_characteristic("motion-detected") + cur_state = service.add_char(CharacteristicsTypes.MOTION_DETECTED) cur_state.value = 0 - return service - async def test_motion_sensor_read_state(hass, utcnow): """Test that we can read the state of a HomeKit motion sensor accessory.""" - sensor = create_motion_sensor_service() - helper = await setup_test_component(hass, [sensor]) + helper = await setup_test_component(hass, create_motion_sensor_service) helper.characteristics[MOTION_DETECTED].value = False state = await helper.poll_and_get_state() @@ -30,20 +30,17 @@ async def test_motion_sensor_read_state(hass, utcnow): assert state.state == "on" -def create_contact_sensor_service(): +def create_contact_sensor_service(accessory): """Define contact characteristics.""" - service = FakeService("public.hap.service.sensor.contact") + service = accessory.add_service(ServicesTypes.CONTACT_SENSOR) - cur_state = service.add_characteristic("contact-state") + cur_state = service.add_char(CharacteristicsTypes.CONTACT_STATE) cur_state.value = 0 - return service - async def test_contact_sensor_read_state(hass, utcnow): """Test that we can read the state of a HomeKit contact accessory.""" - sensor = create_contact_sensor_service() - helper = await setup_test_component(hass, [sensor]) + helper = await setup_test_component(hass, create_contact_sensor_service) helper.characteristics[CONTACT_STATE].value = 0 state = await helper.poll_and_get_state() @@ -54,20 +51,17 @@ async def test_contact_sensor_read_state(hass, utcnow): assert state.state == "on" -def create_smoke_sensor_service(): +def create_smoke_sensor_service(accessory): """Define smoke sensor characteristics.""" - service = FakeService("public.hap.service.sensor.smoke") + service = accessory.add_service(ServicesTypes.SMOKE_SENSOR) - cur_state = service.add_characteristic("smoke-detected") + cur_state = service.add_char(CharacteristicsTypes.SMOKE_DETECTED) cur_state.value = 0 - return service - async def test_smoke_sensor_read_state(hass, utcnow): """Test that we can read the state of a HomeKit contact accessory.""" - sensor = create_smoke_sensor_service() - helper = await setup_test_component(hass, [sensor]) + helper = await setup_test_component(hass, create_smoke_sensor_service) helper.characteristics[SMOKE_DETECTED].value = 0 state = await helper.poll_and_get_state() diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py index e076b2975e2..9bcadb6604e 100644 --- a/tests/components/homekit_controller/test_climate.py +++ b/tests/components/homekit_controller/test_climate.py @@ -1,4 +1,7 @@ """Basic checks for HomeKitclimate.""" +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + from homeassistant.components.climate.const import ( DOMAIN, HVAC_MODE_COOL, @@ -10,7 +13,7 @@ from homeassistant.components.climate.const import ( SERVICE_SET_TEMPERATURE, ) -from tests.components.homekit_controller.common import FakeService, setup_test_component +from tests.components.homekit_controller.common import setup_test_component HEATING_COOLING_TARGET = ("thermostat", "heating-cooling.target") HEATING_COOLING_CURRENT = ("thermostat", "heating-cooling.current") @@ -20,63 +23,65 @@ HUMIDITY_TARGET = ("thermostat", "relative-humidity.target") HUMIDITY_CURRENT = ("thermostat", "relative-humidity.current") -def create_thermostat_service(): +def create_thermostat_service(accessory): """Define thermostat characteristics.""" - service = FakeService("public.hap.service.thermostat") + service = accessory.add_service(ServicesTypes.THERMOSTAT) - char = service.add_characteristic("heating-cooling.target") + char = service.add_char(CharacteristicsTypes.HEATING_COOLING_TARGET) char.value = 0 - char = service.add_characteristic("heating-cooling.current") + char = service.add_char(CharacteristicsTypes.HEATING_COOLING_CURRENT) char.value = 0 - char = service.add_characteristic("temperature.target") + char = service.add_char(CharacteristicsTypes.TEMPERATURE_TARGET) + char.minValue = 7 + char.maxValue = 35 char.value = 0 - char = service.add_characteristic("temperature.current") + char = service.add_char(CharacteristicsTypes.TEMPERATURE_CURRENT) char.value = 0 - char = service.add_characteristic("relative-humidity.target") + char = service.add_char(CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET) char.value = 0 - char = service.add_characteristic("relative-humidity.current") + char = service.add_char(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) char.value = 0 - return service - -async def test_climate_respect_supported_op_modes_1(hass, utcnow): - """Test that climate respects minValue/maxValue hints.""" - service = FakeService("public.hap.service.thermostat") - char = service.add_characteristic("heating-cooling.target") +def create_thermostat_service_min_max(accessory): + """Define thermostat characteristics.""" + service = accessory.add_service(ServicesTypes.THERMOSTAT) + char = service.add_char(CharacteristicsTypes.HEATING_COOLING_TARGET) char.value = 0 char.minValue = 0 char.maxValue = 1 - helper = await setup_test_component(hass, [service]) +async def test_climate_respect_supported_op_modes_1(hass, utcnow): + """Test that climate respects minValue/maxValue hints.""" + helper = await setup_test_component(hass, create_thermostat_service_min_max) state = await helper.poll_and_get_state() assert state.attributes["hvac_modes"] == ["off", "heat"] -async def test_climate_respect_supported_op_modes_2(hass, utcnow): - """Test that climate respects validValue hints.""" - service = FakeService("public.hap.service.thermostat") - char = service.add_characteristic("heating-cooling.target") +def create_thermostat_service_valid_vals(accessory): + """Define thermostat characteristics.""" + service = accessory.add_service(ServicesTypes.THERMOSTAT) + char = service.add_char(CharacteristicsTypes.HEATING_COOLING_TARGET) char.value = 0 char.valid_values = [0, 1, 2] - helper = await setup_test_component(hass, [service]) +async def test_climate_respect_supported_op_modes_2(hass, utcnow): + """Test that climate respects validValue hints.""" + helper = await setup_test_component(hass, create_thermostat_service_valid_vals) state = await helper.poll_and_get_state() assert state.attributes["hvac_modes"] == ["off", "heat", "cool"] async def test_climate_change_thermostat_state(hass, utcnow): """Test that we can turn a HomeKit thermostat on and off again.""" - from homekit.model.services import ThermostatService - - helper = await setup_test_component(hass, [ThermostatService()]) + helper = await setup_test_component(hass, create_thermostat_service) await hass.services.async_call( DOMAIN, @@ -114,9 +119,7 @@ async def test_climate_change_thermostat_state(hass, utcnow): async def test_climate_change_thermostat_temperature(hass, utcnow): """Test that we can turn a HomeKit thermostat on and off again.""" - from homekit.model.services import ThermostatService - - helper = await setup_test_component(hass, [ThermostatService()]) + helper = await setup_test_component(hass, create_thermostat_service) await hass.services.async_call( DOMAIN, @@ -137,7 +140,7 @@ async def test_climate_change_thermostat_temperature(hass, utcnow): async def test_climate_change_thermostat_humidity(hass, utcnow): """Test that we can turn a HomeKit thermostat on and off again.""" - helper = await setup_test_component(hass, [create_thermostat_service()]) + helper = await setup_test_component(hass, create_thermostat_service) await hass.services.async_call( DOMAIN, @@ -158,7 +161,7 @@ async def test_climate_change_thermostat_humidity(hass, utcnow): async def test_climate_read_thermostat_state(hass, utcnow): """Test that we can read the state of a HomeKit thermostat accessory.""" - helper = await setup_test_component(hass, [create_thermostat_service()]) + helper = await setup_test_component(hass, create_thermostat_service) # Simulate that heating is on helper.characteristics[TEMPERATURE_CURRENT].value = 19 @@ -200,7 +203,7 @@ async def test_climate_read_thermostat_state(hass, utcnow): async def test_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_thermostat_service()]) + helper = await setup_test_component(hass, create_thermostat_service) # Simulate that current temperature is above target temp # Heating might be on, but hvac_action currently 'off' diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 2a7f36ba470..144215719dd 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -2,40 +2,39 @@ import json from unittest import mock -import homekit +import aiohomekit +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes +import asynctest import pytest from homeassistant.components.homekit_controller import config_flow from homeassistant.components.homekit_controller.const import KNOWN_DEVICES from tests.common import MockConfigEntry -from tests.components.homekit_controller.common import ( - Accessory, - FakeService, - setup_platform, -) +from tests.components.homekit_controller.common import Accessory, setup_platform PAIRING_START_FORM_ERRORS = [ - (homekit.BusyError, "busy_error"), - (homekit.MaxTriesError, "max_tries_error"), + (aiohomekit.BusyError, "busy_error"), + (aiohomekit.MaxTriesError, "max_tries_error"), (KeyError, "pairing_failed"), ] PAIRING_START_ABORT_ERRORS = [ - (homekit.AccessoryNotFoundError, "accessory_not_found_error"), - (homekit.UnavailableError, "already_paired"), + (aiohomekit.AccessoryNotFoundError, "accessory_not_found_error"), + (aiohomekit.UnavailableError, "already_paired"), ] PAIRING_FINISH_FORM_ERRORS = [ - (homekit.exceptions.MalformedPinError, "authentication_error"), - (homekit.MaxPeersError, "max_peers_error"), - (homekit.AuthenticationError, "authentication_error"), - (homekit.UnknownError, "unknown_error"), + (aiohomekit.exceptions.MalformedPinError, "authentication_error"), + (aiohomekit.MaxPeersError, "max_peers_error"), + (aiohomekit.AuthenticationError, "authentication_error"), + (aiohomekit.UnknownError, "unknown_error"), (KeyError, "pairing_failed"), ] PAIRING_FINISH_ABORT_ERRORS = [ - (homekit.AccessoryNotFoundError, "accessory_not_found_error") + (aiohomekit.AccessoryNotFoundError, "accessory_not_found_error") ] INVALID_PAIRING_CODES = [ @@ -60,13 +59,22 @@ VALID_PAIRING_CODES = [ ] -def _setup_flow_handler(hass): +def _setup_flow_handler(hass, pairing=None): flow = config_flow.HomekitControllerFlowHandler() flow.hass = hass flow.context = {} + finish_pairing = asynctest.CoroutineMock(return_value=pairing) + + discovery = mock.Mock() + discovery.device_id = "00:00:00:00:00:00" + discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing) + flow.controller = mock.Mock() flow.controller.pairings = {} + flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( + return_value=discovery + ) return flow @@ -81,7 +89,7 @@ async def _setup_flow_zeroconf(hass, discovery_info): @pytest.mark.parametrize("pairing_code", INVALID_PAIRING_CODES) def test_invalid_pairing_codes(pairing_code): """Test ensure_pin_format raises for an invalid pin code.""" - with pytest.raises(homekit.exceptions.MalformedPinError): + with pytest.raises(aiohomekit.exceptions.MalformedPinError): config_flow.ensure_pin_format(pairing_code) @@ -106,6 +114,15 @@ async def test_discovery_works(hass): flow = _setup_flow_handler(hass) + finish_pairing = asynctest.CoroutineMock() + + discovery = mock.Mock() + discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing) + + flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( + return_value=discovery + ) + # Device is discovered result = await flow.async_step_zeroconf(discovery_info) assert result["type"] == "form" @@ -120,21 +137,27 @@ async def test_discovery_works(hass): result = await flow.async_step_pair({}) assert result["type"] == "form" assert result["step_id"] == "pair" - assert flow.controller.start_pairing.call_count == 1 + assert discovery.start_pairing.call_count == 1 pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"}) - pairing.list_accessories_and_characteristics.return_value = [ - { - "aid": 1, - "services": [ - { - "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}], - "type": "3e", - } - ], - } - ] + pairing.list_accessories_and_characteristics = asynctest.CoroutineMock( + return_value=[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + {"type": "23", "value": "Koogeek-LS1-20833F"} + ], + "type": "3e", + } + ], + } + ] + ) + + finish_pairing.return_value = pairing # Pairing doesn't error error and pairing results flow.controller.pairings = {"00:00:00:00:00:00": pairing} @@ -155,6 +178,15 @@ async def test_discovery_works_upper_case(hass): flow = _setup_flow_handler(hass) + finish_pairing = asynctest.CoroutineMock() + + discovery = mock.Mock() + discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing) + + flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( + return_value=discovery + ) + # Device is discovered result = await flow.async_step_zeroconf(discovery_info) assert result["type"] == "form" @@ -169,21 +201,27 @@ async def test_discovery_works_upper_case(hass): result = await flow.async_step_pair({}) assert result["type"] == "form" assert result["step_id"] == "pair" - assert flow.controller.start_pairing.call_count == 1 + assert discovery.start_pairing.call_count == 1 pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"}) - pairing.list_accessories_and_characteristics.return_value = [ - { - "aid": 1, - "services": [ - { - "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}], - "type": "3e", - } - ], - } - ] + pairing.list_accessories_and_characteristics = asynctest.CoroutineMock( + return_value=[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + {"type": "23", "value": "Koogeek-LS1-20833F"} + ], + "type": "3e", + } + ], + } + ] + ) + + finish_pairing.return_value = pairing flow.controller.pairings = {"00:00:00:00:00:00": pairing} result = await flow.async_step_pair({"pairing_code": "111-22-333"}) @@ -203,6 +241,15 @@ async def test_discovery_works_missing_csharp(hass): flow = _setup_flow_handler(hass) + finish_pairing = asynctest.CoroutineMock() + + discovery = mock.Mock() + discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing) + + flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( + return_value=discovery + ) + # Device is discovered result = await flow.async_step_zeroconf(discovery_info) assert result["type"] == "form" @@ -217,21 +264,27 @@ async def test_discovery_works_missing_csharp(hass): result = await flow.async_step_pair({}) assert result["type"] == "form" assert result["step_id"] == "pair" - assert flow.controller.start_pairing.call_count == 1 + assert discovery.start_pairing.call_count == 1 pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"}) - pairing.list_accessories_and_characteristics.return_value = [ - { - "aid": 1, - "services": [ - { - "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}], - "type": "3e", - } - ], - } - ] + pairing.list_accessories_and_characteristics = asynctest.CoroutineMock( + return_value=[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + {"type": "23", "value": "Koogeek-LS1-20833F"} + ], + "type": "3e", + } + ], + } + ] + ) + + finish_pairing.return_value = pairing flow.controller.pairings = {"00:00:00:00:00:00": pairing} @@ -390,39 +443,6 @@ async def test_discovery_already_configured_config_change(hass): assert conn.async_refresh_entity_map.call_args == mock.call(2) -async def test_pair_unable_to_pair(hass): - """Pairing completed without exception, but didn't create a pairing.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1}, - } - - flow = _setup_flow_handler(hass) - - # Device is discovered - result = await flow.async_step_zeroconf(discovery_info) - assert result["type"] == "form" - assert result["step_id"] == "pair" - assert flow.context == { - "hkid": "00:00:00:00:00:00", - "title_placeholders": {"name": "TestDevice"}, - "unique_id": "00:00:00:00:00:00", - } - - # User initiates pairing - device enters pairing mode and displays code - result = await flow.async_step_pair({}) - assert result["type"] == "form" - assert result["step_id"] == "pair" - assert flow.controller.start_pairing.call_count == 1 - - # Pairing doesn't error but no pairing object is generated - result = await flow.async_step_pair({"pairing_code": "111-22-333"}) - assert result["type"] == "form" - assert result["errors"]["pairing_code"] == "unable_to_pair" - - @pytest.mark.parametrize("exception,expected", PAIRING_START_ABORT_ERRORS) async def test_pair_abort_errors_on_start(hass, exception, expected): """Test various pairing errors.""" @@ -435,6 +455,13 @@ async def test_pair_abort_errors_on_start(hass, exception, expected): flow = _setup_flow_handler(hass) + discovery = mock.Mock() + discovery.start_pairing = asynctest.CoroutineMock(side_effect=exception("error")) + + flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( + return_value=discovery + ) + # Device is discovered result = await flow.async_step_zeroconf(discovery_info) assert result["type"] == "form" @@ -446,10 +473,7 @@ async def test_pair_abort_errors_on_start(hass, exception, expected): } # User initiates pairing - device refuses to enter pairing mode - with mock.patch.object(flow.controller, "start_pairing") as start_pairing: - start_pairing.side_effect = exception("error") - result = await flow.async_step_pair({}) - + result = await flow.async_step_pair({}) assert result["type"] == "abort" assert result["reason"] == expected assert flow.context == { @@ -471,6 +495,13 @@ async def test_pair_form_errors_on_start(hass, exception, expected): flow = _setup_flow_handler(hass) + discovery = mock.Mock() + discovery.start_pairing = asynctest.CoroutineMock(side_effect=exception("error")) + + flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( + return_value=discovery + ) + # Device is discovered result = await flow.async_step_zeroconf(discovery_info) assert result["type"] == "form" @@ -482,10 +513,7 @@ async def test_pair_form_errors_on_start(hass, exception, expected): } # User initiates pairing - device refuses to enter pairing mode - with mock.patch.object(flow.controller, "start_pairing") as start_pairing: - start_pairing.side_effect = exception("error") - result = await flow.async_step_pair({}) - + result = await flow.async_step_pair({}) assert result["type"] == "form" assert result["errors"]["pairing_code"] == expected assert flow.context == { @@ -507,6 +535,15 @@ async def test_pair_abort_errors_on_finish(hass, exception, expected): flow = _setup_flow_handler(hass) + finish_pairing = asynctest.CoroutineMock(side_effect=exception("error")) + + discovery = mock.Mock() + discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing) + + flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( + return_value=discovery + ) + # Device is discovered result = await flow.async_step_zeroconf(discovery_info) assert result["type"] == "form" @@ -521,10 +558,9 @@ async def test_pair_abort_errors_on_finish(hass, exception, expected): result = await flow.async_step_pair({}) assert result["type"] == "form" assert result["step_id"] == "pair" - assert flow.controller.start_pairing.call_count == 1 + assert discovery.start_pairing.call_count == 1 # User submits code - pairing fails but can be retried - flow.finish_pairing.side_effect = exception("error") result = await flow.async_step_pair({"pairing_code": "111-22-333"}) assert result["type"] == "abort" assert result["reason"] == expected @@ -547,6 +583,15 @@ async def test_pair_form_errors_on_finish(hass, exception, expected): flow = _setup_flow_handler(hass) + finish_pairing = asynctest.CoroutineMock(side_effect=exception("error")) + + discovery = mock.Mock() + discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing) + + flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( + return_value=discovery + ) + # Device is discovered result = await flow.async_step_zeroconf(discovery_info) assert result["type"] == "form" @@ -561,10 +606,9 @@ async def test_pair_form_errors_on_finish(hass, exception, expected): result = await flow.async_step_pair({}) assert result["type"] == "form" assert result["step_id"] == "pair" - assert flow.controller.start_pairing.call_count == 1 + assert discovery.start_pairing.call_count == 1 # User submits code - pairing fails but can be retried - flow.finish_pairing.side_effect = exception("error") result = await flow.async_step_pair({"pairing_code": "111-22-333"}) assert result["type"] == "form" assert result["errors"]["pairing_code"] == expected @@ -588,17 +632,21 @@ async def test_import_works(hass): pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"}) - pairing.list_accessories_and_characteristics.return_value = [ - { - "aid": 1, - "services": [ - { - "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}], - "type": "3e", - } - ], - } - ] + pairing.list_accessories_and_characteristics = asynctest.CoroutineMock( + return_value=[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + {"type": "23", "value": "Koogeek-LS1-20833F"} + ], + "type": "3e", + } + ], + } + ] + ) flow = _setup_flow_handler(hass) @@ -653,22 +701,35 @@ async def test_user_works(hass): } pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"}) - pairing.list_accessories_and_characteristics.return_value = [ - { - "aid": 1, - "services": [ - { - "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}], - "type": "3e", - } - ], - } - ] + pairing.list_accessories_and_characteristics = asynctest.CoroutineMock( + return_value=[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + {"type": "23", "value": "Koogeek-LS1-20833F"} + ], + "type": "3e", + } + ], + } + ] + ) flow = _setup_flow_handler(hass) + finish_pairing = asynctest.CoroutineMock(return_value=pairing) + + discovery = mock.Mock() + discovery.info = discovery_info + discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing) + flow.controller.pairings = {"00:00:00:00:00:00": pairing} - flow.controller.discover.return_value = [discovery_info] + flow.controller.discover_ip = asynctest.CoroutineMock(return_value=[discovery]) + flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( + return_value=discovery + ) result = await flow.async_step_user() assert result["type"] == "form" @@ -688,7 +749,7 @@ async def test_user_no_devices(hass): """Test user initiated pairing where no devices discovered.""" flow = _setup_flow_handler(hass) - flow.controller.discover.return_value = [] + flow.controller.discover_ip = asynctest.CoroutineMock(return_value=[]) result = await flow.async_step_user() assert result["type"] == "abort" @@ -709,7 +770,10 @@ async def test_user_no_unpaired_devices(hass): "sf": 0, } - flow.controller.discover.return_value = [discovery_info] + discovery = mock.Mock() + discovery.info = discovery_info + + flow.controller.discover_ip = asynctest.CoroutineMock(return_value=[discovery]) result = await flow.async_step_user() assert result["type"] == "abort" @@ -718,12 +782,10 @@ async def test_user_no_unpaired_devices(hass): async def test_parse_new_homekit_json(hass): """Test migrating recent .homekit/pairings.json files.""" - service = FakeService("public.hap.service.lightbulb") - on_char = service.add_characteristic("on") - on_char.value = 1 - accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1") - accessory.services.append(service) + service = accessory.add_service(ServicesTypes.LIGHTBULB) + on_char = service.add_char(CharacteristicsTypes.ON) + on_char.value = 0 fake_controller = await setup_platform(hass) pairing = fake_controller.add([accessory]) @@ -766,12 +828,10 @@ async def test_parse_new_homekit_json(hass): async def test_parse_old_homekit_json(hass): """Test migrating original .homekit/hk-00:00:00:00:00:00 files.""" - service = FakeService("public.hap.service.lightbulb") - on_char = service.add_characteristic("on") - on_char.value = 1 - accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1") - accessory.services.append(service) + service = accessory.add_service(ServicesTypes.LIGHTBULB) + on_char = service.add_char(CharacteristicsTypes.ON) + on_char.value = 0 fake_controller = await setup_platform(hass) pairing = fake_controller.add([accessory]) @@ -818,12 +878,10 @@ async def test_parse_old_homekit_json(hass): async def test_parse_overlapping_homekit_json(hass): """Test migrating .homekit/pairings.json files when hk- exists too.""" - service = FakeService("public.hap.service.lightbulb") - on_char = service.add_characteristic("on") - on_char.value = 1 - accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1") - accessory.services.append(service) + service = accessory.add_service(ServicesTypes.LIGHTBULB) + on_char = service.add_char(CharacteristicsTypes.ON) + on_char.value = 0 fake_controller = await setup_platform(hass) pairing = fake_controller.add([accessory]) @@ -857,7 +915,6 @@ async def test_parse_overlapping_homekit_json(hass): pairing_cls_imp = ( "homeassistant.components.homekit_controller.config_flow.IpPairing" ) - with mock.patch(pairing_cls_imp) as pairing_cls: pairing_cls.return_value = pairing with mock.patch("builtins.open", side_effect=side_effects): @@ -894,22 +951,39 @@ async def test_unignore_works(hass): } pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"}) - pairing.list_accessories_and_characteristics.return_value = [ - { - "aid": 1, - "services": [ - { - "characteristics": [{"type": "23", "value": "Koogeek-LS1-20833F"}], - "type": "3e", - } - ], - } - ] + pairing.list_accessories_and_characteristics = asynctest.CoroutineMock( + return_value=[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + {"type": "23", "value": "Koogeek-LS1-20833F"} + ], + "type": "3e", + } + ], + } + ] + ) + + finish_pairing = asynctest.CoroutineMock() + + discovery = mock.Mock() + discovery.device_id = "00:00:00:00:00:00" + discovery.info = discovery_info + discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing) + + finish_pairing.return_value = pairing flow = _setup_flow_handler(hass) flow.controller.pairings = {"00:00:00:00:00:00": pairing} - flow.controller.discover.return_value = [discovery_info] + flow.controller.discover_ip = asynctest.CoroutineMock(return_value=[discovery]) + + flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( + return_value=discovery + ) result = await flow.async_step_unignore({"unique_id": "00:00:00:00:00:00"}) assert result["type"] == "form" @@ -924,7 +998,6 @@ async def test_unignore_works(hass): result = await flow.async_step_pair({}) assert result["type"] == "form" assert result["step_id"] == "pair" - assert flow.controller.start_pairing.call_count == 1 # Pairing finalized result = await flow.async_step_pair({"pairing_code": "111-22-333"}) @@ -949,8 +1022,12 @@ async def test_unignore_ignores_missing_devices(hass): "sf": 1, } + discovery = mock.Mock() + discovery.device_id = "00:00:00:00:00:00" + discovery.info = discovery_info + flow = _setup_flow_handler(hass) - flow.controller.discover.return_value = [discovery_info] + flow.controller.discover_ip = asynctest.CoroutineMock(return_value=[discovery]) result = await flow.async_step_unignore({"unique_id": "00:00:00:00:00:01"}) assert result["type"] == "abort" diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py index 53245176a04..45514b29122 100644 --- a/tests/components/homekit_controller/test_cover.py +++ b/tests/components/homekit_controller/test_cover.py @@ -1,5 +1,8 @@ """Basic checks for HomeKitalarm_control_panel.""" -from tests.components.homekit_controller.common import FakeService, setup_test_component +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + +from tests.components.homekit_controller.common import setup_test_component POSITION_STATE = ("window-covering", "position.state") POSITION_CURRENT = ("window-covering", "position.current") @@ -19,61 +22,56 @@ DOOR_TARGET = ("garage-door-opener", "door-state.target") DOOR_OBSTRUCTION = ("garage-door-opener", "obstruction-detected") -def create_window_covering_service(): +def create_window_covering_service(accessory): """Define a window-covering characteristics as per page 219 of HAP spec.""" - service = FakeService("public.hap.service.window-covering") + service = accessory.add_service(ServicesTypes.WINDOW_COVERING) - cur_state = service.add_characteristic("position.current") + cur_state = service.add_char(CharacteristicsTypes.POSITION_CURRENT) cur_state.value = 0 - targ_state = service.add_characteristic("position.target") + targ_state = service.add_char(CharacteristicsTypes.POSITION_TARGET) targ_state.value = 0 - position_state = service.add_characteristic("position.state") + position_state = service.add_char(CharacteristicsTypes.POSITION_STATE) position_state.value = 0 - position_hold = service.add_characteristic("position.hold") + position_hold = service.add_char(CharacteristicsTypes.POSITION_HOLD) position_hold.value = 0 - obstruction = service.add_characteristic("obstruction-detected") + obstruction = service.add_char(CharacteristicsTypes.OBSTRUCTION_DETECTED) obstruction.value = False - name = service.add_characteristic("name") + name = service.add_char(CharacteristicsTypes.NAME) name.value = "testdevice" return service -def create_window_covering_service_with_h_tilt(): +def create_window_covering_service_with_h_tilt(accessory): """Define a window-covering characteristics as per page 219 of HAP spec.""" - service = create_window_covering_service() + service = create_window_covering_service(accessory) - tilt_current = service.add_characteristic("horizontal-tilt.current") + tilt_current = service.add_char(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT) tilt_current.value = 0 - tilt_target = service.add_characteristic("horizontal-tilt.target") + tilt_target = service.add_char(CharacteristicsTypes.HORIZONTAL_TILT_TARGET) tilt_target.value = 0 - return service - -def create_window_covering_service_with_v_tilt(): +def create_window_covering_service_with_v_tilt(accessory): """Define a window-covering characteristics as per page 219 of HAP spec.""" - service = create_window_covering_service() + service = create_window_covering_service(accessory) - tilt_current = service.add_characteristic("vertical-tilt.current") + tilt_current = service.add_char(CharacteristicsTypes.VERTICAL_TILT_CURRENT) tilt_current.value = 0 - tilt_target = service.add_characteristic("vertical-tilt.target") + tilt_target = service.add_char(CharacteristicsTypes.VERTICAL_TILT_TARGET) tilt_target.value = 0 - return service - async def test_change_window_cover_state(hass, utcnow): """Test that we can turn a HomeKit alarm on and off again.""" - window_cover = create_window_covering_service() - helper = await setup_test_component(hass, [window_cover]) + helper = await setup_test_component(hass, create_window_covering_service) await hass.services.async_call( "cover", "open_cover", {"entity_id": helper.entity_id}, blocking=True @@ -88,8 +86,7 @@ async def test_change_window_cover_state(hass, utcnow): async def test_read_window_cover_state(hass, utcnow): """Test that we can read the state of a HomeKit alarm accessory.""" - window_cover = create_window_covering_service() - helper = await setup_test_component(hass, [window_cover]) + helper = await setup_test_component(hass, create_window_covering_service) helper.characteristics[POSITION_STATE].value = 0 state = await helper.poll_and_get_state() @@ -110,8 +107,9 @@ async def test_read_window_cover_state(hass, utcnow): async def test_read_window_cover_tilt_horizontal(hass, utcnow): """Test that horizontal tilt is handled correctly.""" - window_cover = create_window_covering_service_with_h_tilt() - helper = await setup_test_component(hass, [window_cover]) + helper = await setup_test_component( + hass, create_window_covering_service_with_h_tilt + ) helper.characteristics[H_TILT_CURRENT].value = 75 state = await helper.poll_and_get_state() @@ -120,8 +118,9 @@ async def test_read_window_cover_tilt_horizontal(hass, utcnow): async def test_read_window_cover_tilt_vertical(hass, utcnow): """Test that vertical tilt is handled correctly.""" - window_cover = create_window_covering_service_with_v_tilt() - helper = await setup_test_component(hass, [window_cover]) + helper = await setup_test_component( + hass, create_window_covering_service_with_v_tilt + ) helper.characteristics[V_TILT_CURRENT].value = 75 state = await helper.poll_and_get_state() @@ -130,8 +129,9 @@ async def test_read_window_cover_tilt_vertical(hass, utcnow): async def test_write_window_cover_tilt_horizontal(hass, utcnow): """Test that horizontal tilt is written correctly.""" - window_cover = create_window_covering_service_with_h_tilt() - helper = await setup_test_component(hass, [window_cover]) + helper = await setup_test_component( + hass, create_window_covering_service_with_h_tilt + ) await hass.services.async_call( "cover", @@ -144,8 +144,9 @@ async def test_write_window_cover_tilt_horizontal(hass, utcnow): async def test_write_window_cover_tilt_vertical(hass, utcnow): """Test that vertical tilt is written correctly.""" - window_cover = create_window_covering_service_with_v_tilt() - helper = await setup_test_component(hass, [window_cover]) + helper = await setup_test_component( + hass, create_window_covering_service_with_v_tilt + ) await hass.services.async_call( "cover", @@ -158,8 +159,9 @@ async def test_write_window_cover_tilt_vertical(hass, utcnow): async def test_window_cover_stop(hass, utcnow): """Test that vertical tilt is written correctly.""" - window_cover = create_window_covering_service_with_v_tilt() - helper = await setup_test_component(hass, [window_cover]) + helper = await setup_test_component( + hass, create_window_covering_service_with_v_tilt + ) await hass.services.async_call( "cover", "stop_cover", {"entity_id": helper.entity_id}, blocking=True @@ -167,20 +169,20 @@ async def test_window_cover_stop(hass, utcnow): assert helper.characteristics[POSITION_HOLD].value == 1 -def create_garage_door_opener_service(): +def create_garage_door_opener_service(accessory): """Define a garage-door-opener chars as per page 217 of HAP spec.""" - service = FakeService("public.hap.service.garage-door-opener") + service = accessory.add_service(ServicesTypes.GARAGE_DOOR_OPENER) - cur_state = service.add_characteristic("door-state.current") + cur_state = service.add_char(CharacteristicsTypes.DOOR_STATE_CURRENT) cur_state.value = 0 - targ_state = service.add_characteristic("door-state.target") - targ_state.value = 0 + cur_state = service.add_char(CharacteristicsTypes.DOOR_STATE_TARGET) + cur_state.value = 0 - obstruction = service.add_characteristic("obstruction-detected") + obstruction = service.add_char(CharacteristicsTypes.OBSTRUCTION_DETECTED) obstruction.value = False - name = service.add_characteristic("name") + name = service.add_char(CharacteristicsTypes.NAME) name.value = "testdevice" return service @@ -188,8 +190,7 @@ def create_garage_door_opener_service(): async def test_change_door_state(hass, utcnow): """Test that we can turn open and close a HomeKit garage door.""" - door = create_garage_door_opener_service() - helper = await setup_test_component(hass, [door]) + helper = await setup_test_component(hass, create_garage_door_opener_service) await hass.services.async_call( "cover", "open_cover", {"entity_id": helper.entity_id}, blocking=True @@ -204,8 +205,7 @@ async def test_change_door_state(hass, utcnow): async def test_read_door_state(hass, utcnow): """Test that we can read the state of a HomeKit garage door.""" - door = create_garage_door_opener_service() - helper = await setup_test_component(hass, [door]) + helper = await setup_test_component(hass, create_garage_door_opener_service) helper.characteristics[DOOR_CURRENT].value = 0 state = await helper.poll_and_get_state() diff --git a/tests/components/homekit_controller/test_fan.py b/tests/components/homekit_controller/test_fan.py index fe97451cfbb..fd24f5215da 100644 --- a/tests/components/homekit_controller/test_fan.py +++ b/tests/components/homekit_controller/test_fan.py @@ -1,5 +1,8 @@ """Basic checks for HomeKit motion sensors and contact sensors.""" -from tests.components.homekit_controller.common import FakeService, setup_test_component +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + +from tests.components.homekit_controller.common import setup_test_component V1_ON = ("fan", "on") V1_ROTATION_DIRECTION = ("fan", "rotation.direction") @@ -11,50 +14,45 @@ V2_ROTATION_SPEED = ("fanv2", "rotation.speed") V2_SWING_MODE = ("fanv2", "swing-mode") -def create_fan_service(): +def create_fan_service(accessory): """ Define fan v1 characteristics as per HAP spec. This service is no longer documented in R2 of the public HAP spec but existing devices out there use it (like the SIMPLEconnect fan) """ - service = FakeService("public.hap.service.fan") + service = accessory.add_service(ServicesTypes.FAN) - cur_state = service.add_characteristic("on") + cur_state = service.add_char(CharacteristicsTypes.ON) cur_state.value = 0 - cur_state = service.add_characteristic("rotation.direction") - cur_state.value = 0 + direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION) + direction.value = 0 - cur_state = service.add_characteristic("rotation.speed") - cur_state.value = 0 - - return service + speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED) + speed.value = 0 -def create_fanv2_service(): +def create_fanv2_service(accessory): """Define fan v2 characteristics as per HAP spec.""" - service = FakeService("public.hap.service.fanv2") + service = accessory.add_service(ServicesTypes.FAN_V2) - cur_state = service.add_characteristic("active") + cur_state = service.add_char(CharacteristicsTypes.ACTIVE) cur_state.value = 0 - cur_state = service.add_characteristic("rotation.direction") - cur_state.value = 0 + direction = service.add_char(CharacteristicsTypes.ROTATION_DIRECTION) + direction.value = 0 - cur_state = service.add_characteristic("rotation.speed") - cur_state.value = 0 + speed = service.add_char(CharacteristicsTypes.ROTATION_SPEED) + speed.value = 0 - cur_state = service.add_characteristic("swing-mode") - cur_state.value = 0 - - return service + swing_mode = service.add_char(CharacteristicsTypes.SWING_MODE) + swing_mode.value = 0 async def test_fan_read_state(hass, utcnow): """Test that we can read the state of a HomeKit fan accessory.""" - sensor = create_fan_service() - helper = await setup_test_component(hass, [sensor]) + helper = await setup_test_component(hass, create_fan_service) helper.characteristics[V1_ON].value = False state = await helper.poll_and_get_state() @@ -67,8 +65,7 @@ async def test_fan_read_state(hass, utcnow): async def test_turn_on(hass, utcnow): """Test that we can turn a fan on.""" - fan = create_fan_service() - helper = await setup_test_component(hass, [fan]) + helper = await setup_test_component(hass, create_fan_service) await hass.services.async_call( "fan", @@ -100,8 +97,7 @@ async def test_turn_on(hass, utcnow): async def test_turn_off(hass, utcnow): """Test that we can turn a fan off.""" - fan = create_fan_service() - helper = await setup_test_component(hass, [fan]) + helper = await setup_test_component(hass, create_fan_service) helper.characteristics[V1_ON].value = 1 @@ -113,8 +109,7 @@ async def test_turn_off(hass, utcnow): async def test_set_speed(hass, utcnow): """Test that we set fan speed.""" - fan = create_fan_service() - helper = await setup_test_component(hass, [fan]) + helper = await setup_test_component(hass, create_fan_service) helper.characteristics[V1_ON].value = 1 @@ -153,8 +148,7 @@ async def test_set_speed(hass, utcnow): async def test_speed_read(hass, utcnow): """Test that we can read a fans oscillation.""" - fan = create_fan_service() - helper = await setup_test_component(hass, [fan]) + helper = await setup_test_component(hass, create_fan_service) helper.characteristics[V1_ON].value = 1 helper.characteristics[V1_ROTATION_SPEED].value = 100 @@ -177,8 +171,7 @@ async def test_speed_read(hass, utcnow): async def test_set_direction(hass, utcnow): """Test that we can set fan spin direction.""" - fan = create_fan_service() - helper = await setup_test_component(hass, [fan]) + helper = await setup_test_component(hass, create_fan_service) await hass.services.async_call( "fan", @@ -199,8 +192,7 @@ async def test_set_direction(hass, utcnow): async def test_direction_read(hass, utcnow): """Test that we can read a fans oscillation.""" - fan = create_fan_service() - helper = await setup_test_component(hass, [fan]) + helper = await setup_test_component(hass, create_fan_service) helper.characteristics[V1_ROTATION_DIRECTION].value = 0 state = await helper.poll_and_get_state() @@ -213,8 +205,7 @@ async def test_direction_read(hass, utcnow): async def test_fanv2_read_state(hass, utcnow): """Test that we can read the state of a HomeKit fan accessory.""" - sensor = create_fanv2_service() - helper = await setup_test_component(hass, [sensor]) + helper = await setup_test_component(hass, create_fanv2_service) helper.characteristics[V2_ACTIVE].value = False state = await helper.poll_and_get_state() @@ -227,8 +218,7 @@ async def test_fanv2_read_state(hass, utcnow): async def test_v2_turn_on(hass, utcnow): """Test that we can turn a fan on.""" - fan = create_fanv2_service() - helper = await setup_test_component(hass, [fan]) + helper = await setup_test_component(hass, create_fanv2_service) await hass.services.async_call( "fan", @@ -260,8 +250,7 @@ async def test_v2_turn_on(hass, utcnow): async def test_v2_turn_off(hass, utcnow): """Test that we can turn a fan off.""" - fan = create_fanv2_service() - helper = await setup_test_component(hass, [fan]) + helper = await setup_test_component(hass, create_fanv2_service) helper.characteristics[V2_ACTIVE].value = 1 @@ -273,8 +262,7 @@ async def test_v2_turn_off(hass, utcnow): async def test_v2_set_speed(hass, utcnow): """Test that we set fan speed.""" - fan = create_fanv2_service() - helper = await setup_test_component(hass, [fan]) + helper = await setup_test_component(hass, create_fanv2_service) helper.characteristics[V2_ACTIVE].value = 1 @@ -313,8 +301,7 @@ async def test_v2_set_speed(hass, utcnow): async def test_v2_speed_read(hass, utcnow): """Test that we can read a fans oscillation.""" - fan = create_fanv2_service() - helper = await setup_test_component(hass, [fan]) + helper = await setup_test_component(hass, create_fanv2_service) helper.characteristics[V2_ACTIVE].value = 1 helper.characteristics[V2_ROTATION_SPEED].value = 100 @@ -337,8 +324,7 @@ async def test_v2_speed_read(hass, utcnow): async def test_v2_set_direction(hass, utcnow): """Test that we can set fan spin direction.""" - fan = create_fanv2_service() - helper = await setup_test_component(hass, [fan]) + helper = await setup_test_component(hass, create_fanv2_service) await hass.services.async_call( "fan", @@ -359,8 +345,7 @@ async def test_v2_set_direction(hass, utcnow): async def test_v2_direction_read(hass, utcnow): """Test that we can read a fans oscillation.""" - fan = create_fanv2_service() - helper = await setup_test_component(hass, [fan]) + helper = await setup_test_component(hass, create_fanv2_service) helper.characteristics[V2_ROTATION_DIRECTION].value = 0 state = await helper.poll_and_get_state() @@ -373,8 +358,7 @@ async def test_v2_direction_read(hass, utcnow): async def test_v2_oscillate(hass, utcnow): """Test that we can control a fans oscillation.""" - fan = create_fanv2_service() - helper = await setup_test_component(hass, [fan]) + helper = await setup_test_component(hass, create_fanv2_service) await hass.services.async_call( "fan", @@ -395,8 +379,7 @@ async def test_v2_oscillate(hass, utcnow): async def test_v2_oscillate_read(hass, utcnow): """Test that we can read a fans oscillation.""" - fan = create_fanv2_service() - helper = await setup_test_component(hass, [fan]) + helper = await setup_test_component(hass, create_fanv2_service) helper.characteristics[V2_SWING_MODE].value = 0 state = await helper.poll_and_get_state() diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index b558160a9f2..d9e1d21e2fe 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -1,7 +1,10 @@ """Basic checks for HomeKitSwitch.""" +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + from homeassistant.components.homekit_controller.const import KNOWN_DEVICES -from tests.components.homekit_controller.common import FakeService, setup_test_component +from tests.components.homekit_controller.common import setup_test_component LIGHT_ON = ("lightbulb", "on") LIGHT_BRIGHTNESS = ("lightbulb", "brightness") @@ -10,37 +13,37 @@ LIGHT_SATURATION = ("lightbulb", "saturation") LIGHT_COLOR_TEMP = ("lightbulb", "color-temperature") -def create_lightbulb_service(): +def create_lightbulb_service(accessory): """Define lightbulb characteristics.""" - service = FakeService("public.hap.service.lightbulb") + service = accessory.add_service(ServicesTypes.LIGHTBULB) - on_char = service.add_characteristic("on") + on_char = service.add_char(CharacteristicsTypes.ON) on_char.value = 0 - brightness = service.add_characteristic("brightness") + brightness = service.add_char(CharacteristicsTypes.BRIGHTNESS) brightness.value = 0 return service -def create_lightbulb_service_with_hs(): +def create_lightbulb_service_with_hs(accessory): """Define a lightbulb service with hue + saturation.""" - service = create_lightbulb_service() + service = create_lightbulb_service(accessory) - hue = service.add_characteristic("hue") + hue = service.add_char(CharacteristicsTypes.HUE) hue.value = 0 - saturation = service.add_characteristic("saturation") + saturation = service.add_char(CharacteristicsTypes.SATURATION) saturation.value = 0 return service -def create_lightbulb_service_with_color_temp(): +def create_lightbulb_service_with_color_temp(accessory): """Define a lightbulb service with color temp.""" - service = create_lightbulb_service() + service = create_lightbulb_service(accessory) - color_temp = service.add_characteristic("color-temperature") + color_temp = service.add_char(CharacteristicsTypes.COLOR_TEMPERATURE) color_temp.value = 0 return service @@ -48,8 +51,7 @@ def create_lightbulb_service_with_color_temp(): async def test_switch_change_light_state(hass, utcnow): """Test that we can turn a HomeKit light on and off again.""" - bulb = create_lightbulb_service_with_hs() - helper = await setup_test_component(hass, [bulb]) + helper = await setup_test_component(hass, create_lightbulb_service_with_hs) await hass.services.async_call( "light", @@ -71,8 +73,7 @@ async def test_switch_change_light_state(hass, utcnow): async def test_switch_change_light_state_color_temp(hass, utcnow): """Test that we can turn change color_temp.""" - bulb = create_lightbulb_service_with_color_temp() - helper = await setup_test_component(hass, [bulb]) + helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) await hass.services.async_call( "light", @@ -87,8 +88,7 @@ async def test_switch_change_light_state_color_temp(hass, utcnow): async def test_switch_read_light_state(hass, utcnow): """Test that we can read the state of a HomeKit light accessory.""" - bulb = create_lightbulb_service_with_hs() - helper = await setup_test_component(hass, [bulb]) + helper = await setup_test_component(hass, create_lightbulb_service_with_hs) # Initial state is that the light is off state = await helper.poll_and_get_state() @@ -112,8 +112,7 @@ async def test_switch_read_light_state(hass, utcnow): async def test_switch_read_light_state_color_temp(hass, utcnow): """Test that we can read the color_temp of a light accessory.""" - bulb = create_lightbulb_service_with_color_temp() - helper = await setup_test_component(hass, [bulb]) + helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) # Initial state is that the light is off state = await helper.poll_and_get_state() @@ -132,8 +131,7 @@ async def test_switch_read_light_state_color_temp(hass, utcnow): async def test_light_becomes_unavailable_but_recovers(hass, utcnow): """Test transition to and from unavailable state.""" - bulb = create_lightbulb_service_with_color_temp() - helper = await setup_test_component(hass, [bulb]) + helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) # Initial state is that the light is off state = await helper.poll_and_get_state() @@ -158,8 +156,7 @@ async def test_light_becomes_unavailable_but_recovers(hass, utcnow): async def test_light_unloaded(hass, utcnow): """Test entity and HKDevice are correctly unloaded.""" - bulb = create_lightbulb_service_with_color_temp() - helper = await setup_test_component(hass, [bulb]) + helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) # Initial state is that the light is off state = await helper.poll_and_get_state() diff --git a/tests/components/homekit_controller/test_lock.py b/tests/components/homekit_controller/test_lock.py index d47b77a37eb..197b7b3c3b9 100644 --- a/tests/components/homekit_controller/test_lock.py +++ b/tests/components/homekit_controller/test_lock.py @@ -1,25 +1,28 @@ """Basic checks for HomeKitLock.""" -from tests.components.homekit_controller.common import FakeService, setup_test_component +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + +from tests.components.homekit_controller.common import setup_test_component LOCK_CURRENT_STATE = ("lock-mechanism", "lock-mechanism.current-state") LOCK_TARGET_STATE = ("lock-mechanism", "lock-mechanism.target-state") -def create_lock_service(): +def create_lock_service(accessory): """Define a lock characteristics as per page 219 of HAP spec.""" - service = FakeService("public.hap.service.lock-mechanism") + service = accessory.add_service(ServicesTypes.LOCK_MECHANISM) - cur_state = service.add_characteristic("lock-mechanism.current-state") + cur_state = service.add_char(CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE) cur_state.value = 0 - targ_state = service.add_characteristic("lock-mechanism.target-state") + targ_state = service.add_char(CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE) targ_state.value = 0 # According to the spec, a battery-level characteristic is normally # part of a separate service. However as the code was written (which # predates this test) the battery level would have to be part of the lock # service as it is here. - targ_state = service.add_characteristic("battery-level") + targ_state = service.add_char(CharacteristicsTypes.BATTERY_LEVEL) targ_state.value = 50 return service @@ -27,8 +30,7 @@ def create_lock_service(): async def test_switch_change_lock_state(hass, utcnow): """Test that we can turn a HomeKit lock on and off again.""" - lock = create_lock_service() - helper = await setup_test_component(hass, [lock]) + helper = await setup_test_component(hass, create_lock_service) await hass.services.async_call( "lock", "lock", {"entity_id": "lock.testdevice"}, blocking=True @@ -43,8 +45,7 @@ async def test_switch_change_lock_state(hass, utcnow): async def test_switch_read_lock_state(hass, utcnow): """Test that we can read the state of a HomeKit lock accessory.""" - lock = create_lock_service() - helper = await setup_test_component(hass, [lock]) + helper = await setup_test_component(hass, create_lock_service) helper.characteristics[LOCK_CURRENT_STATE].value = 0 helper.characteristics[LOCK_TARGET_STATE].value = 0 diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index f9d84b06996..5b1c5e1ac85 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -1,5 +1,8 @@ """Basic checks for HomeKit sensor.""" -from tests.components.homekit_controller.common import FakeService, setup_test_component +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + +from tests.components.homekit_controller.common import setup_test_component TEMPERATURE = ("temperature", "temperature.current") HUMIDITY = ("humidity", "relative-humidity.current") @@ -10,57 +13,49 @@ CHARGING_STATE = ("battery", "charging-state") LO_BATT = ("battery", "status-lo-batt") -def create_temperature_sensor_service(): +def create_temperature_sensor_service(accessory): """Define temperature characteristics.""" - service = FakeService("public.hap.service.sensor.temperature") + service = accessory.add_service(ServicesTypes.TEMPERATURE_SENSOR) - cur_state = service.add_characteristic("temperature.current") + cur_state = service.add_char(CharacteristicsTypes.TEMPERATURE_CURRENT) cur_state.value = 0 - return service - -def create_humidity_sensor_service(): +def create_humidity_sensor_service(accessory): """Define humidity characteristics.""" - service = FakeService("public.hap.service.sensor.humidity") + service = accessory.add_service(ServicesTypes.HUMIDITY_SENSOR) - cur_state = service.add_characteristic("relative-humidity.current") + cur_state = service.add_char(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) cur_state.value = 0 - return service - -def create_light_level_sensor_service(): +def create_light_level_sensor_service(accessory): """Define light level characteristics.""" - service = FakeService("public.hap.service.sensor.light") + service = accessory.add_service(ServicesTypes.LIGHT_SENSOR) - cur_state = service.add_characteristic("light-level.current") + cur_state = service.add_char(CharacteristicsTypes.LIGHT_LEVEL_CURRENT) cur_state.value = 0 - return service - -def create_carbon_dioxide_level_sensor_service(): +def create_carbon_dioxide_level_sensor_service(accessory): """Define carbon dioxide level characteristics.""" - service = FakeService("public.hap.service.sensor.carbon-dioxide") + service = accessory.add_service(ServicesTypes.CARBON_DIOXIDE_SENSOR) - cur_state = service.add_characteristic("carbon-dioxide.level") + cur_state = service.add_char(CharacteristicsTypes.CARBON_DIOXIDE_LEVEL) cur_state.value = 0 - return service - -def create_battery_level_sensor(): +def create_battery_level_sensor(accessory): """Define battery level characteristics.""" - service = FakeService("public.hap.service.battery") + service = accessory.add_service(ServicesTypes.BATTERY_SERVICE) - cur_state = service.add_characteristic("battery-level") + cur_state = service.add_char(CharacteristicsTypes.BATTERY_LEVEL) cur_state.value = 100 - low_battery = service.add_characteristic("status-lo-batt") + low_battery = service.add_char(CharacteristicsTypes.STATUS_LO_BATT) low_battery.value = 0 - charging_state = service.add_characteristic("charging-state") + charging_state = service.add_char(CharacteristicsTypes.CHARGING_STATE) charging_state.value = 0 return service @@ -68,8 +63,9 @@ def create_battery_level_sensor(): async def test_temperature_sensor_read_state(hass, utcnow): """Test reading the state of a HomeKit temperature sensor accessory.""" - sensor = create_temperature_sensor_service() - helper = await setup_test_component(hass, [sensor], suffix="temperature") + helper = await setup_test_component( + hass, create_temperature_sensor_service, suffix="temperature" + ) helper.characteristics[TEMPERATURE].value = 10 state = await helper.poll_and_get_state() @@ -82,8 +78,9 @@ async def test_temperature_sensor_read_state(hass, utcnow): async def test_humidity_sensor_read_state(hass, utcnow): """Test reading the state of a HomeKit humidity sensor accessory.""" - sensor = create_humidity_sensor_service() - helper = await setup_test_component(hass, [sensor], suffix="humidity") + helper = await setup_test_component( + hass, create_humidity_sensor_service, suffix="humidity" + ) helper.characteristics[HUMIDITY].value = 10 state = await helper.poll_and_get_state() @@ -96,8 +93,9 @@ async def test_humidity_sensor_read_state(hass, utcnow): async def test_light_level_sensor_read_state(hass, utcnow): """Test reading the state of a HomeKit temperature sensor accessory.""" - sensor = create_light_level_sensor_service() - helper = await setup_test_component(hass, [sensor], suffix="light_level") + helper = await setup_test_component( + hass, create_light_level_sensor_service, suffix="light_level" + ) helper.characteristics[LIGHT_LEVEL].value = 10 state = await helper.poll_and_get_state() @@ -110,8 +108,9 @@ async def test_light_level_sensor_read_state(hass, utcnow): async def test_carbon_dioxide_level_sensor_read_state(hass, utcnow): """Test reading the state of a HomeKit carbon dioxide sensor accessory.""" - sensor = create_carbon_dioxide_level_sensor_service() - helper = await setup_test_component(hass, [sensor], suffix="co2") + helper = await setup_test_component( + hass, create_carbon_dioxide_level_sensor_service, suffix="co2" + ) helper.characteristics[CARBON_DIOXIDE_LEVEL].value = 10 state = await helper.poll_and_get_state() @@ -124,8 +123,9 @@ async def test_carbon_dioxide_level_sensor_read_state(hass, utcnow): async def test_battery_level_sensor(hass, utcnow): """Test reading the state of a HomeKit battery level sensor.""" - sensor = create_battery_level_sensor() - helper = await setup_test_component(hass, [sensor], suffix="battery") + helper = await setup_test_component( + hass, create_battery_level_sensor, suffix="battery" + ) helper.characteristics[BATTERY_LEVEL].value = 100 state = await helper.poll_and_get_state() @@ -140,8 +140,9 @@ async def test_battery_level_sensor(hass, utcnow): async def test_battery_charging(hass, utcnow): """Test reading the state of a HomeKit battery's charging state.""" - sensor = create_battery_level_sensor() - helper = await setup_test_component(hass, [sensor], suffix="battery") + helper = await setup_test_component( + hass, create_battery_level_sensor, suffix="battery" + ) helper.characteristics[BATTERY_LEVEL].value = 0 helper.characteristics[CHARGING_STATE].value = 1 @@ -155,8 +156,9 @@ async def test_battery_charging(hass, utcnow): async def test_battery_low(hass, utcnow): """Test reading the state of a HomeKit battery's low state.""" - sensor = create_battery_level_sensor() - helper = await setup_test_component(hass, [sensor], suffix="battery") + helper = await setup_test_component( + hass, create_battery_level_sensor, suffix="battery" + ) helper.characteristics[LO_BATT].value = 0 helper.characteristics[BATTERY_LEVEL].value = 1 diff --git a/tests/components/homekit_controller/test_storage.py b/tests/components/homekit_controller/test_storage.py index 39b0d9d8250..4f0dabb9bc8 100644 --- a/tests/components/homekit_controller/test_storage.py +++ b/tests/components/homekit_controller/test_storage.py @@ -1,11 +1,13 @@ """Basic checks for entity map storage.""" +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + from homeassistant import config_entries from homeassistant.components.homekit_controller import async_remove_entry from homeassistant.components.homekit_controller.const import ENTITY_MAP from tests.common import flush_store from tests.components.homekit_controller.common import ( - FakeService, setup_platform, setup_test_component, ) @@ -57,18 +59,16 @@ async def test_storage_is_removed_idempotent(hass): assert hkid not in entity_map.storage_data -def create_lightbulb_service(): +def create_lightbulb_service(accessory): """Define lightbulb characteristics.""" - service = FakeService("public.hap.service.lightbulb") - on_char = service.add_characteristic("on") + service = accessory.add_service(ServicesTypes.LIGHTBULB) + on_char = service.add_char(CharacteristicsTypes.ON) on_char.value = 0 - return service async def test_storage_is_updated_on_add(hass, hass_storage, utcnow): """Test entity map storage is cleaned up on adding an accessory.""" - bulb = create_lightbulb_service() - await setup_test_component(hass, [bulb]) + await setup_test_component(hass, create_lightbulb_service) entity_map = hass.data[ENTITY_MAP] hkid = "00:00:00:00:00:00" @@ -83,8 +83,7 @@ async def test_storage_is_updated_on_add(hass, hass_storage, utcnow): async def test_storage_is_removed_on_config_entry_removal(hass, utcnow): """Test entity map storage is cleaned up on config entry removal.""" - bulb = create_lightbulb_service() - await setup_test_component(hass, [bulb]) + await setup_test_component(hass, create_lightbulb_service) hkid = "00:00:00:00:00:00" diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py index 82dae3f4a6e..eb10d42e208 100644 --- a/tests/components/homekit_controller/test_switch.py +++ b/tests/components/homekit_controller/test_switch.py @@ -1,12 +1,25 @@ """Basic checks for HomeKitSwitch.""" + +from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import ServicesTypes + from tests.components.homekit_controller.common import setup_test_component +def create_switch_service(accessory): + """Define outlet characteristics.""" + service = accessory.add_service(ServicesTypes.OUTLET) + + on_char = service.add_char(CharacteristicsTypes.ON) + on_char.value = False + + outlet_in_use = service.add_char(CharacteristicsTypes.OUTLET_IN_USE) + outlet_in_use.value = False + + async def test_switch_change_outlet_state(hass, utcnow): """Test that we can turn a HomeKit outlet on and off again.""" - from homekit.model.services import OutletService - - helper = await setup_test_component(hass, [OutletService()]) + helper = await setup_test_component(hass, create_switch_service) await hass.services.async_call( "switch", "turn_on", {"entity_id": "switch.testdevice"}, blocking=True @@ -21,9 +34,7 @@ async def test_switch_change_outlet_state(hass, utcnow): async def test_switch_read_outlet_state(hass, utcnow): """Test that we can read the state of a HomeKit outlet accessory.""" - from homekit.model.services import OutletService - - helper = await setup_test_component(hass, [OutletService()]) + helper = await setup_test_component(hass, create_switch_service) # Initial state is that the switch is off and the outlet isn't in use switch_1 = await helper.poll_and_get_state() From d996a4a9a9f3eab0f9ecaaf6e5e8040f9d0ed61f Mon Sep 17 00:00:00 2001 From: Rocik Date: Mon, 24 Feb 2020 15:34:53 +0100 Subject: [PATCH 066/416] Add Supla gate (#31643) * Add support for Supla gate with sensor * Fix Supla switch module description and state access * Add docs to methods of Supla gate * Add missing comma * Remove unused import * Sort imports of Supla cover * Add returning availability for every Supla device * Use direct access to dict * Remove deprecated property "hidden" * Remove unused constant * Revert using get function on dict --- homeassistant/components/supla/__init__.py | 20 ++++++++ homeassistant/components/supla/cover.py | 55 ++++++++++++++++++++-- homeassistant/components/supla/switch.py | 2 +- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index fd60254cd0a..6c9bfb8d16e 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -18,8 +18,10 @@ CONF_SERVERS = "servers" SUPLA_FUNCTION_HA_CMP_MAP = { "CONTROLLINGTHEROLLERSHUTTER": "cover", + "CONTROLLINGTHEGATE": "cover", "LIGHTSWITCH": "switch", } +SUPLA_FUNCTION_NONE = "NONE" SUPLA_CHANNELS = "supla_channels" SUPLA_SERVERS = "supla_servers" @@ -86,6 +88,14 @@ def discover_devices(hass, hass_config): for channel in server.get_channels(include=["iodevice"]): channel_function = channel["function"]["name"] + if channel_function == SUPLA_FUNCTION_NONE: + _LOGGER.debug( + "Ignored function: %s, channel id: %s", + channel_function, + channel["id"], + ) + continue + component_name = SUPLA_FUNCTION_HA_CMP_MAP.get(channel_function) if component_name is None: @@ -130,6 +140,16 @@ class SuplaChannel(Entity): """Return the name of the device.""" return self.channel_data["caption"] + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.channel_data is None: + return False + state = self.channel_data.get("state") + if state is None: + return False + return state.get("connected") + def action(self, action, **add_pars): """ Run server action. diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py index 3182aa8c136..659b78cc41a 100644 --- a/homeassistant/components/supla/cover.py +++ b/homeassistant/components/supla/cover.py @@ -1,12 +1,19 @@ -"""Support for Supla cover - curtains, rollershutters etc.""" +"""Support for Supla cover - curtains, rollershutters, entry gate etc.""" import logging from pprint import pformat -from homeassistant.components.cover import ATTR_POSITION, CoverDevice +from homeassistant.components.cover import ( + ATTR_POSITION, + DEVICE_CLASS_GARAGE, + CoverDevice, +) from homeassistant.components.supla import SuplaChannel _LOGGER = logging.getLogger(__name__) +SUPLA_SHUTTER = "CONTROLLINGTHEROLLERSHUTTER" +SUPLA_GATE = "CONTROLLINGTHEGATE" + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Supla covers.""" @@ -15,7 +22,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.debug("Discovery: %s", pformat(discovery_info)) - add_entities([SuplaCover(device) for device in discovery_info]) + entities = [] + for device in discovery_info: + device_name = device["function"]["name"] + if device_name == SUPLA_SHUTTER: + entities.append(SuplaCover(device)) + elif device_name == SUPLA_GATE: + entities.append(SuplaGateDoor(device)) + add_entities(entities) class SuplaCover(SuplaChannel, CoverDevice): @@ -51,3 +65,38 @@ class SuplaCover(SuplaChannel, CoverDevice): def stop_cover(self, **kwargs): """Stop the cover.""" self.action("STOP") + + +class SuplaGateDoor(SuplaChannel, CoverDevice): + """Representation of a Supla gate door.""" + + @property + def is_closed(self): + """Return if the gate is closed or not.""" + state = self.channel_data.get("state") + if state and "hi" in state: + return state.get("hi") + return None + + def open_cover(self, **kwargs) -> None: + """Open the gate.""" + if self.is_closed: + self.action("OPEN_CLOSE") + + def close_cover(self, **kwargs) -> None: + """Close the gate.""" + if not self.is_closed: + self.action("OPEN_CLOSE") + + def stop_cover(self, **kwargs) -> None: + """Stop the gate.""" + self.action("OPEN_CLOSE") + + def toggle(self, **kwargs) -> None: + """Toggle the gate.""" + self.action("OPEN_CLOSE") + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_GARAGE diff --git a/homeassistant/components/supla/switch.py b/homeassistant/components/supla/switch.py index 725771e21e8..556c1b69a53 100644 --- a/homeassistant/components/supla/switch.py +++ b/homeassistant/components/supla/switch.py @@ -1,4 +1,4 @@ -"""Support for Supla cover - curtains, rollershutters etc.""" +"""Support for Supla switch.""" import logging from pprint import pformat From 15b497568113f42617e4f256c65ca270bde1a523 Mon Sep 17 00:00:00 2001 From: Ruslan Sayfutdinov Date: Mon, 24 Feb 2020 16:33:10 +0000 Subject: [PATCH 067/416] Use ciso8601 library to parse datetime faster (#32128) --- homeassistant/package_constraints.txt | 1 + homeassistant/util/dt.py | 5 +++++ pylintrc | 1 + requirements_all.txt | 1 + setup.py | 1 + tests/helpers/test_config_validation.py | 2 +- 6 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8c00b4502b1..cef7cda8017 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -7,6 +7,7 @@ async_timeout==3.0.1 attrs==19.3.0 bcrypt==3.1.7 certifi>=2019.11.28 +ciso8601==2.1.3 cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index dde18688d9f..084888c188c 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -3,6 +3,7 @@ import datetime as dt import re from typing import Any, Dict, List, Optional, Tuple, Union, cast +import ciso8601 import pytz import pytz.exceptions as pytzexceptions import pytz.tzinfo as pytzinfo @@ -122,6 +123,10 @@ def parse_datetime(dt_str: str) -> Optional[dt.datetime]: Raises ValueError if the input is well formatted but not a valid datetime. Returns None if the input isn't well formatted. """ + try: + return ciso8601.parse_datetime(dt_str) + except (ValueError, IndexError): + pass match = DATETIME_RE.match(dt_str) if not match: return None diff --git a/pylintrc b/pylintrc index fcc38ec0734..125062c8cfe 100644 --- a/pylintrc +++ b/pylintrc @@ -5,6 +5,7 @@ ignore=tests jobs=2 load-plugins=pylint_strict_informational persistent=no +extension-pkg-whitelist=ciso8601 [BASIC] good-names=id,i,j,k,ex,Run,_,fp diff --git a/requirements_all.txt b/requirements_all.txt index 57b8672cfaf..1445f9930a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,6 +5,7 @@ async_timeout==3.0.1 attrs==19.3.0 bcrypt==3.1.7 certifi>=2019.11.28 +ciso8601==2.1.3 importlib-metadata==1.5.0 jinja2>=2.10.3 PyJWT==1.7.1 diff --git a/setup.py b/setup.py index eb360c93cf8..0564b7f4773 100755 --- a/setup.py +++ b/setup.py @@ -38,6 +38,7 @@ REQUIRES = [ "attrs==19.3.0", "bcrypt==3.1.7", "certifi>=2019.11.28", + "ciso8601==2.1.3", "importlib-metadata==1.5.0", "jinja2>=2.10.3", "PyJWT==1.7.1", diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index e94fa202ce6..71d845ac637 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -464,7 +464,7 @@ def test_time(): def test_datetime(): """Test date time validation.""" schema = vol.Schema(cv.datetime) - for value in [date.today(), "Wrong DateTime", "2016-11-23"]: + for value in [date.today(), "Wrong DateTime"]: with pytest.raises(vol.MultipleInvalid): schema(value) From 07fa844c43e2540f61973ddce8d900834725d99d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Feb 2020 08:35:02 -0800 Subject: [PATCH 068/416] Speed up validate_entity_id (#32137) * Speed up validate_entity_id * Add some more invalid entity IDs * Adjust regular expression * Extend and sort test cases * Update regular expression, more cases, faster * Adjust tests, allow start with number, disallow double underscore Co-authored-by: Franck Nijhof --- homeassistant/core.py | 8 ++++-- homeassistant/scripts/benchmark/__init__.py | 9 ++++++ tests/test_core.py | 31 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index c17c1f698ce..a1d9a83d1ad 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -12,6 +12,7 @@ import functools import logging import os import pathlib +import re import threading from time import monotonic from types import MappingProxyType @@ -63,7 +64,7 @@ from homeassistant.exceptions import ( ServiceNotFound, Unauthorized, ) -from homeassistant.util import location, slugify +from homeassistant.util import location from homeassistant.util.async_ import fire_coroutine_threadsafe, run_callback_threadsafe import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM, UnitSystem @@ -103,12 +104,15 @@ def split_entity_id(entity_id: str) -> List[str]: return entity_id.split(".", 1) +VALID_ENTITY_ID = re.compile(r"^(?!.+__)(?!_)[\da-z_]+(? bool: """Test if an entity ID is a valid format. Format: . where both are slugs. """ - return "." in entity_id and slugify(entity_id) == entity_id.replace(".", "_", 1) + return VALID_ENTITY_ID.match(entity_id) is not None def valid_state(state: str) -> bool: diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index 58125bc4829..4d7df6d7248 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -185,3 +185,12 @@ def _logbook_filtering(hass, last_changed, last_updated): list(logbook.humanify(None, yield_events(event))) return timer() - start + + +@benchmark +async def valid_entity_id(hass): + """Run valid entity ID a million times.""" + start = timer() + for _ in range(10 ** 6): + core.valid_entity_id("light.kitchen") + return timer() - start diff --git a/tests/test_core.py b/tests/test_core.py index 0c7acfbba0e..f5a6f4718cd 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1206,3 +1206,34 @@ async def test_async_functions_with_callback(hass): await hass.services.async_call("test_domain", "test_service", blocking=True) assert len(runs) == 3 + + +def test_valid_entity_id(): + """Test valid entity ID.""" + for invalid in [ + "_light.kitchen", + ".kitchen", + ".light.kitchen", + "light_.kitchen", + "light._kitchen", + "light.", + "light.kitchen__ceiling", + "light.kitchen_yo_", + "light.kitchen.", + "Light.kitchen", + "light.Kitchen", + "lightkitchen", + ]: + assert not ha.valid_entity_id(invalid), invalid + + for valid in [ + "1.a", + "1light.kitchen", + "a.1", + "a.a", + "input_boolean.hello_world_0123", + "light.1kitchen", + "light.kitchen", + "light.something_yoo", + ]: + assert ha.valid_entity_id(valid), valid From 9801810552c376d7656c1e2b6c1191c997659ad1 Mon Sep 17 00:00:00 2001 From: springstan <46536646+springstan@users.noreply.github.com> Date: Mon, 24 Feb 2020 17:47:52 +0100 Subject: [PATCH 069/416] Use f-strings in integrations starting with "B"-"E" (#32121) * Use f-strings in integrations starting with B * Use f-strings in integrations starting with C * Use f-strings in integrations starting with D * Use f-strings in integrations starting with E * Fix pylint errors * Fix pylint errors v2 * Fix tests * Fix tests v2 --- homeassistant/components/bitcoin/sensor.py | 38 ++++----- .../components/bloomsky/binary_sensor.py | 2 +- homeassistant/components/bloomsky/sensor.py | 2 +- .../components/bluesound/media_player.py | 8 +- homeassistant/components/bom/camera.py | 10 +-- homeassistant/components/bom/sensor.py | 14 ++-- homeassistant/components/bom/weather.py | 2 +- homeassistant/components/broadlink/remote.py | 6 +- homeassistant/components/broadlink/sensor.py | 2 +- homeassistant/components/broadlink/switch.py | 8 +- .../components/brottsplatskartan/sensor.py | 2 +- homeassistant/components/brunt/cover.py | 4 +- homeassistant/components/buienradar/camera.py | 7 +- .../components/buienradar/weather.py | 4 +- homeassistant/components/caldav/calendar.py | 4 +- homeassistant/components/co2signal/sensor.py | 4 +- homeassistant/components/coinbase/sensor.py | 4 +- .../components/configurator/__init__.py | 2 +- homeassistant/components/daikin/sensor.py | 4 +- homeassistant/components/daikin/switch.py | 2 +- homeassistant/components/datadog/__init__.py | 6 +- homeassistant/components/deconz/const.py | 7 -- homeassistant/components/deconz/gateway.py | 11 ++- homeassistant/components/demo/__init__.py | 2 +- homeassistant/components/demo/air_quality.py | 2 +- homeassistant/components/demo/mailbox.py | 2 +- homeassistant/components/demo/media_player.py | 3 +- homeassistant/components/demo/weather.py | 2 +- .../components/deutsche_bahn/sensor.py | 2 +- .../components/device_tracker/const.py | 1 - .../components/device_tracker/legacy.py | 11 +-- .../components/device_tracker/setup.py | 4 +- .../components/directv/media_player.py | 8 +- .../dlib_face_detect/image_processing.py | 2 +- .../dlib_face_identify/image_processing.py | 2 +- homeassistant/components/doorbird/__init__.py | 4 +- homeassistant/components/doorbird/camera.py | 9 +- homeassistant/components/dovado/sensor.py | 2 +- .../components/dte_energy_bridge/sensor.py | 6 +- .../components/dwd_weather_warnings/sensor.py | 7 +- homeassistant/components/dyson/climate.py | 2 +- homeassistant/components/dyson/fan.py | 6 +- homeassistant/components/dyson/sensor.py | 8 +- homeassistant/components/ecobee/sensor.py | 2 +- homeassistant/components/ecovacs/vacuum.py | 4 +- homeassistant/components/efergy/sensor.py | 16 ++-- homeassistant/components/elkm1/__init__.py | 4 +- homeassistant/components/elkm1/sensor.py | 2 +- homeassistant/components/elv/switch.py | 12 +-- homeassistant/components/emoncms/sensor.py | 6 +- .../components/emulated_hue/hue_api.py | 11 +-- homeassistant/components/emulated_hue/upnp.py | 20 ++--- .../components/emulated_roku/config_flow.py | 2 +- homeassistant/components/enocean/sensor.py | 4 +- .../entur_public_transport/sensor.py | 15 ++-- .../components/environment_canada/sensor.py | 2 +- homeassistant/components/esphome/__init__.py | 31 ++++--- .../components/esphome/entry_data.py | 19 ++--- homeassistant/components/esphome/sensor.py | 4 +- homeassistant/components/everlights/light.py | 4 +- .../components/life360/device_tracker.py | 10 +-- .../components/owntracks/device_tracker.py | 4 +- .../device_sun_light_trigger/test_init.py | 10 +-- tests/components/device_tracker/test_init.py | 8 +- tests/components/mqtt/test_device_tracker.py | 83 ++++++------------- .../mqtt_json/test_device_tracker.py | 5 +- 66 files changed, 201 insertions(+), 315 deletions(-) diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py index cf2b76bf25f..a488fa1e2fa 100644 --- a/homeassistant/components/bitcoin/sensor.py +++ b/homeassistant/components/bitcoin/sensor.py @@ -124,45 +124,45 @@ class BitcoinSensor(Entity): self._state = ticker[self._currency].p15min self._unit_of_measurement = self._currency elif self.type == "trade_volume_btc": - self._state = "{0:.1f}".format(stats.trade_volume_btc) + self._state = f"{stats.trade_volume_btc:.1f}" elif self.type == "miners_revenue_usd": - self._state = "{0:.0f}".format(stats.miners_revenue_usd) + self._state = f"{stats.miners_revenue_usd:.0f}" elif self.type == "btc_mined": - self._state = "{}".format(stats.btc_mined * 0.00000001) + self._state = str(stats.btc_mined * 0.00000001) elif self.type == "trade_volume_usd": - self._state = "{0:.1f}".format(stats.trade_volume_usd) + self._state = f"{stats.trade_volume_usd:.1f}" elif self.type == "difficulty": - self._state = "{0:.0f}".format(stats.difficulty) + self._state = f"{stats.difficulty:.0f}" elif self.type == "minutes_between_blocks": - self._state = "{0:.2f}".format(stats.minutes_between_blocks) + self._state = f"{stats.minutes_between_blocks:.2f}" elif self.type == "number_of_transactions": - self._state = "{}".format(stats.number_of_transactions) + self._state = str(stats.number_of_transactions) elif self.type == "hash_rate": - self._state = "{0:.1f}".format(stats.hash_rate * 0.000001) + self._state = f"{stats.hash_rate * 0.000001:.1f}" elif self.type == "timestamp": self._state = stats.timestamp elif self.type == "mined_blocks": - self._state = "{}".format(stats.mined_blocks) + self._state = str(stats.mined_blocks) elif self.type == "blocks_size": - self._state = "{0:.1f}".format(stats.blocks_size) + self._state = f"{stats.blocks_size:.1f}" elif self.type == "total_fees_btc": - self._state = "{0:.2f}".format(stats.total_fees_btc * 0.00000001) + self._state = f"{stats.total_fees_btc * 0.00000001:.2f}" elif self.type == "total_btc_sent": - self._state = "{0:.2f}".format(stats.total_btc_sent * 0.00000001) + self._state = f"{stats.total_btc_sent * 0.00000001:.2f}" elif self.type == "estimated_btc_sent": - self._state = "{0:.2f}".format(stats.estimated_btc_sent * 0.00000001) + self._state = f"{stats.estimated_btc_sent * 0.00000001:.2f}" elif self.type == "total_btc": - self._state = "{0:.2f}".format(stats.total_btc * 0.00000001) + self._state = f"{stats.total_btc * 0.00000001:.2f}" elif self.type == "total_blocks": - self._state = "{0:.0f}".format(stats.total_blocks) + self._state = f"{stats.total_blocks:.0f}" elif self.type == "next_retarget": - self._state = "{0:.2f}".format(stats.next_retarget) + self._state = f"{stats.next_retarget:.2f}" elif self.type == "estimated_transaction_volume_usd": - self._state = "{0:.2f}".format(stats.estimated_transaction_volume_usd) + self._state = f"{stats.estimated_transaction_volume_usd:.2f}" elif self.type == "miners_revenue_btc": - self._state = "{0:.1f}".format(stats.miners_revenue_btc * 0.00000001) + self._state = f"{stats.miners_revenue_btc * 0.00000001:.1f}" elif self.type == "market_price_usd": - self._state = "{0:.2f}".format(stats.market_price_usd) + self._state = f"{stats.market_price_usd:.2f}" class BitcoinData: diff --git a/homeassistant/components/bloomsky/binary_sensor.py b/homeassistant/components/bloomsky/binary_sensor.py index cc6562a0bc1..516fa42cb5c 100644 --- a/homeassistant/components/bloomsky/binary_sensor.py +++ b/homeassistant/components/bloomsky/binary_sensor.py @@ -40,7 +40,7 @@ class BloomSkySensor(BinarySensorDevice): self._bloomsky = bs self._device_id = device["DeviceID"] self._sensor_name = sensor_name - self._name = "{} {}".format(device["DeviceName"], sensor_name) + self._name = f"{device['DeviceName']} {sensor_name}" self._state = None self._unique_id = f"{self._device_id}-{self._sensor_name}" diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py index 84871b7b30e..2ffdb8efab0 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -70,7 +70,7 @@ class BloomSkySensor(Entity): self._bloomsky = bs self._device_id = device["DeviceID"] self._sensor_name = sensor_name - self._name = "{} {}".format(device["DeviceName"], sensor_name) + self._name = f"{device['DeviceName']} {sensor_name}" self._state = None self._unique_id = f"{self._device_id}-{self._sensor_name}" diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py index db5c65eab8b..3ca9cb1f623 100644 --- a/homeassistant/components/bluesound/media_player.py +++ b/homeassistant/components/bluesound/media_player.py @@ -500,7 +500,7 @@ class BluesoundPlayer(MediaPlayerDevice): "image": item.get("@image", ""), "is_raw_url": True, "url2": item.get("@url", ""), - "url": "Preset?id={}".format(item.get("@id", "")), + "url": f"Preset?id={item.get('@id', '')}", } ) @@ -934,9 +934,7 @@ class BluesoundPlayer(MediaPlayerDevice): return selected_source = items[0] - url = "Play?url={}&preset_id&image={}".format( - selected_source["url"], selected_source["image"] - ) + url = f"Play?url={selected_source['url']}&preset_id&image={selected_source['image']}" if "is_raw_url" in selected_source and selected_source["is_raw_url"]: url = selected_source["url"] @@ -1002,7 +1000,7 @@ class BluesoundPlayer(MediaPlayerDevice): if self.is_grouped and not self.is_master: return - return await self.send_bluesound_command("Play?seek={}".format(float(position))) + return await self.send_bluesound_command(f"Play?seek={float(position)}") async def async_play_media(self, media_type, media_id, **kwargs): """ diff --git a/homeassistant/components/bom/camera.py b/homeassistant/components/bom/camera.py index 7460b84f734..3bbd9e39164 100644 --- a/homeassistant/components/bom/camera.py +++ b/homeassistant/components/bom/camera.py @@ -75,16 +75,12 @@ def _validate_schema(config): if config.get(CONF_LOCATION) is None: if not all(config.get(x) for x in (CONF_ID, CONF_DELTA, CONF_FRAMES)): raise vol.Invalid( - "Specify '{}', '{}' and '{}' when '{}' is unspecified".format( - CONF_ID, CONF_DELTA, CONF_FRAMES, CONF_LOCATION - ) + f"Specify '{CONF_ID}', '{CONF_DELTA}' and '{CONF_FRAMES}' when '{CONF_LOCATION}' is unspecified" ) return config -LOCATIONS_MSG = "Set '{}' to one of: {}".format( - CONF_LOCATION, ", ".join(sorted(LOCATIONS)) -) +LOCATIONS_MSG = f"Set '{CONF_LOCATION}' to one of: {', '.join(sorted(LOCATIONS))}" XOR_MSG = f"Specify exactly one of '{CONF_ID}' or '{CONF_LOCATION}'" PLATFORM_SCHEMA = vol.All( @@ -106,7 +102,7 @@ PLATFORM_SCHEMA = vol.All( def setup_platform(hass, config, add_entities, discovery_info=None): """Set up BOM radar-loop camera component.""" - location = config.get(CONF_LOCATION) or "ID {}".format(config.get(CONF_ID)) + location = config.get(CONF_LOCATION) or f"ID {config.get(CONF_ID)}" name = config.get(CONF_NAME) or f"BOM Radar Loop - {location}" args = [ config.get(x) diff --git a/homeassistant/components/bom/sensor.py b/homeassistant/components/bom/sensor.py index 0981f1b0a86..2a38a3e60b0 100644 --- a/homeassistant/components/bom/sensor.py +++ b/homeassistant/components/bom/sensor.py @@ -27,7 +27,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.util.dt as dt_util -_RESOURCE = "http://www.bom.gov.au/fwo/{}/{}.{}.json" _LOGGER = logging.getLogger(__name__) ATTR_LAST_UPDATE = "last_update" @@ -159,9 +158,9 @@ class BOMCurrentSensor(Entity): def name(self): """Return the name of the sensor.""" if self.stationname is None: - return "BOM {}".format(SENSOR_TYPES[self._condition][0]) + return f"BOM {SENSOR_TYPES[self._condition][0]}" - return "BOM {} {}".format(self.stationname, SENSOR_TYPES[self._condition][0]) + return f"BOM {self.stationname} {SENSOR_TYPES[self._condition][0]}" @property def state(self): @@ -203,7 +202,10 @@ class BOMCurrentData: def _build_url(self): """Build the URL for the requests.""" - url = _RESOURCE.format(self._zone_id, self._zone_id, self._wmo_id) + url = ( + f"http://www.bom.gov.au/fwo/{self._zone_id}" + f"/{self._zone_id}.{self._wmo_id}.json" + ) _LOGGER.debug("BOM URL: %s", url) return url @@ -310,10 +312,10 @@ def _get_bom_stations(): r'(?P=zone)\.(?P\d\d\d\d\d).shtml">' ) for state in ("nsw", "vic", "qld", "wa", "tas", "nt"): - url = "http://www.bom.gov.au/{0}/observations/{0}all.shtml".format(state) + url = f"http://www.bom.gov.au/{state}/observations/{state}all.shtml" for zone_id, wmo_id in re.findall(pattern, requests.get(url).text): zones[wmo_id] = zone_id - return {"{}.{}".format(zones[k], k): latlon[k] for k in set(latlon) & set(zones)} + return {f"{zones[k]}.{k}": latlon[k] for k in set(latlon) & set(zones)} def bom_stations(cache_dir): diff --git a/homeassistant/components/bom/weather.py b/homeassistant/components/bom/weather.py index 2513c7c4c40..94b9960c851 100644 --- a/homeassistant/components/bom/weather.py +++ b/homeassistant/components/bom/weather.py @@ -49,7 +49,7 @@ class BOMWeather(WeatherEntity): @property def name(self): """Return the name of the sensor.""" - return "BOM {}".format(self.stationname or "(unknown station)") + return f"BOM {self.stationname or '(unknown station)'}" @property def condition(self): diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index 96698e5b02f..714b5dfec34 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -44,9 +44,7 @@ DEFAULT_TIMEOUT = 5 SCAN_INTERVAL = timedelta(minutes=2) -CODE_STORAGE_KEY = "broadlink_{}_codes" CODE_STORAGE_VERSION = 1 -FLAG_STORAGE_KEY = "broadlink_{}_flags" FLAG_STORAGE_VERSION = 1 FLAG_SAVE_DELAY = 15 @@ -96,8 +94,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= api = broadlink.rm((host, DEFAULT_PORT), mac_addr, None) api.timeout = timeout - code_storage = Store(hass, CODE_STORAGE_VERSION, CODE_STORAGE_KEY.format(unique_id)) - flag_storage = Store(hass, FLAG_STORAGE_VERSION, FLAG_STORAGE_KEY.format(unique_id)) + code_storage = Store(hass, CODE_STORAGE_VERSION, f"broadlink_{unique_id}_codes") + flag_storage = Store(hass, FLAG_STORAGE_VERSION, f"broadlink_{unique_id}_flags") remote = BroadlinkRemote(name, unique_id, api, code_storage, flag_storage) connected, loaded = (False, False) diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index 9f3087335c8..408593e337d 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -67,7 +67,7 @@ class BroadlinkSensor(Entity): def __init__(self, name, broadlink_data, sensor_type): """Initialize the sensor.""" - self._name = "{} {}".format(name, SENSOR_TYPES[sensor_type][0]) + self._name = f"{name} {SENSOR_TYPES[sensor_type][0]}" self._state = None self._is_available = False self._type = sensor_type diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 78738870aaa..9b986ae75d4 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -7,11 +7,7 @@ import socket import broadlink import voluptuous as vol -from homeassistant.components.switch import ( - ENTITY_ID_FORMAT, - PLATFORM_SCHEMA, - SwitchDevice, -) +from homeassistant.components.switch import DOMAIN, PLATFORM_SCHEMA, SwitchDevice from homeassistant.const import ( CONF_COMMAND_OFF, CONF_COMMAND_ON, @@ -159,7 +155,7 @@ class BroadlinkRMSwitch(SwitchDevice, RestoreEntity): self, name, friendly_name, device, command_on, command_off, retry_times ): """Initialize the switch.""" - self.entity_id = ENTITY_ID_FORMAT.format(slugify(name)) + self.entity_id = f"{DOMAIN}.{slugify(name)}" self._name = friendly_name self._state = False self._command_on = command_on diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py index 282433aa7a4..feb066a6f3f 100644 --- a/homeassistant/components/brottsplatskartan/sensor.py +++ b/homeassistant/components/brottsplatskartan/sensor.py @@ -69,7 +69,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): # Every Home Assistant instance should have their own unique # app parameter: https://brottsplatskartan.se/sida/api - app = "ha-{}".format(uuid.getnode()) + app = f"ha-{uuid.getnode()}" bpk = brottsplatskartan.BrottsplatsKartan( app=app, area=area, latitude=latitude, longitude=longitude diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py index 373c3339441..b3a007277c3 100644 --- a/homeassistant/components/brunt/cover.py +++ b/homeassistant/components/brunt/cover.py @@ -56,9 +56,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except (TypeError, KeyError, NameError, ValueError) as ex: _LOGGER.error("%s", ex) hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), + "Error: {ex}
You will need to restart hass after fixing.", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index b41b3220b40..b685bdb5c73 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -17,8 +17,6 @@ CONF_DIMENSION = "dimension" CONF_DELTA = "delta" CONF_COUNTRY = "country_code" -RADAR_MAP_URL_TEMPLATE = "https://api.buienradar.nl/image/1.0/RadarMap{c}?w={w}&h={h}" - _LOG = logging.getLogger(__name__) # Maximum range according to docs @@ -112,8 +110,9 @@ class BuienradarCam(Camera): """Retrieve new radar image and return whether this succeeded.""" session = async_get_clientsession(self.hass) - url = RADAR_MAP_URL_TEMPLATE.format( - c=self._country, w=self._dimension, h=self._dimension + url = ( + f"https://api.buienradar.nl/image/1.0/RadarMap{self._country}" + f"?w={self._dimension}&h={self._dimension}" ) if self._last_modified: diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py index 98cbb2f5e43..32e8babde90 100644 --- a/homeassistant/components/buienradar/weather.py +++ b/homeassistant/components/buienradar/weather.py @@ -113,8 +113,8 @@ class BrWeather(WeatherEntity): @property def name(self): """Return the name of the sensor.""" - return self._stationname or "BR {}".format( - self._data.stationname or "(unknown station)" + return ( + self._stationname or f"BR {self._data.stationname or '(unknown station)'}" ) @property diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 7cdf69a0c33..579755709d1 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -88,9 +88,7 @@ def setup_platform(hass, config, add_entities, disc_info=None): continue name = cust_calendar[CONF_NAME] - device_id = "{} {}".format( - cust_calendar[CONF_CALENDAR], cust_calendar[CONF_NAME] - ) + device_id = f"{cust_calendar[CONF_CALENDAR]} {cust_calendar[CONF_NAME]}" entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) calendar_devices.append( WebDavCalendarEventDevice( diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 7160d140b3f..31a06c94120 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -65,9 +65,7 @@ class CO2Sensor(Entity): if country_code is not None: device_name = country_code else: - device_name = "{lat}/{lon}".format( - lat=round(self._latitude, 2), lon=round(self._longitude, 2) - ) + device_name = f"{round(self._latitude, 2)}/{round(self._longitude, 2)}" self._friendly_name = f"CO2 intensity - {device_name}" diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index 4a3e85d5e43..a13dfef11da 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -75,9 +75,7 @@ class AccountSensor(Entity): """Return the state attributes of the sensor.""" return { ATTR_ATTRIBUTION: ATTRIBUTION, - ATTR_NATIVE_BALANCE: "{} {}".format( - self._native_balance, self._native_currency - ), + ATTR_NATIVE_BALANCE: f"{self._native_balance} {self._native_currency}", } def update(self): diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index 4d79b13355c..e1e6181d8ca 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -237,7 +237,7 @@ class Configurator: def _generate_unique_id(self): """Generate a unique configurator ID.""" self._cur_id += 1 - return "{}-{}".format(id(self), self._cur_id) + return f"{id(self)}-{self._cur_id}" def _validate_request_id(self, request_id): """Validate that the request belongs to this instance.""" diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py index f83566e66e8..e3e2e6a0f27 100644 --- a/homeassistant/components/daikin/sensor.py +++ b/homeassistant/components/daikin/sensor.py @@ -47,9 +47,9 @@ class DaikinClimateSensor(Entity): self._api = api self._sensor = SENSOR_TYPES.get(monitored_state) if name is None: - name = "{} {}".format(self._sensor[CONF_NAME], api.name) + name = f"{self._sensor[CONF_NAME]} {api.name}" - self._name = "{} {}".format(name, monitored_state.replace("_", " ")) + self._name = f"{name} {monitored_state.replace('_', ' ')}" self._device_attribute = monitored_state if self._sensor[CONF_TYPE] == SENSOR_TYPE_TEMPERATURE: diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 4d3b0d3eade..e22c0b04995 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -54,7 +54,7 @@ class DaikinZoneSwitch(ToggleEntity): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format(self._api.name, self._api.device.zones[self._zone_id][0]) + return f"{self._api.name} {self._api.device.zones[self._zone_id][0]}" @property def is_on(self): diff --git a/homeassistant/components/datadog/__init__.py b/homeassistant/components/datadog/__init__.py index adb8bb1f95c..52cbe906402 100644 --- a/homeassistant/components/datadog/__init__.py +++ b/homeassistant/components/datadog/__init__.py @@ -61,8 +61,8 @@ def setup(hass, config): title="Home Assistant", text=f"%%% \n **{name}** {message} \n %%%", tags=[ - "entity:{}".format(event.data.get("entity_id")), - "domain:{}".format(event.data.get("domain")), + f"entity:{event.data.get('entity_id')}", + f"domain:{event.data.get('domain')}", ], ) @@ -84,7 +84,7 @@ def setup(hass, config): for key, value in states.items(): if isinstance(value, (float, int)): - attribute = "{}.{}".format(metric, key.replace(" ", "_")) + attribute = f"{metric}.{key.replace(' ', '_')}" statsd.gauge(attribute, value, sample_rate=sample_rate, tags=tags) _LOGGER.debug("Sent metric %s: %s (tags: %s)", attribute, value, tags) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index 11dbd07e86a..cd125613f21 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -31,13 +31,6 @@ NEW_LIGHT = "lights" NEW_SCENE = "scenes" NEW_SENSOR = "sensors" -NEW_DEVICE = { - NEW_GROUP: "deconz_new_group_{}", - NEW_LIGHT: "deconz_new_light_{}", - NEW_SCENE: "deconz_new_scene_{}", - NEW_SENSOR: "deconz_new_sensor_{}", -} - ATTR_DARK = "dark" ATTR_OFFSET = "offset" ATTR_ON = "on" diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 0b69b82463c..b59c80a0dc8 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -19,8 +19,9 @@ from .const import ( DEFAULT_ALLOW_DECONZ_GROUPS, DOMAIN, LOGGER, - NEW_DEVICE, NEW_GROUP, + NEW_LIGHT, + NEW_SCENE, NEW_SENSOR, SUPPORTED_PLATFORMS, ) @@ -186,7 +187,13 @@ class DeconzGateway: @callback def async_signal_new_device(self, device_type) -> str: """Gateway specific event to signal new device.""" - return NEW_DEVICE[device_type].format(self.bridgeid) + new_device = { + NEW_GROUP: f"deconz_new_group_{self.bridgeid}", + NEW_LIGHT: f"deconz_new_light_{self.bridgeid}", + NEW_SCENE: f"deconz_new_scene_{self.bridgeid}", + NEW_SENSOR: f"deconz_new_sensor_{self.bridgeid}", + } + return new_device[device_type] @property def signal_remove_entity(self) -> str: diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 3ea05ff6ae8..f1e6d3df74f 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -203,7 +203,7 @@ async def finish_setup(hass, config): { "script": { "demo": { - "alias": "Toggle {}".format(lights[0].split(".")[1]), + "alias": f"Toggle {lights[0].split('.')[1]}", "sequence": [ { "service": "light.turn_off", diff --git a/homeassistant/components/demo/air_quality.py b/homeassistant/components/demo/air_quality.py index 9fe0f675d9d..656e22259e1 100644 --- a/homeassistant/components/demo/air_quality.py +++ b/homeassistant/components/demo/air_quality.py @@ -27,7 +27,7 @@ class DemoAirQuality(AirQualityEntity): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format("Demo Air Quality", self._name) + return f"Demo Air Quality {self._name}" @property def should_poll(self): diff --git a/homeassistant/components/demo/mailbox.py b/homeassistant/components/demo/mailbox.py index ce9c5cc0ea6..860524dfd7c 100644 --- a/homeassistant/components/demo/mailbox.py +++ b/homeassistant/components/demo/mailbox.py @@ -26,7 +26,7 @@ class DemoMailbox(Mailbox): txt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " for idx in range(0, 10): msgtime = int(dt.as_timestamp(dt.utcnow()) - 3600 * 24 * (10 - idx)) - msgtxt = "Message {}. {}".format(idx + 1, txt * (1 + idx * (idx % 2))) + msgtxt = f"Message {idx + 1}. {txt * (1 + idx * (idx % 2))}" msgsha = sha1(msgtxt.encode("utf-8")).hexdigest() msg = { "info": { diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py index 33fe4ee3647..7a8f4eb8fbe 100644 --- a/homeassistant/components/demo/media_player.py +++ b/homeassistant/components/demo/media_player.py @@ -48,7 +48,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): await async_setup_platform(hass, {}, async_add_entities) -YOUTUBE_COVER_URL_FORMAT = "https://img.youtube.com/vi/{}/hqdefault.jpg" SOUND_MODE_LIST = ["Dummy Music", "Dummy Movie"] DEFAULT_SOUND_MODE = "Dummy Music" @@ -238,7 +237,7 @@ class DemoYoutubePlayer(AbstractDemoPlayer): @property def media_image_url(self): """Return the image url of current playing media.""" - return YOUTUBE_COVER_URL_FORMAT.format(self.youtube_id) + return f"https://img.youtube.com/vi/{self.youtube_id}/hqdefault.jpg" @property def media_title(self): diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index 8cc1b2f95fd..b17c88fa828 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -106,7 +106,7 @@ class DemoWeather(WeatherEntity): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format("Demo Weather", self._name) + return f"Demo Weather {self._name}" @property def should_poll(self): diff --git a/homeassistant/components/deutsche_bahn/sensor.py b/homeassistant/components/deutsche_bahn/sensor.py index 204518b2ce3..fd7496b1316 100644 --- a/homeassistant/components/deutsche_bahn/sensor.py +++ b/homeassistant/components/deutsche_bahn/sensor.py @@ -82,7 +82,7 @@ class DeutscheBahnSensor(Entity): self.data.update() self._state = self.data.connections[0].get("departure", "Unknown") if self.data.connections[0].get("delay", 0) != 0: - self._state += " + {}".format(self.data.connections[0]["delay"]) + self._state += f" + {self.data.connections[0]['delay']}" class SchieneData: diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index 1778a87b36a..06313deccb6 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -5,7 +5,6 @@ import logging LOGGER = logging.getLogger(__package__) DOMAIN = "device_tracker" -ENTITY_ID_FORMAT = DOMAIN + ".{}" PLATFORM_TYPE_LEGACY = "legacy" PLATFORM_TYPE_ENTITY = "entity_platform" diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 08bbed12519..b4bfd506f27 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -45,7 +45,6 @@ from .const import ( DEFAULT_CONSIDER_HOME, DEFAULT_TRACK_NEW, DOMAIN, - ENTITY_ID_FORMAT, LOGGER, SOURCE_TYPE_GPS, ) @@ -182,7 +181,7 @@ class DeviceTracker: return # Guard from calling see on entity registry entities. - entity_id = ENTITY_ID_FORMAT.format(dev_id) + entity_id = f"{DOMAIN}.{dev_id}" if registry.async_is_registered(entity_id): LOGGER.error( "The see service is not supported for this entity %s", entity_id @@ -308,7 +307,7 @@ class Device(RestoreEntity): ) -> None: """Initialize a device.""" self.hass = hass - self.entity_id = ENTITY_ID_FORMAT.format(dev_id) + self.entity_id = f"{DOMAIN}.{dev_id}" # Timedelta object how long we consider a device home if it is not # detected anymore. @@ -579,5 +578,7 @@ def get_gravatar_for_email(email: str): Async friendly. """ - url = "https://www.gravatar.com/avatar/{}.jpg?s=80&d=wavatar" - return url.format(hashlib.md5(email.encode("utf-8").lower()).hexdigest()) + return ( + f"https://www.gravatar.com/avatar/" + f"{hashlib.md5(email.encode('utf-8').lower()).hexdigest()}.jpg?s=80&d=wavatar" + ) diff --git a/homeassistant/components/device_tracker/setup.py b/homeassistant/components/device_tracker/setup.py index 42751b1a784..595e36ef07c 100644 --- a/homeassistant/components/device_tracker/setup.py +++ b/homeassistant/components/device_tracker/setup.py @@ -109,9 +109,7 @@ async def async_extract_config(hass, config): legacy.append(platform) else: raise ValueError( - "Unable to determine type for {}: {}".format( - platform.name, platform.type - ) + f"Unable to determine type for {platform.name}: {platform.type}" ) return legacy diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index 603d0127fe6..0d593ab9a45 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -97,7 +97,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): elif discovery_info: host = discovery_info.get("host") - name = "DirecTV_{}".format(discovery_info.get("serial", "")) + name = f"DirecTV_{discovery_info.get('serial', '')}" # Attempt to discover additional RVU units _LOGGER.debug("Doing discovery of DirecTV devices on %s", host) @@ -219,9 +219,7 @@ class DirecTvDevice(MediaPlayerDevice): else: # If an error is received then only set to unavailable if # this started at least 1 minute ago. - log_message = "{}: Invalid status {} received".format( - self.entity_id, self._current["status"]["code"] - ) + log_message = f"{self.entity_id}: Invalid status {self._current['status']['code']} received" if self._check_state_available(): _LOGGER.debug(log_message) else: @@ -370,7 +368,7 @@ class DirecTvDevice(MediaPlayerDevice): if self._is_standby: return None - return "{} ({})".format(self._current["callsign"], self._current["major"]) + return f"{self._current['callsign']} ({self._current['major']})" @property def source(self): diff --git a/homeassistant/components/dlib_face_detect/image_processing.py b/homeassistant/components/dlib_face_detect/image_processing.py index 430878ca44f..9e56668eb3e 100644 --- a/homeassistant/components/dlib_face_detect/image_processing.py +++ b/homeassistant/components/dlib_face_detect/image_processing.py @@ -45,7 +45,7 @@ class DlibFaceDetectEntity(ImageProcessingFaceEntity): if name: self._name = name else: - self._name = "Dlib Face {0}".format(split_entity_id(camera_entity)[1]) + self._name = f"Dlib Face {split_entity_id(camera_entity)[1]}" @property def camera_entity(self): diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py index d6fbf106b0c..32c2aa5868c 100644 --- a/homeassistant/components/dlib_face_identify/image_processing.py +++ b/homeassistant/components/dlib_face_identify/image_processing.py @@ -59,7 +59,7 @@ class DlibFaceIdentifyEntity(ImageProcessingFaceEntity): if name: self._name = name else: - self._name = "Dlib Face {0}".format(split_entity_id(camera_entity)[1]) + self._name = f"Dlib Face {split_entity_id(camera_entity)[1]}" self._faces = {} for face_name, face_file in faces.items(): diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py index d82e27f0f9a..049681a4aa6 100644 --- a/homeassistant/components/doorbird/__init__.py +++ b/homeassistant/components/doorbird/__init__.py @@ -66,7 +66,7 @@ def setup(hass, config): custom_url = doorstation_config.get(CONF_CUSTOM_URL) events = doorstation_config.get(CONF_EVENTS) token = doorstation_config.get(CONF_TOKEN) - name = doorstation_config.get(CONF_NAME) or "DoorBird {}".format(index + 1) + name = doorstation_config.get(CONF_NAME) or f"DoorBird {index + 1}" try: device = DoorBird(device_ip, username, password) @@ -297,6 +297,6 @@ class DoorBirdRequestView(HomeAssistantView): hass.bus.async_fire(f"{DOMAIN}_{event}", event_data) - log_entry(hass, "Doorbird {}".format(event), "event was fired.", DOMAIN) + log_entry(hass, f"Doorbird {event}", "event was fired.", DOMAIN) return web.Response(status=200, text="OK") diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index d9a802f071f..4bf3a6e060f 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -12,9 +12,6 @@ import homeassistant.util.dt as dt_util from . import DOMAIN as DOORBIRD_DOMAIN -_CAMERA_LAST_VISITOR = "{} Last Ring" -_CAMERA_LAST_MOTION = "{} Last Motion" -_CAMERA_LIVE = "{} Live" _LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1) _LAST_MOTION_INTERVAL = datetime.timedelta(minutes=1) _LIVE_INTERVAL = datetime.timedelta(seconds=1) @@ -30,18 +27,18 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= [ DoorBirdCamera( device.live_image_url, - _CAMERA_LIVE.format(doorstation.name), + f"{doorstation.name} Live", _LIVE_INTERVAL, device.rtsp_live_video_url, ), DoorBirdCamera( device.history_image_url(1, "doorbell"), - _CAMERA_LAST_VISITOR.format(doorstation.name), + f"{doorstation.name} Last Ring", _LAST_VISITOR_INTERVAL, ), DoorBirdCamera( device.history_image_url(1, "motionsensor"), - _CAMERA_LAST_MOTION.format(doorstation.name), + f"{doorstation.name} Last Motion", _LAST_MOTION_INTERVAL, ), ] diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index ab85c376469..5e3745b27ed 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -85,7 +85,7 @@ class DovadoSensor(Entity): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format(self._data.name, SENSORS[self._sensor][1]) + return f"{self._data.name} {SENSORS[self._sensor][1]}" @property def state(self): diff --git a/homeassistant/components/dte_energy_bridge/sensor.py b/homeassistant/components/dte_energy_bridge/sensor.py index aa822da0d6a..826f9cf5acb 100644 --- a/homeassistant/components/dte_energy_bridge/sensor.py +++ b/homeassistant/components/dte_energy_bridge/sensor.py @@ -47,11 +47,9 @@ class DteEnergyBridgeSensor(Entity): self._version = version if self._version == 1: - url_template = "http://{}/instantaneousdemand" + self._url = f"http://{ip_address}/instantaneousdemand" elif self._version == 2: - url_template = "http://{}:8888/zigbee/se/instantaneousdemand" - - self._url = url_template.format(ip_address) + self._url = f"http://{ip_address}:8888/zigbee/se/instantaneousdemand" self._name = name self._unit_of_measurement = "kW" diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index af358343d8b..966ec407ce8 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -175,12 +175,7 @@ class DwdWeatherWarningsAPI: def __init__(self, region_name): """Initialize the data object.""" - resource = "{}{}{}?{}".format( - "https://", - "www.dwd.de", - "/DWD/warnungen/warnapp_landkreise/json/warnings.json", - "jsonp=loadWarnings", - ) + resource = "https://www.dwd.de/DWD/warnungen/warnapp_landkreise/json/warnings.json?jsonp=loadWarnings" # a User-Agent is necessary for this rest api endpoint (#29496) headers = {"User-Agent": HA_USER_AGENT} diff --git a/homeassistant/components/dyson/climate.py b/homeassistant/components/dyson/climate.py index df97358d550..f4e23b01622 100644 --- a/homeassistant/components/dyson/climate.py +++ b/homeassistant/components/dyson/climate.py @@ -89,7 +89,7 @@ class DysonPureHotCoolLinkDevice(ClimateDevice): if self._device.environmental_state: temperature_kelvin = self._device.environmental_state.temperature if temperature_kelvin != 0: - self._current_temp = float("{0:.1f}".format(temperature_kelvin - 273)) + self._current_temp = float(f"{(temperature_kelvin - 273):.1f}") return self._current_temp @property diff --git a/homeassistant/components/dyson/fan.py b/homeassistant/components/dyson/fan.py index 4ec23921c03..8613ab3e7af 100644 --- a/homeassistant/components/dyson/fan.py +++ b/homeassistant/components/dyson/fan.py @@ -216,7 +216,7 @@ class DysonPureCoolLinkDevice(FanEntity): if speed == FanSpeed.FAN_SPEED_AUTO.value: self._device.set_configuration(fan_mode=FanMode.AUTO) else: - fan_speed = FanSpeed("{0:04d}".format(int(speed))) + fan_speed = FanSpeed(f"{int(speed):04d}") self._device.set_configuration(fan_mode=FanMode.FAN, fan_speed=fan_speed) def turn_on(self, speed: str = None, **kwargs) -> None: @@ -226,7 +226,7 @@ class DysonPureCoolLinkDevice(FanEntity): if speed == FanSpeed.FAN_SPEED_AUTO.value: self._device.set_configuration(fan_mode=FanMode.AUTO) else: - fan_speed = FanSpeed("{0:04d}".format(int(speed))) + fan_speed = FanSpeed(f"{int(speed):04d}") self._device.set_configuration( fan_mode=FanMode.FAN, fan_speed=fan_speed ) @@ -386,7 +386,7 @@ class DysonPureCoolDevice(FanEntity): """Set the exact speed of the purecool fan.""" _LOGGER.debug("Set exact speed for fan %s", self.name) - fan_speed = FanSpeed("{0:04d}".format(int(speed))) + fan_speed = FanSpeed(f"{int(speed):04d}") self._device.set_fan_speed(fan_speed) def oscillate(self, oscillating: bool) -> None: diff --git a/homeassistant/components/dyson/sensor.py b/homeassistant/components/dyson/sensor.py index 870086e7550..c7f61422a2e 100644 --- a/homeassistant/components/dyson/sensor.py +++ b/homeassistant/components/dyson/sensor.py @@ -43,9 +43,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): device_ids = [device.unique_id for device in hass.data[DYSON_SENSOR_DEVICES]] for device in hass.data[DYSON_DEVICES]: if isinstance(device, DysonPureCool): - if "{}-{}".format(device.serial, "temperature") not in device_ids: + if f"{device.serial}-temperature" not in device_ids: devices.append(DysonTemperatureSensor(device, unit)) - if "{}-{}".format(device.serial, "humidity") not in device_ids: + if f"{device.serial}-humidity" not in device_ids: devices.append(DysonHumiditySensor(device)) elif isinstance(device, DysonPureCoolLink): devices.append(DysonFilterLifeSensor(device)) @@ -173,8 +173,8 @@ class DysonTemperatureSensor(DysonSensor): if temperature_kelvin == 0: return STATE_OFF if self._unit == TEMP_CELSIUS: - return float("{0:.1f}".format(temperature_kelvin - 273.15)) - return float("{0:.1f}".format(temperature_kelvin * 9 / 5 - 459.67)) + return float(f"{(temperature_kelvin - 273.15):.1f}") + return float(f"{(temperature_kelvin * 9 / 5 - 459.67):.1f}") return None @property diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index c2c34d148e3..ca3e7732e1b 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -37,7 +37,7 @@ class EcobeeSensor(Entity): def __init__(self, data, sensor_name, sensor_type, sensor_index): """Initialize the sensor.""" self.data = data - self._name = "{} {}".format(sensor_name, SENSOR_TYPES[sensor_type][0]) + self._name = f"{sensor_name} {SENSOR_TYPES[sensor_type][0]}" self.sensor_name = sensor_name self.type = sensor_type self.index = sensor_index diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py index a74fdaa21ba..806c0b41285 100644 --- a/homeassistant/components/ecovacs/vacuum.py +++ b/homeassistant/components/ecovacs/vacuum.py @@ -56,10 +56,10 @@ class EcovacsVacuum(VacuumDevice): self.device = device self.device.connect_and_wait_until_ready() if self.device.vacuum.get("nick", None) is not None: - self._name = "{}".format(self.device.vacuum["nick"]) + self._name = str(self.device.vacuum["nick"]) else: # In case there is no nickname defined, use the device id - self._name = "{}".format(self.device.vacuum["did"]) + self._name = str(format(self.device.vacuum["did"])) self._fan_speed = None self._error = None diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index 3be962fea2f..8c16317beda 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -63,9 +63,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev = [] for variable in config[CONF_MONITORED_VARIABLES]: if variable[CONF_SENSOR_TYPE] == CONF_CURRENT_VALUES: - url_string = "{}getCurrentValuesSummary?token={}".format( - _RESOURCE, app_token - ) + url_string = f"{_RESOURCE}getCurrentValuesSummary?token={app_token}" response = requests.get(url_string, timeout=10) for sensor in response.json(): sid = sensor["sid"] @@ -136,9 +134,7 @@ class EfergySensor(Entity): response = requests.get(url_string, timeout=10) self._state = response.json()["reading"] elif self.type == "amount": - url_string = "{}getEnergy?token={}&offset={}&period={}".format( - _RESOURCE, self.app_token, self.utc_offset, self.period - ) + url_string = f"{_RESOURCE}getEnergy?token={self.app_token}&offset={self.utc_offset}&period={self.period}" response = requests.get(url_string, timeout=10) self._state = response.json()["sum"] elif self.type == "budget": @@ -146,14 +142,12 @@ class EfergySensor(Entity): response = requests.get(url_string, timeout=10) self._state = response.json()["status"] elif self.type == "cost": - url_string = "{}getCost?token={}&offset={}&period={}".format( - _RESOURCE, self.app_token, self.utc_offset, self.period - ) + url_string = f"{_RESOURCE}getCost?token={self.app_token}&offset={self.utc_offset}&period={self.period}" response = requests.get(url_string, timeout=10) self._state = response.json()["sum"] elif self.type == "current_values": - url_string = "{}getCurrentValuesSummary?token={}".format( - _RESOURCE, self.app_token + url_string = ( + f"{_RESOURCE}getCurrentValuesSummary?token={self.app_token}" ) response = requests.get(url_string, timeout=10) for sensor in response.json(): diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 67b84c4f3bf..2acb8030cf1 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -257,9 +257,7 @@ class ElkEntity(Entity): uid_start = f"elkm1m_{self._prefix}" else: uid_start = "elkm1" - self._unique_id = "{uid_start}_{name}".format( - uid_start=uid_start, name=self._element.default_name("_") - ).lower() + self._unique_id = f"{uid_start}_{self._element.default_name('_')}".lower() @property def name(self): diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 3ed5356f4de..df29e1cda7e 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -178,7 +178,7 @@ class ElkZone(ElkSensor): ZoneType.PHONE_KEY.value: "phone-classic", ZoneType.INTERCOM_KEY.value: "deskphone", } - return "mdi:{}".format(zone_icons.get(self._element.definition, "alarm-bell")) + return f"mdi:{zone_icons.get(self._element.definition, 'alarm-bell')}" @property def device_state_attributes(self): diff --git a/homeassistant/components/elv/switch.py b/homeassistant/components/elv/switch.py index a77d21cf173..d867d286f50 100644 --- a/homeassistant/components/elv/switch.py +++ b/homeassistant/components/elv/switch.py @@ -81,12 +81,12 @@ class SmartPlugSwitch(SwitchDevice): def update(self): """Update the PCA switch's state.""" try: - self._emeter_params[ATTR_CURRENT_POWER_W] = "{:.1f}".format( - self._pca.get_current_power(self._device_id) - ) - self._emeter_params[ATTR_TOTAL_ENERGY_KWH] = "{:.2f}".format( - self._pca.get_total_consumption(self._device_id) - ) + self._emeter_params[ + ATTR_CURRENT_POWER_W + ] = f"{self._pca.get_current_power(self._device_id):.1f}" + self._emeter_params[ + ATTR_TOTAL_ENERGY_KWH + ] = f"{self._pca.get_total_consumption(self._device_id):.2f}" self._available = True self._state = self._pca.get_state(self._device_id) diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index f956d3a7295..4f214d697f3 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -64,9 +64,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( def get_id(sensorid, feedtag, feedname, feedid, feeduserid): """Return unique identifier for feed / sensor.""" - return "emoncms{}_{}_{}_{}_{}".format( - sensorid, feedtag, feedname, feedid, feeduserid - ) + return f"emoncms{sensorid}_{feedtag}_{feedname}_{feedid}_{feeduserid}" def setup_platform(hass, config, add_entities, discovery_info=None): @@ -134,7 +132,7 @@ class EmonCmsSensor(Entity): # ID if there's only one. id_for_name = "" if str(sensorid) == "1" else sensorid # Use the feed name assigned in EmonCMS or fall back to the feed ID - feed_name = elem.get("name") or "Feed {}".format(elem["id"]) + feed_name = elem.get("name") or f"Feed {elem['id']}" self._name = f"EmonCMS{id_for_name} {feed_name}" else: self._name = name diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index e9e7114074a..9a2d624a55f 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -617,16 +617,7 @@ def entity_to_json(config, entity): """Convert an entity to its Hue bridge JSON representation.""" entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) unique_id = hashlib.md5(entity.entity_id.encode()).hexdigest() - unique_id = "00:{}:{}:{}:{}:{}:{}:{}-{}".format( - unique_id[0:2], - unique_id[2:4], - unique_id[4:6], - unique_id[6:8], - unique_id[8:10], - unique_id[10:12], - unique_id[12:14], - unique_id[14:16], - ) + unique_id = f"00:{unique_id[0:2]}:{unique_id[2:4]}:{unique_id[4:6]}:{unique_id[6:8]}:{unique_id[8:10]}:{unique_id[10:12]}:{unique_id[12:14]}-{unique_id[14:16]}" state = get_entity_state(config, entity) diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py index da9b4e23fe2..0ee336de670 100644 --- a/homeassistant/components/emulated_hue/upnp.py +++ b/homeassistant/components/emulated_hue/upnp.py @@ -26,16 +26,16 @@ class DescriptionXmlView(HomeAssistantView): @core.callback def get(self, request): """Handle a GET request.""" - xml_template = """ + resp_text = f""" 1 0 -http://{0}:{1}/ +http://{self.config.advertise_ip}:{self.config.advertise_port}/ urn:schemas-upnp-org:device:Basic:1 -Home Assistant Bridge ({0}) +Home Assistant Bridge ({self.config.advertise_ip}) Royal Philips Electronics http://www.philips.com Philips hue Personal Wireless Lighting @@ -48,10 +48,6 @@ class DescriptionXmlView(HomeAssistantView): """ - resp_text = xml_template.format( - self.config.advertise_ip, self.config.advertise_port - ) - return web.Response(text=resp_text, content_type="text/xml") @@ -77,10 +73,10 @@ class UPNPResponderThread(threading.Thread): # Note that the double newline at the end of # this string is required per the SSDP spec - resp_template = """HTTP/1.1 200 OK + resp_template = f"""HTTP/1.1 200 OK CACHE-CONTROL: max-age=60 EXT: -LOCATION: http://{0}:{1}/description.xml +LOCATION: http://{advertise_ip}:{advertise_port}/description.xml SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/0.1 hue-bridgeid: 1234 ST: urn:schemas-upnp-org:device:basic:1 @@ -88,11 +84,7 @@ USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1 """ - self.upnp_response = ( - resp_template.format(advertise_ip, advertise_port) - .replace("\n", "\r\n") - .encode("utf-8") - ) + self.upnp_response = resp_template.replace("\n", "\r\n").encode("utf-8") def run(self): """Run the server.""" diff --git a/homeassistant/components/emulated_roku/config_flow.py b/homeassistant/components/emulated_roku/config_flow.py index 0a6d54693ef..3e363e060c2 100644 --- a/homeassistant/components/emulated_roku/config_flow.py +++ b/homeassistant/components/emulated_roku/config_flow.py @@ -38,7 +38,7 @@ class EmulatedRokuFlowHandler(config_entries.ConfigFlow): servers_num = len(configured_servers(self.hass)) if servers_num: - default_name = "{} {}".format(DEFAULT_NAME, servers_num + 1) + default_name = f"{DEFAULT_NAME} {servers_num + 1}" default_port = DEFAULT_PORT + servers_num else: default_name = DEFAULT_NAME diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 59ca10da791..5cf908a33a1 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -111,9 +111,7 @@ class EnOceanSensor(enocean.EnOceanDevice): super().__init__(dev_id, dev_name) self._sensor_type = sensor_type self._device_class = SENSOR_TYPES[self._sensor_type]["class"] - self._dev_name = "{} {}".format( - SENSOR_TYPES[self._sensor_type]["name"], dev_name - ) + self._dev_name = f"{SENSOR_TYPES[self._sensor_type]['name']} {dev_name}" self._unit_of_measurement = SENSOR_TYPES[self._sensor_type]["unit"] self._icon = SENSOR_TYPES[self._sensor_type]["icon"] self._state = None diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py index 156a0e601b4..0425accd06b 100644 --- a/homeassistant/components/entur_public_transport/sensor.py +++ b/homeassistant/components/entur_public_transport/sensor.py @@ -120,7 +120,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= entities = [] for place in data.all_stop_places_quays(): try: - given_name = "{} {}".format(name, data.get_stop_info(place).name) + given_name = f"{name} {data.get_stop_info(place).name}" except KeyError: given_name = f"{name} {place}" @@ -231,9 +231,9 @@ class EnturPublicTransportSensor(Entity): self._attributes[ATTR_NEXT_UP_AT] = calls[1].expected_departure_time.strftime( "%H:%M" ) - self._attributes[ATTR_NEXT_UP_IN] = "{} min".format( - due_in_minutes(calls[1].expected_departure_time) - ) + self._attributes[ + ATTR_NEXT_UP_IN + ] = f"{due_in_minutes(calls[1].expected_departure_time)} min" self._attributes[ATTR_NEXT_UP_REALTIME] = calls[1].is_realtime self._attributes[ATTR_NEXT_UP_DELAY] = calls[1].delay_in_min @@ -242,8 +242,7 @@ class EnturPublicTransportSensor(Entity): for i, call in enumerate(calls[2:]): key_name = "departure_#" + str(i + 3) - self._attributes[key_name] = "{}{} {}".format( - "" if bool(call.is_realtime) else "ca. ", - call.expected_departure_time.strftime("%H:%M"), - call.front_display, + self._attributes[key_name] = ( + f"{'' if bool(call.is_realtime) else 'ca. '}" + f"{call.expected_departure_time.strftime('%H:%M')} {call.front_display}" ) diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index f32de07e4fb..601a7f2ba36 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -113,7 +113,7 @@ class ECSensor(Entity): metadata = self.ec_data.metadata sensor_data = conditions.get(self.sensor_type) - self._unique_id = "{}-{}".format(metadata["location"], self.sensor_type) + self._unique_id = f"{metadata['location']}-{self.sensor_type}" self._attr = {} self._name = sensor_data.get("label") value = sensor_data.get("value") diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index cabba95ea7e..9fbe3eff822 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -39,20 +39,11 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType # Import config flow so that it's added to the registry from .config_flow import EsphomeFlowHandler # noqa: F401 -from .entry_data import ( - DATA_KEY, - DISPATCHER_ON_DEVICE_UPDATE, - DISPATCHER_ON_LIST, - DISPATCHER_ON_STATE, - DISPATCHER_REMOVE_ENTITY, - DISPATCHER_UPDATE_ENTITY, - RuntimeEntryData, -) +from .entry_data import DATA_KEY, RuntimeEntryData DOMAIN = "esphome" _LOGGER = logging.getLogger(__name__) -STORAGE_KEY = "esphome.{}" STORAGE_VERSION = 1 # No config schema - only configuration entry @@ -85,7 +76,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool # Store client in per-config-entry hass.data store = Store( - hass, STORAGE_VERSION, STORAGE_KEY.format(entry.entry_id), encoder=JSONEncoder + hass, STORAGE_VERSION, f"esphome.{entry.entry_id}", encoder=JSONEncoder ) entry_data = hass.data[DATA_KEY][entry.entry_id] = RuntimeEntryData( client=cli, entry_id=entry.entry_id, store=store @@ -403,7 +394,7 @@ async def platform_async_setup_entry( # Add entities to Home Assistant async_add_entities(add_entities) - signal = DISPATCHER_ON_LIST.format(entry_id=entry.entry_id) + signal = f"esphome_{entry.entry_id}_on_list" entry_data.cleanup_callbacks.append( async_dispatcher_connect(hass, signal, async_list_entities) ) @@ -416,7 +407,7 @@ async def platform_async_setup_entry( entry_data.state[component_key][state.key] = state entry_data.async_update_entity(hass, component_key, state.key) - signal = DISPATCHER_ON_STATE.format(entry_id=entry.entry_id) + signal = f"esphome_{entry.entry_id}_on_state" entry_data.cleanup_callbacks.append( async_dispatcher_connect(hass, signal, async_entity_state) ) @@ -490,21 +481,29 @@ class EsphomeEntity(Entity): self._remove_callbacks.append( async_dispatcher_connect( self.hass, - DISPATCHER_UPDATE_ENTITY.format(**kwargs), + ( + f"esphome_{kwargs.get('entry_id')}" + f"_update_{kwargs.get('component_key')}_{kwargs.get('key')}" + ), self._on_state_update, ) ) self._remove_callbacks.append( async_dispatcher_connect( - self.hass, DISPATCHER_REMOVE_ENTITY.format(**kwargs), self.async_remove + self.hass, + ( + f"esphome_{kwargs.get('entry_id')}_remove_" + f"{kwargs.get('component_key')}_{kwargs.get('key')}" + ), + self.async_remove, ) ) self._remove_callbacks.append( async_dispatcher_connect( self.hass, - DISPATCHER_ON_DEVICE_UPDATE.format(**kwargs), + f"esphome_{kwargs.get('entry_id')}_on_device_update", self._on_device_update, ) ) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index c56760e952f..d8453c974f6 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -27,11 +27,6 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import HomeAssistantType DATA_KEY = "esphome" -DISPATCHER_UPDATE_ENTITY = "esphome_{entry_id}_update_{component_key}_{key}" -DISPATCHER_REMOVE_ENTITY = "esphome_{entry_id}_remove_{component_key}_{key}" -DISPATCHER_ON_LIST = "esphome_{entry_id}_on_list" -DISPATCHER_ON_DEVICE_UPDATE = "esphome_{entry_id}_on_device_update" -DISPATCHER_ON_STATE = "esphome_{entry_id}_on_state" # Mapping from ESPHome info type to HA platform INFO_TYPE_TO_PLATFORM = { @@ -77,9 +72,7 @@ class RuntimeEntryData: self, hass: HomeAssistantType, component_key: str, key: int ) -> None: """Schedule the update of an entity.""" - signal = DISPATCHER_UPDATE_ENTITY.format( - entry_id=self.entry_id, component_key=component_key, key=key - ) + signal = f"esphome_{self.entry_id}_update_{component_key}_{key}" async_dispatcher_send(hass, signal) @callback @@ -87,9 +80,7 @@ class RuntimeEntryData: self, hass: HomeAssistantType, component_key: str, key: int ) -> None: """Schedule the removal of an entity.""" - signal = DISPATCHER_REMOVE_ENTITY.format( - entry_id=self.entry_id, component_key=component_key, key=key - ) + signal = f"esphome_{self.entry_id}_remove_{component_key}_{key}" async_dispatcher_send(hass, signal) async def _ensure_platforms_loaded( @@ -120,19 +111,19 @@ class RuntimeEntryData: await self._ensure_platforms_loaded(hass, entry, needed_platforms) # Then send dispatcher event - signal = DISPATCHER_ON_LIST.format(entry_id=self.entry_id) + signal = f"esphome_{self.entry_id}_on_list" async_dispatcher_send(hass, signal, infos) @callback def async_update_state(self, hass: HomeAssistantType, state: EntityState) -> None: """Distribute an update of state information to all platforms.""" - signal = DISPATCHER_ON_STATE.format(entry_id=self.entry_id) + signal = f"esphome_{self.entry_id}_on_state" async_dispatcher_send(hass, signal, state) @callback def async_update_device_state(self, hass: HomeAssistantType) -> None: """Distribute an update of a core device state like availability.""" - signal = DISPATCHER_ON_DEVICE_UPDATE.format(entry_id=self.entry_id) + signal = f"esphome_{self.entry_id}_on_device_update" async_dispatcher_send(hass, signal) async def async_load_from_store(self) -> Tuple[List[EntityInfo], List[UserService]]: diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index e50991af6c1..0856f270710 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -69,9 +69,7 @@ class EsphomeSensor(EsphomeEntity): return None if self._state.missing_state: return None - return "{:.{prec}f}".format( - self._state.state, prec=self._static_info.accuracy_decimals - ) + return f"{self._state.state:.{self._static_info.accuracy_decimals}f}" @property def unit_of_measurement(self) -> str: diff --git a/homeassistant/components/everlights/light.py b/homeassistant/components/everlights/light.py index f7fa9deffa0..da9d5b88ae0 100644 --- a/homeassistant/components/everlights/light.py +++ b/homeassistant/components/everlights/light.py @@ -32,8 +32,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_HOSTS): vol.All(cv.ensure_list, [cv.string])} ) -NAME_FORMAT = "EverLights {} Zone {}" - def color_rgb_to_int(red: int, green: int, blue: int) -> int: """Return a RGB color as an integer.""" @@ -96,7 +94,7 @@ class EverLightsLight(Light): @property def name(self): """Return the name of the device.""" - return NAME_FORMAT.format(self._mac, self._channel) + return f"EverLights {self._mac} Zone {self._channel}" @property def is_on(self): diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py index b7b0415a1b3..6f4255735e0 100644 --- a/homeassistant/components/life360/device_tracker.py +++ b/homeassistant/components/life360/device_tracker.py @@ -5,9 +5,9 @@ import logging from life360 import Life360Error import voluptuous as vol -from homeassistant.components.device_tracker import CONF_SCAN_INTERVAL -from homeassistant.components.device_tracker.const import ( - ENTITY_ID_FORMAT as DT_ENTITY_ID_FORMAT, +from homeassistant.components.device_tracker import ( + CONF_SCAN_INTERVAL, + DOMAIN as DEVICE_TRACKER_DOMAIN, ) from homeassistant.components.zone import async_active_zone from homeassistant.const import ( @@ -180,14 +180,14 @@ class Life360Scanner: if overdue and not reported and now - self._started > EVENT_DELAY: self._hass.bus.fire( EVENT_UPDATE_OVERDUE, - {ATTR_ENTITY_ID: DT_ENTITY_ID_FORMAT.format(dev_id)}, + {ATTR_ENTITY_ID: f"{DEVICE_TRACKER_DOMAIN}.{dev_id}"}, ) reported = True elif not overdue and reported: self._hass.bus.fire( EVENT_UPDATE_RESTORED, { - ATTR_ENTITY_ID: DT_ENTITY_ID_FORMAT.format(dev_id), + ATTR_ENTITY_ID: f"{DEVICE_TRACKER_DOMAIN}.{dev_id}", ATTR_WAIT: str(last_seen - (prev_seen or self._started)).split( "." )[0], diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 00fa023d6c1..ed94ef0fa14 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -4,7 +4,7 @@ import logging from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.components.device_tracker.const import ( ATTR_SOURCE_TYPE, - ENTITY_ID_FORMAT, + DOMAIN, SOURCE_TYPE_GPS, ) from homeassistant.const import ( @@ -68,7 +68,7 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity): """Set up OwnTracks entity.""" self._dev_id = dev_id self._data = data or {} - self.entity_id = ENTITY_ID_FORMAT.format(dev_id) + self.entity_id = f"{DOMAIN}.{dev_id}" @property def unique_id(self): diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index c8d0e334412..bc4d44e1b42 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -11,9 +11,7 @@ from homeassistant.components import ( group, light, ) -from homeassistant.components.device_tracker.const import ( - ENTITY_ID_FORMAT as DT_ENTITY_ID_FORMAT, -) +from homeassistant.components.device_tracker.const import DOMAIN from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util @@ -122,7 +120,7 @@ async def test_lights_turn_on_when_coming_home_after_sun_set(hass, scanner): hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} ) - hass.states.async_set(DT_ENTITY_ID_FORMAT.format("device_2"), STATE_HOME) + hass.states.async_set(f"{DOMAIN}.device_2", STATE_HOME) await hass.async_block_till_done() @@ -133,8 +131,8 @@ async def test_lights_turn_on_when_coming_home_after_sun_set(hass, scanner): async def test_lights_turn_on_when_coming_home_after_sun_set_person(hass, scanner): """Test lights turn on when coming home after sun set.""" - device_1 = DT_ENTITY_ID_FORMAT.format("device_1") - device_2 = DT_ENTITY_ID_FORMAT.format("device_2") + device_1 = f"{DOMAIN}.device_1" + device_2 = f"{DOMAIN}.device_2" test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.utcnow", return_value=test_time): diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 4d82f93a029..3ad9e741aae 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -57,7 +57,7 @@ def mock_yaml_devices(hass): async def test_is_on(hass): """Test is_on method.""" - entity_id = const.ENTITY_ID_FORMAT.format("test") + entity_id = f"{const.DOMAIN}.test" hass.states.async_set(entity_id, STATE_HOME) @@ -271,7 +271,7 @@ async def test_entity_attributes(hass, mock_device_tracker_conf): """Test the entity attributes.""" devices = mock_device_tracker_conf dev_id = "test_entity" - entity_id = const.ENTITY_ID_FORMAT.format(dev_id) + entity_id = f"{const.DOMAIN}.{dev_id}" friendly_name = "Paulus" picture = "http://placehold.it/200x200" icon = "mdi:kettle" @@ -303,7 +303,7 @@ async def test_device_hidden(hass, mock_device_tracker_conf): """Test hidden devices.""" devices = mock_device_tracker_conf dev_id = "test_entity" - entity_id = const.ENTITY_ID_FORMAT.format(dev_id) + entity_id = f"{const.DOMAIN}.{dev_id}" device = legacy.Device( hass, timedelta(seconds=180), True, dev_id, None, hide_if_away=True ) @@ -350,7 +350,7 @@ async def test_see_service_guard_config_entry(hass, mock_device_tracker_conf): """Test the guard if the device is registered in the entity registry.""" mock_entry = Mock() dev_id = "test" - entity_id = const.ENTITY_ID_FORMAT.format(dev_id) + entity_id = f"{const.DOMAIN}.{dev_id}" mock_registry(hass, {entity_id: mock_entry}) devices = mock_device_tracker_conf assert await async_setup_component(hass, device_tracker.DOMAIN, TEST_PLATFORM) diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index f4324bd8634..aa6d3efc828 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -2,11 +2,7 @@ from asynctest import patch import pytest -from homeassistant.components import device_tracker -from homeassistant.components.device_tracker.const import ( - ENTITY_ID_FORMAT, - SOURCE_TYPE_BLUETOOTH, -) +from homeassistant.components.device_tracker.const import DOMAIN, SOURCE_TYPE_BLUETOOTH from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME from homeassistant.setup import async_setup_component @@ -35,14 +31,7 @@ async def test_ensure_device_tracker_platform_validation(hass): dev_id = "paulus" topic = "/location/paulus" assert await async_setup_component( - hass, - device_tracker.DOMAIN, - { - device_tracker.DOMAIN: { - CONF_PLATFORM: "mqtt", - "devices": {dev_id: topic}, - } - }, + hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: topic}}} ) assert mock_sp.call_count == 1 @@ -50,15 +39,13 @@ async def test_ensure_device_tracker_platform_validation(hass): async def test_new_message(hass, mock_device_tracker_conf): """Test new message.""" dev_id = "paulus" - entity_id = ENTITY_ID_FORMAT.format(dev_id) + entity_id = f"{DOMAIN}.{dev_id}" topic = "/location/paulus" location = "work" hass.config.components = set(["mqtt", "zone"]) assert await async_setup_component( - hass, - device_tracker.DOMAIN, - {device_tracker.DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: topic}}}, + hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: topic}}} ) async_fire_mqtt_message(hass, topic, location) await hass.async_block_till_done() @@ -68,7 +55,7 @@ async def test_new_message(hass, mock_device_tracker_conf): async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf): """Test single level wildcard topic.""" dev_id = "paulus" - entity_id = ENTITY_ID_FORMAT.format(dev_id) + entity_id = f"{DOMAIN}.{dev_id}" subscription = "/location/+/paulus" topic = "/location/room/paulus" location = "work" @@ -76,13 +63,8 @@ async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf): hass.config.components = set(["mqtt", "zone"]) assert await async_setup_component( hass, - device_tracker.DOMAIN, - { - device_tracker.DOMAIN: { - CONF_PLATFORM: "mqtt", - "devices": {dev_id: subscription}, - } - }, + DOMAIN, + {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: subscription}}}, ) async_fire_mqtt_message(hass, topic, location) await hass.async_block_till_done() @@ -92,7 +74,7 @@ async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf): async def test_multi_level_wildcard_topic(hass, mock_device_tracker_conf): """Test multi level wildcard topic.""" dev_id = "paulus" - entity_id = ENTITY_ID_FORMAT.format(dev_id) + entity_id = f"{DOMAIN}.{dev_id}" subscription = "/location/#" topic = "/location/room/paulus" location = "work" @@ -100,13 +82,8 @@ async def test_multi_level_wildcard_topic(hass, mock_device_tracker_conf): hass.config.components = set(["mqtt", "zone"]) assert await async_setup_component( hass, - device_tracker.DOMAIN, - { - device_tracker.DOMAIN: { - CONF_PLATFORM: "mqtt", - "devices": {dev_id: subscription}, - } - }, + DOMAIN, + {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: subscription}}}, ) async_fire_mqtt_message(hass, topic, location) await hass.async_block_till_done() @@ -116,7 +93,7 @@ async def test_multi_level_wildcard_topic(hass, mock_device_tracker_conf): async def test_single_level_wildcard_topic_not_matching(hass, mock_device_tracker_conf): """Test not matching single level wildcard topic.""" dev_id = "paulus" - entity_id = ENTITY_ID_FORMAT.format(dev_id) + entity_id = f"{DOMAIN}.{dev_id}" subscription = "/location/+/paulus" topic = "/location/paulus" location = "work" @@ -124,13 +101,8 @@ async def test_single_level_wildcard_topic_not_matching(hass, mock_device_tracke hass.config.components = set(["mqtt", "zone"]) assert await async_setup_component( hass, - device_tracker.DOMAIN, - { - device_tracker.DOMAIN: { - CONF_PLATFORM: "mqtt", - "devices": {dev_id: subscription}, - } - }, + DOMAIN, + {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: subscription}}}, ) async_fire_mqtt_message(hass, topic, location) await hass.async_block_till_done() @@ -140,7 +112,7 @@ async def test_single_level_wildcard_topic_not_matching(hass, mock_device_tracke async def test_multi_level_wildcard_topic_not_matching(hass, mock_device_tracker_conf): """Test not matching multi level wildcard topic.""" dev_id = "paulus" - entity_id = ENTITY_ID_FORMAT.format(dev_id) + entity_id = f"{DOMAIN}.{dev_id}" subscription = "/location/#" topic = "/somewhere/room/paulus" location = "work" @@ -148,13 +120,8 @@ async def test_multi_level_wildcard_topic_not_matching(hass, mock_device_tracker hass.config.components = set(["mqtt", "zone"]) assert await async_setup_component( hass, - device_tracker.DOMAIN, - { - device_tracker.DOMAIN: { - CONF_PLATFORM: "mqtt", - "devices": {dev_id: subscription}, - } - }, + DOMAIN, + {DOMAIN: {CONF_PLATFORM: "mqtt", "devices": {dev_id: subscription}}}, ) async_fire_mqtt_message(hass, topic, location) await hass.async_block_till_done() @@ -166,7 +133,7 @@ async def test_matching_custom_payload_for_home_and_not_home( ): """Test custom payload_home sets state to home and custom payload_not_home sets state to not_home.""" dev_id = "paulus" - entity_id = ENTITY_ID_FORMAT.format(dev_id) + entity_id = f"{DOMAIN}.{dev_id}" topic = "/location/paulus" payload_home = "present" payload_not_home = "not present" @@ -174,9 +141,9 @@ async def test_matching_custom_payload_for_home_and_not_home( hass.config.components = set(["mqtt", "zone"]) assert await async_setup_component( hass, - device_tracker.DOMAIN, + DOMAIN, { - device_tracker.DOMAIN: { + DOMAIN: { CONF_PLATFORM: "mqtt", "devices": {dev_id: topic}, "payload_home": payload_home, @@ -198,7 +165,7 @@ async def test_not_matching_custom_payload_for_home_and_not_home( ): """Test not matching payload does not set state to home or not_home.""" dev_id = "paulus" - entity_id = ENTITY_ID_FORMAT.format(dev_id) + entity_id = f"{DOMAIN}.{dev_id}" topic = "/location/paulus" payload_home = "present" payload_not_home = "not present" @@ -207,9 +174,9 @@ async def test_not_matching_custom_payload_for_home_and_not_home( hass.config.components = set(["mqtt", "zone"]) assert await async_setup_component( hass, - device_tracker.DOMAIN, + DOMAIN, { - device_tracker.DOMAIN: { + DOMAIN: { CONF_PLATFORM: "mqtt", "devices": {dev_id: topic}, "payload_home": payload_home, @@ -226,7 +193,7 @@ async def test_not_matching_custom_payload_for_home_and_not_home( async def test_matching_source_type(hass, mock_device_tracker_conf): """Test setting source type.""" dev_id = "paulus" - entity_id = ENTITY_ID_FORMAT.format(dev_id) + entity_id = f"{DOMAIN}.{dev_id}" topic = "/location/paulus" source_type = SOURCE_TYPE_BLUETOOTH location = "work" @@ -234,9 +201,9 @@ async def test_matching_source_type(hass, mock_device_tracker_conf): hass.config.components = set(["mqtt", "zone"]) assert await async_setup_component( hass, - device_tracker.DOMAIN, + DOMAIN, { - device_tracker.DOMAIN: { + DOMAIN: { CONF_PLATFORM: "mqtt", "devices": {dev_id: topic}, "source_type": source_type, diff --git a/tests/components/mqtt_json/test_device_tracker.py b/tests/components/mqtt_json/test_device_tracker.py index 5af196c5bf2..9efff135fe2 100644 --- a/tests/components/mqtt_json/test_device_tracker.py +++ b/tests/components/mqtt_json/test_device_tracker.py @@ -8,7 +8,6 @@ import pytest from homeassistant.components.device_tracker.legacy import ( DOMAIN as DT_DOMAIN, - ENTITY_ID_FORMAT, YAML_DEVICES, ) from homeassistant.const import CONF_PLATFORM @@ -161,7 +160,7 @@ async def test_multi_level_wildcard_topic(hass): async def test_single_level_wildcard_topic_not_matching(hass): """Test not matching single level wildcard topic.""" dev_id = "zanzito" - entity_id = ENTITY_ID_FORMAT.format(dev_id) + entity_id = f"{DT_DOMAIN}.{dev_id}" subscription = "location/+/zanzito" topic = "location/zanzito" location = json.dumps(LOCATION_MESSAGE) @@ -179,7 +178,7 @@ async def test_single_level_wildcard_topic_not_matching(hass): async def test_multi_level_wildcard_topic_not_matching(hass): """Test not matching multi level wildcard topic.""" dev_id = "zanzito" - entity_id = ENTITY_ID_FORMAT.format(dev_id) + entity_id = f"{DT_DOMAIN}.{dev_id}" subscription = "location/#" topic = "somewhere/zanzito" location = json.dumps(LOCATION_MESSAGE) From db40b2fc3224cd70c2b23bc7d53cf23bde4c84f0 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 24 Feb 2020 13:02:33 -0700 Subject: [PATCH 070/416] Handle unhandled IQVIA data update exception (#32144) * Handle unhandled IQVIA data update exception * Cleanup * Ask for forgiveness, not permission * Use warning-level logs * Fix log messages --- homeassistant/components/iqvia/sensor.py | 33 +++++++++++++++++------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index 09edca52895..24ccfa9cdbf 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -134,21 +134,34 @@ class IndexSensor(IQVIAEntity): async def async_update(self): """Update the sensor.""" if not self._iqvia.data: + _LOGGER.warning( + "IQVIA didn't return data for %s; trying again later", self.name + ) return - data = {} - if self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW): - data = self._iqvia.data[TYPE_ALLERGY_INDEX].get("Location") - elif self._type in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW): - data = self._iqvia.data[TYPE_ASTHMA_INDEX].get("Location") - elif self._type == TYPE_DISEASE_TODAY: - data = self._iqvia.data[TYPE_DISEASE_INDEX].get("Location") - - if not data: + try: + if self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW): + data = self._iqvia.data[TYPE_ALLERGY_INDEX].get("Location") + elif self._type in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW): + data = self._iqvia.data[TYPE_ASTHMA_INDEX].get("Location") + elif self._type == TYPE_DISEASE_TODAY: + data = self._iqvia.data[TYPE_DISEASE_INDEX].get("Location") + except KeyError: + _LOGGER.warning( + "IQVIA didn't return data for %s; trying again later", self.name + ) return key = self._type.split("_")[-1].title() - [period] = [p for p in data["periods"] if p["Type"] == key] + + try: + [period] = [p for p in data["periods"] if p["Type"] == key] + except ValueError: + _LOGGER.warning( + "IQVIA didn't return data for %s; trying again later", self.name + ) + return + [rating] = [ i["label"] for i in RATING_MAPPING From f7e336eaa68cd28701ca47747231938bd6f193e9 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 24 Feb 2020 13:03:08 -0700 Subject: [PATCH 071/416] Modernize SimpliSafe config flow (#32130) * Modernize SimpliSafe config flow * Fix tests --- .../simplisafe/.translations/en.json | 3 ++ .../components/simplisafe/config_flow.py | 25 ++++++++-------- .../components/simplisafe/strings.json | 3 ++ .../components/simplisafe/test_config_flow.py | 30 +++++++++++++++---- 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/simplisafe/.translations/en.json b/homeassistant/components/simplisafe/.translations/en.json index b000335af8f..7e9c26291f7 100644 --- a/homeassistant/components/simplisafe/.translations/en.json +++ b/homeassistant/components/simplisafe/.translations/en.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "This SimpliSafe account is already in use." + }, "error": { "identifier_exists": "Account already registered", "invalid_credentials": "Invalid credentials" diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 9c93cd18626..5c7c6d7d450 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -1,6 +1,4 @@ """Config flow to configure the SimpliSafe component.""" -from collections import OrderedDict - from simplipy import API from simplipy.errors import SimplipyError import voluptuous as vol @@ -21,8 +19,7 @@ def configured_instances(hass): ) -@config_entries.HANDLERS.register(DOMAIN) -class SimpliSafeFlowHandler(config_entries.ConfigFlow): +class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a SimpliSafe config flow.""" VERSION = 1 @@ -30,16 +27,19 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow): def __init__(self): """Initialize the config flow.""" - self.data_schema = OrderedDict() - self.data_schema[vol.Required(CONF_USERNAME)] = str - self.data_schema[vol.Required(CONF_PASSWORD)] = str - self.data_schema[vol.Optional(CONF_CODE)] = str + self.data_schema = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_CODE): str, + } + ) async def _show_form(self, errors=None): """Show the form to the user.""" return self.async_show_form( step_id="user", - data_schema=vol.Schema(self.data_schema), + data_schema=self.data_schema, errors=errors if errors else {}, ) @@ -49,12 +49,11 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow): async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" - if not user_input: return await self._show_form() - if user_input[CONF_USERNAME] in configured_instances(self.hass): - return await self._show_form({CONF_USERNAME: "identifier_exists"}) + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() username = user_input[CONF_USERNAME] websession = aiohttp_client.async_get_clientsession(self.hass) @@ -64,7 +63,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow): username, user_input[CONF_PASSWORD], websession ) except SimplipyError: - return await self._show_form({"base": "invalid_credentials"}) + return await self._show_form(errors={"base": "invalid_credentials"}) return self.async_create_entry( title=user_input[CONF_USERNAME], diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json index 5df0cf400d4..3043bd79104 100644 --- a/homeassistant/components/simplisafe/strings.json +++ b/homeassistant/components/simplisafe/strings.json @@ -14,6 +14,9 @@ "error": { "identifier_exists": "Account already registered", "invalid_credentials": "Invalid credentials" + }, + "abort": { + "already_configured": "This SimpliSafe account is already in use." } } } diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py index 2d40495215a..eebb437d137 100644 --- a/tests/components/simplisafe/test_config_flow.py +++ b/tests/components/simplisafe/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import MagicMock, PropertyMock, mock_open, patch from homeassistant import data_entry_flow from homeassistant.components.simplisafe import DOMAIN, config_flow +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from tests.common import MockConfigEntry, mock_coro @@ -20,12 +21,27 @@ async def test_duplicate_error(hass): """Test that errors are shown when duplicates are added.""" conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} - MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) - flow = config_flow.SimpliSafeFlowHandler() - flow.hass = hass + MockConfigEntry(domain=DOMAIN, unique_id="user@email.com", data=conf).add_to_hass( + hass + ) - result = await flow.async_step_user(user_input=conf) - assert result["errors"] == {CONF_USERNAME: "identifier_exists"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_get_configured_instances(hass): + """Test retrieving all configured instances.""" + conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} + + MockConfigEntry(domain=DOMAIN, unique_id="user@email.com", data=conf).add_to_hass( + hass + ) + + assert len(config_flow.configured_instances(hass)) == 1 async def test_invalid_credentials(hass): @@ -36,6 +52,7 @@ async def test_invalid_credentials(hass): flow = config_flow.SimpliSafeFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} with patch( "simplipy.API.login_via_credentials", @@ -49,6 +66,7 @@ async def test_show_form(hass): """Test that the form is served with no input.""" flow = config_flow.SimpliSafeFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} result = await flow.async_step_user(user_input=None) @@ -62,6 +80,7 @@ async def test_step_import(hass): flow = config_flow.SimpliSafeFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} mop = mock_open(read_data=json.dumps({"refresh_token": "12345"})) @@ -91,6 +110,7 @@ async def test_step_user(hass): flow = config_flow.SimpliSafeFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} mop = mock_open(read_data=json.dumps({"refresh_token": "12345"})) From edf44f415803a87c92b95989246024771d902175 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 24 Feb 2020 13:05:54 -0700 Subject: [PATCH 072/416] Modernize RainMachine config flow (#32131) * Modernize RainMachine config flow * Update strings --- .../rainmachine/.translations/en.json | 3 ++ .../components/rainmachine/config_flow.py | 24 +++++++------- .../components/rainmachine/strings.json | 3 ++ .../rainmachine/test_config_flow.py | 32 +++++++++++++++++-- 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/rainmachine/.translations/en.json b/homeassistant/components/rainmachine/.translations/en.json index 54b67066f2b..4ad5bfd7c0d 100644 --- a/homeassistant/components/rainmachine/.translations/en.json +++ b/homeassistant/components/rainmachine/.translations/en.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "This RainMachine controller is already configured." + }, "error": { "identifier_exists": "Account already registered", "invalid_credentials": "Invalid credentials" diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 4753335da78..4b93fc64159 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -1,7 +1,4 @@ """Config flow to configure the RainMachine component.""" - -from collections import OrderedDict - from regenmaschine import login from regenmaschine.errors import RainMachineError import voluptuous as vol @@ -29,8 +26,7 @@ def configured_instances(hass): ) -@config_entries.HANDLERS.register(DOMAIN) -class RainMachineFlowHandler(config_entries.ConfigFlow): +class RainMachineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a RainMachine config flow.""" VERSION = 1 @@ -38,16 +34,19 @@ class RainMachineFlowHandler(config_entries.ConfigFlow): def __init__(self): """Initialize the config flow.""" - self.data_schema = OrderedDict() - self.data_schema[vol.Required(CONF_IP_ADDRESS)] = str - self.data_schema[vol.Required(CONF_PASSWORD)] = str - self.data_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int + self.data_schema = vol.Schema( + { + vol.Required(CONF_IP_ADDRESS): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + } + ) async def _show_form(self, errors=None): """Show the form to the user.""" return self.async_show_form( step_id="user", - data_schema=vol.Schema(self.data_schema), + data_schema=self.data_schema, errors=errors if errors else {}, ) @@ -57,12 +56,11 @@ class RainMachineFlowHandler(config_entries.ConfigFlow): async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" - if not user_input: return await self._show_form() - if user_input[CONF_IP_ADDRESS] in configured_instances(self.hass): - return await self._show_form({CONF_IP_ADDRESS: "identifier_exists"}) + await self.async_set_unique_id(user_input[CONF_IP_ADDRESS]) + self._abort_if_unique_id_configured() websession = aiohttp_client.async_get_clientsession(self.hass) diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json index 6e26192ec82..7195cce2e31 100644 --- a/homeassistant/components/rainmachine/strings.json +++ b/homeassistant/components/rainmachine/strings.json @@ -14,6 +14,9 @@ "error": { "identifier_exists": "Account already registered", "invalid_credentials": "Invalid credentials" + }, + "abort": { + "already_configured": "This RainMachine controller is already configured." } } } diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 9e43f647301..435e8fb33b5 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -5,6 +5,7 @@ from regenmaschine.errors import RainMachineError from homeassistant import data_entry_flow from homeassistant.components.rainmachine import DOMAIN, config_flow +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_IP_ADDRESS, CONF_PASSWORD, @@ -25,12 +26,33 @@ async def test_duplicate_error(hass): CONF_SSL: True, } - MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) + MockConfigEntry(domain=DOMAIN, unique_id="192.168.1.100", data=conf).add_to_hass( + hass + ) flow = config_flow.RainMachineFlowHandler() flow.hass = hass - result = await flow.async_step_user(user_input=conf) - assert result["errors"] == {CONF_IP_ADDRESS: "identifier_exists"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_get_configured_instances(hass): + """Test retrieving all configured instances.""" + conf = { + CONF_IP_ADDRESS: "192.168.1.100", + CONF_PASSWORD: "password", + CONF_PORT: 8080, + CONF_SSL: True, + } + + MockConfigEntry(domain=DOMAIN, unique_id="192.168.1.100", data=conf).add_to_hass( + hass + ) + + assert len(config_flow.configured_instances(hass)) == 1 async def test_invalid_password(hass): @@ -44,6 +66,7 @@ async def test_invalid_password(hass): flow = config_flow.RainMachineFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} with patch( "homeassistant.components.rainmachine.config_flow.login", @@ -57,6 +80,7 @@ async def test_show_form(hass): """Test that the form is served with no input.""" flow = config_flow.RainMachineFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} result = await flow.async_step_user(user_input=None) @@ -75,6 +99,7 @@ async def test_step_import(hass): flow = config_flow.RainMachineFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} with patch( "homeassistant.components.rainmachine.config_flow.login", @@ -104,6 +129,7 @@ async def test_step_user(hass): flow = config_flow.RainMachineFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} with patch( "homeassistant.components.rainmachine.config_flow.login", From 6b0d7c77f01a9819ac8b7ee620a04961a5a5c3b7 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 24 Feb 2020 13:07:18 -0700 Subject: [PATCH 073/416] Clean up RainMachine config entry data (#32132) --- .../components/rainmachine/__init__.py | 12 +++++++----- .../components/rainmachine/config_flow.py | 19 ++----------------- homeassistant/components/rainmachine/const.py | 7 ------- .../rainmachine/test_config_flow.py | 2 ++ 4 files changed, 11 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 53f33f68eb9..45070e40e58 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -34,8 +34,6 @@ from .const import ( DATA_ZONES, DATA_ZONES_DETAILS, DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, - DEFAULT_SSL, DOMAIN, PROGRAM_UPDATE_TOPIC, SENSOR_UPDATE_TOPIC, @@ -54,6 +52,8 @@ CONF_ZONE_RUN_TIME = "zone_run_time" DEFAULT_ATTRIBUTION = "Data provided by Green Electronics LLC" DEFAULT_ICON = "mdi:water" +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_SSL = True DEFAULT_ZONE_RUN = 60 * 10 SERVICE_ALTER_PROGRAM = vol.Schema({vol.Required(CONF_PROGRAM_ID): cv.positive_int}) @@ -85,8 +85,10 @@ CONTROLLER_SCHEMA = vol.Schema( vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period, - vol.Optional(CONF_ZONE_RUN_TIME): cv.positive_int, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, lambda value: value.total_seconds() + ), + vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN): cv.positive_int, } ) @@ -153,7 +155,7 @@ async def async_setup_entry(hass, config_entry): rainmachine = RainMachine( hass, controller, - config_entry.data.get(CONF_ZONE_RUN_TIME, DEFAULT_ZONE_RUN), + config_entry.data[CONF_ZONE_RUN_TIME], config_entry.data[CONF_SCAN_INTERVAL], ) diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 4b93fc64159..3fb731c5856 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -4,17 +4,11 @@ from regenmaschine.errors import RainMachineError import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_IP_ADDRESS, - CONF_PASSWORD, - CONF_PORT, - CONF_SCAN_INTERVAL, - CONF_SSL, -) +from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT from homeassistant.core import callback from homeassistant.helpers import aiohttp_client -from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN +from .const import DEFAULT_PORT, DOMAIN @callback @@ -75,15 +69,6 @@ class RainMachineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except RainMachineError: return await self._show_form({CONF_PASSWORD: "invalid_credentials"}) - # Since the config entry doesn't allow for configuration of SSL, make - # sure it's set: - if user_input.get(CONF_SSL) is None: - user_input[CONF_SSL] = DEFAULT_SSL - - # Timedeltas are easily serializable, so store the seconds instead: - scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds - # Unfortunately, RainMachine doesn't provide a way to refresh the # access token without using the IP address and password, so we have to # store it: diff --git a/homeassistant/components/rainmachine/const.py b/homeassistant/components/rainmachine/const.py index b912f8d95ef..855ff5d5df5 100644 --- a/homeassistant/components/rainmachine/const.py +++ b/homeassistant/components/rainmachine/const.py @@ -1,9 +1,4 @@ """Define constants for the SimpliSafe component.""" -from datetime import timedelta -import logging - -LOGGER = logging.getLogger(__package__) - DOMAIN = "rainmachine" DATA_CLIENT = "client" @@ -15,8 +10,6 @@ DATA_ZONES = "zones" DATA_ZONES_DETAILS = "zones_details" DEFAULT_PORT = 8080 -DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) -DEFAULT_SSL = True PROGRAM_UPDATE_TOPIC = f"{DOMAIN}_program_update" SENSOR_UPDATE_TOPIC = f"{DOMAIN}_data_update" diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 435e8fb33b5..379532c8f50 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -95,6 +95,7 @@ async def test_step_import(hass): CONF_PASSWORD: "password", CONF_PORT: 8080, CONF_SSL: True, + CONF_SCAN_INTERVAL: 60, } flow = config_flow.RainMachineFlowHandler() @@ -125,6 +126,7 @@ async def test_step_user(hass): CONF_PASSWORD: "password", CONF_PORT: 8080, CONF_SSL: True, + CONF_SCAN_INTERVAL: 60, } flow = config_flow.RainMachineFlowHandler() From 90859b82e2d3705b219d7b751d7f3e36dcd2e367 Mon Sep 17 00:00:00 2001 From: Colin O'Dell Date: Mon, 24 Feb 2020 16:46:07 -0500 Subject: [PATCH 074/416] Upgrade qnapstats to 0.3.0 (#32148) --- homeassistant/components/qnap/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/qnap/manifest.json b/homeassistant/components/qnap/manifest.json index 6720120b5e2..3c64986c2bc 100644 --- a/homeassistant/components/qnap/manifest.json +++ b/homeassistant/components/qnap/manifest.json @@ -2,7 +2,7 @@ "domain": "qnap", "name": "QNAP", "documentation": "https://www.home-assistant.io/integrations/qnap", - "requirements": ["qnapstats==0.2.7"], + "requirements": ["qnapstats==0.3.0"], "dependencies": [], "codeowners": ["@colinodell"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1445f9930a0..ecc4d8d82a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1723,7 +1723,7 @@ pyzabbix==0.7.4 pyzbar==0.1.7 # homeassistant.components.qnap -qnapstats==0.2.7 +qnapstats==0.3.0 # homeassistant.components.quantum_gateway quantum-gateway==0.0.5 From 309989be8950b983f95d69e39ad4cb062f0944c2 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 24 Feb 2020 17:37:54 -0500 Subject: [PATCH 075/416] Fix vizio bug to use 'get' to get volume_step since it is optional (#32151) * use get to get volume_step since it is optional * always set volume_step to default in options and data if its not included --- homeassistant/components/vizio/config_flow.py | 7 +++++-- tests/components/vizio/test_config_flow.py | 6 +++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index c62222f2d91..cde84a6a9e2 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -180,8 +180,11 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if entry.data[CONF_NAME] != import_config[CONF_NAME]: updated_name[CONF_NAME] = import_config[CONF_NAME] - if entry.data[CONF_VOLUME_STEP] != import_config[CONF_VOLUME_STEP]: - updated_options[CONF_VOLUME_STEP] = import_config[CONF_VOLUME_STEP] + import_volume_step = import_config.get( + CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP + ) + if entry.data.get(CONF_VOLUME_STEP) != import_volume_step: + updated_options[CONF_VOLUME_STEP] = import_volume_step if updated_options or updated_name: new_data = entry.data.copy() diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index 416617d4b9b..2dd32800c2d 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -300,15 +300,15 @@ async def test_import_flow_update_options( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, - data=vol.Schema(VIZIO_SCHEMA)(MOCK_IMPORT_VALID_TV_CONFIG), + data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), ) await hass.async_block_till_done() - assert result["result"].options == {CONF_VOLUME_STEP: VOLUME_STEP} + assert result["result"].options == {CONF_VOLUME_STEP: DEFAULT_VOLUME_STEP} assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY entry_id = result["result"].entry_id - updated_config = MOCK_IMPORT_VALID_TV_CONFIG.copy() + updated_config = MOCK_SPEAKER_CONFIG.copy() updated_config[CONF_VOLUME_STEP] = VOLUME_STEP + 1 result = await hass.config_entries.flow.async_init( DOMAIN, From b2d7bc40dc8c054430c6b8e0111be07060db5878 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Mon, 24 Feb 2020 16:56:00 -0600 Subject: [PATCH 076/416] Add support for simultaneous runs of Script helper (#31937) * Add tests for legacy Script helper behavior * Add Script helper if_running and run_mode options - if_running controls what happens if Script run while previous run has not completed. Can be: - error: Raise an exception - ignore: Return without doing anything (previous run continues as-is) - parallel: Start run in new task - restart: Stop previous run before starting new run - run_mode controls when call to async_run will return. Can be: - background: Returns immediately - legacy: Implements previous behavior, which is to return when done, or when suspended by delay or wait_template - blocking: Returns when run has completed - If neither is specified, default is run_mode=legacy (and if_running is not used.) Otherwise, defaults are if_running=parallel and run_mode=background. If run_mode is set to legacy then if_running must be None. - Caller may supply a logger which will be used throughout instead of default module logger. - Move Script running state into new helper classes, comprised of an abstract base class and two concrete clases, one for legacy behavior and one for new behavior. - Remove some non-async methods, as well as call_from_config which has only been used in tests. - Adjust tests accordingly. * Change per review - Change run_mode default from background to blocking. - Make sure change listener is called, even when there's an unexpected exception. - Make _ScriptRun.async_stop more graceful by using an asyncio.Event for signaling instead of simply cancelling Task. - Subclass _ScriptRun for background & blocking behavior. Also: - Fix timeouts in _ScriptRun by converting timedeltas to float seconds. - General cleanup. * Change per review 2 - Don't propagate exceptions if call from user has already returned (i.e., for background runs or legacy runs that have suspended.) - Allow user to specify if exceptions should be logged. They will still be logged regardless if exception is not propagated. - Rename _start_script_delay and _start_wait_template_delay for clarity. - Remove return value from Script.async_run. - Fix missing await. - Change call to self.is_running in Script.async_run to direct test of self._runs. * Change per review 3 and add tests - Remove Script.set_logger(). - Enhance existing tests to check all run modes. - Add tests for new features. - Fix a few minor bugs found by tests. --- .../components/automation/__init__.py | 10 +- homeassistant/components/script/__init__.py | 17 +- homeassistant/helpers/script.py | 839 +++++--- tests/components/demo/test_notify.py | 14 +- tests/helpers/test_script.py | 1775 +++++++++++------ 5 files changed, 1774 insertions(+), 881 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index b1d6c24d303..6f06eeb0094 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -393,10 +393,8 @@ class AutomationEntity(ToggleEntity, RestoreEntity): try: await self.action_script.async_run(variables, trigger_context) - except Exception as err: # pylint: disable=broad-except - self.action_script.async_log_exception( - _LOGGER, f"Error while executing automation {self.entity_id}", err - ) + except Exception: # pylint: disable=broad-except + pass self._last_triggered = utcnow() await self.async_update_ha_state() @@ -504,7 +502,9 @@ async def _async_process_config(hass, config, component): hidden = config_block[CONF_HIDE_ENTITY] initial_state = config_block.get(CONF_INITIAL_STATE) - action_script = script.Script(hass, config_block.get(CONF_ACTION, {}), name) + action_script = script.Script( + hass, config_block.get(CONF_ACTION, {}), name, logger=_LOGGER + ) if CONF_CONDITION in config_block: cond_func = await _async_process_if(hass, config, config_block) diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 0a7b8596248..9384c58db81 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -242,7 +242,9 @@ class ScriptEntity(ToggleEntity): self.object_id = object_id self.icon = icon self.entity_id = ENTITY_ID_FORMAT.format(object_id) - self.script = Script(hass, sequence, name, self.async_update_ha_state) + self.script = Script( + hass, sequence, name, self.async_update_ha_state, logger=_LOGGER + ) @property def should_poll(self): @@ -279,22 +281,15 @@ class ScriptEntity(ToggleEntity): {ATTR_NAME: self.script.name, ATTR_ENTITY_ID: self.entity_id}, context=context, ) - try: - await self.script.async_run(kwargs.get(ATTR_VARIABLES), context) - except Exception as err: - self.script.async_log_exception( - _LOGGER, f"Error executing script {self.entity_id}", err - ) - raise err + await self.script.async_run(kwargs.get(ATTR_VARIABLES), context) async def async_turn_off(self, **kwargs): """Turn script off.""" - self.script.async_stop() + await self.script.async_stop() async def async_will_remove_from_hass(self): """Stop script and remove service when it will be removed from Home Assistant.""" - if self.script.is_running: - self.script.async_stop() + await self.script.async_stop() # remove service self.hass.services.async_remove(DOMAIN, self.object_id) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 1cac4679d82..1ce9d2b87bb 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -1,10 +1,11 @@ """Helpers to execute scripts.""" +from abc import ABC, abstractmethod import asyncio from contextlib import suppress from datetime import datetime from itertools import islice import logging -from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple +from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, cast import voluptuous as vol @@ -31,13 +32,10 @@ from homeassistant.helpers.event import ( async_track_template, ) from homeassistant.helpers.typing import ConfigType -from homeassistant.util.async_ import run_callback_threadsafe -import homeassistant.util.dt as date_util +from homeassistant.util.dt import utcnow # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs -_LOGGER = logging.getLogger(__name__) - CONF_ALIAS = "alias" CONF_SERVICE = "service" CONF_SERVICE_DATA = "data" @@ -50,7 +48,6 @@ CONF_WAIT_TEMPLATE = "wait_template" CONF_CONTINUE = "continue_on_timeout" CONF_SCENE = "scene" - ACTION_DELAY = "delay" ACTION_WAIT_TEMPLATE = "wait_template" ACTION_CHECK_CONDITION = "condition" @@ -59,6 +56,31 @@ ACTION_CALL_SERVICE = "call_service" ACTION_DEVICE_AUTOMATION = "device" ACTION_ACTIVATE_SCENE = "scene" +IF_RUNNING_ERROR = "error" +IF_RUNNING_IGNORE = "ignore" +IF_RUNNING_PARALLEL = "parallel" +IF_RUNNING_RESTART = "restart" +# First choice is default +IF_RUNNING_CHOICES = [ + IF_RUNNING_PARALLEL, + IF_RUNNING_ERROR, + IF_RUNNING_IGNORE, + IF_RUNNING_RESTART, +] + +RUN_MODE_BACKGROUND = "background" +RUN_MODE_BLOCKING = "blocking" +RUN_MODE_LEGACY = "legacy" +# First choice is default +RUN_MODE_CHOICES = [ + RUN_MODE_BLOCKING, + RUN_MODE_BACKGROUND, + RUN_MODE_LEGACY, +] + +_LOG_EXCEPTION = logging.ERROR + 1 +_TIMEOUT_MSG = "Timeout reached, abort script." + def _determine_action(action): """Determine action type.""" @@ -83,16 +105,6 @@ def _determine_action(action): return ACTION_CALL_SERVICE -def call_from_config( - hass: HomeAssistant, - config: ConfigType, - variables: Optional[Sequence] = None, - context: Optional[Context] = None, -) -> None: - """Call a script based on a config entry.""" - Script(hass, cv.SCRIPT_SCHEMA(config)).run(variables, context) - - async def async_validate_action_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: @@ -121,6 +133,446 @@ class _SuspendScript(Exception): """Throw if script needs to suspend.""" +class _ScriptRunBase(ABC): + """Common data & methods for managing Script sequence run.""" + + def __init__( + self, + hass: HomeAssistant, + script: "Script", + variables: Optional[Sequence], + context: Optional[Context], + log_exceptions: bool, + ) -> None: + self._hass = hass + self._script = script + self._variables = variables + self._context = context + self._log_exceptions = log_exceptions + self._step = -1 + self._action: Optional[Dict[str, Any]] = None + + def _changed(self): + self._script._changed() # pylint: disable=protected-access + + @property + def _config_cache(self): + return self._script._config_cache # pylint: disable=protected-access + + @abstractmethod + async def async_run(self) -> None: + """Run script.""" + + async def _async_step(self, log_exceptions): + try: + await getattr(self, f"_async_{_determine_action(self._action)}_step")() + except Exception as err: + if not isinstance(err, (_SuspendScript, _StopScript)) and ( + self._log_exceptions or log_exceptions + ): + self._log_exception(err) + raise + + @abstractmethod + async def async_stop(self) -> None: + """Stop script run.""" + + def _log_exception(self, exception): + action_type = _determine_action(self._action) + + error = str(exception) + level = logging.ERROR + + if isinstance(exception, vol.Invalid): + error_desc = "Invalid data" + + elif isinstance(exception, exceptions.TemplateError): + error_desc = "Error rendering template" + + elif isinstance(exception, exceptions.Unauthorized): + error_desc = "Unauthorized" + + elif isinstance(exception, exceptions.ServiceNotFound): + error_desc = "Service not found" + + else: + error_desc = "Unexpected error" + level = _LOG_EXCEPTION + + self._log( + "Error executing script. %s for %s at pos %s: %s", + error_desc, + action_type, + self._step + 1, + error, + level=level, + ) + + @abstractmethod + async def _async_delay_step(self): + """Handle delay.""" + + def _prep_delay_step(self): + try: + delay = vol.All(cv.time_period, cv.positive_timedelta)( + template.render_complex(self._action[CONF_DELAY], self._variables) + ) + except (exceptions.TemplateError, vol.Invalid) as ex: + self._raise( + "Error rendering %s delay template: %s", + self._script.name, + ex, + exception=_StopScript, + ) + + self._script.last_action = self._action.get(CONF_ALIAS, f"delay {delay}") + self._log("Executing step %s", self._script.last_action) + + return delay + + @abstractmethod + async def _async_wait_template_step(self): + """Handle a wait template.""" + + def _prep_wait_template_step(self, async_script_wait): + wait_template = self._action[CONF_WAIT_TEMPLATE] + wait_template.hass = self._hass + + self._script.last_action = self._action.get(CONF_ALIAS, "wait template") + self._log("Executing step %s", self._script.last_action) + + # check if condition already okay + if condition.async_template(self._hass, wait_template, self._variables): + return None + + return async_track_template( + self._hass, wait_template, async_script_wait, self._variables + ) + + async def _async_call_service_step(self): + """Call the service specified in the action.""" + self._script.last_action = self._action.get(CONF_ALIAS, "call service") + self._log("Executing step %s", self._script.last_action) + await service.async_call_from_config( + self._hass, + self._action, + blocking=True, + variables=self._variables, + validate_config=False, + context=self._context, + ) + + async def _async_device_step(self): + """Perform the device automation specified in the action.""" + self._script.last_action = self._action.get(CONF_ALIAS, "device automation") + self._log("Executing step %s", self._script.last_action) + platform = await device_automation.async_get_device_automation_platform( + self._hass, self._action[CONF_DOMAIN], "action" + ) + await platform.async_call_action_from_config( + self._hass, self._action, self._variables, self._context + ) + + async def _async_scene_step(self): + """Activate the scene specified in the action.""" + self._script.last_action = self._action.get(CONF_ALIAS, "activate scene") + self._log("Executing step %s", self._script.last_action) + await self._hass.services.async_call( + scene.DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: self._action[CONF_SCENE]}, + blocking=True, + context=self._context, + ) + + async def _async_event_step(self): + """Fire an event.""" + self._script.last_action = self._action.get( + 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: + try: + event_data.update( + template.render_complex( + self._action[CONF_EVENT_DATA_TEMPLATE], self._variables + ) + ) + except exceptions.TemplateError as ex: + self._log( + "Error rendering event data template: %s", ex, level=logging.ERROR + ) + + self._hass.bus.async_fire( + self._action[CONF_EVENT], event_data, context=self._context + ) + + async def _async_condition_step(self): + """Test if condition is matching.""" + config_cache_key = frozenset((k, str(v)) for k, v in self._action.items()) + config = self._config_cache.get(config_cache_key) + if not config: + config = await condition.async_from_config(self._hass, self._action, False) + self._config_cache[config_cache_key] = config + + self._script.last_action = self._action.get( + CONF_ALIAS, self._action[CONF_CONDITION] + ) + check = config(self._hass, self._variables) + self._log("Test condition %s: %s", self._script.last_action, check) + if not check: + raise _StopScript + + def _log(self, msg, *args, level=logging.INFO): + self._script._log(msg, *args, level=level) # pylint: disable=protected-access + + def _raise(self, msg, *args, exception=None): + # pylint: disable=protected-access + self._script._raise(msg, *args, exception=exception) + + +class _ScriptRun(_ScriptRunBase): + """Manage Script sequence run.""" + + def __init__( + self, + hass: HomeAssistant, + script: "Script", + variables: Optional[Sequence], + context: Optional[Context], + log_exceptions: bool, + ) -> None: + super().__init__(hass, script, variables, context, log_exceptions) + self._stop = asyncio.Event() + self._stopped = asyncio.Event() + + async def _async_run(self, propagate_exceptions=True): + self._log("Running script") + try: + for self._step, self._action in enumerate(self._script.sequence): + if self._stop.is_set(): + break + await self._async_step(not propagate_exceptions) + except _StopScript: + pass + except Exception: # pylint: disable=broad-except + if propagate_exceptions: + raise + finally: + if not self._stop.is_set(): + self._changed() + self._script.last_action = None + self._script._runs.remove(self) # pylint: disable=protected-access + self._stopped.set() + + async def async_stop(self) -> None: + """Stop script run.""" + self._stop.set() + await self._stopped.wait() + + async def _async_delay_step(self): + """Handle delay.""" + timeout = self._prep_delay_step().total_seconds() + if not self._stop.is_set(): + self._changed() + await asyncio.wait({self._stop.wait()}, timeout=timeout) + + async def _async_wait_template_step(self): + """Handle a wait template.""" + + @callback + def async_script_wait(entity_id, from_s, to_s): + """Handle script after template condition is true.""" + done.set() + + unsub = self._prep_wait_template_step(async_script_wait) + if not unsub: + return + + if not self._stop.is_set(): + self._changed() + try: + timeout = self._action[CONF_TIMEOUT].total_seconds() + except KeyError: + timeout = None + done = asyncio.Event() + try: + await asyncio.wait_for( + asyncio.wait( + {self._stop.wait(), done.wait()}, + return_when=asyncio.FIRST_COMPLETED, + ), + timeout, + ) + except asyncio.TimeoutError: + if not self._action.get(CONF_CONTINUE, True): + self._log(_TIMEOUT_MSG) + raise _StopScript + finally: + unsub() + + +class _BackgroundScriptRun(_ScriptRun): + """Manage background Script sequence run.""" + + async def async_run(self) -> None: + """Run script.""" + self._hass.async_create_task(self._async_run(False)) + + +class _BlockingScriptRun(_ScriptRun): + """Manage blocking Script sequence run.""" + + async def async_run(self) -> None: + """Run script.""" + try: + await asyncio.shield(self._async_run()) + except asyncio.CancelledError: + await self.async_stop() + raise + + +class _LegacyScriptRun(_ScriptRunBase): + """Manage legacy Script sequence run.""" + + def __init__( + self, + hass: HomeAssistant, + script: "Script", + variables: Optional[Sequence], + context: Optional[Context], + log_exceptions: bool, + shared: Optional["_LegacyScriptRun"], + ) -> None: + super().__init__(hass, script, variables, context, log_exceptions) + if shared: + self._shared = shared + else: + # To implement legacy behavior we need to share the following "run state" + # amongst all runs, so it will only exist in the first instantiation of + # concurrent runs, and the rest will use it, too. + self._current = -1 + self._async_listeners: List[CALLBACK_TYPE] = [] + self._shared = self + + @property + def _cur(self): + return self._shared._current # pylint: disable=protected-access + + @_cur.setter + def _cur(self, value): + self._shared._current = value # pylint: disable=protected-access + + @property + def _async_listener(self): + return self._shared._async_listeners # pylint: disable=protected-access + + async def async_run(self) -> None: + """Run script.""" + await self._async_run() + + async def _async_run(self, propagate_exceptions=True): + if self._cur == -1: + self._log("Running script") + self._cur = 0 + + # Unregister callback if we were in a delay or wait but turn on is + # called again. In that case we just continue execution. + self._async_remove_listener() + + suspended = False + try: + for self._step, self._action in islice( + enumerate(self._script.sequence), self._cur, None + ): + await self._async_step(not propagate_exceptions) + except _StopScript: + pass + except _SuspendScript: + # Store next step to take and notify change listeners + self._cur = self._step + 1 + suspended = True + return + except Exception: # pylint: disable=broad-except + if propagate_exceptions: + raise + finally: + if self._cur != -1: + self._changed() + if not suspended: + self._script.last_action = None + await self.async_stop() + + async def async_stop(self) -> None: + """Stop script run.""" + if self._cur == -1: + return + + self._cur = -1 + self._async_remove_listener() + self._script._runs.clear() # pylint: disable=protected-access + + async def _async_delay_step(self): + """Handle delay.""" + delay = self._prep_delay_step() + + @callback + def async_script_delay(now): + """Handle delay.""" + with suppress(ValueError): + self._async_listener.remove(unsub) + self._hass.async_create_task(self._async_run(False)) + + unsub = async_track_point_in_utc_time( + self._hass, async_script_delay, utcnow() + delay + ) + self._async_listener.append(unsub) + raise _SuspendScript + + async def _async_wait_template_step(self): + """Handle a wait template.""" + + @callback + def async_script_wait(entity_id, from_s, to_s): + """Handle script after template condition is true.""" + self._async_remove_listener() + self._hass.async_create_task(self._async_run(False)) + + @callback + def async_script_timeout(now): + """Call after timeout is retrieve.""" + with suppress(ValueError): + self._async_listener.remove(unsub) + + # Check if we want to continue to execute + # the script after the timeout + if self._action.get(CONF_CONTINUE, True): + self._hass.async_create_task(self._async_run(False)) + else: + self._log(_TIMEOUT_MSG) + self._hass.async_create_task(self.async_stop()) + + unsub_wait = self._prep_wait_template_step(async_script_wait) + if not unsub_wait: + return + self._async_listener.append(unsub_wait) + + if CONF_TIMEOUT in self._action: + unsub = async_track_point_in_utc_time( + self._hass, async_script_timeout, utcnow() + self._action[CONF_TIMEOUT] + ) + self._async_listener.append(unsub) + + raise _SuspendScript + + def _async_remove_listener(self): + """Remove listeners, if any.""" + for unsub in self._async_listener: + unsub() + self._async_listener.clear() + + class Script: """Representation of a script.""" @@ -130,39 +582,46 @@ class Script: sequence: Sequence[Dict[str, Any]], name: Optional[str] = None, change_listener: Optional[Callable[..., Any]] = None, + if_running: Optional[str] = None, + run_mode: Optional[str] = None, + logger: Optional[logging.Logger] = None, + log_exceptions: bool = True, ) -> None: """Initialize the script.""" - self.hass = hass + self._logger = logger or logging.getLogger(__name__) + self._hass = hass self.sequence = sequence template.attach(hass, self.sequence) self.name = name self._change_listener = change_listener - self._cur = -1 - self._exception_step: Optional[int] = None self.last_action = None self.last_triggered: Optional[datetime] = None self.can_cancel = any( CONF_DELAY in action or CONF_WAIT_TEMPLATE in action for action in self.sequence ) - self._async_listener: List[CALLBACK_TYPE] = [] + if not if_running and not run_mode: + self._if_running = IF_RUNNING_PARALLEL + self._run_mode = RUN_MODE_LEGACY + elif if_running and run_mode == RUN_MODE_LEGACY: + self._raise('Cannot use if_running if run_mode is "legacy"') + else: + self._if_running = if_running or IF_RUNNING_CHOICES[0] + self._run_mode = run_mode or RUN_MODE_CHOICES[0] + self._runs: List[_ScriptRunBase] = [] + self._log_exceptions = log_exceptions self._config_cache: Dict[Set[Tuple], Callable[..., bool]] = {} - self._actions = { - ACTION_DELAY: self._async_delay, - ACTION_WAIT_TEMPLATE: self._async_wait_template, - ACTION_CHECK_CONDITION: self._async_check_condition, - ACTION_FIRE_EVENT: self._async_fire_event, - ACTION_CALL_SERVICE: self._async_call_service, - ACTION_DEVICE_AUTOMATION: self._async_device_automation, - ACTION_ACTIVATE_SCENE: self._async_activate_scene, - } self._referenced_entities: Optional[Set[str]] = None self._referenced_devices: Optional[Set[str]] = None + def _changed(self): + if self._change_listener: + self._hass.async_add_job(self._change_listener) + @property def is_running(self) -> bool: """Return true if script is on.""" - return self._cur != -1 + return len(self._runs) > 0 @property def referenced_devices(self): @@ -223,288 +682,62 @@ class Script: def run(self, variables=None, context=None): """Run script.""" asyncio.run_coroutine_threadsafe( - self.async_run(variables, context), self.hass.loop + self.async_run(variables, context), self._hass.loop ).result() async def async_run( self, variables: Optional[Sequence] = None, context: Optional[Context] = None ) -> None: - """Run script. - - This method is a coroutine. - """ - self.last_triggered = date_util.utcnow() - if self._cur == -1: - self._log("Running script") - self._cur = 0 - - # Unregister callback if we were in a delay or wait but turn on is - # called again. In that case we just continue execution. - self._async_remove_listener() - - for cur, action in islice(enumerate(self.sequence), self._cur, None): - try: - await self._handle_action(action, variables, context) - except _SuspendScript: - # Store next step to take and notify change listeners - self._cur = cur + 1 - if self._change_listener: - self.hass.async_add_job(self._change_listener) + """Run script.""" + if self.is_running: + if self._if_running == IF_RUNNING_IGNORE: + self._log("Skipping script") return - except _StopScript: - break - except Exception: - # Store the step that had an exception - self._exception_step = cur - # Set script to not running - self._cur = -1 - self.last_action = None - # Pass exception on. - raise - # Set script to not-running. - self._cur = -1 - self.last_action = None - if self._change_listener: - self.hass.async_add_job(self._change_listener) + if self._if_running == IF_RUNNING_ERROR: + self._raise("Already running") + if self._if_running == IF_RUNNING_RESTART: + self._log("Restarting script") + await self.async_stop() - def stop(self) -> None: - """Stop running script.""" - run_callback_threadsafe(self.hass.loop, self.async_stop).result() - - @callback - def async_stop(self) -> None: - """Stop running script.""" - if self._cur == -1: - return - - self._cur = -1 - self._async_remove_listener() - if self._change_listener: - self.hass.async_add_job(self._change_listener) - - @callback - def async_log_exception(self, logger, message_base, exception): - """Log an exception for this script. - - Should only be called on exceptions raised by this scripts async_run. - """ - step = self._exception_step - action = self.sequence[step] - action_type = _determine_action(action) - - error = None - meth = logger.error - - if isinstance(exception, vol.Invalid): - error_desc = "Invalid data" - - elif isinstance(exception, exceptions.TemplateError): - error_desc = "Error rendering template" - - elif isinstance(exception, exceptions.Unauthorized): - error_desc = "Unauthorized" - - elif isinstance(exception, exceptions.ServiceNotFound): - error_desc = "Service not found" - - else: - # Print the full stack trace, unknown error - error_desc = "Unknown error" - meth = logger.exception - error = "" - - if error is None: - error = str(exception) - - meth( - "%s. %s for %s at pos %s: %s", - message_base, - error_desc, - action_type, - step + 1, - error, - ) - - async def _handle_action(self, action, variables, context): - """Handle an action.""" - await self._actions[_determine_action(action)](action, variables, context) - - async def _async_delay(self, action, variables, context): - """Handle delay.""" - # Call ourselves in the future to continue work - unsub = None - - @callback - def async_script_delay(now): - """Handle delay.""" - with suppress(ValueError): - self._async_listener.remove(unsub) - - self.hass.async_create_task(self.async_run(variables, context)) - - delay = action[CONF_DELAY] - - try: - if isinstance(delay, template.Template): - delay = vol.All(cv.time_period, cv.positive_timedelta)( - delay.async_render(variables) - ) - elif isinstance(delay, dict): - delay_data = {} - delay_data.update(template.render_complex(delay, variables)) - delay = cv.time_period(delay_data) - except (exceptions.TemplateError, vol.Invalid) as ex: - _LOGGER.error("Error rendering '%s' delay template: %s", self.name, ex) - raise _StopScript - - self.last_action = action.get(CONF_ALIAS, f"delay {delay}") - self._log("Executing step %s" % self.last_action) - - unsub = async_track_point_in_utc_time( - self.hass, async_script_delay, date_util.utcnow() + delay - ) - self._async_listener.append(unsub) - raise _SuspendScript - - async def _async_wait_template(self, action, variables, context): - """Handle a wait template.""" - # Call ourselves in the future to continue work - wait_template = action[CONF_WAIT_TEMPLATE] - wait_template.hass = self.hass - - self.last_action = action.get(CONF_ALIAS, "wait template") - self._log("Executing step %s" % self.last_action) - - # check if condition already okay - if condition.async_template(self.hass, wait_template, variables): - return - - @callback - def async_script_wait(entity_id, from_s, to_s): - """Handle script after template condition is true.""" - self._async_remove_listener() - self.hass.async_create_task(self.async_run(variables, context)) - - self._async_listener.append( - async_track_template(self.hass, wait_template, async_script_wait, variables) - ) - - if CONF_TIMEOUT in action: - self._async_set_timeout( - action, variables, context, action.get(CONF_CONTINUE, True) - ) - - raise _SuspendScript - - async def _async_call_service(self, action, variables, context): - """Call the service specified in the action. - - This method is a coroutine. - """ - self.last_action = action.get(CONF_ALIAS, "call service") - self._log("Executing step %s" % self.last_action) - await service.async_call_from_config( - self.hass, - action, - blocking=True, - variables=variables, - validate_config=False, - context=context, - ) - - async def _async_device_automation(self, action, variables, context): - """Perform the device automation specified in the action. - - This method is a coroutine. - """ - self.last_action = action.get(CONF_ALIAS, "device automation") - self._log("Executing step %s" % self.last_action) - platform = await device_automation.async_get_device_automation_platform( - self.hass, action[CONF_DOMAIN], "action" - ) - await platform.async_call_action_from_config( - self.hass, action, variables, context - ) - - async def _async_activate_scene(self, action, variables, context): - """Activate the scene specified in the action. - - This method is a coroutine. - """ - self.last_action = action.get(CONF_ALIAS, "activate scene") - self._log("Executing step %s" % self.last_action) - await self.hass.services.async_call( - scene.DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: action[CONF_SCENE]}, - blocking=True, - context=context, - ) - - async def _async_fire_event(self, action, variables, context): - """Fire an event.""" - self.last_action = action.get(CONF_ALIAS, action[CONF_EVENT]) - self._log("Executing step %s" % self.last_action) - event_data = dict(action.get(CONF_EVENT_DATA, {})) - if CONF_EVENT_DATA_TEMPLATE in action: - try: - event_data.update( - template.render_complex(action[CONF_EVENT_DATA_TEMPLATE], variables) - ) - except exceptions.TemplateError as ex: - _LOGGER.error("Error rendering event data template: %s", ex) - - self.hass.bus.async_fire(action[CONF_EVENT], event_data, context=context) - - async def _async_check_condition(self, action, variables, context): - """Test if condition is matching.""" - config_cache_key = frozenset((k, str(v)) for k, v in action.items()) - config = self._config_cache.get(config_cache_key) - if not config: - config = await condition.async_from_config(self.hass, action, False) - self._config_cache[config_cache_key] = config - - self.last_action = action.get(CONF_ALIAS, action[CONF_CONDITION]) - check = config(self.hass, variables) - self._log(f"Test condition {self.last_action}: {check}") - - if not check: - raise _StopScript - - def _async_set_timeout(self, action, variables, context, continue_on_timeout): - """Schedule a timeout to abort or continue script.""" - timeout = action[CONF_TIMEOUT] - unsub = None - - @callback - def async_script_timeout(now): - """Call after timeout is retrieve.""" - with suppress(ValueError): - self._async_listener.remove(unsub) - - # Check if we want to continue to execute - # the script after the timeout - if continue_on_timeout: - self.hass.async_create_task(self.async_run(variables, context)) + self.last_triggered = utcnow() + if self._run_mode == RUN_MODE_LEGACY: + if self._runs: + shared = cast(Optional[_LegacyScriptRun], self._runs[0]) else: - self._log("Timeout reached, abort script.") - self.async_stop() + shared = None + run: _ScriptRunBase = _LegacyScriptRun( + self._hass, self, variables, context, self._log_exceptions, shared + ) + else: + if self._run_mode == RUN_MODE_BACKGROUND: + run = _BackgroundScriptRun( + self._hass, self, variables, context, self._log_exceptions + ) + else: + run = _BlockingScriptRun( + self._hass, self, variables, context, self._log_exceptions + ) + self._runs.append(run) + await run.async_run() - unsub = async_track_point_in_utc_time( - self.hass, async_script_timeout, date_util.utcnow() + timeout - ) - self._async_listener.append(unsub) + async def async_stop(self) -> None: + """Stop running script.""" + if not self.is_running: + return + await asyncio.shield(asyncio.gather(*(run.async_stop() for run in self._runs))) + self._changed() - def _async_remove_listener(self): - """Remove point in time listener, if any.""" - for unsub in self._async_listener: - unsub() - self._async_listener.clear() + def _log(self, msg, *args, level=logging.INFO): + if self.name: + msg = f"{self.name}: {msg}" + if level == _LOG_EXCEPTION: + self._logger.exception(msg, *args) + else: + self._logger.log(level, msg, *args) - def _log(self, msg): - """Logger helper.""" - if self.name is not None: - msg = f"Script {self.name}: {msg}" - - _LOGGER.info(msg) + def _raise(self, msg, *args, exception=None): + if not exception: + exception = exceptions.HomeAssistantError + self._log(msg, *args, level=logging.ERROR) + raise exception(msg % args) diff --git a/tests/components/demo/test_notify.py b/tests/components/demo/test_notify.py index 30fb49be47d..e30d65112e8 100644 --- a/tests/components/demo/test_notify.py +++ b/tests/components/demo/test_notify.py @@ -8,7 +8,7 @@ import voluptuous as vol import homeassistant.components.demo.notify as demo import homeassistant.components.notify as notify from homeassistant.core import callback -from homeassistant.helpers import discovery, script +from homeassistant.helpers import discovery from homeassistant.setup import setup_component from tests.common import assert_setup_component, get_test_home_assistant @@ -121,7 +121,7 @@ class TestNotifyDemo(unittest.TestCase): def test_calling_notify_from_script_loaded_from_yaml_without_title(self): """Test if we can call a notify from a script.""" self._setup_notify() - conf = { + step = { "service": "notify.notify", "data": { "data": { @@ -130,8 +130,8 @@ class TestNotifyDemo(unittest.TestCase): }, "data_template": {"message": "Test 123 {{ 2 + 2 }}\n"}, } - - script.call_from_config(self.hass, conf) + setup_component(self.hass, "script", {"script": {"test": {"sequence": step}}}) + self.hass.services.call("script", "test") self.hass.block_till_done() assert len(self.events) == 1 assert { @@ -144,7 +144,7 @@ class TestNotifyDemo(unittest.TestCase): def test_calling_notify_from_script_loaded_from_yaml_with_title(self): """Test if we can call a notify from a script.""" self._setup_notify() - conf = { + step = { "service": "notify.notify", "data": { "data": { @@ -153,8 +153,8 @@ class TestNotifyDemo(unittest.TestCase): }, "data_template": {"message": "Test 123 {{ 2 + 2 }}\n", "title": "Test"}, } - - script.call_from_config(self.hass, conf) + setup_component(self.hass, "script", {"script": {"test": {"sequence": step}}}) + self.hass.services.call("script", "test") self.hass.block_till_done() assert len(self.events) == 1 assert { diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py index 5e748e3adfe..443b131b2aa 100644 --- a/tests/helpers/test_script.py +++ b/tests/helpers/test_script.py @@ -1,11 +1,11 @@ """The tests for the Script component.""" # pylint: disable=protected-access +import asyncio from datetime import timedelta -import functools as ft +import logging from unittest import mock import asynctest -import jinja2 import pytest import voluptuous as vol @@ -21,80 +21,94 @@ from tests.common import async_fire_time_changed ENTITY_ID = "script.test" +_ALL_RUN_MODES = [None, "background", "blocking"] -async def test_firing_event(hass): + +async def test_firing_event_basic(hass): """Test the firing of events.""" event = "test_event" context = Context() - calls = [] @callback def record_event(event): """Add recorded event to set.""" - calls.append(event) + events.append(event) hass.bus.async_listen(event, record_event) - script_obj = script.Script( - hass, cv.SCRIPT_SCHEMA({"event": event, "event_data": {"hello": "world"}}) - ) + schema = cv.SCRIPT_SCHEMA({"event": event, "event_data": {"hello": "world"}}) - await script_obj.async_run(context=context) + # For this one test we'll make sure "legacy" works the same as None. + for run_mode in _ALL_RUN_MODES + ["legacy"]: + events = [] - await hass.async_block_till_done() + if run_mode is None: + script_obj = script.Script(hass, schema) + else: + script_obj = script.Script(hass, schema, run_mode=run_mode) - assert len(calls) == 1 - assert calls[0].context is context - assert calls[0].data.get("hello") == "world" - assert not script_obj.can_cancel + assert not script_obj.can_cancel + + await script_obj.async_run(context=context) + + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].context is context + assert events[0].data.get("hello") == "world" + assert not script_obj.can_cancel async def test_firing_event_template(hass): """Test the firing of events.""" event = "test_event" context = Context() - calls = [] @callback def record_event(event): """Add recorded event to set.""" - calls.append(event) + events.append(event) hass.bus.async_listen(event, record_event) - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - { - "event": event, - "event_data_template": { - "dict": { - 1: "{{ is_world }}", - 2: "{{ is_world }}{{ is_world }}", - 3: "{{ is_world }}{{ is_world }}{{ is_world }}", - }, - "list": ["{{ is_world }}", "{{ is_world }}{{ is_world }}"], + schema = cv.SCRIPT_SCHEMA( + { + "event": event, + "event_data_template": { + "dict": { + 1: "{{ is_world }}", + 2: "{{ is_world }}{{ is_world }}", + 3: "{{ is_world }}{{ is_world }}{{ is_world }}", }, - } - ), + "list": ["{{ is_world }}", "{{ is_world }}{{ is_world }}"], + }, + } ) - await script_obj.async_run({"is_world": "yes"}, context=context) + for run_mode in _ALL_RUN_MODES: + events = [] - await hass.async_block_till_done() + if run_mode is None: + script_obj = script.Script(hass, schema) + else: + script_obj = script.Script(hass, schema, run_mode=run_mode) - assert len(calls) == 1 - assert calls[0].context is context - assert calls[0].data == { - "dict": {1: "yes", 2: "yesyes", 3: "yesyesyes"}, - "list": ["yes", "yesyes"], - } - assert not script_obj.can_cancel + assert not script_obj.can_cancel + + await script_obj.async_run({"is_world": "yes"}, context=context) + + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].context is context + assert events[0].data == { + "dict": {1: "yes", 2: "yesyes", 3: "yesyesyes"}, + "list": ["yes", "yesyes"], + } -async def test_calling_service(hass): +async def test_calling_service_basic(hass): """Test the calling of a service.""" - calls = [] context = Context() @callback @@ -104,25 +118,76 @@ async def test_calling_service(hass): hass.services.async_register("test", "script", record_call) - hass.async_add_job( - ft.partial( - script.call_from_config, - hass, - {"service": "test.script", "data": {"hello": "world"}}, - context=context, - ) - ) + schema = cv.SCRIPT_SCHEMA({"service": "test.script", "data": {"hello": "world"}}) - await hass.async_block_till_done() + for run_mode in _ALL_RUN_MODES: + calls = [] - assert len(calls) == 1 - assert calls[0].context is context - assert calls[0].data.get("hello") == "world" + if run_mode is None: + script_obj = script.Script(hass, schema) + else: + script_obj = script.Script(hass, schema, run_mode=run_mode) + + assert not script_obj.can_cancel + + await script_obj.async_run(context=context) + + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].context is context + assert calls[0].data.get("hello") == "world" + + +async def test_cancel_no_wait(hass, caplog): + """Test stopping script.""" + event = "test_event" + + async def async_simulate_long_service(service): + """Simulate a service that takes a not insignificant time.""" + await asyncio.sleep(0.01) + + hass.services.async_register("test", "script", async_simulate_long_service) + + @callback + def monitor_event(event): + """Signal event happened.""" + event_sem.release() + + hass.bus.async_listen(event, monitor_event) + + schema = cv.SCRIPT_SCHEMA([{"event": event}, {"service": "test.script"}]) + + for run_mode in _ALL_RUN_MODES: + event_sem = asyncio.Semaphore(0) + + if run_mode is None: + script_obj = script.Script(hass, schema) + else: + script_obj = script.Script(hass, schema, run_mode=run_mode) + + tasks = [] + for _ in range(3): + if run_mode == "background": + await script_obj.async_run() + else: + hass.async_create_task(script_obj.async_run()) + tasks.append(hass.async_create_task(event_sem.acquire())) + await asyncio.wait_for(asyncio.gather(*tasks), 1) + + # Can't assert just yet because we haven't verified stopping works yet. + # If assert fails we can hang test if async_stop doesn't work. + script_was_runing = script_obj.is_running + + await script_obj.async_stop() + await hass.async_block_till_done() + + assert script_was_runing + assert not script_obj.is_running async def test_activating_scene(hass): """Test the activation of a scene.""" - calls = [] context = Context() @callback @@ -132,22 +197,29 @@ async def test_activating_scene(hass): hass.services.async_register(scene.DOMAIN, SERVICE_TURN_ON, record_call) - hass.async_add_job( - ft.partial( - script.call_from_config, hass, {"scene": "scene.hello"}, context=context - ) - ) + schema = cv.SCRIPT_SCHEMA({"scene": "scene.hello"}) - await hass.async_block_till_done() + for run_mode in _ALL_RUN_MODES: + calls = [] - assert len(calls) == 1 - assert calls[0].context is context - assert calls[0].data.get(ATTR_ENTITY_ID) == "scene.hello" + if run_mode is None: + script_obj = script.Script(hass, schema) + else: + script_obj = script.Script(hass, schema, run_mode=run_mode) + + assert not script_obj.can_cancel + + await script_obj.async_run(context=context) + + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].context is context + assert calls[0].data.get(ATTR_ENTITY_ID) == "scene.hello" async def test_calling_service_template(hass): """Test the calling of a service.""" - calls = [] context = Context() @callback @@ -157,45 +229,179 @@ async def test_calling_service_template(hass): hass.services.async_register("test", "script", record_call) - hass.async_add_job( - ft.partial( - script.call_from_config, - hass, - { - "service_template": """ - {% if True %} - test.script + schema = cv.SCRIPT_SCHEMA( + { + "service_template": """ + {% if True %} + test.script + {% else %} + test.not_script + {% endif %}""", + "data_template": { + "hello": """ + {% if is_world == 'yes' %} + world {% else %} - test.not_script - {% endif %}""", - "data_template": { - "hello": """ - {% if is_world == 'yes' %} - world - {% else %} - not world - {% endif %} - """ - }, + not world + {% endif %} + """ }, - {"is_world": "yes"}, - context=context, + } + ) + + for run_mode in _ALL_RUN_MODES: + calls = [] + + if run_mode is None: + script_obj = script.Script(hass, schema) + else: + script_obj = script.Script(hass, schema, run_mode=run_mode) + + assert not script_obj.can_cancel + + await script_obj.async_run({"is_world": "yes"}, context=context) + + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].context is context + assert calls[0].data.get("hello") == "world" + + +async def test_multiple_runs_no_wait(hass): + """Test multiple runs with no wait in script.""" + logger = logging.getLogger("TEST") + + async def async_simulate_long_service(service): + """Simulate a service that takes a not insignificant time.""" + + @callback + def service_done_cb(event): + logger.debug("simulated service (%s:%s) done", fire, listen) + service_done.set() + + calls.append(service) + + fire = service.data.get("fire") + listen = service.data.get("listen") + logger.debug("simulated service (%s:%s) started", fire, listen) + + service_done = asyncio.Event() + unsub = hass.bus.async_listen(listen, service_done_cb) + + hass.bus.async_fire(fire) + + await service_done.wait() + unsub() + + hass.services.async_register("test", "script", async_simulate_long_service) + + heard_event = asyncio.Event() + + @callback + def heard_event_cb(event): + logger.debug("heard: %s", event) + heard_event.set() + + schema = cv.SCRIPT_SCHEMA( + [ + { + "service": "test.script", + "data_template": {"fire": "{{ fire1 }}", "listen": "{{ listen1 }}"}, + }, + { + "service": "test.script", + "data_template": {"fire": "{{ fire2 }}", "listen": "{{ listen2 }}"}, + }, + ] + ) + + for run_mode in _ALL_RUN_MODES: + calls = [] + heard_event.clear() + + if run_mode is None: + script_obj = script.Script(hass, schema) + else: + script_obj = script.Script(hass, schema, run_mode=run_mode) + + # 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. + + unsub = hass.bus.async_listen("1", heard_event_cb) + + logger.debug("starting 1st script") + coro = script_obj.async_run( + {"fire1": "1", "listen1": "2", "fire2": "3", "listen2": "4"} ) - ) + if run_mode == "background": + await coro + else: + hass.async_create_task(coro) + await asyncio.wait_for(heard_event.wait(), 1) - await hass.async_block_till_done() + unsub() - assert len(calls) == 1 - assert calls[0].context is context - assert calls[0].data.get("hello") == "world" + logger.debug("starting 2nd script") + await script_obj.async_run( + {"fire1": "2", "listen1": "3", "fire2": "4", "listen2": "4"} + ) + + await hass.async_block_till_done() + + assert len(calls) == 4 -async def test_delay(hass): +async def test_delay_basic(hass): """Test the delay.""" - event = "test_event" - events = [] - context = Context() delay_alias = "delay step" + delay_started_flag = asyncio.Event() + + @callback + def delay_started_cb(): + delay_started_flag.set() + + delay = timedelta(milliseconds=10) + schema = cv.SCRIPT_SCHEMA({"delay": delay, "alias": delay_alias}) + + for run_mode in _ALL_RUN_MODES: + delay_started_flag.clear() + + if run_mode is None: + script_obj = script.Script(hass, schema, change_listener=delay_started_cb) + else: + script_obj = script.Script( + hass, schema, change_listener=delay_started_cb, run_mode=run_mode + ) + + assert script_obj.can_cancel + + try: + if run_mode == "background": + await script_obj.async_run() + else: + hass.async_create_task(script_obj.async_run()) + await asyncio.wait_for(delay_started_flag.wait(), 1) + + assert script_obj.is_running + assert script_obj.last_action == delay_alias + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + if run_mode in (None, "legacy"): + future = dt_util.utcnow() + delay + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert not script_obj.is_running + assert script_obj.last_action is None + + +async def test_multiple_runs_delay(hass): + """Test multiple runs with delay in script.""" + event = "test_event" + delay_started_flag = asyncio.Event() @callback def record_event(event): @@ -204,79 +410,105 @@ async def test_delay(hass): hass.bus.async_listen(event, record_event) - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [ - {"event": event}, - {"delay": {"seconds": 5}, "alias": delay_alias}, - {"event": event}, - ] - ), + @callback + def delay_started_cb(): + delay_started_flag.set() + + delay = timedelta(milliseconds=10) + schema = cv.SCRIPT_SCHEMA( + [ + {"event": event, "event_data": {"value": 1}}, + {"delay": delay}, + {"event": event, "event_data": {"value": 2}}, + ] ) - await script_obj.async_run(context=context) - await hass.async_block_till_done() + for run_mode in _ALL_RUN_MODES: + events = [] + delay_started_flag.clear() - assert script_obj.is_running - assert script_obj.can_cancel - assert script_obj.last_action == delay_alias - assert len(events) == 1 + if run_mode is None: + script_obj = script.Script(hass, schema, change_listener=delay_started_cb) + else: + script_obj = script.Script( + hass, schema, change_listener=delay_started_cb, run_mode=run_mode + ) - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + try: + if run_mode == "background": + await script_obj.async_run() + else: + hass.async_create_task(script_obj.async_run()) + await asyncio.wait_for(delay_started_flag.wait(), 1) - assert not script_obj.is_running - assert len(events) == 2 - assert events[0].context is context - assert events[1].context is context + assert script_obj.is_running + assert len(events) == 1 + assert events[-1].data["value"] == 1 + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + # Start second run of script while first run is in a delay. + await script_obj.async_run() + if run_mode in (None, "legacy"): + future = dt_util.utcnow() + delay + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert not script_obj.is_running + if run_mode in (None, "legacy"): + assert len(events) == 2 + else: + assert len(events) == 4 + assert events[-3].data["value"] == 1 + assert events[-2].data["value"] == 2 + assert events[-1].data["value"] == 2 -async def test_delay_template(hass): +async def test_delay_template_ok(hass): """Test the delay as a template.""" - event = "test_event" - events = [] - delay_alias = "delay step" + delay_started_flag = asyncio.Event() @callback - def record_event(event): - """Add recorded event to set.""" - events.append(event) + def delay_started_cb(): + delay_started_flag.set() - hass.bus.async_listen(event, record_event) + schema = cv.SCRIPT_SCHEMA({"delay": "00:00:{{ 1 }}"}) - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [ - {"event": event}, - {"delay": "00:00:{{ 5 }}", "alias": delay_alias}, - {"event": event}, - ] - ), - ) + for run_mode in _ALL_RUN_MODES: + delay_started_flag.clear() - await script_obj.async_run() - await hass.async_block_till_done() + if run_mode is None: + script_obj = script.Script(hass, schema, change_listener=delay_started_cb) + else: + script_obj = script.Script( + hass, schema, change_listener=delay_started_cb, run_mode=run_mode + ) - assert script_obj.is_running - assert script_obj.can_cancel - assert script_obj.last_action == delay_alias - assert len(events) == 1 + assert script_obj.can_cancel - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + try: + if run_mode == "background": + await script_obj.async_run() + else: + hass.async_create_task(script_obj.async_run()) + await asyncio.wait_for(delay_started_flag.wait(), 1) + assert script_obj.is_running + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + if run_mode in (None, "legacy"): + future = dt_util.utcnow() + timedelta(seconds=1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() - assert not script_obj.is_running - assert len(events) == 2 + assert not script_obj.is_running -async def test_delay_invalid_template(hass): +async def test_delay_template_invalid(hass, caplog): """Test the delay as a template that fails.""" event = "test_event" - events = [] @callback def record_event(event): @@ -285,71 +517,82 @@ async def test_delay_invalid_template(hass): hass.bus.async_listen(event, record_event) - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [ - {"event": event}, - {"delay": "{{ invalid_delay }}"}, - {"delay": {"seconds": 5}}, - {"event": event}, - ] - ), + schema = cv.SCRIPT_SCHEMA( + [ + {"event": event}, + {"delay": "{{ invalid_delay }}"}, + {"delay": {"seconds": 5}}, + {"event": event}, + ] ) - with mock.patch.object(script, "_LOGGER") as mock_logger: + for run_mode in _ALL_RUN_MODES: + events = [] + + if run_mode is None: + script_obj = script.Script(hass, schema) + else: + script_obj = script.Script(hass, schema, run_mode=run_mode) + start_idx = len(caplog.records) + await script_obj.async_run() await hass.async_block_till_done() - assert mock_logger.error.called - assert not script_obj.is_running - assert len(events) == 1 + assert any( + rec.levelname == "ERROR" and "Error rendering" in rec.message + for rec in caplog.records[start_idx:] + ) + + assert not script_obj.is_running + assert len(events) == 1 -async def test_delay_complex_template(hass): +async def test_delay_template_complex_ok(hass): """Test the delay with a working complex template.""" - event = "test_event" - events = [] - delay_alias = "delay step" + delay_started_flag = asyncio.Event() @callback - def record_event(event): - """Add recorded event to set.""" - events.append(event) + def delay_started_cb(): + delay_started_flag.set() - hass.bus.async_listen(event, record_event) + milliseconds = 10 + schema = cv.SCRIPT_SCHEMA({"delay": {"milliseconds": "{{ milliseconds }}"}}) - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [ - {"event": event}, - {"delay": {"seconds": "{{ 5 }}"}, "alias": delay_alias}, - {"event": event}, - ] - ), - ) + for run_mode in _ALL_RUN_MODES: + delay_started_flag.clear() - await script_obj.async_run() - await hass.async_block_till_done() + if run_mode is None: + script_obj = script.Script(hass, schema, change_listener=delay_started_cb) + else: + script_obj = script.Script( + hass, schema, change_listener=delay_started_cb, run_mode=run_mode + ) - assert script_obj.is_running - assert script_obj.can_cancel - assert script_obj.last_action == delay_alias - assert len(events) == 1 + assert script_obj.can_cancel - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + try: + coro = script_obj.async_run({"milliseconds": milliseconds}) + if run_mode == "background": + await coro + else: + hass.async_create_task(coro) + await asyncio.wait_for(delay_started_flag.wait(), 1) + assert script_obj.is_running + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + if run_mode in (None, "legacy"): + future = dt_util.utcnow() + timedelta(milliseconds=milliseconds) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() - assert not script_obj.is_running - assert len(events) == 2 + assert not script_obj.is_running -async def test_delay_complex_invalid_template(hass): +async def test_delay_template_complex_invalid(hass, caplog): """Test the delay with a complex template that fails.""" event = "test_event" - events = [] @callback def record_event(event): @@ -358,31 +601,44 @@ async def test_delay_complex_invalid_template(hass): hass.bus.async_listen(event, record_event) - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [ - {"event": event}, - {"delay": {"seconds": "{{ invalid_delay }}"}}, - {"delay": {"seconds": "{{ 5 }}"}}, - {"event": event}, - ] - ), + schema = cv.SCRIPT_SCHEMA( + [ + {"event": event}, + {"delay": {"seconds": "{{ invalid_delay }}"}}, + {"delay": {"seconds": 5}}, + {"event": event}, + ] ) - with mock.patch.object(script, "_LOGGER") as mock_logger: + for run_mode in _ALL_RUN_MODES: + events = [] + + if run_mode is None: + script_obj = script.Script(hass, schema) + else: + script_obj = script.Script(hass, schema, run_mode=run_mode) + start_idx = len(caplog.records) + await script_obj.async_run() await hass.async_block_till_done() - assert mock_logger.error.called - assert not script_obj.is_running - assert len(events) == 1 + assert any( + rec.levelname == "ERROR" and "Error rendering" in rec.message + for rec in caplog.records[start_idx:] + ) + + assert not script_obj.is_running + assert len(events) == 1 -async def test_cancel_while_delay(hass): +async def test_cancel_delay(hass): """Test the cancelling while the delay is present.""" + delay_started_flag = asyncio.Event() event = "test_event" - events = [] + + @callback + def delay_started_cb(): + delay_started_flag.set() @callback def record_event(event): @@ -391,35 +647,101 @@ async def test_cancel_while_delay(hass): hass.bus.async_listen(event, record_event) - script_obj = script.Script( - hass, cv.SCRIPT_SCHEMA([{"delay": {"seconds": 5}}, {"event": event}]) - ) + delay = timedelta(milliseconds=10) + schema = cv.SCRIPT_SCHEMA([{"delay": delay}, {"event": event}]) - await script_obj.async_run() - await hass.async_block_till_done() + for run_mode in _ALL_RUN_MODES: + delay_started_flag.clear() + events = [] - assert script_obj.is_running - assert len(events) == 0 + if run_mode is None: + script_obj = script.Script(hass, schema, change_listener=delay_started_cb) + else: + script_obj = script.Script( + hass, schema, change_listener=delay_started_cb, run_mode=run_mode + ) - script_obj.async_stop() + try: + if run_mode == "background": + await script_obj.async_run() + else: + hass.async_create_task(script_obj.async_run()) + await asyncio.wait_for(delay_started_flag.wait(), 1) - assert not script_obj.is_running + assert script_obj.is_running + assert len(events) == 0 + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + await script_obj.async_stop() - # Make sure the script is really stopped. - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + assert not script_obj.is_running - assert not script_obj.is_running - assert len(events) == 0 + # Make sure the script is really stopped. + + if run_mode in (None, "legacy"): + future = dt_util.utcnow() + delay + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert not script_obj.is_running + assert len(events) == 0 -async def test_wait_template(hass): +async def test_wait_template_basic(hass): """Test the wait template.""" - event = "test_event" - events = [] - context = Context() wait_alias = "wait step" + wait_started_flag = asyncio.Event() + + @callback + def wait_started_cb(): + wait_started_flag.set() + + schema = cv.SCRIPT_SCHEMA( + { + "wait_template": "{{ states.switch.test.state == 'off' }}", + "alias": wait_alias, + } + ) + + for run_mode in _ALL_RUN_MODES: + wait_started_flag.clear() + hass.states.async_set("switch.test", "on") + + if run_mode is None: + script_obj = script.Script(hass, schema, change_listener=wait_started_cb) + else: + script_obj = script.Script( + hass, schema, change_listener=wait_started_cb, run_mode=run_mode + ) + + assert script_obj.can_cancel + + try: + if run_mode == "background": + await script_obj.async_run() + else: + hass.async_create_task(script_obj.async_run()) + await asyncio.wait_for(wait_started_flag.wait(), 1) + + assert script_obj.is_running + assert script_obj.last_action == wait_alias + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + hass.states.async_set("switch.test", "off") + await hass.async_block_till_done() + + assert not script_obj.is_running + assert script_obj.last_action is None + + +async def test_multiple_runs_wait_template(hass): + """Test multiple runs with wait_template in script.""" + event = "test_event" + wait_started_flag = asyncio.Event() @callback def record_event(event): @@ -428,44 +750,70 @@ async def test_wait_template(hass): hass.bus.async_listen(event, record_event) - hass.states.async_set("switch.test", "on") + @callback + def wait_started_cb(): + wait_started_flag.set() - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [ - {"event": event}, - { - "wait_template": "{{states.switch.test.state == 'off'}}", - "alias": wait_alias, - }, - {"event": event}, - ] - ), + schema = cv.SCRIPT_SCHEMA( + [ + {"event": event, "event_data": {"value": 1}}, + {"wait_template": "{{ states.switch.test.state == 'off' }}"}, + {"event": event, "event_data": {"value": 2}}, + ] ) - await script_obj.async_run(context=context) - await hass.async_block_till_done() + for run_mode in _ALL_RUN_MODES: + events = [] + wait_started_flag.clear() + hass.states.async_set("switch.test", "on") - assert script_obj.is_running - assert script_obj.can_cancel - assert script_obj.last_action == wait_alias - assert len(events) == 1 + if run_mode is None: + script_obj = script.Script(hass, schema, change_listener=wait_started_cb) + else: + script_obj = script.Script( + hass, schema, change_listener=wait_started_cb, run_mode=run_mode + ) - hass.states.async_set("switch.test", "off") - await hass.async_block_till_done() + try: + if run_mode == "background": + await script_obj.async_run() + else: + hass.async_create_task(script_obj.async_run()) + await asyncio.wait_for(wait_started_flag.wait(), 1) - assert not script_obj.is_running - assert len(events) == 2 - assert events[0].context is context - assert events[1].context is context + assert script_obj.is_running + assert len(events) == 1 + assert events[-1].data["value"] == 1 + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + # Start second run of script while first run is in wait_template. + if run_mode == "blocking": + hass.async_create_task(script_obj.async_run()) + else: + await script_obj.async_run() + hass.states.async_set("switch.test", "off") + await hass.async_block_till_done() + + assert not script_obj.is_running + if run_mode in (None, "legacy"): + assert len(events) == 2 + else: + assert len(events) == 4 + assert events[-3].data["value"] == 1 + assert events[-2].data["value"] == 2 + assert events[-1].data["value"] == 2 -async def test_wait_template_cancel(hass): - """Test the wait template cancel action.""" +async def test_cancel_wait_template(hass): + """Test the cancelling while wait_template is present.""" + wait_started_flag = asyncio.Event() event = "test_event" - events = [] - wait_alias = "wait step" + + @callback + def wait_started_cb(): + wait_started_flag.set() @callback def record_event(event): @@ -474,46 +822,54 @@ async def test_wait_template_cancel(hass): hass.bus.async_listen(event, record_event) - hass.states.async_set("switch.test", "on") - - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [ - {"event": event}, - { - "wait_template": "{{states.switch.test.state == 'off'}}", - "alias": wait_alias, - }, - {"event": event}, - ] - ), + schema = cv.SCRIPT_SCHEMA( + [ + {"wait_template": "{{ states.switch.test.state == 'off' }}"}, + {"event": event}, + ] ) - await script_obj.async_run() - await hass.async_block_till_done() + for run_mode in _ALL_RUN_MODES: + wait_started_flag.clear() + events = [] + hass.states.async_set("switch.test", "on") - assert script_obj.is_running - assert script_obj.can_cancel - assert script_obj.last_action == wait_alias - assert len(events) == 1 + if run_mode is None: + script_obj = script.Script(hass, schema, change_listener=wait_started_cb) + else: + script_obj = script.Script( + hass, schema, change_listener=wait_started_cb, run_mode=run_mode + ) - script_obj.async_stop() + try: + if run_mode == "background": + await script_obj.async_run() + else: + hass.async_create_task(script_obj.async_run()) + await asyncio.wait_for(wait_started_flag.wait(), 1) - assert not script_obj.is_running - assert len(events) == 1 + assert script_obj.is_running + assert len(events) == 0 + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + await script_obj.async_stop() - hass.states.async_set("switch.test", "off") - await hass.async_block_till_done() + assert not script_obj.is_running - assert not script_obj.is_running - assert len(events) == 1 + # Make sure the script is really stopped. + + hass.states.async_set("switch.test", "off") + await hass.async_block_till_done() + + assert not script_obj.is_running + assert len(events) == 0 async def test_wait_template_not_schedule(hass): """Test the wait template with correct condition.""" event = "test_event" - events = [] @callback def record_event(event): @@ -524,30 +880,33 @@ async def test_wait_template_not_schedule(hass): hass.states.async_set("switch.test", "on") - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [ - {"event": event}, - {"wait_template": "{{states.switch.test.state == 'on'}}"}, - {"event": event}, - ] - ), + schema = cv.SCRIPT_SCHEMA( + [ + {"event": event}, + {"wait_template": "{{ states.switch.test.state == 'on' }}"}, + {"event": event}, + ] ) - await script_obj.async_run() - await hass.async_block_till_done() + for run_mode in _ALL_RUN_MODES: + events = [] - assert not script_obj.is_running - assert script_obj.can_cancel - assert len(events) == 2 + if run_mode is None: + script_obj = script.Script(hass, schema) + else: + script_obj = script.Script(hass, schema, run_mode=run_mode) + + await script_obj.async_run() + await hass.async_block_till_done() + + assert not script_obj.is_running + assert len(events) == 2 async def test_wait_template_timeout_halt(hass): """Test the wait template, halt on timeout.""" event = "test_event" - events = [] - wait_alias = "wait step" + wait_started_flag = asyncio.Event() @callback def record_event(event): @@ -556,45 +915,61 @@ async def test_wait_template_timeout_halt(hass): hass.bus.async_listen(event, record_event) + @callback + def wait_started_cb(): + wait_started_flag.set() + hass.states.async_set("switch.test", "on") - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [ - {"event": event}, - { - "wait_template": "{{states.switch.test.state == 'off'}}", - "continue_on_timeout": False, - "timeout": 5, - "alias": wait_alias, - }, - {"event": event}, - ] - ), + timeout = timedelta(milliseconds=10) + schema = cv.SCRIPT_SCHEMA( + [ + { + "wait_template": "{{ states.switch.test.state == 'off' }}", + "continue_on_timeout": False, + "timeout": timeout, + }, + {"event": event}, + ] ) - await script_obj.async_run() - await hass.async_block_till_done() + for run_mode in _ALL_RUN_MODES: + events = [] + wait_started_flag.clear() - assert script_obj.is_running - assert script_obj.can_cancel - assert script_obj.last_action == wait_alias - assert len(events) == 1 + if run_mode is None: + script_obj = script.Script(hass, schema, change_listener=wait_started_cb) + else: + script_obj = script.Script( + hass, schema, change_listener=wait_started_cb, run_mode=run_mode + ) - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + try: + if run_mode == "background": + await script_obj.async_run() + else: + hass.async_create_task(script_obj.async_run()) + await asyncio.wait_for(wait_started_flag.wait(), 1) - assert not script_obj.is_running - assert len(events) == 1 + assert script_obj.is_running + assert len(events) == 0 + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + if run_mode in (None, "legacy"): + future = dt_util.utcnow() + timeout + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert not script_obj.is_running + assert len(events) == 0 async def test_wait_template_timeout_continue(hass): """Test the wait template with continuing the script.""" event = "test_event" - events = [] - wait_alias = "wait step" + wait_started_flag = asyncio.Event() @callback def record_event(event): @@ -603,45 +978,61 @@ async def test_wait_template_timeout_continue(hass): hass.bus.async_listen(event, record_event) + @callback + def wait_started_cb(): + wait_started_flag.set() + hass.states.async_set("switch.test", "on") - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [ - {"event": event}, - { - "wait_template": "{{states.switch.test.state == 'off'}}", - "timeout": 5, - "continue_on_timeout": True, - "alias": wait_alias, - }, - {"event": event}, - ] - ), + timeout = timedelta(milliseconds=10) + schema = cv.SCRIPT_SCHEMA( + [ + { + "wait_template": "{{ states.switch.test.state == 'off' }}", + "continue_on_timeout": True, + "timeout": timeout, + }, + {"event": event}, + ] ) - await script_obj.async_run() - await hass.async_block_till_done() + for run_mode in _ALL_RUN_MODES: + events = [] + wait_started_flag.clear() - assert script_obj.is_running - assert script_obj.can_cancel - assert script_obj.last_action == wait_alias - assert len(events) == 1 + if run_mode is None: + script_obj = script.Script(hass, schema, change_listener=wait_started_cb) + else: + script_obj = script.Script( + hass, schema, change_listener=wait_started_cb, run_mode=run_mode + ) - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + try: + if run_mode == "background": + await script_obj.async_run() + else: + hass.async_create_task(script_obj.async_run()) + await asyncio.wait_for(wait_started_flag.wait(), 1) - assert not script_obj.is_running - assert len(events) == 2 + assert script_obj.is_running + assert len(events) == 0 + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + if run_mode in (None, "legacy"): + future = dt_util.utcnow() + timeout + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert not script_obj.is_running + assert len(events) == 1 async def test_wait_template_timeout_default(hass): - """Test the wait template with default contiune.""" + """Test the wait template with default continue.""" event = "test_event" - events = [] - wait_alias = "wait step" + wait_started_flag = asyncio.Event() @callback def record_event(event): @@ -650,128 +1041,99 @@ async def test_wait_template_timeout_default(hass): hass.bus.async_listen(event, record_event) + @callback + def wait_started_cb(): + wait_started_flag.set() + hass.states.async_set("switch.test", "on") - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [ - {"event": event}, - { - "wait_template": "{{states.switch.test.state == 'off'}}", - "timeout": 5, - "alias": wait_alias, - }, - {"event": event}, - ] - ), + timeout = timedelta(milliseconds=10) + schema = cv.SCRIPT_SCHEMA( + [ + { + "wait_template": "{{ states.switch.test.state == 'off' }}", + "timeout": timeout, + }, + {"event": event}, + ] ) - await script_obj.async_run() - await hass.async_block_till_done() + for run_mode in _ALL_RUN_MODES: + events = [] + wait_started_flag.clear() - assert script_obj.is_running - assert script_obj.can_cancel - assert script_obj.last_action == wait_alias - assert len(events) == 1 + if run_mode is None: + script_obj = script.Script(hass, schema, change_listener=wait_started_cb) + else: + script_obj = script.Script( + hass, schema, change_listener=wait_started_cb, run_mode=run_mode + ) - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() + try: + if run_mode == "background": + await script_obj.async_run() + else: + hass.async_create_task(script_obj.async_run()) + await asyncio.wait_for(wait_started_flag.wait(), 1) - assert not script_obj.is_running - assert len(events) == 2 + assert script_obj.is_running + assert len(events) == 0 + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + if run_mode in (None, "legacy"): + future = dt_util.utcnow() + timeout + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert not script_obj.is_running + assert len(events) == 1 async def test_wait_template_variables(hass): """Test the wait template with variables.""" - event = "test_event" - events = [] - wait_alias = "wait step" + wait_started_flag = asyncio.Event() @callback - def record_event(event): - """Add recorded event to set.""" - events.append(event) + def wait_started_cb(): + wait_started_flag.set() - hass.bus.async_listen(event, record_event) + schema = cv.SCRIPT_SCHEMA({"wait_template": "{{ is_state(data, 'off') }}"}) - hass.states.async_set("switch.test", "on") + for run_mode in _ALL_RUN_MODES: + wait_started_flag.clear() + hass.states.async_set("switch.test", "on") - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [ - {"event": event}, - {"wait_template": "{{is_state(data, 'off')}}", "alias": wait_alias}, - {"event": event}, - ] - ), - ) + if run_mode is None: + script_obj = script.Script(hass, schema, change_listener=wait_started_cb) + else: + script_obj = script.Script( + hass, schema, change_listener=wait_started_cb, run_mode=run_mode + ) - await script_obj.async_run({"data": "switch.test"}) - await hass.async_block_till_done() + assert script_obj.can_cancel - assert script_obj.is_running - assert script_obj.can_cancel - assert script_obj.last_action == wait_alias - assert len(events) == 1 + try: + coro = script_obj.async_run({"data": "switch.test"}) + if run_mode == "background": + await coro + else: + hass.async_create_task(coro) + await asyncio.wait_for(wait_started_flag.wait(), 1) - hass.states.async_set("switch.test", "off") - await hass.async_block_till_done() + assert script_obj.is_running + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + hass.states.async_set("switch.test", "off") + await hass.async_block_till_done() - assert not script_obj.is_running - assert len(events) == 2 + assert not script_obj.is_running -async def test_passing_variables_to_script(hass): - """Test if we can pass variables to script.""" - calls = [] - - @callback - def record_call(service): - """Add recorded event to set.""" - calls.append(service) - - hass.services.async_register("test", "script", record_call) - - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [ - { - "service": "test.script", - "data_template": {"hello": "{{ greeting }}"}, - }, - {"delay": "{{ delay_period }}"}, - { - "service": "test.script", - "data_template": {"hello": "{{ greeting2 }}"}, - }, - ] - ), - ) - - await script_obj.async_run( - {"greeting": "world", "greeting2": "universe", "delay_period": "00:00:05"} - ) - - await hass.async_block_till_done() - - assert script_obj.is_running - assert len(calls) == 1 - assert calls[-1].data["hello"] == "world" - - future = dt_util.utcnow() + timedelta(seconds=5) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert not script_obj.is_running - assert len(calls) == 2 - assert calls[-1].data["hello"] == "universe" - - -async def test_condition(hass): +async def test_condition_basic(hass): """Test if we can use conditions in a script.""" event = "test_event" events = [] @@ -783,31 +1145,39 @@ async def test_condition(hass): hass.bus.async_listen(event, record_event) - hass.states.async_set("test.entity", "hello") - - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [ - {"event": event}, - { - "condition": "template", - "value_template": '{{ states.test.entity.state == "hello" }}', - }, - {"event": event}, - ] - ), + schema = cv.SCRIPT_SCHEMA( + [ + {"event": event}, + { + "condition": "template", + "value_template": "{{ states.test.entity.state == 'hello' }}", + }, + {"event": event}, + ] ) - await script_obj.async_run() - await hass.async_block_till_done() - assert len(events) == 2 + for run_mode in _ALL_RUN_MODES: + events = [] + hass.states.async_set("test.entity", "hello") - hass.states.async_set("test.entity", "goodbye") + if run_mode is None: + script_obj = script.Script(hass, schema) + else: + script_obj = script.Script(hass, schema, run_mode=run_mode) - await script_obj.async_run() - await hass.async_block_till_done() - assert len(events) == 3 + assert not script_obj.can_cancel + + await script_obj.async_run() + await hass.async_block_till_done() + + assert len(events) == 2 + + hass.states.async_set("test.entity", "goodbye") + + await script_obj.async_run() + await hass.async_block_till_done() + + assert len(events) == 3 @asynctest.patch("homeassistant.helpers.script.condition.async_from_config") @@ -846,7 +1216,7 @@ async def test_condition_created_once(async_from_config, hass): assert len(script_obj._config_cache) == 1 -async def test_all_conditions_cached(hass): +async def test_condition_all_cached(hass): """Test that multiple conditions get cached.""" event = "test_event" events = [] @@ -887,55 +1257,63 @@ async def test_last_triggered(hass): """Test the last_triggered.""" event = "test_event" - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [{"event": event}, {"delay": {"seconds": 5}}, {"event": event}] - ), - ) + schema = cv.SCRIPT_SCHEMA({"event": event}) - assert script_obj.last_triggered is None + for run_mode in _ALL_RUN_MODES: + if run_mode is None: + script_obj = script.Script(hass, schema) + else: + script_obj = script.Script(hass, schema, run_mode=run_mode) - time = dt_util.utcnow() - with mock.patch("homeassistant.helpers.script.date_util.utcnow", return_value=time): - await script_obj.async_run() - await hass.async_block_till_done() + assert script_obj.last_triggered is None - assert script_obj.last_triggered == time + time = dt_util.utcnow() + with mock.patch("homeassistant.helpers.script.utcnow", return_value=time): + await script_obj.async_run() + await hass.async_block_till_done() + + assert script_obj.last_triggered == time async def test_propagate_error_service_not_found(hass): """Test that a script aborts when a service is not found.""" - events = [] + event = "test_event" @callback def record_event(event): events.append(event) - hass.bus.async_listen("test_event", record_event) + hass.bus.async_listen(event, record_event) - script_obj = script.Script( - hass, cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": "test_event"}]) - ) + schema = cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": event}]) - with pytest.raises(exceptions.ServiceNotFound): - await script_obj.async_run() + run_modes = _ALL_RUN_MODES + if "background" in run_modes: + run_modes.remove("background") + for run_mode in run_modes: + events = [] - assert len(events) == 0 - assert script_obj._cur == -1 + if run_mode is None: + script_obj = script.Script(hass, schema) + else: + script_obj = script.Script(hass, schema, run_mode=run_mode) + + with pytest.raises(exceptions.ServiceNotFound): + await script_obj.async_run() + + assert len(events) == 0 + assert not script_obj.is_running async def test_propagate_error_invalid_service_data(hass): """Test that a script aborts when we send invalid service data.""" - events = [] + event = "test_event" @callback def record_event(event): events.append(event) - hass.bus.async_listen("test_event", record_event) - - calls = [] + hass.bus.async_listen(event, record_event) @callback def record_call(service): @@ -946,32 +1324,39 @@ async def test_propagate_error_invalid_service_data(hass): "test", "script", record_call, schema=vol.Schema({"text": str}) ) - script_obj = script.Script( - hass, - cv.SCRIPT_SCHEMA( - [{"service": "test.script", "data": {"text": 1}}, {"event": "test_event"}] - ), + schema = cv.SCRIPT_SCHEMA( + [{"service": "test.script", "data": {"text": 1}}, {"event": event}] ) - with pytest.raises(vol.Invalid): - await script_obj.async_run() + run_modes = _ALL_RUN_MODES + if "background" in run_modes: + run_modes.remove("background") + for run_mode in run_modes: + events = [] + calls = [] - assert len(events) == 0 - assert len(calls) == 0 - assert script_obj._cur == -1 + if run_mode is None: + script_obj = script.Script(hass, schema) + else: + script_obj = script.Script(hass, schema, run_mode=run_mode) + + with pytest.raises(vol.Invalid): + await script_obj.async_run() + + assert len(events) == 0 + assert len(calls) == 0 + assert not script_obj.is_running async def test_propagate_error_service_exception(hass): """Test that a script aborts when a service throws an exception.""" - events = [] + event = "test_event" @callback def record_event(event): events.append(event) - hass.bus.async_listen("test_event", record_event) - - calls = [] + hass.bus.async_listen(event, record_event) @callback def record_call(service): @@ -980,48 +1365,24 @@ async def test_propagate_error_service_exception(hass): hass.services.async_register("test", "script", record_call) - script_obj = script.Script( - hass, cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": "test_event"}]) - ) + schema = cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": event}]) - with pytest.raises(ValueError): - await script_obj.async_run() + run_modes = _ALL_RUN_MODES + if "background" in run_modes: + run_modes.remove("background") + for run_mode in run_modes: + events = [] - assert len(events) == 0 - assert len(calls) == 0 - assert script_obj._cur == -1 - - -def test_log_exception(): - """Test logged output.""" - script_obj = script.Script( - None, cv.SCRIPT_SCHEMA([{"service": "test.script"}, {"event": "test_event"}]) - ) - script_obj._exception_step = 1 - - for exc, msg in ( - (vol.Invalid("Invalid number"), "Invalid data"), - ( - exceptions.TemplateError(jinja2.TemplateError("Unclosed bracket")), - "Error rendering template", - ), - (exceptions.Unauthorized(), "Unauthorized"), - (exceptions.ServiceNotFound("light", "turn_on"), "Service not found"), - (ValueError("Cannot parse JSON"), "Unknown error"), - ): - logger = mock.Mock() - script_obj.async_log_exception(logger, "Test error", exc) - - assert len(logger.mock_calls) == 1 - _, _, p_error_desc, p_action_type, p_step, p_error = logger.mock_calls[0][1] - - assert p_error_desc == msg - assert p_action_type == script.ACTION_FIRE_EVENT - assert p_step == 2 - if isinstance(exc, ValueError): - assert p_error == "" + if run_mode is None: + script_obj = script.Script(hass, schema) else: - assert p_error == str(exc) + script_obj = script.Script(hass, schema, run_mode=run_mode) + + with pytest.raises(ValueError): + await script_obj.async_run() + + assert len(events) == 0 + assert not script_obj.is_running async def test_referenced_entities(): @@ -1078,3 +1439,307 @@ async def test_referenced_devices(): assert script_obj.referenced_devices == {"script-dev-id", "condition-dev-id"} # Test we cache results. assert script_obj.referenced_devices is script_obj.referenced_devices + + +async def test_if_running_with_legacy_run_mode(hass, caplog): + """Test using if_running with run_mode='legacy'.""" + # TODO: REMOVE + if _ALL_RUN_MODES == [None]: + return + + with pytest.raises(exceptions.HomeAssistantError): + script.Script( + hass, + [], + if_running="ignore", + run_mode="legacy", + logger=logging.getLogger("TEST"), + ) + assert any( + rec.levelname == "ERROR" + and rec.name == "TEST" + and all(text in rec.message for text in ("if_running", "legacy")) + for rec in caplog.records + ) + + +async def test_if_running_ignore(hass, caplog): + """Test overlapping runs with if_running='ignore'.""" + # TODO: REMOVE + if _ALL_RUN_MODES == [None]: + return + + event = "test_event" + events = [] + wait_started_flag = asyncio.Event() + + @callback + def record_event(event): + """Add recorded event to set.""" + events.append(event) + + hass.bus.async_listen(event, record_event) + + @callback + def wait_started_cb(): + wait_started_flag.set() + + hass.states.async_set("switch.test", "on") + + script_obj = script.Script( + hass, + cv.SCRIPT_SCHEMA( + [ + {"event": event, "event_data": {"value": 1}}, + {"wait_template": "{{ states.switch.test.state == 'off' }}"}, + {"event": event, "event_data": {"value": 2}}, + ] + ), + change_listener=wait_started_cb, + if_running="ignore", + run_mode="background", + logger=logging.getLogger("TEST"), + ) + + try: + await script_obj.async_run() + await asyncio.wait_for(wait_started_flag.wait(), 1) + + assert script_obj.is_running + assert len(events) == 1 + assert events[0].data["value"] == 1 + + # Start second run of script while first run is suspended in wait_template. + # This should ignore second run. + + await script_obj.async_run() + + assert script_obj.is_running + assert any( + rec.levelname == "INFO" and rec.name == "TEST" and "Skipping" in rec.message + for rec in caplog.records + ) + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + hass.states.async_set("switch.test", "off") + await hass.async_block_till_done() + + assert not script_obj.is_running + assert len(events) == 2 + assert events[1].data["value"] == 2 + + +async def test_if_running_error(hass, caplog): + """Test overlapping runs with if_running='error'.""" + # TODO: REMOVE + if _ALL_RUN_MODES == [None]: + return + + event = "test_event" + events = [] + wait_started_flag = asyncio.Event() + + @callback + def record_event(event): + """Add recorded event to set.""" + events.append(event) + + hass.bus.async_listen(event, record_event) + + @callback + def wait_started_cb(): + wait_started_flag.set() + + hass.states.async_set("switch.test", "on") + + script_obj = script.Script( + hass, + cv.SCRIPT_SCHEMA( + [ + {"event": event, "event_data": {"value": 1}}, + {"wait_template": "{{ states.switch.test.state == 'off' }}"}, + {"event": event, "event_data": {"value": 2}}, + ] + ), + change_listener=wait_started_cb, + if_running="error", + run_mode="background", + logger=logging.getLogger("TEST"), + ) + + try: + await script_obj.async_run() + await asyncio.wait_for(wait_started_flag.wait(), 1) + + assert script_obj.is_running + assert len(events) == 1 + assert events[0].data["value"] == 1 + + # Start second run of script while first run is suspended in wait_template. + # This should cause an error. + + with pytest.raises(exceptions.HomeAssistantError): + await script_obj.async_run() + + assert script_obj.is_running + assert any( + rec.levelname == "ERROR" + and rec.name == "TEST" + and "Already running" in rec.message + for rec in caplog.records + ) + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + hass.states.async_set("switch.test", "off") + await hass.async_block_till_done() + + assert not script_obj.is_running + assert len(events) == 2 + assert events[1].data["value"] == 2 + + +async def test_if_running_restart(hass, caplog): + """Test overlapping runs with if_running='restart'.""" + # TODO: REMOVE + if _ALL_RUN_MODES == [None]: + return + + event = "test_event" + events = [] + wait_started_flag = asyncio.Event() + + @callback + def record_event(event): + """Add recorded event to set.""" + events.append(event) + + hass.bus.async_listen(event, record_event) + + @callback + def wait_started_cb(): + wait_started_flag.set() + + hass.states.async_set("switch.test", "on") + + script_obj = script.Script( + hass, + cv.SCRIPT_SCHEMA( + [ + {"event": event, "event_data": {"value": 1}}, + {"wait_template": "{{ states.switch.test.state == 'off' }}"}, + {"event": event, "event_data": {"value": 2}}, + ] + ), + change_listener=wait_started_cb, + if_running="restart", + run_mode="background", + logger=logging.getLogger("TEST"), + ) + + try: + await script_obj.async_run() + await asyncio.wait_for(wait_started_flag.wait(), 1) + + assert script_obj.is_running + assert len(events) == 1 + assert events[0].data["value"] == 1 + + # Start second run of script while first run is suspended in wait_template. + # This should stop first run then start a new run. + + wait_started_flag.clear() + await script_obj.async_run() + await asyncio.wait_for(wait_started_flag.wait(), 1) + + assert script_obj.is_running + assert len(events) == 2 + assert events[1].data["value"] == 1 + assert any( + rec.levelname == "INFO" + and rec.name == "TEST" + and "Restarting" in rec.message + for rec in caplog.records + ) + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + hass.states.async_set("switch.test", "off") + await hass.async_block_till_done() + + assert not script_obj.is_running + assert len(events) == 3 + assert events[2].data["value"] == 2 + + +async def test_if_running_parallel(hass): + """Test overlapping runs with if_running='parallel'.""" + # TODO: REMOVE + if _ALL_RUN_MODES == [None]: + return + + event = "test_event" + events = [] + wait_started_flag = asyncio.Event() + + @callback + def record_event(event): + """Add recorded event to set.""" + events.append(event) + + hass.bus.async_listen(event, record_event) + + @callback + def wait_started_cb(): + wait_started_flag.set() + + hass.states.async_set("switch.test", "on") + + script_obj = script.Script( + hass, + cv.SCRIPT_SCHEMA( + [ + {"event": event, "event_data": {"value": 1}}, + {"wait_template": "{{ states.switch.test.state == 'off' }}"}, + {"event": event, "event_data": {"value": 2}}, + ] + ), + change_listener=wait_started_cb, + if_running="parallel", + run_mode="background", + logger=logging.getLogger("TEST"), + ) + + try: + await script_obj.async_run() + await asyncio.wait_for(wait_started_flag.wait(), 1) + + assert script_obj.is_running + assert len(events) == 1 + assert events[0].data["value"] == 1 + + # Start second run of script while first run is suspended in wait_template. + # This should start a new, independent run. + + wait_started_flag.clear() + await script_obj.async_run() + await asyncio.wait_for(wait_started_flag.wait(), 1) + + assert script_obj.is_running + assert len(events) == 2 + assert events[1].data["value"] == 1 + except (AssertionError, asyncio.TimeoutError): + await script_obj.async_stop() + raise + else: + hass.states.async_set("switch.test", "off") + await hass.async_block_till_done() + + assert not script_obj.is_running + assert len(events) == 4 + assert events[2].data["value"] == 2 + assert events[3].data["value"] == 2 From 3f49f6c0471f30aac0c740691e959e45e14b720c Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Mon, 24 Feb 2020 18:30:00 -0600 Subject: [PATCH 077/416] Add constants file for directv (#32157) --- homeassistant/components/directv/const.py | 12 ++++++++++ .../components/directv/media_player.py | 22 +++++++++---------- 2 files changed, 23 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/directv/const.py diff --git a/homeassistant/components/directv/const.py b/homeassistant/components/directv/const.py new file mode 100644 index 00000000000..8b3ae08c526 --- /dev/null +++ b/homeassistant/components/directv/const.py @@ -0,0 +1,12 @@ +"""Constants for the DirecTV integration.""" + +ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" +ATTR_MEDIA_RATING = "media_rating" +ATTR_MEDIA_RECORDED = "media_recorded" +ATTR_MEDIA_START_TIME = "media_start_time" + +DATA_DIRECTV = "data_directv" + +DEFAULT_DEVICE = "0" +DEFAULT_NAME = "DirecTV Receiver" +DEFAULT_PORT = 8080 diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index 0d593ab9a45..673e97a18af 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -31,17 +31,19 @@ from homeassistant.const import ( import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util +from .const import ( + ATTR_MEDIA_CURRENTLY_RECORDING, + ATTR_MEDIA_RATING, + ATTR_MEDIA_RECORDED, + ATTR_MEDIA_START_TIME, + DATA_DIRECTV, + DEFAULT_DEVICE, + DEFAULT_NAME, + DEFAULT_PORT, +) + _LOGGER = logging.getLogger(__name__) -ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" -ATTR_MEDIA_RATING = "media_rating" -ATTR_MEDIA_RECORDED = "media_recorded" -ATTR_MEDIA_START_TIME = "media_start_time" - -DEFAULT_DEVICE = "0" -DEFAULT_NAME = "DirecTV Receiver" -DEFAULT_PORT = 8080 - SUPPORT_DTV = ( SUPPORT_PAUSE | SUPPORT_TURN_ON @@ -62,8 +64,6 @@ SUPPORT_DTV_CLIENT = ( | SUPPORT_PLAY ) -DATA_DIRECTV = "data_directv" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, From a9e061270b24476c5f10424654506f606aaf3fd1 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Tue, 25 Feb 2020 00:32:13 +0000 Subject: [PATCH 078/416] [ci skip] Translation update --- .../components/deconz/.translations/da.json | 3 +- .../components/demo/.translations/da.json | 23 ++++ .../components/ipma/.translations/da.json | 1 + .../konnected/.translations/da.json | 104 +++++++++++++++++- .../components/mqtt/.translations/da.json | 22 ++++ .../components/plex/.translations/da.json | 2 + .../rainmachine/.translations/da.json | 3 + .../simplisafe/.translations/da.json | 3 + .../components/unifi/.translations/da.json | 11 +- .../components/vilfo/.translations/da.json | 23 ++++ .../components/vizio/.translations/da.json | 2 +- 11 files changed, 188 insertions(+), 9 deletions(-) create mode 100644 homeassistant/components/vilfo/.translations/da.json diff --git a/homeassistant/components/deconz/.translations/da.json b/homeassistant/components/deconz/.translations/da.json index ed1f0b06e64..d1af7e1f4ba 100644 --- a/homeassistant/components/deconz/.translations/da.json +++ b/homeassistant/components/deconz/.translations/da.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "Tillad deCONZ CLIP-sensorer", "allow_deconz_groups": "Tillad deCONZ-lysgrupper" }, - "description": "Konfigurer synligheden af deCONZ-enhedstyper" + "description": "Konfigurer synligheden af deCONZ-enhedstyper", + "title": "deCONZ-indstillinger" } } } diff --git a/homeassistant/components/demo/.translations/da.json b/homeassistant/components/demo/.translations/da.json index ef01fcb4f3c..fd2764e5ec9 100644 --- a/homeassistant/components/demo/.translations/da.json +++ b/homeassistant/components/demo/.translations/da.json @@ -1,5 +1,28 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "init": { + "data": { + "one": "en", + "other": "anden" + } + }, + "options_1": { + "data": { + "bool": "Valgfri boolsk", + "int": "Numerisk input" + } + }, + "options_2": { + "data": { + "multi": "Multimarkering", + "select": "V\u00e6lg en mulighed", + "string": "Strengv\u00e6rdi" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/ipma/.translations/da.json b/homeassistant/components/ipma/.translations/da.json index 017aff4d0ec..e2f72db7c4d 100644 --- a/homeassistant/components/ipma/.translations/da.json +++ b/homeassistant/components/ipma/.translations/da.json @@ -8,6 +8,7 @@ "data": { "latitude": "Breddegrad", "longitude": "L\u00e6ngdegrad", + "mode": "Tilstand", "name": "Navn" }, "description": "Instituto Portugu\u00eas do Mar e Atmosfera", diff --git a/homeassistant/components/konnected/.translations/da.json b/homeassistant/components/konnected/.translations/da.json index b8b8acc2f49..db37ad73610 100644 --- a/homeassistant/components/konnected/.translations/da.json +++ b/homeassistant/components/konnected/.translations/da.json @@ -1,8 +1,104 @@ { - "options": { + "config": { + "abort": { + "already_configured": "Enheden er allerede konfigureret", + "already_in_progress": "Enhedskonfiguration er allerede i gang.", + "not_konn_panel": "Ikke en genkendt Konnected.io-enhed", + "unknown": "Ukendt fejl opstod" + }, "error": { - "one": "EN", - "other": "ANDEN" - } + "cannot_connect": "Der kan ikke oprettes forbindelse til et Konnected-panel p\u00e5 {host}:{port}" + }, + "step": { + "confirm": { + "description": "Model: {model}\nV\u00e6rt: {host}\nPort: {port}\n\nDu kan konfigurere IO og panelfunktionsm\u00e5den i indstillingerne for Konnected-alarmpanel.", + "title": "Konnected-enhed klar" + }, + "user": { + "data": { + "host": "Konnected-enhedens IP-adresse", + "port": "Konnected-enhedsport" + }, + "description": "Indtast v\u00e6rtsinformationen for dit Konnected-panel.", + "title": "Find Konnected-enhed" + } + }, + "title": "Konnected.io" + }, + "options": { + "abort": { + "not_konn_panel": "Ikke en genkendt Konnected.io-enhed" + }, + "error": { + "one": "en", + "other": "anden" + }, + "step": { + "options_binary": { + "data": { + "inverse": "Inverter tilstanden \u00e5ben/lukket", + "name": "Navn (valgfrit)", + "type": "Bin\u00e6r sensortype" + }, + "description": "V\u00e6lg indstillingerne for den bin\u00e6re sensor, der er knyttet til {zone}", + "title": "Konfigurer bin\u00e6r sensor" + }, + "options_digital": { + "data": { + "name": "Navn (valgfrit)", + "poll_interval": "Foresp\u00f8rgselsinterval (minutter) (valgfrit)", + "type": "Sensortype" + }, + "description": "V\u00e6lg indstillingerne for den digitale sensor, der er knyttet til {zone}", + "title": "Konfigurer digital sensor" + }, + "options_io": { + "data": { + "1": "Zone 1", + "2": "Zone 2", + "3": "Zone 3", + "4": "Zone 4", + "5": "Zone 5", + "6": "Zone 6", + "7": "Zone 7", + "out": "OUT" + }, + "description": "Der blev fundet en {model} p\u00e5 {host}. V\u00e6lg basiskonfigurationen af hver I/O nedenfor - afh\u00e6ngigt af I/O kan det give mulighed for bin\u00e6re sensorer (\u00e5ben-/lukket-kontakter), digitale sensorer (dht og ds18b20) eller omskiftelige outputs. Du kan konfigurere detaljerede indstillinger i de n\u00e6ste trin.", + "title": "Konfigurer I/O" + }, + "options_io_ext": { + "data": { + "10": "Zone 10", + "11": "Zone 11", + "12": "Zone 12", + "8": "Zone 8", + "9": "Zone 9", + "alarm1": "ALARM1", + "alarm2_out2": "OUT2/ALARM2", + "out1": "OUT1" + }, + "description": "V\u00e6lg konfigurationen af det resterende I/O nedenfor. Du kan konfigurere detaljerede indstillinger i de n\u00e6ste trin.", + "title": "Konfigurer udvidet I/O" + }, + "options_misc": { + "data": { + "blink": "Blink panel-LED ved sending af tilstands\u00e6ndring" + }, + "description": "V\u00e6lg den \u00f8nskede funktionsm\u00e5de for panelet", + "title": "Konfigurer diverse" + }, + "options_switch": { + "data": { + "activation": "Output n\u00e5r der er t\u00e6ndt", + "momentary": "Impulsvarighed (ms) (valgfrit)", + "name": "Navn (valgfrit)", + "pause": "Pause mellem impulser (ms) (valgfrit)", + "repeat": "Gange til gentagelse (-1=uendelig) (valgfrit)" + }, + "description": "V\u00e6lg outputindstillingerne for {zone}", + "title": "Konfigurer skifteligt output" + } + }, + "title": "Indstillinger for Konnected-alarmpanel" } } \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/da.json b/homeassistant/components/mqtt/.translations/da.json index 93ea57d49ea..e018ab7aa14 100644 --- a/homeassistant/components/mqtt/.translations/da.json +++ b/homeassistant/components/mqtt/.translations/da.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "F\u00f8rste knap", + "button_2": "Anden knap", + "button_3": "Tredje knap", + "button_4": "Fjerde knap", + "button_5": "Femte knap", + "button_6": "Sjette knap", + "turn_off": "Sluk", + "turn_on": "T\u00e6nd" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" dobbeltklikket", + "button_long_press": "\"{subtype}\" trykket p\u00e5 konstant", + "button_long_release": "\"{subtype}\" sluppet efter langt tryk", + "button_quadruple_press": "\"{subtype}\" firedobbelt-klikket", + "button_quintuple_press": "\"{subtype}\" femdobbelt-klikket", + "button_short_press": "\"{subtype}\" trykket p\u00e5", + "button_short_release": "\"{subtype}\" sluppet", + "button_triple_press": "\"{subtype}\" tredobbeltklikket" + } } } \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/da.json b/homeassistant/components/plex/.translations/da.json index 18dbbb840c3..9b80373727d 100644 --- a/homeassistant/components/plex/.translations/da.json +++ b/homeassistant/components/plex/.translations/da.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignorer nye administrerede/delte brugere", + "monitored_users": "Monitorerede brugere", "show_all_controls": "Vis alle kontrolelementer", "use_episode_art": "Brug episodekunst" }, diff --git a/homeassistant/components/rainmachine/.translations/da.json b/homeassistant/components/rainmachine/.translations/da.json index 34f4fff4ed0..fe53a86993d 100644 --- a/homeassistant/components/rainmachine/.translations/da.json +++ b/homeassistant/components/rainmachine/.translations/da.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne RainMachine-controller er allerede konfigureret." + }, "error": { "identifier_exists": "Konto er allerede registreret", "invalid_credentials": "Ugyldige legitimationsoplysninger" diff --git a/homeassistant/components/simplisafe/.translations/da.json b/homeassistant/components/simplisafe/.translations/da.json index 0d3970eeba5..ccd82979520 100644 --- a/homeassistant/components/simplisafe/.translations/da.json +++ b/homeassistant/components/simplisafe/.translations/da.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne SimpliSafe-konto er allerede i brug." + }, "error": { "identifier_exists": "Konto er allerede registreret", "invalid_credentials": "Ugyldige legitimationsoplysninger" diff --git a/homeassistant/components/unifi/.translations/da.json b/homeassistant/components/unifi/.translations/da.json index 46a94cc4047..1afd1ca96ce 100644 --- a/homeassistant/components/unifi/.translations/da.json +++ b/homeassistant/components/unifi/.translations/da.json @@ -28,10 +28,13 @@ "device_tracker": { "data": { "detection_time": "Tid i sekunder fra sidst set indtil betragtet som v\u00e6k", + "ssid_filter": "V\u00e6lg SSIDer, der skal spores tr\u00e5dl\u00f8se klienter p\u00e5", "track_clients": "Spor netv\u00e6rksklienter", "track_devices": "Spor netv\u00e6rksenheder (Ubiquiti-enheder)", "track_wired_clients": "Inkluder kablede netv\u00e6rksklienter" - } + }, + "description": "Konfigurer enhedssporing", + "title": "UniFi-indstillinger" }, "init": { "data": { @@ -41,8 +44,10 @@ }, "statistics_sensors": { "data": { - "allow_bandwidth_sensors": "Opret b\u00e5ndbredde-forbrugssensorer for netv\u00e6rksklienter" - } + "allow_bandwidth_sensors": "B\u00e5ndbreddeforbrugssensorer for netv\u00e6rksklienter" + }, + "description": "Konfigurer statistiksensorer", + "title": "UniFi-indstillinger" } } } diff --git a/homeassistant/components/vilfo/.translations/da.json b/homeassistant/components/vilfo/.translations/da.json new file mode 100644 index 00000000000..f233b4cb7b9 --- /dev/null +++ b/homeassistant/components/vilfo/.translations/da.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Denne Vilfo-router er allerede konfigureret." + }, + "error": { + "cannot_connect": "Forbindelsen kunne ikke oprettes. Tjek de oplysninger, du har angivet, og pr\u00f8v igen.", + "invalid_auth": "Ugyldig godkendelse. Kontroller adgangstoken og pr\u00f8v igen.", + "unknown": "Der opstod en uventet fejl under konfiguration af integrationen." + }, + "step": { + "user": { + "data": { + "access_token": "Adgangstoken til Vilfo-router-API", + "host": "Router-v\u00e6rtsnavn eller IP" + }, + "description": "Indstil Vilfo-routerintegration. Du har brug for dit Vilfo-routerv\u00e6rtsnavn/IP og et API-adgangstoken. For yderligere information om denne integration og hvordan du f\u00e5r disse detaljer, kan du bes\u00f8ge: https://www.home-assistant.io/integrations/vilfo", + "title": "Opret forbindelse til Vilfo-router" + } + }, + "title": "Vilfo-router" + } +} \ No newline at end of file diff --git a/homeassistant/components/vizio/.translations/da.json b/homeassistant/components/vizio/.translations/da.json index 9ec9c4122ee..9bfd5864025 100644 --- a/homeassistant/components/vizio/.translations/da.json +++ b/homeassistant/components/vizio/.translations/da.json @@ -24,7 +24,7 @@ "host": ":", "name": "Navn" }, - "title": "Ops\u00e6tning af Vizio SmartCast-klient" + "title": "Ops\u00e6t Vizio SmartCast-enhed" } }, "title": "Vizio SmartCast" From 9b2544c9236c5613430e1b55a5f83256c8af609e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 25 Feb 2020 02:47:15 +0200 Subject: [PATCH 079/416] Remove some unneeded pylint suppressions (#32152) --- homeassistant/components/alexa/capabilities.py | 1 - homeassistant/components/alexa/config.py | 2 +- homeassistant/components/almond/config_flow.py | 1 - homeassistant/components/amcrest/__init__.py | 1 - homeassistant/components/automation/geo_location.py | 1 - homeassistant/components/automation/zone.py | 1 - homeassistant/components/device_tracker/legacy.py | 1 - homeassistant/components/hue/config_flow.py | 2 -- homeassistant/components/mqtt/fan.py | 1 - homeassistant/components/mqtt/light/schema_basic.py | 1 - homeassistant/components/mqtt/light/schema_json.py | 1 - homeassistant/components/mqtt/light/schema_template.py | 1 - homeassistant/components/mqtt/switch.py | 1 - homeassistant/components/mqtt/vacuum/schema_legacy.py | 1 - homeassistant/components/mqtt/vacuum/schema_state.py | 1 - homeassistant/components/mysensors/light.py | 2 -- homeassistant/components/pi_hole/__init__.py | 2 +- homeassistant/components/rflink/light.py | 4 ---- homeassistant/components/rflink/switch.py | 1 - homeassistant/components/websocket_api/const.py | 5 +---- homeassistant/components/zha/core/typing.py | 1 - homeassistant/helpers/collection.py | 2 +- homeassistant/util/yaml/loader.py | 1 - 23 files changed, 4 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 94cf41d530b..8b38fe4d298 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -227,7 +227,6 @@ class AlexaCapability: """Return properties serialized for an API response.""" for prop in self.properties_supported(): prop_name = prop["name"] - # pylint: disable=assignment-from-no-return prop_value = self.get_property(prop_name) if prop_value is not None: result = { diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index bd579dc4dad..7d3a3994ace 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -53,7 +53,7 @@ class AbstractConfig(ABC): ) try: await self._unsub_proactive_report - except Exception: # pylint: disable=broad-except + except Exception: self._unsub_proactive_report = None raise diff --git a/homeassistant/components/almond/config_flow.py b/homeassistant/components/almond/config_flow.py index 42f9318a06f..b1eb506270b 100644 --- a/homeassistant/components/almond/config_flow.py +++ b/homeassistant/components/almond/config_flow.py @@ -87,7 +87,6 @@ class AlmondFlowHandler(config_entry_oauth2_flow.AbstractOAuth2FlowHandler): ) return self.async_abort(reason="cannot_connect") - # pylint: disable=invalid-name self.CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL return self.async_create_entry( diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 5578d350e22..b4b3e1866b4 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -109,7 +109,6 @@ CONFIG_SCHEMA = vol.Schema( ) -# pylint: disable=too-many-ancestors class AmcrestChecker(Http): """amcrest.Http wrapper for catching errors.""" diff --git a/homeassistant/components/automation/geo_location.py b/homeassistant/components/automation/geo_location.py index 5dc4f3c80f6..92094a751a0 100644 --- a/homeassistant/components/automation/geo_location.py +++ b/homeassistant/components/automation/geo_location.py @@ -58,7 +58,6 @@ async def async_attach_trigger(hass, config, action, automation_info): from_match = condition.zone(hass, zone_state, from_state) to_match = condition.zone(hass, zone_state, to_state) - # pylint: disable=too-many-boolean-expressions if ( trigger_event == EVENT_ENTER and not from_match diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/automation/zone.py index 3dba1a4df35..14233d783f9 100644 --- a/homeassistant/components/automation/zone.py +++ b/homeassistant/components/automation/zone.py @@ -53,7 +53,6 @@ async def async_attach_trigger(hass, config, action, automation_info): from_match = False to_match = condition.zone(hass, zone_state, to_s) - # pylint: disable=too-many-boolean-expressions if ( event == EVENT_ENTER and not from_match diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index b4bfd506f27..68908f8c79f 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -411,7 +411,6 @@ class Device(RestoreEntity): self.gps_accuracy = 0 LOGGER.warning("Could not parse gps value for %s: %s", self.dev_id, gps) - # pylint: disable=not-an-iterable await self.async_update() def stale(self, now: dt_util.dt.datetime = None): diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index d214c5509ea..77c24caa389 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -60,10 +60,8 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if ( user_input is not None and self.discovered_bridges is not None - # pylint: disable=unsupported-membership-test and user_input["id"] in self.discovered_bridges ): - # pylint: disable=unsubscriptable-object self.bridge = self.discovered_bridges[user_input["id"]] await self.async_set_unique_id(self.bridge.id, raise_on_progress=False) # We pass user input to link so it will attempt to link right away diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 07cb711ebd0..c5e4b3145de 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -141,7 +141,6 @@ async def _async_setup_entity( async_add_entities([MqttFan(config, config_entry, discovery_hash)]) -# pylint: disable=too-many-ancestors class MqttFan( MqttAttributes, MqttAvailability, diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 041c5e804dc..23f8684cf46 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -154,7 +154,6 @@ async def async_setup_entity_basic( async_add_entities([MqttLight(config, config_entry, discovery_hash)]) -# pylint: disable=too-many-ancestors class MqttLight( MqttAttributes, MqttAvailability, diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 373fbc1b3b2..e7256614002 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -125,7 +125,6 @@ async def async_setup_entity_json( async_add_entities([MqttLightJson(config, config_entry, discovery_hash)]) -# pylint: disable=too-many-ancestors class MqttLightJson( MqttAttributes, MqttAvailability, diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 82f0fa3c9d0..6bbf5ee1572 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -99,7 +99,6 @@ async def async_setup_entity_template( async_add_entities([MqttTemplate(config, config_entry, discovery_hash)]) -# pylint: disable=too-many-ancestors class MqttTemplate( MqttAttributes, MqttAvailability, diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 3c35434be86..65b43f6bf53 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -99,7 +99,6 @@ async def _async_setup_entity( async_add_entities([MqttSwitch(config, config_entry, discovery_hash)]) -# pylint: disable=too-many-ancestors class MqttSwitch( MqttAttributes, MqttAvailability, diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index 6c08b18bc9c..c6322d9fec5 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -168,7 +168,6 @@ async def async_setup_entity_legacy( async_add_entities([MqttVacuum(config, config_entry, discovery_hash)]) -# pylint: disable=too-many-ancestors class MqttVacuum( MqttAttributes, MqttAvailability, diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 9dd5053d019..0399e66c0ad 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -163,7 +163,6 @@ async def async_setup_entity_state( async_add_entities([MqttStateVacuum(config, config_entry, discovery_hash)]) -# pylint: disable=too-many-ancestors class MqttStateVacuum( MqttAttributes, MqttAvailability, diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 45da4a77d5f..b25cf977d83 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -228,8 +228,6 @@ class MySensorsLightRGB(MySensorsLight): class MySensorsLightRGBW(MySensorsLightRGB): """RGBW child class to MySensorsLightRGB.""" - # pylint: disable=too-many-ancestors - @property def supported_features(self): """Flag supported features.""" diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py index 5791d17f6dd..fb06d06cfb4 100644 --- a/homeassistant/components/pi_hole/__init__.py +++ b/homeassistant/components/pi_hole/__init__.py @@ -115,7 +115,7 @@ async def async_setup(hass, config): return call_data - service_disable_schema = vol.Schema( # pylint: disable=invalid-name + service_disable_schema = vol.Schema( vol.All( { vol.Required(SERVICE_DISABLE_ATTR_DURATION): vol.All( diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py index 1ed19569585..01004a3b45a 100644 --- a/homeassistant/components/rflink/light.py +++ b/homeassistant/components/rflink/light.py @@ -159,14 +159,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= hass.data[DATA_DEVICE_REGISTER][EVENT_KEY_COMMAND] = add_new_device -# pylint: disable=too-many-ancestors class RflinkLight(SwitchableRflinkDevice, Light): """Representation of a Rflink light.""" pass -# pylint: disable=too-many-ancestors class DimmableRflinkLight(SwitchableRflinkDevice, Light): """Rflink light device that support dimming.""" @@ -212,7 +210,6 @@ class DimmableRflinkLight(SwitchableRflinkDevice, Light): return SUPPORT_BRIGHTNESS -# pylint: disable=too-many-ancestors class HybridRflinkLight(SwitchableRflinkDevice, Light): """Rflink light device that sends out both dim and on/off commands. @@ -276,7 +273,6 @@ class HybridRflinkLight(SwitchableRflinkDevice, Light): return SUPPORT_BRIGHTNESS -# pylint: disable=too-many-ancestors class ToggleRflinkLight(SwitchableRflinkDevice, Light): """Rflink light device which sends out only 'on' commands. diff --git a/homeassistant/components/rflink/switch.py b/homeassistant/components/rflink/switch.py index 990d76101cc..943f8a6aae6 100644 --- a/homeassistant/components/rflink/switch.py +++ b/homeassistant/components/rflink/switch.py @@ -69,7 +69,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities(devices_from_config(config)) -# pylint: disable=too-many-ancestors class RflinkSwitch(SwitchableRflinkDevice, SwitchDevice): """Representation of a Rflink switch.""" diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index b1fa1263a99..61f12fd5f57 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -12,10 +12,7 @@ if TYPE_CHECKING: from .connection import ActiveConnection # noqa -WebSocketCommandHandler = Callable[ - [HomeAssistant, "ActiveConnection", dict], None -] # pylint: disable=invalid-name - +WebSocketCommandHandler = Callable[[HomeAssistant, "ActiveConnection", dict], None] DOMAIN = "websocket_api" URL = "/api/websocket" diff --git a/homeassistant/components/zha/core/typing.py b/homeassistant/components/zha/core/typing.py index 3d10912d165..fb397ea15ae 100644 --- a/homeassistant/components/zha/core/typing.py +++ b/homeassistant/components/zha/core/typing.py @@ -30,7 +30,6 @@ if TYPE_CHECKING: import homeassistant.components.zha.entity import homeassistant.components.zha.core.channels - # pylint: disable=invalid-name ChannelType = base_channels.ZigbeeChannel ChannelsType = channels.Channels ChannelPoolType = channels.ChannelPool diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index 025c6c07dee..bea08fb322c 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -35,7 +35,7 @@ ChangeListener = Callable[ Optional[dict], ], Awaitable[None], -] # pylint: disable=invalid-name +] class CollectionError(HomeAssistantError): diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py index 6b921ade961..ba4d1e77576 100644 --- a/homeassistant/util/yaml/loader.py +++ b/homeassistant/util/yaml/loader.py @@ -41,7 +41,6 @@ def clear_secret_cache() -> None: __SECRET_CACHE.clear() -# pylint: disable=too-many-ancestors class SafeLineLoader(yaml.SafeLoader): """Loader class that keeps track of line numbers.""" From c98f50115d5df6e089b1a47b6d9ed04f8e9c178c Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 25 Feb 2020 02:16:23 +0100 Subject: [PATCH 080/416] Upgrade pre-commit to 2.1.1 (#32159) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index db76d1ec46b..6fc7e10a78d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,7 +7,7 @@ asynctest==0.13.0 codecov==2.0.15 mock-open==1.3.1 mypy==0.761 -pre-commit==2.1.0 +pre-commit==2.1.1 pylint==2.4.4 astroid==2.3.3 pylint-strict-informational==0.1 From 496bd3dddf5216b375635653d2300dfa5a3f2db6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 25 Feb 2020 03:52:14 +0200 Subject: [PATCH 081/416] Add and use more unit constants (#32122) * Add and use speed constants * Add and use meter based volume and area constants * Add and use more mass unit constants * Add and use concentration unit constants * Add and use watts per square meter constant * Use more time constants * Use more data constants --- homeassistant/components/airly/sensor.py | 4 +- homeassistant/components/airvisual/sensor.py | 19 ++- .../components/ambient_station/__init__.py | 16 ++- homeassistant/components/arlo/sensor.py | 3 +- homeassistant/components/awair/sensor.py | 13 +- homeassistant/components/bom/sensor.py | 6 +- homeassistant/components/buienradar/sensor.py | 18 +-- homeassistant/components/darksky/sensor.py | 24 ++-- .../components/dsmr_reader/definitions.py | 10 +- homeassistant/components/fibaro/sensor.py | 10 +- homeassistant/components/fitbit/sensor.py | 6 +- homeassistant/components/foobot/sensor.py | 17 ++- .../components/homekit_controller/sensor.py | 9 +- homeassistant/components/homematic/sensor.py | 9 +- .../components/homematicip_cloud/sensor.py | 4 +- homeassistant/components/isy994/sensor.py | 14 +- homeassistant/components/kaiterra/const.py | 18 ++- .../components/luftdaten/__init__.py | 11 +- .../components/meteo_france/const.py | 4 +- homeassistant/components/metoffice/sensor.py | 5 +- homeassistant/components/mhz19/sensor.py | 6 +- homeassistant/components/mysensors/sensor.py | 3 +- homeassistant/components/netatmo/sensor.py | 19 ++- .../components/openweathermap/sensor.py | 4 +- homeassistant/components/serial_pm/sensor.py | 4 +- homeassistant/components/smappee/sensor.py | 18 ++- .../components/smartthings/sensor.py | 31 ++++- .../components/tellduslive/sensor.py | 6 +- .../trafikverket_weatherstation/sensor.py | 4 +- homeassistant/components/upnp/sensor.py | 12 +- homeassistant/components/withings/const.py | 4 +- homeassistant/components/withings/sensor.py | 14 +- .../components/wunderground/sensor.py | 128 +++++++++++++++--- .../components/xiaomi_miio/air_quality.py | 9 +- homeassistant/components/yr/sensor.py | 6 +- homeassistant/components/zamg/sensor.py | 13 +- homeassistant/const.py | 20 +++ tests/components/awair/test_sensor.py | 12 +- tests/components/dsmr/test_sensor.py | 20 +-- tests/components/foobot/test_sensor.py | 13 +- .../homematicip_cloud/test_sensor.py | 10 +- tests/components/mhz19/test_sensor.py | 4 +- tests/components/prometheus/test_init.py | 13 +- tests/components/yr/test_sensor.py | 6 +- 44 files changed, 422 insertions(+), 177 deletions(-) diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index ab83f711153..2d42dac5614 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -2,6 +2,7 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PRESSURE, @@ -27,14 +28,13 @@ ATTR_LABEL = "label" ATTR_UNIT = "unit" HUMI_PERCENT = "%" -VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m³" SENSOR_TYPES = { ATTR_API_PM1: { ATTR_DEVICE_CLASS: None, ATTR_ICON: "mdi:blur", ATTR_LABEL: ATTR_API_PM1, - ATTR_UNIT: VOLUME_MICROGRAMS_PER_CUBIC_METER, + ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, }, ATTR_API_HUMIDITY: { ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index 1c0109c0d68..a7bf3f4dd1b 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -11,6 +11,9 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, @@ -37,10 +40,6 @@ CONF_COUNTRY = "country" DEFAULT_ATTRIBUTION = "Data provided by AirVisual" DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) -MASS_PARTS_PER_MILLION = "ppm" -MASS_PARTS_PER_BILLION = "ppb" -VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m3" - SENSOR_TYPE_LEVEL = "air_pollution_level" SENSOR_TYPE_AQI = "air_quality_index" SENSOR_TYPE_POLLUTANT = "main_pollutant" @@ -70,12 +69,12 @@ POLLUTANT_LEVEL_MAPPING = [ ] POLLUTANT_MAPPING = { - "co": {"label": "Carbon Monoxide", "unit": MASS_PARTS_PER_MILLION}, - "n2": {"label": "Nitrogen Dioxide", "unit": MASS_PARTS_PER_BILLION}, - "o3": {"label": "Ozone", "unit": MASS_PARTS_PER_BILLION}, - "p1": {"label": "PM10", "unit": VOLUME_MICROGRAMS_PER_CUBIC_METER}, - "p2": {"label": "PM2.5", "unit": VOLUME_MICROGRAMS_PER_CUBIC_METER}, - "s2": {"label": "Sulfur Dioxide", "unit": MASS_PARTS_PER_BILLION}, + "co": {"label": "Carbon Monoxide", "unit": CONCENTRATION_PARTS_PER_MILLION}, + "n2": {"label": "Nitrogen Dioxide", "unit": CONCENTRATION_PARTS_PER_BILLION}, + "o3": {"label": "Ozone", "unit": CONCENTRATION_PARTS_PER_BILLION}, + "p1": {"label": "PM10", "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, + "p2": {"label": "PM2.5", "unit": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}, + "s2": {"label": "Sulfur Dioxide", "unit": CONCENTRATION_PARTS_PER_BILLION}, } SENSOR_LOCALES = {"cn": "Chinese", "us": "U.S."} diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 9e8ff4b9f8d..4d068b2d0d8 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -10,8 +10,10 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( ATTR_LOCATION, ATTR_NAME, + CONCENTRATION_PARTS_PER_MILLION, CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, + SPEED_MILES_PER_HOUR, ) from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady @@ -147,7 +149,7 @@ SENSOR_TYPES = { TYPE_BATT8: ("Battery 8", None, TYPE_BINARY_SENSOR, "battery"), TYPE_BATT9: ("Battery 9", None, TYPE_BINARY_SENSOR, "battery"), TYPE_BATTOUT: ("Battery", None, TYPE_BINARY_SENSOR, "battery"), - TYPE_CO2: ("co2", "ppm", TYPE_SENSOR, None), + TYPE_CO2: ("co2", CONCENTRATION_PARTS_PER_MILLION, TYPE_SENSOR, None), TYPE_DAILYRAININ: ("Daily Rain", "in", TYPE_SENSOR, None), TYPE_DEWPOINT: ("Dew Point", "°F", TYPE_SENSOR, "temperature"), TYPE_EVENTRAININ: ("Event Rain", "in", TYPE_SENSOR, None), @@ -166,7 +168,7 @@ SENSOR_TYPES = { TYPE_HUMIDITY: ("Humidity", "%", TYPE_SENSOR, "humidity"), TYPE_HUMIDITYIN: ("Humidity In", "%", TYPE_SENSOR, "humidity"), TYPE_LASTRAIN: ("Last Rain", None, TYPE_SENSOR, "timestamp"), - TYPE_MAXDAILYGUST: ("Max Gust", "mph", TYPE_SENSOR, None), + TYPE_MAXDAILYGUST: ("Max Gust", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), TYPE_MONTHLYRAININ: ("Monthly Rain", "in", TYPE_SENSOR, None), TYPE_RELAY10: ("Relay 10", None, TYPE_BINARY_SENSOR, "connectivity"), TYPE_RELAY1: ("Relay 1", None, TYPE_BINARY_SENSOR, "connectivity"), @@ -217,12 +219,12 @@ SENSOR_TYPES = { TYPE_WEEKLYRAININ: ("Weekly Rain", "in", TYPE_SENSOR, None), TYPE_WINDDIR: ("Wind Dir", "°", TYPE_SENSOR, None), TYPE_WINDDIR_AVG10M: ("Wind Dir Avg 10m", "°", TYPE_SENSOR, None), - TYPE_WINDDIR_AVG2M: ("Wind Dir Avg 2m", "mph", TYPE_SENSOR, None), + TYPE_WINDDIR_AVG2M: ("Wind Dir Avg 2m", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), TYPE_WINDGUSTDIR: ("Gust Dir", "°", TYPE_SENSOR, None), - TYPE_WINDGUSTMPH: ("Wind Gust", "mph", TYPE_SENSOR, None), - TYPE_WINDSPDMPH_AVG10M: ("Wind Avg 10m", "mph", TYPE_SENSOR, None), - TYPE_WINDSPDMPH_AVG2M: ("Wind Avg 2m", "mph", TYPE_SENSOR, None), - TYPE_WINDSPEEDMPH: ("Wind Speed", "mph", TYPE_SENSOR, None), + TYPE_WINDGUSTMPH: ("Wind Gust", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), + TYPE_WINDSPDMPH_AVG10M: ("Wind Avg 10m", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), + TYPE_WINDSPDMPH_AVG2M: ("Wind Avg 2m", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), + TYPE_WINDSPEEDMPH: ("Wind Speed", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), TYPE_YEARLYRAININ: ("Yearly Rain", "in", TYPE_SENSOR, None), } diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py index 959fe9916df..5d11e9bc891 100644 --- a/homeassistant/components/arlo/sensor.py +++ b/homeassistant/components/arlo/sensor.py @@ -6,6 +6,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, + CONCENTRATION_PARTS_PER_MILLION, CONF_MONITORED_CONDITIONS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, @@ -30,7 +31,7 @@ SENSOR_TYPES = { "signal_strength": ["Signal Strength", None, "signal"], "temperature": ["Temperature", TEMP_CELSIUS, "thermometer"], "humidity": ["Humidity", "%", "water-percent"], - "air_quality": ["Air Quality", "ppm", "biohazard"], + "air_quality": ["Air Quality", CONCENTRATION_PARTS_PER_MILLION, "biohazard"], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index f15e4a80e36..18fb3f2cd54 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -8,6 +8,9 @@ from python_awair import AwairClient import voluptuous as vol from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, CONF_ACCESS_TOKEN, CONF_DEVICES, DEVICE_CLASS_HUMIDITY, @@ -50,29 +53,29 @@ SENSOR_TYPES = { }, "CO2": { "device_class": DEVICE_CLASS_CARBON_DIOXIDE, - "unit_of_measurement": "ppm", + "unit_of_measurement": CONCENTRATION_PARTS_PER_MILLION, "icon": "mdi:periodic-table-co2", }, "VOC": { "device_class": DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, - "unit_of_measurement": "ppb", + "unit_of_measurement": CONCENTRATION_PARTS_PER_BILLION, "icon": "mdi:cloud", }, # Awair docs don't actually specify the size they measure for 'dust', # but 2.5 allows the sensor to show up in HomeKit "DUST": { "device_class": DEVICE_CLASS_PM2_5, - "unit_of_measurement": "µg/m3", + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "icon": "mdi:cloud", }, "PM25": { "device_class": DEVICE_CLASS_PM2_5, - "unit_of_measurement": "µg/m3", + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "icon": "mdi:cloud", }, "PM10": { "device_class": DEVICE_CLASS_PM10, - "unit_of_measurement": "µg/m3", + "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "icon": "mdi:cloud", }, "score": { diff --git a/homeassistant/components/bom/sensor.py b/homeassistant/components/bom/sensor.py index 2a38a3e60b0..836a2a79509 100644 --- a/homeassistant/components/bom/sensor.py +++ b/homeassistant/components/bom/sensor.py @@ -19,8 +19,8 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_NAME, + SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, - TIME_HOURS, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -59,7 +59,7 @@ SENSOR_TYPES = { "cloud_type_id": ["Cloud Type ID", None], "cloud_type": ["Cloud Type", None], "delta_t": ["Delta Temp C", TEMP_CELSIUS], - "gust_kmh": ["Wind Gust kmh", f"km/{TIME_HOURS}"], + "gust_kmh": ["Wind Gust kmh", SPEED_KILOMETERS_PER_HOUR], "gust_kt": ["Wind Gust kt", "kt"], "air_temp": ["Air Temp C", TEMP_CELSIUS], "dewpt": ["Dew Point C", TEMP_CELSIUS], @@ -76,7 +76,7 @@ SENSOR_TYPES = { "vis_km": ["Visability km", "km"], "weather": ["Weather", None], "wind_dir": ["Wind Direction", None], - "wind_spd_kmh": ["Wind Speed kmh", f"km/{TIME_HOURS}"], + "wind_spd_kmh": ["Wind Speed kmh", SPEED_KILOMETERS_PER_HOUR], "wind_spd_kt": ["Wind Speed kt", "kt"], } diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index fa3e7fd343b..32ecf50ed9d 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -27,6 +27,8 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_NAME, + IRRADIATION_WATTS_PER_SQUARE_METER, + SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, TIME_HOURS, ) @@ -68,15 +70,15 @@ SENSOR_TYPES = { "humidity": ["Humidity", "%", "mdi:water-percent"], "temperature": ["Temperature", TEMP_CELSIUS, "mdi:thermometer"], "groundtemperature": ["Ground temperature", TEMP_CELSIUS, "mdi:thermometer"], - "windspeed": ["Wind speed", f"km/{TIME_HOURS}", "mdi:weather-windy"], + "windspeed": ["Wind speed", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], "windforce": ["Wind force", "Bft", "mdi:weather-windy"], "winddirection": ["Wind direction", None, "mdi:compass-outline"], "windazimuth": ["Wind direction azimuth", "°", "mdi:compass-outline"], "pressure": ["Pressure", "hPa", "mdi:gauge"], "visibility": ["Visibility", "km", None], - "windgust": ["Wind gust", f"km/{TIME_HOURS}", "mdi:weather-windy"], + "windgust": ["Wind gust", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], "precipitation": ["Precipitation", f"mm/{TIME_HOURS}", "mdi:weather-pouring"], - "irradiance": ["Irradiance", "W/m2", "mdi:sunglasses"], + "irradiance": ["Irradiance", IRRADIATION_WATTS_PER_SQUARE_METER, "mdi:sunglasses"], "precipitation_forecast_average": [ "Precipitation forecast average", f"mm/{TIME_HOURS}", @@ -133,11 +135,11 @@ SENSOR_TYPES = { "windforce_3d": ["Wind force 3d", "Bft", "mdi:weather-windy"], "windforce_4d": ["Wind force 4d", "Bft", "mdi:weather-windy"], "windforce_5d": ["Wind force 5d", "Bft", "mdi:weather-windy"], - "windspeed_1d": ["Wind speed 1d", f"km/{TIME_HOURS}", "mdi:weather-windy"], - "windspeed_2d": ["Wind speed 2d", f"km/{TIME_HOURS}", "mdi:weather-windy"], - "windspeed_3d": ["Wind speed 3d", f"km/{TIME_HOURS}", "mdi:weather-windy"], - "windspeed_4d": ["Wind speed 4d", f"km/{TIME_HOURS}", "mdi:weather-windy"], - "windspeed_5d": ["Wind speed 5d", f"km/{TIME_HOURS}", "mdi:weather-windy"], + "windspeed_1d": ["Wind speed 1d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "windspeed_2d": ["Wind speed 2d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "windspeed_3d": ["Wind speed 3d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "windspeed_4d": ["Wind speed 4d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], + "windspeed_5d": ["Wind speed 5d", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], "winddirection_1d": ["Wind direction 1d", None, "mdi:compass-outline"], "winddirection_2d": ["Wind direction 2d", None, "mdi:compass-outline"], "winddirection_3d": ["Wind direction 3d", None, "mdi:compass-outline"], diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py index cecff626813..46741e3aca7 100644 --- a/homeassistant/components/darksky/sensor.py +++ b/homeassistant/components/darksky/sensor.py @@ -15,8 +15,10 @@ from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_SCAN_INTERVAL, + SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, TIME_HOURS, - TIME_SECONDS, UNIT_UV_INDEX, ) import homeassistant.helpers.config_validation as cv @@ -161,11 +163,11 @@ SENSOR_TYPES = { ], "wind_speed": [ "Wind Speed", - f"m/{TIME_SECONDS}", - "mph", - f"km/{TIME_HOURS}", - "mph", - "mph", + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, "mdi:weather-windy", ["currently", "hourly", "daily"], ], @@ -181,11 +183,11 @@ SENSOR_TYPES = { ], "wind_gust": [ "Wind Gust", - f"m/{TIME_SECONDS}", - "mph", - f"km/{TIME_HOURS}", - "mph", - "mph", + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, "mdi:weather-windy-variant", ["currently", "hourly", "daily"], ], diff --git a/homeassistant/components/dsmr_reader/definitions.py b/homeassistant/components/dsmr_reader/definitions.py index 45bebfeda92..bd583be37f4 100644 --- a/homeassistant/components/dsmr_reader/definitions.py +++ b/homeassistant/components/dsmr_reader/definitions.py @@ -1,5 +1,7 @@ """Definitions for DSMR Reader sensors added to MQTT.""" +from homeassistant.const import VOLUME_CUBIC_METERS + def dsmr_transform(value): """Transform DSMR version value to right format.""" @@ -79,7 +81,7 @@ DEFINITIONS = { "dsmr/reading/extra_device_delivered": { "name": "Gas meter usage", "icon": "mdi:fire", - "unit": "m3", + "unit": VOLUME_CUBIC_METERS, }, "dsmr/reading/phase_voltage_l1": { "name": "Current voltage L1", @@ -99,12 +101,12 @@ DEFINITIONS = { "dsmr/consumption/gas/delivered": { "name": "Gas usage", "icon": "mdi:fire", - "unit": "m3", + "unit": VOLUME_CUBIC_METERS, }, "dsmr/consumption/gas/currently_delivered": { "name": "Current gas usage", "icon": "mdi:fire", - "unit": "m3", + "unit": VOLUME_CUBIC_METERS, }, "dsmr/consumption/gas/read_at": { "name": "Gas meter read", @@ -159,7 +161,7 @@ DEFINITIONS = { "dsmr/day-consumption/gas": { "name": "Gas usage", "icon": "mdi:counter", - "unit": "m3", + "unit": VOLUME_CUBIC_METERS, }, "dsmr/day-consumption/gas_cost": { "name": "Gas cost", diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index 1e0bae212f8..720249b9b8a 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -3,6 +3,7 @@ import logging from homeassistant.components.sensor import ENTITY_ID_FORMAT from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, @@ -20,8 +21,13 @@ SENSOR_TYPES = { None, DEVICE_CLASS_TEMPERATURE, ], - "com.fibaro.smokeSensor": ["Smoke", "ppm", "mdi:fire", None], - "CO2": ["CO2", "ppm", "mdi:cloud", None], + "com.fibaro.smokeSensor": [ + "Smoke", + CONCENTRATION_PARTS_PER_MILLION, + "mdi:fire", + None, + ], + "CO2": ["CO2", CONCENTRATION_PARTS_PER_MILLION, "mdi:cloud", None], "com.fibaro.humiditySensor": ["Humidity", "%", None, DEVICE_CLASS_HUMIDITY], "com.fibaro.lightSensor": ["Light", "lx", None, DEVICE_CLASS_ILLUMINANCE], } diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 8c17e3b9c4c..4c5d55e0241 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -14,6 +14,8 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_UNIT_SYSTEM, + MASS_KILOGRAMS, + MASS_MILLIGRAMS, TIME_MILLISECONDS, TIME_MINUTES, ) @@ -115,7 +117,7 @@ FITBIT_MEASUREMENTS = { "weight": "lbs", "body": "in", "liquids": "fl. oz.", - "blood glucose": "mg/dL", + "blood glucose": f"{MASS_MILLIGRAMS}/dL", "battery": "", }, "en_GB": { @@ -134,7 +136,7 @@ FITBIT_MEASUREMENTS = { "distance": "kilometers", "elevation": "meters", "height": "centimeters", - "weight": "kilograms", + "weight": MASS_KILOGRAMS, "body": "centimeters", "liquids": "milliliters", "blood glucose": "mmol/L", diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py index 656631f0774..65afb04ba0e 100644 --- a/homeassistant/components/foobot/sensor.py +++ b/homeassistant/components/foobot/sensor.py @@ -10,6 +10,9 @@ import voluptuous as vol from homeassistant.const import ( ATTR_TEMPERATURE, ATTR_TIME, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, CONF_TOKEN, CONF_USERNAME, TEMP_CELSIUS, @@ -32,11 +35,19 @@ ATTR_FOOBOT_INDEX = "index" SENSOR_TYPES = { "time": [ATTR_TIME, TIME_SECONDS], - "pm": [ATTR_PM2_5, "µg/m3", "mdi:cloud"], + "pm": [ATTR_PM2_5, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "mdi:cloud"], "tmp": [ATTR_TEMPERATURE, TEMP_CELSIUS, "mdi:thermometer"], "hum": [ATTR_HUMIDITY, "%", "mdi:water-percent"], - "co2": [ATTR_CARBON_DIOXIDE, "ppm", "mdi:periodic-table-co2"], - "voc": [ATTR_VOLATILE_ORGANIC_COMPOUNDS, "ppb", "mdi:cloud"], + "co2": [ + ATTR_CARBON_DIOXIDE, + CONCENTRATION_PARTS_PER_MILLION, + "mdi:periodic-table-co2", + ], + "voc": [ + ATTR_VOLATILE_ORGANIC_COMPOUNDS, + CONCENTRATION_PARTS_PER_BILLION, + "mdi:cloud", + ], "allpollu": [ATTR_FOOBOT_INDEX, "%", "mdi:percent"], } diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index dd062fb982f..a71ff7e4ac2 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -1,7 +1,11 @@ """Support for Homekit sensors.""" from aiohomekit.model.characteristics import CharacteristicsTypes -from homeassistant.const import DEVICE_CLASS_BATTERY, TEMP_CELSIUS +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_BATTERY, + TEMP_CELSIUS, +) from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity @@ -13,7 +17,6 @@ CO2_ICON = "mdi:periodic-table-co2" UNIT_PERCENT = "%" UNIT_LUX = "lux" -UNIT_CO2 = "ppm" class HomeKitHumiditySensor(HomeKitEntity): @@ -149,7 +152,7 @@ class HomeKitCarbonDioxideSensor(HomeKitEntity): @property def unit_of_measurement(self): """Return units for the sensor.""" - return UNIT_CO2 + return CONCENTRATION_PARTS_PER_MILLION def _update_carbon_dioxide_level(self, value): self._state = value diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index e7ab5ddfefc..09d1e2f59cf 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -8,7 +8,8 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, ENERGY_WATT_HOUR, POWER_WATT, - TIME_HOURS, + SPEED_KILOMETERS_PER_HOUR, + VOLUME_CUBIC_METERS, ) from .const import ATTR_DISCOVER_DEVICES @@ -39,8 +40,8 @@ HM_UNIT_HA_CAST = { "CURRENT": "mA", "VOLTAGE": "V", "ENERGY_COUNTER": ENERGY_WATT_HOUR, - "GAS_POWER": "m3", - "GAS_ENERGY_COUNTER": "m3", + "GAS_POWER": VOLUME_CUBIC_METERS, + "GAS_ENERGY_COUNTER": VOLUME_CUBIC_METERS, "LUX": "lx", "ILLUMINATION": "lx", "CURRENT_ILLUMINATION": "lx", @@ -48,7 +49,7 @@ HM_UNIT_HA_CAST = { "LOWEST_ILLUMINATION": "lx", "HIGHEST_ILLUMINATION": "lx", "RAIN_COUNTER": "mm", - "WIND_SPEED": f"km/{TIME_HOURS}", + "WIND_SPEED": SPEED_KILOMETERS_PER_HOUR, "WIND_DIRECTION": "°", "WIND_DIRECTION_RANGE": "°", "SUNSHINEDURATION": "#", diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index f38eea840c6..4335eebb8b8 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -31,8 +31,8 @@ from homeassistant.const import ( DEVICE_CLASS_POWER, DEVICE_CLASS_TEMPERATURE, POWER_WATT, + SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, - TIME_HOURS, ) from homeassistant.helpers.typing import HomeAssistantType @@ -333,7 +333,7 @@ class HomematicipWindspeedSensor(HomematicipGenericDevice): @property def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" - return f"km/{TIME_HOURS}" + return SPEED_KILOMETERS_PER_HOUR @property def device_state_attributes(self) -> Dict[str, Any]: diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 563f292c489..42590b0ea13 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -4,7 +4,11 @@ from typing import Callable from homeassistant.components.sensor import DOMAIN from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, POWER_WATT, + SPEED_KILOMETERS_PER_HOUR, + SPEED_METERS_PER_SECOND, + SPEED_MILES_PER_HOUR, TEMP_CELSIUS, TEMP_FAHRENHEIT, TIME_DAYS, @@ -51,7 +55,7 @@ UOM_FRIENDLY_NAME = { "29": "kV", "30": "kW", "31": "kPa", - "32": "KPH", + "32": SPEED_KILOMETERS_PER_HOUR, "33": "kWH", "34": "liedu", "35": "l", @@ -59,7 +63,7 @@ UOM_FRIENDLY_NAME = { "37": "mercalli", "38": "m", "39": "m³/hr", - "40": f"m/{TIME_SECONDS}", + "40": SPEED_METERS_PER_SECOND, "41": "mA", "42": TIME_MILLISECONDS, "43": "mV", @@ -67,13 +71,13 @@ UOM_FRIENDLY_NAME = { "45": TIME_MINUTES, "46": "mm/hr", "47": TIME_MONTHS, - "48": "MPH", - "49": f"m/{TIME_SECONDS}", + "48": SPEED_MILES_PER_HOUR, + "49": SPEED_METERS_PER_SECOND, "50": "ohm", "51": "%", "52": "lb", "53": "power factor", - "54": "ppm", + "54": CONCENTRATION_PARTS_PER_MILLION, "55": "pulse count", "57": TIME_SECONDS, "58": TIME_SECONDS, diff --git a/homeassistant/components/kaiterra/const.py b/homeassistant/components/kaiterra/const.py index 7e23edb1259..6c3ea4d6f01 100644 --- a/homeassistant/components/kaiterra/const.py +++ b/homeassistant/components/kaiterra/const.py @@ -2,6 +2,13 @@ from datetime import timedelta +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, +) + DOMAIN = "kaiterra" DISPATCHER_KAITERRA = "kaiterra_update" @@ -44,7 +51,16 @@ ATTR_AQI_LEVEL = "air_quality_index_level" ATTR_AQI_POLLUTANT = "air_quality_index_pollutant" AVAILABLE_AQI_STANDARDS = ["us", "cn", "in"] -AVAILABLE_UNITS = ["x", "%", "C", "F", "mg/m³", "µg/m³", "ppm", "ppb"] +AVAILABLE_UNITS = [ + "x", + "%", + "C", + "F", + CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_MILLION, + CONCENTRATION_PARTS_PER_BILLION, +] AVAILABLE_DEVICE_TYPES = ["laseregg", "sensedge"] CONF_AQI_STANDARD = "aqi_standard" diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py index 4daadcd9c94..a722829a4a2 100644 --- a/homeassistant/components/luftdaten/__init__.py +++ b/homeassistant/components/luftdaten/__init__.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL, CONF_SENSORS, @@ -39,18 +40,20 @@ SENSOR_TEMPERATURE = "temperature" TOPIC_UPDATE = f"{DOMAIN}_data_update" -VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m3" - SENSORS = { SENSOR_TEMPERATURE: ["Temperature", "mdi:thermometer", TEMP_CELSIUS], SENSOR_HUMIDITY: ["Humidity", "mdi:water-percent", "%"], SENSOR_PRESSURE: ["Pressure", "mdi:arrow-down-bold", "Pa"], SENSOR_PRESSURE_AT_SEALEVEL: ["Pressure at sealevel", "mdi:download", "Pa"], - SENSOR_PM10: ["PM10", "mdi:thought-bubble", VOLUME_MICROGRAMS_PER_CUBIC_METER], + SENSOR_PM10: [ + "PM10", + "mdi:thought-bubble", + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ], SENSOR_PM2_5: [ "PM2.5", "mdi:thought-bubble-outline", - VOLUME_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, ], } diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index d797f610dca..9fde6f38b51 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -1,6 +1,6 @@ """Meteo-France component constants.""" -from homeassistant.const import TEMP_CELSIUS, TIME_HOURS, TIME_MINUTES +from homeassistant.const import SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, TIME_MINUTES DOMAIN = "meteo_france" PLATFORMS = ["sensor", "weather"] @@ -47,7 +47,7 @@ SENSOR_TYPES = { }, "wind_speed": { SENSOR_TYPE_NAME: "Wind Speed", - SENSOR_TYPE_UNIT: f"km/{TIME_HOURS}", + SENSOR_TYPE_UNIT: SPEED_KILOMETERS_PER_HOUR, SENSOR_TYPE_ICON: "mdi:weather-windy", SENSOR_TYPE_CLASS: None, }, diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 98d94ebe6ca..6e6fde06d8c 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_NAME, + SPEED_MILES_PER_HOUR, TEMP_CELSIUS, ) import homeassistant.helpers.config_validation as cv @@ -64,9 +65,9 @@ SENSOR_TYPES = { "weather": ["Weather", None], "temperature": ["Temperature", TEMP_CELSIUS], "feels_like_temperature": ["Feels Like Temperature", TEMP_CELSIUS], - "wind_speed": ["Wind Speed", "mph"], + "wind_speed": ["Wind Speed", SPEED_MILES_PER_HOUR], "wind_direction": ["Wind Direction", None], - "wind_gust": ["Wind Gust", "mph"], + "wind_gust": ["Wind Gust", SPEED_MILES_PER_HOUR], "visibility": ["Visibility", None], "visibility_distance": ["Visibility Distance", "km"], "uv": ["UV", None], diff --git a/homeassistant/components/mhz19/sensor.py b/homeassistant/components/mhz19/sensor.py index aedd5ea9b09..961d8646979 100644 --- a/homeassistant/components/mhz19/sensor.py +++ b/homeassistant/components/mhz19/sensor.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_TEMPERATURE, + CONCENTRATION_PARTS_PER_MILLION, CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_FAHRENHEIT, @@ -28,7 +29,10 @@ ATTR_CO2_CONCENTRATION = "co2_concentration" SENSOR_TEMPERATURE = "temperature" SENSOR_CO2 = "co2" -SENSOR_TYPES = {SENSOR_TEMPERATURE: ["Temperature", None], SENSOR_CO2: ["CO2", "ppm"]} +SENSOR_TYPES = { + SENSOR_TEMPERATURE: ["Temperature", None], + SENSOR_CO2: ["CO2", CONCENTRATION_PARTS_PER_MILLION], +} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index ddad451d20f..2b8cf208c14 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -3,6 +3,7 @@ from homeassistant.components import mysensors from homeassistant.components.sensor import DOMAIN from homeassistant.const import ( ENERGY_KILO_WATT_HOUR, + MASS_KILOGRAMS, POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT, @@ -20,7 +21,7 @@ SENSORS = { "V_WIND": [None, "mdi:weather-windy"], "V_GUST": [None, "mdi:weather-windy"], "V_DIRECTION": ["°", "mdi:compass"], - "V_WEIGHT": ["kg", "mdi:weight-kilogram"], + "V_WEIGHT": [MASS_KILOGRAMS, "mdi:weight-kilogram"], "V_DISTANCE": ["m", "mdi:ruler"], "V_IMPEDANCE": ["ohm", None], "V_WATT": [POWER_WATT, None], diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index b5da37f012f..9254f2f45ab 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -5,11 +5,12 @@ import logging import pyatmo from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, + SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, - TIME_HOURS, ) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -53,7 +54,7 @@ SENSOR_TYPES = { "mdi:thermometer", DEVICE_CLASS_TEMPERATURE, ], - "co2": ["CO2", "ppm", "mdi:periodic-table-co2", None], + "co2": ["CO2", CONCENTRATION_PARTS_PER_MILLION, "mdi:periodic-table-co2", None], "pressure": ["Pressure", "mbar", "mdi:gauge", None], "noise": ["Noise", "dB", "mdi:volume-high", None], "humidity": ["Humidity", "%", "mdi:water-percent", DEVICE_CLASS_HUMIDITY], @@ -67,10 +68,20 @@ SENSOR_TYPES = { "max_temp": ["Max Temp.", TEMP_CELSIUS, "mdi:thermometer", None], "windangle": ["Angle", "", "mdi:compass", None], "windangle_value": ["Angle Value", "º", "mdi:compass", None], - "windstrength": ["Wind Strength", f"km/{TIME_HOURS}", "mdi:weather-windy", None], + "windstrength": [ + "Wind Strength", + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", + None, + ], "gustangle": ["Gust Angle", "", "mdi:compass", None], "gustangle_value": ["Gust Angle Value", "º", "mdi:compass", None], - "guststrength": ["Gust Strength", f"km/{TIME_HOURS}", "mdi:weather-windy", None], + "guststrength": [ + "Gust Strength", + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", + None, + ], "reachable": ["Reachability", "", "mdi:signal", None], "rf_status": ["Radio", "", "mdi:signal", None], "rf_status_lvl": ["Radio_lvl", "", "mdi:signal", None], diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 0fead516b7e..5908ccfff06 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -12,9 +12,9 @@ from homeassistant.const import ( CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, TEMP_FAHRENHEIT, - TIME_SECONDS, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -34,7 +34,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120) SENSOR_TYPES = { "weather": ["Condition", None], "temperature": ["Temperature", None], - "wind_speed": ["Wind speed", f"m/{TIME_SECONDS}"], + "wind_speed": ["Wind speed", SPEED_METERS_PER_SECOND], "wind_bearing": ["Wind bearing", "°"], "humidity": ["Humidity", "%"], "pressure": ["Pressure", "mbar"], diff --git a/homeassistant/components/serial_pm/sensor.py b/homeassistant/components/serial_pm/sensor.py index 75587e4eab7..2e7604ee97d 100644 --- a/homeassistant/components/serial_pm/sensor.py +++ b/homeassistant/components/serial_pm/sensor.py @@ -5,7 +5,7 @@ from pmsensor import serial_pm as pm import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME +from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -79,7 +79,7 @@ class ParticulateMatterSensor(Entity): @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - return "µg/m³" + return CONCENTRATION_MICROGRAMS_PER_CUBIC_METER def update(self): """Read from sensor and update the state.""" diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index c61d28bbaac..4ff0bb5b853 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -2,7 +2,7 @@ from datetime import timedelta import logging -from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT +from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT, VOLUME_CUBIC_METERS from homeassistant.helpers.entity import Entity from . import DATA_SMAPPEE @@ -43,8 +43,20 @@ SENSOR_TYPES = { ENERGY_KILO_WATT_HOUR, "consumption", ], - "water_sensor_1": ["Water Sensor 1", "mdi:water", "water", "m3", "value1"], - "water_sensor_2": ["Water Sensor 2", "mdi:water", "water", "m3", "value2"], + "water_sensor_1": [ + "Water Sensor 1", + "mdi:water", + "water", + VOLUME_CUBIC_METERS, + "value1", + ], + "water_sensor_2": [ + "Water Sensor 2", + "mdi:water", + "water", + VOLUME_CUBIC_METERS, + "value2", + ], "water_sensor_temperature": [ "Water Sensor Temperature", "mdi:temperature-celsius", diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 38e32e90b85..fb04c01c682 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -5,6 +5,7 @@ from typing import Optional, Sequence from pysmartthings import Attribute, Capability from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, @@ -42,13 +43,23 @@ CAPABILITY_TO_SENSORS = { Map(Attribute.body_weight_measurement, "Body Weight", MASS_KILOGRAMS, None) ], Capability.carbon_dioxide_measurement: [ - Map(Attribute.carbon_dioxide, "Carbon Dioxide Measurement", "ppm", None) + Map( + Attribute.carbon_dioxide, + "Carbon Dioxide Measurement", + CONCENTRATION_PARTS_PER_MILLION, + None, + ) ], Capability.carbon_monoxide_detector: [ Map(Attribute.carbon_monoxide, "Carbon Monoxide Detector", None, None) ], Capability.carbon_monoxide_measurement: [ - Map(Attribute.carbon_monoxide_level, "Carbon Monoxide Measurement", "ppm", None) + Map( + Attribute.carbon_monoxide_level, + "Carbon Monoxide Measurement", + CONCENTRATION_PARTS_PER_MILLION, + None, + ) ], Capability.dishwasher_operating_state: [ Map(Attribute.machine_state, "Dishwasher Machine State", None, None), @@ -82,12 +93,17 @@ CAPABILITY_TO_SENSORS = { Map( Attribute.equivalent_carbon_dioxide_measurement, "Equivalent Carbon Dioxide Measurement", - "ppm", + CONCENTRATION_PARTS_PER_MILLION, None, ) ], Capability.formaldehyde_measurement: [ - Map(Attribute.formaldehyde_level, "Formaldehyde Measurement", "ppm", None) + Map( + Attribute.formaldehyde_level, + "Formaldehyde Measurement", + CONCENTRATION_PARTS_PER_MILLION, + None, + ) ], Capability.illuminance_measurement: [ Map(Attribute.illuminance, "Illuminance", "lux", DEVICE_CLASS_ILLUMINANCE) @@ -203,7 +219,12 @@ CAPABILITY_TO_SENSORS = { Capability.three_axis: [], Capability.tv_channel: [Map(Attribute.tv_channel, "Tv Channel", None, None)], Capability.tvoc_measurement: [ - Map(Attribute.tvoc_level, "Tvoc Measurement", "ppm", None) + Map( + Attribute.tvoc_level, + "Tvoc Measurement", + CONCENTRATION_PARTS_PER_MILLION, + None, + ) ], Capability.ultraviolet_index: [ Map(Attribute.ultraviolet_index, "Ultraviolet Index", None, None) diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index e3cb1e48c37..7aafa38c94f 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -7,9 +7,9 @@ from homeassistant.const import ( DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, POWER_WATT, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, TIME_HOURS, - TIME_SECONDS, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -41,8 +41,8 @@ SENSOR_TYPES = { 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], - SENSOR_TYPE_WINDAVERAGE: ["Wind average", f"m/{TIME_SECONDS}", "", None], - SENSOR_TYPE_WINDGUST: ["Wind gust", f"m/{TIME_SECONDS}", "", None], + SENSOR_TYPE_WINDAVERAGE: ["Wind average", SPEED_METERS_PER_SECOND, "", None], + SENSOR_TYPE_WINDGUST: ["Wind gust", SPEED_METERS_PER_SECOND, "", None], SENSOR_TYPE_UV: ["UV", "UV", "", None], SENSOR_TYPE_WATT: ["Power", POWER_WATT, "", None], SENSOR_TYPE_LUMINANCE: ["Luminance", "lx", None, DEVICE_CLASS_ILLUMINANCE], diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index b57a24d0ced..78f5bbbb8ca 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -16,8 +16,8 @@ from homeassistant.const import ( CONF_NAME, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, - TIME_SECONDS, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -74,7 +74,7 @@ SENSOR_TYPES = { ], "wind_speed": [ "Wind speed", - f"m/{TIME_SECONDS}", + SPEED_METERS_PER_SECOND, "windforce", "mdi:weather-windy", None, diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index fdb5bc5100a..c77a1b6279f 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -1,7 +1,7 @@ """Support for UPnP/IGD Sensors.""" import logging -from homeassistant.const import TIME_SECONDS +from homeassistant.const import DATA_BYTES, DATA_KIBIBYTES, TIME_SECONDS from homeassistant.core import callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -19,15 +19,15 @@ PACKETS_RECEIVED = "packets_received" PACKETS_SENT = "packets_sent" SENSOR_TYPES = { - BYTES_RECEIVED: {"name": "bytes received", "unit": "bytes"}, - BYTES_SENT: {"name": "bytes sent", "unit": "bytes"}, + BYTES_RECEIVED: {"name": "bytes received", "unit": DATA_BYTES}, + BYTES_SENT: {"name": "bytes sent", "unit": DATA_BYTES}, PACKETS_RECEIVED: {"name": "packets received", "unit": "packets"}, PACKETS_SENT: {"name": "packets sent", "unit": "packets"}, } IN = "received" OUT = "sent" -KBYTE = 1024 +KIBIBYTE = 1024 async def async_setup_platform( @@ -226,7 +226,7 @@ class KBytePerSecondUPnPIGDSensor(PerSecondUPnPIGDSensor): @property def unit(self) -> str: """Get unit we are measuring in.""" - return "kB" + return DATA_KIBIBYTES async def _async_fetch_value(self) -> float: """Fetch value from device.""" @@ -241,7 +241,7 @@ class KBytePerSecondUPnPIGDSensor(PerSecondUPnPIGDSensor): if self._state is None: return None - return format(float(self._state / KBYTE), ".1f") + return format(float(self._state / KIBIBYTE), ".1f") class PacketsPerSecondUPnPIGDSensor(PerSecondUPnPIGDSensor): diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 82081eee2fa..ea3814d3b3a 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -59,11 +59,9 @@ MEAS_TEMP_C = "temperature_c" MEAS_WEIGHT_KG = "weight_kg" UOM_BEATS_PER_MINUTE = "bpm" -UOM_BREATHS_PER_MINUTE = "br/m" +UOM_BREATHS_PER_MINUTE = f"br/{const.TIME_MINUTES}" UOM_FREQUENCY = "times" -UOM_METERS_PER_SECOND = f"m/{const.TIME_SECONDS}" UOM_MMHG = "mmhg" UOM_PERCENT = "%" UOM_LENGTH_M = const.LENGTH_METERS -UOM_MASS_KG = const.MASS_KILOGRAMS UOM_TEMP_C = const.TEMP_CELSIUS diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index c690580ffa4..0fee2271067 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -13,7 +13,7 @@ from withings_api.common import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TIME_SECONDS +from homeassistant.const import MASS_KILOGRAMS, SPEED_METERS_PER_SECOND, TIME_SECONDS from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.entity import Entity @@ -87,35 +87,35 @@ WITHINGS_ATTRIBUTES = [ const.MEAS_WEIGHT_KG, MeasureType.WEIGHT, "Weight", - const.UOM_MASS_KG, + MASS_KILOGRAMS, "mdi:weight-kilogram", ), WithingsMeasureAttribute( const.MEAS_FAT_MASS_KG, MeasureType.FAT_MASS_WEIGHT, "Fat Mass", - const.UOM_MASS_KG, + MASS_KILOGRAMS, "mdi:weight-kilogram", ), WithingsMeasureAttribute( const.MEAS_FAT_FREE_MASS_KG, MeasureType.FAT_FREE_MASS, "Fat Free Mass", - const.UOM_MASS_KG, + MASS_KILOGRAMS, "mdi:weight-kilogram", ), WithingsMeasureAttribute( const.MEAS_MUSCLE_MASS_KG, MeasureType.MUSCLE_MASS, "Muscle Mass", - const.UOM_MASS_KG, + MASS_KILOGRAMS, "mdi:weight-kilogram", ), WithingsMeasureAttribute( const.MEAS_BONE_MASS_KG, MeasureType.BONE_MASS, "Bone Mass", - const.UOM_MASS_KG, + MASS_KILOGRAMS, "mdi:weight-kilogram", ), WithingsMeasureAttribute( @@ -188,7 +188,7 @@ WITHINGS_ATTRIBUTES = [ const.MEAS_PWV, MeasureType.PULSE_WAVE_VELOCITY, "Pulse Wave Velocity", - const.UOM_METERS_PER_SECOND, + SPEED_METERS_PER_SECOND, None, ), WithingsSleepStateAttribute( diff --git a/homeassistant/components/wunderground/sensor.py b/homeassistant/components/wunderground/sensor.py index 44eb10c0e7d..6a1112c028b 100644 --- a/homeassistant/components/wunderground/sensor.py +++ b/homeassistant/components/wunderground/sensor.py @@ -17,10 +17,13 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, + IRRADIATION_WATTS_PER_SQUARE_METER, LENGTH_FEET, LENGTH_INCHES, LENGTH_KILOMETERS, LENGTH_MILES, + SPEED_KILOMETERS_PER_HOUR, + SPEED_MILES_PER_HOUR, TEMP_CELSIUS, TEMP_FAHRENHEIT, ) @@ -421,7 +424,10 @@ SENSOR_TYPES = { "Station ID", "station_id", "mdi:home" ), "solarradiation": WUCurrentConditionsSensorConfig( - "Solar Radiation", "solarradiation", "mdi:weather-sunny", "w/m2" + "Solar Radiation", + "solarradiation", + "mdi:weather-sunny", + IRRADIATION_WATTS_PER_SQUARE_METER, ), "temperature_string": WUCurrentConditionsSensorConfig( "Temperature Summary", "temperature_string", "mdi:thermometer" @@ -455,16 +461,16 @@ SENSOR_TYPES = { "Wind Direction", "wind_dir", "mdi:weather-windy" ), "wind_gust_kph": WUCurrentConditionsSensorConfig( - "Wind Gust", "wind_gust_kph", "mdi:weather-windy", "kph" + "Wind Gust", "wind_gust_kph", "mdi:weather-windy", SPEED_KILOMETERS_PER_HOUR ), "wind_gust_mph": WUCurrentConditionsSensorConfig( - "Wind Gust", "wind_gust_mph", "mdi:weather-windy", "mph" + "Wind Gust", "wind_gust_mph", "mdi:weather-windy", SPEED_MILES_PER_HOUR ), "wind_kph": WUCurrentConditionsSensorConfig( - "Wind Speed", "wind_kph", "mdi:weather-windy", "kph" + "Wind Speed", "wind_kph", "mdi:weather-windy", SPEED_KILOMETERS_PER_HOUR ), "wind_mph": WUCurrentConditionsSensorConfig( - "Wind Speed", "wind_mph", "mdi:weather-windy", "mph" + "Wind Speed", "wind_mph", "mdi:weather-windy", SPEED_MILES_PER_HOUR ), "wind_string": WUCurrentConditionsSensorConfig( "Wind Summary", "wind_string", "mdi:weather-windy" @@ -738,52 +744,132 @@ SENSOR_TYPES = { device_class="temperature", ), "wind_gust_1d_kph": WUDailySimpleForecastSensorConfig( - "Max. Wind Today", 0, "maxwind", "kph", "kph", "mdi:weather-windy" + "Max. Wind Today", + 0, + "maxwind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_gust_2d_kph": WUDailySimpleForecastSensorConfig( - "Max. Wind Tomorrow", 1, "maxwind", "kph", "kph", "mdi:weather-windy" + "Max. Wind Tomorrow", + 1, + "maxwind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_gust_3d_kph": WUDailySimpleForecastSensorConfig( - "Max. Wind in 3 Days", 2, "maxwind", "kph", "kph", "mdi:weather-windy" + "Max. Wind in 3 Days", + 2, + "maxwind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_gust_4d_kph": WUDailySimpleForecastSensorConfig( - "Max. Wind in 4 Days", 3, "maxwind", "kph", "kph", "mdi:weather-windy" + "Max. Wind in 4 Days", + 3, + "maxwind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_gust_1d_mph": WUDailySimpleForecastSensorConfig( - "Max. Wind Today", 0, "maxwind", "mph", "mph", "mdi:weather-windy" + "Max. Wind Today", + 0, + "maxwind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "wind_gust_2d_mph": WUDailySimpleForecastSensorConfig( - "Max. Wind Tomorrow", 1, "maxwind", "mph", "mph", "mdi:weather-windy" + "Max. Wind Tomorrow", + 1, + "maxwind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "wind_gust_3d_mph": WUDailySimpleForecastSensorConfig( - "Max. Wind in 3 Days", 2, "maxwind", "mph", "mph", "mdi:weather-windy" + "Max. Wind in 3 Days", + 2, + "maxwind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "wind_gust_4d_mph": WUDailySimpleForecastSensorConfig( - "Max. Wind in 4 Days", 3, "maxwind", "mph", "mph", "mdi:weather-windy" + "Max. Wind in 4 Days", + 3, + "maxwind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "wind_1d_kph": WUDailySimpleForecastSensorConfig( - "Avg. Wind Today", 0, "avewind", "kph", "kph", "mdi:weather-windy" + "Avg. Wind Today", + 0, + "avewind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_2d_kph": WUDailySimpleForecastSensorConfig( - "Avg. Wind Tomorrow", 1, "avewind", "kph", "kph", "mdi:weather-windy" + "Avg. Wind Tomorrow", + 1, + "avewind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_3d_kph": WUDailySimpleForecastSensorConfig( - "Avg. Wind in 3 Days", 2, "avewind", "kph", "kph", "mdi:weather-windy" + "Avg. Wind in 3 Days", + 2, + "avewind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_4d_kph": WUDailySimpleForecastSensorConfig( - "Avg. Wind in 4 Days", 3, "avewind", "kph", "kph", "mdi:weather-windy" + "Avg. Wind in 4 Days", + 3, + "avewind", + SPEED_KILOMETERS_PER_HOUR, + SPEED_KILOMETERS_PER_HOUR, + "mdi:weather-windy", ), "wind_1d_mph": WUDailySimpleForecastSensorConfig( - "Avg. Wind Today", 0, "avewind", "mph", "mph", "mdi:weather-windy" + "Avg. Wind Today", + 0, + "avewind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "wind_2d_mph": WUDailySimpleForecastSensorConfig( - "Avg. Wind Tomorrow", 1, "avewind", "mph", "mph", "mdi:weather-windy" + "Avg. Wind Tomorrow", + 1, + "avewind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "wind_3d_mph": WUDailySimpleForecastSensorConfig( - "Avg. Wind in 3 Days", 2, "avewind", "mph", "mph", "mdi:weather-windy" + "Avg. Wind in 3 Days", + 2, + "avewind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "wind_4d_mph": WUDailySimpleForecastSensorConfig( - "Avg. Wind in 4 Days", 3, "avewind", "mph", "mph", "mdi:weather-windy" + "Avg. Wind in 4 Days", + 3, + "avewind", + SPEED_MILES_PER_HOUR, + SPEED_MILES_PER_HOUR, + "mdi:weather-windy", ), "precip_1d_mm": WUDailySimpleForecastSensorConfig( "Precipitation Intensity Today", 0, "qpf_allday", "mm", "mm", "mdi:umbrella" diff --git a/homeassistant/components/xiaomi_miio/air_quality.py b/homeassistant/components/xiaomi_miio/air_quality.py index 110ca7cff49..93aeb0d28b7 100644 --- a/homeassistant/components/xiaomi_miio/air_quality.py +++ b/homeassistant/components/xiaomi_miio/air_quality.py @@ -5,7 +5,12 @@ from miio import AirQualityMonitor, Device, DeviceException import voluptuous as vol from homeassistant.components.air_quality import PLATFORM_SCHEMA, AirQualityEntity -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONF_HOST, + CONF_NAME, + CONF_TOKEN, +) from homeassistant.exceptions import NoEntitySpecifiedError, PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -88,7 +93,7 @@ class AirMonitorB1(AirQualityEntity): self._device = device self._unique_id = unique_id self._icon = "mdi:cloud" - self._unit_of_measurement = "μg/m3" + self._unit_of_measurement = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER self._available = None self._air_quality_index = None self._carbon_dioxide = None diff --git a/homeassistant/components/yr/sensor.py b/homeassistant/components/yr/sensor.py index 63fe4012fe5..5e7f4ed1db5 100644 --- a/homeassistant/components/yr/sensor.py +++ b/homeassistant/components/yr/sensor.py @@ -21,8 +21,8 @@ from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, PRESSURE_HPA, + SPEED_METERS_PER_SECOND, TEMP_CELSIUS, - TIME_SECONDS, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -42,8 +42,8 @@ SENSOR_TYPES = { "symbol": ["Symbol", None, None], "precipitation": ["Precipitation", "mm", None], "temperature": ["Temperature", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE], - "windSpeed": ["Wind speed", f"m/{TIME_SECONDS}", None], - "windGust": ["Wind gust", f"m/{TIME_SECONDS}", None], + "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", "°", None], "humidity": ["Humidity", "%", DEVICE_CLASS_HUMIDITY], diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 6e96d28e176..74335a2ccdd 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_NAME, - TIME_HOURS, + SPEED_KILOMETERS_PER_HOUR, __version__, ) import homeassistant.helpers.config_validation as cv @@ -40,12 +40,17 @@ SENSOR_TYPES = { "pressure": ("Pressure", "hPa", "LDstat hPa", float), "pressure_sealevel": ("Pressure at Sea Level", "hPa", "LDred hPa", float), "humidity": ("Humidity", "%", "RF %", int), - "wind_speed": ("Wind Speed", f"km/{TIME_HOURS}", f"WG km/{TIME_HOURS}", float), + "wind_speed": ( + "Wind Speed", + SPEED_KILOMETERS_PER_HOUR, + f"WG {SPEED_KILOMETERS_PER_HOUR}", + float, + ), "wind_bearing": ("Wind Bearing", "°", "WR °", int), "wind_max_speed": ( "Top Wind Speed", - f"km/{TIME_HOURS}", - f"WSG km/{TIME_HOURS}", + SPEED_KILOMETERS_PER_HOUR, + f"WSG {SPEED_KILOMETERS_PER_HOUR}", float, ), "wind_max_bearing": ("Top Wind Bearing", "°", "WSR °", int), diff --git a/homeassistant/const.py b/homeassistant/const.py index 32dfac973a2..155eff79bb3 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -375,13 +375,19 @@ PRESSURE_PSI: str = "psi" # Volume units VOLUME_LITERS: str = "L" VOLUME_MILLILITERS: str = "mL" +VOLUME_CUBIC_METERS = f"{LENGTH_METERS}³" VOLUME_GALLONS: str = "gal" VOLUME_FLUID_OUNCE: str = "fl. oz." +# Area units +AREA_SQUARE_METERS = f"{LENGTH_METERS}²" + # Mass units MASS_GRAMS: str = "g" MASS_KILOGRAMS: str = "kg" +MASS_MILLIGRAMS = "mg" +MASS_MICROGRAMS = "µg" MASS_OUNCES: str = "oz" MASS_POUNDS: str = "lb" @@ -389,6 +395,20 @@ MASS_POUNDS: str = "lb" # UV Index units UNIT_UV_INDEX: str = "UV index" +# Irradiation units +IRRADIATION_WATTS_PER_SQUARE_METER = f"{POWER_WATT}/{AREA_SQUARE_METERS}" + +# Concentration units +CONCENTRATION_MICROGRAMS_PER_CUBIC_METER = f"{MASS_MICROGRAMS}/{VOLUME_CUBIC_METERS}" +CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER = f"{MASS_MILLIGRAMS}/{VOLUME_CUBIC_METERS}" +CONCENTRATION_PARTS_PER_MILLION = "ppm" +CONCENTRATION_PARTS_PER_BILLION = "ppb" + +# Speed units +SPEED_METERS_PER_SECOND = f"{LENGTH_METERS}/{TIME_SECONDS}" +SPEED_KILOMETERS_PER_HOUR = f"{LENGTH_KILOMETERS}/{TIME_HOURS}" +SPEED_MILES_PER_HOUR = "mph" + # Data units DATA_BITS = "bit" DATA_KILOBITS = "kbit" diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py index ded1520718f..03d3f71d5f9 100644 --- a/tests/components/awair/test_sensor.py +++ b/tests/components/awair/test_sensor.py @@ -16,6 +16,9 @@ from homeassistant.components.awair.sensor import ( ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, STATE_UNAVAILABLE, @@ -183,7 +186,7 @@ async def test_awair_co2(hass): sensor = hass.states.get("sensor.awair_co2") assert sensor.state == "612" assert sensor.attributes["device_class"] == DEVICE_CLASS_CARBON_DIOXIDE - assert sensor.attributes["unit_of_measurement"] == "ppm" + assert sensor.attributes["unit_of_measurement"] == CONCENTRATION_PARTS_PER_MILLION async def test_awair_voc(hass): @@ -193,7 +196,7 @@ async def test_awair_voc(hass): sensor = hass.states.get("sensor.awair_voc") assert sensor.state == "1012" assert sensor.attributes["device_class"] == DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS - assert sensor.attributes["unit_of_measurement"] == "ppb" + assert sensor.attributes["unit_of_measurement"] == CONCENTRATION_PARTS_PER_BILLION async def test_awair_dust(hass): @@ -205,7 +208,10 @@ async def test_awair_dust(hass): sensor = hass.states.get("sensor.awair_pm2_5") assert sensor.state == "6.2" assert sensor.attributes["device_class"] == DEVICE_CLASS_PM2_5 - assert sensor.attributes["unit_of_measurement"] == "µg/m3" + assert ( + sensor.attributes["unit_of_measurement"] + == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + ) async def test_awair_unsupported_sensors(hass): diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index e3d5e62f435..297447b5038 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -15,7 +15,7 @@ import pytest from homeassistant.bootstrap import async_setup_component from homeassistant.components.dsmr.sensor import DerivativeDSMREntity -from homeassistant.const import TIME_HOURS +from homeassistant.const import TIME_HOURS, VOLUME_CUBIC_METERS from tests.common import assert_setup_component @@ -67,7 +67,7 @@ async def test_default_setup(hass, mock_connection_factory): GAS_METER_READING: MBusObject( [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS}, ] ), } @@ -101,7 +101,7 @@ async def test_default_setup(hass, mock_connection_factory): # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get("unit_of_measurement") == "m3" + assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS async def test_derivative(): @@ -119,7 +119,7 @@ async def test_derivative(): "1.0.0": MBusObject( [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS}, ] ) } @@ -131,7 +131,7 @@ async def test_derivative(): "1.0.0": MBusObject( [ {"value": datetime.datetime.fromtimestamp(1551642543)}, - {"value": Decimal(745.698), "unit": "m3"}, + {"value": Decimal(745.698), "unit": VOLUME_CUBIC_METERS}, ] ) } @@ -141,7 +141,7 @@ async def test_derivative(): abs(entity.state - 0.033) < 0.00001 ), "state should be hourly usage calculated from first and second update" - assert entity.unit_of_measurement == f"m3/{TIME_HOURS}" + assert entity.unit_of_measurement == f"{VOLUME_CUBIC_METERS}/{TIME_HOURS}" async def test_v4_meter(hass, mock_connection_factory): @@ -160,7 +160,7 @@ async def test_v4_meter(hass, mock_connection_factory): HOURLY_GAS_METER_READING: MBusObject( [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS}, ] ), ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), @@ -185,7 +185,7 @@ async def test_v4_meter(hass, mock_connection_factory): # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get("unit_of_measurement") == "m3" + assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS async def test_belgian_meter(hass, mock_connection_factory): @@ -204,7 +204,7 @@ async def test_belgian_meter(hass, mock_connection_factory): BELGIUM_HOURLY_GAS_METER_READING: MBusObject( [ {"value": datetime.datetime.fromtimestamp(1551642213)}, - {"value": Decimal(745.695), "unit": "m3"}, + {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS}, ] ), ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), @@ -229,7 +229,7 @@ async def test_belgian_meter(hass, mock_connection_factory): # check if gas consumption is parsed correctly gas_consumption = hass.states.get("sensor.gas_consumption") assert gas_consumption.state == "745.695" - assert gas_consumption.attributes.get("unit_of_measurement") == "m3" + assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS async def test_belgian_meter_low(hass, mock_connection_factory): diff --git a/tests/components/foobot/test_sensor.py b/tests/components/foobot/test_sensor.py index 9c6a17264eb..a843f9a3012 100644 --- a/tests/components/foobot/test_sensor.py +++ b/tests/components/foobot/test_sensor.py @@ -8,7 +8,12 @@ import pytest from homeassistant.components.foobot import sensor as foobot import homeassistant.components.sensor as sensor -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + TEMP_CELSIUS, +) from homeassistant.exceptions import PlatformNotReady from homeassistant.setup import async_setup_component @@ -33,11 +38,11 @@ async def test_default_setup(hass, aioclient_mock): assert await async_setup_component(hass, sensor.DOMAIN, {"sensor": VALID_CONFIG}) metrics = { - "co2": ["1232.0", "ppm"], + "co2": ["1232.0", CONCENTRATION_PARTS_PER_MILLION], "temperature": ["21.1", TEMP_CELSIUS], "humidity": ["49.5", "%"], - "pm2_5": ["144.8", "µg/m3"], - "voc": ["340.7", "ppb"], + "pm2_5": ["144.8", CONCENTRATION_MICROGRAMS_PER_CUBIC_METER], + "voc": ["340.7", CONCENTRATION_PARTS_PER_BILLION], "index": ["138.9", "%"], } diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index e3b0f8dd366..c5dbef1a499 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -20,10 +20,14 @@ from homeassistant.components.homematicip_cloud.sensor import ( ATTR_TEMPERATURE_OFFSET, ATTR_WIND_DIRECTION, ATTR_WIND_DIRECTION_VARIATION, - TIME_HOURS, ) from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, POWER_WATT, TEMP_CELSIUS +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + POWER_WATT, + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, +) from homeassistant.setup import async_setup_component from .helper import async_manipulate_test_data, get_and_check_entity_basics @@ -285,7 +289,7 @@ async def test_hmip_windspeed_sensor(hass, default_mock_hap_factory): ) assert ha_state.state == "2.6" - assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == f"km/{TIME_HOURS}" + assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == SPEED_KILOMETERS_PER_HOUR await async_manipulate_test_data(hass, hmip_device, "windSpeed", 9.4) ha_state = hass.states.get(entity_id) assert ha_state.state == "9.4" diff --git a/tests/components/mhz19/test_sensor.py b/tests/components/mhz19/test_sensor.py index 5eab93a30ff..45413a10cda 100644 --- a/tests/components/mhz19/test_sensor.py +++ b/tests/components/mhz19/test_sensor.py @@ -4,7 +4,7 @@ from unittest.mock import DEFAULT, Mock, patch import homeassistant.components.mhz19.sensor as mhz19 from homeassistant.components.sensor import DOMAIN -from homeassistant.const import TEMP_FAHRENHEIT +from homeassistant.const import CONCENTRATION_PARTS_PER_MILLION, TEMP_FAHRENHEIT from homeassistant.setup import setup_component from tests.common import assert_setup_component, get_test_home_assistant @@ -100,7 +100,7 @@ class TestMHZ19Sensor(unittest.TestCase): assert "name: CO2" == sensor.name assert 1000 == sensor.state - assert "ppm" == sensor.unit_of_measurement + assert CONCENTRATION_PARTS_PER_MILLION == sensor.unit_of_measurement assert sensor.should_poll assert {"temperature": 24} == sensor.device_state_attributes diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 5c6189a811e..a8bc9fe9823 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -5,7 +5,11 @@ from homeassistant import setup from homeassistant.components import climate, sensor from homeassistant.components.demo.sensor import DemoSensor import homeassistant.components.prometheus as prometheus -from homeassistant.const import DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, +) from homeassistant.setup import async_setup_component @@ -47,7 +51,12 @@ async def prometheus_client(loop, hass, hass_client): await sensor4.async_update_ha_state() sensor5 = DemoSensor( - None, "SPS30 PM <1µm Weight concentration", 3.7069, None, "µg/m³", None + None, + "SPS30 PM <1µm Weight concentration", + 3.7069, + None, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + None, ) sensor5.hass = hass sensor5.entity_id = "sensor.sps30_pm_1um_weight_concentration" diff --git a/tests/components/yr/test_sensor.py b/tests/components/yr/test_sensor.py index 8161c8c0faa..d3e3bd6286f 100644 --- a/tests/components/yr/test_sensor.py +++ b/tests/components/yr/test_sensor.py @@ -3,7 +3,7 @@ from datetime import datetime from unittest.mock import patch from homeassistant.bootstrap import async_setup_component -from homeassistant.const import TIME_SECONDS +from homeassistant.const import SPEED_METERS_PER_SECOND import homeassistant.util.dt as dt_util from tests.common import assert_setup_component, load_fixture @@ -71,7 +71,7 @@ async def test_custom_setup(hass, aioclient_mock): assert state.state == "0.0" state = hass.states.get("sensor.yr_wind_speed") - assert state.attributes.get("unit_of_measurement") == f"m/{TIME_SECONDS}" + assert state.attributes.get("unit_of_measurement") == SPEED_METERS_PER_SECOND assert state.state == "3.5" @@ -117,5 +117,5 @@ async def test_forecast_setup(hass, aioclient_mock): assert state.state == "0.0" state = hass.states.get("sensor.yr_wind_speed") - assert state.attributes.get("unit_of_measurement") == f"m/{TIME_SECONDS}" + assert state.attributes.get("unit_of_measurement") == SPEED_METERS_PER_SECOND assert state.state == "3.6" From ad102b3840d14e5e554bc2989b891c5982441da2 Mon Sep 17 00:00:00 2001 From: springstan <46536646+springstan@users.noreply.github.com> Date: Tue, 25 Feb 2020 02:54:20 +0100 Subject: [PATCH 082/416] Use f-strings in integrations starting with "F"and"G" (#32150) * Use f-strings in integrations starting with F * Use f-strings in tests for integrations starting with F * Use f-strings in integrations starting with G * Use f-strings in tests for integrations starting with G * Fix pylint error * Fix broken test --- homeassistant/components/fibaro/__init__.py | 8 +-- .../components/fibaro/binary_sensor.py | 4 +- homeassistant/components/fibaro/cover.py | 4 +- homeassistant/components/fibaro/light.py | 4 +- homeassistant/components/fibaro/sensor.py | 4 +- homeassistant/components/fibaro/switch.py | 4 +- homeassistant/components/file/notify.py | 8 +-- homeassistant/components/fitbit/sensor.py | 42 +++++-------- .../components/flic/binary_sensor.py | 6 +- homeassistant/components/flux/switch.py | 2 +- homeassistant/components/flux_led/light.py | 2 +- homeassistant/components/foobot/sensor.py | 2 +- homeassistant/components/foscam/camera.py | 7 +-- .../components/foursquare/__init__.py | 7 +-- homeassistant/components/fritzbox/switch.py | 6 +- homeassistant/components/fronius/sensor.py | 10 +--- homeassistant/components/frontend/__init__.py | 4 +- homeassistant/components/frontend/storage.py | 4 +- .../frontier_silicon/media_player.py | 3 +- homeassistant/components/garadget/cover.py | 4 +- homeassistant/components/gdacs/__init__.py | 12 ++-- homeassistant/components/gdacs/const.py | 6 -- .../components/gdacs/geo_location.py | 16 +---- homeassistant/components/gdacs/sensor.py | 4 +- .../geo_json_events/geo_location.py | 11 ++-- .../components/geo_rss_events/sensor.py | 4 +- homeassistant/components/geofency/__init__.py | 2 +- .../components/geonetnz_quakes/__init__.py | 14 ++--- .../components/geonetnz_quakes/const.py | 6 -- .../geonetnz_quakes/geo_location.py | 6 +- .../components/geonetnz_quakes/sensor.py | 4 +- .../components/geonetnz_volcano/__init__.py | 13 +--- .../components/geonetnz_volcano/const.py | 3 - .../components/geonetnz_volcano/sensor.py | 3 +- homeassistant/components/gogogate2/cover.py | 4 +- homeassistant/components/google/__init__.py | 16 ++--- .../components/google_assistant/http.py | 4 +- .../components/google_assistant/trait.py | 22 +++---- .../components/google_domains/__init__.py | 4 +- .../components/google_maps/device_tracker.py | 6 +- .../components/google_travel_time/sensor.py | 2 +- .../components/greeneye_monitor/sensor.py | 10 +--- homeassistant/components/group/__init__.py | 10 ++-- homeassistant/components/gtfs/sensor.py | 59 +++++++++---------- .../facebox/test_image_processing.py | 18 +++--- tests/components/fan/test_device_trigger.py | 8 +-- tests/components/feedreader/test_init.py | 6 +- tests/components/file/test_notify.py | 9 +-- tests/components/flux/test_switch.py | 4 +- tests/components/frontend/test_storage.py | 6 +- .../geo_json_events/test_geo_location.py | 6 +- tests/components/geofency/test_init.py | 54 ++++++++--------- tests/components/google/test_calendar.py | 6 +- .../google_assistant/test_google_assistant.py | 2 +- .../components/google_assistant/test_http.py | 4 +- tests/components/google_domains/test_init.py | 2 +- tests/components/google_wifi/test_sensor.py | 10 ++-- tests/components/gpslogger/test_init.py | 28 ++++----- tests/components/group/test_init.py | 24 ++------ 59 files changed, 219 insertions(+), 344 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 52ecb881205..89529046f85 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -247,8 +247,8 @@ class FibaroController: room_name = self._room_map[device.roomID].name device.room_name = room_name device.friendly_name = f"{room_name} {device.name}" - device.ha_id = "scene_{}_{}_{}".format( - slugify(room_name), slugify(device.name), device.id + device.ha_id = ( + f"scene_{slugify(room_name)}_{slugify(device.name)}_{device.id}" ) device.unique_id_str = f"{self.hub_serial}.scene.{device.id}" self._scene_map[device.id] = device @@ -269,8 +269,8 @@ class FibaroController: room_name = self._room_map[device.roomID].name device.room_name = room_name device.friendly_name = f"{room_name} {device.name}" - device.ha_id = "{}_{}_{}".format( - slugify(room_name), slugify(device.name), device.id + device.ha_id = ( + f"{slugify(room_name)}_{slugify(device.name)}_{device.id}" ) if ( device.enabled diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index af2c2a9401a..fa2d6ceb3c6 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -1,7 +1,7 @@ """Support for Fibaro binary sensors.""" import logging -from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorDevice +from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice from homeassistant.const import CONF_DEVICE_CLASS, CONF_ICON from . import FIBARO_DEVICES, FibaroDevice @@ -40,7 +40,7 @@ class FibaroBinarySensor(FibaroDevice, BinarySensorDevice): """Initialize the binary_sensor.""" self._state = None super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + self.entity_id = f"{DOMAIN}.{self.ha_id}" stype = None devconf = fibaro_device.device_config if fibaro_device.type in SENSOR_TYPES: diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index fe9c0990fa8..d2f8094f26d 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -4,7 +4,7 @@ import logging from homeassistant.components.cover import ( ATTR_POSITION, ATTR_TILT_POSITION, - ENTITY_ID_FORMAT, + DOMAIN, CoverDevice, ) @@ -29,7 +29,7 @@ class FibaroCover(FibaroDevice, CoverDevice): def __init__(self, fibaro_device): """Initialize the Vera device.""" super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + self.entity_id = f"{DOMAIN}.{self.ha_id}" @staticmethod def bound(position): diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 38779a05cb0..d14d9a195d9 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -7,7 +7,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, - ENTITY_ID_FORMAT, + DOMAIN, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_WHITE_VALUE, @@ -77,7 +77,7 @@ class FibaroLight(FibaroDevice, Light): self._supported_flags |= SUPPORT_WHITE_VALUE super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + self.entity_id = f"{DOMAIN}.{self.ha_id}" @property def brightness(self): diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index 720249b9b8a..5fce0da7a2b 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -1,7 +1,7 @@ """Support for Fibaro sensors.""" import logging -from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.components.sensor import DOMAIN from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_HUMIDITY, @@ -53,7 +53,7 @@ class FibaroSensor(FibaroDevice, Entity): self.current_value = None self.last_changed_time = None super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + self.entity_id = f"{DOMAIN}.{self.ha_id}" if fibaro_device.type in SENSOR_TYPES: self._unit = SENSOR_TYPES[fibaro_device.type][1] self._icon = SENSOR_TYPES[fibaro_device.type][2] diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index 4bb8c34d579..b00e5817c9e 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -1,7 +1,7 @@ """Support for Fibaro switches.""" import logging -from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice +from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.util import convert from . import FIBARO_DEVICES, FibaroDevice @@ -26,7 +26,7 @@ class FibaroSwitch(FibaroDevice, SwitchDevice): """Initialize the Fibaro device.""" self._state = False super().__init__(fibaro_device) - self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id) + self.entity_id = f"{DOMAIN}.{self.ha_id}" def turn_on(self, **kwargs): """Turn device on.""" diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py index 4cd83e64a83..528d44bbb83 100644 --- a/homeassistant/components/file/notify.py +++ b/homeassistant/components/file/notify.py @@ -46,15 +46,11 @@ class FileNotificationService(BaseNotificationService): """Send a message to a file.""" with open(self.filepath, "a") as file: if os.stat(self.filepath).st_size == 0: - title = "{} notifications (Log started: {})\n{}\n".format( - kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), - dt_util.utcnow().isoformat(), - "-" * 80, - ) + title = f"{kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)} notifications (Log started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n" file.write(title) if self.add_timestamp: - text = "{} {}\n".format(dt_util.utcnow().isoformat(), message) + text = f"{dt_util.utcnow().isoformat()} {message}\n" else: text = f"{message}\n" file.write(text) diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index 4c5d55e0241..b6a4fe550c9 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -181,16 +181,14 @@ def request_app_setup(hass, config, add_entities, config_path, discovery_info=No start_url = f"{hass.config.api.base_url}{FITBIT_AUTH_CALLBACK_PATH}" - description = """Please create a Fitbit developer app at + description = f"""Please create a Fitbit developer app at https://dev.fitbit.com/apps/new. For the OAuth 2.0 Application Type choose Personal. - Set the Callback URL to {}. + Set the Callback URL to {start_url}. They will provide you a Client ID and secret. - These need to be saved into the file located at: {}. + These need to be saved into the file located at: {config_path}. Then come back here and hit the below button. - """.format( - start_url, config_path - ) + """ submit = "I have saved my Client ID and Client Secret into fitbit.conf." @@ -308,9 +306,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): config_file.get(ATTR_CLIENT_ID), config_file.get(ATTR_CLIENT_SECRET) ) - redirect_uri = "{}{}".format( - hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH - ) + redirect_uri = f"{hass.config.api.base_url}{FITBIT_AUTH_CALLBACK_PATH}" fitbit_auth_start_url, _ = oauth.authorize_token_url( redirect_uri=redirect_uri, @@ -355,26 +351,20 @@ class FitbitAuthCallbackView(HomeAssistantView): result = None if data.get("code") is not None: - redirect_uri = "{}{}".format( - hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH - ) + redirect_uri = f"{hass.config.api.base_url}{FITBIT_AUTH_CALLBACK_PATH}" try: result = self.oauth.fetch_access_token(data.get("code"), redirect_uri) except MissingTokenError as error: _LOGGER.error("Missing token: %s", error) - response_message = """Something went wrong when + response_message = f"""Something went wrong when attempting authenticating with Fitbit. The error - encountered was {}. Please try again!""".format( - error - ) + encountered was {error}. Please try again!""" except MismatchingStateError as error: _LOGGER.error("Mismatched state, CSRF error: %s", error) - response_message = """Something went wrong when + response_message = f"""Something went wrong when attempting authenticating with Fitbit. The error - encountered was {}. Please try again!""".format( - error - ) + encountered was {error}. Please try again!""" else: _LOGGER.error("Unknown error when authing") response_message = """Something went wrong when @@ -389,10 +379,8 @@ class FitbitAuthCallbackView(HomeAssistantView): An unknown error occurred. Please try again! """ - html_response = """Fitbit Auth -

{}

""".format( - response_message - ) + html_response = f"""Fitbit Auth +

{response_message}

""" if result: config_contents = { @@ -424,7 +412,7 @@ class FitbitSensor(Entity): self.extra = extra self._name = FITBIT_RESOURCES_LIST[self.resource_type][0] if self.extra: - self._name = "{0} Battery".format(self.extra.get("deviceVersion")) + self._name = f"{self.extra.get('deviceVersion')} Battery" unit_type = FITBIT_RESOURCES_LIST[self.resource_type][1] if unit_type == "": split_resource = self.resource_type.split("/") @@ -460,7 +448,7 @@ class FitbitSensor(Entity): if self.resource_type == "devices/battery" and self.extra: battery_level = BATTERY_LEVELS[self.extra.get("battery")] return icon_for_battery_level(battery_level=battery_level, charging=None) - return "mdi:{}".format(FITBIT_RESOURCES_LIST[self.resource_type][2]) + return f"mdi:{FITBIT_RESOURCES_LIST[self.resource_type][2]}" @property def device_state_attributes(self): @@ -513,7 +501,7 @@ class FitbitSensor(Entity): self._state = raw_state else: try: - self._state = "{0:,}".format(int(raw_state)) + self._state = f"{int(raw_state):,}" except TypeError: self._state = raw_state diff --git a/homeassistant/components/flic/binary_sensor.py b/homeassistant/components/flic/binary_sensor.py index 4f2f229977f..55f92e2e5ce 100644 --- a/homeassistant/components/flic/binary_sensor.py +++ b/homeassistant/components/flic/binary_sensor.py @@ -168,7 +168,7 @@ class FlicButton(BinarySensorDevice): @property def name(self): """Return the name of the device.""" - return "flic_{}".format(self.address.replace(":", "")) + return f"flic_{self.address.replace(':', '')}" @property def address(self): @@ -192,9 +192,7 @@ class FlicButton(BinarySensorDevice): def _queued_event_check(self, click_type, time_diff): """Generate a log message and returns true if timeout exceeded.""" - time_string = "{:d} {}".format( - time_diff, "second" if time_diff == 1 else "seconds" - ) + time_string = f"{time_diff:d} {'second' if time_diff == 1 else 'seconds'}" if time_diff > self._timeout: _LOGGER.warning( diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py index 8ffd84a518f..0205bb308be 100644 --- a/homeassistant/components/flux/switch.py +++ b/homeassistant/components/flux/switch.py @@ -164,7 +164,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Update lights.""" await flux.async_flux_update() - service_name = slugify("{} {}".format(name, "update")) + service_name = slugify(f"{name} update") hass.services.async_register(DOMAIN, service_name, async_update) diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py index 16db60abbc0..88b8c91420d 100644 --- a/homeassistant/components/flux_led/light.py +++ b/homeassistant/components/flux_led/light.py @@ -167,7 +167,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ipaddr = device["ipaddr"] if ipaddr in light_ips: continue - device["name"] = "{} {}".format(device["id"], ipaddr) + device["name"] = f"{device['id']} {ipaddr}" device[ATTR_MODE] = None device[CONF_PROTOCOL] = None device[CONF_CUSTOM_EFFECT] = None diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py index 65afb04ba0e..80d17b4f23b 100644 --- a/homeassistant/components/foobot/sensor.py +++ b/homeassistant/components/foobot/sensor.py @@ -101,7 +101,7 @@ class FoobotSensor(Entity): """Initialize the sensor.""" self._uuid = device["uuid"] self.foobot_data = data - self._name = "Foobot {} {}".format(device["name"], SENSOR_TYPES[sensor_type][0]) + self._name = f"Foobot {device['name']} {SENSOR_TYPES[sensor_type][0]}" self.type = sensor_type self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py index f4ec6556894..1c4c6bb9c8c 100644 --- a/homeassistant/components/foscam/camera.py +++ b/homeassistant/components/foscam/camera.py @@ -190,12 +190,7 @@ class HassFoscamCamera(Camera): async def stream_source(self): """Return the stream source.""" if self._rtsp_port: - return "rtsp://{}:{}@{}:{}/videoMain".format( - self._username, - self._password, - self._foscam_session.host, - self._rtsp_port, - ) + return f"rtsp://{self._username}:{self._password}@{self._foscam_session.host}:{self._rtsp_port}/videoMain" return None @property diff --git a/homeassistant/components/foursquare/__init__.py b/homeassistant/components/foursquare/__init__.py index af15c4e5fa8..07d177ebf30 100644 --- a/homeassistant/components/foursquare/__init__.py +++ b/homeassistant/components/foursquare/__init__.py @@ -52,12 +52,7 @@ def setup(hass, config): def checkin_user(call): """Check a user in on Swarm.""" - url = ( - "https://api.foursquare.com/v2/checkins/add" - "?oauth_token={}" - "&v=20160802" - "&m=swarm" - ).format(config[CONF_ACCESS_TOKEN]) + url = f"https://api.foursquare.com/v2/checkins/add?oauth_token={config[CONF_ACCESS_TOKEN]}&v=20160802&m=swarm" response = requests.post(url, data=call.data, timeout=10) if response.status_code not in (200, 201): diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index c51c952ab06..5b87d6e726a 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -78,9 +78,9 @@ class FritzboxSwitch(SwitchDevice): attrs[ATTR_STATE_LOCKED] = self._device.lock if self._device.has_powermeter: - attrs[ATTR_TOTAL_CONSUMPTION] = "{:.3f}".format( - (self._device.energy or 0.0) / 1000 - ) + attrs[ + ATTR_TOTAL_CONSUMPTION + ] = f"{((self._device.energy or 0.0) / 1000):.3f}" attrs[ATTR_TOTAL_CONSUMPTION_UNIT] = ATTR_TOTAL_CONSUMPTION_UNIT_VALUE if self._device.has_temperature_sensor: attrs[ATTR_TEMPERATURE] = str( diff --git a/homeassistant/components/fronius/sensor.py b/homeassistant/components/fronius/sensor.py index 27e2531c9f9..722dc2dc659 100644 --- a/homeassistant/components/fronius/sensor.py +++ b/homeassistant/components/fronius/sensor.py @@ -90,11 +90,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= device = condition[CONF_DEVICE] sensor_type = condition[CONF_SENSOR_TYPE] scope = condition[CONF_SCOPE] - name = "Fronius {} {} {}".format( - condition[CONF_SENSOR_TYPE].replace("_", " ").capitalize(), - device if scope == SCOPE_DEVICE else SCOPE_SYSTEM, - config[CONF_RESOURCE], - ) + name = f"Fronius {condition[CONF_SENSOR_TYPE].replace('_', ' ').capitalize()} {device if scope == SCOPE_DEVICE else SCOPE_SYSTEM} {config[CONF_RESOURCE]}" if sensor_type == TYPE_INVERTER: if scope == SCOPE_SYSTEM: adapter_cls = FroniusInverterSystem @@ -258,9 +254,7 @@ class FroniusTemplateSensor(Entity): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format( - self._name.replace("_", " ").capitalize(), self.parent.name - ) + return f"{self._name.replace('_', ' ').capitalize()} {self.parent.name}" @property def state(self): diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index a6f531b6dd5..db721ff18a5 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -50,8 +50,8 @@ MANIFEST_JSON = { "display": "standalone", "icons": [ { - "src": "/static/icons/favicon-{size}x{size}.png".format(size=size), - "sizes": "{size}x{size}".format(size=size), + "src": f"/static/icons/favicon-{size}x{size}.png", + "sizes": f"{size}x{size}", "type": "image/png", "purpose": "maskable any", } diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index 2f68c5f8e01..b37945b5e07 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -9,7 +9,6 @@ from homeassistant.components import websocket_api DATA_STORAGE = "frontend_storage" STORAGE_VERSION_USER_DATA = 1 -STORAGE_KEY_USER_DATA = "frontend.user_data_{}" async def async_setup_frontend_storage(hass): @@ -31,8 +30,7 @@ def with_store(orig_func): if store is None: store = stores[user_id] = hass.helpers.storage.Store( - STORAGE_VERSION_USER_DATA, - STORAGE_KEY_USER_DATA.format(connection.user.id), + STORAGE_VERSION_USER_DATA, f"frontend.user_data_{connection.user.id}" ) if user_id not in data: diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 82ed14c4336..93e96d6e967 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -55,7 +55,6 @@ SUPPORT_FRONTIER_SILICON = ( DEFAULT_PORT = 80 DEFAULT_PASSWORD = "1234" -DEVICE_URL = "http://{0}:{1}/device" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -83,7 +82,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= try: async_add_entities( - [AFSAPIDevice(DEVICE_URL.format(host, port), password, name)], True + [AFSAPIDevice(f"http://{host}:{port}/device", password, name)], True ) _LOGGER.debug("FSAPI device %s:%s -> %s", host, port, password) return True diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py index 0eeb5f2b8f9..5a43c3c7281 100644 --- a/homeassistant/components/garadget/cover.py +++ b/homeassistant/components/garadget/cover.py @@ -251,9 +251,7 @@ class GaradgetCover(CoverDevice): def _get_variable(self, var): """Get latest status.""" - url = "{}/v1/devices/{}/{}?access_token={}".format( - self.particle_url, self.device_id, var, self.access_token - ) + url = f"{self.particle_url}/v1/devices/{self.device_id}/{var}?access_token={self.access_token}" ret = requests.get(url, timeout=10) result = {} for pairs in ret.json()["result"].split("|"): diff --git a/homeassistant/components/gdacs/__init__.py b/homeassistant/components/gdacs/__init__.py index 34f1bdc88d8..8b00b2b3ff1 100644 --- a/homeassistant/components/gdacs/__init__.py +++ b/homeassistant/components/gdacs/__init__.py @@ -28,10 +28,6 @@ from .const import ( DOMAIN, FEED, PLATFORMS, - SIGNAL_DELETE_ENTITY, - SIGNAL_NEW_GEOLOCATION, - SIGNAL_STATUS, - SIGNAL_UPDATE_ENTITY, VALID_CATEGORIES, ) @@ -181,7 +177,7 @@ class GdacsFeedEntityManager: @callback def async_event_new_entity(self): """Return manager specific event to signal new entity.""" - return SIGNAL_NEW_GEOLOCATION.format(self._config_entry_id) + return f"gdacs_new_geolocation_{self._config_entry_id}" def get_entry(self, external_id): """Get feed entry by external id.""" @@ -199,14 +195,14 @@ class GdacsFeedEntityManager: async def _update_entity(self, external_id): """Update entity.""" - async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + async_dispatcher_send(self._hass, f"gdacs_update_{external_id}") async def _remove_entity(self, external_id): """Remove entity.""" - async_dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + async_dispatcher_send(self._hass, f"gdacs_delete_{external_id}") async def _status_update(self, status_info): """Propagate status update.""" _LOGGER.debug("Status update received: %s", status_info) self._status_info = status_info - async_dispatcher_send(self._hass, SIGNAL_STATUS.format(self._config_entry_id)) + async_dispatcher_send(self._hass, f"gdacs_status_{self._config_entry_id}") diff --git a/homeassistant/components/gdacs/const.py b/homeassistant/components/gdacs/const.py index 4579304f30d..5d5c83f013e 100644 --- a/homeassistant/components/gdacs/const.py +++ b/homeassistant/components/gdacs/const.py @@ -15,11 +15,5 @@ DEFAULT_ICON = "mdi:alert" DEFAULT_RADIUS = 500.0 DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) -SIGNAL_DELETE_ENTITY = "gdacs_delete_{}" -SIGNAL_UPDATE_ENTITY = "gdacs_update_{}" -SIGNAL_STATUS = "gdacs_status_{}" - -SIGNAL_NEW_GEOLOCATION = "gdacs_new_geolocation_{}" - # Fetch valid categories from integration library. VALID_CATEGORIES = list(EVENT_TYPE_MAP.values()) diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py index 34da104e093..616be5a5e18 100644 --- a/homeassistant/components/gdacs/geo_location.py +++ b/homeassistant/components/gdacs/geo_location.py @@ -13,13 +13,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.unit_system import IMPERIAL_SYSTEM -from .const import ( - DEFAULT_ICON, - DOMAIN, - FEED, - SIGNAL_DELETE_ENTITY, - SIGNAL_UPDATE_ENTITY, -) +from .const import DEFAULT_ICON, DOMAIN, FEED _LOGGER = logging.getLogger(__name__) @@ -102,14 +96,10 @@ class GdacsEvent(GeolocationEvent): async def async_added_to_hass(self): """Call when entity is added to hass.""" self._remove_signal_delete = async_dispatcher_connect( - self.hass, - SIGNAL_DELETE_ENTITY.format(self._external_id), - self._delete_callback, + self.hass, f"gdacs_delete_{self._external_id}", self._delete_callback ) self._remove_signal_update = async_dispatcher_connect( - self.hass, - SIGNAL_UPDATE_ENTITY.format(self._external_id), - self._update_callback, + self.hass, f"gdacs_update_{self._external_id}", self._update_callback ) async def async_will_remove_from_hass(self) -> None: diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index e58090fd165..7ef2855a9be 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -7,7 +7,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.util import dt -from .const import DEFAULT_ICON, DOMAIN, FEED, SIGNAL_STATUS +from .const import DEFAULT_ICON, DOMAIN, FEED _LOGGER = logging.getLogger(__name__) @@ -55,7 +55,7 @@ class GdacsSensor(Entity): """Call when entity is added to hass.""" self._remove_signal_status = async_dispatcher_connect( self.hass, - SIGNAL_STATUS.format(self._config_entry_id), + f"gdacs_status_{self._config_entry_id}", self._update_status_callback, ) _LOGGER.debug("Waiting for updates %s", self._config_entry_id) diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index 2f881232495..3435fcc50cf 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -29,9 +29,6 @@ DEFAULT_UNIT_OF_MEASUREMENT = "km" SCAN_INTERVAL = timedelta(minutes=5) -SIGNAL_DELETE_ENTITY = "geo_json_events_delete_{}" -SIGNAL_UPDATE_ENTITY = "geo_json_events_update_{}" - SOURCE = "geo_json_events" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -108,11 +105,11 @@ class GeoJsonFeedEntityManager: def _update_entity(self, external_id): """Update entity.""" - dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + dispatcher_send(self._hass, f"geo_json_events_update_{external_id}") def _remove_entity(self, external_id): """Remove entity.""" - dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + dispatcher_send(self._hass, f"geo_json_events_delete_{external_id}") class GeoJsonLocationEvent(GeolocationEvent): @@ -133,12 +130,12 @@ class GeoJsonLocationEvent(GeolocationEvent): """Call when entity is added to hass.""" self._remove_signal_delete = async_dispatcher_connect( self.hass, - SIGNAL_DELETE_ENTITY.format(self._external_id), + f"geo_json_events_delete_{self._external_id}", self._delete_callback, ) self._remove_signal_update = async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_ENTITY.format(self._external_id), + f"geo_json_events_update_{self._external_id}", self._update_callback, ) diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py index 3ac973f77a0..22f02a4218c 100644 --- a/homeassistant/components/geo_rss_events/sensor.py +++ b/homeassistant/components/geo_rss_events/sensor.py @@ -118,9 +118,7 @@ class GeoRssServiceSensor(Entity): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format( - self._service_name, "Any" if self._category is None else self._category - ) + return f"{self._service_name} {'Any' if self._category is None else self._category}" @property def state(self): diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py index 9afc9a8bfac..cb663676512 100644 --- a/homeassistant/components/geofency/__init__.py +++ b/homeassistant/components/geofency/__init__.py @@ -114,7 +114,7 @@ def _is_mobile_beacon(data, mobile_beacons): def _device_name(data): """Return name of device tracker.""" if ATTR_BEACON_ID in data: - return "{}_{}".format(BEACON_DEV_PREFIX, data["name"]) + return f"{BEACON_DEV_PREFIX}_{data['name']}" return data["device"] diff --git a/homeassistant/components/geonetnz_quakes/__init__.py b/homeassistant/components/geonetnz_quakes/__init__.py index 141d0506847..fae8841bee3 100644 --- a/homeassistant/components/geonetnz_quakes/__init__.py +++ b/homeassistant/components/geonetnz_quakes/__init__.py @@ -34,10 +34,6 @@ from .const import ( DOMAIN, FEED, PLATFORMS, - SIGNAL_DELETE_ENTITY, - SIGNAL_NEW_GEOLOCATION, - SIGNAL_STATUS, - SIGNAL_UPDATE_ENTITY, ) _LOGGER = logging.getLogger(__name__) @@ -200,7 +196,7 @@ class GeonetnzQuakesFeedEntityManager: @callback def async_event_new_entity(self): """Return manager specific event to signal new entity.""" - return SIGNAL_NEW_GEOLOCATION.format(self._config_entry_id) + return f"geonetnz_quakes_new_geolocation_{self._config_entry_id}" def get_entry(self, external_id): """Get feed entry by external id.""" @@ -222,14 +218,16 @@ class GeonetnzQuakesFeedEntityManager: async def _update_entity(self, external_id): """Update entity.""" - async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + async_dispatcher_send(self._hass, f"geonetnz_quakes_update_{external_id}") async def _remove_entity(self, external_id): """Remove entity.""" - async_dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + async_dispatcher_send(self._hass, f"geonetnz_quakes_delete_{external_id}") async def _status_update(self, status_info): """Propagate status update.""" _LOGGER.debug("Status update received: %s", status_info) self._status_info = status_info - async_dispatcher_send(self._hass, SIGNAL_STATUS.format(self._config_entry_id)) + async_dispatcher_send( + self._hass, f"geonetnz_quakes_status_{self._config_entry_id}" + ) diff --git a/homeassistant/components/geonetnz_quakes/const.py b/homeassistant/components/geonetnz_quakes/const.py index d564d407f7c..43818b55f6f 100644 --- a/homeassistant/components/geonetnz_quakes/const.py +++ b/homeassistant/components/geonetnz_quakes/const.py @@ -15,9 +15,3 @@ DEFAULT_MINIMUM_MAGNITUDE = 0.0 DEFAULT_MMI = 3 DEFAULT_RADIUS = 50.0 DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) - -SIGNAL_DELETE_ENTITY = "geonetnz_quakes_delete_{}" -SIGNAL_UPDATE_ENTITY = "geonetnz_quakes_update_{}" -SIGNAL_STATUS = "geonetnz_quakes_status_{}" - -SIGNAL_NEW_GEOLOCATION = "geonetnz_quakes_new_geolocation_{}" diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py index ae8b8fef48d..d7fd91d3d5b 100644 --- a/homeassistant/components/geonetnz_quakes/geo_location.py +++ b/homeassistant/components/geonetnz_quakes/geo_location.py @@ -14,7 +14,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.unit_system import IMPERIAL_SYSTEM -from .const import DOMAIN, FEED, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY +from .const import DOMAIN, FEED _LOGGER = logging.getLogger(__name__) @@ -75,12 +75,12 @@ class GeonetnzQuakesEvent(GeolocationEvent): """Call when entity is added to hass.""" self._remove_signal_delete = async_dispatcher_connect( self.hass, - SIGNAL_DELETE_ENTITY.format(self._external_id), + f"geonetnz_quakes_delete_{self._external_id}", self._delete_callback, ) self._remove_signal_update = async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_ENTITY.format(self._external_id), + f"geonetnz_quakes_update_{self._external_id}", self._update_callback, ) diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index e0be94d1b26..f5360c76c45 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -7,7 +7,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.util import dt -from .const import DOMAIN, FEED, SIGNAL_STATUS +from .const import DOMAIN, FEED _LOGGER = logging.getLogger(__name__) @@ -53,7 +53,7 @@ class GeonetnzQuakesSensor(Entity): """Call when entity is added to hass.""" self._remove_signal_status = async_dispatcher_connect( self.hass, - SIGNAL_STATUS.format(self._config_entry_id), + f"geonetnz_quakes_status_{self._config_entry_id}", self._update_status_callback, ) _LOGGER.debug("Waiting for updates %s", self._config_entry_id) diff --git a/homeassistant/components/geonetnz_volcano/__init__.py b/homeassistant/components/geonetnz_volcano/__init__.py index e24de7fdc5d..e2c6cb77083 100644 --- a/homeassistant/components/geonetnz_volcano/__init__.py +++ b/homeassistant/components/geonetnz_volcano/__init__.py @@ -24,14 +24,7 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.unit_system import METRIC_SYSTEM from .config_flow import configured_instances -from .const import ( - DEFAULT_RADIUS, - DEFAULT_SCAN_INTERVAL, - DOMAIN, - FEED, - SIGNAL_NEW_SENSOR, - SIGNAL_UPDATE_ENTITY, -) +from .const import DEFAULT_RADIUS, DEFAULT_SCAN_INTERVAL, DOMAIN, FEED _LOGGER = logging.getLogger(__name__) @@ -173,7 +166,7 @@ class GeonetnzVolcanoFeedEntityManager: @callback def async_event_new_entity(self): """Return manager specific event to signal new entity.""" - return SIGNAL_NEW_SENSOR.format(self._config_entry_id) + return f"geonetnz_volcano_new_sensor_{self._config_entry_id}" def get_entry(self, external_id): """Get feed entry by external id.""" @@ -199,7 +192,7 @@ class GeonetnzVolcanoFeedEntityManager: async def _update_entity(self, external_id): """Update entity.""" - async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + async_dispatcher_send(self._hass, f"geonetnz_volcano_update_{external_id}") async def _remove_entity(self, external_id): """Ignore removing entity.""" diff --git a/homeassistant/components/geonetnz_volcano/const.py b/homeassistant/components/geonetnz_volcano/const.py index 7bc15d3a6a1..d48e9775f19 100644 --- a/homeassistant/components/geonetnz_volcano/const.py +++ b/homeassistant/components/geonetnz_volcano/const.py @@ -14,6 +14,3 @@ ATTR_HAZARDS = "hazards" DEFAULT_ICON = "mdi:image-filter-hdr" DEFAULT_RADIUS = 50.0 DEFAULT_SCAN_INTERVAL = timedelta(minutes=5) - -SIGNAL_NEW_SENSOR = "geonetnz_volcano_new_sensor_{}" -SIGNAL_UPDATE_ENTITY = "geonetnz_volcano_update_{}" diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index f87ea88fc1c..3d5d0681f02 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -23,7 +23,6 @@ from .const import ( DEFAULT_ICON, DOMAIN, FEED, - SIGNAL_UPDATE_ENTITY, ) _LOGGER = logging.getLogger(__name__) @@ -79,7 +78,7 @@ class GeonetnzVolcanoSensor(Entity): """Call when entity is added to hass.""" self._remove_signal_update = async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_ENTITY.format(self._external_id), + f"geonetnz_volcano_update_{self._external_id}", self._update_callback, ) diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py index fcb0182ec0e..62aea62bf84 100644 --- a/homeassistant/components/gogogate2/cover.py +++ b/homeassistant/components/gogogate2/cover.py @@ -51,9 +51,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except (TypeError, KeyError, NameError, ValueError) as ex: _LOGGER.error("%s", ex) hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), + (f"Error: {ex}
You will need to restart hass after fixing."), title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py index 0e7ccd33b33..f3321416b1f 100644 --- a/homeassistant/components/google/__init__.py +++ b/homeassistant/components/google/__init__.py @@ -144,17 +144,17 @@ def do_authentication(hass, hass_config, config): dev_flow = oauth.step1_get_device_and_user_codes() except OAuth2DeviceCodeError as err: hass.components.persistent_notification.create( - "Error: {}
You will need to restart hass after fixing." "".format(err), + f"Error: {err}
You will need to restart hass after fixing." "", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) return False hass.components.persistent_notification.create( - "In order to authorize Home-Assistant to view your calendars " - 'you must visit: {} and enter ' - "code: {}".format( - dev_flow.verification_url, dev_flow.verification_url, dev_flow.user_code + ( + f"In order to authorize Home-Assistant to view your calendars " + f'you must visit: {dev_flow.verification_url} and enter ' + f"code: {dev_flow.user_code}" ), title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, @@ -182,8 +182,10 @@ def do_authentication(hass, hass_config, config): do_setup(hass, hass_config, config) listener() hass.components.persistent_notification.create( - "We are all setup now. Check {} for calendars that have " - "been found".format(YAML_DEVICES), + ( + f"We are all setup now. Check {YAML_DEVICES} for calendars that have " + f"been found" + ), title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 60e4cdae6a5..4c16e230e92 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -53,7 +53,7 @@ def _get_homegraph_jwt(time, iss, key): async def _get_homegraph_token(hass, jwt_signed): headers = { - "Authorization": "Bearer {}".format(jwt_signed), + "Authorization": f"Bearer {jwt_signed}", "Content-Type": "application/x-www-form-urlencoded", } data = { @@ -185,7 +185,7 @@ class GoogleConfig(AbstractConfig): async def _call(): headers = { - "Authorization": "Bearer {}".format(self._access_token), + "Authorization": f"Bearer {self._access_token}", "X-GFE-SSL": "yes", } async with session.post(url, headers=headers, json=data) as res: diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index b4585ebde03..9da319226fa 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -392,9 +392,7 @@ class ColorSettingTrait(_Trait): if temp < min_temp or temp > max_temp: raise SmartHomeError( ERR_VALUE_OUT_OF_RANGE, - "Temperature should be between {} and {}".format( - min_temp, max_temp - ), + f"Temperature should be between {min_temp} and {max_temp}", ) await self.hass.services.async_call( @@ -407,7 +405,7 @@ class ColorSettingTrait(_Trait): elif "spectrumRGB" in params["color"]: # Convert integer to hex format and left pad with 0's till length 6 - hex_value = "{0:06x}".format(params["color"]["spectrumRGB"]) + hex_value = f"{params['color']['spectrumRGB']:06x}" color = color_util.color_RGB_to_hs( *color_util.rgb_hex_to_rgb_list(hex_value) ) @@ -746,9 +744,7 @@ class TemperatureSettingTrait(_Trait): if temp < min_temp or temp > max_temp: raise SmartHomeError( ERR_VALUE_OUT_OF_RANGE, - "Temperature should be between {} and {}".format( - min_temp, max_temp - ), + f"Temperature should be between {min_temp} and {max_temp}", ) await self.hass.services.async_call( @@ -769,8 +765,10 @@ class TemperatureSettingTrait(_Trait): if temp_high < min_temp or temp_high > max_temp: raise SmartHomeError( ERR_VALUE_OUT_OF_RANGE, - "Upper bound for temperature range should be between " - "{} and {}".format(min_temp, max_temp), + ( + f"Upper bound for temperature range should be between " + f"{min_temp} and {max_temp}" + ), ) temp_low = temp_util.convert( @@ -782,8 +780,10 @@ class TemperatureSettingTrait(_Trait): if temp_low < min_temp or temp_low > max_temp: raise SmartHomeError( ERR_VALUE_OUT_OF_RANGE, - "Lower bound for temperature range should be between " - "{} and {}".format(min_temp, max_temp), + ( + f"Lower bound for temperature range should be between " + f"{min_temp} and {max_temp}" + ), ) supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) diff --git a/homeassistant/components/google_domains/__init__.py b/homeassistant/components/google_domains/__init__.py index d440567d9ad..ae6cb5c70d5 100644 --- a/homeassistant/components/google_domains/__init__.py +++ b/homeassistant/components/google_domains/__init__.py @@ -18,8 +18,6 @@ INTERVAL = timedelta(minutes=5) DEFAULT_TIMEOUT = 10 -UPDATE_URL = "https://{}:{}@domains.google.com/nic/update" - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -62,7 +60,7 @@ async def async_setup(hass, config): async def _update_google_domains(hass, session, domain, user, password, timeout): """Update Google Domains.""" - url = UPDATE_URL.format(user, password) + url = f"https://{user}:{password}@domains.google.com/nic/update" params = {"hostname": domain} diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py index 9e33ff5f715..7b48c12cc93 100644 --- a/homeassistant/components/google_maps/device_tracker.py +++ b/homeassistant/components/google_maps/device_tracker.py @@ -55,9 +55,7 @@ class GoogleMapsScanner: self.scan_interval = config.get(CONF_SCAN_INTERVAL) or timedelta(seconds=60) self._prev_seen = {} - credfile = "{}.{}".format( - hass.config.path(CREDENTIALS_FILE), slugify(self.username) - ) + credfile = f"{hass.config.path(CREDENTIALS_FILE)}.{slugify(self.username)}" try: self.service = Service(credfile, self.username) self._update_info() @@ -75,7 +73,7 @@ class GoogleMapsScanner: def _update_info(self, now=None): for person in self.service.get_all_people(): try: - dev_id = "google_maps_{0}".format(slugify(person.id)) + dev_id = f"google_maps_{slugify(person.id)}" except TypeError: _LOGGER.warning("No location(s) shared with this account") return diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index 7f106bc7e3e..213f773fb60 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -163,7 +163,7 @@ def setup_platform(hass, config, add_entities_callback, discovery_info=None): options[CONF_MODE] = travel_mode titled_mode = options.get(CONF_MODE).title() - formatted_name = "{} - {}".format(DEFAULT_NAME, titled_mode) + formatted_name = f"{DEFAULT_NAME} - {titled_mode}" name = config.get(CONF_NAME, formatted_name) api_key = config.get(CONF_API_KEY) origin = config.get(CONF_ORIGIN) diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py index b88b2567750..1d53525ab37 100644 --- a/homeassistant/components/greeneye_monitor/sensor.py +++ b/homeassistant/components/greeneye_monitor/sensor.py @@ -107,11 +107,7 @@ class GEMSensor(Entity): @property def unique_id(self): """Return a unique ID for this sensor.""" - return "{serial}-{sensor_type}-{number}".format( - serial=self._monitor_serial_number, - sensor_type=self._sensor_type, - number=self._number, - ) + return f"{self._monitor_serial_number}-{self._sensor_type }-{self._number}" @property def name(self): @@ -249,9 +245,7 @@ class PulseCounter(GEMSensor): @property def unit_of_measurement(self): """Return the unit of measurement for this pulse counter.""" - return "{counted_quantity}/{time_unit}".format( - counted_quantity=self._counted_quantity, time_unit=self._time_unit - ) + return f"{self._counted_quantity}/{self._time_unit}" @property def device_state_attributes(self): diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index efd8dfdf0fa..f8a10017cab 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -74,7 +74,7 @@ GROUP_SCHEMA = vol.All( CONF_ICON: cv.icon, CONF_ALL: cv.boolean, } - ), + ) ) CONFIG_SCHEMA = vol.Schema( @@ -231,7 +231,7 @@ async def async_setup(hass, config): async def groups_service_handler(service): """Handle dynamic group service functions.""" object_id = service.data[ATTR_OBJECT_ID] - entity_id = ENTITY_ID_FORMAT.format(object_id) + entity_id = f"{DOMAIN}.{object_id}" group = component.get_entity(entity_id) # new group @@ -311,7 +311,7 @@ async def async_setup(hass, config): vol.Exclusive(ATTR_ENTITIES, "entities"): cv.entity_ids, vol.Exclusive(ATTR_ADD_ENTITIES, "entities"): cv.entity_ids, } - ), + ) ), ) @@ -336,7 +336,7 @@ async def _async_process_config(hass, config, component): # Don't create tasks and await them all. The order is important as # groups get a number based on creation order. await Group.async_create_group( - hass, name, entity_ids, icon=icon, object_id=object_id, mode=mode, + hass, name, entity_ids, icon=icon, object_id=object_id, mode=mode ) @@ -388,7 +388,7 @@ class Group(Entity): """Initialize a group.""" return asyncio.run_coroutine_threadsafe( Group.async_create_group( - hass, name, entity_ids, user_defined, icon, object_id, mode, + hass, name, entity_ids, user_defined, icon, object_id, mode ), hass.loop, ).result() diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index 07b450dd33e..2bd0ce1b09f 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -143,7 +143,7 @@ def get_next_departure( tomorrow_where = f"OR calendar.{tomorrow_name} = 1" tomorrow_order = f"calendar.{tomorrow_name} DESC," - sql_query = """ + sql_query = f""" SELECT trip.trip_id, trip.route_id, time(origin_stop_time.arrival_time) AS origin_arrival_time, time(origin_stop_time.departure_time) AS origin_depart_time, @@ -162,8 +162,8 @@ def get_next_departure( destination_stop_time.stop_headsign AS dest_stop_headsign, destination_stop_time.stop_sequence AS dest_stop_sequence, destination_stop_time.timepoint AS dest_stop_timepoint, - calendar.{yesterday_name} AS yesterday, - calendar.{today_name} AS today, + calendar.{yesterday.strftime("%A").lower()} AS yesterday, + calendar.{now.strftime("%A").lower()} AS today, {tomorrow_select} calendar.start_date AS start_date, calendar.end_date AS end_date @@ -178,8 +178,8 @@ def get_next_departure( ON trip.trip_id = destination_stop_time.trip_id INNER JOIN stops end_station ON destination_stop_time.stop_id = end_station.stop_id - WHERE (calendar.{yesterday_name} = 1 - OR calendar.{today_name} = 1 + WHERE (calendar.{yesterday.strftime("%A").lower()} = 1 + OR calendar.{now.strftime("%A").lower()} = 1 {tomorrow_where} ) AND start_station.stop_id = :origin_station_id @@ -187,18 +187,12 @@ def get_next_departure( AND origin_stop_sequence < dest_stop_sequence AND calendar.start_date <= :today AND calendar.end_date >= :today - ORDER BY calendar.{yesterday_name} DESC, - calendar.{today_name} DESC, + ORDER BY calendar.{yesterday.strftime("%A").lower()} DESC, + calendar.{now.strftime("%A").lower()} DESC, {tomorrow_order} origin_stop_time.departure_time LIMIT :limit - """.format( - yesterday_name=yesterday.strftime("%A").lower(), - today_name=now.strftime("%A").lower(), - tomorrow_select=tomorrow_select, - tomorrow_where=tomorrow_where, - tomorrow_order=tomorrow_order, - ) + """ result = schedule.engine.execute( text(sql_query), origin_station_id=start_station_id, @@ -220,7 +214,7 @@ def get_next_departure( if yesterday_start is None: yesterday_start = row["origin_depart_date"] if yesterday_start != row["origin_depart_date"]: - idx = "{} {}".format(now_date, row["origin_depart_time"]) + idx = f"{now_date} {row['origin_depart_time']}" timetable[idx] = {**row, **extras} yesterday_last = idx @@ -233,7 +227,7 @@ def get_next_departure( idx_prefix = now_date else: idx_prefix = tomorrow_date - idx = "{} {}".format(idx_prefix, row["origin_depart_time"]) + idx = f"{idx_prefix} {row['origin_depart_time']}" timetable[idx] = {**row, **extras} today_last = idx @@ -247,7 +241,7 @@ def get_next_departure( tomorrow_start = row["origin_depart_date"] extras["first"] = True if tomorrow_start == row["origin_depart_date"]: - idx = "{} {}".format(tomorrow_date, row["origin_depart_time"]) + idx = f"{tomorrow_date} {row['origin_depart_time']}" timetable[idx] = {**row, **extras} # Flag last departures. @@ -273,24 +267,27 @@ def get_next_departure( origin_arrival = now if item["origin_arrival_time"] > item["origin_depart_time"]: origin_arrival -= datetime.timedelta(days=1) - origin_arrival_time = "{} {}".format( - origin_arrival.strftime(dt_util.DATE_STR_FORMAT), item["origin_arrival_time"] + origin_arrival_time = ( + f"{origin_arrival.strftime(dt_util.DATE_STR_FORMAT)} " + f"{item['origin_arrival_time']}" ) - origin_depart_time = "{} {}".format(now_date, item["origin_depart_time"]) + origin_depart_time = f"{now_date} {item['origin_depart_time']}" dest_arrival = now if item["dest_arrival_time"] < item["origin_depart_time"]: dest_arrival += datetime.timedelta(days=1) - dest_arrival_time = "{} {}".format( - dest_arrival.strftime(dt_util.DATE_STR_FORMAT), item["dest_arrival_time"] + dest_arrival_time = ( + f"{dest_arrival.strftime(dt_util.DATE_STR_FORMAT)} " + f"{item['dest_arrival_time']}" ) dest_depart = dest_arrival if item["dest_depart_time"] < item["dest_arrival_time"]: dest_depart += datetime.timedelta(days=1) - dest_depart_time = "{} {}".format( - dest_depart.strftime(dt_util.DATE_STR_FORMAT), item["dest_depart_time"] + dest_depart_time = ( + f"{dest_depart.strftime(dt_util.DATE_STR_FORMAT)} " + f"{item['dest_depart_time']}" ) depart_time = dt_util.parse_datetime(origin_depart_time) @@ -511,15 +508,13 @@ class GTFSDepartureSensor(Entity): else: self._icon = ICON - name = "{agency} {origin} to {destination} next departure" - if not self._departure: - name = "{default}" - self._name = self._custom_name or name.format( - agency=getattr(self._agency, "agency_name", DEFAULT_NAME), - default=DEFAULT_NAME, - origin=self.origin, - destination=self.destination, + name = ( + f"{getattr(self._agency, 'agency_name', DEFAULT_NAME)} " + f"{self.origin} to {self.destination} next departure" ) + if not self._departure: + name = f"{DEFAULT_NAME}" + self._name = self._custom_name or name def update_attributes(self) -> None: """Update state attributes.""" diff --git a/tests/components/facebox/test_image_processing.py b/tests/components/facebox/test_image_processing.py index 6b248ba1c3c..8506cd2d817 100644 --- a/tests/components/facebox/test_image_processing.py +++ b/tests/components/facebox/test_image_processing.py @@ -119,7 +119,7 @@ def mock_open_file(): def test_check_box_health(caplog): """Test check box health.""" with requests_mock.Mocker() as mock_req: - url = "http://{}:{}/healthz".format(MOCK_IP, MOCK_PORT) + url = f"http://{MOCK_IP}:{MOCK_PORT}/healthz" mock_req.get(url, status_code=HTTP_OK, json=MOCK_HEALTH) assert fb.check_box_health(url, "user", "pass") == MOCK_BOX_ID @@ -184,7 +184,7 @@ async def test_process_image(hass, mock_healthybox, mock_image): hass.bus.async_listen("image_processing.detect_face", mock_face_event) with requests_mock.Mocker() as mock_req: - url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT) + url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/check" mock_req.post(url, json=MOCK_JSON) data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) @@ -219,7 +219,7 @@ async def test_process_image_errors(hass, mock_healthybox, mock_image, caplog): # Test connection error. with requests_mock.Mocker() as mock_req: - url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT) + url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/check" mock_req.register_uri("POST", url, exc=requests.exceptions.ConnectTimeout) data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) @@ -233,7 +233,7 @@ async def test_process_image_errors(hass, mock_healthybox, mock_image, caplog): # Now test with bad auth. with requests_mock.Mocker() as mock_req: - url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT) + url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/check" mock_req.register_uri("POST", url, status_code=HTTP_UNAUTHORIZED) data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) @@ -253,7 +253,7 @@ async def test_teach_service( # Test successful teach. with requests_mock.Mocker() as mock_req: - url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT) + url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach" mock_req.post(url, status_code=HTTP_OK) data = { ATTR_ENTITY_ID: VALID_ENTITY_ID, @@ -267,7 +267,7 @@ async def test_teach_service( # Now test with bad auth. with requests_mock.Mocker() as mock_req: - url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT) + url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach" mock_req.post(url, status_code=HTTP_UNAUTHORIZED) data = { ATTR_ENTITY_ID: VALID_ENTITY_ID, @@ -282,7 +282,7 @@ async def test_teach_service( # Now test the failed teaching. with requests_mock.Mocker() as mock_req: - url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT) + url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach" mock_req.post(url, status_code=HTTP_BAD_REQUEST, text=MOCK_ERROR_NO_FACE) data = { ATTR_ENTITY_ID: VALID_ENTITY_ID, @@ -297,7 +297,7 @@ async def test_teach_service( # Now test connection error. with requests_mock.Mocker() as mock_req: - url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT) + url = f"http://{MOCK_IP}:{MOCK_PORT}/facebox/teach" mock_req.post(url, exc=requests.exceptions.ConnectTimeout) data = { ATTR_ENTITY_ID: VALID_ENTITY_ID, @@ -313,7 +313,7 @@ async def test_teach_service( async def test_setup_platform_with_name(hass, mock_healthybox): """Set up platform with one entity and a name.""" - named_entity_id = "image_processing.{}".format(MOCK_NAME) + named_entity_id = f"image_processing.{MOCK_NAME}" valid_config_named = VALID_CONFIG.copy() valid_config_named[ip.DOMAIN][ip.CONF_SOURCE][ip.CONF_NAME] = MOCK_NAME diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index b44ba22d8e5..c46b3a6fcec 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -119,14 +119,10 @@ async def test_if_fires_on_state_change(hass, calls): hass.states.async_set("fan.entity", STATE_ON) await hass.async_block_till_done() assert len(calls) == 1 - assert calls[0].data["some"] == "turn_on - device - {} - off - on - None".format( - "fan.entity" - ) + assert calls[0].data["some"] == "turn_on - device - fan.entity - off - on - None" # Fake that the entity is turning off. hass.states.async_set("fan.entity", STATE_OFF) await hass.async_block_till_done() assert len(calls) == 2 - assert calls[1].data["some"] == "turn_off - device - {} - on - off - None".format( - "fan.entity" - ) + assert calls[1].data["some"] == "turn_off - device - fan.entity - on - off - None" diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 048be11e079..58a660fcb5d 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -39,7 +39,7 @@ class TestFeedreaderComponent(unittest.TestCase): """Initialize values for this testcase class.""" self.hass = get_test_home_assistant() # Delete any previously stored data - data_file = self.hass.config.path("{}.pickle".format("feedreader")) + data_file = self.hass.config.path(f"{feedreader.DOMAIN}.pickle") if exists(data_file): remove(data_file) @@ -85,7 +85,7 @@ class TestFeedreaderComponent(unittest.TestCase): # Loading raw data from fixture and plug in to data object as URL # works since the third-party feedparser library accepts a URL # as well as the actual data. - data_file = self.hass.config.path("{}.pickle".format(feedreader.DOMAIN)) + data_file = self.hass.config.path(f"{feedreader.DOMAIN}.pickle") storage = StoredData(data_file) with patch( "homeassistant.components.feedreader.track_time_interval" @@ -179,7 +179,7 @@ class TestFeedreaderComponent(unittest.TestCase): @mock.patch("feedparser.parse", return_value=None) def test_feed_parsing_failed(self, mock_parse): """Test feed where parsing fails.""" - data_file = self.hass.config.path("{}.pickle".format(feedreader.DOMAIN)) + data_file = self.hass.config.path(f"{feedreader.DOMAIN}.pickle") storage = StoredData(data_file) manager = FeedManager( "FEED DATA", DEFAULT_SCAN_INTERVAL, DEFAULT_MAX_ENTRIES, self.hass, storage diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py index 52524d5b189..bd5ae68cb37 100644 --- a/tests/components/file/test_notify.py +++ b/tests/components/file/test_notify.py @@ -56,8 +56,9 @@ class TestNotifyFile(unittest.TestCase): ): mock_st.return_value.st_size = 0 - title = "{} notifications (Log started: {})\n{}\n".format( - ATTR_TITLE_DEFAULT, dt_util.utcnow().isoformat(), "-" * 80 + title = ( + f"{ATTR_TITLE_DEFAULT} notifications " + f"(Log started: {dt_util.utcnow().isoformat()})\n{'-' * 80}\n" ) self.hass.services.call( @@ -72,12 +73,12 @@ class TestNotifyFile(unittest.TestCase): if not timestamp: assert m_open.return_value.write.call_args_list == [ call(title), - call("{}\n".format(message)), + call(f"{message}\n"), ] else: assert m_open.return_value.write.call_args_list == [ call(title), - call("{} {}\n".format(dt_util.utcnow().isoformat(), message)), + call(f"{dt_util.utcnow().isoformat()} {message}\n"), ] def test_notify_file(self): diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index b3d0a008961..13824dff9c3 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -923,9 +923,9 @@ async def test_flux_with_multiple_lights(hass): def event_date(hass, event, now=None): if event == SUN_EVENT_SUNRISE: - print("sunrise {}".format(sunrise_time)) + print(f"sunrise {sunrise_time}") return sunrise_time - print("sunset {}".format(sunset_time)) + print(f"sunset {sunset_time}") return sunset_time with patch( diff --git a/tests/components/frontend/test_storage.py b/tests/components/frontend/test_storage.py index d907f69bbf9..d4cf1916c52 100644 --- a/tests/components/frontend/test_storage.py +++ b/tests/components/frontend/test_storage.py @@ -1,7 +1,7 @@ """The tests for frontend storage.""" import pytest -from homeassistant.components.frontend import storage +from homeassistant.components.frontend import DOMAIN from homeassistant.setup import async_setup_component @@ -26,7 +26,7 @@ async def test_get_user_data_empty(hass, hass_ws_client, hass_storage): async def test_get_user_data(hass, hass_ws_client, hass_admin_user, hass_storage): """Test get_user_data command.""" - storage_key = storage.STORAGE_KEY_USER_DATA.format(hass_admin_user.id) + storage_key = f"{DOMAIN}.user_data_{hass_admin_user.id}" hass_storage[storage_key] = { "key": storage_key, "version": 1, @@ -102,7 +102,7 @@ async def test_set_user_data_empty(hass, hass_ws_client, hass_storage): async def test_set_user_data(hass, hass_ws_client, hass_storage, hass_admin_user): """Test set_user_data command with initial data.""" - storage_key = storage.STORAGE_KEY_USER_DATA.format(hass_admin_user.id) + storage_key = f"{DOMAIN}.user_data_{hass_admin_user.id}" hass_storage[storage_key] = { "version": 1, "data": {"test-key": "test-value", "test-complex": "string"}, diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py index 38c7200cce1..8bfbed52a11 100644 --- a/tests/components/geo_json_events/test_geo_location.py +++ b/tests/components/geo_json_events/test_geo_location.py @@ -5,8 +5,6 @@ from homeassistant.components import geo_location from homeassistant.components.geo_json_events.geo_location import ( ATTR_EXTERNAL_ID, SCAN_INTERVAL, - SIGNAL_DELETE_ENTITY, - SIGNAL_UPDATE_ENTITY, ) from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.const import ( @@ -190,8 +188,8 @@ async def test_setup_race_condition(hass): # Set up some mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 15.5, (-31.0, 150.0)) - delete_signal = SIGNAL_DELETE_ENTITY.format("1234") - update_signal = SIGNAL_UPDATE_ENTITY.format("1234") + delete_signal = f"geo_json_events_delete_1234" + update_signal = f"geo_json_events_update_1234" # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py index 319a79966fd..b988d613d6c 100644 --- a/tests/components/geofency/test_init.py +++ b/tests/components/geofency/test_init.py @@ -163,7 +163,7 @@ async def webhook_id(hass, geofency_client): async def test_data_validation(geofency_client, webhook_id): """Test data validation.""" - url = "/api/webhook/{}".format(webhook_id) + url = f"/api/webhook/{webhook_id}" # No data req = await geofency_client.post(url) @@ -181,14 +181,14 @@ async def test_data_validation(geofency_client, webhook_id): async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id): """Test GPS based zone enter and exit.""" - url = "/api/webhook/{}".format(webhook_id) + url = f"/api/webhook/{webhook_id}" # Enter the Home zone req = await geofency_client.post(url, data=GPS_ENTER_HOME) await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_ENTER_HOME["device"]) - state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state + state_name = hass.states.get(f"device_tracker.{device_name}").state assert STATE_HOME == state_name # Exit the Home zone @@ -196,7 +196,7 @@ async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id): await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_EXIT_HOME["device"]) - state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state + state_name = hass.states.get(f"device_tracker.{device_name}").state assert STATE_NOT_HOME == state_name # Exit the Home zone with "Send Current Position" enabled @@ -208,13 +208,13 @@ async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id): await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_EXIT_HOME["device"]) - current_latitude = hass.states.get( - "{}.{}".format("device_tracker", device_name) - ).attributes["latitude"] + current_latitude = hass.states.get(f"device_tracker.{device_name}").attributes[ + "latitude" + ] assert NOT_HOME_LATITUDE == current_latitude - current_longitude = hass.states.get( - "{}.{}".format("device_tracker", device_name) - ).attributes["longitude"] + current_longitude = hass.states.get(f"device_tracker.{device_name}").attributes[ + "longitude" + ] assert NOT_HOME_LONGITUDE == current_longitude dev_reg = await hass.helpers.device_registry.async_get_registry() @@ -226,43 +226,43 @@ async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id): async def test_beacon_enter_and_exit_home(hass, geofency_client, webhook_id): """Test iBeacon based zone enter and exit - a.k.a stationary iBeacon.""" - url = "/api/webhook/{}".format(webhook_id) + url = f"/api/webhook/{webhook_id}" # Enter the Home zone req = await geofency_client.post(url, data=BEACON_ENTER_HOME) await hass.async_block_till_done() assert req.status == HTTP_OK - device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME["name"])) - state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state + device_name = slugify(f"beacon_{BEACON_ENTER_HOME['name']}") + state_name = hass.states.get(f"device_tracker.{device_name}").state assert STATE_HOME == state_name # Exit the Home zone req = await geofency_client.post(url, data=BEACON_EXIT_HOME) await hass.async_block_till_done() assert req.status == HTTP_OK - device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME["name"])) - state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state + device_name = slugify(f"beacon_{BEACON_ENTER_HOME['name']}") + state_name = hass.states.get(f"device_tracker.{device_name}").state assert STATE_NOT_HOME == state_name async def test_beacon_enter_and_exit_car(hass, geofency_client, webhook_id): """Test use of mobile iBeacon.""" - url = "/api/webhook/{}".format(webhook_id) + url = f"/api/webhook/{webhook_id}" # Enter the Car away from Home zone req = await geofency_client.post(url, data=BEACON_ENTER_CAR) await hass.async_block_till_done() assert req.status == HTTP_OK - device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR["name"])) - state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state + device_name = slugify(f"beacon_{BEACON_ENTER_CAR['name']}") + state_name = hass.states.get(f"device_tracker.{device_name}").state assert STATE_NOT_HOME == state_name # Exit the Car away from Home zone req = await geofency_client.post(url, data=BEACON_EXIT_CAR) await hass.async_block_till_done() assert req.status == HTTP_OK - device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR["name"])) - state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state + device_name = slugify(f"beacon_{BEACON_ENTER_CAR['name']}") + state_name = hass.states.get(f"device_tracker.{device_name}").state assert STATE_NOT_HOME == state_name # Enter the Car in the Home zone @@ -272,29 +272,29 @@ async def test_beacon_enter_and_exit_car(hass, geofency_client, webhook_id): req = await geofency_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTP_OK - device_name = slugify("beacon_{}".format(data["name"])) - state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state + device_name = slugify(f"beacon_{data['name']}") + state_name = hass.states.get(f"device_tracker.{device_name}").state assert STATE_HOME == state_name # Exit the Car in the Home zone req = await geofency_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTP_OK - device_name = slugify("beacon_{}".format(data["name"])) - state_name = hass.states.get("{}.{}".format("device_tracker", device_name)).state + device_name = slugify(f"beacon_{data['name']}") + state_name = hass.states.get(f"device_tracker.{device_name}").state assert STATE_HOME == state_name async def test_load_unload_entry(hass, geofency_client, webhook_id): """Test that the appropriate dispatch signals are added and removed.""" - url = "/api/webhook/{}".format(webhook_id) + url = f"/api/webhook/{webhook_id}" # Enter the Home zone req = await geofency_client.post(url, data=GPS_ENTER_HOME) await hass.async_block_till_done() assert req.status == HTTP_OK device_name = slugify(GPS_ENTER_HOME["device"]) - state_1 = hass.states.get("{}.{}".format("device_tracker", device_name)) + state_1 = hass.states.get(f"device_tracker.{device_name}") assert STATE_HOME == state_1.state assert len(hass.data[DOMAIN]["devices"]) == 1 @@ -307,7 +307,7 @@ async def test_load_unload_entry(hass, geofency_client, webhook_id): assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - state_2 = hass.states.get("{}.{}".format("device_tracker", device_name)) + state_2 = hass.states.get(f"device_tracker.{device_name}") assert state_2 is not None assert state_1 is not state_2 diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py index 4aace6f5484..ad7b6b12001 100644 --- a/tests/components/google/test_calendar.py +++ b/tests/components/google/test_calendar.py @@ -218,7 +218,7 @@ async def test_offset_in_progress_event(hass, mock_next_event): event = copy.deepcopy(TEST_EVENT) event["start"]["dateTime"] = start event["end"]["dateTime"] = end - event["summary"] = "{} !!-15".format(event_summary) + event["summary"] = f"{event_summary} !!-15" mock_next_event.return_value.event = event assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG}) @@ -250,7 +250,7 @@ async def test_all_day_offset_in_progress_event(hass, mock_next_event): event = copy.deepcopy(TEST_EVENT) event["start"]["date"] = start event["end"]["date"] = end - event["summary"] = "{} !!-25:0".format(event_summary) + event["summary"] = f"{event_summary} !!-25:0" mock_next_event.return_value.event = event assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG}) @@ -282,7 +282,7 @@ async def test_all_day_offset_event(hass, mock_next_event): event = copy.deepcopy(TEST_EVENT) event["start"]["date"] = start event["end"]["date"] = end - event["summary"] = "{} !!-{}:0".format(event_summary, offset_hours) + event["summary"] = f"{event_summary} !!-{offset_hours}:0" mock_next_event.return_value.event = event assert await async_setup_component(hass, "google", {"google": GOOGLE_CONFIG}) diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index 3be97013e4d..f2f43b6dabd 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -31,7 +31,7 @@ ACCESS_TOKEN = "superdoublesecret" @pytest.fixture def auth_header(hass_access_token): """Generate an HTTP header with bearer token authorization.""" - return {AUTHORIZATION: "Bearer {}".format(hass_access_token)} + return {AUTHORIZATION: f"Bearer {hass_access_token}"} @pytest.fixture diff --git a/tests/components/google_assistant/test_http.py b/tests/components/google_assistant/test_http.py index f5e3e505a28..ff159e4e10c 100644 --- a/tests/components/google_assistant/test_http.py +++ b/tests/components/google_assistant/test_http.py @@ -27,7 +27,7 @@ MOCK_TOKEN = {"access_token": "dummtoken", "expires_in": 3600} MOCK_JSON = {"devices": {}} MOCK_URL = "https://dummy" MOCK_HEADER = { - "Authorization": "Bearer {}".format(MOCK_TOKEN["access_token"]), + "Authorization": f"Bearer {MOCK_TOKEN['access_token']}", "X-GFE-SSL": "yes", } @@ -57,7 +57,7 @@ async def test_get_access_token(hass, aioclient_mock): await _get_homegraph_token(hass, jwt) assert aioclient_mock.call_count == 1 assert aioclient_mock.mock_calls[0][3] == { - "Authorization": "Bearer {}".format(jwt), + "Authorization": f"Bearer {jwt}", "Content-Type": "application/x-www-form-urlencoded", } diff --git a/tests/components/google_domains/test_init.py b/tests/components/google_domains/test_init.py index 66e334d342f..1ebc5cfda80 100644 --- a/tests/components/google_domains/test_init.py +++ b/tests/components/google_domains/test_init.py @@ -13,7 +13,7 @@ DOMAIN = "test.example.com" USERNAME = "abc123" PASSWORD = "xyz789" -UPDATE_URL = google_domains.UPDATE_URL.format(USERNAME, PASSWORD) +UPDATE_URL = f"https://{USERNAME}:{PASSWORD}@domains.google.com/nic/update" @pytest.fixture diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index 8a529f93f72..bddee724966 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -48,9 +48,7 @@ class TestGoogleWifiSetup(unittest.TestCase): @requests_mock.Mocker() def test_setup_minimum(self, mock_req): """Test setup with minimum configuration.""" - resource = "{}{}{}".format( - "http://", google_wifi.DEFAULT_HOST, google_wifi.ENDPOINT - ) + resource = f"http://{google_wifi.DEFAULT_HOST}{google_wifi.ENDPOINT}" mock_req.get(resource, status_code=200) assert setup_component( self.hass, @@ -62,7 +60,7 @@ class TestGoogleWifiSetup(unittest.TestCase): @requests_mock.Mocker() def test_setup_get(self, mock_req): """Test setup with full configuration.""" - resource = "{}{}{}".format("http://", "localhost", google_wifi.ENDPOINT) + resource = f"http://localhost{google_wifi.ENDPOINT}" mock_req.get(resource, status_code=200) assert setup_component( self.hass, @@ -101,7 +99,7 @@ class TestGoogleWifiSensor(unittest.TestCase): def setup_api(self, data, mock_req): """Set up API with fake data.""" - resource = "{}{}{}".format("http://", "localhost", google_wifi.ENDPOINT) + resource = f"http://localhost{google_wifi.ENDPOINT}" now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): mock_req.get(resource, text=data, status_code=200) @@ -111,7 +109,7 @@ class TestGoogleWifiSensor(unittest.TestCase): self.sensor_dict = dict() for condition, cond_list in google_wifi.MONITORED_CONDITIONS.items(): sensor = google_wifi.GoogleWifiSensor(self.api, self.name, condition) - name = "{}_{}".format(self.name, condition) + name = f"{self.name}_{condition}" units = cond_list[1] icon = cond_list[2] self.sensor_dict[condition] = { diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index f81ef45a648..9135f583d19 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -77,7 +77,7 @@ async def webhook_id(hass, gpslogger_client): async def test_missing_data(hass, gpslogger_client, webhook_id): """Test missing data.""" - url = "/api/webhook/{}".format(webhook_id) + url = f"/api/webhook/{webhook_id}" data = {"latitude": 1.0, "longitude": 1.1, "device": "123"} @@ -103,7 +103,7 @@ async def test_missing_data(hass, gpslogger_client, webhook_id): async def test_enter_and_exit(hass, gpslogger_client, webhook_id): """Test when there is a known zone.""" - url = "/api/webhook/{}".format(webhook_id) + url = f"/api/webhook/{webhook_id}" data = {"latitude": HOME_LATITUDE, "longitude": HOME_LONGITUDE, "device": "123"} @@ -111,18 +111,14 @@ async def test_enter_and_exit(hass, gpslogger_client, webhook_id): req = await gpslogger_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTP_OK - state_name = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"]) - ).state + state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state assert STATE_HOME == state_name # Enter Home again req = await gpslogger_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTP_OK - state_name = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"]) - ).state + state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state assert STATE_HOME == state_name data["longitude"] = 0 @@ -132,9 +128,7 @@ async def test_enter_and_exit(hass, gpslogger_client, webhook_id): req = await gpslogger_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTP_OK - state_name = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"]) - ).state + state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state assert STATE_NOT_HOME == state_name dev_reg = await hass.helpers.device_registry.async_get_registry() @@ -146,7 +140,7 @@ async def test_enter_and_exit(hass, gpslogger_client, webhook_id): async def test_enter_with_attrs(hass, gpslogger_client, webhook_id): """Test when additional attributes are present.""" - url = "/api/webhook/{}".format(webhook_id) + url = f"/api/webhook/{webhook_id}" data = { "latitude": 1.0, @@ -164,7 +158,7 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id): req = await gpslogger_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTP_OK - state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"])) + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}") assert state.state == STATE_NOT_HOME assert state.attributes["gps_accuracy"] == 10.5 assert state.attributes["battery_level"] == 10.0 @@ -190,7 +184,7 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id): req = await gpslogger_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTP_OK - state = hass.states.get("{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"])) + state = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}") assert state.state == STATE_HOME assert state.attributes["gps_accuracy"] == 123 assert state.attributes["battery_level"] == 23 @@ -206,16 +200,14 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id): ) async def test_load_unload_entry(hass, gpslogger_client, webhook_id): """Test that the appropriate dispatch signals are added and removed.""" - url = "/api/webhook/{}".format(webhook_id) + url = f"/api/webhook/{webhook_id}" data = {"latitude": HOME_LATITUDE, "longitude": HOME_LONGITUDE, "device": "123"} # Enter the Home req = await gpslogger_client.post(url, data=data) await hass.async_block_till_done() assert req.status == HTTP_OK - state_name = hass.states.get( - "{}.{}".format(DEVICE_TRACKER_DOMAIN, data["device"]) - ).state + state_name = hass.states.get(f"{DEVICE_TRACKER_DOMAIN}.{data['device']}").state assert STATE_HOME == state_name assert len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) == 1 diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 9a45b0ec273..e8878b7cf4a 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -43,10 +43,7 @@ class TestComponentsGroup(unittest.TestCase): ) assert ( - STATE_ON - == self.hass.states.get( - group.ENTITY_ID_FORMAT.format("person_and_light") - ).state + STATE_ON == self.hass.states.get(f"{group.DOMAIN}.person_and_light").state ) def test_setup_group_with_a_non_existing_state(self): @@ -296,9 +293,7 @@ class TestComponentsGroup(unittest.TestCase): setup_component(self.hass, "group", {"group": group_conf}) - group_state = self.hass.states.get( - group.ENTITY_ID_FORMAT.format("second_group") - ) + group_state = self.hass.states.get(f"{group.DOMAIN}.second_group") assert STATE_ON == group_state.state assert set((test_group.entity_id, "light.bowl")) == set( group_state.attributes["entity_id"] @@ -307,7 +302,7 @@ class TestComponentsGroup(unittest.TestCase): assert "mdi:work" == group_state.attributes.get(ATTR_ICON) assert 1 == group_state.attributes.get(group.ATTR_ORDER) - group_state = self.hass.states.get(group.ENTITY_ID_FORMAT.format("test_group")) + group_state = self.hass.states.get(f"{group.DOMAIN}.test_group") assert STATE_UNKNOWN == group_state.state assert set(("sensor.happy", "hello.world")) == set( group_state.attributes["entity_id"] @@ -373,10 +368,7 @@ class TestComponentsGroup(unittest.TestCase): ) self.hass.states.set("device_tracker.Adam", "cool_state_not_home") self.hass.block_till_done() - assert ( - STATE_NOT_HOME - == self.hass.states.get(group.ENTITY_ID_FORMAT.format("peeps")).state - ) + assert STATE_NOT_HOME == self.hass.states.get(f"{group.DOMAIN}.peeps").state def test_reloading_groups(self): """Test reloading the group config.""" @@ -431,9 +423,7 @@ class TestComponentsGroup(unittest.TestCase): common.set_group(self.hass, "modify_group", icon="mdi:play") self.hass.block_till_done() - group_state = self.hass.states.get( - group.ENTITY_ID_FORMAT.format("modify_group") - ) + group_state = self.hass.states.get(f"{group.DOMAIN}.modify_group") assert self.hass.states.entity_ids() == ["group.modify_group"] assert group_state.attributes.get(ATTR_ICON) == "mdi:play" @@ -463,9 +453,7 @@ async def test_service_group_set_group_remove_group(hass): assert group_state.attributes[group.ATTR_AUTO] assert group_state.attributes["friendly_name"] == "Test" - common.async_set_group( - hass, "user_test_group", entity_ids=["test.entity_bla1"], - ) + common.async_set_group(hass, "user_test_group", entity_ids=["test.entity_bla1"]) await hass.async_block_till_done() group_state = hass.states.get("group.user_test_group") From 88df9c8ab4e2c6a5b20b2cd81ceab64f9a6bb936 Mon Sep 17 00:00:00 2001 From: springstan <46536646+springstan@users.noreply.github.com> Date: Tue, 25 Feb 2020 02:55:31 +0100 Subject: [PATCH 083/416] Remove friendly_name attribute from twitch sensor (#32067) * Remove friendly_name attribute twitch sensor * Use deepcopy to only copy the content * Update homeassistant/components/twitch/sensor.py Co-Authored-By: Paulus Schoutsen * Update homeassistant/components/twitch/sensor.py Co-authored-by: Paulus Schoutsen --- homeassistant/components/twitch/sensor.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index 1bf66810e5b..68b7d5dce21 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -6,7 +6,7 @@ from twitch import TwitchClient import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_FRIENDLY_NAME, CONF_TOKEN +from homeassistant.const import CONF_TOKEN import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -96,10 +96,7 @@ class TwitchSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - attr = { - ATTR_FRIENDLY_NAME: self._channel.display_name, - } - attr.update(self._statistics) + attr = dict(self._statistics) if self._oauth_enabled: attr.update(self._subscription) From e0586df602583bcb24af8db2eadc98558d1f129b Mon Sep 17 00:00:00 2001 From: SukramJ Date: Tue, 25 Feb 2020 03:16:57 +0100 Subject: [PATCH 084/416] Migrate HomematicIP Cloud services to admin services (#32107) * Migrate HomematicIP Cloud services to admin services * remove unused dict * vacation and eco mode are usable by users * add verify_domain_control, make service user accessible --- .../components/homematicip_cloud/services.py | 72 +++++++++---------- 1 file changed, 34 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/services.py b/homeassistant/components/homematicip_cloud/services.py index 193cac94629..d8535edda50 100644 --- a/homeassistant/components/homematicip_cloud/services.py +++ b/homeassistant/components/homematicip_cloud/services.py @@ -12,6 +12,10 @@ import voluptuous as vol from homeassistant.const import ATTR_ENTITY_ID import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import comp_entity_ids +from homeassistant.helpers.service import ( + async_register_admin_service, + verify_domain_control, +) from homeassistant.helpers.typing import HomeAssistantType, ServiceCallType from .const import DOMAIN as HMIPC_DOMAIN @@ -38,17 +42,6 @@ SERVICE_DUMP_HAP_CONFIG = "dump_hap_config" SERVICE_RESET_ENERGY_COUNTER = "reset_energy_counter" SERVICE_SET_ACTIVE_CLIMATE_PROFILE = "set_active_climate_profile" -HMIPC_SERVICES2 = { - SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION: "_async_activate_eco_mode_with_duration", - SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD: "_async_activate_eco_mode_with_period", - SERVICE_ACTIVATE_VACATION: "_async_activate_vacation", - SERVICE_DEACTIVATE_ECO_MODE: "SERVICE_DEACTIVATE_ECO_MODE", - SERVICE_DEACTIVATE_VACATION: "_async_deactivate_vacation", - SERVICE_DUMP_HAP_CONFIG: "_async_dump_hap_config", - SERVICE_RESET_ENERGY_COUNTER: "_async_reset_energy_counter", - SERVICE_SET_ACTIVE_CLIMATE_PROFILE: "_set_active_climate_profile", -} - HMIPC_SERVICES = [ SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION, SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD, @@ -120,6 +113,7 @@ async def async_setup_services(hass: HomeAssistantType) -> None: if hass.services.async_services().get(HMIPC_DOMAIN): return + @verify_domain_control(hass, HMIPC_DOMAIN) async def async_call_hmipc_service(service: ServiceCallType): """Call correct HomematicIP Cloud service.""" service_name = service.service @@ -142,58 +136,60 @@ async def async_setup_services(hass: HomeAssistantType) -> None: await _set_active_climate_profile(hass, service) hass.services.async_register( - HMIPC_DOMAIN, - SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION, - async_call_hmipc_service, + domain=HMIPC_DOMAIN, + service=SERVICE_ACTIVATE_ECO_MODE_WITH_DURATION, + service_func=async_call_hmipc_service, schema=SCHEMA_ACTIVATE_ECO_MODE_WITH_DURATION, ) hass.services.async_register( - HMIPC_DOMAIN, - SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD, - async_call_hmipc_service, + domain=HMIPC_DOMAIN, + service=SERVICE_ACTIVATE_ECO_MODE_WITH_PERIOD, + service_func=async_call_hmipc_service, schema=SCHEMA_ACTIVATE_ECO_MODE_WITH_PERIOD, ) hass.services.async_register( - HMIPC_DOMAIN, - SERVICE_ACTIVATE_VACATION, - async_call_hmipc_service, + domain=HMIPC_DOMAIN, + service=SERVICE_ACTIVATE_VACATION, + service_func=async_call_hmipc_service, schema=SCHEMA_ACTIVATE_VACATION, ) hass.services.async_register( - HMIPC_DOMAIN, - SERVICE_DEACTIVATE_ECO_MODE, - async_call_hmipc_service, + domain=HMIPC_DOMAIN, + service=SERVICE_DEACTIVATE_ECO_MODE, + service_func=async_call_hmipc_service, schema=SCHEMA_DEACTIVATE_ECO_MODE, ) hass.services.async_register( - HMIPC_DOMAIN, - SERVICE_DEACTIVATE_VACATION, - async_call_hmipc_service, + domain=HMIPC_DOMAIN, + service=SERVICE_DEACTIVATE_VACATION, + service_func=async_call_hmipc_service, schema=SCHEMA_DEACTIVATE_VACATION, ) hass.services.async_register( - HMIPC_DOMAIN, - SERVICE_SET_ACTIVE_CLIMATE_PROFILE, - async_call_hmipc_service, + domain=HMIPC_DOMAIN, + service=SERVICE_SET_ACTIVE_CLIMATE_PROFILE, + service_func=async_call_hmipc_service, schema=SCHEMA_SET_ACTIVE_CLIMATE_PROFILE, ) - hass.services.async_register( - HMIPC_DOMAIN, - SERVICE_DUMP_HAP_CONFIG, - async_call_hmipc_service, + async_register_admin_service( + hass=hass, + domain=HMIPC_DOMAIN, + service=SERVICE_DUMP_HAP_CONFIG, + service_func=async_call_hmipc_service, schema=SCHEMA_DUMP_HAP_CONFIG, ) - hass.helpers.service.async_register_admin_service( - HMIPC_DOMAIN, - SERVICE_RESET_ENERGY_COUNTER, - async_call_hmipc_service, + async_register_admin_service( + hass=hass, + domain=HMIPC_DOMAIN, + service=SERVICE_RESET_ENERGY_COUNTER, + service_func=async_call_hmipc_service, schema=SCHEMA_RESET_ENERGY_COUNTER, ) @@ -204,7 +200,7 @@ async def async_unload_services(hass: HomeAssistantType): return for hmipc_service in HMIPC_SERVICES: - hass.services.async_remove(HMIPC_DOMAIN, hmipc_service) + hass.services.async_remove(domain=HMIPC_DOMAIN, service=hmipc_service) async def _async_activate_eco_mode_with_duration( From 71a6ea1c10b59d4d6b9c30bcab855486d7999cf9 Mon Sep 17 00:00:00 2001 From: Mike <22148848+thegame3202@users.noreply.github.com> Date: Mon, 24 Feb 2020 20:43:27 -0600 Subject: [PATCH 085/416] Add shopping_list_item_added event_type (#28334) * Update __init__.py Added event_type "shopping_list_item_added" with an item tied to it. * Update __init__.py * Update __init__.py Black formatting style * Modified global event variables * Typo fix * Update __init__.py * Formatting changes * More formatting changes * Update __init__.py * Black formatting * Update __init__.py Co-authored-by: Paulus Schoutsen --- homeassistant/components/shopping_list/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 50d317c9095..3f61f70f858 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -241,7 +241,7 @@ def websocket_handle_items(hass, connection, msg): def websocket_handle_add(hass, connection, msg): """Handle add item to shopping_list.""" item = hass.data[DOMAIN].async_add(msg["name"]) - hass.bus.async_fire(EVENT) + hass.bus.async_fire(EVENT, {"action": "add", "item": item}) connection.send_message(websocket_api.result_message(msg["id"], item)) @@ -255,7 +255,7 @@ async def websocket_handle_update(hass, connection, msg): try: item = hass.data[DOMAIN].async_update(item_id, data) - hass.bus.async_fire(EVENT) + hass.bus.async_fire(EVENT, {"action": "update", "item": item}) connection.send_message(websocket_api.result_message(msg_id, item)) except KeyError: connection.send_message( @@ -267,5 +267,5 @@ async def websocket_handle_update(hass, connection, msg): def websocket_handle_clear(hass, connection, msg): """Handle clearing shopping_list items.""" hass.data[DOMAIN].async_clear_completed() - hass.bus.async_fire(EVENT) + hass.bus.async_fire(EVENT, {"action": "clear"}) connection.send_message(websocket_api.result_message(msg["id"])) From 4236d62b44d6481be356025d02594116b544f0bf Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 24 Feb 2020 22:35:28 -0500 Subject: [PATCH 086/416] Improve Vizio fix to avoid KeyError (#32163) * dont set default volume_step on import check * ensure config entry data['volume_step'] is set * consolidate entry update during entry setup --- homeassistant/components/vizio/config_flow.py | 7 ++----- homeassistant/components/vizio/media_player.py | 14 +++++++++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index cde84a6a9e2..969d387a26b 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -180,11 +180,8 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if entry.data[CONF_NAME] != import_config[CONF_NAME]: updated_name[CONF_NAME] = import_config[CONF_NAME] - import_volume_step = import_config.get( - CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP - ) - if entry.data.get(CONF_VOLUME_STEP) != import_volume_step: - updated_options[CONF_VOLUME_STEP] = import_volume_step + if entry.data.get(CONF_VOLUME_STEP) != import_config[CONF_VOLUME_STEP]: + updated_options[CONF_VOLUME_STEP] = import_config[CONF_VOLUME_STEP] if updated_options or updated_name: new_data = entry.data.copy() diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 13554ab8f36..6b62d6bafd0 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -56,10 +56,18 @@ async def async_setup_entry( volume_step = config_entry.options.get( CONF_VOLUME_STEP, config_entry.data.get(CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP), ) + + params = {} if not config_entry.options: - hass.config_entries.async_update_entry( - config_entry, options={CONF_VOLUME_STEP: volume_step} - ) + params["options"] = {CONF_VOLUME_STEP: volume_step} + + if not config_entry.data.get(CONF_VOLUME_STEP): + new_data = config_entry.data.copy() + new_data.update({CONF_VOLUME_STEP: volume_step}) + params["data"] = new_data + + if params: + hass.config_entries.async_update_entry(config_entry, **params) device = VizioAsync( DEVICE_ID, From 7e387f93d622231ee7ed6128ee7d54c103ac48c7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2020 05:46:02 +0100 Subject: [PATCH 087/416] Add MQTT WS command to remove device (#31989) * Add MQTT WS command to remove device * Review comments, fix test * Fix tests --- homeassistant/components/mqtt/__init__.py | 52 +++++-- .../components/mqtt/alarm_control_panel.py | 16 +-- .../components/mqtt/binary_sensor.py | 16 +-- homeassistant/components/mqtt/camera.py | 16 +-- homeassistant/components/mqtt/climate.py | 16 +-- homeassistant/components/mqtt/const.py | 1 + homeassistant/components/mqtt/cover.py | 15 +- .../components/mqtt/device_automation.py | 15 +- .../components/mqtt/device_trigger.py | 35 ++++- homeassistant/components/mqtt/discovery.py | 9 +- homeassistant/components/mqtt/fan.py | 16 +-- .../components/mqtt/light/__init__.py | 11 +- .../components/mqtt/light/schema_basic.py | 9 +- .../components/mqtt/light/schema_json.py | 9 +- .../components/mqtt/light/schema_template.py | 9 +- homeassistant/components/mqtt/lock.py | 16 +-- homeassistant/components/mqtt/sensor.py | 16 +-- homeassistant/components/mqtt/switch.py | 16 +-- .../components/mqtt/vacuum/__init__.py | 11 +- .../components/mqtt/vacuum/schema_legacy.py | 5 +- .../components/mqtt/vacuum/schema_state.py | 5 +- homeassistant/helpers/entity.py | 9 ++ tests/common.py | 4 + tests/components/mqtt/test_device_trigger.py | 58 +++++++- tests/components/mqtt/test_discovery.py | 130 +++++++++++++++++- tests/components/mqtt/test_init.py | 82 +++++++++++ 26 files changed, 473 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 540d09d7c9f..61c62a1eaa4 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -37,6 +37,7 @@ from homeassistant.exceptions import ( Unauthorized, ) from homeassistant.helpers import config_validation as cv, event, template +from homeassistant.helpers.device_registry import async_get_registry as get_dev_reg from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ConfigType, HomeAssistantType, ServiceDataType @@ -48,6 +49,7 @@ from homeassistant.util.logging import catch_log_exception from . import config_flow, discovery, server # noqa: F401 pylint: disable=unused-import from .const import ( ATTR_DISCOVERY_HASH, + ATTR_DISCOVERY_TOPIC, CONF_BROKER, CONF_DISCOVERY, CONF_STATE_TOPIC, @@ -510,6 +512,7 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: hass.data[DATA_MQTT_HASS_CONFIG] = config websocket_api.async_register_command(hass, websocket_subscribe) + websocket_api.async_register_command(hass, websocket_remove_device) if conf is None: # If we have a config entry, setup is done by that config entry. @@ -1156,43 +1159,55 @@ class MqttAvailability(Entity): class MqttDiscoveryUpdate(Entity): """Mixin used to handle updated discovery message.""" - def __init__(self, discovery_hash, discovery_update=None) -> None: + def __init__(self, discovery_data, discovery_update=None) -> None: """Initialize the discovery update mixin.""" - self._discovery_hash = discovery_hash + self._discovery_data = discovery_data self._discovery_update = discovery_update self._remove_signal = None async def async_added_to_hass(self) -> None: """Subscribe to discovery updates.""" await super().async_added_to_hass() + discovery_hash = ( + self._discovery_data[ATTR_DISCOVERY_HASH] if self._discovery_data else None + ) @callback def discovery_callback(payload): """Handle discovery update.""" _LOGGER.info( - "Got update for entity with hash: %s '%s'", - self._discovery_hash, - payload, + "Got update for entity with hash: %s '%s'", discovery_hash, payload, ) if not payload: # Empty payload: Remove component _LOGGER.info("Removing component: %s", self.entity_id) self.hass.async_create_task(self.async_remove()) - clear_discovery_hash(self.hass, self._discovery_hash) + clear_discovery_hash(self.hass, discovery_hash) self._remove_signal() elif self._discovery_update: # Non-empty payload: Notify component _LOGGER.info("Updating component: %s", self.entity_id) - payload.pop(ATTR_DISCOVERY_HASH) self.hass.async_create_task(self._discovery_update(payload)) - if self._discovery_hash: + if discovery_hash: self._remove_signal = async_dispatcher_connect( self.hass, - MQTT_DISCOVERY_UPDATED.format(self._discovery_hash), + MQTT_DISCOVERY_UPDATED.format(discovery_hash), discovery_callback, ) + async def async_removed_from_registry(self) -> None: + """Clear retained discovery topic in broker.""" + discovery_topic = self._discovery_data[ATTR_DISCOVERY_TOPIC] + publish( + self.hass, discovery_topic, "", retain=True, + ) + + async def async_will_remove_from_hass(self) -> None: + """Stop listening to signal.""" + if self._remove_signal: + self._remove_signal() + def device_info_from_config(config): """Return a device description for device registry.""" @@ -1247,6 +1262,25 @@ class MqttEntityDeviceInfo(Entity): return device_info_from_config(self._device_config) +@websocket_api.websocket_command( + {vol.Required("type"): "mqtt/device/remove", vol.Required("device_id"): str} +) +@websocket_api.async_response +async def websocket_remove_device(hass, connection, msg): + """Delete device.""" + device_id = msg["device_id"] + dev_registry = await get_dev_reg(hass) + + device = dev_registry.async_get(device_id) + for config_entry in device.config_entries: + config_entry = hass.config_entries.async_get_entry(config_entry) + # Only delete the device if it belongs to an MQTT device entry + if config_entry.domain == DOMAIN: + dev_registry.async_remove_device(device_id) + connection.send_message(websocket_api.result_message(msg["id"])) + break + + @websocket_api.async_response @websocket_api.websocket_command( { diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 43d0bb570a8..043fa62f6ef 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -98,15 +98,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add an MQTT alarm control panel.""" + discovery_data = discovery_payload.discovery_data try: - discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) await _async_setup_entity( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -115,10 +114,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT Alarm Control Panel platform.""" - async_add_entities([MqttAlarm(config, config_entry, discovery_hash)]) + async_add_entities([MqttAlarm(config, config_entry, discovery_data)]) class MqttAlarm( @@ -130,7 +129,7 @@ class MqttAlarm( ): """Representation of a MQTT alarm status.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Init the MQTT Alarm Control Panel.""" self._state = None self._config = config @@ -141,7 +140,7 @@ class MqttAlarm( MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -207,6 +206,7 @@ class MqttAlarm( ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index fe47729561d..d268c12aa87 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -79,15 +79,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT binary sensor.""" + discovery_data = discovery_payload.discovery_data try: - discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) await _async_setup_entity( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -96,10 +95,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT binary sensor.""" - async_add_entities([MqttBinarySensor(config, config_entry, discovery_hash)]) + async_add_entities([MqttBinarySensor(config, config_entry, discovery_data)]) class MqttBinarySensor( @@ -111,7 +110,7 @@ class MqttBinarySensor( ): """Representation a binary sensor that is updated by MQTT.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize the MQTT binary sensor.""" self._config = config self._unique_id = config.get(CONF_UNIQUE_ID) @@ -124,7 +123,7 @@ class MqttBinarySensor( MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -229,6 +228,7 @@ class MqttBinarySensor( ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @callback def value_is_expired(self, *_): diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 6cf0865ff6a..9bbb1503196 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -47,15 +47,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT camera.""" + discovery_data = discovery_payload.discovery_data try: - discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) await _async_setup_entity( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -64,16 +63,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT Camera.""" - async_add_entities([MqttCamera(config, config_entry, discovery_hash)]) + async_add_entities([MqttCamera(config, config_entry, discovery_data)]) class MqttCamera(MqttDiscoveryUpdate, MqttEntityDeviceInfo, Camera): """representation of a MQTT camera.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize the MQTT Camera.""" self._config = config self._unique_id = config.get(CONF_UNIQUE_ID) @@ -85,7 +84,7 @@ class MqttCamera(MqttDiscoveryUpdate, MqttEntityDeviceInfo, Camera): device_config = config.get(CONF_DEVICE) Camera.__init__(self) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -127,6 +126,7 @@ class MqttCamera(MqttDiscoveryUpdate, MqttEntityDeviceInfo, Camera): self._sub_state = await subscription.async_unsubscribe_topics( self.hass, self._sub_state ) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) async def async_camera_image(self): """Return image response.""" diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 91a36a310cb..46404de0c8a 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -243,15 +243,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT climate device.""" + discovery_data = discovery_payload.discovery_data try: - discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) await _async_setup_entity( - hass, config, async_add_entities, config_entry, discovery_hash + hass, config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -260,10 +259,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_setup_entity( - hass, config, async_add_entities, config_entry=None, discovery_hash=None + hass, config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT climate devices.""" - async_add_entities([MqttClimate(hass, config, config_entry, discovery_hash)]) + async_add_entities([MqttClimate(hass, config, config_entry, discovery_data)]) class MqttClimate( @@ -275,7 +274,7 @@ class MqttClimate( ): """Representation of an MQTT climate device.""" - def __init__(self, hass, config, config_entry, discovery_hash): + def __init__(self, hass, config, config_entry, discovery_data): """Initialize the climate device.""" self._config = config self._unique_id = config.get(CONF_UNIQUE_ID) @@ -303,7 +302,7 @@ class MqttClimate( MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -552,6 +551,7 @@ class MqttClimate( ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 3234bebbfc1..6044ec2af6e 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -4,6 +4,7 @@ CONF_DISCOVERY = "discovery" DEFAULT_DISCOVERY = False ATTR_DISCOVERY_HASH = "discovery_hash" +ATTR_DISCOVERY_TOPIC = "discovery_topic" CONF_STATE_TOPIC = "state_topic" PROTOCOL_311 = "3.1.1" DEFAULT_QOS = 0 diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 885343b7090..a7a39678192 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -178,14 +178,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add an MQTT cover.""" - discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) + discovery_data = discovery_payload.discovery_data try: config = PLATFORM_SCHEMA(discovery_payload) await _async_setup_entity( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ) except Exception: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -194,10 +194,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT Cover.""" - async_add_entities([MqttCover(config, config_entry, discovery_hash)]) + async_add_entities([MqttCover(config, config_entry, discovery_data)]) class MqttCover( @@ -209,7 +209,7 @@ class MqttCover( ): """Representation of a cover that can be controlled using MQTT.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize the cover.""" self._unique_id = config.get(CONF_UNIQUE_ID) self._position = None @@ -227,7 +227,7 @@ class MqttCover( MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -369,6 +369,7 @@ class MqttCover( ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/homeassistant/components/mqtt/device_automation.py b/homeassistant/components/mqtt/device_automation.py index 3f0889875d0..4fcfd8f66f2 100644 --- a/homeassistant/components/mqtt/device_automation.py +++ b/homeassistant/components/mqtt/device_automation.py @@ -4,6 +4,7 @@ import logging import voluptuous as vol from homeassistant.components import mqtt +from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import ATTR_DISCOVERY_HASH, device_trigger @@ -25,20 +26,26 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend( async def async_setup_entry(hass, config_entry): """Set up MQTT device automation dynamically through MQTT discovery.""" + async def async_device_removed(event): + """Handle the removal of a device.""" + if event.data["action"] != "remove": + return + await device_trigger.async_device_removed(hass, event.data["device_id"]) + async def async_discover(discovery_payload): """Discover and add an MQTT device automation.""" - discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) + discovery_data = discovery_payload.discovery_data try: config = PLATFORM_SCHEMA(discovery_payload) if config[CONF_AUTOMATION_TYPE] == AUTOMATION_TYPE_TRIGGER: await device_trigger.async_setup_trigger( - hass, config, config_entry, discovery_hash + hass, config, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( hass, MQTT_DISCOVERY_NEW.format("device_automation", "mqtt"), async_discover ) + hass.bus.async_listen(EVENT_DEVICE_REGISTRY_UPDATED, async_device_removed) diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index 2149024266d..92bef0578c9 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -1,6 +1,6 @@ """Provides device automations for MQTT.""" import logging -from typing import List +from typing import Callable, List import attr import voluptuous as vol @@ -99,9 +99,11 @@ class Trigger: """Device trigger settings.""" device_id = attr.ib(type=str) + discovery_hash = attr.ib(type=tuple) hass = attr.ib(type=HomeAssistantType) payload = attr.ib(type=str) qos = attr.ib(type=int) + remove_signal = attr.ib(type=Callable[[], None]) subtype = attr.ib(type=str) topic = attr.ib(type=str) type = attr.ib(type=str) @@ -128,8 +130,10 @@ class Trigger: return async_remove - async def update_trigger(self, config): + async def update_trigger(self, config, discovery_hash, remove_signal): """Update MQTT device trigger.""" + self.discovery_hash = discovery_hash + self.remove_signal = remove_signal self.type = config[CONF_TYPE] self.subtype = config[CONF_SUBTYPE] self.topic = config[CONF_TOPIC] @@ -143,8 +147,8 @@ class Trigger: def detach_trigger(self): """Remove MQTT device trigger.""" # Mark trigger as unknown - self.topic = None + # Unsubscribe if this trigger is in use for trig in self.trigger_instances: if trig.remove: @@ -163,9 +167,10 @@ async def _update_device(hass, config_entry, config): device_registry.async_get_or_create(**device_info) -async def async_setup_trigger(hass, config, config_entry, discovery_hash): +async def async_setup_trigger(hass, config, config_entry, discovery_data): """Set up the MQTT device trigger.""" config = TRIGGER_DISCOVERY_SCHEMA(config) + discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] discovery_id = discovery_hash[1] remove_signal = None @@ -185,11 +190,10 @@ async def async_setup_trigger(hass, config, config_entry, discovery_hash): else: # Non-empty payload: Update trigger _LOGGER.info("Updating trigger: %s", discovery_hash) - payload.pop(ATTR_DISCOVERY_HASH) config = TRIGGER_DISCOVERY_SCHEMA(payload) await _update_device(hass, config_entry, config) device_trigger = hass.data[DEVICE_TRIGGERS][discovery_id] - await device_trigger.update_trigger(config) + await device_trigger.update_trigger(config, discovery_hash, remove_signal) remove_signal = async_dispatcher_connect( hass, MQTT_DISCOVERY_UPDATED.format(discovery_hash), discovery_update @@ -212,14 +216,29 @@ async def async_setup_trigger(hass, config, config_entry, discovery_hash): hass.data[DEVICE_TRIGGERS][discovery_id] = Trigger( hass=hass, device_id=device.id, + discovery_hash=discovery_hash, type=config[CONF_TYPE], subtype=config[CONF_SUBTYPE], topic=config[CONF_TOPIC], payload=config[CONF_PAYLOAD], qos=config[CONF_QOS], + remove_signal=remove_signal, ) else: - await hass.data[DEVICE_TRIGGERS][discovery_id].update_trigger(config) + await hass.data[DEVICE_TRIGGERS][discovery_id].update_trigger( + config, discovery_hash, remove_signal + ) + + +async def async_device_removed(hass: HomeAssistant, device_id: str): + """Handle the removal of a device.""" + triggers = await async_get_triggers(hass, device_id) + for trig in triggers: + device_trigger = hass.data[DEVICE_TRIGGERS].pop(trig[CONF_DISCOVERY_ID]) + if device_trigger: + device_trigger.detach_trigger() + clear_discovery_hash(hass, device_trigger.discovery_hash) + device_trigger.remove_signal() async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: @@ -262,6 +281,8 @@ async def async_attach_trigger( hass.data[DEVICE_TRIGGERS][discovery_id] = Trigger( hass=hass, device_id=device_id, + discovery_hash=None, + remove_signal=None, type=config[CONF_TYPE], subtype=config[CONF_SUBTYPE], topic=None, diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 418f648564d..c54ab395c94 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -11,7 +11,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.typing import HomeAssistantType from .abbreviations import ABBREVIATIONS, DEVICE_ABBREVIATIONS -from .const import ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC +from .const import ATTR_DISCOVERY_HASH, ATTR_DISCOVERY_TOPIC, CONF_STATE_TOPIC _LOGGER = logging.getLogger(__name__) @@ -137,6 +137,11 @@ async def async_start( if payload: # Attach MQTT topic to the payload, used for debug prints setattr(payload, "__configuration_source__", f"MQTT (topic: '{topic}')") + discovery_data = { + ATTR_DISCOVERY_HASH: discovery_hash, + ATTR_DISCOVERY_TOPIC: topic, + } + setattr(payload, "discovery_data", discovery_data) if CONF_PLATFORM in payload and "schema" not in payload: platform = payload[CONF_PLATFORM] @@ -173,8 +178,6 @@ async def async_start( topic, ) - payload[ATTR_DISCOVERY_HASH] = discovery_hash - if ALREADY_DISCOVERED not in hass.data: hass.data[ALREADY_DISCOVERED] = {} if discovery_hash in hass.data[ALREADY_DISCOVERED]: diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index c5e4b3145de..b50bdf9734b 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -118,15 +118,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT fan.""" + discovery_data = discovery_payload.discovery_data try: - discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) await _async_setup_entity( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -135,10 +134,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT fan.""" - async_add_entities([MqttFan(config, config_entry, discovery_hash)]) + async_add_entities([MqttFan(config, config_entry, discovery_data)]) class MqttFan( @@ -150,7 +149,7 @@ class MqttFan( ): """A MQTT fan component.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize the MQTT fan.""" self._unique_id = config.get(CONF_UNIQUE_ID) self._state = False @@ -173,7 +172,7 @@ class MqttFan( MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -317,6 +316,7 @@ class MqttFan( ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index 511ee6049df..d48b4ae4762 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -47,15 +47,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT light.""" + discovery_data = discovery_payload.discovery_data try: - discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) await _async_setup_entity( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -64,7 +63,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up a MQTT Light.""" setup_entity = { @@ -73,5 +72,5 @@ async def _async_setup_entity( "template": async_setup_entity_template, } await setup_entity[config[CONF_SCHEMA]]( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ) diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 23f8684cf46..a9ea21b4b0a 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -146,12 +146,12 @@ PLATFORM_SCHEMA_BASIC = ( async def async_setup_entity_basic( - config, async_add_entities, config_entry, discovery_hash=None + config, async_add_entities, config_entry, discovery_data=None ): """Set up a MQTT Light.""" config.setdefault(CONF_STATE_VALUE_TEMPLATE, config.get(CONF_VALUE_TEMPLATE)) - async_add_entities([MqttLight(config, config_entry, discovery_hash)]) + async_add_entities([MqttLight(config, config_entry, discovery_data)]) class MqttLight( @@ -164,7 +164,7 @@ class MqttLight( ): """Representation of a MQTT light.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize MQTT light.""" self._state = False self._sub_state = None @@ -194,7 +194,7 @@ class MqttLight( MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -535,6 +535,7 @@ class MqttLight( ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def brightness(self): diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index e7256614002..60ecf80fb63 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -119,10 +119,10 @@ PLATFORM_SCHEMA_JSON = ( async def async_setup_entity_json( - config: ConfigType, async_add_entities, config_entry, discovery_hash + config: ConfigType, async_add_entities, config_entry, discovery_data ): """Set up a MQTT JSON Light.""" - async_add_entities([MqttLightJson(config, config_entry, discovery_hash)]) + async_add_entities([MqttLightJson(config, config_entry, discovery_data)]) class MqttLightJson( @@ -135,7 +135,7 @@ class MqttLightJson( ): """Representation of a MQTT JSON light.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize MQTT JSON light.""" self._state = False self._sub_state = None @@ -158,7 +158,7 @@ class MqttLightJson( MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -346,6 +346,7 @@ class MqttLightJson( ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def brightness(self): diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 6bbf5ee1572..853e7f4411f 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -93,10 +93,10 @@ PLATFORM_SCHEMA_TEMPLATE = ( async def async_setup_entity_template( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ): """Set up a MQTT Template light.""" - async_add_entities([MqttTemplate(config, config_entry, discovery_hash)]) + async_add_entities([MqttTemplate(config, config_entry, discovery_data)]) class MqttTemplate( @@ -109,7 +109,7 @@ class MqttTemplate( ): """Representation of a MQTT Template light.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize a MQTT Template light.""" self._state = False self._sub_state = None @@ -133,7 +133,7 @@ class MqttTemplate( MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -323,6 +323,7 @@ class MqttTemplate( ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def brightness(self): diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 6910e955288..89f005b7469 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -80,15 +80,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add an MQTT lock.""" + discovery_data = discovery_payload.discovery_data try: - discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) await _async_setup_entity( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -97,10 +96,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT Lock platform.""" - async_add_entities([MqttLock(config, config_entry, discovery_hash)]) + async_add_entities([MqttLock(config, config_entry, discovery_data)]) class MqttLock( @@ -112,7 +111,7 @@ class MqttLock( ): """Representation of a lock that can be toggled using MQTT.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize the lock.""" self._unique_id = config.get(CONF_UNIQUE_ID) self._state = False @@ -126,7 +125,7 @@ class MqttLock( MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -192,6 +191,7 @@ class MqttLock( ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 967a434c9d5..07910697d21 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -76,15 +76,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover_sensor(discovery_payload): """Discover and add a discovered MQTT sensor.""" + discovery_data = discovery_payload.discovery_data try: - discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) await _async_setup_entity( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -93,10 +92,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_setup_entity( - config: ConfigType, async_add_entities, config_entry=None, discovery_hash=None + config: ConfigType, async_add_entities, config_entry=None, discovery_data=None ): """Set up MQTT sensor.""" - async_add_entities([MqttSensor(config, config_entry, discovery_hash)]) + async_add_entities([MqttSensor(config, config_entry, discovery_data)]) class MqttSensor( @@ -104,7 +103,7 @@ class MqttSensor( ): """Representation of a sensor that can be updated using MQTT.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize the sensor.""" self._config = config self._unique_id = config.get(CONF_UNIQUE_ID) @@ -123,7 +122,7 @@ class MqttSensor( MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -208,6 +207,7 @@ class MqttSensor( ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @callback def value_is_expired(self, *_): diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 65b43f6bf53..32066c67b7a 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -76,15 +76,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT switch.""" + discovery_data = discovery_payload.discovery_data try: - discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) await _async_setup_entity( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -93,10 +92,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_setup_entity( - config, async_add_entities, config_entry=None, discovery_hash=None + config, async_add_entities, config_entry=None, discovery_data=None ): """Set up the MQTT switch.""" - async_add_entities([MqttSwitch(config, config_entry, discovery_hash)]) + async_add_entities([MqttSwitch(config, config_entry, discovery_data)]) class MqttSwitch( @@ -109,7 +108,7 @@ class MqttSwitch( ): """Representation of a switch that can be toggled using MQTT.""" - def __init__(self, config, config_entry, discovery_hash): + def __init__(self, config, config_entry, discovery_data): """Initialize the MQTT switch.""" self._state = False self._sub_state = None @@ -126,7 +125,7 @@ class MqttSwitch( MqttAttributes.__init__(self, config) MqttAvailability.__init__(self, config) - MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttDiscoveryUpdate.__init__(self, discovery_data, self.discovery_update) MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): @@ -203,6 +202,7 @@ class MqttSwitch( ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) @property def should_poll(self): diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py index d33a23f3a6d..b16ec7aaf74 100644 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -39,15 +39,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def async_discover(discovery_payload): """Discover and add a MQTT vacuum.""" + discovery_data = discovery_payload.discovery_data try: - discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) await _async_setup_entity( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ) except Exception: - if discovery_hash: - clear_discovery_hash(hass, discovery_hash) + clear_discovery_hash(hass, discovery_data[ATTR_DISCOVERY_HASH]) raise async_dispatcher_connect( @@ -56,10 +55,10 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_setup_entity( - config, async_add_entities, config_entry, discovery_hash=None + config, async_add_entities, config_entry, discovery_data=None ): """Set up the MQTT vacuum.""" setup_entity = {LEGACY: async_setup_entity_legacy, STATE: async_setup_entity_state} await setup_entity[config[CONF_SCHEMA]]( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ) diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index c6322d9fec5..eff7cc1b039 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -162,10 +162,10 @@ PLATFORM_SCHEMA_LEGACY = ( async def async_setup_entity_legacy( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ): """Set up a MQTT Vacuum Legacy.""" - async_add_entities([MqttVacuum(config, config_entry, discovery_hash)]) + async_add_entities([MqttVacuum(config, config_entry, discovery_data)]) class MqttVacuum( @@ -269,6 +269,7 @@ class MqttVacuum( await subscription.async_unsubscribe_topics(self.hass, self._sub_state) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) async def _subscribe_topics(self): """(Re)Subscribe to topics.""" diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 0399e66c0ad..f9bcc7e845e 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -157,10 +157,10 @@ PLATFORM_SCHEMA_STATE = ( async def async_setup_entity_state( - config, async_add_entities, config_entry, discovery_hash + config, async_add_entities, config_entry, discovery_data ): """Set up a State MQTT Vacuum.""" - async_add_entities([MqttStateVacuum(config, config_entry, discovery_hash)]) + async_add_entities([MqttStateVacuum(config, config_entry, discovery_data)]) class MqttStateVacuum( @@ -234,6 +234,7 @@ class MqttStateVacuum( await subscription.async_unsubscribe_topics(self.hass, self._sub_state) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + await MqttDiscoveryUpdate.async_will_remove_from_hass(self) async def _subscribe_topics(self): """(Re)Subscribe to topics.""" diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 49ed0f4a567..186aecd78f4 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -488,6 +488,12 @@ class Entity(ABC): self._on_remove = [] self._on_remove.append(func) + async def async_removed_from_registry(self) -> None: + """Run when entity has been removed from entity registry. + + To be extended by integrations. + """ + async def async_remove(self) -> None: """Remove entity from Home Assistant.""" assert self.hass is not None @@ -534,6 +540,9 @@ class Entity(ABC): async def _async_registry_updated(self, event): """Handle entity registry update.""" data = event.data + if data["action"] == "remove" and data["entity_id"] == self.entity_id: + await self.async_removed_from_registry() + if ( data["action"] != "update" or data.get("old_entity_id", data["entity_id"]) != self.entity_id diff --git a/tests/common.py b/tests/common.py index 5a00a2bc7df..4581c96b52a 100644 --- a/tests/common.py +++ b/tests/common.py @@ -323,11 +323,15 @@ async def async_mock_mqtt_component(hass, config=None): if config is None: config = {mqtt.CONF_BROKER: "mock-broker"} + async def _async_fire_mqtt_message(topic, payload, qos, retain): + async_fire_mqtt_message(hass, topic, payload, qos, retain) + with patch("paho.mqtt.client.Client") as mock_client: mock_client().connect.return_value = 0 mock_client().subscribe.return_value = (0, 0) mock_client().unsubscribe.return_value = (0, 0) mock_client().publish.return_value = (0, 0) + mock_client().publish.side_effect = _async_fire_mqtt_message result = await async_setup_component(hass, mqtt.DOMAIN, {mqtt.DOMAIN: config}) assert result diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index c3ba6eebadd..c9d9ec4ad08 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -468,7 +468,7 @@ async def test_if_fires_on_mqtt_message_after_update( assert len(calls) == 2 -async def test_not_fires_on_mqtt_message_after_remove( +async def test_not_fires_on_mqtt_message_after_remove_by_mqtt( hass, device_reg, calls, mqtt_mock ): """Test triggers not firing after removal.""" @@ -532,6 +532,62 @@ async def test_not_fires_on_mqtt_message_after_remove( assert len(calls) == 2 +async def test_not_fires_on_mqtt_message_after_remove_from_registry( + hass, device_reg, calls, mqtt_mock +): + """Test triggers not firing after removal.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={}) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + data1 = ( + '{ "automation_type":"trigger",' + ' "device":{"identifiers":["0AFFD2"]},' + ' "topic": "foobar/triggers/button1",' + ' "type": "button_short_press",' + ' "subtype": "button_1" }' + ) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla1/config", data1) + await hass.async_block_till_done() + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "discovery_id": "bla1", + "type": "button_short_press", + "subtype": "button_1", + }, + "action": { + "service": "test.automation", + "data_template": {"some": ("short_press")}, + }, + }, + ] + }, + ) + + # Fake short press. + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 1 + + # Remove the device + device_reg.async_remove_device(device_entry.id) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "foobar/triggers/button1", "short_press") + await hass.async_block_till_done() + assert len(calls) == 1 + + async def test_attach_remove(hass, device_reg, mqtt_mock): """Test attach and removal of trigger.""" config_entry = MockConfigEntry(domain=DOMAIN, data={}) diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index e09b4d786a6..4a28b95e32c 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -3,6 +3,8 @@ from pathlib import Path import re from unittest.mock import patch +import pytest + from homeassistant.components import mqtt from homeassistant.components.mqtt.abbreviations import ( ABBREVIATIONS, @@ -11,7 +13,25 @@ from homeassistant.components.mqtt.abbreviations import ( from homeassistant.components.mqtt.discovery import ALREADY_DISCOVERED, async_start from homeassistant.const import STATE_OFF, STATE_ON -from tests.common import MockConfigEntry, async_fire_mqtt_message, mock_coro +from tests.common import ( + MockConfigEntry, + async_fire_mqtt_message, + mock_coro, + 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) async def test_subscribing_config_topic(hass, mqtt_mock): @@ -213,6 +233,114 @@ async def test_non_duplicate_discovery(hass, mqtt_mock, caplog): assert "Component has already been discovered: binary_sensor bla" in caplog.text +async def test_removal(hass, mqtt_mock, caplog): + """Test removal of component through empty discovery message.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + + await async_start(hass, "homeassistant", {}, entry) + + async_fire_mqtt_message( + hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }' + ) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.beer") + assert state is not None + + async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "") + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.beer") + assert state is None + + +async def test_rediscover(hass, mqtt_mock, caplog): + """Test rediscover of removed component.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + + await async_start(hass, "homeassistant", {}, entry) + + async_fire_mqtt_message( + hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }' + ) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.beer") + assert state is not None + + async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "") + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.beer") + assert state is None + + async_fire_mqtt_message( + hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }' + ) + await hass.async_block_till_done() + state = hass.states.get("binary_sensor.beer") + assert state is not None + + +async def test_duplicate_removal(hass, mqtt_mock, caplog): + """Test for a non duplicate component.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + + await async_start(hass, "homeassistant", {}, entry) + + async_fire_mqtt_message( + hass, "homeassistant/binary_sensor/bla/config", '{ "name": "Beer" }' + ) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "") + await hass.async_block_till_done() + assert "Component has already been discovered: binary_sensor bla" in caplog.text + caplog.clear() + async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "") + await hass.async_block_till_done() + + assert "Component has already been discovered: binary_sensor bla" not in caplog.text + + +async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock): + """Test discvered device is cleaned up when removed from registry.""" + config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + data = ( + '{ "device":{"identifiers":["0AFFD2"]},' + ' "state_topic": "foobar/sensor",' + ' "unique_id": "unique" }' + ) + + async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) + await hass.async_block_till_done() + + # Verify device and registry entries are created + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + assert device_entry is not None + entity_entry = entity_reg.async_get("sensor.mqtt_sensor") + assert entity_entry is not None + + state = hass.states.get("sensor.mqtt_sensor") + assert state is not None + + device_reg.async_remove_device(device_entry.id) + await hass.async_block_till_done() + + # Verify device and registry entries are cleared + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + assert device_entry is None + entity_entry = entity_reg.async_get("sensor.mqtt_sensor") + assert entity_entry is None + + # Verify state is removed + state = hass.states.get("sensor.mqtt_sensor") + assert state is None + + # Verify retained discovery topic has been cleared + mqtt_mock.async_publish.assert_called_once_with( + "homeassistant/sensor/bla/config", "", 0, True + ) + + async def test_discovery_expansion(hass, mqtt_mock, caplog): """Test expansion of abbreviated discovery payload.""" entry = MockConfigEntry(domain=mqtt.DOMAIN) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index dc79cb8a2e7..5dc05a95a55 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -8,6 +8,7 @@ import pytest import voluptuous as vol from homeassistant.components import mqtt +from homeassistant.components.mqtt.discovery import async_start from homeassistant.const import ( ATTR_DOMAIN, ATTR_SERVICE, @@ -27,11 +28,25 @@ from tests.common import ( fire_mqtt_message, get_test_home_assistant, mock_coro, + mock_device_registry, mock_mqtt_component, + mock_registry, threadsafe_coroutine_factory, ) +@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 mock_MQTT(): """Make sure connection is established.""" @@ -828,3 +843,70 @@ async def test_dump_service(hass): assert len(writes) == 2 assert writes[0][1][0] == "bla/1,test1\n" assert writes[1][1][0] == "bla/2,test2\n" + + +async def test_mqtt_ws_remove_discovered_device( + hass, device_reg, entity_reg, hass_ws_client, mqtt_mock +): + """Test MQTT websocket device removal.""" + config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + data = ( + '{ "device":{"identifiers":["0AFFD2"]},' + ' "state_topic": "foobar/sensor",' + ' "unique_id": "unique" }' + ) + + async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) + await hass.async_block_till_done() + + # Verify device entry is created + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + assert device_entry is not None + + client = await hass_ws_client(hass) + await client.send_json( + {"id": 5, "type": "mqtt/device/remove", "device_id": device_entry.id} + ) + response = await client.receive_json() + assert response["success"] + + # Verify device entry is cleared + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + assert device_entry is None + + +async def test_mqtt_ws_remove_discovered_device_twice( + hass, device_reg, hass_ws_client, mqtt_mock +): + """Test MQTT websocket device removal.""" + config_entry = MockConfigEntry(domain=mqtt.DOMAIN) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + data = ( + '{ "device":{"identifiers":["0AFFD2"]},' + ' "state_topic": "foobar/sensor",' + ' "unique_id": "unique" }' + ) + + async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) + await hass.async_block_till_done() + + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + assert device_entry is not None + + client = await hass_ws_client(hass) + await client.send_json( + {"id": 5, "type": "mqtt/device/remove", "device_id": device_entry.id} + ) + response = await client.receive_json() + assert response["success"] + + await client.send_json( + {"id": 5, "type": "mqtt/device/remove", "device_id": device_entry.id} + ) + response = await client.receive_json() + assert not response["success"] From 75f465bf7ec5bdfdb6d59a6e8ffe93e2928ce7d1 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 24 Feb 2020 22:01:55 -0700 Subject: [PATCH 088/416] Remove unused RainMachine config flow function (#32165) * Remove unused RainMachine config flow function * Remove test we don't need * Code review comments * Linting --- .../components/rainmachine/__init__.py | 9 +++++---- .../components/rainmachine/config_flow.py | 12 +----------- .../components/rainmachine/test_config_flow.py | 18 ------------------ 3 files changed, 6 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 45070e40e58..4844a9e68c8 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -24,7 +24,6 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.service import verify_domain_control -from .config_flow import configured_instances from .const import ( DATA_CLIENT, DATA_PROGRAMS, @@ -118,9 +117,6 @@ async def async_setup(hass, config): conf = config[DOMAIN] for controller in conf[CONF_CONTROLLERS]: - if controller[CONF_IP_ADDRESS] in configured_instances(hass): - continue - hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=controller @@ -132,6 +128,11 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry): """Set up RainMachine as config entry.""" + if not config_entry.unique_id: + hass.config_entries.async_update_entry( + config_entry, unique_id=config_entry.data[CONF_IP_ADDRESS] + ) + _verify_domain_control = verify_domain_control(hass, DOMAIN) websession = aiohttp_client.async_get_clientsession(hass) diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py index 3fb731c5856..ffa46cc2c15 100644 --- a/homeassistant/components/rainmachine/config_flow.py +++ b/homeassistant/components/rainmachine/config_flow.py @@ -5,19 +5,9 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT -from homeassistant.core import callback from homeassistant.helpers import aiohttp_client -from .const import DEFAULT_PORT, DOMAIN - - -@callback -def configured_instances(hass): - """Return a set of configured RainMachine instances.""" - return set( - entry.data[CONF_IP_ADDRESS] - for entry in hass.config_entries.async_entries(DOMAIN) - ) +from .const import DEFAULT_PORT, DOMAIN # pylint: disable=unused-import class RainMachineFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 379532c8f50..38dafdda986 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -29,8 +29,6 @@ async def test_duplicate_error(hass): MockConfigEntry(domain=DOMAIN, unique_id="192.168.1.100", data=conf).add_to_hass( hass ) - flow = config_flow.RainMachineFlowHandler() - flow.hass = hass result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf @@ -39,22 +37,6 @@ async def test_duplicate_error(hass): assert result["reason"] == "already_configured" -async def test_get_configured_instances(hass): - """Test retrieving all configured instances.""" - conf = { - CONF_IP_ADDRESS: "192.168.1.100", - CONF_PASSWORD: "password", - CONF_PORT: 8080, - CONF_SSL: True, - } - - MockConfigEntry(domain=DOMAIN, unique_id="192.168.1.100", data=conf).add_to_hass( - hass - ) - - assert len(config_flow.configured_instances(hass)) == 1 - - async def test_invalid_password(hass): """Test that an invalid password throws an error.""" conf = { From 7c2765fbff233c3184850a98f3e9adf76629a060 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 24 Feb 2020 22:02:20 -0700 Subject: [PATCH 089/416] Remove unused SimpliSafe config flow function (#32166) * Remove unused SimpliSafe config flow function * Code review comments from other PR * Linting --- homeassistant/components/simplisafe/__init__.py | 9 +++++---- .../components/simplisafe/config_flow.py | 11 +---------- tests/components/simplisafe/test_config_flow.py | 15 ++------------- 3 files changed, 8 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 71cfe077d5f..f9c61b6add2 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -40,7 +40,6 @@ from homeassistant.helpers.service import ( verify_domain_control, ) -from .config_flow import configured_instances from .const import ( ATTR_ALARM_DURATION, ATTR_ALARM_VOLUME, @@ -182,9 +181,6 @@ async def async_setup(hass, config): conf = config[DOMAIN] for account in conf[CONF_ACCOUNTS]: - if account[CONF_USERNAME] in configured_instances(hass): - continue - hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, @@ -202,6 +198,11 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry): """Set up SimpliSafe as config entry.""" + if not config_entry.unique_id: + hass.config_entries.async_update_entry( + config_entry, unique_id=config_entry.data[CONF_USERNAME] + ) + _verify_domain_control = verify_domain_control(hass, DOMAIN) websession = aiohttp_client.async_get_clientsession(hass) diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 5c7c6d7d450..4963f9d2de1 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -5,18 +5,9 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME -from homeassistant.core import callback from homeassistant.helpers import aiohttp_client -from .const import DOMAIN - - -@callback -def configured_instances(hass): - """Return a set of configured SimpliSafe instances.""" - return set( - entry.data[CONF_USERNAME] for entry in hass.config_entries.async_entries(DOMAIN) - ) +from .const import DOMAIN # pylint: disable=unused-import class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py index eebb437d137..496c6d88954 100644 --- a/tests/components/simplisafe/test_config_flow.py +++ b/tests/components/simplisafe/test_config_flow.py @@ -2,6 +2,8 @@ import json from unittest.mock import MagicMock, PropertyMock, mock_open, patch +from simplipy.errors import SimplipyError + from homeassistant import data_entry_flow from homeassistant.components.simplisafe import DOMAIN, config_flow from homeassistant.config_entries import SOURCE_USER @@ -33,21 +35,8 @@ async def test_duplicate_error(hass): assert result["reason"] == "already_configured" -async def test_get_configured_instances(hass): - """Test retrieving all configured instances.""" - conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} - - MockConfigEntry(domain=DOMAIN, unique_id="user@email.com", data=conf).add_to_hass( - hass - ) - - assert len(config_flow.configured_instances(hass)) == 1 - - async def test_invalid_credentials(hass): """Test that invalid credentials throws an error.""" - from simplipy.errors import SimplipyError - conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} flow = config_flow.SimpliSafeFlowHandler() From 5776b9f17d7efdd2e244ed52e530992cee6d7c45 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 24 Feb 2020 21:27:08 -0800 Subject: [PATCH 090/416] Fix flaky coverage in UK transport test (#32053) --- tests/components/uk_transport/test_sensor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/components/uk_transport/test_sensor.py b/tests/components/uk_transport/test_sensor.py index ce568a64b95..4979efc22dc 100644 --- a/tests/components/uk_transport/test_sensor.py +++ b/tests/components/uk_transport/test_sensor.py @@ -2,6 +2,7 @@ import re import unittest +from asynctest import patch import requests_mock from homeassistant.components.uk_transport.sensor import ( @@ -17,6 +18,7 @@ from homeassistant.components.uk_transport.sensor import ( UkTransportSensor, ) from homeassistant.setup import setup_component +from homeassistant.util.dt import now from tests.common import get_test_home_assistant, load_fixture @@ -77,7 +79,9 @@ class TestUkTransportSensor(unittest.TestCase): @requests_mock.Mocker() def test_train(self, mock_req): """Test for operational uk_transport sensor with proper attributes.""" - with requests_mock.Mocker() as mock_req: + with requests_mock.Mocker() as mock_req, patch( + "homeassistant.util.dt.now", return_value=now().replace(hour=13) + ): uri = re.compile(UkTransportSensor.TRANSPORT_API_URL_BASE + "*") mock_req.get(uri, text=load_fixture("uk_transport_train.json")) assert setup_component(self.hass, "sensor", {"sensor": self.config}) From 0e6a48c68882f338c361a50090fe995204d01880 Mon Sep 17 00:00:00 2001 From: paolog89 Date: Tue, 25 Feb 2020 06:27:59 +0100 Subject: [PATCH 091/416] Add observed entities to bayesian sensor (#27721) * Add observed entities to bayesian sensor * Update binary_sensor.py to comply with style guidelines and test_binary_sensor.py to verify the entity_id * Update binary_sensor.py and test_binary_sensor.py to include an additional attribute for observed entities * Use of ATTR_ENTITY_ID and numeric key of observed entity * Update binary_sensor.py * Update test_binary_sensor.py to verify behavior * Update to return a list without duplicates in the state attribute * Update binary_sensor.py: rename of ATTR_ENTITY_ID into ATTR_OBSERVED_ENTITIES * Rename new attribute into ATTR_OCCURRED_OBSERVATION_ENTITIES and fix test --- .../components/bayesian/binary_sensor.py | 18 +++++++ .../components/bayesian/test_binary_sensor.py | 51 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py index 1d3720f6723..c2e9e220a20 100644 --- a/homeassistant/components/bayesian/binary_sensor.py +++ b/homeassistant/components/bayesian/binary_sensor.py @@ -1,5 +1,6 @@ """Use Bayesian Inference to trigger a binary sensor.""" from collections import OrderedDict +from itertools import chain import voluptuous as vol @@ -21,6 +22,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_state_change ATTR_OBSERVATIONS = "observations" +ATTR_OCCURRED_OBSERVATION_ENTITIES = "occurred_observation_entities" ATTR_PROBABILITY = "probability" ATTR_PROBABILITY_THRESHOLD = "probability_threshold" @@ -126,6 +128,15 @@ class BayesianBinarySensor(BinarySensorDevice): self.probability = prior self.current_obs = OrderedDict({}) + self.entity_obs_dict = [] + + for obs in self._observations: + if "entity_id" in obs: + self.entity_obs_dict.append([obs.get("entity_id")]) + if "value_template" in obs: + self.entity_obs_dict.append( + list(obs.get(CONF_VALUE_TEMPLATE).extract_entities()) + ) to_observe = set() for obs in self._observations: @@ -251,6 +262,13 @@ class BayesianBinarySensor(BinarySensorDevice): """Return the state attributes of the sensor.""" return { ATTR_OBSERVATIONS: list(self.current_obs.values()), + ATTR_OCCURRED_OBSERVATION_ENTITIES: list( + set( + chain.from_iterable( + self.entity_obs_dict[obs] for obs in self.current_obs.keys() + ) + ) + ), ATTR_PROBABILITY: round(self.probability, 2), ATTR_PROBABILITY_THRESHOLD: self._probability_threshold, } diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py index c8a23517ae1..fb9bc7d5e5c 100644 --- a/tests/components/bayesian/test_binary_sensor.py +++ b/tests/components/bayesian/test_binary_sensor.py @@ -259,3 +259,54 @@ class TestBayesianBinarySensor(unittest.TestCase): prior = bayesian.update_probability(prior, pt, pf) assert round(abs(0.9130434782608695 - prior), 7) == 0 + + def test_observed_entities(self): + """Test sensor on observed entities.""" + config = { + "binary_sensor": { + "name": "Test_Binary", + "platform": "bayesian", + "observations": [ + { + "platform": "state", + "entity_id": "sensor.test_monitored", + "to_state": "off", + "prob_given_true": 0.8, + "prob_given_false": 0.4, + }, + { + "platform": "template", + "value_template": "{{is_state('sensor.test_monitored1','on') and is_state('sensor.test_monitored','off')}}", + "prob_given_true": 0.9, + }, + ], + "prior": 0.2, + "probability_threshold": 0.32, + } + } + + assert setup_component(self.hass, "binary_sensor", config) + + self.hass.states.set("sensor.test_monitored", "on") + self.hass.block_till_done() + self.hass.states.set("sensor.test_monitored1", "off") + self.hass.block_till_done() + + state = self.hass.states.get("binary_sensor.test_binary") + assert [] == state.attributes.get("occurred_observation_entities") + + self.hass.states.set("sensor.test_monitored", "off") + self.hass.block_till_done() + + state = self.hass.states.get("binary_sensor.test_binary") + assert ["sensor.test_monitored"] == state.attributes.get( + "occurred_observation_entities" + ) + + self.hass.states.set("sensor.test_monitored1", "on") + self.hass.block_till_done() + + state = self.hass.states.get("binary_sensor.test_binary") + assert ["sensor.test_monitored", "sensor.test_monitored1"] == sorted( + state.attributes.get("occurred_observation_entities") + ) From c97b1c60b06b40017b6bdce7b7371ede10bc571a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 24 Feb 2020 22:36:58 -0700 Subject: [PATCH 092/416] Modernize Notion config flow (#32167) * Modernize Notion config flow * Linting --- .coveragerc | 1 + .../components/notion/.translations/en.json | 4 ++- homeassistant/components/notion/__init__.py | 9 +++--- .../components/notion/config_flow.py | 29 +++++++------------ homeassistant/components/notion/strings.json | 4 ++- tests/components/notion/test_config_flow.py | 19 ++++++++---- 6 files changed, 36 insertions(+), 30 deletions(-) diff --git a/.coveragerc b/.coveragerc index 6dcd20f6ad6..fe5242327dc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -480,6 +480,7 @@ omit = homeassistant/components/nissan_leaf/* homeassistant/components/nmap_tracker/device_tracker.py homeassistant/components/nmbs/sensor.py + homeassistant/components/notion/__init__.py homeassistant/components/notion/binary_sensor.py homeassistant/components/notion/sensor.py homeassistant/components/noaa_tides/sensor.py diff --git a/homeassistant/components/notion/.translations/en.json b/homeassistant/components/notion/.translations/en.json index b05f613a73f..b729b368c37 100644 --- a/homeassistant/components/notion/.translations/en.json +++ b/homeassistant/components/notion/.translations/en.json @@ -1,7 +1,9 @@ { "config": { + "abort": { + "already_configured": "This username is already in use." + }, "error": { - "identifier_exists": "Username already registered", "invalid_credentials": "Invalid username or password", "no_devices": "No devices found in account" }, diff --git a/homeassistant/components/notion/__init__.py b/homeassistant/components/notion/__init__.py index 1e04c4a8e8e..f387e820253 100644 --- a/homeassistant/components/notion/__init__.py +++ b/homeassistant/components/notion/__init__.py @@ -22,7 +22,6 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_time_interval -from .config_flow import configured_instances from .const import DATA_CLIENT, DEFAULT_SCAN_INTERVAL, DOMAIN, TOPIC_DATA_UPDATE _LOGGER = logging.getLogger(__name__) @@ -84,9 +83,6 @@ async def async_setup(hass, config): conf = config[DOMAIN] - if conf[CONF_USERNAME] in configured_instances(hass): - return True - hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, @@ -103,6 +99,11 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry): """Set up Notion as a config entry.""" + if not config_entry.unique_id: + hass.config_entries.async_update_entry( + config_entry, unique_id=config_entry.data[CONF_USERNAME] + ) + session = aiohttp_client.async_get_clientsession(hass) try: diff --git a/homeassistant/components/notion/config_flow.py b/homeassistant/components/notion/config_flow.py index 2af231d582e..58c5c0d44ee 100644 --- a/homeassistant/components/notion/config_flow.py +++ b/homeassistant/components/notion/config_flow.py @@ -5,35 +5,27 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import callback from homeassistant.helpers import aiohttp_client -from .const import DOMAIN +from .const import DOMAIN # pylint: disable=unused-import -@callback -def configured_instances(hass): - """Return a set of configured Notion instances.""" - return set( - entry.data[CONF_USERNAME] for entry in hass.config_entries.async_entries(DOMAIN) - ) - - -@config_entries.HANDLERS.register(DOMAIN) -class NotionFlowHandler(config_entries.ConfigFlow): +class NotionFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a Notion config flow.""" VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - async def _show_form(self, errors=None): - """Show the form to the user.""" - data_schema = vol.Schema( + def __init__(self): + """Initialize the config flow.""" + self.data_schema = vol.Schema( {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} ) + async def _show_form(self, errors=None): + """Show the form to the user.""" return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors or {} + step_id="user", data_schema=self.data_schema, errors=errors or {} ) async def async_step_import(self, import_config): @@ -42,12 +34,11 @@ class NotionFlowHandler(config_entries.ConfigFlow): async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" - if not user_input: return await self._show_form() - if user_input[CONF_USERNAME] in configured_instances(self.hass): - return await self._show_form({CONF_USERNAME: "identifier_exists"}) + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() session = aiohttp_client.async_get_clientsession(self.hass) diff --git a/homeassistant/components/notion/strings.json b/homeassistant/components/notion/strings.json index 8825e25bfe8..fa47c2819ba 100644 --- a/homeassistant/components/notion/strings.json +++ b/homeassistant/components/notion/strings.json @@ -11,9 +11,11 @@ } }, "error": { - "identifier_exists": "Username already registered", "invalid_credentials": "Invalid username or password", "no_devices": "No devices found in account" + }, + "abort": { + "already_configured": "This username is already in use." } } } diff --git a/tests/components/notion/test_config_flow.py b/tests/components/notion/test_config_flow.py index f7651a570cf..60ca4c07fb5 100644 --- a/tests/components/notion/test_config_flow.py +++ b/tests/components/notion/test_config_flow.py @@ -6,6 +6,7 @@ import pytest from homeassistant import data_entry_flow from homeassistant.components.notion import DOMAIN, config_flow +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from tests.common import MockConfigEntry, mock_coro @@ -29,12 +30,16 @@ async def test_duplicate_error(hass): """Test that errors are shown when duplicates are added.""" conf = {CONF_USERNAME: "user@host.com", CONF_PASSWORD: "password123"} - MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) - flow = config_flow.NotionFlowHandler() - flow.hass = hass + MockConfigEntry(domain=DOMAIN, unique_id="user@host.com", data=conf).add_to_hass( + hass + ) - result = await flow.async_step_user(user_input=conf) - assert result["errors"] == {CONF_USERNAME: "identifier_exists"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" @pytest.mark.parametrize( @@ -46,6 +51,7 @@ async def test_invalid_credentials(hass, mock_aionotion): flow = config_flow.NotionFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} result = await flow.async_step_user(user_input=conf) assert result["errors"] == {"base": "invalid_credentials"} @@ -55,6 +61,7 @@ async def test_show_form(hass): """Test that the form is served with no input.""" flow = config_flow.NotionFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} result = await flow.async_step_user(user_input=None) @@ -68,6 +75,7 @@ async def test_step_import(hass, mock_aionotion): flow = config_flow.NotionFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} result = await flow.async_step_import(import_config=conf) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -84,6 +92,7 @@ async def test_step_user(hass, mock_aionotion): flow = config_flow.NotionFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} result = await flow.async_step_user(user_input=conf) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY From ba4cc373c8696cc25b7e6084c6026944eb7299c0 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 24 Feb 2020 22:37:38 -0700 Subject: [PATCH 093/416] Modernize Ambient PWS config flow (#32164) * Modernize Ambient PWS config flow * Linting --- .../ambient_station/.translations/en.json | 4 ++- .../components/ambient_station/__init__.py | 9 +++--- .../components/ambient_station/config_flow.py | 31 +++++++------------ .../components/ambient_station/manifest.json | 2 +- .../components/ambient_station/strings.json | 4 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../ambient_station/test_config_flow.py | 20 +++++++++--- 8 files changed, 41 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/ambient_station/.translations/en.json b/homeassistant/components/ambient_station/.translations/en.json index 5bd643da55c..c3e2a40ab13 100644 --- a/homeassistant/components/ambient_station/.translations/en.json +++ b/homeassistant/components/ambient_station/.translations/en.json @@ -1,7 +1,9 @@ { "config": { + "abort": { + "already_configured": "This app key is already in use." + }, "error": { - "identifier_exists": "Application Key and/or API Key already registered", "invalid_key": "Invalid API Key and/or Application Key", "no_devices": "No devices found in account" }, diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 4d068b2d0d8..63c00b05038 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -25,7 +25,6 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_call_later -from .config_flow import configured_instances from .const import ( ATTR_LAST_DATA, ATTR_MONITORED_CONDITIONS, @@ -254,9 +253,6 @@ async def async_setup(hass, config): # Store config for use during entry setup: hass.data[DOMAIN][DATA_CONFIG] = conf - if conf[CONF_APP_KEY] in configured_instances(hass): - return True - hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, @@ -270,6 +266,11 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry): """Set up the Ambient PWS as config entry.""" + if not config_entry.unique_id: + hass.config_entries.async_update_entry( + config_entry, unique_id=config_entry.data[CONF_APP_KEY] + ) + session = aiohttp_client.async_get_clientsession(hass) try: diff --git a/homeassistant/components/ambient_station/config_flow.py b/homeassistant/components/ambient_station/config_flow.py index c20b43598ca..c363a2839fb 100644 --- a/homeassistant/components/ambient_station/config_flow.py +++ b/homeassistant/components/ambient_station/config_flow.py @@ -5,35 +5,29 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.const import CONF_API_KEY -from homeassistant.core import callback from homeassistant.helpers import aiohttp_client -from .const import CONF_APP_KEY, DOMAIN +from .const import CONF_APP_KEY, DOMAIN # pylint: disable=unused-import -@callback -def configured_instances(hass): - """Return a set of configured Ambient PWS instances.""" - return set( - entry.data[CONF_APP_KEY] for entry in hass.config_entries.async_entries(DOMAIN) - ) - - -@config_entries.HANDLERS.register(DOMAIN) -class AmbientStationFlowHandler(config_entries.ConfigFlow): +class AmbientStationFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle an Ambient PWS config flow.""" VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH - async def _show_form(self, errors=None): - """Show the form to the user.""" - data_schema = vol.Schema( + def __init__(self): + """Initialize the config flow.""" + self.data_schema = vol.Schema( {vol.Required(CONF_API_KEY): str, vol.Required(CONF_APP_KEY): str} ) + async def _show_form(self, errors=None): + """Show the form to the user.""" return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors if errors else {} + step_id="user", + data_schema=self.data_schema, + errors=errors if errors else {}, ) async def async_step_import(self, import_config): @@ -42,12 +36,11 @@ class AmbientStationFlowHandler(config_entries.ConfigFlow): async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" - if not user_input: return await self._show_form() - if user_input[CONF_APP_KEY] in configured_instances(self.hass): - return await self._show_form({CONF_APP_KEY: "identifier_exists"}) + await self.async_set_unique_id(user_input[CONF_APP_KEY]) + self._abort_if_unique_id_configured() session = aiohttp_client.async_get_clientsession(self.hass) client = Client(user_input[CONF_API_KEY], user_input[CONF_APP_KEY], session) diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json index 25f60f63abf..a6572070a5e 100644 --- a/homeassistant/components/ambient_station/manifest.json +++ b/homeassistant/components/ambient_station/manifest.json @@ -3,7 +3,7 @@ "name": "Ambient Weather Station", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ambient_station", - "requirements": ["aioambient==1.0.2"], + "requirements": ["aioambient==1.0.4"], "dependencies": [], "codeowners": ["@bachya"] } diff --git a/homeassistant/components/ambient_station/strings.json b/homeassistant/components/ambient_station/strings.json index 657b3477bb2..3cfe36b3220 100644 --- a/homeassistant/components/ambient_station/strings.json +++ b/homeassistant/components/ambient_station/strings.json @@ -11,9 +11,11 @@ } }, "error": { - "identifier_exists": "Application Key and/or API Key already registered", "invalid_key": "Invalid API Key and/or Application Key", "no_devices": "No devices found in account" + }, + "abort": { + "already_configured": "This app key is already in use." } } } diff --git a/requirements_all.txt b/requirements_all.txt index ecc4d8d82a0..112d8c15cce 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -136,7 +136,7 @@ aio_geojson_nsw_rfs_incidents==0.3 aio_georss_gdacs==0.3 # homeassistant.components.ambient_station -aioambient==1.0.2 +aioambient==1.0.4 # homeassistant.components.asuswrt aioasuswrt==1.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cfb9afdff5c..34745f8e253 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -47,7 +47,7 @@ aio_geojson_nsw_rfs_incidents==0.3 aio_georss_gdacs==0.3 # homeassistant.components.ambient_station -aioambient==1.0.2 +aioambient==1.0.4 # homeassistant.components.asuswrt aioasuswrt==1.2.2 diff --git a/tests/components/ambient_station/test_config_flow.py b/tests/components/ambient_station/test_config_flow.py index 25e46090009..a64b7761338 100644 --- a/tests/components/ambient_station/test_config_flow.py +++ b/tests/components/ambient_station/test_config_flow.py @@ -7,6 +7,7 @@ import pytest from homeassistant import data_entry_flow from homeassistant.components.ambient_station import CONF_APP_KEY, DOMAIN, config_flow +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_API_KEY from tests.common import MockConfigEntry, load_fixture, mock_coro @@ -30,12 +31,16 @@ async def test_duplicate_error(hass): """Test that errors are shown when duplicates are added.""" conf = {CONF_API_KEY: "12345abcde12345abcde", CONF_APP_KEY: "67890fghij67890fghij"} - MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) - flow = config_flow.AmbientStationFlowHandler() - flow.hass = hass + MockConfigEntry( + domain=DOMAIN, unique_id="67890fghij67890fghij", data=conf + ).add_to_hass(hass) - result = await flow.async_step_user(user_input=conf) - assert result["errors"] == {CONF_APP_KEY: "identifier_exists"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" @pytest.mark.parametrize( @@ -47,6 +52,7 @@ async def test_invalid_api_key(hass, mock_aioambient): flow = config_flow.AmbientStationFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} result = await flow.async_step_user(user_input=conf) assert result["errors"] == {"base": "invalid_key"} @@ -59,6 +65,7 @@ async def test_no_devices(hass, mock_aioambient): flow = config_flow.AmbientStationFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} result = await flow.async_step_user(user_input=conf) assert result["errors"] == {"base": "no_devices"} @@ -68,6 +75,7 @@ async def test_show_form(hass): """Test that the form is served with no input.""" flow = config_flow.AmbientStationFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} result = await flow.async_step_user(user_input=None) @@ -85,6 +93,7 @@ async def test_step_import(hass, mock_aioambient): flow = config_flow.AmbientStationFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} result = await flow.async_step_import(import_config=conf) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -105,6 +114,7 @@ async def test_step_user(hass, mock_aioambient): flow = config_flow.AmbientStationFlowHandler() flow.hass = hass + flow.context = {"source": SOURCE_USER} result = await flow.async_step_user(user_input=conf) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY From c9d78aa78cd6e3e70b6e8e56e5d4ae7386415dbf Mon Sep 17 00:00:00 2001 From: Jc2k Date: Tue, 25 Feb 2020 11:06:35 +0000 Subject: [PATCH 094/416] Refactor homekit_controller config flow tests (#32141) * Config flow test refactor * Add a service and characteristic to the accessory so its more realistic * Feedback from review * Missing apostrophe --- .../homekit_controller/config_flow.py | 31 +- .../homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/homekit_controller/conftest.py | 10 + .../homekit_controller/test_config_flow.py | 883 ++++++------------ 6 files changed, 296 insertions(+), 634 deletions(-) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index c4101140aaf..4b713636beb 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -5,7 +5,6 @@ import os import re import aiohomekit -from aiohomekit import Controller from aiohomekit.controller.ip import IpPairing import voluptuous as vol @@ -59,7 +58,7 @@ def normalize_hkid(hkid): def find_existing_host(hass, serial): """Return a set of the configured hosts.""" for entry in hass.config_entries.async_entries(DOMAIN): - if entry.data["AccessoryPairingID"] == serial: + if entry.data.get("AccessoryPairingID") == serial: return entry @@ -89,7 +88,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): self.model = None self.hkid = None self.devices = {} - self.controller = Controller() + self.controller = aiohomekit.Controller() self.finish_pairing = None async def async_step_user(self, user_input=None): @@ -203,6 +202,14 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): _LOGGER.debug("Discovered device %s (%s - %s)", name, model, hkid) + # Device isn't paired with us or anyone else. + # But we have a 'complete' config entry for it - that is probably + # invalid. Remove it automatically. + existing = find_existing_host(self.hass, hkid) + if not paired and existing: + await self.hass.config_entries.async_remove(existing.entry_id) + + # Set unique-id and error out if it's already configured await self.async_set_unique_id(normalize_hkid(hkid)) self._abort_if_unique_id_configured() @@ -230,13 +237,6 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): if model in HOMEKIT_IGNORE: return self.async_abort(reason="ignored_model") - # Device isn't paired with us or anyone else. - # But we have a 'complete' config entry for it - that is probably - # invalid. Remove it automatically. - existing = find_existing_host(self.hass, hkid) - if existing: - await self.hass.config_entries.async_remove(existing.entry_id) - self.model = model self.hkid = hkid @@ -250,17 +250,6 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): hkid = discovery_props["id"] - existing = find_existing_host(self.hass, hkid) - if existing: - _LOGGER.info( - ( - "Legacy configuration for homekit accessory %s" - "not loaded as already migrated" - ), - hkid, - ) - return self.async_abort(reason="already_configured") - _LOGGER.info( ( "Legacy configuration %s for homekit" diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 618b6274253..cd2d0c67b44 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit[IP]==0.2.10"], + "requirements": ["aiohomekit[IP]==0.2.11"], "dependencies": [], "zeroconf": ["_hap._tcp.local."], "codeowners": ["@Jc2k"] diff --git a/requirements_all.txt b/requirements_all.txt index 112d8c15cce..edb4564aa72 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -163,7 +163,7 @@ aioftp==0.12.0 aioharmony==0.1.13 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.10 +aiohomekit[IP]==0.2.11 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34745f8e253..15f1a1d82a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -62,7 +62,7 @@ aiobotocore==0.11.1 aioesphomeapi==2.6.1 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.10 +aiohomekit[IP]==0.2.11 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py index cca272be062..99e86335cdb 100644 --- a/tests/components/homekit_controller/conftest.py +++ b/tests/components/homekit_controller/conftest.py @@ -2,6 +2,8 @@ import datetime from unittest import mock +from aiohomekit.testing import FakeController +import asynctest import pytest @@ -12,3 +14,11 @@ def utcnow(request): with mock.patch("homeassistant.util.dt.utcnow") as dt_utcnow: dt_utcnow.return_value = start_dt yield dt_utcnow + + +@pytest.fixture +def controller(hass): + """Replace aiohomekit.Controller with an instance of aiohomekit.testing.FakeController.""" + instance = FakeController() + with asynctest.patch("aiohomekit.Controller", return_value=instance): + yield instance diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 144215719dd..e02bc045b3e 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -3,16 +3,17 @@ import json from unittest import mock import aiohomekit +from aiohomekit.model import Accessories, Accessory from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes import asynctest +from asynctest import patch import pytest from homeassistant.components.homekit_controller import config_flow -from homeassistant.components.homekit_controller.const import KNOWN_DEVICES from tests.common import MockConfigEntry -from tests.components.homekit_controller.common import Accessory, setup_platform +from tests.components.homekit_controller.common import setup_platform PAIRING_START_FORM_ERRORS = [ (aiohomekit.BusyError, "busy_error"), @@ -79,13 +80,6 @@ def _setup_flow_handler(hass, pairing=None): return flow -async def _setup_flow_zeroconf(hass, discovery_info): - result = await hass.config_entries.flow.async_init( - "homekit_controller", context={"source": "zeroconf"}, data=discovery_info - ) - return result - - @pytest.mark.parametrize("pairing_code", INVALID_PAIRING_CODES) def test_invalid_pairing_codes(pairing_code): """Test ensure_pin_format raises for an invalid pin code.""" @@ -103,288 +97,174 @@ def test_valid_pairing_codes(pairing_code): assert len(valid_pin[2]) == 3 -async def test_discovery_works(hass): - """Test a device being discovered.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1}, - } - - flow = _setup_flow_handler(hass) - - finish_pairing = asynctest.CoroutineMock() - - discovery = mock.Mock() - discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing) - - flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( - return_value=discovery +def get_flow_context(hass, result): + """Get the flow context from the result of async_init or async_configure.""" + flow = next( + ( + flow + for flow in hass.config_entries.flow.async_progress() + if flow["flow_id"] == result["flow_id"] + ) ) + return flow["context"] + + +def get_device_discovery_info(device, upper_case_props=False, missing_csharp=False): + """Turn a aiohomekit format zeroconf entry into a homeassistant one.""" + record = device.info + result = { + "host": record["address"], + "port": record["port"], + "hostname": record["name"], + "type": "_hap._tcp.local.", + "name": record["name"], + "properties": { + "md": record["md"], + "pv": record["pv"], + "id": device.device_id, + "c#": record["c#"], + "s#": record["s#"], + "ff": record["ff"], + "ci": record["ci"], + "sf": 0x01, # record["sf"], + "sh": "", + }, + } + + if missing_csharp: + del result["properties"]["c#"] + + if upper_case_props: + result["properties"] = { + key.upper(): val for (key, val) in result["properties"].items() + } + + return result + + +def setup_mock_accessory(controller): + """Add a bridge accessory to a test controller.""" + bridge = Accessories() + + accessory = Accessory( + name="Koogeek-LS1-20833F", + manufacturer="Koogeek", + model="LS1", + serial_number="12345", + firmware_revision="1.1", + ) + + service = accessory.add_service(ServicesTypes.LIGHTBULB) + on_char = service.add_char(CharacteristicsTypes.ON) + on_char.value = 0 + + bridge.add_accessory(accessory) + + return controller.add_device(bridge) + + +@pytest.mark.parametrize("upper_case_props", [True, False]) +@pytest.mark.parametrize("missing_csharp", [True, False]) +async def test_discovery_works(hass, controller, upper_case_props, missing_csharp): + """Test a device being discovered.""" + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device, upper_case_props, missing_csharp) + # Device is discovered - result = await flow.async_step_zeroconf(discovery_info) + result = await hass.config_entries.flow.async_init( + "homekit_controller", context={"source": "zeroconf"}, data=discovery_info + ) assert result["type"] == "form" assert result["step_id"] == "pair" - assert flow.context == { + assert get_flow_context(hass, result) == { "hkid": "00:00:00:00:00:00", + "source": "zeroconf", "title_placeholders": {"name": "TestDevice"}, "unique_id": "00:00:00:00:00:00", } # User initiates pairing - device enters pairing mode and displays code - result = await flow.async_step_pair({}) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == "form" assert result["step_id"] == "pair" - assert discovery.start_pairing.call_count == 1 - - pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"}) - - pairing.list_accessories_and_characteristics = asynctest.CoroutineMock( - return_value=[ - { - "aid": 1, - "services": [ - { - "characteristics": [ - {"type": "23", "value": "Koogeek-LS1-20833F"} - ], - "type": "3e", - } - ], - } - ] - ) - - finish_pairing.return_value = pairing # Pairing doesn't error error and pairing results - flow.controller.pairings = {"00:00:00:00:00:00": pairing} - result = await flow.async_step_pair({"pairing_code": "111-22-333"}) + 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" - assert result["data"] == pairing.pairing_data + assert result["data"] == {} -async def test_discovery_works_upper_case(hass): - """Test a device being discovered.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"MD": "TestDevice", "ID": "00:00:00:00:00:00", "C#": 1, "SF": 1}, - } - - flow = _setup_flow_handler(hass) - - finish_pairing = asynctest.CoroutineMock() - - discovery = mock.Mock() - discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing) - - flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( - return_value=discovery - ) - - # Device is discovered - result = await flow.async_step_zeroconf(discovery_info) - assert result["type"] == "form" - assert result["step_id"] == "pair" - assert flow.context == { - "hkid": "00:00:00:00:00:00", - "title_placeholders": {"name": "TestDevice"}, - "unique_id": "00:00:00:00:00:00", - } - - # User initiates pairing - device enters pairing mode and displays code - result = await flow.async_step_pair({}) - assert result["type"] == "form" - assert result["step_id"] == "pair" - assert discovery.start_pairing.call_count == 1 - - pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"}) - - pairing.list_accessories_and_characteristics = asynctest.CoroutineMock( - return_value=[ - { - "aid": 1, - "services": [ - { - "characteristics": [ - {"type": "23", "value": "Koogeek-LS1-20833F"} - ], - "type": "3e", - } - ], - } - ] - ) - - finish_pairing.return_value = pairing - - flow.controller.pairings = {"00:00:00:00:00:00": pairing} - result = await flow.async_step_pair({"pairing_code": "111-22-333"}) - assert result["type"] == "create_entry" - assert result["title"] == "Koogeek-LS1-20833F" - assert result["data"] == pairing.pairing_data - - -async def test_discovery_works_missing_csharp(hass): - """Test a device being discovered that has missing mdns attrs.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "sf": 1}, - } - - flow = _setup_flow_handler(hass) - - finish_pairing = asynctest.CoroutineMock() - - discovery = mock.Mock() - discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing) - - flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( - return_value=discovery - ) - - # Device is discovered - result = await flow.async_step_zeroconf(discovery_info) - assert result["type"] == "form" - assert result["step_id"] == "pair" - assert flow.context == { - "hkid": "00:00:00:00:00:00", - "title_placeholders": {"name": "TestDevice"}, - "unique_id": "00:00:00:00:00:00", - } - - # User initiates pairing - device enters pairing mode and displays code - result = await flow.async_step_pair({}) - assert result["type"] == "form" - assert result["step_id"] == "pair" - assert discovery.start_pairing.call_count == 1 - - pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"}) - - pairing.list_accessories_and_characteristics = asynctest.CoroutineMock( - return_value=[ - { - "aid": 1, - "services": [ - { - "characteristics": [ - {"type": "23", "value": "Koogeek-LS1-20833F"} - ], - "type": "3e", - } - ], - } - ] - ) - - finish_pairing.return_value = pairing - - flow.controller.pairings = {"00:00:00:00:00:00": pairing} - - result = await flow.async_step_pair({"pairing_code": "111-22-333"}) - assert result["type"] == "create_entry" - assert result["title"] == "Koogeek-LS1-20833F" - assert result["data"] == pairing.pairing_data - - -async def test_abort_duplicate_flow(hass): +async def test_abort_duplicate_flow(hass, controller): """Already paired.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1}, - } + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) - result = await _setup_flow_zeroconf(hass, discovery_info) + # Device is discovered + result = await hass.config_entries.flow.async_init( + "homekit_controller", context={"source": "zeroconf"}, data=discovery_info + ) assert result["type"] == "form" assert result["step_id"] == "pair" - result = await _setup_flow_zeroconf(hass, discovery_info) + result = await hass.config_entries.flow.async_init( + "homekit_controller", context={"source": "zeroconf"}, data=discovery_info + ) assert result["type"] == "abort" assert result["reason"] == "already_in_progress" -async def test_pair_already_paired_1(hass): +async def test_pair_already_paired_1(hass, controller): """Already paired.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 0}, - } + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) - flow = _setup_flow_handler(hass) + # Flag device as already paired + discovery_info["properties"]["sf"] = 0x0 - result = await flow.async_step_zeroconf(discovery_info) + # 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"] == "already_paired" - assert flow.context == { - "hkid": "00:00:00:00:00:00", - "title_placeholders": {"name": "TestDevice"}, - "unique_id": "00:00:00:00:00:00", - } -async def test_discovery_ignored_model(hass): +async def test_discovery_ignored_model(hass, controller): """Already paired.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": { - "md": config_flow.HOMEKIT_IGNORE[0], - "id": "00:00:00:00:00:00", - "c#": 1, - "sf": 1, - }, - } + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) + discovery_info["properties"]["md"] = config_flow.HOMEKIT_IGNORE[0] - flow = _setup_flow_handler(hass) - - result = await flow.async_step_zeroconf(discovery_info) + # 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"] == "ignored_model" - assert flow.context == { - "hkid": "00:00:00:00:00:00", - "title_placeholders": {"name": "TestDevice"}, - "unique_id": "00:00:00:00:00:00", - } -async def test_discovery_invalid_config_entry(hass): - """There is already a config entry for the pairing id but its invalid.""" +async def test_discovery_invalid_config_entry(hass, controller): + """There is already a config entry for the pairing id but it's invalid.""" MockConfigEntry( - domain="homekit_controller", data={"AccessoryPairingID": "00:00:00:00:00:00"} + domain="homekit_controller", + data={"AccessoryPairingID": "00:00:00:00:00:00"}, + unique_id="00:00:00:00:00:00", ).add_to_hass(hass) # We just added a mock config entry so it must be visible in hass assert len(hass.config_entries.async_entries()) == 1 - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1}, - } + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) - flow = _setup_flow_handler(hass) - - result = await flow.async_step_zeroconf(discovery_info) - assert result["type"] == "form" - assert result["step_id"] == "pair" - assert flow.context == { - "hkid": "00:00:00:00:00:00", - "title_placeholders": {"name": "TestDevice"}, - "unique_id": "00:00:00:00:00:00", - } + # Device is discovered + result = await hass.config_entries.flow.async_init( + "homekit_controller", context={"source": "zeroconf"}, data=discovery_info + ) # Discovery of a HKID that is in a pairable state but for which there is # already a config entry - in that case the stale config entry is @@ -392,389 +272,227 @@ async def test_discovery_invalid_config_entry(hass): config_entry_count = len(hass.config_entries.async_entries()) assert config_entry_count == 0 + # And new config flow should continue allowing user to set up a new pairing + assert result["type"] == "form" -async def test_discovery_already_configured(hass): + +async def test_discovery_already_configured(hass, controller): """Already configured.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 0}, - } + MockConfigEntry( + domain="homekit_controller", + data={"AccessoryPairingID": "00:00:00:00:00:00"}, + unique_id="00:00:00:00:00:00", + ).add_to_hass(hass) - await setup_platform(hass) + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) - conn = mock.Mock() - conn.config_num = 1 - hass.data[KNOWN_DEVICES]["00:00:00:00:00:00"] = conn + # Set device as already paired + discovery_info["properties"]["sf"] = 0x00 - flow = _setup_flow_handler(hass) - - result = await flow.async_step_zeroconf(discovery_info) + # 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"] == "already_configured" - assert flow.context == {} - - assert conn.async_config_num_changed.call_count == 0 - - -async def test_discovery_already_configured_config_change(hass): - """Already configured.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 2, "sf": 0}, - } - - await setup_platform(hass) - - conn = mock.Mock() - conn.config_num = 1 - hass.data[KNOWN_DEVICES]["00:00:00:00:00:00"] = conn - - flow = _setup_flow_handler(hass) - - result = await flow.async_step_zeroconf(discovery_info) - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - assert flow.context == {} - - assert conn.async_refresh_entity_map.call_args == mock.call(2) @pytest.mark.parametrize("exception,expected", PAIRING_START_ABORT_ERRORS) -async def test_pair_abort_errors_on_start(hass, exception, expected): +async def test_pair_abort_errors_on_start(hass, controller, exception, expected): """Test various pairing errors.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1}, - } - flow = _setup_flow_handler(hass) - - discovery = mock.Mock() - discovery.start_pairing = asynctest.CoroutineMock(side_effect=exception("error")) - - flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( - return_value=discovery - ) + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) # Device is discovered - result = await flow.async_step_zeroconf(discovery_info) - assert result["type"] == "form" - assert result["step_id"] == "pair" - assert flow.context == { - "hkid": "00:00:00:00:00:00", - "title_placeholders": {"name": "TestDevice"}, - "unique_id": "00:00:00:00:00:00", - } + 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 - result = await flow.async_step_pair({}) + 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"]) assert result["type"] == "abort" assert result["reason"] == expected - assert flow.context == { - "hkid": "00:00:00:00:00:00", - "title_placeholders": {"name": "TestDevice"}, - "unique_id": "00:00:00:00:00:00", - } @pytest.mark.parametrize("exception,expected", PAIRING_START_FORM_ERRORS) -async def test_pair_form_errors_on_start(hass, exception, expected): +async def test_pair_form_errors_on_start(hass, controller, exception, expected): """Test various pairing errors.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1}, - } - flow = _setup_flow_handler(hass) - - discovery = mock.Mock() - discovery.start_pairing = asynctest.CoroutineMock(side_effect=exception("error")) - - flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( - return_value=discovery - ) + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) # Device is discovered - result = await flow.async_step_zeroconf(discovery_info) - assert result["type"] == "form" - assert result["step_id"] == "pair" - assert flow.context == { + result = await hass.config_entries.flow.async_init( + "homekit_controller", context={"source": "zeroconf"}, data=discovery_info + ) + + assert get_flow_context(hass, result) == { "hkid": "00:00:00:00:00:00", "title_placeholders": {"name": "TestDevice"}, "unique_id": "00:00:00:00:00:00", + "source": "zeroconf", } # User initiates pairing - device refuses to enter pairing mode - result = await flow.async_step_pair({}) + 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"]) assert result["type"] == "form" assert result["errors"]["pairing_code"] == expected - assert flow.context == { + + assert get_flow_context(hass, result) == { "hkid": "00:00:00:00:00:00", "title_placeholders": {"name": "TestDevice"}, "unique_id": "00:00:00:00:00:00", + "source": "zeroconf", } @pytest.mark.parametrize("exception,expected", PAIRING_FINISH_ABORT_ERRORS) -async def test_pair_abort_errors_on_finish(hass, exception, expected): +async def test_pair_abort_errors_on_finish(hass, controller, exception, expected): """Test various pairing errors.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1}, - } - - flow = _setup_flow_handler(hass) - - finish_pairing = asynctest.CoroutineMock(side_effect=exception("error")) - - discovery = mock.Mock() - discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing) - - flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( - return_value=discovery - ) + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) # Device is discovered - result = await flow.async_step_zeroconf(discovery_info) - assert result["type"] == "form" - assert result["step_id"] == "pair" - assert flow.context == { + result = await hass.config_entries.flow.async_init( + "homekit_controller", context={"source": "zeroconf"}, data=discovery_info + ) + + assert get_flow_context(hass, result) == { "hkid": "00:00:00:00:00:00", "title_placeholders": {"name": "TestDevice"}, "unique_id": "00:00:00:00:00:00", + "source": "zeroconf", } - # User initiates pairing - device enters pairing mode and displays code - result = await flow.async_step_pair({}) - assert result["type"] == "form" - assert result["step_id"] == "pair" - assert discovery.start_pairing.call_count == 1 + # User initiates pairing - this triggers the device to show a pairing code + # and then HA to show a pairing form + finish_pairing = asynctest.CoroutineMock(side_effect=exception("error")) + with patch.object(device, "start_pairing", return_value=finish_pairing): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - # User submits code - pairing fails but can be retried - result = await flow.async_step_pair({"pairing_code": "111-22-333"}) + assert result["type"] == "form" + assert get_flow_context(hass, result) == { + "hkid": "00:00:00:00:00:00", + "title_placeholders": {"name": "TestDevice"}, + "unique_id": "00:00:00:00:00:00", + "source": "zeroconf", + } + + # User enters pairing code + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"pairing_code": "111-22-333"} + ) assert result["type"] == "abort" assert result["reason"] == expected - assert flow.context == { - "hkid": "00:00:00:00:00:00", - "title_placeholders": {"name": "TestDevice"}, - "unique_id": "00:00:00:00:00:00", - } @pytest.mark.parametrize("exception,expected", PAIRING_FINISH_FORM_ERRORS) -async def test_pair_form_errors_on_finish(hass, exception, expected): +async def test_pair_form_errors_on_finish(hass, controller, exception, expected): """Test various pairing errors.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1}, - } - - flow = _setup_flow_handler(hass) - - finish_pairing = asynctest.CoroutineMock(side_effect=exception("error")) - - discovery = mock.Mock() - discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing) - - flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( - return_value=discovery - ) + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) # Device is discovered - result = await flow.async_step_zeroconf(discovery_info) - assert result["type"] == "form" - assert result["step_id"] == "pair" - assert flow.context == { + result = await hass.config_entries.flow.async_init( + "homekit_controller", context={"source": "zeroconf"}, data=discovery_info + ) + + assert get_flow_context(hass, result) == { "hkid": "00:00:00:00:00:00", "title_placeholders": {"name": "TestDevice"}, "unique_id": "00:00:00:00:00:00", + "source": "zeroconf", } - # User initiates pairing - device enters pairing mode and displays code - result = await flow.async_step_pair({}) - assert result["type"] == "form" - assert result["step_id"] == "pair" - assert discovery.start_pairing.call_count == 1 + # User initiates pairing - this triggers the device to show a pairing code + # and then HA to show a pairing form + finish_pairing = asynctest.CoroutineMock(side_effect=exception("error")) + with patch.object(device, "start_pairing", return_value=finish_pairing): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) - # User submits code - pairing fails but can be retried - result = await flow.async_step_pair({"pairing_code": "111-22-333"}) + assert result["type"] == "form" + assert get_flow_context(hass, result) == { + "hkid": "00:00:00:00:00:00", + "title_placeholders": {"name": "TestDevice"}, + "unique_id": "00:00:00:00:00:00", + "source": "zeroconf", + } + + # User enters pairing code + 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 - assert flow.context == { + + assert get_flow_context(hass, result) == { "hkid": "00:00:00:00:00:00", "title_placeholders": {"name": "TestDevice"}, "unique_id": "00:00:00:00:00:00", + "source": "zeroconf", } -async def test_import_works(hass): - """Test a device being discovered.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1}, - } - - import_info = {"AccessoryPairingID": "00:00:00:00:00:00"} - - pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"}) - - pairing.list_accessories_and_characteristics = asynctest.CoroutineMock( - return_value=[ - { - "aid": 1, - "services": [ - { - "characteristics": [ - {"type": "23", "value": "Koogeek-LS1-20833F"} - ], - "type": "3e", - } - ], - } - ] - ) - - flow = _setup_flow_handler(hass) - - pairing_cls_imp = ( - "homeassistant.components.homekit_controller.config_flow.IpPairing" - ) - - with mock.patch(pairing_cls_imp) as pairing_cls: - pairing_cls.return_value = pairing - result = await flow.async_import_legacy_pairing( - discovery_info["properties"], import_info - ) - - assert result["type"] == "create_entry" - assert result["title"] == "Koogeek-LS1-20833F" - assert result["data"] == pairing.pairing_data - - -async def test_import_already_configured(hass): - """Test importing a device from .homekit that is already a ConfigEntry.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1, "sf": 1}, - } - - import_info = {"AccessoryPairingID": "00:00:00:00:00:00"} - - config_entry = MockConfigEntry(domain="homekit_controller", data=import_info) - config_entry.add_to_hass(hass) - - flow = _setup_flow_handler(hass) - - result = await flow.async_import_legacy_pairing( - discovery_info["properties"], import_info - ) - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - -async def test_user_works(hass): +async def test_user_works(hass, controller): """Test user initiated disovers devices.""" - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "md": "TestDevice", - "id": "00:00:00:00:00:00", - "c#": 1, - "sf": 1, - } + setup_mock_accessory(controller) - pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"}) - pairing.list_accessories_and_characteristics = asynctest.CoroutineMock( - return_value=[ - { - "aid": 1, - "services": [ - { - "characteristics": [ - {"type": "23", "value": "Koogeek-LS1-20833F"} - ], - "type": "3e", - } - ], - } - ] + # Device is discovered + result = await hass.config_entries.flow.async_init( + "homekit_controller", context={"source": "user"} ) - flow = _setup_flow_handler(hass) - - finish_pairing = asynctest.CoroutineMock(return_value=pairing) - - discovery = mock.Mock() - discovery.info = discovery_info - discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing) - - flow.controller.pairings = {"00:00:00:00:00:00": pairing} - flow.controller.discover_ip = asynctest.CoroutineMock(return_value=[discovery]) - flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( - return_value=discovery - ) - - result = await flow.async_step_user() assert result["type"] == "form" assert result["step_id"] == "user" + assert get_flow_context(hass, result) == { + "source": "user", + } - result = await flow.async_step_user({"device": "TestDevice"}) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"device": "TestDevice"} + ) assert result["type"] == "form" assert result["step_id"] == "pair" - result = await flow.async_step_pair({"pairing_code": "111-22-333"}) + assert get_flow_context(hass, result) == { + "source": "user", + "unique_id": "00:00:00:00:00:00", + } + + 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" - assert result["data"] == pairing.pairing_data -async def test_user_no_devices(hass): +async def test_user_no_devices(hass, controller): """Test user initiated pairing where no devices discovered.""" - flow = _setup_flow_handler(hass) - - flow.controller.discover_ip = asynctest.CoroutineMock(return_value=[]) - result = await flow.async_step_user() - + result = await hass.config_entries.flow.async_init( + "homekit_controller", context={"source": "user"} + ) assert result["type"] == "abort" assert result["reason"] == "no_devices" -async def test_user_no_unpaired_devices(hass): +async def test_user_no_unpaired_devices(hass, controller): """Test user initiated pairing where no unpaired devices discovered.""" - flow = _setup_flow_handler(hass) + device = setup_mock_accessory(controller) - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "md": "TestDevice", - "id": "00:00:00:00:00:00", - "c#": 1, - "sf": 0, - } + # Pair the mock device so that it shows as paired in discovery + finish_pairing = await device.start_pairing(device.device_id) + await finish_pairing(device.pairing_code) - discovery = mock.Mock() - discovery.info = discovery_info - - flow.controller.discover_ip = asynctest.CoroutineMock(return_value=[discovery]) - result = await flow.async_step_user() + # Device discovery is requested + result = await hass.config_entries.flow.async_init( + "homekit_controller", context={"source": "user"} + ) assert result["type"] == "abort" assert result["reason"] == "no_devices" @@ -934,103 +652,48 @@ async def test_parse_overlapping_homekit_json(hass): } -async def test_unignore_works(hass): +async def test_unignore_works(hass, controller): """Test rediscovery triggered disovers work.""" - discovery_info = { - "name": "TestDevice", - "address": "127.0.0.1", - "port": 8080, - "md": "TestDevice", - "pv": "1.0", - "id": "00:00:00:00:00:00", - "c#": 1, - "s#": 1, - "ff": 0, - "ci": 0, - "sf": 1, - } + device = setup_mock_accessory(controller) - pairing = mock.Mock(pairing_data={"AccessoryPairingID": "00:00:00:00:00:00"}) - pairing.list_accessories_and_characteristics = asynctest.CoroutineMock( - return_value=[ - { - "aid": 1, - "services": [ - { - "characteristics": [ - {"type": "23", "value": "Koogeek-LS1-20833F"} - ], - "type": "3e", - } - ], - } - ] + # Device is unignored + result = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": "unignore"}, + data={"unique_id": device.device_id}, ) - - finish_pairing = asynctest.CoroutineMock() - - discovery = mock.Mock() - discovery.device_id = "00:00:00:00:00:00" - discovery.info = discovery_info - discovery.start_pairing = asynctest.CoroutineMock(return_value=finish_pairing) - - finish_pairing.return_value = pairing - - flow = _setup_flow_handler(hass) - - flow.controller.pairings = {"00:00:00:00:00:00": pairing} - flow.controller.discover_ip = asynctest.CoroutineMock(return_value=[discovery]) - - flow.controller.find_ip_by_device_id = asynctest.CoroutineMock( - return_value=discovery - ) - - result = await flow.async_step_unignore({"unique_id": "00:00:00:00:00:00"}) assert result["type"] == "form" assert result["step_id"] == "pair" - assert flow.context == { + assert get_flow_context(hass, result) == { "hkid": "00:00:00:00:00:00", "title_placeholders": {"name": "TestDevice"}, "unique_id": "00:00:00:00:00:00", + "source": "unignore", } # User initiates pairing by clicking on 'configure' - device enters pairing mode and displays code - result = await flow.async_step_pair({}) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == "form" assert result["step_id"] == "pair" # Pairing finalized - result = await flow.async_step_pair({"pairing_code": "111-22-333"}) + 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" - assert result["data"] == pairing.pairing_data -async def test_unignore_ignores_missing_devices(hass): +async def test_unignore_ignores_missing_devices(hass, controller): """Test rediscovery triggered disovers handle devices that have gone away.""" - discovery_info = { - "name": "TestDevice", - "address": "127.0.0.1", - "port": 8080, - "md": "TestDevice", - "pv": "1.0", - "id": "00:00:00:00:00:00", - "c#": 1, - "s#": 1, - "ff": 0, - "ci": 0, - "sf": 1, - } + setup_mock_accessory(controller) - discovery = mock.Mock() - discovery.device_id = "00:00:00:00:00:00" - discovery.info = discovery_info + # Device is unignored + result = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": "unignore"}, + data={"unique_id": "00:00:00:00:00:01"}, + ) - flow = _setup_flow_handler(hass) - flow.controller.discover_ip = asynctest.CoroutineMock(return_value=[discovery]) - - result = await flow.async_step_unignore({"unique_id": "00:00:00:00:00:01"}) assert result["type"] == "abort" - assert flow.context == { - "unique_id": "00:00:00:00:00:01", - } + assert result["reason"] == "no_devices" From 438c4acf0776513ce7fe192a08862cb84fa9c8ec Mon Sep 17 00:00:00 2001 From: Jc2k Date: Tue, 25 Feb 2020 11:09:04 +0000 Subject: [PATCH 095/416] Deprecate homekit_controller .homekit folder (#32158) * homekit_controller: Deprecate .homekit folder * Tweaks from review * Delay it a release --- .../components/homekit_controller/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 630a75e496b..3477e23897a 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -1,5 +1,6 @@ """Support for Homekit device discovery.""" import logging +import os import aiohomekit from aiohomekit.model.characteristics import CharacteristicsTypes @@ -227,6 +228,16 @@ async def async_setup(hass, config): hass.data[CONTROLLER] = aiohomekit.Controller() hass.data[KNOWN_DEVICES] = {} + dothomekit_dir = hass.config.path(".homekit") + if os.path.exists(dothomekit_dir): + _LOGGER.warning( + ( + "Legacy homekit_controller state found in %s. Support for reading " + "the folder is deprecated and will be removed in 0.109.0." + ), + dothomekit_dir, + ) + return True From 548838924400c447d4e50e30676cb6eb889dd94f Mon Sep 17 00:00:00 2001 From: Kit Klein <33464407+kit-klein@users.noreply.github.com> Date: Tue, 25 Feb 2020 07:55:06 -0500 Subject: [PATCH 096/416] Dedup and clarify imported konnected config flows (#32138) * dedup config flows * use default (imported) options until user goes thru options flow * address pr feedback * correct key used to distinguish pro model --- .../konnected/.translations/en.json | 7 +- .../components/konnected/config_flow.py | 55 +- homeassistant/components/konnected/const.py | 1 + homeassistant/components/konnected/panel.py | 5 +- .../components/konnected/strings.json | 6 +- .../components/konnected/test_config_flow.py | 103 +++- tests/components/konnected/test_panel.py | 482 ++++++++++++------ 7 files changed, 478 insertions(+), 181 deletions(-) diff --git a/homeassistant/components/konnected/.translations/en.json b/homeassistant/components/konnected/.translations/en.json index cb6d2d24ff1..9d642a43603 100644 --- a/homeassistant/components/konnected/.translations/en.json +++ b/homeassistant/components/konnected/.translations/en.json @@ -11,9 +11,13 @@ }, "step": { "confirm": { - "description": "Model: {model}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected Alarm Panel settings.", + "description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected Alarm Panel settings.", "title": "Konnected Device Ready" }, + "import_confirm": { + "description": "A Konnected Alarm Panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry.", + "title": "Import Konnected Device" + }, "user": { "data": { "host": "Konnected device IP address", @@ -29,6 +33,7 @@ "abort": { "not_konn_panel": "Not a recognized Konnected.io device" }, + "error": {}, "step": { "options_binary": { "data": { diff --git a/homeassistant/components/konnected/config_flow.py b/homeassistant/components/konnected/config_flow.py index b6e0c00c465..cb9004c9efe 100644 --- a/homeassistant/components/konnected/config_flow.py +++ b/homeassistant/components/konnected/config_flow.py @@ -32,6 +32,7 @@ from homeassistant.helpers import config_validation as cv from .const import ( CONF_ACTIVATION, CONF_BLINK, + CONF_DEFAULT_OPTIONS, CONF_DISCOVERY, CONF_INVERSE, CONF_MODEL, @@ -138,7 +139,6 @@ OPTIONS_SCHEMA = vol.Schema( extra=vol.REMOVE_EXTRA, ) -CONF_DEFAULT_OPTIONS = "default_options" CONFIG_ENTRY_SCHEMA = vol.Schema( { vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"), @@ -158,6 +158,9 @@ class KonnectedFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + # class variable to store/share discovered host information + discovered_hosts = {} + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 def __init__(self): @@ -178,7 +181,7 @@ class KonnectedFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except (CannotConnect, KeyError): raise CannotConnect else: - self.data[CONF_MODEL] = status.get("name", KONN_MODEL) + self.data[CONF_MODEL] = status.get("model", KONN_MODEL) self.data[CONF_ACCESS_TOKEN] = "".join( random.choices(f"{string.ascii_uppercase}{string.digits}", k=20) ) @@ -196,6 +199,7 @@ class KonnectedFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # config schema ensures we have port if we have host if device_config.get(CONF_HOST): + # automatically connect if we have host info return await self.async_step_user( user_input={ CONF_HOST: device_config[CONF_HOST], @@ -205,6 +209,28 @@ class KonnectedFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # if we have no host info wait for it or abort if previously configured self._abort_if_unique_id_configured() + return await self.async_step_import_confirm() + + async def async_step_import_confirm(self, user_input=None): + """Confirm the user wants to import the config entry.""" + if user_input is None: + return self.async_show_form( + step_id="import_confirm", + description_placeholders={"id": self.unique_id}, + ) + + # if we have ssdp discovered applicable host info use it + if KonnectedFlowHandler.discovered_hosts.get(self.unique_id): + return await self.async_step_user( + user_input={ + CONF_HOST: KonnectedFlowHandler.discovered_hosts[self.unique_id][ + CONF_HOST + ], + CONF_PORT: KonnectedFlowHandler.discovered_hosts[self.unique_id][ + CONF_PORT + ], + } + ) return await self.async_step_user() async def async_step_ssdp(self, discovery_info): @@ -265,7 +291,13 @@ class KonnectedFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cannot_connect" else: self.data[CONF_ID] = status["mac"].replace(":", "") - self.data[CONF_MODEL] = status.get("name", KONN_MODEL) + self.data[CONF_MODEL] = status.get("model", KONN_MODEL) + + # save off our discovered host info + KonnectedFlowHandler.discovered_hosts[self.data[CONF_ID]] = { + CONF_HOST: self.data[CONF_HOST], + CONF_PORT: self.data[CONF_PORT], + } return await self.async_step_confirm() return self.async_show_form( @@ -290,23 +322,14 @@ class KonnectedFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): the connection. """ if user_input is None: - # update an existing config entry if host info changes - entry = await self.async_set_unique_id( - self.data[CONF_ID], raise_on_progress=False - ) - if entry and ( - entry.data[CONF_HOST] != self.data[CONF_HOST] - or entry.data[CONF_PORT] != self.data[CONF_PORT] - ): - entry_data = copy.deepcopy(entry.data) - entry_data.update(self.data) - self.hass.config_entries.async_update_entry(entry, data=entry_data) - - self._abort_if_unique_id_configured() + # abort and update an existing config entry if host info changes + await self.async_set_unique_id(self.data[CONF_ID]) + self._abort_if_unique_id_configured(updates=self.data) return self.async_show_form( step_id="confirm", description_placeholders={ "model": KONN_PANEL_MODEL_NAMES[self.data[CONF_MODEL]], + "id": self.unique_id, "host": self.data[CONF_HOST], "port": self.data[CONF_PORT], }, diff --git a/homeassistant/components/konnected/const.py b/homeassistant/components/konnected/const.py index d8777a5611e..d6819dcf71f 100644 --- a/homeassistant/components/konnected/const.py +++ b/homeassistant/components/konnected/const.py @@ -4,6 +4,7 @@ DOMAIN = "konnected" CONF_ACTIVATION = "activation" CONF_API_HOST = "api_host" +CONF_DEFAULT_OPTIONS = "default_options" CONF_MOMENTARY = "momentary" CONF_PAUSE = "pause" CONF_POLL_INTERVAL = "poll_interval" diff --git a/homeassistant/components/konnected/panel.py b/homeassistant/components/konnected/panel.py index 9f4b39e82bc..2668a382ccc 100644 --- a/homeassistant/components/konnected/panel.py +++ b/homeassistant/components/konnected/panel.py @@ -28,6 +28,7 @@ from .const import ( CONF_ACTIVATION, CONF_API_HOST, CONF_BLINK, + CONF_DEFAULT_OPTIONS, CONF_DHT_SENSORS, CONF_DISCOVERY, CONF_DS18B20_SENSORS, @@ -64,7 +65,9 @@ class AlarmPanel: self.hass = hass self.config_entry = config_entry self.config = config_entry.data - self.options = config_entry.options + self.options = config_entry.options or config_entry.data.get( + CONF_DEFAULT_OPTIONS, {} + ) self.host = self.config.get(CONF_HOST) self.port = self.config.get(CONF_PORT) self.client = None diff --git a/homeassistant/components/konnected/strings.json b/homeassistant/components/konnected/strings.json index 1f27b04d811..4d923238df4 100644 --- a/homeassistant/components/konnected/strings.json +++ b/homeassistant/components/konnected/strings.json @@ -2,6 +2,10 @@ "config": { "title": "Konnected.io", "step": { + "import_confirm": { + "title": "Import Konnected Device", + "description": "A Konnected Alarm Panel with ID {id} has been discovered in configuration.yaml. This flow will allow you to import it into a config entry." + }, "user": { "title": "Discover Konnected Device", "description": "Please enter the host information for your Konnected Panel.", @@ -12,7 +16,7 @@ }, "confirm": { "title": "Konnected Device Ready", - "description": "Model: {model}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected Alarm Panel settings." + "description": "Model: {model}\nID: {id}\nHost: {host}\nPort: {port}\n\nYou can configure the IO and panel behavior in the Konnected Alarm Panel settings." } }, "error": { diff --git a/tests/components/konnected/test_config_flow.py b/tests/components/konnected/test_config_flow.py index 8dfead58659..3638f40735b 100644 --- a/tests/components/konnected/test_config_flow.py +++ b/tests/components/konnected/test_config_flow.py @@ -34,7 +34,7 @@ async def test_flow_works(hass, mock_panel): mock_panel.get_status.return_value = { "mac": "11:22:33:44:55:66", - "name": "Konnected", + "model": "Konnected", } result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"port": 1234, "host": "1.2.3.4"} @@ -43,6 +43,7 @@ async def test_flow_works(hass, mock_panel): assert result["step_id"] == "confirm" assert result["description_placeholders"] == { "model": "Konnected Alarm Panel", + "id": "112233445566", "host": "1.2.3.4", "port": 1234, } @@ -70,7 +71,7 @@ async def test_pro_flow_works(hass, mock_panel): mock_panel.get_status.return_value = { "mac": "11:22:33:44:55:66", - "name": "Konnected Pro", + "model": "Konnected Pro", } result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"port": 1234, "host": "1.2.3.4"} @@ -79,6 +80,7 @@ async def test_pro_flow_works(hass, mock_panel): assert result["step_id"] == "confirm" assert result["description_placeholders"] == { "model": "Konnected Alarm Panel Pro", + "id": "112233445566", "host": "1.2.3.4", "port": 1234, } @@ -100,7 +102,7 @@ async def test_ssdp(hass, mock_panel): """Test a panel being discovered.""" mock_panel.get_status.return_value = { "mac": "11:22:33:44:55:66", - "name": "Konnected", + "model": "Konnected", } result = await hass.config_entries.flow.async_init( @@ -117,6 +119,7 @@ async def test_ssdp(hass, mock_panel): assert result["step_id"] == "confirm" assert result["description_placeholders"] == { "model": "Konnected Alarm Panel", + "id": "112233445566", "host": "1.2.3.4", "port": 1234, } @@ -125,8 +128,8 @@ async def test_ssdp(hass, mock_panel): async def test_import_no_host_user_finish(hass, mock_panel): """Test importing a panel with no host info.""" mock_panel.get_status.return_value = { - "mac": "11:22:33:44:55:66", - "name": "Konnected Pro", + "mac": "aa:bb:cc:dd:ee:ff", + "model": "Konnected Pro", } result = await hass.config_entries.flow.async_init( @@ -159,6 +162,13 @@ async def test_import_no_host_user_finish(hass, mock_panel): }, ) assert result["type"] == "form" + assert result["step_id"] == "import_confirm" + assert result["description_placeholders"]["id"] == "aabbccddeeff" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "form" assert result["step_id"] == "user" # confirm user is prompted to enter host @@ -169,6 +179,7 @@ async def test_import_no_host_user_finish(hass, mock_panel): assert result["step_id"] == "confirm" assert result["description_placeholders"] == { "model": "Konnected Alarm Panel Pro", + "id": "aabbccddeeff", "host": "1.1.1.1", "port": 1234, } @@ -180,6 +191,78 @@ async def test_import_no_host_user_finish(hass, mock_panel): assert result["type"] == "create_entry" +async def test_import_ssdp_host_user_finish(hass, mock_panel): + """Test importing a panel with no host info which ssdp discovers.""" + mock_panel.get_status.return_value = { + "mac": "11:22:33:44:55:66", + "model": "Konnected Pro", + } + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "import"}, + data={ + "default_options": { + "blink": True, + "discovery": True, + "io": { + "1": "Disabled", + "10": "Disabled", + "11": "Disabled", + "12": "Disabled", + "2": "Disabled", + "3": "Disabled", + "4": "Disabled", + "5": "Disabled", + "6": "Disabled", + "7": "Disabled", + "8": "Disabled", + "9": "Disabled", + "alarm1": "Disabled", + "alarm2_out2": "Disabled", + "out": "Disabled", + "out1": "Disabled", + }, + }, + "id": "112233445566", + }, + ) + assert result["type"] == "form" + assert result["step_id"] == "import_confirm" + assert result["description_placeholders"]["id"] == "112233445566" + + # discover the panel via ssdp + ssdp_result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "ssdp"}, + data={ + "ssdp_location": "http://0.0.0.0:1234/Device.xml", + "manufacturer": config_flow.KONN_MANUFACTURER, + "modelName": config_flow.KONN_MODEL_PRO, + }, + ) + assert ssdp_result["type"] == "abort" + assert ssdp_result["reason"] == "already_in_progress" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "form" + assert result["step_id"] == "confirm" + assert result["description_placeholders"] == { + "model": "Konnected Alarm Panel Pro", + "id": "112233445566", + "host": "0.0.0.0", + "port": 1234, + } + + # final confirmation + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] == "create_entry" + + async def test_ssdp_already_configured(hass, mock_panel): """Test if a discovered panel has already been configured.""" MockConfigEntry( @@ -189,7 +272,7 @@ async def test_ssdp_already_configured(hass, mock_panel): ).add_to_hass(hass) mock_panel.get_status.return_value = { "mac": "11:22:33:44:55:66", - "name": "Konnected Pro", + "model": "Konnected Pro", } result = await hass.config_entries.flow.async_init( @@ -265,7 +348,7 @@ async def test_ssdp_host_update(hass, mock_panel): ).add_to_hass(hass) mock_panel.get_status.return_value = { "mac": "11:22:33:44:55:66", - "name": "Konnected Pro", + "model": "Konnected Pro", } result = await hass.config_entries.flow.async_init( @@ -289,7 +372,7 @@ async def test_import_existing_config(hass, mock_panel): """Test importing a host with an existing config file.""" mock_panel.get_status.return_value = { "mac": "11:22:33:44:55:66", - "name": "Konnected Pro", + "model": "Konnected Pro", } result = await hass.config_entries.flow.async_init( @@ -402,7 +485,7 @@ async def test_import_existing_config_entry(hass, mock_panel): mock_panel.get_status.return_value = { "mac": "11:22:33:44:55:66", - "name": "Konnected Pro", + "model": "Konnected Pro", } # utilize a global access token this time @@ -462,7 +545,7 @@ async def test_import_pin_config(hass, mock_panel): """Test importing a host with an existing config file that specifies pin configs.""" mock_panel.get_status.return_value = { "mac": "11:22:33:44:55:66", - "name": "Konnected Pro", + "model": "Konnected Pro", } result = await hass.config_entries.flow.async_init( diff --git a/tests/components/konnected/test_panel.py b/tests/components/konnected/test_panel.py index 0ad384bd35e..f1ae8a4357c 100644 --- a/tests/components/konnected/test_panel.py +++ b/tests/components/konnected/test_panel.py @@ -3,6 +3,7 @@ from asynctest import patch import pytest from homeassistant.components.konnected import config_flow, panel +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -92,9 +93,6 @@ async def test_create_and_setup(hass, mock_panel): options=device_options, ) entry.add_to_hass(hass) - hass.data[panel.DOMAIN] = { - panel.CONF_API_HOST: "192.168.1.1", - } # override get_status to reflect non-pro board mock_panel.get_status.return_value = { @@ -111,19 +109,35 @@ async def test_create_and_setup(hass, mock_panel): "mac": "11:22:33:44:55:66", "settings": {}, } - device = panel.AlarmPanel(hass, entry) - await device.async_save_data() - await device.async_connect() + + # 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 panel instance was created and configured + # hass.data is the only mechanism to get a reference to the created panel instance + device = hass.data[panel.DOMAIN][panel.CONF_DEVICES]["112233445566"]["panel"] await device.update_switch("1", 0) # confirm the correct api is used # pylint: disable=no-member - assert device.client.put_device.call_count == 1 - assert device.client.put_zone.call_count == 0 + assert mock_panel.put_device.call_count == 1 + assert mock_panel.put_zone.call_count == 0 # confirm the settings are sent to the panel # pylint: disable=no-member - assert device.client.put_settings.call_args_list[0][1] == { + assert mock_panel.put_settings.call_args_list[0][1] == { "sensors": [{"pin": "1"}, {"pin": "2"}, {"pin": "5"}], "actuators": [{"trigger": 0, "pin": "8"}, {"trigger": 1, "pin": "9"}], "dht_sensors": [{"poll_interval": 3, "pin": "6"}], @@ -131,67 +145,60 @@ async def test_create_and_setup(hass, mock_panel): "auth_token": "11223344556677889900", "blink": True, "discovery": True, - "endpoint": "192.168.1.1/api/konnected", + "endpoint": "http://192.168.1.1:8123/api/konnected", } # confirm the device settings are saved in hass.data - assert hass.data[panel.DOMAIN][panel.CONF_DEVICES] == { - "112233445566": { - "binary_sensors": { - "1": { - "inverse": False, - "name": "Konnected 445566 Zone 1", - "state": None, - "type": "door", - }, - "2": { - "inverse": True, - "name": "winder", - "state": None, - "type": "window", - }, - "3": { - "inverse": False, - "name": "Konnected 445566 Zone 3", - "state": None, - "type": "door", - }, + assert device.stored_configuration == { + "binary_sensors": { + "1": { + "inverse": False, + "name": "Konnected 445566 Zone 1", + "state": None, + "type": "door", }, - "blink": True, - "panel": device, - "discovery": True, - "host": "1.2.3.4", - "port": 1234, - "sensors": [ - { - "name": "Konnected 445566 Sensor 4", - "poll_interval": 3, - "type": "dht", - "zone": "4", - }, - {"name": "temper", "poll_interval": 3, "type": "ds18b20", "zone": "5"}, - ], - "switches": [ - { - "activation": "low", - "momentary": 50, - "name": "switcher", - "pause": 100, - "repeat": 4, - "state": None, - "zone": "out", - }, - { - "activation": "high", - "momentary": None, - "name": "Konnected 445566 Actuator 6", - "pause": None, - "repeat": None, - "state": None, - "zone": "6", - }, - ], - } + "2": {"inverse": True, "name": "winder", "state": None, "type": "window"}, + "3": { + "inverse": False, + "name": "Konnected 445566 Zone 3", + "state": None, + "type": "door", + }, + }, + "blink": True, + "panel": device, + "discovery": True, + "host": "1.2.3.4", + "port": 1234, + "sensors": [ + { + "name": "Konnected 445566 Sensor 4", + "poll_interval": 3, + "type": "dht", + "zone": "4", + }, + {"name": "temper", "poll_interval": 3, "type": "ds18b20", "zone": "5"}, + ], + "switches": [ + { + "activation": "low", + "momentary": 50, + "name": "switcher", + "pause": 100, + "repeat": 4, + "state": None, + "zone": "out", + }, + { + "activation": "high", + "momentary": None, + "name": "Konnected 445566 Actuator 6", + "pause": None, + "repeat": None, + "state": None, + "zone": "6", + }, + ], } @@ -255,23 +262,35 @@ async def test_create_and_setup_pro(hass, mock_panel): options=device_options, ) entry.add_to_hass(hass) - hass.data[panel.DOMAIN] = { - panel.CONF_API_HOST: "192.168.1.1", - } - device = panel.AlarmPanel(hass, entry) - await device.async_save_data() - await device.async_connect() + # 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 panel instance was created and configured + # hass.data is the only mechanism to get a reference to the created panel instance + device = hass.data[panel.DOMAIN][panel.CONF_DEVICES]["112233445566"]["panel"] await device.update_switch("2", 1) # confirm the correct api is used # pylint: disable=no-member - assert device.client.put_device.call_count == 0 - assert device.client.put_zone.call_count == 1 + assert mock_panel.put_device.call_count == 0 + assert mock_panel.put_zone.call_count == 1 # confirm the settings are sent to the panel # pylint: disable=no-member - assert device.client.put_settings.call_args_list[0][1] == { + assert mock_panel.put_settings.call_args_list[0][1] == { "sensors": [{"zone": "2"}, {"zone": "6"}, {"zone": "10"}], "actuators": [ {"trigger": 1, "zone": "4"}, @@ -287,89 +306,248 @@ async def test_create_and_setup_pro(hass, mock_panel): "auth_token": "11223344556677889900", "blink": True, "discovery": True, - "endpoint": "192.168.1.1/api/konnected", + "endpoint": "http://192.168.1.1:8123/api/konnected", } # confirm the device settings are saved in hass.data - assert hass.data[panel.DOMAIN][panel.CONF_DEVICES] == { - "112233445566": { - "binary_sensors": { - "10": { - "inverse": False, - "name": "Konnected 445566 Zone 10", - "state": None, - "type": "door", - }, - "2": { - "inverse": False, - "name": "Konnected 445566 Zone 2", - "state": None, - "type": "door", - }, - "6": { - "inverse": True, - "name": "winder", - "state": None, - "type": "window", - }, + assert device.stored_configuration == { + "binary_sensors": { + "10": { + "inverse": False, + "name": "Konnected 445566 Zone 10", + "state": None, + "type": "door", }, - "blink": True, - "panel": device, - "discovery": True, + "2": { + "inverse": False, + "name": "Konnected 445566 Zone 2", + "state": None, + "type": "door", + }, + "6": {"inverse": True, "name": "winder", "state": None, "type": "window"}, + }, + "blink": True, + "panel": device, + "discovery": True, + "host": "1.2.3.4", + "port": 1234, + "sensors": [ + { + "name": "Konnected 445566 Sensor 3", + "poll_interval": 3, + "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": [ + { + "activation": "high", + "momentary": None, + "name": "Konnected 445566 Actuator 4", + "pause": None, + "repeat": None, + "state": None, + "zone": "4", + }, + { + "activation": "low", + "momentary": 50, + "name": "switcher", + "pause": 100, + "repeat": 4, + "state": None, + "zone": "8", + }, + { + "activation": "high", + "momentary": None, + "name": "Konnected 445566 Actuator out1", + "pause": None, + "repeat": None, + "state": None, + "zone": "out1", + }, + { + "activation": "high", + "momentary": None, + "name": "Konnected 445566 Actuator alarm1", + "pause": None, + "repeat": None, + "state": None, + "zone": "alarm1", + }, + ], + } + + +async def test_default_options(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, - "sensors": [ + "id": "112233445566", + "model": "Konnected Pro", + "access_token": "11223344556677889900", + "default_options": config_flow.OPTIONS_SCHEMA( { - "name": "Konnected 445566 Sensor 3", - "poll_interval": 3, - "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": [ - { - "activation": "high", - "momentary": None, - "name": "Konnected 445566 Actuator 4", - "pause": None, - "repeat": None, - "state": None, - "zone": "4", - }, - { - "activation": "low", - "momentary": 50, - "name": "switcher", - "pause": 100, - "repeat": 4, - "state": None, - "zone": "8", - }, - { - "activation": "high", - "momentary": None, - "name": "Konnected 445566 Actuator out1", - "pause": None, - "repeat": None, - "state": None, - "zone": "out1", - }, - { - "activation": "high", - "momentary": None, - "name": "Konnected 445566 Actuator alarm1", - "pause": None, - "repeat": None, - "state": None, - "zone": "alarm1", - }, - ], + "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) + + # override get_status to reflect non-pro board + mock_panel.get_status.return_value = { + "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", + "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 panel instance was created and configured. + # hass.data is the only mechanism to get a reference to the created panel instance + device = hass.data[panel.DOMAIN][panel.CONF_DEVICES]["112233445566"]["panel"] + await device.update_switch("1", 0) + + # confirm the correct api is used + # pylint: disable=no-member + assert mock_panel.put_device.call_count == 1 + assert mock_panel.put_zone.call_count == 0 + + # confirm the settings are sent to the panel + # pylint: disable=no-member + assert mock_panel.put_settings.call_args_list[0][1] == { + "sensors": [{"pin": "1"}, {"pin": "2"}, {"pin": "5"}], + "actuators": [{"trigger": 0, "pin": "8"}, {"trigger": 1, "pin": "9"}], + "dht_sensors": [{"poll_interval": 3, "pin": "6"}], + "ds18b20_sensors": [{"pin": "7"}], + "auth_token": "11223344556677889900", + "blink": True, + "discovery": True, + "endpoint": "http://192.168.1.1:8123/api/konnected", + } + + # confirm the device settings are saved in hass.data + assert device.stored_configuration == { + "binary_sensors": { + "1": { + "inverse": False, + "name": "Konnected 445566 Zone 1", + "state": None, + "type": "door", + }, + "2": {"inverse": True, "name": "winder", "state": None, "type": "window"}, + "3": { + "inverse": False, + "name": "Konnected 445566 Zone 3", + "state": None, + "type": "door", + }, + }, + "blink": True, + "panel": device, + "discovery": True, + "host": "1.2.3.4", + "port": 1234, + "sensors": [ + { + "name": "Konnected 445566 Sensor 4", + "poll_interval": 3, + "type": "dht", + "zone": "4", + }, + {"name": "temper", "poll_interval": 3, "type": "ds18b20", "zone": "5"}, + ], + "switches": [ + { + "activation": "low", + "momentary": 50, + "name": "switcher", + "pause": 100, + "repeat": 4, + "state": None, + "zone": "out", + }, + { + "activation": "high", + "momentary": None, + "name": "Konnected 445566 Actuator 6", + "pause": None, + "repeat": None, + "state": None, + "zone": "6", + }, + ], } From 900714a3eea3f2e1d8621870041be707436ed2d4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 25 Feb 2020 09:05:36 -0800 Subject: [PATCH 097/416] Do not report state when closing down (#32168) --- homeassistant/components/alexa/state_report.py | 3 +++ homeassistant/components/google_assistant/report_state.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 44e1b7f4f55..b595bc98589 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -26,6 +26,9 @@ async def async_enable_proactive_mode(hass, smart_home_config): await smart_home_config.async_get_access_token() async def async_entity_state_listener(changed_entity, old_state, new_state): + if not hass.is_running: + return + if not new_state: return diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 1e8b6c020de..d6bcafd3bff 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -21,6 +21,9 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig """Enable state reporting.""" async def async_entity_state_listener(changed_entity, old_state, new_state): + if not hass.is_running: + return + if not new_state: return From 2925e0617ceed9e5fdd204b5fab690f7c39a167f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2020 08:18:15 -1000 Subject: [PATCH 098/416] Add Config flow to august (#32133) * Add battery sensors for August devices * Additional tests and cleanup in prep for config flow and device registry * pylint * update name for new style guidelines - https://developers.home-assistant.io/docs/development_guidelines/#use-new-style-string-formatting * Config Flow for august push * Update homeassistant/components/august/__init__.py Co-Authored-By: Paulus Schoutsen * Address review items * Update tests Co-authored-by: Paulus Schoutsen --- .../components/august/.translations/en.json | 32 ++ homeassistant/components/august/__init__.py | 330 +++++++++--------- .../components/august/binary_sensor.py | 84 +++-- homeassistant/components/august/camera.py | 44 ++- .../components/august/config_flow.py | 135 +++++++ homeassistant/components/august/const.py | 42 +++ homeassistant/components/august/exceptions.py | 15 + homeassistant/components/august/gateway.py | 143 ++++++++ homeassistant/components/august/lock.py | 38 +- homeassistant/components/august/manifest.json | 15 +- homeassistant/components/august/sensor.py | 158 +++++++++ homeassistant/components/august/strings.json | 32 ++ homeassistant/generated/config_flows.py | 1 + tests/components/august/mocks.py | 147 ++------ tests/components/august/test_binary_sensor.py | 19 + tests/components/august/test_config_flow.py | 195 +++++++++++ tests/components/august/test_gateway.py | 49 +++ tests/components/august/test_init.py | 198 ++++++----- tests/components/august/test_lock.py | 48 ++- tests/components/august/test_sensor.py | 84 +++++ .../august/get_doorbell.nobattery.json | 80 +++++ .../fixtures/august/get_doorbell.offline.json | 130 +++++++ .../august/get_lock.online.unknown_state.json | 59 ++++ .../get_lock.online_missing_doorsense.json | 50 +++ .../get_lock.online_with_doorsense.json | 2 +- 25 files changed, 1686 insertions(+), 444 deletions(-) create mode 100644 homeassistant/components/august/.translations/en.json create mode 100644 homeassistant/components/august/config_flow.py create mode 100644 homeassistant/components/august/const.py create mode 100644 homeassistant/components/august/exceptions.py create mode 100644 homeassistant/components/august/gateway.py create mode 100644 homeassistant/components/august/sensor.py create mode 100644 homeassistant/components/august/strings.json create mode 100644 tests/components/august/test_config_flow.py create mode 100644 tests/components/august/test_gateway.py create mode 100644 tests/components/august/test_sensor.py create mode 100644 tests/fixtures/august/get_doorbell.nobattery.json create mode 100644 tests/fixtures/august/get_doorbell.offline.json create mode 100644 tests/fixtures/august/get_lock.online.unknown_state.json create mode 100644 tests/fixtures/august/get_lock.online_missing_doorsense.json diff --git a/homeassistant/components/august/.translations/en.json b/homeassistant/components/august/.translations/en.json new file mode 100644 index 00000000000..1695d33cd63 --- /dev/null +++ b/homeassistant/components/august/.translations/en.json @@ -0,0 +1,32 @@ +{ + "config" : { + "error" : { + "unknown" : "Unexpected error", + "cannot_connect" : "Failed to connect, please try again", + "invalid_auth" : "Invalid authentication" + }, + "abort" : { + "already_configured" : "Account is already configured" + }, + "step" : { + "validation" : { + "title" : "Two factor authentication", + "data" : { + "code" : "Verification code" + }, + "description" : "Please check your {login_method} ({username}) and enter the verification code below" + }, + "user" : { + "description" : "If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'.", + "data" : { + "timeout" : "Timeout (seconds)", + "password" : "Password", + "username" : "Username", + "login_method" : "Login Method" + }, + "title" : "Setup an August account" + } + }, + "title" : "August" + } +} diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 95206b5bee1..c80101d5658 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -4,62 +4,45 @@ from datetime import timedelta from functools import partial import logging -from august.api import Api, AugustApiHTTPError -from august.authenticator import AuthenticationState, Authenticator, ValidationResult -from requests import RequestException, Session +from august.api import AugustApiHTTPError +from august.authenticator import ValidationResult +from august.doorbell import Doorbell +from august.lock import Lock +from requests import RequestException import voluptuous as vol -from homeassistant.const import ( - CONF_PASSWORD, - CONF_TIMEOUT, - CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, -) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME +from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle +from .const import ( + AUGUST_COMPONENTS, + CONF_ACCESS_TOKEN_CACHE_FILE, + CONF_INSTALL_ID, + CONF_LOGIN_METHOD, + DATA_AUGUST, + DEFAULT_AUGUST_CONFIG_FILE, + DEFAULT_NAME, + DEFAULT_TIMEOUT, + DOMAIN, + LOGIN_METHODS, + MIN_TIME_BETWEEN_ACTIVITY_UPDATES, + MIN_TIME_BETWEEN_DOORBELL_DETAIL_UPDATES, + MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES, + VERIFICATION_CODE_KEY, +) +from .exceptions import InvalidAuth, RequireValidation +from .gateway import AugustGateway + _LOGGER = logging.getLogger(__name__) -_CONFIGURING = {} - -DEFAULT_TIMEOUT = 10 -ACTIVITY_FETCH_LIMIT = 10 -ACTIVITY_INITIAL_FETCH_LIMIT = 20 - -CONF_LOGIN_METHOD = "login_method" -CONF_INSTALL_ID = "install_id" - -NOTIFICATION_ID = "august_notification" -NOTIFICATION_TITLE = "August Setup" - -AUGUST_CONFIG_FILE = ".august.conf" - -DATA_AUGUST = "august" -DOMAIN = "august" -DEFAULT_ENTITY_NAMESPACE = "august" - -# Limit battery, online, and hardware updates to 1800 seconds -# in order to reduce the number of api requests and -# avoid hitting rate limits -MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES = timedelta(seconds=1800) - -# Doorbells need to update more frequently than locks -# since we get an image from the doorbell api. Once -# py-august 0.18.0 is released doorbell status updates -# can be reduced in the same was as locks have been -MIN_TIME_BETWEEN_DOORBELL_DETAIL_UPDATES = timedelta(seconds=20) - -# Activity needs to be checked more frequently as the -# doorbell motion and rings are included here -MIN_TIME_BETWEEN_ACTIVITY_UPDATES = timedelta(seconds=10) +TWO_FA_REVALIDATE = "verify_configurator" DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) - -LOGIN_METHODS = ["phone", "email"] - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -75,138 +58,159 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -AUGUST_COMPONENTS = ["camera", "binary_sensor", "lock"] +async def async_request_validation(hass, config_entry, august_gateway): + """Request a new verification code from the user.""" -def request_configuration(hass, config, api, authenticator, token_refresh_lock): - """Request configuration steps from the user.""" + # + # In the future this should start a new config flow + # instead of using the legacy configurator + # + _LOGGER.error("Access token is no longer valid.") configurator = hass.components.configurator + entry_id = config_entry.entry_id - def august_configuration_callback(data): - """Run when the configuration callback is called.""" - - result = authenticator.validate_verification_code(data.get("verification_code")) + async def async_august_configuration_validation_callback(data): + code = data.get(VERIFICATION_CODE_KEY) + result = await hass.async_add_executor_job( + august_gateway.authenticator.validate_verification_code, code + ) if result == ValidationResult.INVALID_VERIFICATION_CODE: - configurator.notify_errors( - _CONFIGURING[DOMAIN], "Invalid verification code" + configurator.async_notify_errors( + hass.data[DOMAIN][entry_id][TWO_FA_REVALIDATE], + "Invalid verification code, please make sure you are using the latest code and try again.", ) elif result == ValidationResult.VALIDATED: - setup_august(hass, config, api, authenticator, token_refresh_lock) + return await async_setup_august(hass, config_entry, august_gateway) - if DOMAIN not in _CONFIGURING: - authenticator.send_verification_code() + return False - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - login_method = conf.get(CONF_LOGIN_METHOD) + if TWO_FA_REVALIDATE not in hass.data[DOMAIN][entry_id]: + await hass.async_add_executor_job( + august_gateway.authenticator.send_verification_code + ) - _CONFIGURING[DOMAIN] = configurator.request_config( - NOTIFICATION_TITLE, - august_configuration_callback, - description=f"Please check your {login_method} ({username}) and enter the verification code below", + entry_data = config_entry.data + login_method = entry_data.get(CONF_LOGIN_METHOD) + username = entry_data.get(CONF_USERNAME) + + hass.data[DOMAIN][entry_id][TWO_FA_REVALIDATE] = configurator.async_request_config( + f"{DEFAULT_NAME} ({username})", + async_august_configuration_validation_callback, + description="August must be re-verified. Please check your {} ({}) and enter the verification " + "code below".format(login_method, username), submit_caption="Verify", fields=[ - {"id": "verification_code", "name": "Verification code", "type": "string"} + {"id": VERIFICATION_CODE_KEY, "name": "Verification code", "type": "string"} ], ) + return -def setup_august(hass, config, api, authenticator, token_refresh_lock): +async def async_setup_august(hass, config_entry, august_gateway): """Set up the August component.""" - authentication = None + entry_id = config_entry.entry_id + hass.data[DOMAIN].setdefault(entry_id, {}) + try: - authentication = authenticator.authenticate() - except RequestException as ex: - _LOGGER.error("Unable to connect to August service: %s", str(ex)) - - hass.components.persistent_notification.create( - "Error: {ex}
You will need to restart hass after fixing.", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) - - state = authentication.state - - if state == AuthenticationState.AUTHENTICATED: - if DOMAIN in _CONFIGURING: - hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN)) - - hass.data[DATA_AUGUST] = AugustData( - hass, api, authentication, authenticator, token_refresh_lock - ) - - for component in AUGUST_COMPONENTS: - discovery.load_platform(hass, component, DOMAIN, {}, config) - - return True - if state == AuthenticationState.BAD_PASSWORD: - _LOGGER.error("Invalid password provided") + august_gateway.authenticate() + except RequireValidation: + await async_request_validation(hass, config_entry, august_gateway) return False - if state == AuthenticationState.REQUIRES_VALIDATION: - request_configuration(hass, config, api, authenticator, token_refresh_lock) + except InvalidAuth: + _LOGGER.error("Password is no longer valid. Please set up August again") + return False + + # We still use the configurator to get a new 2fa code + # when needed since config_flow doesn't have a way + # to re-request if it expires + if TWO_FA_REVALIDATE in hass.data[DOMAIN][entry_id]: + hass.components.configurator.async_request_done( + hass.data[DOMAIN][entry_id].pop(TWO_FA_REVALIDATE) + ) + + hass.data[DOMAIN][entry_id][DATA_AUGUST] = await hass.async_add_executor_job( + AugustData, hass, august_gateway + ) + + for component in AUGUST_COMPONENTS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + return True + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the August component from YAML.""" + + conf = config.get(DOMAIN) + hass.data.setdefault(DOMAIN, {}) + + if not conf: return True - return False + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_LOGIN_METHOD: conf.get(CONF_LOGIN_METHOD), + CONF_USERNAME: conf.get(CONF_USERNAME), + CONF_PASSWORD: conf.get(CONF_PASSWORD), + CONF_INSTALL_ID: conf.get(CONF_INSTALL_ID), + CONF_ACCESS_TOKEN_CACHE_FILE: DEFAULT_AUGUST_CONFIG_FILE, + }, + ) + ) + return True -async def async_setup(hass, config): - """Set up the August component.""" +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up August from a config entry.""" - conf = config[DOMAIN] - api_http_session = None - try: - api_http_session = Session() - except RequestException as ex: - _LOGGER.warning("Creating HTTP session failed with: %s", str(ex)) + august_gateway = AugustGateway(hass) + august_gateway.async_setup(entry.data) - api = Api(timeout=conf.get(CONF_TIMEOUT), http_session=api_http_session) + return await async_setup_august(hass, entry, august_gateway) - authenticator = Authenticator( - api, - conf.get(CONF_LOGIN_METHOD), - conf.get(CONF_USERNAME), - conf.get(CONF_PASSWORD), - install_id=conf.get(CONF_INSTALL_ID), - access_token_cache_file=hass.config.path(AUGUST_CONFIG_FILE), + +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 AUGUST_COMPONENTS + ] + ) ) - def close_http_session(event): - """Close API sessions used to connect to August.""" - _LOGGER.debug("Closing August HTTP sessions") - if api_http_session: - try: - api_http_session.close() - except RequestException: - pass + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) - _LOGGER.debug("August HTTP session closed.") - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_http_session) - _LOGGER.debug("Registered for Home Assistant stop event") - - token_refresh_lock = asyncio.Lock() - - return await hass.async_add_executor_job( - setup_august, hass, config, api, authenticator, token_refresh_lock - ) + return unload_ok class AugustData: """August data object.""" - def __init__(self, hass, api, authentication, authenticator, token_refresh_lock): + DEFAULT_ACTIVITY_FETCH_LIMIT = 10 + + def __init__(self, hass, august_gateway): """Init August data object.""" self._hass = hass - self._api = api - self._authenticator = authenticator - self._access_token = authentication.access_token - self._access_token_expires = authentication.access_token_expires + self._august_gateway = august_gateway + self._api = august_gateway.api - self._token_refresh_lock = token_refresh_lock - self._doorbells = self._api.get_doorbells(self._access_token) or [] - self._locks = self._api.get_operable_locks(self._access_token) or [] + self._doorbells = ( + self._api.get_doorbells(self._august_gateway.access_token) or [] + ) + self._locks = ( + self._api.get_operable_locks(self._august_gateway.access_token) or [] + ) self._house_ids = set() for device in self._doorbells + self._locks: self._house_ids.add(device.house_id) @@ -218,7 +222,7 @@ class AugustData: # We check the locks right away so we can # remove inoperative ones self._update_locks_detail() - + self._update_doorbells_detail() self._filter_inoperative_locks() @property @@ -236,22 +240,6 @@ class AugustData: """Return a list of locks.""" return self._locks - async def _async_refresh_access_token_if_needed(self): - """Refresh the august access token if needed.""" - if self._authenticator.should_refresh(): - async with self._token_refresh_lock: - await self._hass.async_add_executor_job(self._refresh_access_token) - - def _refresh_access_token(self): - refreshed_authentication = self._authenticator.refresh_access_token(force=False) - _LOGGER.info( - "Refreshed august access token. The old token expired at %s, and the new token expires at %s", - self._access_token_expires, - refreshed_authentication.access_token_expires, - ) - self._access_token = refreshed_authentication.access_token - self._access_token_expires = refreshed_authentication.access_token_expires - async def async_get_device_activities(self, device_id, *activity_types): """Return a list of activities.""" _LOGGER.debug("Getting device activities for %s", device_id) @@ -268,22 +256,23 @@ class AugustData: return next(iter(activities or []), None) @Throttle(MIN_TIME_BETWEEN_ACTIVITY_UPDATES) - async def _async_update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): + async def _async_update_device_activities(self, limit=DEFAULT_ACTIVITY_FETCH_LIMIT): """Update data object with latest from August API.""" # This is the only place we refresh the api token - await self._async_refresh_access_token_if_needed() + await self._august_gateway.async_refresh_access_token_if_needed() + return await self._hass.async_add_executor_job( - partial(self._update_device_activities, limit=ACTIVITY_FETCH_LIMIT) + partial(self._update_device_activities, limit=limit) ) - def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT): + def _update_device_activities(self, limit=DEFAULT_ACTIVITY_FETCH_LIMIT): _LOGGER.debug("Start retrieving device activities") for house_id in self.house_ids: _LOGGER.debug("Updating device activity for house id %s", house_id) activities = self._api.get_house_activities( - self._access_token, house_id, limit=limit + self._august_gateway.access_token, house_id, limit=limit ) device_ids = {a.device_id for a in activities} @@ -294,6 +283,14 @@ class AugustData: _LOGGER.debug("Completed retrieving device activities") + async def async_get_device_detail(self, device): + """Return the detail for a device.""" + if isinstance(device, Lock): + return await self.async_get_lock_detail(device.device_id) + if isinstance(device, Doorbell): + return await self.async_get_doorbell_detail(device.device_id) + raise ValueError + async def async_get_doorbell_detail(self, device_id): """Return doorbell detail.""" await self._async_update_doorbells_detail() @@ -342,8 +339,11 @@ class AugustData: _LOGGER.debug("Start retrieving %s detail", device_type) for device in devices: device_id = device.device_id + detail_by_id[device_id] = None try: - detail_by_id[device_id] = api_call(self._access_token, device_id) + detail_by_id[device_id] = api_call( + self._august_gateway.access_token, device_id + ) except RequestException as ex: _LOGGER.error( "Request error trying to retrieve %s details for %s. %s", @@ -351,10 +351,6 @@ class AugustData: device.device_name, ex, ) - detail_by_id[device_id] = None - except Exception: - detail_by_id[device_id] = None - raise _LOGGER.debug("Completed retrieving %s detail", device_type) return detail_by_id @@ -365,7 +361,7 @@ class AugustData: self.get_lock_name(device_id), "lock", self._api.lock_return_activities, - self._access_token, + self._august_gateway.access_token, device_id, ) @@ -375,7 +371,7 @@ class AugustData: self.get_lock_name(device_id), "unlock", self._api.unlock_return_activities, - self._access_token, + self._august_gateway.access_token, device_id, ) diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 41bf820319c..b5b65863eac 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -6,43 +6,44 @@ from august.activity import ActivityType from august.lock import LockDoorStatus from august.util import update_lock_detail_from_activity -from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, + BinarySensorDevice, +) -from . import DATA_AUGUST +from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) -async def _async_retrieve_online_state(data, doorbell): +async def _async_retrieve_online_state(data, detail): """Get the latest state of the sensor.""" - detail = await data.async_get_doorbell_detail(doorbell.device_id) - if detail is None: - return None - - return detail.is_online + return detail.is_online or detail.status == "standby" -async def _async_retrieve_motion_state(data, doorbell): +async def _async_retrieve_motion_state(data, detail): return await _async_activity_time_based_state( - data, doorbell, [ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING] + data, + detail.device_id, + [ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING], ) -async def _async_retrieve_ding_state(data, doorbell): +async def _async_retrieve_ding_state(data, detail): return await _async_activity_time_based_state( - data, doorbell, [ActivityType.DOORBELL_DING] + data, detail.device_id, [ActivityType.DOORBELL_DING] ) -async def _async_activity_time_based_state(data, doorbell, activity_types): +async def _async_activity_time_based_state(data, device_id, activity_types): """Get the latest state of the sensor.""" - latest = await data.async_get_latest_device_activity( - doorbell.device_id, *activity_types - ) + latest = await data.async_get_latest_device_activity(device_id, *activity_types) if latest is not None: start = latest.activity_start_time @@ -57,15 +58,19 @@ SENSOR_STATE_PROVIDER = 2 # sensor_type: [name, device_class, async_state_provider] SENSOR_TYPES_DOORBELL = { - "doorbell_ding": ["Ding", "occupancy", _async_retrieve_ding_state], - "doorbell_motion": ["Motion", "motion", _async_retrieve_motion_state], - "doorbell_online": ["Online", "connectivity", _async_retrieve_online_state], + "doorbell_ding": ["Ding", DEVICE_CLASS_OCCUPANCY, _async_retrieve_ding_state], + "doorbell_motion": ["Motion", DEVICE_CLASS_MOTION, _async_retrieve_motion_state], + "doorbell_online": [ + "Online", + DEVICE_CLASS_CONNECTIVITY, + _async_retrieve_online_state, + ], } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the August binary sensors.""" - data = hass.data[DATA_AUGUST] + data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] devices = [] for door in data.locks: @@ -98,6 +103,7 @@ class AugustDoorBinarySensor(BinarySensorDevice): self._door = door self._state = None self._available = False + self._firmware_version = None @property def available(self): @@ -132,6 +138,7 @@ class AugustDoorBinarySensor(BinarySensorDevice): lock_door_state = None if detail is not None: lock_door_state = detail.door_state + self._firmware_version = detail.firmware_version self._available = lock_door_state != LockDoorStatus.UNKNOWN self._state = lock_door_state == LockDoorStatus.OPEN @@ -141,6 +148,16 @@ class AugustDoorBinarySensor(BinarySensorDevice): """Get the unique of the door open binary sensor.""" return f"{self._door.device_id}_open" + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._door.device_id)}, + "name": self._door.device_name, + "manufacturer": DEFAULT_NAME, + "sw_version": self._firmware_version, + } + class AugustDoorbellBinarySensor(BinarySensorDevice): """Representation of an August binary sensor.""" @@ -152,6 +169,7 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): self._doorbell = doorbell self._state = None self._available = False + self._firmware_version = None @property def available(self): @@ -178,11 +196,21 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): async_state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][ SENSOR_STATE_PROVIDER ] - self._state = await async_state_provider(self._data, self._doorbell) + detail = await self._data.async_get_doorbell_detail(self._doorbell.device_id) # The doorbell will go into standby mode when there is no motion # for a short while. It will wake by itself when needed so we need # to consider is available or we will not report motion or dings - self._available = self._doorbell.is_online or self._doorbell.status == "standby" + if self.device_class == DEVICE_CLASS_CONNECTIVITY: + self._available = True + else: + self._available = detail is not None and ( + detail.is_online or detail.status == "standby" + ) + + self._state = None + if detail is not None: + self._firmware_version = detail.firmware_version + self._state = await async_state_provider(self._data, detail) @property def unique_id(self) -> str: @@ -191,3 +219,13 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): f"{self._doorbell.device_id}_" f"{SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME].lower()}" ) + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._doorbell.device_id)}, + "name": self._doorbell.device_name, + "manufacturer": "August", + "sw_version": self._firmware_version, + } diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index 5426d9574dc..ad31cb4ddc6 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -5,14 +5,14 @@ import requests from homeassistant.components.camera import Camera -from . import DATA_AUGUST, DEFAULT_TIMEOUT +from .const import DATA_AUGUST, DEFAULT_NAME, DEFAULT_TIMEOUT, DOMAIN -SCAN_INTERVAL = timedelta(seconds=10) +SCAN_INTERVAL = timedelta(seconds=5) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up August cameras.""" - data = hass.data[DATA_AUGUST] + data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] devices = [] for doorbell in data.doorbells: @@ -29,9 +29,11 @@ class AugustCamera(Camera): super().__init__() self._data = data self._doorbell = doorbell + self._doorbell_detail = None self._timeout = timeout self._image_url = None self._image_content = None + self._firmware_version = None @property def name(self): @@ -51,7 +53,7 @@ class AugustCamera(Camera): @property def brand(self): """Return the camera brand.""" - return "August" + return DEFAULT_NAME @property def model(self): @@ -60,16 +62,30 @@ class AugustCamera(Camera): async def async_camera_image(self): """Return bytes of camera image.""" - latest = await self._data.async_get_doorbell_detail(self._doorbell.device_id) + self._doorbell_detail = await self._data.async_get_doorbell_detail( + self._doorbell.device_id + ) + if self._doorbell_detail is None: + return None - if self._image_url is not latest.image_url: - self._image_url = latest.image_url + if self._image_url is not self._doorbell_detail.image_url: + self._image_url = self._doorbell_detail.image_url self._image_content = await self.hass.async_add_executor_job( self._camera_image ) - return self._image_content + async def async_update(self): + """Update camera data.""" + self._doorbell_detail = await self._data.async_get_doorbell_detail( + self._doorbell.device_id + ) + + if self._doorbell_detail is None: + return None + + self._firmware_version = self._doorbell_detail.firmware_version + def _camera_image(self): """Return bytes of camera image via http get.""" # Move this to py-august: see issue#32048 @@ -79,3 +95,13 @@ class AugustCamera(Camera): def unique_id(self) -> str: """Get the unique id of the camera.""" return f"{self._doorbell.device_id:s}_camera" + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._doorbell.device_id)}, + "name": self._doorbell.device_name + " Camera", + "manufacturer": DEFAULT_NAME, + "sw_version": self._firmware_version, + } diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py new file mode 100644 index 00000000000..1fa446ea566 --- /dev/null +++ b/homeassistant/components/august/config_flow.py @@ -0,0 +1,135 @@ +"""Config flow for August integration.""" +import logging + +from august.authenticator import ValidationResult +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME + +from .const import ( + CONF_LOGIN_METHOD, + DEFAULT_TIMEOUT, + LOGIN_METHODS, + VERIFICATION_CODE_KEY, +) +from .const import DOMAIN # pylint:disable=unused-import +from .exceptions import CannotConnect, InvalidAuth, RequireValidation +from .gateway import AugustGateway + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LOGIN_METHOD, default="phone"): vol.In(LOGIN_METHODS), + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int), + } +) + + +async def async_validate_input( + hass: core.HomeAssistant, data, august_gateway, +): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + + Request configuration steps from the user. + """ + + code = data.get(VERIFICATION_CODE_KEY) + + if code is not None: + result = await hass.async_add_executor_job( + august_gateway.authenticator.validate_verification_code, code + ) + _LOGGER.debug("Verification code validation: %s", result) + if result != ValidationResult.VALIDATED: + raise RequireValidation + + try: + august_gateway.authenticate() + except RequireValidation: + _LOGGER.debug( + "Requesting new verification code for %s via %s", + data.get(CONF_USERNAME), + data.get(CONF_LOGIN_METHOD), + ) + if code is None: + await hass.async_add_executor_job( + august_gateway.authenticator.send_verification_code + ) + raise + + return { + "title": data.get(CONF_USERNAME), + "data": august_gateway.config_entry(), + } + + +class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for August.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Store an AugustGateway().""" + self._august_gateway = None + self.user_auth_details = {} + super().__init__() + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + if self._august_gateway is None: + self._august_gateway = AugustGateway(self.hass) + errors = {} + if user_input is not None: + self._august_gateway.async_setup(user_input) + + try: + info = await async_validate_input( + self.hass, user_input, self._august_gateway, + ) + await self.async_set_unique_id(user_input[CONF_USERNAME]) + return self.async_create_entry(title=info["title"], data=info["data"]) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except RequireValidation: + self.user_auth_details = user_input + + return await self.async_step_validation() + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_validation(self, user_input=None): + """Handle validation (2fa) step.""" + if user_input: + return await self.async_step_user({**self.user_auth_details, **user_input}) + + return self.async_show_form( + step_id="validation", + data_schema=vol.Schema( + {vol.Required(VERIFICATION_CODE_KEY): vol.All(str, vol.Strip)} + ), + description_placeholders={ + CONF_USERNAME: self.user_auth_details.get(CONF_USERNAME), + CONF_LOGIN_METHOD: self.user_auth_details.get(CONF_LOGIN_METHOD), + }, + ) + + async def async_step_import(self, user_input): + """Handle import.""" + await self.async_set_unique_id(user_input[CONF_USERNAME]) + self._abort_if_unique_id_configured() + + return await self.async_step_user(user_input) diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py new file mode 100644 index 00000000000..6e367e96ac5 --- /dev/null +++ b/homeassistant/components/august/const.py @@ -0,0 +1,42 @@ +"""Constants for August devices.""" + +from datetime import timedelta + +DEFAULT_TIMEOUT = 10 + +CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file" +CONF_LOGIN_METHOD = "login_method" +CONF_INSTALL_ID = "install_id" + +VERIFICATION_CODE_KEY = "verification_code" + +NOTIFICATION_ID = "august_notification" +NOTIFICATION_TITLE = "August" + +DEFAULT_AUGUST_CONFIG_FILE = ".august.conf" + +DATA_AUGUST = "data_august" + +DEFAULT_NAME = "August" +DOMAIN = "august" + +# Limit battery, online, and hardware updates to 1800 seconds +# in order to reduce the number of api requests and +# avoid hitting rate limits +MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES = timedelta(seconds=1800) + +# Doorbells need to update more frequently than locks +# since we get an image from the doorbell api. Once +# py-august 0.18.0 is released doorbell status updates +# can be reduced in the same was as locks have been +MIN_TIME_BETWEEN_DOORBELL_DETAIL_UPDATES = timedelta(seconds=20) + +# Activity needs to be checked more frequently as the +# doorbell motion and rings are included here +MIN_TIME_BETWEEN_ACTIVITY_UPDATES = timedelta(seconds=10) + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) + +LOGIN_METHODS = ["phone", "email"] + +AUGUST_COMPONENTS = ["camera", "binary_sensor", "lock", "sensor"] diff --git a/homeassistant/components/august/exceptions.py b/homeassistant/components/august/exceptions.py new file mode 100644 index 00000000000..78c467ab3a1 --- /dev/null +++ b/homeassistant/components/august/exceptions.py @@ -0,0 +1,15 @@ +"""Shared excecption for the august integration.""" + +from homeassistant import exceptions + + +class RequireValidation(exceptions.HomeAssistantError): + """Error to indicate we require validation (2fa).""" + + +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/august/gateway.py b/homeassistant/components/august/gateway.py new file mode 100644 index 00000000000..e01e2fb9a8f --- /dev/null +++ b/homeassistant/components/august/gateway.py @@ -0,0 +1,143 @@ +"""Handle August connection setup and authentication.""" + +import asyncio +import logging + +from august.api import Api +from august.authenticator import AuthenticationState, Authenticator +from requests import RequestException, Session + +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME +from homeassistant.core import callback + +from .const import ( + CONF_ACCESS_TOKEN_CACHE_FILE, + CONF_INSTALL_ID, + CONF_LOGIN_METHOD, + DEFAULT_AUGUST_CONFIG_FILE, + VERIFICATION_CODE_KEY, +) +from .exceptions import CannotConnect, InvalidAuth, RequireValidation + +_LOGGER = logging.getLogger(__name__) + + +class AugustGateway: + """Handle the connection to August.""" + + def __init__(self, hass): + """Init the connection.""" + self._api_http_session = Session() + self._token_refresh_lock = asyncio.Lock() + self._hass = hass + self._config = None + self._api = None + self._authenticator = None + self._authentication = None + + @property + def authenticator(self): + """August authentication object from py-august.""" + return self._authenticator + + @property + def authentication(self): + """August authentication object from py-august.""" + return self._authentication + + @property + def access_token(self): + """Access token for the api.""" + return self._authentication.access_token + + @property + def api(self): + """August api object from py-august.""" + return self._api + + def config_entry(self): + """Config entry.""" + return { + CONF_LOGIN_METHOD: self._config[CONF_LOGIN_METHOD], + CONF_USERNAME: self._config[CONF_USERNAME], + CONF_PASSWORD: self._config[CONF_PASSWORD], + CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID), + CONF_TIMEOUT: self._config.get(CONF_TIMEOUT), + CONF_ACCESS_TOKEN_CACHE_FILE: self._config[CONF_ACCESS_TOKEN_CACHE_FILE], + } + + @callback + def async_setup(self, conf): + """Create the api and authenticator objects.""" + if conf.get(VERIFICATION_CODE_KEY): + return + if conf.get(CONF_ACCESS_TOKEN_CACHE_FILE) is None: + conf[ + CONF_ACCESS_TOKEN_CACHE_FILE + ] = f".{conf[CONF_USERNAME]}{DEFAULT_AUGUST_CONFIG_FILE}" + self._config = conf + + self._api = Api( + timeout=self._config.get(CONF_TIMEOUT), http_session=self._api_http_session, + ) + + self._authenticator = Authenticator( + self._api, + self._config[CONF_LOGIN_METHOD], + self._config[CONF_USERNAME], + self._config[CONF_PASSWORD], + install_id=self._config.get(CONF_INSTALL_ID), + access_token_cache_file=self._hass.config.path( + self._config[CONF_ACCESS_TOKEN_CACHE_FILE] + ), + ) + + def authenticate(self): + """Authenticate with the details provided to setup.""" + self._authentication = None + try: + self._authentication = self.authenticator.authenticate() + except RequestException as ex: + _LOGGER.error("Unable to connect to August service: %s", str(ex)) + raise CannotConnect + + if self._authentication.state == AuthenticationState.BAD_PASSWORD: + raise InvalidAuth + + if self._authentication.state == AuthenticationState.REQUIRES_VALIDATION: + raise RequireValidation + + if self._authentication.state != AuthenticationState.AUTHENTICATED: + _LOGGER.error( + "Unknown authentication state: %s", self._authentication.state + ) + raise InvalidAuth + + return self._authentication + + async def async_refresh_access_token_if_needed(self): + """Refresh the august access token if needed.""" + if self.authenticator.should_refresh(): + async with self._token_refresh_lock: + await self._hass.async_add_executor_job(self._refresh_access_token) + + def _refresh_access_token(self): + refreshed_authentication = self.authenticator.refresh_access_token(force=False) + _LOGGER.info( + "Refreshed august access token. The old token expired at %s, and the new token expires at %s", + self.authentication.access_token_expires, + refreshed_authentication.access_token_expires, + ) + self._authentication = refreshed_authentication + + def _close_http_session(self): + """Close API sessions used to connect to August.""" + if self._api_http_session: + try: + self._api_http_session.close() + except RequestException: + pass + + def __del__(self): + """Close out the http session on destroy.""" + self._close_http_session() diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index a805fa2657a..2db1fe5eede 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -9,16 +9,16 @@ from august.util import update_lock_detail_from_activity from homeassistant.components.lock import LockDevice from homeassistant.const import ATTR_BATTERY_LEVEL -from . import DATA_AUGUST +from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up August locks.""" - data = hass.data[DATA_AUGUST] + data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] devices = [] for lock in data.locks: @@ -39,6 +39,7 @@ class AugustLock(LockDevice): self._lock_detail = None self._changed_by = None self._available = False + self._firmware_version = None async def async_lock(self, **kwargs): """Lock the device.""" @@ -59,12 +60,18 @@ class AugustLock(LockDevice): self.schedule_update_ha_state() def _update_lock_status_from_detail(self): - lock_status = self._lock_detail.lock_status - if self._lock_status != lock_status: - self._lock_status = lock_status + detail = self._lock_detail + lock_status = None + self._available = False + + if detail is not None: + lock_status = detail.lock_status self._available = ( lock_status is not None and lock_status != LockStatus.UNKNOWN ) + + if self._lock_status != lock_status: + self._lock_status = lock_status return True return False @@ -77,7 +84,11 @@ class AugustLock(LockDevice): if lock_activity is not None: self._changed_by = lock_activity.operated_by - update_lock_detail_from_activity(self._lock_detail, lock_activity) + if self._lock_detail is not None: + update_lock_detail_from_activity(self._lock_detail, lock_activity) + + if self._lock_detail is not None: + self._firmware_version = self._lock_detail.firmware_version self._update_lock_status_from_detail() @@ -94,7 +105,8 @@ class AugustLock(LockDevice): @property def is_locked(self): """Return true if device is on.""" - + if self._lock_status is None or self._lock_status is LockStatus.UNKNOWN: + return None return self._lock_status is LockStatus.LOCKED @property @@ -115,6 +127,16 @@ class AugustLock(LockDevice): return attributes + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._lock.device_id)}, + "name": self._lock.device_name, + "manufacturer": DEFAULT_NAME, + "sw_version": self._firmware_version, + } + @property def unique_id(self) -> str: """Get the unique id of the lock.""" diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 53bbdaaa33f..0523ed178aa 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -2,7 +2,14 @@ "domain": "august", "name": "August", "documentation": "https://www.home-assistant.io/integrations/august", - "requirements": ["py-august==0.17.0"], - "dependencies": ["configurator"], - "codeowners": ["@bdraco"] -} + "requirements": [ + "py-august==0.17.0" + ], + "dependencies": [ + "configurator" + ], + "codeowners": [ + "@bdraco" + ], + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py new file mode 100644 index 00000000000..f1bfd0ad8b4 --- /dev/null +++ b/homeassistant/components/august/sensor.py @@ -0,0 +1,158 @@ +"""Support for August sensors.""" +from datetime import timedelta +import logging + +from homeassistant.components.sensor import DEVICE_CLASS_BATTERY +from homeassistant.helpers.entity import Entity + +from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=5) + + +async def _async_retrieve_device_battery_state(detail): + """Get the latest state of the sensor.""" + if detail is None: + return None + + return detail.battery_level + + +async def _async_retrieve_linked_keypad_battery_state(detail): + """Get the latest state of the sensor.""" + if detail is None: + return None + + if detail.keypad is None: + return None + + battery_level = detail.keypad.battery_level + + _LOGGER.debug("keypad battery level: %s %s", battery_level, battery_level.lower()) + + if battery_level.lower() == "full": + return 100 + if battery_level.lower() == "medium": + return 60 + if battery_level.lower() == "low": + return 10 + + return 0 + + +SENSOR_TYPES_BATTERY = { + "device_battery": { + "name": "Battery", + "async_state_provider": _async_retrieve_device_battery_state, + }, + "linked_keypad_battery": { + "name": "Keypad Battery", + "async_state_provider": _async_retrieve_linked_keypad_battery_state, + }, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the August sensors.""" + data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] + devices = [] + + batteries = { + "device_battery": [], + "linked_keypad_battery": [], + } + for device in data.doorbells: + batteries["device_battery"].append(device) + for device in data.locks: + batteries["device_battery"].append(device) + batteries["linked_keypad_battery"].append(device) + + for sensor_type in SENSOR_TYPES_BATTERY: + for device in batteries[sensor_type]: + async_state_provider = SENSOR_TYPES_BATTERY[sensor_type][ + "async_state_provider" + ] + detail = await data.async_get_device_detail(device) + state = await async_state_provider(detail) + sensor_name = SENSOR_TYPES_BATTERY[sensor_type]["name"] + if state is None: + _LOGGER.debug( + "Not adding battery sensor %s for %s because it is not present", + sensor_name, + device.device_name, + ) + else: + _LOGGER.debug( + "Adding battery sensor %s for %s", sensor_name, device.device_name, + ) + devices.append(AugustBatterySensor(data, sensor_type, device)) + + async_add_entities(devices, True) + + +class AugustBatterySensor(Entity): + """Representation of an August sensor.""" + + def __init__(self, data, sensor_type, device): + """Initialize the sensor.""" + self._data = data + self._sensor_type = sensor_type + self._device = device + self._state = None + self._available = False + self._firmware_version = None + + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "%" # UNIT_PERCENTAGE will be available after PR#32094 + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS_BATTERY + + @property + def name(self): + """Return the name of the sensor.""" + device_name = self._device.device_name + sensor_name = SENSOR_TYPES_BATTERY[self._sensor_type]["name"] + return f"{device_name} {sensor_name}" + + async def async_update(self): + """Get the latest state of the sensor.""" + async_state_provider = SENSOR_TYPES_BATTERY[self._sensor_type][ + "async_state_provider" + ] + detail = await self._data.async_get_device_detail(self._device) + self._state = await async_state_provider(detail) + self._available = self._state is not None + if detail is not None: + self._firmware_version = detail.firmware_version + + @property + def unique_id(self) -> str: + """Get the unique id of the device sensor.""" + return f"{self._device.device_id}_{self._sensor_type}" + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._device.device_id)}, + "name": self._device.device_name, + "manufacturer": DEFAULT_NAME, + "sw_version": self._firmware_version, + } diff --git a/homeassistant/components/august/strings.json b/homeassistant/components/august/strings.json new file mode 100644 index 00000000000..1695d33cd63 --- /dev/null +++ b/homeassistant/components/august/strings.json @@ -0,0 +1,32 @@ +{ + "config" : { + "error" : { + "unknown" : "Unexpected error", + "cannot_connect" : "Failed to connect, please try again", + "invalid_auth" : "Invalid authentication" + }, + "abort" : { + "already_configured" : "Account is already configured" + }, + "step" : { + "validation" : { + "title" : "Two factor authentication", + "data" : { + "code" : "Verification code" + }, + "description" : "Please check your {login_method} ({username}) and enter the verification code below" + }, + "user" : { + "description" : "If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'.", + "data" : { + "timeout" : "Timeout (seconds)", + "password" : "Password", + "username" : "Username", + "login_method" : "Login Method" + }, + "title" : "Setup an August account" + } + }, + "title" : "August" + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 39a9bccf607..cb12b13afed 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -12,6 +12,7 @@ FLOWS = [ "almond", "ambiclimate", "ambient_station", + "august", "axis", "brother", "cast", diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py index d1c3efb00e8..7b7fcd9f28c 100644 --- a/tests/components/august/mocks.py +++ b/tests/components/august/mocks.py @@ -1,16 +1,13 @@ """Mocks for the august component.""" -import datetime import json import os import time from unittest.mock import MagicMock, PropertyMock from asynctest import mock -from august.activity import Activity, DoorOperationActivity, LockOperationActivity -from august.api import Api +from august.activity import DoorOperationActivity, LockOperationActivity from august.authenticator import AuthenticationState from august.doorbell import Doorbell, DoorbellDetail -from august.exceptions import AugustApiHTTPError from august.lock import Lock, LockDetail from homeassistant.components.august import ( @@ -18,10 +15,8 @@ from homeassistant.components.august import ( CONF_PASSWORD, CONF_USERNAME, DOMAIN, - AugustData, ) from homeassistant.setup import async_setup_component -from homeassistant.util import dt from tests.common import load_fixture @@ -37,8 +32,8 @@ def _mock_get_config(): } -@mock.patch("homeassistant.components.august.Api") -@mock.patch("homeassistant.components.august.Authenticator.authenticate") +@mock.patch("homeassistant.components.august.gateway.Api") +@mock.patch("homeassistant.components.august.gateway.Authenticator.authenticate") async def _mock_setup_august(hass, api_instance, authenticate_mock, api_mock): """Set up august integration.""" authenticate_mock.side_effect = MagicMock( @@ -84,6 +79,9 @@ async def _create_august_with_devices(hass, devices, api_call_side_effects=None) def get_lock_detail_side_effect(access_token, device_id): return _get_device_detail("locks", device_id) + def get_doorbell_detail_side_effect(access_token, device_id): + return _get_device_detail("doorbells", device_id) + def get_operable_locks_side_effect(access_token): return _get_base_devices("locks") @@ -109,6 +107,8 @@ async def _create_august_with_devices(hass, devices, api_call_side_effects=None) if "get_lock_detail" not in api_call_side_effects: api_call_side_effects["get_lock_detail"] = get_lock_detail_side_effect + if "get_doorbell_detail" not in api_call_side_effects: + api_call_side_effects["get_doorbell_detail"] = get_doorbell_detail_side_effect if "get_operable_locks" not in api_call_side_effects: api_call_side_effects["get_operable_locks"] = get_operable_locks_side_effect if "get_doorbells" not in api_call_side_effects: @@ -143,6 +143,11 @@ async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects): if api_call_side_effects["get_doorbells"]: api_instance.get_doorbells.side_effect = api_call_side_effects["get_doorbells"] + if api_call_side_effects["get_doorbell_detail"]: + api_instance.get_doorbell_detail.side_effect = api_call_side_effects[ + "get_doorbell_detail" + ] + if api_call_side_effects["get_house_activities"]: api_instance.get_house_activities.side_effect = api_call_side_effects[ "get_house_activities" @@ -160,106 +165,6 @@ async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects): return await _mock_setup_august(hass, api_instance) -class MockAugustApiFailing(Api): - """A mock for py-august Api class that always has an AugustApiHTTPError.""" - - def _call_api(self, *args, **kwargs): - """Mock the time activity started.""" - raise AugustApiHTTPError("This should bubble up as its user consumable") - - -class MockActivity(Activity): - """A mock for py-august Activity class.""" - - def __init__( - self, action=None, activity_start_timestamp=None, activity_end_timestamp=None - ): - """Init the py-august Activity class mock.""" - self._action = action - self._activity_start_timestamp = activity_start_timestamp - self._activity_end_timestamp = activity_end_timestamp - - @property - def activity_start_time(self): - """Mock the time activity started.""" - return datetime.datetime.fromtimestamp(self._activity_start_timestamp) - - @property - def activity_end_time(self): - """Mock the time activity ended.""" - return datetime.datetime.fromtimestamp(self._activity_end_timestamp) - - @property - def action(self): - """Mock the action.""" - return self._action - - -class MockAugustComponentData(AugustData): - """A wrapper to mock AugustData.""" - - # AugustData support multiple locks, however for the purposes of - # mocking we currently only mock one lockid - - def __init__( - self, - last_lock_status_update_timestamp=1, - last_door_state_update_timestamp=1, - api=MockAugustApiFailing(), - access_token="mocked_access_token", - locks=[], - doorbells=[], - ): - """Mock AugustData.""" - self._last_lock_status_update_time_utc = dt.as_utc( - datetime.datetime.fromtimestamp(last_lock_status_update_timestamp) - ) - self._last_door_state_update_time_utc = dt.as_utc( - datetime.datetime.fromtimestamp(last_lock_status_update_timestamp) - ) - self._api = api - self._access_token = access_token - self._locks = locks - self._doorbells = doorbells - self._lock_status_by_id = {} - self._lock_last_status_update_time_utc_by_id = {} - - def set_mocked_locks(self, locks): - """Set lock mocks.""" - self._locks = locks - - def set_mocked_doorbells(self, doorbells): - """Set doorbell mocks.""" - self._doorbells = doorbells - - def get_last_lock_status_update_time_utc(self, device_id): - """Mock to get last lock status update time.""" - return self._last_lock_status_update_time_utc - - def set_last_lock_status_update_time_utc(self, device_id, update_time): - """Mock to set last lock status update time.""" - self._last_lock_status_update_time_utc = update_time - - def get_last_door_state_update_time_utc(self, device_id): - """Mock to get last door state update time.""" - return self._last_door_state_update_time_utc - - def set_last_door_state_update_time_utc(self, device_id, update_time): - """Mock to set last door state update time.""" - self._last_door_state_update_time_utc = update_time - - -def _mock_august_authenticator(): - authenticator = MagicMock(name="august.authenticator") - authenticator.should_refresh = MagicMock( - name="august.authenticator.should_refresh", return_value=0 - ) - authenticator.refresh_access_token = MagicMock( - name="august.authenticator.refresh_access_token" - ) - return authenticator - - def _mock_august_authentication(token_text, token_timestamp): authentication = MagicMock(name="august.authentication") type(authentication).state = PropertyMock( @@ -321,20 +226,12 @@ def _mock_august_lock_data(lockid="mocklockid1", houseid="mockhouseid1"): } -def _mock_operative_august_lock_detail(lockid): - operative_lock_detail_data = _mock_august_lock_data(lockid=lockid) - return LockDetail(operative_lock_detail_data) +async def _mock_operative_august_lock_detail(hass): + return await _mock_lock_from_fixture(hass, "get_lock.online.json") -def _mock_inoperative_august_lock_detail(lockid): - inoperative_lock_detail_data = _mock_august_lock_data(lockid=lockid) - del inoperative_lock_detail_data["Bridge"] - return LockDetail(inoperative_lock_detail_data) - - -def _mock_doorsense_enabled_august_lock_detail(lockid): - doorsense_lock_detail_data = _mock_august_lock_data(lockid=lockid) - return LockDetail(doorsense_lock_detail_data) +async def _mock_inoperative_august_lock_detail(hass): + return await _mock_lock_from_fixture(hass, "get_lock.offline.json") async def _mock_lock_from_fixture(hass, path): @@ -354,10 +251,12 @@ async def _load_json_fixture(hass, path): return json.loads(fixture) -def _mock_doorsense_missing_august_lock_detail(lockid): - doorsense_lock_detail_data = _mock_august_lock_data(lockid=lockid) - del doorsense_lock_detail_data["LockStatus"]["doorState"] - return LockDetail(doorsense_lock_detail_data) +async def _mock_doorsense_enabled_august_lock_detail(hass): + return await _mock_lock_from_fixture(hass, "get_lock.online_with_doorsense.json") + + +async def _mock_doorsense_missing_august_lock_detail(hass): + return await _mock_lock_from_fixture(hass, "get_lock.online_missing_doorsense.json") def _mock_lock_operation_activity(lock, action): diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 47acfb59c72..260f86120f3 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -9,6 +9,7 @@ from homeassistant.const import ( SERVICE_UNLOCK, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, ) from tests.components.august.mocks import ( @@ -69,3 +70,21 @@ async def test_create_doorbell(hass): "binary_sensor.k98gidt45gul_name_ding" ) assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF + + +async def test_create_doorbell_offline(hass): + """Test creation of a doorbell that is offline.""" + doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") + doorbell_details = [doorbell_one] + await _create_august_with_devices(hass, doorbell_details) + + binary_sensor_tmt100_name_motion = hass.states.get( + "binary_sensor.tmt100_name_motion" + ) + assert binary_sensor_tmt100_name_motion.state == STATE_UNAVAILABLE + binary_sensor_tmt100_name_online = hass.states.get( + "binary_sensor.tmt100_name_online" + ) + assert binary_sensor_tmt100_name_online.state == STATE_OFF + binary_sensor_tmt100_name_ding = hass.states.get("binary_sensor.tmt100_name_ding") + assert binary_sensor_tmt100_name_ding.state == STATE_UNAVAILABLE diff --git a/tests/components/august/test_config_flow.py b/tests/components/august/test_config_flow.py new file mode 100644 index 00000000000..3e81986d9f4 --- /dev/null +++ b/tests/components/august/test_config_flow.py @@ -0,0 +1,195 @@ +"""Test the August config flow.""" +from asynctest import patch +from august.authenticator import ValidationResult + +from homeassistant import config_entries, setup +from homeassistant.components.august.const import ( + CONF_ACCESS_TOKEN_CACHE_FILE, + CONF_INSTALL_ID, + CONF_LOGIN_METHOD, + DOMAIN, + VERIFICATION_CODE_KEY, +) +from homeassistant.components.august.exceptions import ( + CannotConnect, + InvalidAuth, + RequireValidation, +) +from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME + + +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( + "homeassistant.components.august.config_flow.AugustGateway.authenticate", + return_value=True, + ), patch( + "homeassistant.components.august.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.august.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOGIN_METHOD: "email", + CONF_USERNAME: "my@email.tld", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "my@email.tld" + assert result2["data"] == { + CONF_LOGIN_METHOD: "email", + CONF_USERNAME: "my@email.tld", + CONF_PASSWORD: "test-password", + CONF_INSTALL_ID: None, + CONF_TIMEOUT: 10, + CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf", + } + 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( + "homeassistant.components.august.config_flow.AugustGateway.authenticate", + side_effect=InvalidAuth, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOGIN_METHOD: "email", + CONF_USERNAME: "my@email.tld", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +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.august.config_flow.AugustGateway.authenticate", + side_effect=CannotConnect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOGIN_METHOD: "email", + CONF_USERNAME: "my@email.tld", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_needs_validate(hass): + """Test we present validation when we need to validate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.august.config_flow.AugustGateway.authenticate", + side_effect=RequireValidation, + ), patch( + "homeassistant.components.august.gateway.Authenticator.send_verification_code", + return_value=True, + ) as mock_send_verification_code: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_LOGIN_METHOD: "email", + CONF_USERNAME: "my@email.tld", + CONF_PASSWORD: "test-password", + }, + ) + + assert len(mock_send_verification_code.mock_calls) == 1 + assert result2["type"] == "form" + assert result2["errors"] is None + assert result2["step_id"] == "validation" + + # Try with the WRONG verification code give us the form back again + with patch( + "homeassistant.components.august.config_flow.AugustGateway.authenticate", + side_effect=RequireValidation, + ), patch( + "homeassistant.components.august.gateway.Authenticator.validate_verification_code", + return_value=ValidationResult.INVALID_VERIFICATION_CODE, + ) as mock_validate_verification_code, patch( + "homeassistant.components.august.gateway.Authenticator.send_verification_code", + return_value=True, + ) as mock_send_verification_code, patch( + "homeassistant.components.august.async_setup", return_value=True + ) as mock_setup, patch( + "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"}, + ) + + # Make sure we do not resend the code again + # so they have a chance to retry + assert len(mock_send_verification_code.mock_calls) == 0 + assert len(mock_validate_verification_code.mock_calls) == 1 + assert result3["type"] == "form" + assert result3["errors"] is None + assert result3["step_id"] == "validation" + + # Try with the CORRECT verification code and we setup + with patch( + "homeassistant.components.august.config_flow.AugustGateway.authenticate", + return_value=True, + ), patch( + "homeassistant.components.august.gateway.Authenticator.validate_verification_code", + return_value=ValidationResult.VALIDATED, + ) as mock_validate_verification_code, patch( + "homeassistant.components.august.gateway.Authenticator.send_verification_code", + return_value=True, + ) as mock_send_verification_code, patch( + "homeassistant.components.august.async_setup", return_value=True + ) as mock_setup, patch( + "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"}, + ) + + assert len(mock_send_verification_code.mock_calls) == 0 + assert len(mock_validate_verification_code.mock_calls) == 1 + assert result4["type"] == "create_entry" + assert result4["title"] == "my@email.tld" + assert result4["data"] == { + CONF_LOGIN_METHOD: "email", + CONF_USERNAME: "my@email.tld", + CONF_PASSWORD: "test-password", + CONF_INSTALL_ID: None, + CONF_TIMEOUT: 10, + CONF_ACCESS_TOKEN_CACHE_FILE: ".my@email.tld.august.conf", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/august/test_gateway.py b/tests/components/august/test_gateway.py new file mode 100644 index 00000000000..38696d316ca --- /dev/null +++ b/tests/components/august/test_gateway.py @@ -0,0 +1,49 @@ +"""The gateway tests for the august platform.""" +from unittest.mock import MagicMock + +from asynctest import mock + +from homeassistant.components.august.const import DOMAIN +from homeassistant.components.august.gateway import AugustGateway + +from tests.components.august.mocks import _mock_august_authentication, _mock_get_config + + +async def test_refresh_access_token(hass): + """Test token refreshes.""" + await _patched_refresh_access_token(hass, "new_token", 5678) + + +@mock.patch("homeassistant.components.august.gateway.Authenticator.authenticate") +@mock.patch("homeassistant.components.august.gateway.Authenticator.should_refresh") +@mock.patch( + "homeassistant.components.august.gateway.Authenticator.refresh_access_token" +) +async def _patched_refresh_access_token( + hass, + new_token, + new_token_expire_time, + refresh_access_token_mock, + should_refresh_mock, + authenticate_mock, +): + authenticate_mock.side_effect = MagicMock( + return_value=_mock_august_authentication("original_token", 1234) + ) + august_gateway = AugustGateway(hass) + mocked_config = _mock_get_config() + august_gateway.async_setup(mocked_config[DOMAIN]) + august_gateway.authenticate() + + should_refresh_mock.return_value = False + await august_gateway.async_refresh_access_token_if_needed() + refresh_access_token_mock.assert_not_called() + + should_refresh_mock.return_value = True + refresh_access_token_mock.return_value = _mock_august_authentication( + new_token, new_token_expire_time + ) + await august_gateway.async_refresh_access_token_if_needed() + refresh_access_token_mock.assert_called() + assert august_gateway.access_token == new_token + assert august_gateway.authentication.access_token_expires == new_token_expire_time diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index eb50e37561e..4767f24e113 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -1,136 +1,146 @@ """The tests for the august platform.""" -import asyncio -from unittest.mock import MagicMock +from asynctest import patch +from august.exceptions import AugustApiHTTPError -from august.lock import LockDetail -from requests import RequestException - -from homeassistant.components import august +from homeassistant import setup +from homeassistant.components.august.const import ( + CONF_ACCESS_TOKEN_CACHE_FILE, + CONF_INSTALL_ID, + CONF_LOGIN_METHOD, + DEFAULT_AUGUST_CONFIG_FILE, +) +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_PASSWORD, + CONF_TIMEOUT, + CONF_USERNAME, + SERVICE_LOCK, + SERVICE_UNLOCK, + STATE_LOCKED, + STATE_ON, +) from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component from tests.components.august.mocks import ( - MockAugustApiFailing, - MockAugustComponentData, - _mock_august_authentication, - _mock_august_authenticator, - _mock_august_lock, + _create_august_with_devices, _mock_doorsense_enabled_august_lock_detail, _mock_doorsense_missing_august_lock_detail, + _mock_get_config, _mock_inoperative_august_lock_detail, _mock_operative_august_lock_detail, ) -def test_get_lock_name(): - """Get the lock name from August data.""" - data = MockAugustComponentData(last_lock_status_update_timestamp=1) - lock = _mock_august_lock() - data.set_mocked_locks([lock]) - assert data.get_lock_name("mocklockid1") == "mocklockid1 Name" +async def test_unlock_throws_august_api_http_error(hass): + """Test unlock throws correct error on http error.""" + mocked_lock_detail = await _mock_operative_august_lock_detail(hass) + def _unlock_return_activities_side_effect(access_token, device_id): + raise AugustApiHTTPError("This should bubble up as its user consumable") -def test_unlock_throws_august_api_http_error(): - """Test unlock.""" - data = MockAugustComponentData(api=MockAugustApiFailing()) - lock = _mock_august_lock() - data.set_mocked_locks([lock]) + await _create_august_with_devices( + hass, + [mocked_lock_detail], + api_call_side_effects={ + "unlock_return_activities": _unlock_return_activities_side_effect + }, + ) last_err = None + data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"} try: - data.unlock("mocklockid1") + await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True) except HomeAssistantError as err: last_err = err assert ( str(last_err) - == "mocklockid1 Name: This should bubble up as its user consumable" + == "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user consumable" ) -def test_lock_throws_august_api_http_error(): - """Test lock.""" - data = MockAugustComponentData(api=MockAugustApiFailing()) - lock = _mock_august_lock() - data.set_mocked_locks([lock]) +async def test_lock_throws_august_api_http_error(hass): + """Test lock throws correct error on http error.""" + mocked_lock_detail = await _mock_operative_august_lock_detail(hass) + + def _lock_return_activities_side_effect(access_token, device_id): + raise AugustApiHTTPError("This should bubble up as its user consumable") + + await _create_august_with_devices( + hass, + [mocked_lock_detail], + api_call_side_effects={ + "lock_return_activities": _lock_return_activities_side_effect + }, + ) last_err = None + data = {ATTR_ENTITY_ID: "lock.a6697750d607098bae8d6baa11ef8063_name"} try: - data.unlock("mocklockid1") + await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True) except HomeAssistantError as err: last_err = err assert ( str(last_err) - == "mocklockid1 Name: This should bubble up as its user consumable" + == "A6697750D607098BAE8D6BAA11EF8063 Name: This should bubble up as its user consumable" ) -def test_inoperative_locks_are_filtered_out(): +async def test_inoperative_locks_are_filtered_out(hass): """Ensure inoperative locks do not get setup.""" - august_operative_lock = _mock_operative_august_lock_detail("oplockid1") - data = _create_august_data_with_lock_details( - [august_operative_lock, _mock_inoperative_august_lock_detail("inoplockid1")] + august_operative_lock = await _mock_operative_august_lock_detail(hass) + august_inoperative_lock = await _mock_inoperative_august_lock_detail(hass) + await _create_august_with_devices( + hass, [august_operative_lock, august_inoperative_lock] ) - assert len(data.locks) == 1 - assert data.locks[0].device_id == "oplockid1" + lock_abc_name = hass.states.get("lock.abc_name") + assert lock_abc_name is None + lock_a6697750d607098bae8d6baa11ef8063_name = hass.states.get( + "lock.a6697750d607098bae8d6baa11ef8063_name" + ) + assert lock_a6697750d607098bae8d6baa11ef8063_name.state == STATE_LOCKED -def test_lock_has_doorsense(): +async def test_lock_has_doorsense(hass): """Check to see if a lock has doorsense.""" - data = _create_august_data_with_lock_details( - [ - _mock_doorsense_enabled_august_lock_detail("doorsenselock1"), - _mock_doorsense_missing_august_lock_detail("nodoorsenselock1"), - RequestException("mocked request error"), - RequestException("mocked request error"), - ] + doorsenselock = await _mock_doorsense_enabled_august_lock_detail(hass) + nodoorsenselock = await _mock_doorsense_missing_august_lock_detail(hass) + await _create_august_with_devices(hass, [doorsenselock, nodoorsenselock]) + + binary_sensor_online_with_doorsense_name_open = hass.states.get( + "binary_sensor.online_with_doorsense_name_open" ) - - assert data.lock_has_doorsense("doorsenselock1") is True - assert data.lock_has_doorsense("nodoorsenselock1") is False - - # The api calls are mocked to fail on the second - # run of async_get_lock_detail - # - # This will be switched to await data.async_get_lock_detail("doorsenselock1") - # once we mock the full home assistant setup - data._update_locks_detail() - # doorsenselock1 should be false if we cannot tell due - # to an api error - assert data.lock_has_doorsense("doorsenselock1") is False - - -async def test__refresh_access_token(hass): - """Test refresh of the access token.""" - authentication = _mock_august_authentication("original_token", 1234) - authenticator = _mock_august_authenticator() - token_refresh_lock = asyncio.Lock() - - data = august.AugustData( - hass, MagicMock(name="api"), authentication, authenticator, token_refresh_lock + assert binary_sensor_online_with_doorsense_name_open.state == STATE_ON + binary_sensor_missing_doorsense_id_name_open = hass.states.get( + "binary_sensor.missing_doorsense_id_name_open" ) - await data._async_refresh_access_token_if_needed() - authenticator.refresh_access_token.assert_not_called() - - authenticator.should_refresh.return_value = 1 - authenticator.refresh_access_token.return_value = _mock_august_authentication( - "new_token", 5678 - ) - await data._async_refresh_access_token_if_needed() - authenticator.refresh_access_token.assert_called() - assert data._access_token == "new_token" - assert data._access_token_expires == 5678 + assert binary_sensor_missing_doorsense_id_name_open is None -def _create_august_data_with_lock_details(lock_details): - locks = [] - for lock in lock_details: - if isinstance(lock, LockDetail): - locks.append(_mock_august_lock(lock.device_id)) - authentication = _mock_august_authentication("original_token", 1234) - authenticator = _mock_august_authenticator() - token_refresh_lock = MagicMock() - api = MagicMock() - api.get_lock_detail = MagicMock(side_effect=lock_details) - api.get_operable_locks = MagicMock(return_value=locks) - api.get_doorbells = MagicMock(return_value=[]) - return august.AugustData( - MagicMock(), api, authentication, authenticator, token_refresh_lock - ) +async def test_set_up_from_yaml(hass): + """Test to make sure config is imported from yaml.""" + + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "homeassistant.components.august.async_setup_august", return_value=True, + ) as mock_setup_august, patch( + "homeassistant.components.august.config_flow.AugustGateway.authenticate", + return_value=True, + ): + mocked_config = _mock_get_config() + assert await async_setup_component(hass, "august", mocked_config) + await hass.async_block_till_done() + assert len(mock_setup_august.mock_calls) == 1 + call = mock_setup_august.call_args + args, kwargs = call + imported_config_entry = args[1] + # The import must use DEFAULT_AUGUST_CONFIG_FILE so they + # do not loose their token when config is migrated + assert imported_config_entry.data == { + CONF_ACCESS_TOKEN_CACHE_FILE: DEFAULT_AUGUST_CONFIG_FILE, + CONF_INSTALL_ID: None, + CONF_LOGIN_METHOD: "email", + CONF_PASSWORD: "mocked_password", + CONF_TIMEOUT: None, + CONF_USERNAME: "mocked_username", + } diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index b0b298690a5..104c93855be 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -6,45 +6,65 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_UNLOCK, STATE_LOCKED, + STATE_UNAVAILABLE, STATE_UNLOCKED, ) from tests.components.august.mocks import ( _create_august_with_devices, + _mock_doorsense_enabled_august_lock_detail, _mock_lock_from_fixture, ) async def test_one_lock_operation(hass): """Test creation of a lock with doorsense and bridge.""" - lock_one = await _mock_lock_from_fixture( - hass, "get_lock.online_with_doorsense.json" - ) + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) lock_details = [lock_one] await _create_august_with_devices(hass, lock_details) - lock_abc_name = hass.states.get("lock.abc_name") + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") - assert lock_abc_name.state == STATE_LOCKED + assert lock_online_with_doorsense_name.state == STATE_LOCKED - assert lock_abc_name.attributes.get("battery_level") == 92 - assert lock_abc_name.attributes.get("friendly_name") == "ABC Name" + assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 + assert ( + lock_online_with_doorsense_name.attributes.get("friendly_name") + == "online_with_doorsense Name" + ) data = {} - data[ATTR_ENTITY_ID] = "lock.abc_name" + data[ATTR_ENTITY_ID] = "lock.online_with_doorsense_name" assert await hass.services.async_call( LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True ) - lock_abc_name = hass.states.get("lock.abc_name") - assert lock_abc_name.state == STATE_UNLOCKED + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + assert lock_online_with_doorsense_name.state == STATE_UNLOCKED - assert lock_abc_name.attributes.get("battery_level") == 92 - assert lock_abc_name.attributes.get("friendly_name") == "ABC Name" + assert lock_online_with_doorsense_name.attributes.get("battery_level") == 92 + assert ( + lock_online_with_doorsense_name.attributes.get("friendly_name") + == "online_with_doorsense Name" + ) assert await hass.services.async_call( LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True ) - lock_abc_name = hass.states.get("lock.abc_name") - assert lock_abc_name.state == STATE_LOCKED + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + assert lock_online_with_doorsense_name.state == STATE_LOCKED + + +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", + ) + lock_details = [lock_one] + await _create_august_with_devices(hass, lock_details) + + lock_brokenid_name = hass.states.get("lock.brokenid_name") + # Once we have bridge_is_online support in py-august + # this can change to STATE_UNKNOWN + assert lock_brokenid_name.state == STATE_UNAVAILABLE diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py new file mode 100644 index 00000000000..a0c1a2ea7bb --- /dev/null +++ b/tests/components/august/test_sensor.py @@ -0,0 +1,84 @@ +"""The sensor tests for the august platform.""" + +from tests.components.august.mocks import ( + _create_august_with_devices, + _mock_doorbell_from_fixture, + _mock_lock_from_fixture, +) + + +async def test_create_doorbell(hass): + """Test creation of a doorbell.""" + doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") + await _create_august_with_devices(hass, [doorbell_one]) + + sensor_k98gidt45gul_name_battery = hass.states.get( + "sensor.k98gidt45gul_name_battery" + ) + assert sensor_k98gidt45gul_name_battery.state == "96" + assert sensor_k98gidt45gul_name_battery.attributes["unit_of_measurement"] == "%" + + +async def test_create_doorbell_offline(hass): + """Test creation of a doorbell that is offline.""" + doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") + await _create_august_with_devices(hass, [doorbell_one]) + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + 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"] == "%" + + entry = entity_registry.async_get("sensor.tmt100_name_battery") + assert entry + assert entry.unique_id == "tmt100_device_battery" + + +async def test_create_doorbell_hardwired(hass): + """Test creation of a doorbell that is hardwired without a battery.""" + doorbell_one = await _mock_doorbell_from_fixture( + hass, "get_doorbell.nobattery.json" + ) + await _create_august_with_devices(hass, [doorbell_one]) + + sensor_tmt100_name_battery = hass.states.get("sensor.tmt100_name_battery") + assert sensor_tmt100_name_battery is None + + +async def test_create_lock_with_linked_keypad(hass): + """Test creation of a lock with a linked keypad that both have a battery.""" + lock_one = await _mock_lock_from_fixture(hass, "get_lock.doorsense_init.json") + await _create_august_with_devices(hass, [lock_one]) + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + sensor_a6697750d607098bae8d6baa11ef8063_name_battery = hass.states.get( + "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" + ) + assert sensor_a6697750d607098bae8d6baa11ef8063_name_battery.state == "88" + assert ( + sensor_a6697750d607098bae8d6baa11ef8063_name_battery.attributes[ + "unit_of_measurement" + ] + == "%" + ) + entry = entity_registry.async_get( + "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" + ) + assert entry + assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_device_battery" + + sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery = hass.states.get( + "sensor.a6697750d607098bae8d6baa11ef8063_name_keypad_battery" + ) + assert sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery.state == "60" + assert ( + sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery.attributes[ + "unit_of_measurement" + ] + == "%" + ) + entry = entity_registry.async_get( + "sensor.a6697750d607098bae8d6baa11ef8063_name_keypad_battery" + ) + assert entry + assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_linked_keypad_battery" diff --git a/tests/fixtures/august/get_doorbell.nobattery.json b/tests/fixtures/august/get_doorbell.nobattery.json new file mode 100644 index 00000000000..e2a93a086cc --- /dev/null +++ b/tests/fixtures/august/get_doorbell.nobattery.json @@ -0,0 +1,80 @@ +{ + "status_timestamp" : 1512811834532, + "appID" : "august-iphone", + "LockID" : "BBBB1F5F11114C24CCCC97571DD6AAAA", + "recentImage" : { + "original_filename" : "file", + "placeholder" : false, + "bytes" : 24476, + "height" : 640, + "format" : "jpg", + "width" : 480, + "version" : 1512892814, + "resource_type" : "image", + "etag" : "54966926be2e93f77d498a55f247661f", + "tags" : [], + "public_id" : "qqqqt4ctmxwsysylaaaa", + "url" : "http://image.com/vmk16naaaa7ibuey7sar.jpg", + "created_at" : "2017-12-10T08:01:35Z", + "signature" : "75z47ca21b5e8ffda21d2134e478a2307c4625da", + "secure_url" : "https://image.com/vmk16naaaa7ibuey7sar.jpg", + "type" : "upload" + }, + "settings" : { + "keepEncoderRunning" : true, + "videoResolution" : "640x480", + "minACNoScaling" : 40, + "irConfiguration" : 8448272, + "directLink" : true, + "overlayEnabled" : true, + "notify_when_offline" : true, + "micVolume" : 100, + "bitrateCeiling" : 512000, + "initialBitrate" : 384000, + "IVAEnabled" : false, + "turnOffCamera" : false, + "ringSoundEnabled" : true, + "JPGQuality" : 70, + "motion_notifications" : true, + "speakerVolume" : 92, + "buttonpush_notifications" : true, + "ABREnabled" : true, + "debug" : false, + "batteryLowThreshold" : 3.1, + "batteryRun" : false, + "IREnabled" : true, + "batteryUseThreshold" : 3.4 + }, + "doorbellServerURL" : "https://doorbells.august.com", + "name" : "Front Door", + "createdAt" : "2016-11-26T22:27:11.176Z", + "installDate" : "2016-11-26T22:27:11.176Z", + "serialNumber" : "tBXZR0Z35E", + "dvrSubscriptionSetupDone" : true, + "caps" : [ + "reconnect" + ], + "doorbellID" : "K98GiDT45GUL", + "HouseID" : "3dd2accaea08", + "telemetry" : { + "signal_level" : -56, + "date" : "2017-12-10 08:05:12", + "steady_ac_in" : 22.196405, + "BSSID" : "88:ee:00:dd:aa:11", + "SSID" : "foo_ssid", + "updated_at" : "2017-12-10T08:05:13.650Z", + "temperature" : 28.25, + "wifi_freq" : 5745, + "load_average" : "0.50 0.47 0.35 1/154 9345", + "link_quality" : 54, + "uptime" : "16168.75 13830.49", + "ip_addr" : "10.0.1.11", + "doorbell_low_battery" : false, + "ac_in" : 23.856874 + }, + "installUserID" : "c3b2a94e-373e-aaaa-bbbb-36e996827777", + "status" : "doorbell_call_status_online", + "firmwareVersion" : "2.3.0-RC153+201711151527", + "pubsubChannel" : "7c7a6672-59c8-3333-ffff-dcd98705cccc", + "updatedAt" : "2017-12-10T08:05:13.650Z" +} diff --git a/tests/fixtures/august/get_doorbell.offline.json b/tests/fixtures/august/get_doorbell.offline.json new file mode 100644 index 00000000000..dec94374355 --- /dev/null +++ b/tests/fixtures/august/get_doorbell.offline.json @@ -0,0 +1,130 @@ +{ + "recentImage" : { + "tags" : [], + "height" : 576, + "public_id" : "fdsfds", + "bytes" : 50013, + "resource_type" : "image", + "original_filename" : "file", + "version" : 1582242766, + "format" : "jpg", + "signature" : "fdsfdsf", + "created_at" : "2020-02-20T23:52:46Z", + "type" : "upload", + "placeholder" : false, + "url" : "http://res.cloudinary.com/august-com/image/upload/ccc/ccccc.jpg", + "secure_url" : "https://res.cloudinary.com/august-com/image/upload/cc/cccc.jpg", + "etag" : "zds", + "width" : 720 + }, + "firmwareVersion" : "3.1.0-HYDRC75+201909251139", + "doorbellServerURL" : "https://doorbells.august.com", + "installUserID" : "mock", + "caps" : [ + "reconnect", + "webrtc", + "tcp_wakeup" + ], + "messagingProtocol" : "pubnub", + "createdAt" : "2020-02-12T03:52:28.719Z", + "invitations" : [], + "appID" : "august-iphone-v5", + "HouseID" : "houseid1", + "doorbellID" : "tmt100", + "name" : "Front Door", + "settings" : { + "batteryUseThreshold" : 3.4, + "brightness" : 50, + "batteryChargeCurrent" : 60, + "overCurrentThreshold" : -250, + "irLedBrightness" : 40, + "videoResolution" : "720x576", + "pirPulseCounter" : 1, + "contrast" : 50, + "micVolume" : 50, + "directLink" : true, + "auto_contrast_mode" : 0, + "saturation" : 50, + "motion_notifications" : true, + "pirSensitivity" : 20, + "pirBlindTime" : 7, + "notify_when_offline" : false, + "nightModeAlsThreshold" : 10, + "minACNoScaling" : 40, + "DVRRecordingTimeout" : 15, + "turnOffCamera" : false, + "debug" : false, + "keepEncoderRunning" : true, + "pirWindowTime" : 0, + "bitrateCeiling" : 2000000, + "backlight_comp" : false, + "buttonpush_notifications" : true, + "buttonpush_notifications_partners" : false, + "minimumSnapshotInterval" : 30, + "pirConfiguration" : 272, + "batteryLowThreshold" : 3.1, + "sharpness" : 50, + "ABREnabled" : true, + "hue" : 50, + "initialBitrate" : 1000000, + "ringSoundEnabled" : true, + "IVAEnabled" : false, + "overlayEnabled" : true, + "speakerVolume" : 92, + "ringRepetitions" : 3, + "powerProfilePreset" : -1, + "irConfiguration" : 16836880, + "JPGQuality" : 70, + "IREnabled" : true + }, + "updatedAt" : "2020-02-20T23:58:21.580Z", + "serialNumber" : "abc", + "installDate" : "2019-02-12T03:52:28.719Z", + "dvrSubscriptionSetupDone" : true, + "pubsubChannel" : "mock", + "chimes" : [ + { + "updatedAt" : "2020-02-12T03:55:38.805Z", + "_id" : "cccc", + "type" : 1, + "serialNumber" : "ccccc", + "doorbellID" : "tmt100", + "name" : "Living Room", + "chimeID" : "cccc", + "createdAt" : "2020-02-12T03:55:38.805Z", + "firmware" : "3.1.16" + } + ], + "telemetry" : { + "battery" : 3.985, + "battery_soc" : 81, + "load_average" : "0.45 0.18 0.07 4/98 831", + "ip_addr" : "192.168.100.174", + "BSSID" : "snp", + "uptime" : "96.55 70.59", + "SSID" : "bob", + "updated_at" : "2020-02-20T23:53:09.586Z", + "dtim_period" : 0, + "wifi_freq" : 2462, + "date" : "2020-02-20 11:47:36", + "BSSIDManufacturer" : "Ubiquiti - Ubiquiti Networks Inc.", + "battery_temp" : 22, + "battery_avg_cur" : -291, + "beacon_interval" : 0, + "signal_level" : -49, + "battery_soh" : 95, + "doorbell_low_battery" : false + }, + "secChipCertSerial" : "", + "tcpKeepAlive" : { + "keepAliveUUID" : "mock", + "wakeUp" : { + "token" : "wakemeup", + "lastUpdated" : 1582242723931 + } + }, + "statusUpdatedAtMs" : 1582243101579, + "status" : "doorbell_offline", + "type" : "hydra1", + "HouseName" : "housename" +} diff --git a/tests/fixtures/august/get_lock.online.unknown_state.json b/tests/fixtures/august/get_lock.online.unknown_state.json new file mode 100644 index 00000000000..ad455655902 --- /dev/null +++ b/tests/fixtures/august/get_lock.online.unknown_state.json @@ -0,0 +1,59 @@ +{ + "LockName": "Side Door", + "Type": 1001, + "Created": "2019-10-07T01:49:06.831Z", + "Updated": "2019-10-07T01:49:06.831Z", + "LockID": "BROKENID", + "HouseID": "abc", + "HouseName": "dog", + "Calibrated": false, + "timeZone": "America/Chicago", + "battery": 0.9524716174964851, + "hostLockInfo": { + "serialNumber": "YR", + "manufacturer": "yale", + "productID": 1536, + "productTypeID": 32770 + }, + "supportsEntryCodes": true, + "skuNumber": "AUG-MD01", + "macAddress": "MAC", + "SerialNumber": "M1FXZ00EZ9", + "LockStatus": { + "status": "unknown_error_during_connect", + "dateTime": "2020-02-22T02:48:11.741Z", + "isLockStatusChanged": true, + "valid": true, + "doorState": "closed" + }, + "currentFirmwareVersion": "undefined-4.3.0-1.8.14", + "homeKitEnabled": true, + "zWaveEnabled": false, + "isGalileo": false, + "Bridge": { + "_id": "id", + "mfgBridgeID": "id", + "deviceModel": "august-connect", + "firmwareVersion": "2.2.1", + "operative": true, + "status": { + "current": "online", + "updated": "2020-02-21T15:06:47.001Z", + "lastOnline": "2020-02-21T15:06:47.001Z", + "lastOffline": "2020-02-06T17:33:21.265Z" + }, + "hyperBridge": true + }, + "parametersToSet": {}, + "ruleHash": {}, + "cameras": [], + "geofenceLimits": { + "ios": { + "debounceInterval": 90, + "gpsAccuracyMultiplier": 2.5, + "maximumGeofence": 5000, + "minimumGeofence": 100, + "minGPSAccuracyRequired": 80 + } + } +} diff --git a/tests/fixtures/august/get_lock.online_missing_doorsense.json b/tests/fixtures/august/get_lock.online_missing_doorsense.json new file mode 100644 index 00000000000..46971c3bbd2 --- /dev/null +++ b/tests/fixtures/august/get_lock.online_missing_doorsense.json @@ -0,0 +1,50 @@ +{ + "Bridge" : { + "_id" : "bridgeid", + "deviceModel" : "august-connect", + "firmwareVersion" : "2.2.1", + "hyperBridge" : true, + "mfgBridgeID" : "C5WY200WSH", + "operative" : true, + "status" : { + "current" : "online", + "lastOffline" : "2000-00-00T00:00:00.447Z", + "lastOnline" : "2000-00-00T00:00:00.447Z", + "updated" : "2000-00-00T00:00:00.447Z" + } + }, + "Calibrated" : false, + "Created" : "2000-00-00T00:00:00.447Z", + "HouseID" : "123", + "HouseName" : "Test", + "LockID" : "missing_doorsense_id", + "LockName" : "Online door missing doorsense", + "LockStatus" : { + "dateTime" : "2017-12-10T04:48:30.272Z", + "isLockStatusChanged" : false, + "status" : "locked", + "valid" : true + }, + "SerialNumber" : "XY", + "Type" : 1001, + "Updated" : "2000-00-00T00:00:00.447Z", + "battery" : 0.922, + "currentFirmwareVersion" : "undefined-4.3.0-1.8.14", + "homeKitEnabled" : true, + "hostLockInfo" : { + "manufacturer" : "yale", + "productID" : 1536, + "productTypeID" : 32770, + "serialNumber" : "ABC" + }, + "isGalileo" : false, + "macAddress" : "12:22", + "pins" : { + "created" : [], + "loaded" : [] + }, + "skuNumber" : "AUG-MD01", + "supportsEntryCodes" : true, + "timeZone" : "Pacific/Hawaii", + "zWaveEnabled" : false +} diff --git a/tests/fixtures/august/get_lock.online_with_doorsense.json b/tests/fixtures/august/get_lock.online_with_doorsense.json index b0f9475c009..f7376570482 100644 --- a/tests/fixtures/august/get_lock.online_with_doorsense.json +++ b/tests/fixtures/august/get_lock.online_with_doorsense.json @@ -17,7 +17,7 @@ "Created" : "2000-00-00T00:00:00.447Z", "HouseID" : "123", "HouseName" : "Test", - "LockID" : "ABC", + "LockID" : "online_with_doorsense", "LockName" : "Online door with doorsense", "LockStatus" : { "dateTime" : "2017-12-10T04:48:30.272Z", From 5c12fa0daac81c017ba214622b4aab53108db192 Mon Sep 17 00:00:00 2001 From: springstan <46536646+springstan@users.noreply.github.com> Date: Tue, 25 Feb 2020 19:18:44 +0100 Subject: [PATCH 099/416] Fix description of fan service set_direction (#32181) --- homeassistant/components/fan/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index ee478950095..1fbd9089ed7 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -48,7 +48,7 @@ set_direction: description: Set the fan rotation. fields: entity_id: - description: Name(s) of the entities to toggle + description: Name(s) of the entities to set example: 'fan.living_room' direction: description: The direction to rotate. Either 'forward' or 'reverse' From 2365e2e8cf60f6e86e9033f27082750526825209 Mon Sep 17 00:00:00 2001 From: Ruslan Sayfutdinov Date: Tue, 25 Feb 2020 18:20:51 +0000 Subject: [PATCH 100/416] Use orjson to parse json faster (#32153) * [recorder] Use orjson to parse json faster * Remove from http manifest * Bump to orjson 2.5.1 * Empty commit to trigger CI Co-authored-by: Paulus Schoutsen --- homeassistant/components/recorder/models.py | 5 +++-- homeassistant/helpers/aiohttp_client.py | 2 ++ homeassistant/package_constraints.txt | 1 + homeassistant/util/json.py | 8 +++++--- pylintrc | 2 +- requirements_all.txt | 1 + setup.py | 1 + 7 files changed, 14 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index f3e80a9a739..3cc5c54e992 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -3,6 +3,7 @@ from datetime import datetime import json import logging +import orjson from sqlalchemy import ( Boolean, Column, @@ -63,7 +64,7 @@ class Events(Base): # type: ignore try: return Event( self.event_type, - json.loads(self.event_data), + orjson.loads(self.event_data), EventOrigin(self.origin), _process_timestamp(self.time_fired), context=context, @@ -133,7 +134,7 @@ class States(Base): # type: ignore return State( self.entity_id, self.state, - json.loads(self.attributes), + orjson.loads(self.attributes), _process_timestamp(self.last_changed), _process_timestamp(self.last_updated), context=context, diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index eee891b7f88..a90b8b61fb4 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -9,6 +9,7 @@ from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout import async_timeout +import orjson from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, callback @@ -67,6 +68,7 @@ def async_create_clientsession( loop=hass.loop, connector=connector, headers={USER_AGENT: SERVER_SOFTWARE}, + json_serialize=lambda x: orjson.dumps(x).decode(), **kwargs, ) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cef7cda8017..9c3ea995210 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,6 +16,7 @@ home-assistant-frontend==20200220.1 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 +orjson==2.5.1 pip>=8.0.3 python-slugify==4.0.0 pytz>=2019.03 diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index 94dc816e03c..ac64794d952 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -6,6 +6,8 @@ import os import tempfile from typing import Any, Dict, List, Optional, Type, Union +import orjson + from homeassistant.exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) @@ -28,7 +30,7 @@ def load_json( """ try: with open(filename, encoding="utf-8") as fdesc: - return json.loads(fdesc.read()) # type: ignore + return orjson.loads(fdesc.read()) # type: ignore except FileNotFoundError: # This is not a fatal error _LOGGER.debug("JSON file not found: %s", filename) @@ -97,7 +99,7 @@ def find_paths_unserializable_data(bad_data: Any) -> List[str]: obj, obj_path = to_process.popleft() try: - json.dumps(obj) + orjson.dumps(obj) continue except TypeError: pass @@ -106,7 +108,7 @@ def find_paths_unserializable_data(bad_data: Any) -> List[str]: for key, value in obj.items(): try: # Is key valid? - json.dumps({key: None}) + orjson.dumps({key: None}) except TypeError: invalid.append(f"{obj_path}") else: diff --git a/pylintrc b/pylintrc index 125062c8cfe..00e1621bb04 100644 --- a/pylintrc +++ b/pylintrc @@ -5,7 +5,7 @@ ignore=tests jobs=2 load-plugins=pylint_strict_informational persistent=no -extension-pkg-whitelist=ciso8601 +extension-pkg-whitelist=ciso8601,orjson [BASIC] good-names=id,i,j,k,ex,Run,_,fp diff --git a/requirements_all.txt b/requirements_all.txt index edb4564aa72..8c4f0869417 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -10,6 +10,7 @@ importlib-metadata==1.5.0 jinja2>=2.10.3 PyJWT==1.7.1 cryptography==2.8 +orjson==2.5.1 pip>=8.0.3 python-slugify==4.0.0 pytz>=2019.03 diff --git a/setup.py b/setup.py index 0564b7f4773..997e0595441 100755 --- a/setup.py +++ b/setup.py @@ -44,6 +44,7 @@ REQUIRES = [ "PyJWT==1.7.1", # PyJWT has loose dependency. We want the latest one. "cryptography==2.8", + "orjson==2.5.1", "pip>=8.0.3", "python-slugify==4.0.0", "pytz>=2019.03", From 2d6b80470f168fc811e27263b069294079364268 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 25 Feb 2020 19:44:24 +0100 Subject: [PATCH 101/416] Improve error handling (#32182) --- homeassistant/components/mqtt/__init__.py | 12 ++++++++++- tests/components/mqtt/test_init.py | 26 ++++++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 61c62a1eaa4..4014c2162dd 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -1272,13 +1272,23 @@ async def websocket_remove_device(hass, connection, msg): dev_registry = await get_dev_reg(hass) device = dev_registry.async_get(device_id) + if not device: + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Device not found" + ) + return + for config_entry in device.config_entries: config_entry = hass.config_entries.async_get_entry(config_entry) # Only delete the device if it belongs to an MQTT device entry if config_entry.domain == DOMAIN: dev_registry.async_remove_device(device_id) connection.send_message(websocket_api.result_message(msg["id"])) - break + return + + connection.send_error( + msg["id"], websocket_api.const.ERR_NOT_FOUND, "Non MQTT device" + ) @websocket_api.async_response diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 5dc05a95a55..7d06c62b915 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -7,7 +7,7 @@ from unittest import mock import pytest import voluptuous as vol -from homeassistant.components import mqtt +from homeassistant.components import mqtt, websocket_api from homeassistant.components.mqtt.discovery import async_start from homeassistant.const import ( ATTR_DOMAIN, @@ -17,6 +17,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -905,8 +906,31 @@ async def test_mqtt_ws_remove_discovered_device_twice( response = await client.receive_json() assert response["success"] + await client.send_json( + {"id": 6, "type": "mqtt/device/remove", "device_id": device_entry.id} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == websocket_api.const.ERR_NOT_FOUND + + +async def test_mqtt_ws_remove_non_mqtt_device( + hass, device_reg, hass_ws_client, mqtt_mock +): + """Test MQTT websocket device removal of device belonging to other domain.""" + config_entry = MockConfigEntry(domain="test") + 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")}, + ) + assert device_entry is not None + + client = await hass_ws_client(hass) await client.send_json( {"id": 5, "type": "mqtt/device/remove", "device_id": device_entry.id} ) response = await client.receive_json() assert not response["success"] + assert response["error"]["code"] == websocket_api.const.ERR_NOT_FOUND From 536b31305ab43a23196a5a427dcb6a0da599abf9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 25 Feb 2020 11:18:21 -0800 Subject: [PATCH 102/416] Support multiple Lovelace dashboards (#32134) * Support multiple Lovelace dashboards * Mark collection maintenance as unfinished * Fix import * Add websockets commands for resource management * Revert "Add websockets commands for resource management" This reverts commit 7d140b2bccd27543db55c51930ad97e15e938ea5. Co-authored-by: Bram Kragten --- homeassistant/components/frontend/__init__.py | 2 +- homeassistant/components/lovelace/__init__.py | 326 +++++++----------- homeassistant/components/lovelace/const.py | 26 ++ .../components/lovelace/dashboard.py | 181 ++++++++++ .../components/lovelace/resources.py | 96 ++++++ .../components/lovelace/websocket.py | 98 ++++++ .../components/websocket_api/permissions.py | 2 +- homeassistant/components/zone/__init__.py | 24 +- homeassistant/helpers/collection.py | 20 ++ homeassistant/helpers/config_validation.py | 28 +- homeassistant/util/__init__.py | 4 +- .../{test_init.py => test_dashboard.py} | 113 +++++- tests/components/lovelace/test_resources.py | 113 ++++++ 13 files changed, 779 insertions(+), 254 deletions(-) create mode 100644 homeassistant/components/lovelace/const.py create mode 100644 homeassistant/components/lovelace/dashboard.py create mode 100644 homeassistant/components/lovelace/resources.py create mode 100644 homeassistant/components/lovelace/websocket.py rename tests/components/lovelace/{test_init.py => test_dashboard.py} (61%) create mode 100644 tests/components/lovelace/test_resources.py diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index db721ff18a5..5864c642fa9 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -185,7 +185,7 @@ def async_register_built_in_panel( panels = hass.data.setdefault(DATA_PANELS, {}) if panel.frontend_url_path in panels: - _LOGGER.warning("Overwriting integration %s", panel.frontend_url_path) + raise ValueError(f"Overwriting panel {panel.frontend_url_path}") panels[panel.frontend_url_path] = panel diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index fc8cb67894b..c78356e0dd6 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -1,247 +1,165 @@ """Support for the Lovelace UI.""" -from functools import wraps import logging -import os -import time +from typing import Any import voluptuous as vol -from homeassistant.components import websocket_api +from homeassistant.components import frontend +from homeassistant.const import CONF_FILENAME, CONF_ICON from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.yaml import load_yaml +from homeassistant.helpers import config_validation as cv +from homeassistant.util import sanitize_filename, slugify + +from . import dashboard, resources, websocket +from .const import ( + CONF_RESOURCES, + DOMAIN, + LOVELACE_CONFIG_FILE, + MODE_STORAGE, + MODE_YAML, + RESOURCE_SCHEMA, +) _LOGGER = logging.getLogger(__name__) -DOMAIN = "lovelace" -STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 CONF_MODE = "mode" -MODE_YAML = "yaml" -MODE_STORAGE = "storage" + +CONF_DASHBOARDS = "dashboards" +CONF_SIDEBAR = "sidebar" +CONF_TITLE = "title" +CONF_REQUIRE_ADMIN = "require_admin" + +DASHBOARD_BASE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean, + vol.Optional(CONF_SIDEBAR): { + vol.Required(CONF_ICON): cv.icon, + vol.Required(CONF_TITLE): cv.string, + }, + } +) + +YAML_DASHBOARD_SCHEMA = DASHBOARD_BASE_SCHEMA.extend( + { + vol.Required(CONF_MODE): MODE_YAML, + vol.Required(CONF_FILENAME): vol.All(cv.string, sanitize_filename), + } +) + + +def url_slug(value: Any) -> str: + """Validate value is a valid url slug.""" + if value is None: + raise vol.Invalid("Slug should not be None") + str_value = str(value) + slg = slugify(str_value, separator="-") + if str_value == slg: + return str_value + raise vol.Invalid(f"invalid slug {value} (try {slg})") + CONFIG_SCHEMA = vol.Schema( { - DOMAIN: vol.Schema( + vol.Optional(DOMAIN, default={}): vol.Schema( { vol.Optional(CONF_MODE, default=MODE_STORAGE): vol.All( vol.Lower, vol.In([MODE_YAML, MODE_STORAGE]) - ) + ), + vol.Optional(CONF_DASHBOARDS): cv.schema_with_slug_keys( + YAML_DASHBOARD_SCHEMA, slug_validator=url_slug, + ), + vol.Optional(CONF_RESOURCES): [RESOURCE_SCHEMA], } ) }, extra=vol.ALLOW_EXTRA, ) -EVENT_LOVELACE_UPDATED = "lovelace_updated" - -LOVELACE_CONFIG_FILE = "ui-lovelace.yaml" - - -class ConfigNotFound(HomeAssistantError): - """When no config available.""" - async def async_setup(hass, config): """Set up the Lovelace commands.""" # Pass in default to `get` because defaults not set if loaded as dep - mode = config.get(DOMAIN, {}).get(CONF_MODE, MODE_STORAGE) + mode = config[DOMAIN][CONF_MODE] + yaml_resources = config[DOMAIN].get(CONF_RESOURCES) - hass.components.frontend.async_register_built_in_panel( - DOMAIN, config={"mode": mode} - ) + frontend.async_register_built_in_panel(hass, DOMAIN, config={"mode": mode}) if mode == MODE_YAML: - hass.data[DOMAIN] = LovelaceYAML(hass) + default_config = dashboard.LovelaceYAML(hass, None, LOVELACE_CONFIG_FILE) + + if yaml_resources is None: + try: + ll_conf = await default_config.async_load(False) + except HomeAssistantError: + pass + else: + if CONF_RESOURCES in ll_conf: + _LOGGER.warning( + "Resources need to be specified in your configuration.yaml. Please see the docs." + ) + yaml_resources = ll_conf[CONF_RESOURCES] + + resource_collection = resources.ResourceYAMLCollection(yaml_resources or []) + else: - hass.data[DOMAIN] = LovelaceStorage(hass) + default_config = dashboard.LovelaceStorage(hass, None) - hass.components.websocket_api.async_register_command(websocket_lovelace_config) + if yaml_resources is not None: + _LOGGER.warning( + "Lovelace is running in storage mode. Define resources via user interface" + ) - hass.components.websocket_api.async_register_command(websocket_lovelace_save_config) + resource_collection = resources.ResourceStorageCollection(hass, default_config) hass.components.websocket_api.async_register_command( - websocket_lovelace_delete_config + websocket.websocket_lovelace_config + ) + hass.components.websocket_api.async_register_command( + websocket.websocket_lovelace_save_config + ) + hass.components.websocket_api.async_register_command( + websocket.websocket_lovelace_delete_config + ) + hass.components.websocket_api.async_register_command( + websocket.websocket_lovelace_resources ) hass.components.system_health.async_register_info(DOMAIN, system_health_info) + hass.data[DOMAIN] = { + # We store a dictionary mapping url_path: config. None is the default. + "dashboards": {None: default_config}, + "resources": resource_collection, + } + + if hass.config.safe_mode or CONF_DASHBOARDS not in config[DOMAIN]: + return True + + for url_path, dashboard_conf in config[DOMAIN][CONF_DASHBOARDS].items(): + # For now always mode=yaml + config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf[CONF_FILENAME]) + hass.data[DOMAIN]["dashboards"][url_path] = config + + kwargs = { + "hass": hass, + "component_name": DOMAIN, + "frontend_url_path": url_path, + "require_admin": dashboard_conf[CONF_REQUIRE_ADMIN], + "config": {"mode": dashboard_conf[CONF_MODE]}, + } + + if CONF_SIDEBAR in dashboard_conf: + kwargs["sidebar_title"] = dashboard_conf[CONF_SIDEBAR][CONF_TITLE] + kwargs["sidebar_icon"] = dashboard_conf[CONF_SIDEBAR][CONF_ICON] + + try: + frontend.async_register_built_in_panel(**kwargs) + except ValueError: + _LOGGER.warning("Panel url path %s is not unique", url_path) + return True -class LovelaceStorage: - """Class to handle Storage based Lovelace config.""" - - def __init__(self, hass): - """Initialize Lovelace config based on storage helper.""" - self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) - self._data = None - self._hass = hass - - async def async_get_info(self): - """Return the YAML storage mode.""" - if self._data is None: - await self._load() - - if self._data["config"] is None: - return {"mode": "auto-gen"} - - return _config_info("storage", self._data["config"]) - - async def async_load(self, force): - """Load config.""" - if self._hass.config.safe_mode: - raise ConfigNotFound - - if self._data is None: - await self._load() - - config = self._data["config"] - - if config is None: - raise ConfigNotFound - - return config - - async def async_save(self, config): - """Save config.""" - if self._data is None: - await self._load() - self._data["config"] = config - self._hass.bus.async_fire(EVENT_LOVELACE_UPDATED) - await self._store.async_save(self._data) - - async def async_delete(self): - """Delete config.""" - await self.async_save(None) - - async def _load(self): - """Load the config.""" - data = await self._store.async_load() - self._data = data if data else {"config": None} - - -class LovelaceYAML: - """Class to handle YAML-based Lovelace config.""" - - def __init__(self, hass): - """Initialize the YAML config.""" - self.hass = hass - self._cache = None - - async def async_get_info(self): - """Return the YAML storage mode.""" - try: - config = await self.async_load(False) - except ConfigNotFound: - return { - "mode": "yaml", - "error": "{} not found".format( - self.hass.config.path(LOVELACE_CONFIG_FILE) - ), - } - - return _config_info("yaml", config) - - async def async_load(self, force): - """Load config.""" - is_updated, config = await self.hass.async_add_executor_job( - self._load_config, force - ) - if is_updated: - self.hass.bus.async_fire(EVENT_LOVELACE_UPDATED) - return config - - def _load_config(self, force): - """Load the actual config.""" - fname = self.hass.config.path(LOVELACE_CONFIG_FILE) - # Check for a cached version of the config - if not force and self._cache is not None: - config, last_update = self._cache - modtime = os.path.getmtime(fname) - if config and last_update > modtime: - return False, config - - is_updated = self._cache is not None - - try: - config = load_yaml(fname) - except FileNotFoundError: - raise ConfigNotFound from None - - self._cache = (config, time.time()) - return is_updated, config - - async def async_save(self, config): - """Save config.""" - raise HomeAssistantError("Not supported") - - async def async_delete(self): - """Delete config.""" - raise HomeAssistantError("Not supported") - - -def handle_yaml_errors(func): - """Handle error with WebSocket calls.""" - - @wraps(func) - async def send_with_error_handling(hass, connection, msg): - error = None - try: - result = await func(hass, connection, msg) - except ConfigNotFound: - error = "config_not_found", "No config found." - except HomeAssistantError as err: - error = "error", str(err) - - if error is not None: - connection.send_error(msg["id"], *error) - return - - if msg is not None: - await connection.send_big_result(msg["id"], result) - else: - connection.send_result(msg["id"], result) - - return send_with_error_handling - - -@websocket_api.async_response -@websocket_api.websocket_command( - {"type": "lovelace/config", vol.Optional("force", default=False): bool} -) -@handle_yaml_errors -async def websocket_lovelace_config(hass, connection, msg): - """Send Lovelace UI config over WebSocket configuration.""" - return await hass.data[DOMAIN].async_load(msg["force"]) - - -@websocket_api.async_response -@websocket_api.websocket_command( - {"type": "lovelace/config/save", "config": vol.Any(str, dict)} -) -@handle_yaml_errors -async def websocket_lovelace_save_config(hass, connection, msg): - """Save Lovelace UI configuration.""" - await hass.data[DOMAIN].async_save(msg["config"]) - - -@websocket_api.async_response -@websocket_api.websocket_command({"type": "lovelace/config/delete"}) -@handle_yaml_errors -async def websocket_lovelace_delete_config(hass, connection, msg): - """Delete Lovelace UI configuration.""" - await hass.data[DOMAIN].async_delete() - - async def system_health_info(hass): """Get info for the info page.""" - return await hass.data[DOMAIN].async_get_info() - - -def _config_info(mode, config): - """Generate info about the config.""" - return { - "mode": mode, - "resources": len(config.get("resources", [])), - "views": len(config.get("views", [])), - } + return await hass.data[DOMAIN]["dashboards"][None].async_get_info() diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py new file mode 100644 index 00000000000..2bf2b34098c --- /dev/null +++ b/homeassistant/components/lovelace/const.py @@ -0,0 +1,26 @@ +"""Constants for Lovelace.""" +import voluptuous as vol + +from homeassistant.const import CONF_TYPE, CONF_URL +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv + +DOMAIN = "lovelace" +EVENT_LOVELACE_UPDATED = "lovelace_updated" + +MODE_YAML = "yaml" +MODE_STORAGE = "storage" + +LOVELACE_CONFIG_FILE = "ui-lovelace.yaml" +CONF_RESOURCES = "resources" +CONF_URL_PATH = "url_path" + +RESOURCE_FIELDS = { + CONF_TYPE: vol.In(["js", "css", "module", "html"]), + CONF_URL: cv.string, +} +RESOURCE_SCHEMA = vol.Schema(RESOURCE_FIELDS) + + +class ConfigNotFound(HomeAssistantError): + """When no config available.""" diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py new file mode 100644 index 00000000000..dcd7a6c4e52 --- /dev/null +++ b/homeassistant/components/lovelace/dashboard.py @@ -0,0 +1,181 @@ +"""Lovelace dashboard support.""" +from abc import ABC, abstractmethod +import os +import time + +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import storage +from homeassistant.util.yaml import load_yaml + +from .const import ( + DOMAIN, + EVENT_LOVELACE_UPDATED, + MODE_STORAGE, + MODE_YAML, + ConfigNotFound, +) + +CONFIG_STORAGE_KEY_DEFAULT = DOMAIN +CONFIG_STORAGE_VERSION = 1 + + +class LovelaceConfig(ABC): + """Base class for Lovelace config.""" + + def __init__(self, hass, url_path): + """Initialize Lovelace config.""" + self.hass = hass + self.url_path = url_path + + @property + @abstractmethod + def mode(self) -> str: + """Return mode of the lovelace config.""" + + @abstractmethod + async def async_get_info(self): + """Return the config info.""" + + @abstractmethod + async def async_load(self, force): + """Load config.""" + + async def async_save(self, config): + """Save config.""" + raise HomeAssistantError("Not supported") + + async def async_delete(self): + """Delete config.""" + raise HomeAssistantError("Not supported") + + @callback + def _config_updated(self): + """Fire config updated event.""" + self.hass.bus.async_fire(EVENT_LOVELACE_UPDATED, {"url_path": self.url_path}) + + +class LovelaceStorage(LovelaceConfig): + """Class to handle Storage based Lovelace config.""" + + def __init__(self, hass, url_path): + """Initialize Lovelace config based on storage helper.""" + super().__init__(hass, url_path) + if url_path is None: + storage_key = CONFIG_STORAGE_KEY_DEFAULT + else: + raise ValueError("Storage-based dashboards are not supported") + + self._store = storage.Store(hass, CONFIG_STORAGE_VERSION, storage_key) + self._data = None + + @property + def mode(self) -> str: + """Return mode of the lovelace config.""" + return MODE_STORAGE + + async def async_get_info(self): + """Return the YAML storage mode.""" + if self._data is None: + await self._load() + + if self._data["config"] is None: + return {"mode": "auto-gen"} + + return _config_info(self.mode, self._data["config"]) + + async def async_load(self, force): + """Load config.""" + if self.hass.config.safe_mode: + raise ConfigNotFound + + if self._data is None: + await self._load() + + config = self._data["config"] + + if config is None: + raise ConfigNotFound + + return config + + async def async_save(self, config): + """Save config.""" + if self._data is None: + await self._load() + self._data["config"] = config + self._config_updated() + await self._store.async_save(self._data) + + async def async_delete(self): + """Delete config.""" + await self.async_save(None) + + async def _load(self): + """Load the config.""" + data = await self._store.async_load() + self._data = data if data else {"config": None} + + +class LovelaceYAML(LovelaceConfig): + """Class to handle YAML-based Lovelace config.""" + + def __init__(self, hass, url_path, path): + """Initialize the YAML config.""" + super().__init__(hass, url_path) + self.path = hass.config.path(path) + self._cache = None + + @property + def mode(self) -> str: + """Return mode of the lovelace config.""" + return MODE_YAML + + async def async_get_info(self): + """Return the YAML storage mode.""" + try: + config = await self.async_load(False) + except ConfigNotFound: + return { + "mode": self.mode, + "error": "{} not found".format(self.path), + } + + return _config_info(self.mode, config) + + async def async_load(self, force): + """Load config.""" + is_updated, config = await self.hass.async_add_executor_job( + self._load_config, force + ) + if is_updated: + self._config_updated() + return config + + def _load_config(self, force): + """Load the actual config.""" + # Check for a cached version of the config + if not force and self._cache is not None: + config, last_update = self._cache + modtime = os.path.getmtime(self.path) + if config and last_update > modtime: + return False, config + + is_updated = self._cache is not None + + try: + config = load_yaml(self.path) + except FileNotFoundError: + raise ConfigNotFound from None + + self._cache = (config, time.time()) + return is_updated, config + + +def _config_info(mode, config): + """Generate info about the config.""" + return { + "mode": mode, + "resources": len(config.get("resources", [])), + "views": len(config.get("views", [])), + } diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py new file mode 100644 index 00000000000..4244feb26dd --- /dev/null +++ b/homeassistant/components/lovelace/resources.py @@ -0,0 +1,96 @@ +"""Lovelace resources support.""" +import logging +from typing import List, Optional, cast +import uuid + +import voluptuous as vol + +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import collection, storage + +from .const import CONF_RESOURCES, DOMAIN, RESOURCE_SCHEMA +from .dashboard import LovelaceConfig + +RESOURCE_STORAGE_KEY = f"{DOMAIN}_resources" +RESOURCES_STORAGE_VERSION = 1 +_LOGGER = logging.getLogger(__name__) + + +class ResourceYAMLCollection: + """Collection representing static YAML.""" + + loaded = True + + def __init__(self, data): + """Initialize a resource YAML collection.""" + self.data = data + + @callback + def async_items(self) -> List[dict]: + """Return list of items in collection.""" + return self.data + + +class ResourceStorageCollection(collection.StorageCollection): + """Collection to store resources.""" + + loaded = False + + def __init__(self, hass: HomeAssistant, ll_config: LovelaceConfig): + """Initialize the storage collection.""" + super().__init__( + storage.Store(hass, RESOURCES_STORAGE_VERSION, RESOURCE_STORAGE_KEY), + _LOGGER, + ) + self.ll_config = ll_config + + async def _async_load_data(self) -> Optional[dict]: + """Load the data.""" + data = await self.store.async_load() + + if data is not None: + return cast(Optional[dict], data) + + # Import it from config. + try: + conf = await self.ll_config.async_load(False) + except HomeAssistantError: + return None + + if CONF_RESOURCES not in conf: + return None + + # Remove it from config and save both resources + config + data = conf[CONF_RESOURCES] + + try: + vol.Schema([RESOURCE_SCHEMA])(data) + except vol.Invalid as err: + _LOGGER.warning("Resource import failed. Data invalid: %s", err) + return None + + conf.pop(CONF_RESOURCES) + + for item in data: + item[collection.CONF_ID] = uuid.uuid4().hex + + data = {"items": data} + + await self.store.async_save(data) + await self.ll_config.async_save(conf) + + return data + + async def _process_create_data(self, data: dict) -> dict: + """Validate the config is valid.""" + raise NotImplementedError + + @callback + def _get_suggested_id(self, info: dict) -> str: + """Suggest an ID based on the config.""" + raise NotImplementedError + + async def _update_data(self, data: dict, update_data: dict) -> dict: + """Return a new updated data object.""" + raise NotImplementedError diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py new file mode 100644 index 00000000000..d80764f4ed9 --- /dev/null +++ b/homeassistant/components/lovelace/websocket.py @@ -0,0 +1,98 @@ +"""Websocket API for Lovelace.""" +from functools import wraps + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv + +from .const import CONF_URL_PATH, DOMAIN, ConfigNotFound + + +def _handle_errors(func): + """Handle error with WebSocket calls.""" + + @wraps(func) + async def send_with_error_handling(hass, connection, msg): + url_path = msg.get(CONF_URL_PATH) + config = hass.data[DOMAIN]["dashboards"].get(url_path) + + if config is None: + connection.send_error( + msg["id"], "config_not_found", f"Unknown config specified: {url_path}" + ) + return + + error = None + try: + result = await func(hass, connection, msg, config) + except ConfigNotFound: + error = "config_not_found", "No config found." + except HomeAssistantError as err: + error = "error", str(err) + + if error is not None: + connection.send_error(msg["id"], *error) + return + + if msg is not None: + await connection.send_big_result(msg["id"], result) + else: + connection.send_result(msg["id"], result) + + return send_with_error_handling + + +@websocket_api.async_response +@websocket_api.websocket_command({"type": "lovelace/resources"}) +async def websocket_lovelace_resources(hass, connection, msg): + """Send Lovelace UI resources over WebSocket configuration.""" + resources = hass.data[DOMAIN]["resources"] + + if not resources.loaded: + await resources.async_load() + resources.loaded = True + + connection.send_result(msg["id"], resources.async_items()) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + "type": "lovelace/config", + vol.Optional("force", default=False): bool, + vol.Optional(CONF_URL_PATH): vol.Any(None, cv.string), + } +) +@_handle_errors +async def websocket_lovelace_config(hass, connection, msg, config): + """Send Lovelace UI config over WebSocket configuration.""" + return await config.async_load(msg["force"]) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + "type": "lovelace/config/save", + "config": vol.Any(str, dict), + vol.Optional(CONF_URL_PATH): vol.Any(None, cv.string), + } +) +@_handle_errors +async def websocket_lovelace_save_config(hass, connection, msg, config): + """Save Lovelace UI configuration.""" + await config.async_save(msg["config"]) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + "type": "lovelace/config/delete", + vol.Optional(CONF_URL_PATH): vol.Any(None, cv.string), + } +) +@_handle_errors +async def websocket_lovelace_delete_config(hass, connection, msg, config): + """Delete Lovelace UI configuration.""" + await config.async_delete() diff --git a/homeassistant/components/websocket_api/permissions.py b/homeassistant/components/websocket_api/permissions.py index bd6013aac0a..8b00981fb04 100644 --- a/homeassistant/components/websocket_api/permissions.py +++ b/homeassistant/components/websocket_api/permissions.py @@ -3,7 +3,7 @@ Separate file to avoid circular imports. """ from homeassistant.components.frontend import EVENT_PANELS_UPDATED -from homeassistant.components.lovelace import EVENT_LOVELACE_UPDATED +from homeassistant.components.lovelace.const import EVENT_LOVELACE_UPDATED from homeassistant.components.persistent_notification import ( EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, ) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 33ac15853f4..d14e31273b7 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -1,6 +1,6 @@ """Support for the definition of zones.""" import logging -from typing import Dict, List, Optional, cast +from typing import Dict, Optional, cast import voluptuous as vol @@ -159,32 +159,12 @@ class ZoneStorageCollection(collection.StorageCollection): return {**data, **update_data} -class IDLessCollection(collection.ObservableCollection): - """A collection without IDs.""" - - counter = 0 - - async def async_load(self, data: List[dict]) -> None: - """Load the collection. Overrides existing data.""" - for item_id in list(self.data): - await self.notify_change(collection.CHANGE_REMOVED, item_id, None) - - self.data.clear() - - for item in data: - self.counter += 1 - item_id = f"fakeid-{self.counter}" - - self.data[item_id] = item - await self.notify_change(collection.CHANGE_ADDED, item_id, item) - - async def async_setup(hass: HomeAssistant, config: Dict) -> bool: """Set up configured zones as well as Home Assistant zone if necessary.""" component = entity_component.EntityComponent(_LOGGER, DOMAIN, hass) id_manager = collection.IDManager() - yaml_collection = IDLessCollection( + yaml_collection = collection.IDLessCollection( logging.getLogger(f"{__name__}.yaml_collection"), id_manager ) collection.attach_entity_component_collection( diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index bea08fb322c..d03469e20bb 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -235,6 +235,26 @@ class StorageCollection(ObservableCollection): return {"items": list(self.data.values())} +class IDLessCollection(ObservableCollection): + """A collection without IDs.""" + + counter = 0 + + async def async_load(self, data: List[dict]) -> None: + """Load the collection. Overrides existing data.""" + for item_id in list(self.data): + await self.notify_change(CHANGE_REMOVED, item_id, None) + + self.data.clear() + + for item in data: + self.counter += 1 + item_id = f"fakeid-{self.counter}" + + self.data[item_id] = item + await self.notify_change(CHANGE_ADDED, item_id, item) + + @callback def attach_entity_component_collection( entity_component: EntityComponent, diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 8e4454751bf..565cac4058c 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -402,7 +402,20 @@ def service(value: Any) -> str: raise vol.Invalid(f"Service {value} does not match format .") -def schema_with_slug_keys(value_schema: Union[T, Callable]) -> Callable: +def slug(value: Any) -> str: + """Validate value is a valid slug.""" + if value is None: + raise vol.Invalid("Slug should not be None") + str_value = str(value) + slg = util_slugify(str_value) + if str_value == slg: + return str_value + raise vol.Invalid(f"invalid slug {value} (try {slg})") + + +def schema_with_slug_keys( + value_schema: Union[T, Callable], *, slug_validator: Callable[[Any], str] = slug +) -> Callable: """Ensure dicts have slugs as keys. Replacement of vol.Schema({cv.slug: value_schema}) to prevent misleading @@ -416,24 +429,13 @@ def schema_with_slug_keys(value_schema: Union[T, Callable]) -> Callable: raise vol.Invalid("expected dictionary") for key in value.keys(): - slug(key) + slug_validator(key) return cast(Dict, schema(value)) return verify -def slug(value: Any) -> str: - """Validate value is a valid slug.""" - if value is None: - raise vol.Invalid("Slug should not be None") - str_value = str(value) - slg = util_slugify(str_value) - if str_value == slg: - return str_value - raise vol.Invalid(f"invalid slug {value} (try {slg})") - - def slugify(value: Any) -> str: """Coerce a value to a slug.""" if value is None: diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index f39fa5f1e55..07b6a8d48f8 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -44,9 +44,9 @@ def sanitize_path(path: str) -> str: return RE_SANITIZE_PATH.sub("", path) -def slugify(text: str) -> str: +def slugify(text: str, *, separator: str = "_") -> str: """Slugify a given text.""" - return unicode_slug.slugify(text, separator="_") # type: ignore + return unicode_slug.slugify(text, separator=separator) # type: ignore def repr_helper(inp: Any) -> str: diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_dashboard.py similarity index 61% rename from tests/components/lovelace/test_init.py rename to tests/components/lovelace/test_dashboard.py index 82e7b3bc2ac..9511e001197 100644 --- a/tests/components/lovelace/test_init.py +++ b/tests/components/lovelace/test_dashboard.py @@ -1,7 +1,10 @@ """Test the Lovelace initialization.""" from unittest.mock import patch -from homeassistant.components import frontend, lovelace +import pytest + +from homeassistant.components import frontend +from homeassistant.components.lovelace import const, dashboard from homeassistant.setup import async_setup_component from tests.common import async_capture_events, get_system_health_info @@ -21,14 +24,16 @@ async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage): assert response["error"]["code"] == "config_not_found" # Store new config - events = async_capture_events(hass, lovelace.EVENT_LOVELACE_UPDATED) + events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) await client.send_json( {"id": 6, "type": "lovelace/config/save", "config": {"yo": "hello"}} ) response = await client.receive_json() assert response["success"] - assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": {"yo": "hello"}} + assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == { + "config": {"yo": "hello"} + } assert len(events) == 1 # Load new config @@ -59,7 +64,9 @@ async def test_lovelace_from_storage_save_before_load( ) response = await client.receive_json() assert response["success"] - assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": {"yo": "hello"}} + assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == { + "config": {"yo": "hello"} + } async def test_lovelace_from_storage_delete(hass, hass_ws_client, hass_storage): @@ -73,13 +80,17 @@ async def test_lovelace_from_storage_delete(hass, hass_ws_client, hass_storage): ) response = await client.receive_json() assert response["success"] - assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": {"yo": "hello"}} + assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == { + "config": {"yo": "hello"} + } # Delete config await client.send_json({"id": 7, "type": "lovelace/config/delete"}) response = await client.receive_json() assert response["success"] - assert hass_storage[lovelace.STORAGE_KEY]["data"] == {"config": None} + assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == { + "config": None + } # Fetch data await client.send_json({"id": 8, "type": "lovelace/config"}) @@ -110,10 +121,11 @@ async def test_lovelace_from_yaml(hass, hass_ws_client): assert not response["success"] # Patch data - events = async_capture_events(hass, lovelace.EVENT_LOVELACE_UPDATED) + events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) with patch( - "homeassistant.components.lovelace.load_yaml", return_value={"hello": "yo"} + "homeassistant.components.lovelace.dashboard.load_yaml", + return_value={"hello": "yo"}, ): await client.send_json({"id": 7, "type": "lovelace/config"}) response = await client.receive_json() @@ -125,7 +137,8 @@ async def test_lovelace_from_yaml(hass, hass_ws_client): # Fake new data to see we fire event with patch( - "homeassistant.components.lovelace.load_yaml", return_value={"hello": "yo2"} + "homeassistant.components.lovelace.dashboard.load_yaml", + return_value={"hello": "yo2"}, ): await client.send_json({"id": 8, "type": "lovelace/config", "force": True}) response = await client.receive_json() @@ -145,7 +158,7 @@ async def test_system_health_info_autogen(hass): async def test_system_health_info_storage(hass, hass_storage): """Test system health info endpoint.""" - hass_storage[lovelace.STORAGE_KEY] = { + hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = { "key": "lovelace", "version": 1, "data": {"config": {"resources": [], "views": []}}, @@ -159,7 +172,7 @@ async def test_system_health_info_yaml(hass): """Test system health info endpoint.""" assert await async_setup_component(hass, "lovelace", {"lovelace": {"mode": "YAML"}}) with patch( - "homeassistant.components.lovelace.load_yaml", + "homeassistant.components.lovelace.dashboard.load_yaml", return_value={"views": [{"cards": []}]}, ): info = await get_system_health_info(hass, "lovelace") @@ -174,3 +187,81 @@ async def test_system_health_info_yaml_not_found(hass): "mode": "yaml", "error": "{} not found".format(hass.config.path("ui-lovelace.yaml")), } + + +@pytest.mark.parametrize("url_path", ("test-panel", "test-panel-no-sidebar")) +async def test_dashboard_from_yaml(hass, hass_ws_client, url_path): + """Test we load lovelace dashboard config from yaml.""" + assert await async_setup_component( + hass, + "lovelace", + { + "lovelace": { + "dashboards": { + "test-panel": { + "mode": "yaml", + "filename": "bla.yaml", + "sidebar": {"title": "Test Panel", "icon": "mdi:test-icon"}, + }, + "test-panel-no-sidebar": {"mode": "yaml", "filename": "bla.yaml"}, + } + } + }, + ) + assert hass.data[frontend.DATA_PANELS]["test-panel"].config == {"mode": "yaml"} + assert hass.data[frontend.DATA_PANELS]["test-panel-no-sidebar"].config == { + "mode": "yaml" + } + + client = await hass_ws_client(hass) + + # Fetch data + await client.send_json({"id": 5, "type": "lovelace/config", "url_path": url_path}) + response = await client.receive_json() + assert not response["success"] + + assert response["error"]["code"] == "config_not_found" + + # Store new config not allowed + await client.send_json( + { + "id": 6, + "type": "lovelace/config/save", + "config": {"yo": "hello"}, + "url_path": url_path, + } + ) + response = await client.receive_json() + assert not response["success"] + + # Patch data + events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) + + with patch( + "homeassistant.components.lovelace.dashboard.load_yaml", + return_value={"hello": "yo"}, + ): + await client.send_json( + {"id": 7, "type": "lovelace/config", "url_path": url_path} + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"hello": "yo"} + + assert len(events) == 0 + + # Fake new data to see we fire event + with patch( + "homeassistant.components.lovelace.dashboard.load_yaml", + return_value={"hello": "yo2"}, + ): + await client.send_json( + {"id": 8, "type": "lovelace/config", "force": True, "url_path": url_path} + ) + response = await client.receive_json() + + assert response["success"] + assert response["result"] == {"hello": "yo2"} + + assert len(events) == 1 diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py new file mode 100644 index 00000000000..89464d95350 --- /dev/null +++ b/tests/components/lovelace/test_resources.py @@ -0,0 +1,113 @@ +"""Test Lovelace resources.""" +import copy +import uuid + +from asynctest import patch + +from homeassistant.components.lovelace import dashboard, resources +from homeassistant.setup import async_setup_component + +RESOURCE_EXAMPLES = [ + {"type": "js", "url": "/local/bla.js"}, + {"type": "css", "url": "/local/bla.css"}, +] + + +async def test_yaml_resources(hass, hass_ws_client): + """Test defining resources in configuration.yaml.""" + assert await async_setup_component( + hass, "lovelace", {"lovelace": {"mode": "yaml", "resources": RESOURCE_EXAMPLES}} + ) + + client = await hass_ws_client(hass) + + # Fetch data + await client.send_json({"id": 5, "type": "lovelace/resources"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == RESOURCE_EXAMPLES + + +async def test_yaml_resources_backwards(hass, hass_ws_client): + """Test defining resources in YAML ll config (legacy).""" + with patch( + "homeassistant.components.lovelace.dashboard.load_yaml", + return_value={"resources": RESOURCE_EXAMPLES}, + ): + assert await async_setup_component( + hass, "lovelace", {"lovelace": {"mode": "yaml"}} + ) + + client = await hass_ws_client(hass) + + # Fetch data + await client.send_json({"id": 5, "type": "lovelace/resources"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == RESOURCE_EXAMPLES + + +async def test_storage_resources(hass, hass_ws_client, hass_storage): + """Test defining resources in storage config.""" + resource_config = [{**item, "id": uuid.uuid4().hex} for item in RESOURCE_EXAMPLES] + hass_storage[resources.RESOURCE_STORAGE_KEY] = { + "key": resources.RESOURCE_STORAGE_KEY, + "version": 1, + "data": {"items": resource_config}, + } + assert await async_setup_component(hass, "lovelace", {}) + + client = await hass_ws_client(hass) + + # Fetch data + await client.send_json({"id": 5, "type": "lovelace/resources"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == resource_config + + +async def test_storage_resources_import(hass, hass_ws_client, hass_storage): + """Test importing resources from storage config.""" + assert await async_setup_component(hass, "lovelace", {}) + hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = { + "key": "lovelace", + "version": 1, + "data": {"config": {"resources": copy.deepcopy(RESOURCE_EXAMPLES)}}, + } + + client = await hass_ws_client(hass) + + # Fetch data + await client.send_json({"id": 5, "type": "lovelace/resources"}) + response = await client.receive_json() + assert response["success"] + assert ( + response["result"] + == hass_storage[resources.RESOURCE_STORAGE_KEY]["data"]["items"] + ) + assert ( + "resources" + not in hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"]["config"] + ) + + +async def test_storage_resources_import_invalid(hass, hass_ws_client, hass_storage): + """Test importing resources from storage config.""" + assert await async_setup_component(hass, "lovelace", {}) + hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT] = { + "key": "lovelace", + "version": 1, + "data": {"config": {"resources": [{"invalid": "resource"}]}}, + } + + client = await hass_ws_client(hass) + + # Fetch data + await client.send_json({"id": 5, "type": "lovelace/resources"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [] + assert ( + "resources" + in hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"]["config"] + ) From ceb3985a990a08120bdd5a5377bb76cd6c8be4f7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 25 Feb 2020 20:21:05 +0100 Subject: [PATCH 103/416] Remove hide_entity property from automation integration (#32038) * Remove hidden property from automation integration * Allow configuration options untils 0.110 --- homeassistant/components/automation/__init__.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 6f06eeb0094..c19a0033f86 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -57,7 +57,6 @@ CONDITION_TYPE_AND = "and" CONDITION_TYPE_OR = "or" DEFAULT_CONDITION_TYPE = CONDITION_TYPE_AND -DEFAULT_HIDE_ENTITY = False DEFAULT_INITIAL_STATE = True ATTR_LAST_TRIGGERED = "last_triggered" @@ -92,7 +91,7 @@ _TRIGGER_SCHEMA = vol.All( _CONDITION_SCHEMA = vol.All(cv.ensure_list, [cv.CONDITION_SCHEMA]) PLATFORM_SCHEMA = vol.All( - cv.deprecated(CONF_HIDE_ENTITY, invalidation_version="0.107"), + cv.deprecated(CONF_HIDE_ENTITY, invalidation_version="0.110"), vol.Schema( { # str on purpose @@ -100,7 +99,7 @@ PLATFORM_SCHEMA = vol.All( CONF_ALIAS: cv.string, vol.Optional(CONF_DESCRIPTION): cv.string, vol.Optional(CONF_INITIAL_STATE): cv.boolean, - vol.Optional(CONF_HIDE_ENTITY, default=DEFAULT_HIDE_ENTITY): cv.boolean, + vol.Optional(CONF_HIDE_ENTITY): cv.boolean, vol.Required(CONF_TRIGGER): _TRIGGER_SCHEMA, vol.Optional(CONF_CONDITION): _CONDITION_SCHEMA, vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA, @@ -235,7 +234,6 @@ class AutomationEntity(ToggleEntity, RestoreEntity): trigger_config, cond_func, action_script, - hidden, initial_state, ): """Initialize an automation entity.""" @@ -246,7 +244,6 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self._cond_func = cond_func self.action_script = action_script self._last_triggered = None - self._hidden = hidden self._initial_state = initial_state self._is_enabled = False self._referenced_entities: Optional[Set[str]] = None @@ -272,11 +269,6 @@ class AutomationEntity(ToggleEntity, RestoreEntity): """Return the entity state attributes.""" return {ATTR_LAST_TRIGGERED: self._last_triggered} - @property - def hidden(self) -> bool: - """Return True if the automation entity should be hidden from UIs.""" - return self._hidden - @property def is_on(self) -> bool: """Return True if entity is on.""" @@ -499,7 +491,6 @@ async def _async_process_config(hass, config, component): automation_id = config_block.get(CONF_ID) name = config_block.get(CONF_ALIAS) or f"{config_key} {list_no}" - hidden = config_block[CONF_HIDE_ENTITY] initial_state = config_block.get(CONF_INITIAL_STATE) action_script = script.Script( @@ -520,7 +511,6 @@ async def _async_process_config(hass, config, component): config_block[CONF_TRIGGER], cond_func, action_script, - hidden, initial_state, ) From 7d8da4730916be15498bec9cf1bfccc2e68c620b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 25 Feb 2020 12:07:16 -0800 Subject: [PATCH 104/416] Revert "Use orjson to parse json faster (#32153)" (#32185) This reverts commit 2365e2e8cf60f6e86e9033f27082750526825209. --- homeassistant/components/recorder/models.py | 5 ++--- homeassistant/helpers/aiohttp_client.py | 2 -- homeassistant/package_constraints.txt | 1 - homeassistant/util/json.py | 8 +++----- pylintrc | 2 +- requirements_all.txt | 1 - setup.py | 1 - 7 files changed, 6 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 3cc5c54e992..f3e80a9a739 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -3,7 +3,6 @@ from datetime import datetime import json import logging -import orjson from sqlalchemy import ( Boolean, Column, @@ -64,7 +63,7 @@ class Events(Base): # type: ignore try: return Event( self.event_type, - orjson.loads(self.event_data), + json.loads(self.event_data), EventOrigin(self.origin), _process_timestamp(self.time_fired), context=context, @@ -134,7 +133,7 @@ class States(Base): # type: ignore return State( self.entity_id, self.state, - orjson.loads(self.attributes), + json.loads(self.attributes), _process_timestamp(self.last_changed), _process_timestamp(self.last_updated), context=context, diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index a90b8b61fb4..eee891b7f88 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -9,7 +9,6 @@ from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE, USER_AGENT from aiohttp.web_exceptions import HTTPBadGateway, HTTPGatewayTimeout import async_timeout -import orjson from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.core import Event, callback @@ -68,7 +67,6 @@ def async_create_clientsession( loop=hass.loop, connector=connector, headers={USER_AGENT: SERVER_SOFTWARE}, - json_serialize=lambda x: orjson.dumps(x).decode(), **kwargs, ) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9c3ea995210..cef7cda8017 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,6 @@ home-assistant-frontend==20200220.1 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 -orjson==2.5.1 pip>=8.0.3 python-slugify==4.0.0 pytz>=2019.03 diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index ac64794d952..94dc816e03c 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -6,8 +6,6 @@ import os import tempfile from typing import Any, Dict, List, Optional, Type, Union -import orjson - from homeassistant.exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) @@ -30,7 +28,7 @@ def load_json( """ try: with open(filename, encoding="utf-8") as fdesc: - return orjson.loads(fdesc.read()) # type: ignore + return json.loads(fdesc.read()) # type: ignore except FileNotFoundError: # This is not a fatal error _LOGGER.debug("JSON file not found: %s", filename) @@ -99,7 +97,7 @@ def find_paths_unserializable_data(bad_data: Any) -> List[str]: obj, obj_path = to_process.popleft() try: - orjson.dumps(obj) + json.dumps(obj) continue except TypeError: pass @@ -108,7 +106,7 @@ def find_paths_unserializable_data(bad_data: Any) -> List[str]: for key, value in obj.items(): try: # Is key valid? - orjson.dumps({key: None}) + json.dumps({key: None}) except TypeError: invalid.append(f"{obj_path}") else: diff --git a/pylintrc b/pylintrc index 00e1621bb04..125062c8cfe 100644 --- a/pylintrc +++ b/pylintrc @@ -5,7 +5,7 @@ ignore=tests jobs=2 load-plugins=pylint_strict_informational persistent=no -extension-pkg-whitelist=ciso8601,orjson +extension-pkg-whitelist=ciso8601 [BASIC] good-names=id,i,j,k,ex,Run,_,fp diff --git a/requirements_all.txt b/requirements_all.txt index 8c4f0869417..edb4564aa72 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -10,7 +10,6 @@ importlib-metadata==1.5.0 jinja2>=2.10.3 PyJWT==1.7.1 cryptography==2.8 -orjson==2.5.1 pip>=8.0.3 python-slugify==4.0.0 pytz>=2019.03 diff --git a/setup.py b/setup.py index 997e0595441..0564b7f4773 100755 --- a/setup.py +++ b/setup.py @@ -44,7 +44,6 @@ REQUIRES = [ "PyJWT==1.7.1", # PyJWT has loose dependency. We want the latest one. "cryptography==2.8", - "orjson==2.5.1", "pip>=8.0.3", "python-slugify==4.0.0", "pytz>=2019.03", From 1ddc1ebc6b546681902a824747d3a0c8107c1327 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Tue, 25 Feb 2020 20:19:04 +0000 Subject: [PATCH 105/416] Refactor away deprecated homekit_controller test helpers (#32177) --- tests/components/homekit_controller/common.py | 99 +++---------------- .../specific_devices/test_ecobee3.py | 2 +- .../specific_devices/test_koogeek_ls1.py | 2 +- .../homekit_controller/test_config_flow.py | 15 ++- 4 files changed, 27 insertions(+), 91 deletions(-) diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index fa55b605cf3..51a12815124 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -4,10 +4,10 @@ import json import os from unittest import mock -from aiohomekit.exceptions import AccessoryNotFoundError -from aiohomekit.model import Accessory +from aiohomekit.model import Accessories, Accessory from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from aiohomekit.testing import FakeController from homeassistant import config_entries from homeassistant.components.homekit_controller import config_flow @@ -22,77 +22,6 @@ import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed, load_fixture -class FakePairing: - """ - A test fake that pretends to be a paired HomeKit accessory. - - This only contains methods and values that exist on the upstream Pairing - class. - """ - - def __init__(self, accessories): - """Create a fake pairing from an accessory model.""" - self.accessories = accessories - self.pairing_data = {} - self.available = True - - async def list_accessories_and_characteristics(self): - """Fake implementation of list_accessories_and_characteristics.""" - accessories = [a.to_accessory_and_service_list() for a in self.accessories] - # replicate what happens upstream right now - self.pairing_data["accessories"] = accessories - return accessories - - async def get_characteristics(self, characteristics): - """Fake implementation of get_characteristics.""" - if not self.available: - raise AccessoryNotFoundError("Accessory not found") - - results = {} - for aid, cid in characteristics: - for accessory in self.accessories: - if aid != accessory.aid: - continue - for service in accessory.services: - for char in service.characteristics: - if char.iid != cid: - continue - results[(aid, cid)] = {"value": char.get_value()} - return results - - async def put_characteristics(self, characteristics): - """Fake implementation of put_characteristics.""" - for aid, cid, new_val in characteristics: - for accessory in self.accessories: - if aid != accessory.aid: - continue - for service in accessory.services: - for char in service.characteristics: - if char.iid != cid: - continue - char.set_value(new_val) - return {} - - -class FakeController: - """ - A test fake that pretends to be a paired HomeKit accessory. - - This only contains methods and values that exist on the upstream Controller - class. - """ - - def __init__(self): - """Create a Fake controller with no pairings.""" - self.pairings = {} - - def add(self, accessories): - """Create and register a fake pairing for a simulated accessory.""" - pairing = FakePairing(accessories) - self.pairings["00:00:00:00:00:00"] = pairing - return pairing - - class Helper: """Helper methods for interacting with HomeKit fakes.""" @@ -151,28 +80,22 @@ async def setup_platform(hass): async def setup_test_accessories(hass, accessories): """Load a fake homekit device based on captured JSON profile.""" fake_controller = await setup_platform(hass) - pairing = fake_controller.add(accessories) - discovery_info = { - "name": "TestDevice", - "host": "127.0.0.1", - "port": 8080, - "properties": {"md": "TestDevice", "id": "00:00:00:00:00:00", "c#": 1}, - } + pairing_id = "00:00:00:00:00:00" - pairing.pairing_data.update( - {"AccessoryPairingID": discovery_info["properties"]["id"]} - ) + accessories_obj = Accessories() + for accessory in accessories: + accessories_obj.add_accessory(accessory) + pairing = await fake_controller.add_paired_device(accessories_obj, pairing_id) config_entry = MockConfigEntry( version=1, domain="homekit_controller", entry_id="TestData", - data=pairing.pairing_data, + data={"AccessoryPairingID": pairing_id}, title="test", connection_class=config_entries.CONN_CLASS_LOCAL_PUSH, ) - config_entry.add_to_hass(hass) pairing_cls_loc = "homeassistant.components.homekit_controller.connection.IpPairing" @@ -189,7 +112,11 @@ async def device_config_changed(hass, accessories): # Update the accessories our FakePairing knows about controller = hass.data[CONTROLLER] pairing = controller.pairings["00:00:00:00:00:00"] - pairing.accessories = accessories + + accessories_obj = Accessories() + for accessory in accessories: + accessories_obj.add_accessory(accessory) + pairing.accessories = accessories_obj discovery_info = { "name": "TestDevice", diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 8b869f881d5..7a18dad4f5c 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -7,6 +7,7 @@ https://github.com/home-assistant/home-assistant/issues/15336 from unittest import mock from aiohomekit import AccessoryDisconnectedError +from aiohomekit.testing import FakePairing from homeassistant.components.climate.const import ( SUPPORT_TARGET_HUMIDITY, @@ -15,7 +16,6 @@ from homeassistant.components.climate.const import ( from homeassistant.config_entries import ENTRY_STATE_SETUP_RETRY from tests.components.homekit_controller.common import ( - FakePairing, Helper, device_config_changed, setup_accessories_from_file, diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py index 33647f85a0b..2abd12b3df4 100644 --- a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py +++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py @@ -4,6 +4,7 @@ from datetime import timedelta from unittest import mock from aiohomekit.exceptions import AccessoryDisconnectedError, EncryptionError +from aiohomekit.testing import FakePairing import pytest from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_COLOR @@ -11,7 +12,6 @@ import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed from tests.components.homekit_controller.common import ( - FakePairing, Helper, setup_accessories_from_file, setup_test_accessories, diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index e02bc045b3e..2f2554caf85 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -505,8 +505,11 @@ async def test_parse_new_homekit_json(hass): on_char = service.add_char(CharacteristicsTypes.ON) on_char.value = 0 + accessories = Accessories() + accessories.add_accessory(accessory) + fake_controller = await setup_platform(hass) - pairing = fake_controller.add([accessory]) + pairing = await fake_controller.add_paired_device(accessories, "00:00:00:00:00:00") pairing.pairing_data = {"AccessoryPairingID": "00:00:00:00:00:00"} mock_path = mock.Mock() @@ -551,8 +554,11 @@ async def test_parse_old_homekit_json(hass): on_char = service.add_char(CharacteristicsTypes.ON) on_char.value = 0 + accessories = Accessories() + accessories.add_accessory(accessory) + fake_controller = await setup_platform(hass) - pairing = fake_controller.add([accessory]) + pairing = await fake_controller.add_paired_device(accessories, "00:00:00:00:00:00") pairing.pairing_data = {"AccessoryPairingID": "00:00:00:00:00:00"} mock_path = mock.Mock() @@ -601,8 +607,11 @@ async def test_parse_overlapping_homekit_json(hass): on_char = service.add_char(CharacteristicsTypes.ON) on_char.value = 0 + accessories = Accessories() + accessories.add_accessory(accessory) + fake_controller = await setup_platform(hass) - pairing = fake_controller.add([accessory]) + pairing = await fake_controller.add_paired_device(accessories) pairing.pairing_data = {"AccessoryPairingID": "00:00:00:00:00:00"} mock_listdir = mock.Mock() From 24652d82ab96c5083ec2694f211b1409c511a218 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Arnauts?= Date: Tue, 25 Feb 2020 21:29:45 +0100 Subject: [PATCH 106/416] Bump python-tado to 0.3.0 (#32186) --- homeassistant/components/tado/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 4728f1622ed..e51cc53caa5 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -3,7 +3,7 @@ "name": "Tado", "documentation": "https://www.home-assistant.io/integrations/tado", "requirements": [ - "python-tado==0.2.9" + "python-tado==0.3.0" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index edb4564aa72..0ec56c9c08f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1632,7 +1632,7 @@ python-songpal==0.11.2 python-synology==0.4.0 # homeassistant.components.tado -python-tado==0.2.9 +python-tado==0.3.0 # homeassistant.components.telegram_bot python-telegram-bot==11.1.0 From dd13e999677baf5e3b943d5233609abcb8eacd21 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Tue, 25 Feb 2020 20:43:14 +0000 Subject: [PATCH 107/416] Add missing device class attributes to homekit_controller sensors (#32175) * Add some missing device class attributes to homekit_controller sensors * Add classes for binary sensors --- .../homekit_controller/binary_sensor.py | 9 ++++++++- .../components/homekit_controller/sensor.py | 18 ++++++++++++++++++ .../homekit_controller/test_binary_sensor.py | 12 +++++++++++- .../homekit_controller/test_sensor.py | 15 +++++++++++++++ 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py index 467a9567676..39e37fab68e 100644 --- a/homeassistant/components/homekit_controller/binary_sensor.py +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -4,6 +4,8 @@ import logging from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOTION, + DEVICE_CLASS_OPENING, DEVICE_CLASS_SMOKE, BinarySensorDevice, ) @@ -32,7 +34,7 @@ class HomeKitMotionSensor(HomeKitEntity, BinarySensorDevice): @property def device_class(self): """Define this binary_sensor as a motion sensor.""" - return "motion" + return DEVICE_CLASS_MOTION @property def is_on(self): @@ -55,6 +57,11 @@ class HomeKitContactSensor(HomeKitEntity, BinarySensorDevice): def _update_contact_state(self, value): self._state = value + @property + def device_class(self): + """Define this binary_sensor as a opening sensor.""" + return DEVICE_CLASS_OPENING + @property def is_on(self): """Return true if the binary sensor is on/open.""" diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index a71ff7e4ac2..ab8a6fa6672 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -4,6 +4,9 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, ) from homeassistant.core import callback @@ -31,6 +34,11 @@ class HomeKitHumiditySensor(HomeKitEntity): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT] + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_HUMIDITY + @property def name(self): """Return the name of the device.""" @@ -67,6 +75,11 @@ class HomeKitTemperatureSensor(HomeKitEntity): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.TEMPERATURE_CURRENT] + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_TEMPERATURE + @property def name(self): """Return the name of the device.""" @@ -103,6 +116,11 @@ class HomeKitLightSensor(HomeKitEntity): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.LIGHT_LEVEL_CURRENT] + @property + def device_class(self) -> str: + """Return the device class of the sensor.""" + return DEVICE_CLASS_ILLUMINANCE + @property def name(self): """Return the name of the device.""" diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py index 2809ab860be..5107ae32bd5 100644 --- a/tests/components/homekit_controller/test_binary_sensor.py +++ b/tests/components/homekit_controller/test_binary_sensor.py @@ -2,6 +2,12 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_MOTION, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_SMOKE, +) + from tests.components.homekit_controller.common import setup_test_component MOTION_DETECTED = ("motion", "motion-detected") @@ -29,6 +35,8 @@ async def test_motion_sensor_read_state(hass, utcnow): state = await helper.poll_and_get_state() assert state.state == "on" + assert state.attributes["device_class"] == DEVICE_CLASS_MOTION + def create_contact_sensor_service(accessory): """Define contact characteristics.""" @@ -50,6 +58,8 @@ async def test_contact_sensor_read_state(hass, utcnow): state = await helper.poll_and_get_state() assert state.state == "on" + assert state.attributes["device_class"] == DEVICE_CLASS_OPENING + def create_smoke_sensor_service(accessory): """Define smoke sensor characteristics.""" @@ -71,4 +81,4 @@ async def test_smoke_sensor_read_state(hass, utcnow): state = await helper.poll_and_get_state() assert state.state == "on" - assert state.attributes["device_class"] == "smoke" + assert state.attributes["device_class"] == DEVICE_CLASS_SMOKE diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index 5b1c5e1ac85..8b0528ea46d 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -2,6 +2,13 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes +from homeassistant.const import ( + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, +) + from tests.components.homekit_controller.common import setup_test_component TEMPERATURE = ("temperature", "temperature.current") @@ -75,6 +82,8 @@ async def test_temperature_sensor_read_state(hass, utcnow): state = await helper.poll_and_get_state() assert state.state == "20" + assert state.attributes["device_class"] == DEVICE_CLASS_TEMPERATURE + async def test_humidity_sensor_read_state(hass, utcnow): """Test reading the state of a HomeKit humidity sensor accessory.""" @@ -90,6 +99,8 @@ async def test_humidity_sensor_read_state(hass, utcnow): state = await helper.poll_and_get_state() assert state.state == "20" + assert state.attributes["device_class"] == DEVICE_CLASS_HUMIDITY + async def test_light_level_sensor_read_state(hass, utcnow): """Test reading the state of a HomeKit temperature sensor accessory.""" @@ -105,6 +116,8 @@ async def test_light_level_sensor_read_state(hass, utcnow): state = await helper.poll_and_get_state() assert state.state == "20" + assert state.attributes["device_class"] == DEVICE_CLASS_ILLUMINANCE + async def test_carbon_dioxide_level_sensor_read_state(hass, utcnow): """Test reading the state of a HomeKit carbon dioxide sensor accessory.""" @@ -137,6 +150,8 @@ async def test_battery_level_sensor(hass, utcnow): assert state.state == "20" assert state.attributes["icon"] == "mdi:battery-20" + assert state.attributes["device_class"] == DEVICE_CLASS_BATTERY + async def test_battery_charging(hass, utcnow): """Test reading the state of a HomeKit battery's charging state.""" From 4a89fba6f9a2f7b5db1247032fcae4b93d0bbf49 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Tue, 25 Feb 2020 22:01:03 +0000 Subject: [PATCH 108/416] Add homekit_controller occupancy sensor (#32188) --- .../homekit_controller/binary_sensor.py | 28 +++ .../components/homekit_controller/const.py | 1 + .../specific_devices/test_ecobee_occupancy.py | 37 +++ .../homekit_controller/test_binary_sensor.py | 25 ++ .../homekit_controller/ecobee_occupancy.json | 236 ++++++++++++++++++ 5 files changed, 327 insertions(+) create mode 100644 tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py create mode 100644 tests/fixtures/homekit_controller/ecobee_occupancy.json diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py index 39e37fab68e..7ca7f7a5711 100644 --- a/homeassistant/components/homekit_controller/binary_sensor.py +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -5,6 +5,7 @@ from aiohomekit.model.characteristics import CharacteristicsTypes from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, DEVICE_CLASS_OPENING, DEVICE_CLASS_SMOKE, BinarySensorDevice, @@ -94,10 +95,37 @@ class HomeKitSmokeSensor(HomeKitEntity, BinarySensorDevice): return self._state == 1 +class HomeKitOccupancySensor(HomeKitEntity, BinarySensorDevice): + """Representation of a Homekit smoke sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._state = None + + @property + def device_class(self) -> str: + """Return the class of this sensor.""" + return DEVICE_CLASS_OCCUPANCY + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + return [CharacteristicsTypes.OCCUPANCY_DETECTED] + + def _update_occupancy_detected(self, value): + self._state = value + + @property + def is_on(self): + """Return true if smoke is currently detected.""" + return self._state == 1 + + ENTITY_TYPES = { "motion": HomeKitMotionSensor, "contact": HomeKitContactSensor, "smoke": HomeKitSmokeSensor, + "occupancy": HomeKitOccupancySensor, } diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 684f83ba5d4..9c750b17e8f 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -30,4 +30,5 @@ HOMEKIT_ACCESSORY_DISPATCH = { "fan": "fan", "fanv2": "fan", "air-quality": "air_quality", + "occupancy": "binary_sensor", } diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py new file mode 100644 index 00000000000..b1a8c0a636f --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_ecobee_occupancy.py @@ -0,0 +1,37 @@ +""" +Regression tests for Ecobee occupancy. + +https://github.com/home-assistant/home-assistant/issues/31827 +""" + +from tests.components.homekit_controller.common import ( + Helper, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_ecobee_occupancy_setup(hass): + """Test that an Ecbobee occupancy sensor be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "ecobee_occupancy.json") + config_entry, pairing = await setup_test_accessories(hass, accessories) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + sensor = entity_registry.async_get("binary_sensor.master_fan") + assert sensor.unique_id == "homekit-111111111111-56" + + sensor_helper = Helper( + hass, "binary_sensor.master_fan", pairing, accessories[0], config_entry + ) + sensor_state = await sensor_helper.poll_and_get_state() + assert sensor_state.attributes["friendly_name"] == "Master Fan" + + device_registry = await hass.helpers.device_registry.async_get_registry() + + device = device_registry.async_get(sensor.device_id) + assert device.manufacturer == "ecobee Inc." + assert device.name == "Master Fan" + assert device.model == "ecobee Switch+" + assert device.sw_version == "4.5.130201" + assert device.via_device_id is None diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py index 5107ae32bd5..8817ed5c22d 100644 --- a/tests/components/homekit_controller/test_binary_sensor.py +++ b/tests/components/homekit_controller/test_binary_sensor.py @@ -4,6 +4,7 @@ from aiohomekit.model.services import ServicesTypes from homeassistant.components.binary_sensor import ( DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, DEVICE_CLASS_OPENING, DEVICE_CLASS_SMOKE, ) @@ -13,6 +14,7 @@ from tests.components.homekit_controller.common import setup_test_component MOTION_DETECTED = ("motion", "motion-detected") CONTACT_STATE = ("contact", "contact-state") SMOKE_DETECTED = ("smoke", "smoke-detected") +OCCUPANCY_DETECTED = ("occupancy", "occupancy-detected") def create_motion_sensor_service(accessory): @@ -82,3 +84,26 @@ async def test_smoke_sensor_read_state(hass, utcnow): assert state.state == "on" assert state.attributes["device_class"] == DEVICE_CLASS_SMOKE + + +def create_occupancy_sensor_service(accessory): + """Define occupancy characteristics.""" + service = accessory.add_service(ServicesTypes.OCCUPANCY_SENSOR) + + cur_state = service.add_char(CharacteristicsTypes.OCCUPANCY_DETECTED) + cur_state.value = 0 + + +async def test_occupancy_sensor_read_state(hass, utcnow): + """Test that we can read the state of a HomeKit occupancy sensor accessory.""" + helper = await setup_test_component(hass, create_occupancy_sensor_service) + + helper.characteristics[OCCUPANCY_DETECTED].value = False + state = await helper.poll_and_get_state() + assert state.state == "off" + + helper.characteristics[OCCUPANCY_DETECTED].value = True + state = await helper.poll_and_get_state() + assert state.state == "on" + + assert state.attributes["device_class"] == DEVICE_CLASS_OCCUPANCY diff --git a/tests/fixtures/homekit_controller/ecobee_occupancy.json b/tests/fixtures/homekit_controller/ecobee_occupancy.json new file mode 100644 index 00000000000..78c98599961 --- /dev/null +++ b/tests/fixtures/homekit_controller/ecobee_occupancy.json @@ -0,0 +1,236 @@ +[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + { + "format": "string", + "iid": 2, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Master Fan" + }, + { + "format": "string", + "iid": 3, + "perms": [ + "pr" + ], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "ecobee Inc." + }, + { + "format": "string", + "iid": 4, + "perms": [ + "pr" + ], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "111111111111" + }, + { + "format": "string", + "iid": 5, + "perms": [ + "pr" + ], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "ecobee Switch+" + }, + { + "format": "bool", + "iid": 6, + "perms": [ + "pw" + ], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "format": "string", + "iid": 8, + "perms": [ + "pr" + ], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "4.5.130201" + }, + { + "format": "uint32", + "iid": 9, + "perms": [ + "pr", + "ev" + ], + "type": "000000A6-0000-1000-8000-0026BB765291", + "value": 0 + } + ], + "iid": 1, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "string", + "iid": 31, + "maxLen": 64, + "perms": [ + "pr" + ], + "type": "00000037-0000-1000-8000-0026BB765291", + "value": "1.1.0" + } + ], + "iid": 30, + "stype": "service", + "type": "000000A2-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "bool", + "iid": 17, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "00000025-0000-1000-8000-0026BB765291", + "value": false + }, + { + "format": "string", + "iid": 18, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Master Fan" + } + ], + "iid": 16, + "primary": true, + "stype": "switch", + "type": "00000049-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "float", + "iid": 20, + "maxValue": 100000, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "0000006B-0000-1000-8000-0026BB765291", + "unit": "lux", + "value": 0 + }, + { + "format": "string", + "iid": 21, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Master Fan" + } + ], + "iid": 27, + "stype": "light", + "type": "00000084-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "bool", + "iid": 66, + "perms": [ + "pr", + "ev" + ], + "type": "00000022-0000-1000-8000-0026BB765291", + "value": false + }, + { + "format": "string", + "iid": 28, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Master Fan" + } + ], + "iid": 56, + "stype": "motion", + "type": "00000085-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "uint8", + "iid": 65, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "00000071-0000-1000-8000-0026BB765291", + "value": 0 + }, + { + "format": "string", + "iid": 29, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Master Fan" + } + ], + "iid": 57, + "stype": "occupancy", + "type": "00000086-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "format": "float", + "iid": 19, + "maxValue": 100, + "minStep": 0.1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "00000011-0000-1000-8000-0026BB765291", + "unit": "celsius", + "value": 25.6 + }, + { + "format": "string", + "iid": 22, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Master Fan" + } + ], + "iid": 55, + "stype": "temperature", + "type": "0000008A-0000-1000-8000-0026BB765291" + } + ] + } +] \ No newline at end of file From 1f23361a5d44165c3c164262fcb1ade1b35ac49f Mon Sep 17 00:00:00 2001 From: Jenny Date: Tue, 25 Feb 2020 22:01:18 +0000 Subject: [PATCH 109/416] Bump socialbladeclient to 0.5 (#32191) --- homeassistant/components/socialblade/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/socialblade/manifest.json b/homeassistant/components/socialblade/manifest.json index 2ce7fbabf0f..540febe7f2e 100644 --- a/homeassistant/components/socialblade/manifest.json +++ b/homeassistant/components/socialblade/manifest.json @@ -2,7 +2,7 @@ "domain": "socialblade", "name": "Social Blade", "documentation": "https://www.home-assistant.io/integrations/socialblade", - "requirements": ["socialbladeclient==0.2"], + "requirements": ["socialbladeclient==0.5"], "dependencies": [], "codeowners": [] } diff --git a/requirements_all.txt b/requirements_all.txt index 0ec56c9c08f..4fd089d79b1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1869,7 +1869,7 @@ smhi-pkg==1.0.10 snapcast==2.0.10 # homeassistant.components.socialblade -socialbladeclient==0.2 +socialbladeclient==0.5 # homeassistant.components.solaredge_local solaredge-local==0.2.0 From fc0278c91f6ec7a721f4a44f5917358f261733c2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 25 Feb 2020 14:10:43 -0800 Subject: [PATCH 110/416] Fix Arlo doing I/O in event loop (#32190) --- homeassistant/components/arlo/alarm_control_panel.py | 8 ++++---- homeassistant/components/arlo/camera.py | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/arlo/alarm_control_panel.py b/homeassistant/components/arlo/alarm_control_panel.py index 838f319abc1..49a1bced577 100644 --- a/homeassistant/components/arlo/alarm_control_panel.py +++ b/homeassistant/components/arlo/alarm_control_panel.py @@ -110,19 +110,19 @@ class ArloBaseStation(AlarmControlPanel): else: self._state = None - async def async_alarm_disarm(self, code=None): + def alarm_disarm(self, code=None): """Send disarm command.""" self._base_station.mode = DISARMED - async def async_alarm_arm_away(self, code=None): + def alarm_arm_away(self, code=None): """Send arm away command. Uses custom mode.""" self._base_station.mode = self._away_mode_name - async def async_alarm_arm_home(self, code=None): + def alarm_arm_home(self, code=None): """Send arm home command. Uses custom mode.""" self._base_station.mode = self._home_mode_name - async def async_alarm_arm_night(self, code=None): + def alarm_arm_night(self, code=None): """Send arm night command. Uses custom mode.""" self._base_station.mode = self._night_mode_name diff --git a/homeassistant/components/arlo/camera.py b/homeassistant/components/arlo/camera.py index f52c22fced2..8152a76feec 100644 --- a/homeassistant/components/arlo/camera.py +++ b/homeassistant/components/arlo/camera.py @@ -78,8 +78,10 @@ class ArloCam(Camera): async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" + video = await self.hass.async_add_executor_job( + getattr, self._camera, "last_video" + ) - video = self._camera.last_video if not video: error_msg = ( f"Video not found for {self.name}. " From b0fdbebd56325073afc77419ec71f5c417f1dd3a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 25 Feb 2020 23:15:25 +0100 Subject: [PATCH 111/416] Updated frontend to 20200220.3 (#32189) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 993d63ea29b..2b39681af25 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200220.1" + "home-assistant-frontend==20200220.3" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cef7cda8017..746b4be0e61 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.31 -home-assistant-frontend==20200220.1 +home-assistant-frontend==20200220.3 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 4fd089d79b1..7495fca8588 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -687,7 +687,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200220.1 +home-assistant-frontend==20200220.3 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 15f1a1d82a1..a3717f9d7d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -257,7 +257,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200220.1 +home-assistant-frontend==20200220.3 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 From 3e702c8ca4a2f5bc97d34c3cc193743ec274ddf1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2020 13:37:41 -1000 Subject: [PATCH 112/416] Add Config Flow for sense (#32160) * Config Flow for sense * Fix unique ids so they are actually unique (and migrate the old ones) * Fix missing solar production * Do not mark sensors available until they have data * Address review items * Address review round #2 --- .../components/sense/.translations/en.json | 22 ++++ homeassistant/components/sense/__init__.py | 105 ++++++++++++++---- .../components/sense/binary_sensor.py | 50 +++++++-- homeassistant/components/sense/config_flow.py | 75 +++++++++++++ homeassistant/components/sense/const.py | 7 ++ homeassistant/components/sense/manifest.json | 11 +- homeassistant/components/sense/sensor.py | 39 +++++-- homeassistant/components/sense/strings.json | 22 ++++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/sense/__init__.py | 1 + tests/components/sense/test_config_flow.py | 75 +++++++++++++ 12 files changed, 367 insertions(+), 44 deletions(-) create mode 100644 homeassistant/components/sense/.translations/en.json create mode 100644 homeassistant/components/sense/config_flow.py create mode 100644 homeassistant/components/sense/const.py create mode 100644 homeassistant/components/sense/strings.json create mode 100644 tests/components/sense/__init__.py create mode 100644 tests/components/sense/test_config_flow.py diff --git a/homeassistant/components/sense/.translations/en.json b/homeassistant/components/sense/.translations/en.json new file mode 100644 index 00000000000..d3af47b5378 --- /dev/null +++ b/homeassistant/components/sense/.translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "title": "Sense", + "step": { + "user": { + "title": "Connect to your Sense Energy Monitor", + "data": { + "email": "Email Address", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured" + } + } +} diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index ce0d3bce5dc..f54e4092178 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -1,4 +1,5 @@ """Support for monitoring a Sense energy sensor.""" +import asyncio from datetime import timedelta import logging @@ -9,21 +10,25 @@ from sense_energy import ( ) import voluptuous as vol +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval +from .const import ( + ACTIVE_UPDATE_RATE, + DEFAULT_TIMEOUT, + DOMAIN, + SENSE_DATA, + SENSE_DEVICE_UPDATE, +) + _LOGGER = logging.getLogger(__name__) -ACTIVE_UPDATE_RATE = 60 - -DEFAULT_TIMEOUT = 5 -DOMAIN = "sense" - -SENSE_DATA = "sense_data" -SENSE_DEVICE_UPDATE = "sense_devices_update" +PLATFORMS = ["sensor", "binary_sensor"] CONFIG_SCHEMA = vol.Schema( { @@ -39,34 +44,88 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass, config): - """Set up the Sense sensor.""" +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Sense component.""" + hass.data.setdefault(DOMAIN, {}) + conf = config.get(DOMAIN) + if not conf: + return True - username = config[DOMAIN][CONF_EMAIL] - password = config[DOMAIN][CONF_PASSWORD] + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_EMAIL: conf[CONF_EMAIL], + CONF_PASSWORD: conf[CONF_PASSWORD], + CONF_TIMEOUT: conf.get[CONF_TIMEOUT], + }, + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Sense from a config entry.""" + + entry_data = entry.data + email = entry_data[CONF_EMAIL] + password = entry_data[CONF_PASSWORD] + timeout = entry_data[CONF_TIMEOUT] + + gateway = ASyncSenseable(api_timeout=timeout, wss_timeout=timeout) + gateway.rate_limit = ACTIVE_UPDATE_RATE - timeout = config[DOMAIN][CONF_TIMEOUT] try: - hass.data[SENSE_DATA] = ASyncSenseable(api_timeout=timeout, wss_timeout=timeout) - hass.data[SENSE_DATA].rate_limit = ACTIVE_UPDATE_RATE - await hass.data[SENSE_DATA].authenticate(username, password) + await gateway.authenticate(email, password) except SenseAuthenticationException: _LOGGER.error("Could not authenticate with sense server") return False - hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) - hass.async_create_task( - async_load_platform(hass, "binary_sensor", DOMAIN, {}, config) - ) + except SenseAPITimeoutException: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = {SENSE_DATA: gateway} + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) async def async_sense_update(now): """Retrieve latest state.""" try: - await hass.data[SENSE_DATA].update_realtime() - async_dispatcher_send(hass, SENSE_DEVICE_UPDATE) + gateway = hass.data[DOMAIN][entry.entry_id][SENSE_DATA] + await gateway.update_realtime() + async_dispatcher_send( + hass, f"{SENSE_DEVICE_UPDATE}-{gateway.sense_monitor_id}" + ) except SenseAPITimeoutException: _LOGGER.error("Timeout retrieving data") - async_track_time_interval( + hass.data[DOMAIN][entry.entry_id][ + "track_time_remove_callback" + ] = async_track_time_interval( hass, async_sense_update, timedelta(seconds=ACTIVE_UPDATE_RATE) ) 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 + ] + ) + ) + track_time_remove_callback = hass.data[DOMAIN][entry.entry_id][ + "track_time_remove_callback" + ] + track_time_remove_callback() + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index 81f1b64c864..2ae79d71e5a 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -4,11 +4,14 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_registry import async_get_registry -from . import SENSE_DATA, SENSE_DEVICE_UPDATE +from .const import DOMAIN, SENSE_DATA, SENSE_DEVICE_UPDATE _LOGGER = logging.getLogger(__name__) +ATTR_WATTS = "watts" +DEVICE_ID_SOLAR = "solar" BIN_SENSOR_CLASS = "power" MDI_ICONS = { "ac": "air-conditioner", @@ -41,6 +44,7 @@ MDI_ICONS = { "skillet": "pot", "smartcamera": "webcam", "socket": "power-plug", + "solar_alt": "solar-power", "sound": "speaker", "stove": "stove", "trash": "trash-can", @@ -50,21 +54,40 @@ MDI_ICONS = { } -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Sense binary sensor.""" - if discovery_info is None: - return - data = hass.data[SENSE_DATA] + data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DATA] + sense_monitor_id = data.sense_monitor_id sense_devices = await data.get_discovered_device_data() devices = [ - SenseDevice(data, device) + SenseDevice(data, device, sense_monitor_id) for device in sense_devices - if device["tags"]["DeviceListAllowed"] == "true" + if device["id"] == DEVICE_ID_SOLAR + or device["tags"]["DeviceListAllowed"] == "true" ] + + await _migrate_old_unique_ids(hass, devices) + async_add_entities(devices) +async def _migrate_old_unique_ids(hass, devices): + registry = await async_get_registry(hass) + for device in devices: + # Migration of old not so unique ids + old_entity_id = registry.async_get_entity_id( + "binary_sensor", DOMAIN, device.old_unique_id + ) + if old_entity_id is not None: + _LOGGER.debug( + "Migrating unique_id from [%s] to [%s]", + device.old_unique_id, + device.unique_id, + ) + registry.async_update_entity(old_entity_id, new_unique_id=device.unique_id) + + def sense_to_mdi(sense_icon): """Convert sense icon to mdi icon.""" return "mdi:{}".format(MDI_ICONS.get(sense_icon, "power-plug")) @@ -73,10 +96,12 @@ def sense_to_mdi(sense_icon): class SenseDevice(BinarySensorDevice): """Implementation of a Sense energy device binary sensor.""" - def __init__(self, data, device): + def __init__(self, data, device, sense_monitor_id): """Initialize the Sense binary sensor.""" self._name = device["name"] self._id = device["id"] + self._sense_monitor_id = sense_monitor_id + self._unique_id = f"{sense_monitor_id}-{self._id}" self._icon = sense_to_mdi(device["icon"]) self._data = data self._undo_dispatch_subscription = None @@ -93,7 +118,12 @@ class SenseDevice(BinarySensorDevice): @property def unique_id(self): - """Return the id of the binary sensor.""" + """Return the unique id of the binary sensor.""" + return self._unique_id + + @property + def old_unique_id(self): + """Return the old not so unique id of the binary sensor.""" return self._id @property @@ -120,7 +150,7 @@ class SenseDevice(BinarySensorDevice): self.async_schedule_update_ha_state(True) self._undo_dispatch_subscription = async_dispatcher_connect( - self.hass, SENSE_DEVICE_UPDATE, update + self.hass, f"{SENSE_DEVICE_UPDATE}-{self._sense_monitor_id}", update ) async def async_will_remove_from_hass(self): diff --git a/homeassistant/components/sense/config_flow.py b/homeassistant/components/sense/config_flow.py new file mode 100644 index 00000000000..68bbb9ed932 --- /dev/null +++ b/homeassistant/components/sense/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for Sense integration.""" +import logging + +from sense_energy import ( + ASyncSenseable, + SenseAPITimeoutException, + SenseAuthenticationException, +) +import voluptuous as vol + +from homeassistant import config_entries, core +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT + +from .const import ACTIVE_UPDATE_RATE, DEFAULT_TIMEOUT + +from .const import DOMAIN # pylint:disable=unused-import; pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int), + } +) + + +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. + """ + timeout = data[CONF_TIMEOUT] + + gateway = ASyncSenseable(api_timeout=timeout, wss_timeout=timeout) + gateway.rate_limit = ACTIVE_UPDATE_RATE + await gateway.authenticate(data[CONF_EMAIL], data[CONF_PASSWORD]) + + # Return info that you want to store in the config entry. + return {"title": data[CONF_EMAIL]} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Sense.""" + + 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) + await self.async_set_unique_id(user_input[CONF_EMAIL]) + return self.async_create_entry(title=info["title"], data=user_input) + except SenseAPITimeoutException: + errors["base"] = "cannot_connect" + except SenseAuthenticationException: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input): + """Handle import.""" + await self.async_set_unique_id(user_input[CONF_EMAIL]) + self._abort_if_unique_id_configured() + + return await self.async_step_user(user_input) diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py new file mode 100644 index 00000000000..cc30591e02a --- /dev/null +++ b/homeassistant/components/sense/const.py @@ -0,0 +1,7 @@ +"""Constants for monitoring a Sense energy sensor.""" +DOMAIN = "sense" +DEFAULT_TIMEOUT = 10 +ACTIVE_UPDATE_RATE = 60 +DEFAULT_NAME = "Sense" +SENSE_DATA = "sense_data" +SENSE_DEVICE_UPDATE = "sense_devices_update" diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index e27d4bb72f6..61f09fb444b 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -2,7 +2,12 @@ "domain": "sense", "name": "Sense", "documentation": "https://www.home-assistant.io/integrations/sense", - "requirements": ["sense_energy==0.7.0"], + "requirements": [ + "sense_energy==0.7.0" + ], "dependencies": [], - "codeowners": ["@kbickar"] -} + "codeowners": [ + "@kbickar" + ], + "config_flow": true +} \ No newline at end of file diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index d177a480ddf..8d3c8f9e171 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -8,7 +8,7 @@ from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -from . import SENSE_DATA +from .const import DOMAIN, SENSE_DATA _LOGGER = logging.getLogger(__name__) @@ -46,11 +46,9 @@ SENSOR_TYPES = { SENSOR_VARIANTS = [PRODUCTION_NAME.lower(), CONSUMPTION_NAME.lower()] -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): +async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Sense sensor.""" - if discovery_info is None: - return - data = hass.data[SENSE_DATA] + data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DATA] @Throttle(MIN_TIME_BETWEEN_DAILY_UPDATES) async def update_trends(): @@ -61,8 +59,11 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= """Update the active power usage.""" await data.update_realtime() + sense_monitor_id = data.sense_monitor_id + devices = [] - for typ in SENSOR_TYPES.values(): + for type_id in SENSOR_TYPES: + typ = SENSOR_TYPES[type_id] for var in SENSOR_VARIANTS: name = typ.name sensor_type = typ.sensor_type @@ -71,7 +72,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= update_call = update_active else: update_call = update_trends - devices.append(Sense(data, name, sensor_type, is_production, update_call)) + + unique_id = f"{sense_monitor_id}-{type_id}-{var}".lower() + devices.append( + Sense( + data, name, sensor_type, is_production, update_call, var, unique_id + ) + ) async_add_entities(devices) @@ -79,10 +86,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class Sense(Entity): """Implementation of a Sense energy sensor.""" - def __init__(self, data, name, sensor_type, is_production, update_call): + def __init__( + self, data, name, sensor_type, is_production, update_call, sensor_id, unique_id + ): """Initialize the Sense sensor.""" name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME self._name = f"{name} {name_type}" + self._unique_id = unique_id + self._available = False self._data = data self._sensor_type = sensor_type self.update_sensor = update_call @@ -104,6 +115,11 @@ class Sense(Entity): """Return the state of the sensor.""" return self._state + @property + def available(self): + """Return the availability of the sensor.""" + return self._available + @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" @@ -114,6 +130,11 @@ class Sense(Entity): """Icon to use in the frontend, if any.""" return ICON + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + async def async_update(self): """Get the latest data, update state.""" @@ -131,3 +152,5 @@ class Sense(Entity): else: state = self._data.get_trend(self._sensor_type, self._is_production) self._state = round(state, 1) + + self._available = True diff --git a/homeassistant/components/sense/strings.json b/homeassistant/components/sense/strings.json new file mode 100644 index 00000000000..d3af47b5378 --- /dev/null +++ b/homeassistant/components/sense/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "title": "Sense", + "step": { + "user": { + "title": "Connect to your Sense Energy Monitor", + "data": { + "email": "Email Address", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "Device is already configured" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cb12b13afed..d0162f84737 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -78,6 +78,7 @@ FLOWS = [ "rainmachine", "ring", "samsungtv", + "sense", "sentry", "simplisafe", "smartthings", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3717f9d7d7..2b531deeca8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -619,6 +619,9 @@ rxv==0.6.0 # homeassistant.components.samsungtv samsungctl[websocket]==0.7.1 +# homeassistant.components.sense +sense_energy==0.7.0 + # homeassistant.components.sentry sentry-sdk==0.13.5 diff --git a/tests/components/sense/__init__.py b/tests/components/sense/__init__.py new file mode 100644 index 00000000000..bf0a87737b9 --- /dev/null +++ b/tests/components/sense/__init__.py @@ -0,0 +1 @@ +"""Tests for the Sense integration.""" diff --git a/tests/components/sense/test_config_flow.py b/tests/components/sense/test_config_flow.py new file mode 100644 index 00000000000..fdce335b7cf --- /dev/null +++ b/tests/components/sense/test_config_flow.py @@ -0,0 +1,75 @@ +"""Test the Sense config flow.""" +from asynctest import patch +from sense_energy import SenseAPITimeoutException, SenseAuthenticationException + +from homeassistant import config_entries, setup +from homeassistant.components.sense.const import DOMAIN + + +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("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, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"timeout": "6", "email": "test-email", "password": "test-password"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "test-email" + assert result2["data"] == { + "timeout": 6, + "email": "test-email", + "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_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( + "sense_energy.ASyncSenseable.authenticate", + side_effect=SenseAuthenticationException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"timeout": "6", "email": "test-email", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +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( + "sense_energy.ASyncSenseable.authenticate", + side_effect=SenseAPITimeoutException, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"timeout": "6", "email": "test-email", "password": "test-password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} From ed461a0ad85c272329746aa6ec284f1a9a90251d Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Wed, 26 Feb 2020 00:31:47 +0000 Subject: [PATCH 113/416] [ci skip] Translation update --- .../ambient_station/.translations/ca.json | 3 + .../ambient_station/.translations/da.json | 3 + .../ambient_station/.translations/de.json | 3 + .../ambient_station/.translations/en.json | 1 + .../ambient_station/.translations/ko.json | 3 + .../ambient_station/.translations/no.json | 3 + .../ambient_station/.translations/pl.json | 3 + .../.translations/zh-Hant.json | 3 + .../components/august/.translations/da.json | 32 ++++++++++ .../components/august/.translations/de.json | 31 ++++++++++ .../components/august/.translations/en.json | 60 +++++++++---------- .../components/deconz/.translations/de.json | 3 +- .../components/demo/.translations/de.json | 17 ++++++ .../konnected/.translations/da.json | 6 +- .../konnected/.translations/de.json | 3 + .../konnected/.translations/en.json | 1 - .../konnected/.translations/ko.json | 4 ++ .../components/mqtt/.translations/de.json | 22 +++++++ .../components/notion/.translations/ca.json | 3 + .../components/notion/.translations/da.json | 3 + .../components/notion/.translations/de.json | 3 + .../components/notion/.translations/en.json | 1 + .../components/notion/.translations/ko.json | 3 + .../components/notion/.translations/no.json | 3 + .../components/notion/.translations/pl.json | 3 + .../notion/.translations/zh-Hant.json | 3 + .../components/plex/.translations/de.json | 2 + .../rainmachine/.translations/ca.json | 3 + .../rainmachine/.translations/de.json | 3 + .../rainmachine/.translations/ko.json | 3 + .../rainmachine/.translations/no.json | 3 + .../rainmachine/.translations/pl.json | 3 + .../rainmachine/.translations/ru.json | 3 + .../rainmachine/.translations/zh-Hant.json | 3 + .../components/sense/.translations/en.json | 40 ++++++------- .../simplisafe/.translations/ca.json | 3 + .../simplisafe/.translations/de.json | 3 + .../simplisafe/.translations/ko.json | 3 + .../simplisafe/.translations/no.json | 3 + .../simplisafe/.translations/pl.json | 3 + .../simplisafe/.translations/ru.json | 3 + .../simplisafe/.translations/zh-Hant.json | 3 + .../components/unifi/.translations/de.json | 9 ++- 43 files changed, 261 insertions(+), 55 deletions(-) create mode 100644 homeassistant/components/august/.translations/da.json create mode 100644 homeassistant/components/august/.translations/de.json diff --git a/homeassistant/components/ambient_station/.translations/ca.json b/homeassistant/components/ambient_station/.translations/ca.json index d3c451f3e3f..280a90354b0 100644 --- a/homeassistant/components/ambient_station/.translations/ca.json +++ b/homeassistant/components/ambient_station/.translations/ca.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Aquesta clau d'aplicaci\u00f3 ja est\u00e0 en \u00fas." + }, "error": { "identifier_exists": "Clau d'aplicaci\u00f3 i/o clau API ja registrada", "invalid_key": "Clau API i/o clau d'aplicaci\u00f3 inv\u00e0lida/es", diff --git a/homeassistant/components/ambient_station/.translations/da.json b/homeassistant/components/ambient_station/.translations/da.json index 6cec31eca29..6428508687d 100644 --- a/homeassistant/components/ambient_station/.translations/da.json +++ b/homeassistant/components/ambient_station/.translations/da.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne appn\u00f8gle er allerede i brug." + }, "error": { "identifier_exists": "Applikationsn\u00f8gle og/eller API n\u00f8gle er allerede registreret", "invalid_key": "Ugyldig API n\u00f8gle og/eller applikationsn\u00f8gle", diff --git a/homeassistant/components/ambient_station/.translations/de.json b/homeassistant/components/ambient_station/.translations/de.json index 1431efbf167..451a2e70e68 100644 --- a/homeassistant/components/ambient_station/.translations/de.json +++ b/homeassistant/components/ambient_station/.translations/de.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Dieser App-Schl\u00fcssel wird bereits verwendet." + }, "error": { "identifier_exists": "Anwendungsschl\u00fcssel und / oder API-Schl\u00fcssel bereits registriert", "invalid_key": "Ung\u00fcltiger API Key und / oder Anwendungsschl\u00fcssel", diff --git a/homeassistant/components/ambient_station/.translations/en.json b/homeassistant/components/ambient_station/.translations/en.json index c3e2a40ab13..8b8e71d5316 100644 --- a/homeassistant/components/ambient_station/.translations/en.json +++ b/homeassistant/components/ambient_station/.translations/en.json @@ -4,6 +4,7 @@ "already_configured": "This app key is already in use." }, "error": { + "identifier_exists": "Application Key and/or API Key already registered", "invalid_key": "Invalid API Key and/or Application Key", "no_devices": "No devices found in account" }, diff --git a/homeassistant/components/ambient_station/.translations/ko.json b/homeassistant/components/ambient_station/.translations/ko.json index eb9209a6c37..3379411678b 100644 --- a/homeassistant/components/ambient_station/.translations/ko.json +++ b/homeassistant/components/ambient_station/.translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\uc774 \uc571 \ud0a4\ub294 \uc774\ubbf8 \uc0ac\uc6a9 \uc911\uc785\ub2c8\ub2e4." + }, "error": { "identifier_exists": "\uc560\ud50c\ub9ac\ucf00\uc774\uc158 \ud0a4 \ud639\uc740 API \ud0a4\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "invalid_key": "\uc560\ud50c\ub9ac\ucf00\uc774\uc158 \ud0a4 \ud639\uc740 API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", diff --git a/homeassistant/components/ambient_station/.translations/no.json b/homeassistant/components/ambient_station/.translations/no.json index 0b9d377718b..4a089eba4c0 100644 --- a/homeassistant/components/ambient_station/.translations/no.json +++ b/homeassistant/components/ambient_station/.translations/no.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne app n\u00f8kkelen er allerede i bruk." + }, "error": { "identifier_exists": "Programn\u00f8kkel og/eller API-n\u00f8kkel er allerede registrert", "invalid_key": "Ugyldig API-n\u00f8kkel og/eller programn\u00f8kkel", diff --git a/homeassistant/components/ambient_station/.translations/pl.json b/homeassistant/components/ambient_station/.translations/pl.json index 3ac612d0ea7..5da886f05cd 100644 --- a/homeassistant/components/ambient_station/.translations/pl.json +++ b/homeassistant/components/ambient_station/.translations/pl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Ten klucz aplikacji jest ju\u017c w u\u017cyciu." + }, "error": { "identifier_exists": "Klucz aplikacji i/lub klucz API ju\u017c jest zarejestrowany.", "invalid_key": "Nieprawid\u0142owy klucz API i/lub klucz aplikacji", diff --git a/homeassistant/components/ambient_station/.translations/zh-Hant.json b/homeassistant/components/ambient_station/.translations/zh-Hant.json index 6c7c88a8045..6de1579f6ff 100644 --- a/homeassistant/components/ambient_station/.translations/zh-Hant.json +++ b/homeassistant/components/ambient_station/.translations/zh-Hant.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u6b64\u61c9\u7528\u7a0b\u5f0f\u5bc6\u9470\u5df2\u88ab\u4f7f\u7528\u3002" + }, "error": { "identifier_exists": "API \u5bc6\u9470\u53ca/\u6216\u61c9\u7528\u5bc6\u9470\u5df2\u8a3b\u518a", "invalid_key": "API \u5bc6\u9470\u53ca/\u6216\u61c9\u7528\u5bc6\u9470\u7121\u6548", diff --git a/homeassistant/components/august/.translations/da.json b/homeassistant/components/august/.translations/da.json new file mode 100644 index 00000000000..d63bcf9acca --- /dev/null +++ b/homeassistant/components/august/.translations/da.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigureret" + }, + "error": { + "cannot_connect": "Kunne ikke oprette forbindelse. Pr\u00f8v igen", + "invalid_auth": "Ugyldig godkendelse", + "unknown": "Uventet fejl" + }, + "step": { + "user": { + "data": { + "login_method": "Loginmetode", + "password": "Adgangskode", + "timeout": "Timeout (sekunder)", + "username": "Brugernavn" + }, + "description": "Hvis loginmetoden er 'e-mail', er brugernavn e-mailadressen. Hvis loginmetoden er 'telefon', er brugernavn telefonnummeret i formatet '+NNNNNNNNNN'.", + "title": "Konfigurer en August-konto" + }, + "validation": { + "data": { + "code": "Bekr\u00e6ftelseskode" + }, + "description": "Kontroller dit {login_method} ({username}), og angiv bekr\u00e6ftelseskoden nedenfor", + "title": "Tofaktorgodkendelse" + } + }, + "title": "August" + } +} \ No newline at end of file diff --git a/homeassistant/components/august/.translations/de.json b/homeassistant/components/august/.translations/de.json new file mode 100644 index 00000000000..dd3b2ea9f44 --- /dev/null +++ b/homeassistant/components/august/.translations/de.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "Konto ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "login_method": "Anmeldemethode", + "password": "Passwort", + "timeout": "Zeit\u00fcberschreitung (Sekunden)", + "username": "Benutzername" + }, + "title": "Richten Sie ein August-Konto ein" + }, + "validation": { + "data": { + "code": "Verifizierungs-Code" + }, + "description": "Bitte \u00fcberpr\u00fcfen Sie Ihre {login_method} ({username}) und geben Sie den Best\u00e4tigungscode ein", + "title": "Zwei-Faktor-Authentifizierung" + } + }, + "title": "August" + } +} \ No newline at end of file diff --git a/homeassistant/components/august/.translations/en.json b/homeassistant/components/august/.translations/en.json index 1695d33cd63..32c628f0b0d 100644 --- a/homeassistant/components/august/.translations/en.json +++ b/homeassistant/components/august/.translations/en.json @@ -1,32 +1,32 @@ { - "config" : { - "error" : { - "unknown" : "Unexpected error", - "cannot_connect" : "Failed to connect, please try again", - "invalid_auth" : "Invalid authentication" - }, - "abort" : { - "already_configured" : "Account is already configured" - }, - "step" : { - "validation" : { - "title" : "Two factor authentication", - "data" : { - "code" : "Verification code" + "config": { + "abort": { + "already_configured": "Account is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "login_method": "Login Method", + "password": "Password", + "timeout": "Timeout (seconds)", + "username": "Username" + }, + "description": "If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'.", + "title": "Setup an August account" }, - "description" : "Please check your {login_method} ({username}) and enter the verification code below" - }, - "user" : { - "description" : "If the Login Method is 'email', Username is the email address. If the Login Method is 'phone', Username is the phone number in the format '+NNNNNNNNN'.", - "data" : { - "timeout" : "Timeout (seconds)", - "password" : "Password", - "username" : "Username", - "login_method" : "Login Method" - }, - "title" : "Setup an August account" - } - }, - "title" : "August" - } -} + "validation": { + "data": { + "code": "Verification code" + }, + "description": "Please check your {login_method} ({username}) and enter the verification code below", + "title": "Two factor authentication" + } + }, + "title": "August" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/de.json b/homeassistant/components/deconz/.translations/de.json index 479e645173b..c3ad3cd24c8 100644 --- a/homeassistant/components/deconz/.translations/de.json +++ b/homeassistant/components/deconz/.translations/de.json @@ -108,7 +108,8 @@ "allow_clip_sensor": "deCONZ CLIP-Sensoren zulassen", "allow_deconz_groups": "deCONZ-Lichtgruppen zulassen" }, - "description": "Sichtbarkeit der deCONZ-Ger\u00e4tetypen konfigurieren" + "description": "Sichtbarkeit der deCONZ-Ger\u00e4tetypen konfigurieren", + "title": "deCONZ-Optionen" } } } diff --git a/homeassistant/components/demo/.translations/de.json b/homeassistant/components/demo/.translations/de.json index ef01fcb4f3c..a600790d2fc 100644 --- a/homeassistant/components/demo/.translations/de.json +++ b/homeassistant/components/demo/.translations/de.json @@ -1,5 +1,22 @@ { "config": { "title": "Demo" + }, + "options": { + "step": { + "options_1": { + "data": { + "bool": "Optionaler Boolescher Wert", + "int": "Numerische Eingabe" + } + }, + "options_2": { + "data": { + "multi": "Mehrfachauswahl", + "select": "W\u00e4hlen Sie eine Option", + "string": "String-Wert" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/da.json b/homeassistant/components/konnected/.translations/da.json index db37ad73610..a1545bd6575 100644 --- a/homeassistant/components/konnected/.translations/da.json +++ b/homeassistant/components/konnected/.translations/da.json @@ -11,9 +11,13 @@ }, "step": { "confirm": { - "description": "Model: {model}\nV\u00e6rt: {host}\nPort: {port}\n\nDu kan konfigurere IO og panelfunktionsm\u00e5den i indstillingerne for Konnected-alarmpanel.", + "description": "Model: {model}\nID: {id}\nV\u00e6rt: {host}\nPort: {port}\n\nDu kan konfigurere IO og panelfunktionsm\u00e5den i indstillingerne for Konnected-alarmpanel.", "title": "Konnected-enhed klar" }, + "import_confirm": { + "description": "Et Konnected-alarmpanel med id {id} er blevet fundet i configuration.yaml. Dette flow giver dig mulighed for at importere det til en konfigurationspost.", + "title": "Importer Konnected-enhed" + }, "user": { "data": { "host": "Konnected-enhedens IP-adresse", diff --git a/homeassistant/components/konnected/.translations/de.json b/homeassistant/components/konnected/.translations/de.json index ca4dd01f098..fa5b1f53dfb 100644 --- a/homeassistant/components/konnected/.translations/de.json +++ b/homeassistant/components/konnected/.translations/de.json @@ -14,6 +14,9 @@ "description": "Modell: {model} \nHost: {host} \nPort: {port} \n\nSie k\u00f6nnen das I / O - und Bedienfeldverhalten in den Einstellungen der verbundenen Alarmzentrale konfigurieren.", "title": "Konnected Device Bereit" }, + "import_confirm": { + "title": "Importieren von Konnected Ger\u00e4t" + }, "user": { "data": { "host": "Konnected Ger\u00e4t IP-Adresse", diff --git a/homeassistant/components/konnected/.translations/en.json b/homeassistant/components/konnected/.translations/en.json index 9d642a43603..fd0a8e84e37 100644 --- a/homeassistant/components/konnected/.translations/en.json +++ b/homeassistant/components/konnected/.translations/en.json @@ -33,7 +33,6 @@ "abort": { "not_konn_panel": "Not a recognized Konnected.io device" }, - "error": {}, "step": { "options_binary": { "data": { diff --git a/homeassistant/components/konnected/.translations/ko.json b/homeassistant/components/konnected/.translations/ko.json index d39ca606cc7..fe196050766 100644 --- a/homeassistant/components/konnected/.translations/ko.json +++ b/homeassistant/components/konnected/.translations/ko.json @@ -14,6 +14,10 @@ "description": "\ubaa8\ub378: {model}\n\ud638\uc2a4\ud2b8: {host}\n\ud3ec\ud2b8: {port}\n\nKonnected \uc54c\ub78c \ud328\ub110 \uc124\uc815\uc5d0\uc11c IO \uc640 \ud328\ub110 \ub3d9\uc791\uc744 \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", "title": "Konnected \uae30\uae30 \uc900\ube44" }, + "import_confirm": { + "description": "Konnected \uc54c\ub78c \ud328\ub110 ID {id} \uac00 configuration.yaml \uc5d0\uc11c \ubc1c\uacac\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \uc774 \uacfc\uc815\uc744 \ud1b5\ud574 \uad6c\uc131 \ud56d\ubaa9\uc73c\ub85c \uac00\uc838\uc62c \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "title": "Konnected \uae30\uae30 \uac00\uc838\uc624\uae30" + }, "user": { "data": { "host": "Konnected \uae30\uae30 IP \uc8fc\uc18c", diff --git a/homeassistant/components/mqtt/.translations/de.json b/homeassistant/components/mqtt/.translations/de.json index 7bca8de54eb..87c6a989f52 100644 --- a/homeassistant/components/mqtt/.translations/de.json +++ b/homeassistant/components/mqtt/.translations/de.json @@ -27,5 +27,27 @@ } }, "title": "MQTT" + }, + "device_automation": { + "trigger_subtype": { + "button_1": "Erste Taste", + "button_2": "Zweite Taste", + "button_3": "Dritte Taste", + "button_4": "Vierte Taste", + "button_5": "F\u00fcnfte Taste", + "button_6": "Sechste Taste", + "turn_off": "Ausschalten", + "turn_on": "Einschalten" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" doppelt angeklickt", + "button_long_press": "\"{subtype}\" kontinuierlich gedr\u00fcckt", + "button_long_release": "\"{subtype}\" nach langem Dr\u00fccken freigegeben", + "button_quadruple_press": "\"{subtype}\" Vierfach geklickt", + "button_quintuple_press": "\"{subtype}\" f\u00fcnffach geklickt", + "button_short_press": "\"{subtype}\" gedr\u00fcckt", + "button_short_release": "\"{subtype}\" freigegeben", + "button_triple_press": "\"{subtype}\" dreifach geklickt" + } } } \ No newline at end of file diff --git a/homeassistant/components/notion/.translations/ca.json b/homeassistant/components/notion/.translations/ca.json index 0b6a24626be..09f598ef5d1 100644 --- a/homeassistant/components/notion/.translations/ca.json +++ b/homeassistant/components/notion/.translations/ca.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Aquest nom d'usuari ja est\u00e0 en \u00fas." + }, "error": { "identifier_exists": "Nom d'usuari ja registrat", "invalid_credentials": "Nom d'usuari o contrasenya incorrectes", diff --git a/homeassistant/components/notion/.translations/da.json b/homeassistant/components/notion/.translations/da.json index bf17b41d777..784d106b94c 100644 --- a/homeassistant/components/notion/.translations/da.json +++ b/homeassistant/components/notion/.translations/da.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Dette brugernavn er allerede i brug." + }, "error": { "identifier_exists": "Brugernavn er allerede registreret", "invalid_credentials": "Ugyldigt brugernavn eller adgangskode", diff --git a/homeassistant/components/notion/.translations/de.json b/homeassistant/components/notion/.translations/de.json index e9c735001e9..e11a16458c9 100644 --- a/homeassistant/components/notion/.translations/de.json +++ b/homeassistant/components/notion/.translations/de.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Dieser Benutzername wird bereits benutzt." + }, "error": { "identifier_exists": "Benutzername bereits registriert", "invalid_credentials": "Ung\u00fcltiger Benutzername oder Passwort", diff --git a/homeassistant/components/notion/.translations/en.json b/homeassistant/components/notion/.translations/en.json index b729b368c37..2476293a216 100644 --- a/homeassistant/components/notion/.translations/en.json +++ b/homeassistant/components/notion/.translations/en.json @@ -4,6 +4,7 @@ "already_configured": "This username is already in use." }, "error": { + "identifier_exists": "Username already registered", "invalid_credentials": "Invalid username or password", "no_devices": "No devices found in account" }, diff --git a/homeassistant/components/notion/.translations/ko.json b/homeassistant/components/notion/.translations/ko.json index 76dc91cf46b..52c7b6339cb 100644 --- a/homeassistant/components/notion/.translations/ko.json +++ b/homeassistant/components/notion/.translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\uc774 \uc0ac\uc6a9\uc790 \uc774\ub984\uc740 \uc774\ubbf8 \uc0ac\uc6a9 \uc911\uc785\ub2c8\ub2e4." + }, "error": { "identifier_exists": "\uc0ac\uc6a9\uc790 \uc774\ub984\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "invalid_credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ub610\ub294 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", diff --git a/homeassistant/components/notion/.translations/no.json b/homeassistant/components/notion/.translations/no.json index 2798db1cbc3..16105e680c5 100644 --- a/homeassistant/components/notion/.translations/no.json +++ b/homeassistant/components/notion/.translations/no.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Dette brukernavnet er allerede i bruk." + }, "error": { "identifier_exists": "Brukernavn er allerede registrert", "invalid_credentials": "Ugyldig brukernavn eller passord", diff --git a/homeassistant/components/notion/.translations/pl.json b/homeassistant/components/notion/.translations/pl.json index ffb3b8386dd..07facb21e93 100644 --- a/homeassistant/components/notion/.translations/pl.json +++ b/homeassistant/components/notion/.translations/pl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Ta nazwa u\u017cytkownika jest ju\u017c w u\u017cyciu." + }, "error": { "identifier_exists": "Nazwa u\u017cytkownika jest ju\u017c zarejestrowana.", "invalid_credentials": "Nieprawid\u0142owa nazwa u\u017cytkownika lub has\u0142o", diff --git a/homeassistant/components/notion/.translations/zh-Hant.json b/homeassistant/components/notion/.translations/zh-Hant.json index f672f519f40..c426dfa3265 100644 --- a/homeassistant/components/notion/.translations/zh-Hant.json +++ b/homeassistant/components/notion/.translations/zh-Hant.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u6b64\u4f7f\u7528\u8005\u540d\u7a31\u5df2\u88ab\u4f7f\u7528\u3002" + }, "error": { "identifier_exists": "\u4f7f\u7528\u8005\u540d\u7a31\u5df2\u8a3b\u518a", "invalid_credentials": "\u4f7f\u7528\u8005\u540d\u7a31\u6216\u5bc6\u78bc\u7121\u6548", diff --git a/homeassistant/components/plex/.translations/de.json b/homeassistant/components/plex/.translations/de.json index aa8c5e08dd6..ea8f4b60de4 100644 --- a/homeassistant/components/plex/.translations/de.json +++ b/homeassistant/components/plex/.translations/de.json @@ -53,6 +53,8 @@ "step": { "plex_mp_settings": { "data": { + "ignore_new_shared_users": "Ignorieren neuer verwalteter/freigegebener Benutzer", + "monitored_users": "\u00dcberwachte Benutzer", "show_all_controls": "Alle Steuerelemente anzeigen", "use_episode_art": "Episode-Bilder verwenden" }, diff --git a/homeassistant/components/rainmachine/.translations/ca.json b/homeassistant/components/rainmachine/.translations/ca.json index 60458f1469e..494b1ecc69c 100644 --- a/homeassistant/components/rainmachine/.translations/ca.json +++ b/homeassistant/components/rainmachine/.translations/ca.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Aquest controlador RainMachine ja est\u00e0 configurat." + }, "error": { "identifier_exists": "Aquest compte ja est\u00e0 registrat", "invalid_credentials": "Credencials inv\u00e0lides" diff --git a/homeassistant/components/rainmachine/.translations/de.json b/homeassistant/components/rainmachine/.translations/de.json index c262fa5a652..257a0908c6a 100644 --- a/homeassistant/components/rainmachine/.translations/de.json +++ b/homeassistant/components/rainmachine/.translations/de.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Dieser RainMachine-Kontroller ist bereits konfiguriert." + }, "error": { "identifier_exists": "Konto bereits registriert", "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen" diff --git a/homeassistant/components/rainmachine/.translations/ko.json b/homeassistant/components/rainmachine/.translations/ko.json index 4e2df2ca217..66d6cb0b740 100644 --- a/homeassistant/components/rainmachine/.translations/ko.json +++ b/homeassistant/components/rainmachine/.translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\uc774 RainMachine \ucee8\ud2b8\ub864\ub7ec\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, "error": { "identifier_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "invalid_credentials": "\ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/rainmachine/.translations/no.json b/homeassistant/components/rainmachine/.translations/no.json index 5ec4e5fdc34..980c2c693ce 100644 --- a/homeassistant/components/rainmachine/.translations/no.json +++ b/homeassistant/components/rainmachine/.translations/no.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne RainMachine-kontrolleren er allerede konfigurert." + }, "error": { "identifier_exists": "Konto er allerede registrert", "invalid_credentials": "Ugyldig legitimasjon" diff --git a/homeassistant/components/rainmachine/.translations/pl.json b/homeassistant/components/rainmachine/.translations/pl.json index d5b853122a6..5e813243f13 100644 --- a/homeassistant/components/rainmachine/.translations/pl.json +++ b/homeassistant/components/rainmachine/.translations/pl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Ten kontroler RainMachine jest ju\u017c skonfigurowany." + }, "error": { "identifier_exists": "Konto jest ju\u017c zarejestrowane.", "invalid_credentials": "Nieprawid\u0142owe dane uwierzytelniaj\u0105ce" diff --git a/homeassistant/components/rainmachine/.translations/ru.json b/homeassistant/components/rainmachine/.translations/ru.json index ca535663f54..e1bce5874e3 100644 --- a/homeassistant/components/rainmachine/.translations/ru.json +++ b/homeassistant/components/rainmachine/.translations/ru.json @@ -1,5 +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." + }, "error": { "identifier_exists": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430.", "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435." diff --git a/homeassistant/components/rainmachine/.translations/zh-Hant.json b/homeassistant/components/rainmachine/.translations/zh-Hant.json index 518cc54192f..3d9663a9a79 100644 --- a/homeassistant/components/rainmachine/.translations/zh-Hant.json +++ b/homeassistant/components/rainmachine/.translations/zh-Hant.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u6b64 RainMachine \u63a7\u5236\u5668\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, "error": { "identifier_exists": "\u5e33\u865f\u5df2\u8a3b\u518a", "invalid_credentials": "\u6191\u8b49\u7121\u6548" diff --git a/homeassistant/components/sense/.translations/en.json b/homeassistant/components/sense/.translations/en.json index d3af47b5378..32e6f48e153 100644 --- a/homeassistant/components/sense/.translations/en.json +++ b/homeassistant/components/sense/.translations/en.json @@ -1,22 +1,22 @@ { - "config": { - "title": "Sense", - "step": { - "user": { - "title": "Connect to your Sense Energy Monitor", - "data": { - "email": "Email Address", - "password": "Password" - } - } - }, - "error": { - "cannot_connect": "Failed to connect, please try again", - "invalid_auth": "Invalid authentication", - "unknown": "Unexpected error" - }, - "abort": { - "already_configured": "Device is already configured" + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "email": "Email Address", + "password": "Password" + }, + "title": "Connect to your Sense Energy Monitor" + } + }, + "title": "Sense" } - } -} +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/ca.json b/homeassistant/components/simplisafe/.translations/ca.json index a02c3a5e28e..a89e4c753cb 100644 --- a/homeassistant/components/simplisafe/.translations/ca.json +++ b/homeassistant/components/simplisafe/.translations/ca.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Aquest compte SimpliSafe ja est\u00e0 en \u00fas." + }, "error": { "identifier_exists": "Aquest compte ja est\u00e0 registrat", "invalid_credentials": "Credencials inv\u00e0lides" diff --git a/homeassistant/components/simplisafe/.translations/de.json b/homeassistant/components/simplisafe/.translations/de.json index 5ebc17f13b9..4d5eefc480b 100644 --- a/homeassistant/components/simplisafe/.translations/de.json +++ b/homeassistant/components/simplisafe/.translations/de.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Dieses SimpliSafe-Konto wird bereits verwendet." + }, "error": { "identifier_exists": "Konto bereits registriert", "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen" diff --git a/homeassistant/components/simplisafe/.translations/ko.json b/homeassistant/components/simplisafe/.translations/ko.json index 5cbe233a05e..3327ddf9ab1 100644 --- a/homeassistant/components/simplisafe/.translations/ko.json +++ b/homeassistant/components/simplisafe/.translations/ko.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\uc774 SimpliSafe \uacc4\uc815\uc740 \uc774\ubbf8 \uc0ac\uc6a9 \uc911\uc785\ub2c8\ub2e4." + }, "error": { "identifier_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "invalid_credentials": "\ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" diff --git a/homeassistant/components/simplisafe/.translations/no.json b/homeassistant/components/simplisafe/.translations/no.json index 7c28209514e..4c25893791b 100644 --- a/homeassistant/components/simplisafe/.translations/no.json +++ b/homeassistant/components/simplisafe/.translations/no.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Denne SimpliSafe-kontoen er allerede i bruk." + }, "error": { "identifier_exists": "Konto er allerede registrert", "invalid_credentials": "Ugyldig legitimasjon" diff --git a/homeassistant/components/simplisafe/.translations/pl.json b/homeassistant/components/simplisafe/.translations/pl.json index 71316eb1a6c..3a9c160a0c5 100644 --- a/homeassistant/components/simplisafe/.translations/pl.json +++ b/homeassistant/components/simplisafe/.translations/pl.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "To konto SimpliSafe jest ju\u017c w u\u017cyciu." + }, "error": { "identifier_exists": "Konto jest ju\u017c zarejestrowane.", "invalid_credentials": "Nieprawid\u0142owe dane uwierzytelniaj\u0105ce" diff --git a/homeassistant/components/simplisafe/.translations/ru.json b/homeassistant/components/simplisafe/.translations/ru.json index 301eed6d1c1..2d8b63c4bab 100644 --- a/homeassistant/components/simplisafe/.translations/ru.json +++ b/homeassistant/components/simplisafe/.translations/ru.json @@ -1,5 +1,8 @@ { "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." + }, "error": { "identifier_exists": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430.", "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0451\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435." diff --git a/homeassistant/components/simplisafe/.translations/zh-Hant.json b/homeassistant/components/simplisafe/.translations/zh-Hant.json index bd0b2c6f3d6..b456bde33c7 100644 --- a/homeassistant/components/simplisafe/.translations/zh-Hant.json +++ b/homeassistant/components/simplisafe/.translations/zh-Hant.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u6b64 SimpliSafe \u5e33\u865f\u5df2\u88ab\u4f7f\u7528\u3002" + }, "error": { "identifier_exists": "\u5e33\u865f\u5df2\u8a3b\u518a", "invalid_credentials": "\u6191\u8b49\u7121\u6548" diff --git a/homeassistant/components/unifi/.translations/de.json b/homeassistant/components/unifi/.translations/de.json index 32a378b7c00..2f3db9d9b89 100644 --- a/homeassistant/components/unifi/.translations/de.json +++ b/homeassistant/components/unifi/.translations/de.json @@ -28,10 +28,13 @@ "device_tracker": { "data": { "detection_time": "Zeit in Sekunden vom letzten Gesehenen bis zur Entfernung", + "ssid_filter": "W\u00e4hlen Sie SSIDs zur Verfolgung von drahtlosen Clients aus", "track_clients": "Nachverfolgen von Netzwerkclients", "track_devices": "Verfolgen von Netzwerkger\u00e4ten (Ubiquiti-Ger\u00e4te)", "track_wired_clients": "Einbinden von kabelgebundenen Netzwerk-Clients" - } + }, + "description": "Konfigurieren Sie die Ger\u00e4teverfolgung", + "title": "UniFi-Optionen" }, "init": { "data": { @@ -42,7 +45,9 @@ "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Erstellen von Bandbreiten-Nutzungssensoren f\u00fcr Netzwerk-Clients" - } + }, + "description": "Konfigurieren Sie Statistiksensoren", + "title": "UniFi-Optionen" } } } From 58de7fe9a319fd9a32d3bbc4b1ffd477d5fd0d0b Mon Sep 17 00:00:00 2001 From: springstan <46536646+springstan@users.noreply.github.com> Date: Wed, 26 Feb 2020 02:30:03 +0100 Subject: [PATCH 114/416] Fix name of emby media player (#32183) * Fix name of emby media player * Remove client name from entity_id --- homeassistant/components/emby/media_player.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py index b4b05fd6c12..56d68cee6b5 100644 --- a/homeassistant/components/emby/media_player.py +++ b/homeassistant/components/emby/media_player.py @@ -190,9 +190,7 @@ class EmbyDevice(MediaPlayerDevice): @property def name(self): """Return the name of the device.""" - return ( - f"Emby - {self.device.client} - {self.device.name}" or DEVICE_DEFAULT_NAME - ) + return f"Emby {self.device.name}" or DEVICE_DEFAULT_NAME @property def should_poll(self): From 4c33a9d732b67f8f1a4fc388fe87441f70ae6d02 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2020 16:34:31 -1000 Subject: [PATCH 115/416] Add model to august device_info (#32187) * Add model to august device_info * Address review comments from PR#32125 * Fix test name --- .../components/august/binary_sensor.py | 6 ++++ homeassistant/components/august/camera.py | 5 ++- homeassistant/components/august/lock.py | 3 ++ homeassistant/components/august/manifest.json | 4 +-- homeassistant/components/august/sensor.py | 35 ++++++++++--------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/august/test_binary_sensor.py | 15 ++++++++ tests/components/august/test_lock.py | 15 ++++++++ 9 files changed, 65 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index b5b65863eac..4cec054cf7d 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -104,6 +104,7 @@ class AugustDoorBinarySensor(BinarySensorDevice): self._state = None self._available = False self._firmware_version = None + self._model = None @property def available(self): @@ -139,6 +140,7 @@ class AugustDoorBinarySensor(BinarySensorDevice): if detail is not None: lock_door_state = detail.door_state self._firmware_version = detail.firmware_version + self._model = detail.model self._available = lock_door_state != LockDoorStatus.UNKNOWN self._state = lock_door_state == LockDoorStatus.OPEN @@ -156,6 +158,7 @@ class AugustDoorBinarySensor(BinarySensorDevice): "name": self._door.device_name, "manufacturer": DEFAULT_NAME, "sw_version": self._firmware_version, + "model": self._model, } @@ -170,6 +173,7 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): self._state = None self._available = False self._firmware_version = None + self._model = None @property def available(self): @@ -210,6 +214,7 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): self._state = None if detail is not None: self._firmware_version = detail.firmware_version + self._model = detail.model self._state = await async_state_provider(self._data, detail) @property @@ -228,4 +233,5 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): "name": self._doorbell.device_name, "manufacturer": "August", "sw_version": self._firmware_version, + "model": self._model, } diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index ad31cb4ddc6..4e0ef65c82d 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -34,6 +34,7 @@ class AugustCamera(Camera): self._image_url = None self._image_content = None self._firmware_version = None + self._model = None @property def name(self): @@ -58,7 +59,7 @@ class AugustCamera(Camera): @property def model(self): """Return the camera model.""" - return "Doorbell" + return self._model async def async_camera_image(self): """Return bytes of camera image.""" @@ -85,6 +86,7 @@ class AugustCamera(Camera): return None self._firmware_version = self._doorbell_detail.firmware_version + self._model = self._doorbell_detail.model def _camera_image(self): """Return bytes of camera image via http get.""" @@ -104,4 +106,5 @@ class AugustCamera(Camera): "name": self._doorbell.device_name + " Camera", "manufacturer": DEFAULT_NAME, "sw_version": self._firmware_version, + "model": self._model, } diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 2db1fe5eede..d339dae8063 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -40,6 +40,7 @@ class AugustLock(LockDevice): self._changed_by = None self._available = False self._firmware_version = None + self._model = None async def async_lock(self, **kwargs): """Lock the device.""" @@ -89,6 +90,7 @@ class AugustLock(LockDevice): if self._lock_detail is not None: self._firmware_version = self._lock_detail.firmware_version + self._model = self._lock_detail.model self._update_lock_status_from_detail() @@ -135,6 +137,7 @@ class AugustLock(LockDevice): "name": self._lock.device_name, "manufacturer": DEFAULT_NAME, "sw_version": self._firmware_version, + "model": self._model, } @property diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 0523ed178aa..523cb5a361f 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -3,7 +3,7 @@ "name": "August", "documentation": "https://www.home-assistant.io/integrations/august", "requirements": [ - "py-august==0.17.0" + "py-august==0.21.0" ], "dependencies": [ "configurator" @@ -12,4 +12,4 @@ "@bdraco" ], "config_flow": true -} \ No newline at end of file +} diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index f1bfd0ad8b4..8b54c42352a 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -7,12 +7,16 @@ from homeassistant.helpers.entity import Entity from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN +BATTERY_LEVEL_FULL = "Full" +BATTERY_LEVEL_MEDIUM = "Medium" +BATTERY_LEVEL_LOW = "Low" + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(seconds=5) -async def _async_retrieve_device_battery_state(detail): +def _retrieve_device_battery_state(detail): """Get the latest state of the sensor.""" if detail is None: return None @@ -20,7 +24,7 @@ async def _async_retrieve_device_battery_state(detail): return detail.battery_level -async def _async_retrieve_linked_keypad_battery_state(detail): +def _retrieve_linked_keypad_battery_state(detail): """Get the latest state of the sensor.""" if detail is None: return None @@ -30,13 +34,11 @@ async def _async_retrieve_linked_keypad_battery_state(detail): battery_level = detail.keypad.battery_level - _LOGGER.debug("keypad battery level: %s %s", battery_level, battery_level.lower()) - - if battery_level.lower() == "full": + if battery_level == BATTERY_LEVEL_FULL: return 100 - if battery_level.lower() == "medium": + if battery_level == BATTERY_LEVEL_MEDIUM: return 60 - if battery_level.lower() == "low": + if battery_level == BATTERY_LEVEL_LOW: return 10 return 0 @@ -45,11 +47,11 @@ async def _async_retrieve_linked_keypad_battery_state(detail): SENSOR_TYPES_BATTERY = { "device_battery": { "name": "Battery", - "async_state_provider": _async_retrieve_device_battery_state, + "state_provider": _retrieve_device_battery_state, }, "linked_keypad_battery": { "name": "Keypad Battery", - "async_state_provider": _async_retrieve_linked_keypad_battery_state, + "state_provider": _retrieve_linked_keypad_battery_state, }, } @@ -71,11 +73,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for sensor_type in SENSOR_TYPES_BATTERY: for device in batteries[sensor_type]: - async_state_provider = SENSOR_TYPES_BATTERY[sensor_type][ - "async_state_provider" - ] + state_provider = SENSOR_TYPES_BATTERY[sensor_type]["state_provider"] detail = await data.async_get_device_detail(device) - state = await async_state_provider(detail) + state = state_provider(detail) sensor_name = SENSOR_TYPES_BATTERY[sensor_type]["name"] if state is None: _LOGGER.debug( @@ -103,6 +103,7 @@ class AugustBatterySensor(Entity): self._state = None self._available = False self._firmware_version = None + self._model = None @property def available(self): @@ -133,14 +134,13 @@ class AugustBatterySensor(Entity): async def async_update(self): """Get the latest state of the sensor.""" - async_state_provider = SENSOR_TYPES_BATTERY[self._sensor_type][ - "async_state_provider" - ] + state_provider = SENSOR_TYPES_BATTERY[self._sensor_type]["state_provider"] detail = await self._data.async_get_device_detail(self._device) - self._state = await async_state_provider(detail) + self._state = state_provider(detail) self._available = self._state is not None if detail is not None: self._firmware_version = detail.firmware_version + self._model = detail.model @property def unique_id(self) -> str: @@ -155,4 +155,5 @@ class AugustBatterySensor(Entity): "name": self._device.device_name, "manufacturer": DEFAULT_NAME, "sw_version": self._firmware_version, + "model": self._model, } diff --git a/requirements_all.txt b/requirements_all.txt index 7495fca8588..9889faf292a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1076,7 +1076,7 @@ pushover_complete==1.1.1 pwmled==1.5.0 # homeassistant.components.august -py-august==0.17.0 +py-august==0.21.0 # homeassistant.components.canary py-canary==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b531deeca8..0522a3a8ea4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -391,7 +391,7 @@ pure-python-adb==0.2.2.dev0 pushbullet.py==0.11.0 # homeassistant.components.august -py-august==0.17.0 +py-august==0.21.0 # homeassistant.components.canary py-canary==0.5.0 diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 260f86120f3..f64b2de7918 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -88,3 +88,18 @@ async def test_create_doorbell_offline(hass): assert binary_sensor_tmt100_name_online.state == STATE_OFF binary_sensor_tmt100_name_ding = hass.states.get("binary_sensor.tmt100_name_ding") assert binary_sensor_tmt100_name_ding.state == STATE_UNAVAILABLE + + +async def test_doorbell_device_registry(hass): + """Test creation of a lock with doorsense and bridge ands up in the registry.""" + doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") + doorbell_details = [doorbell_one] + await _create_august_with_devices(hass, doorbell_details) + + device_registry = await hass.helpers.device_registry.async_get_registry() + + reg_device = device_registry.async_get_device( + identifiers={("august", "tmt100")}, connections=set() + ) + assert "hydra1" == reg_device.model + assert "3.1.0-HYDRC75+201909251139" == reg_device.sw_version diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 104c93855be..a7027842b57 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -17,6 +17,21 @@ from tests.components.august.mocks import ( ) +async def test_lock_device_registry(hass): + """Test creation of a lock with doorsense and bridge ands up in the registry.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + lock_details = [lock_one] + await _create_august_with_devices(hass, lock_details) + + device_registry = await hass.helpers.device_registry.async_get_registry() + + reg_device = device_registry.async_get_device( + identifiers={("august", "online_with_doorsense")}, connections=set() + ) + assert "AUG-MD01" == reg_device.model + assert "undefined-4.3.0-1.8.14" == reg_device.sw_version + + async def test_one_lock_operation(hass): """Test creation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) From b5c1afcb848671a3c4cf66cf8f7ce1836316d16f Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 25 Feb 2020 20:03:41 -0700 Subject: [PATCH 116/416] Make SimpliSafe entities unavailable when wifi is lost (#32154) * Make SimpliSafe entities unavailable when wifi is lost * Remove online status from REST API * Comments * Mispelling --- .../components/simplisafe/__init__.py | 28 +++++++++++++++++-- .../simplisafe/alarm_control_panel.py | 5 ---- homeassistant/components/simplisafe/lock.py | 5 ---- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index f9c61b6add2..38a715d494a 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -6,6 +6,8 @@ from simplipy import API from simplipy.errors import InvalidCredentialsError, SimplipyError from simplipy.websocket import ( EVENT_CAMERA_MOTION_DETECTED, + EVENT_CONNECTION_LOST, + EVENT_CONNECTION_RESTORED, EVENT_DOORBELL_DETECTED, EVENT_ENTRY_DETECTED, EVENT_LOCK_LOCKED, @@ -528,7 +530,10 @@ class SimpliSafeEntity(Entity): self._online = True self._simplisafe = simplisafe self._system = system - self.websocket_events_to_listen_for = [] + self.websocket_events_to_listen_for = [ + EVENT_CONNECTION_LOST, + EVENT_CONNECTION_RESTORED, + ] if serial: self._serial = serial @@ -655,13 +660,32 @@ class SimpliSafeEntity(Entity): ATTR_LAST_EVENT_TIMESTAMP: last_websocket_event.timestamp, } ) - self.async_update_from_websocket_event(last_websocket_event) + self._async_internal_update_from_websocket_event(last_websocket_event) @callback def async_update_from_rest_api(self): """Update the entity with the provided REST API data.""" pass + @callback + def _async_internal_update_from_websocket_event(self, event): + """Check for connection events and set offline appropriately. + + Should not be called directly. + """ + if event.event_type == EVENT_CONNECTION_LOST: + self._online = False + elif event.event_type == EVENT_CONNECTION_RESTORED: + self._online = True + + # It's uncertain whether SimpliSafe events will still propagate down the + # websocket when the base station is offline. Just in case, we guard against + # further action until connection is restored: + if not self._online: + return + + self.async_update_from_websocket_event(event) + @callback def async_update_from_websocket_event(self, event): """Update the entity with the provided websocket API data.""" diff --git a/homeassistant/components/simplisafe/alarm_control_panel.py b/homeassistant/components/simplisafe/alarm_control_panel.py index c675f9c2748..9166c59bec0 100644 --- a/homeassistant/components/simplisafe/alarm_control_panel.py +++ b/homeassistant/components/simplisafe/alarm_control_panel.py @@ -190,11 +190,6 @@ class SimpliSafeAlarm(SimpliSafeEntity, AlarmControlPanel): @callback def async_update_from_rest_api(self): """Update the entity with the provided REST API data.""" - if self._system.state == SystemStates.error: - self._online = False - return - self._online = True - if self._system.version == 3: self._attrs.update( { diff --git a/homeassistant/components/simplisafe/lock.py b/homeassistant/components/simplisafe/lock.py index 58448ec4599..fc98d67ccbf 100644 --- a/homeassistant/components/simplisafe/lock.py +++ b/homeassistant/components/simplisafe/lock.py @@ -70,11 +70,6 @@ class SimpliSafeLock(SimpliSafeEntity, LockDevice): @callback def async_update_from_rest_api(self): """Update the entity with the provided REST API data.""" - if self._lock.offline or self._lock.disabled: - self._online = False - return - - self._online = True self._attrs.update( { ATTR_LOCK_LOW_BATTERY: self._lock.lock_low_battery, From 638a3025df1ef85a4cb53244a223d9d629381911 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 25 Feb 2020 21:43:41 -1000 Subject: [PATCH 117/416] Reduce August doorbell detail updates (#32193) * Reduce August doorbell detail updates * Doorbell images now get updates from the activity feed * Tests for activity updates * py-august now provides bridge_is_online for available state * py-august now provides is_standby for available state * py-august now provides get_doorbell_image (eliminate requests) * remove debug * black after merge conflict --- homeassistant/components/august/__init__.py | 7 +-- .../components/august/binary_sensor.py | 7 ++- homeassistant/components/august/camera.py | 17 ++++-- homeassistant/components/august/const.py | 8 +-- homeassistant/components/august/lock.py | 4 +- tests/components/august/mocks.py | 49 +++++++++++++++- tests/components/august/test_binary_sensor.py | 28 +++++++++ tests/components/august/test_lock.py | 7 +-- .../august/get_activity.doorbell_motion.json | 58 +++++++++++++++++++ 9 files changed, 158 insertions(+), 27 deletions(-) create mode 100644 tests/fixtures/august/get_activity.doorbell_motion.json diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index c80101d5658..6a497920ce3 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -30,8 +30,7 @@ from .const import ( DOMAIN, LOGIN_METHODS, MIN_TIME_BETWEEN_ACTIVITY_UPDATES, - MIN_TIME_BETWEEN_DOORBELL_DETAIL_UPDATES, - MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES, + MIN_TIME_BETWEEN_DETAIL_UPDATES, VERIFICATION_CODE_KEY, ) from .exceptions import InvalidAuth, RequireValidation @@ -296,7 +295,7 @@ class AugustData: await self._async_update_doorbells_detail() return self._doorbell_detail_by_id.get(device_id) - @Throttle(MIN_TIME_BETWEEN_DOORBELL_DETAIL_UPDATES) + @Throttle(MIN_TIME_BETWEEN_DETAIL_UPDATES) async def _async_update_doorbells_detail(self): await self._hass.async_add_executor_job(self._update_doorbells_detail) @@ -324,7 +323,7 @@ class AugustData: if lock.device_id == device_id: return lock.device_name - @Throttle(MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES) + @Throttle(MIN_TIME_BETWEEN_DETAIL_UPDATES) async def _async_update_locks_detail(self): await self._hass.async_add_executor_job(self._update_locks_detail) diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 4cec054cf7d..c2b5603759d 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -22,7 +22,7 @@ SCAN_INTERVAL = timedelta(seconds=5) async def _async_retrieve_online_state(data, detail): """Get the latest state of the sensor.""" - return detail.is_online or detail.status == "standby" + return detail.is_online or detail.is_standby async def _async_retrieve_motion_state(data, detail): @@ -137,12 +137,13 @@ class AugustDoorBinarySensor(BinarySensorDevice): update_lock_detail_from_activity(detail, door_activity) lock_door_state = None + self._available = False if detail is not None: lock_door_state = detail.door_state + self._available = detail.bridge_is_online self._firmware_version = detail.firmware_version self._model = detail.model - self._available = lock_door_state != LockDoorStatus.UNKNOWN self._state = lock_door_state == LockDoorStatus.OPEN @property @@ -208,7 +209,7 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): self._available = True else: self._available = detail is not None and ( - detail.is_online or detail.status == "standby" + detail.is_online or detail.is_standby ) self._state = None diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index 4e0ef65c82d..02c3a6b1231 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -1,7 +1,8 @@ """Support for August camera.""" from datetime import timedelta -import requests +from august.activity import ActivityType +from august.util import update_doorbell_image_from_activity from homeassistant.components.camera import Camera @@ -66,6 +67,15 @@ class AugustCamera(Camera): self._doorbell_detail = await self._data.async_get_doorbell_detail( self._doorbell.device_id ) + doorbell_activity = await self._data.async_get_latest_device_activity( + self._doorbell.device_id, ActivityType.DOORBELL_MOTION + ) + + if doorbell_activity is not None: + update_doorbell_image_from_activity( + self._doorbell_detail, doorbell_activity + ) + if self._doorbell_detail is None: return None @@ -89,9 +99,8 @@ class AugustCamera(Camera): self._model = self._doorbell_detail.model def _camera_image(self): - """Return bytes of camera image via http get.""" - # Move this to py-august: see issue#32048 - return requests.get(self._image_url, timeout=self._timeout).content + """Return bytes of camera image.""" + return self._doorbell_detail.get_doorbell_image(timeout=self._timeout) @property def unique_id(self) -> str: diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py index 6e367e96ac5..a7ba61efe1f 100644 --- a/homeassistant/components/august/const.py +++ b/homeassistant/components/august/const.py @@ -23,13 +23,7 @@ DOMAIN = "august" # Limit battery, online, and hardware updates to 1800 seconds # in order to reduce the number of api requests and # avoid hitting rate limits -MIN_TIME_BETWEEN_LOCK_DETAIL_UPDATES = timedelta(seconds=1800) - -# Doorbells need to update more frequently than locks -# since we get an image from the doorbell api. Once -# py-august 0.18.0 is released doorbell status updates -# can be reduced in the same was as locks have been -MIN_TIME_BETWEEN_DOORBELL_DETAIL_UPDATES = timedelta(seconds=20) +MIN_TIME_BETWEEN_DETAIL_UPDATES = timedelta(seconds=1800) # Activity needs to be checked more frequently as the # doorbell motion and rings are included here diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index d339dae8063..c335292ca54 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -67,9 +67,7 @@ class AugustLock(LockDevice): if detail is not None: lock_status = detail.lock_status - self._available = ( - lock_status is not None and lock_status != LockStatus.UNKNOWN - ) + self._available = detail.bridge_is_online if self._lock_status != lock_status: self._lock_status = lock_status diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py index 7b7fcd9f28c..cb78049d149 100644 --- a/tests/components/august/mocks.py +++ b/tests/components/august/mocks.py @@ -5,7 +5,18 @@ import time from unittest.mock import MagicMock, PropertyMock from asynctest import mock -from august.activity import DoorOperationActivity, LockOperationActivity +from august.activity import ( + ACTIVITY_ACTIONS_DOOR_OPERATION, + ACTIVITY_ACTIONS_DOORBELL_DING, + ACTIVITY_ACTIONS_DOORBELL_MOTION, + ACTIVITY_ACTIONS_DOORBELL_VIEW, + ACTIVITY_ACTIONS_LOCK_OPERATION, + DoorbellDingActivity, + DoorbellMotionActivity, + DoorbellViewActivity, + DoorOperationActivity, + LockOperationActivity, +) from august.authenticator import AuthenticationState from august.doorbell import Doorbell, DoorbellDetail from august.lock import Lock, LockDetail @@ -45,9 +56,12 @@ async def _mock_setup_august(hass, api_instance, authenticate_mock, api_mock): return True -async def _create_august_with_devices(hass, devices, api_call_side_effects=None): +async def _create_august_with_devices( + hass, devices, api_call_side_effects=None, activities=None +): if api_call_side_effects is None: api_call_side_effects = {} + device_data = { "doorbells": [], "locks": [], @@ -89,6 +103,8 @@ async def _create_august_with_devices(hass, devices, api_call_side_effects=None) return _get_base_devices("doorbells") def get_house_activities_side_effect(access_token, house_id, limit=10): + if activities is not None: + return activities return [] def lock_return_activities_side_effect(access_token, device_id): @@ -234,6 +250,17 @@ async def _mock_inoperative_august_lock_detail(hass): return await _mock_lock_from_fixture(hass, "get_lock.offline.json") +async def _mock_activities_from_fixture(hass, path): + json_dict = await _load_json_fixture(hass, path) + activities = [] + for activity_json in json_dict: + activity = _activity_from_dict(activity_json) + if activity: + activities.append(activity) + + return activities + + async def _mock_lock_from_fixture(hass, path): json_dict = await _load_json_fixture(hass, path) return LockDetail(json_dict) @@ -279,3 +306,21 @@ def _mock_door_operation_activity(lock, action): "action": action, } ) + + +def _activity_from_dict(activity_dict): + action = activity_dict.get("action") + + activity_dict["dateTime"] = time.time() * 1000 + + if action in ACTIVITY_ACTIONS_DOORBELL_DING: + return DoorbellDingActivity(activity_dict) + if action in ACTIVITY_ACTIONS_DOORBELL_MOTION: + return DoorbellMotionActivity(activity_dict) + if action in ACTIVITY_ACTIONS_DOORBELL_VIEW: + return DoorbellViewActivity(activity_dict) + if action in ACTIVITY_ACTIONS_LOCK_OPERATION: + return LockOperationActivity(activity_dict) + if action in ACTIVITY_ACTIONS_DOOR_OPERATION: + return DoorOperationActivity(activity_dict) + return None diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index f64b2de7918..1ecca29985d 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( from tests.components.august.mocks import ( _create_august_with_devices, + _mock_activities_from_fixture, _mock_doorbell_from_fixture, _mock_lock_from_fixture, ) @@ -70,6 +71,10 @@ async def test_create_doorbell(hass): "binary_sensor.k98gidt45gul_name_ding" ) assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF + binary_sensor_k98gidt45gul_name_motion = hass.states.get( + "binary_sensor.k98gidt45gul_name_motion" + ) + assert binary_sensor_k98gidt45gul_name_motion.state == STATE_OFF async def test_create_doorbell_offline(hass): @@ -90,6 +95,29 @@ async def test_create_doorbell_offline(hass): assert binary_sensor_tmt100_name_ding.state == STATE_UNAVAILABLE +async def test_create_doorbell_with_motion(hass): + """Test creation of a doorbell.""" + doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") + doorbell_details = [doorbell_one] + activities = await _mock_activities_from_fixture( + hass, "get_activity.doorbell_motion.json" + ) + await _create_august_with_devices(hass, doorbell_details, activities=activities) + + binary_sensor_k98gidt45gul_name_motion = hass.states.get( + "binary_sensor.k98gidt45gul_name_motion" + ) + assert binary_sensor_k98gidt45gul_name_motion.state == STATE_ON + binary_sensor_k98gidt45gul_name_online = hass.states.get( + "binary_sensor.k98gidt45gul_name_online" + ) + assert binary_sensor_k98gidt45gul_name_online.state == STATE_ON + binary_sensor_k98gidt45gul_name_ding = hass.states.get( + "binary_sensor.k98gidt45gul_name_ding" + ) + assert binary_sensor_k98gidt45gul_name_ding.state == STATE_OFF + + async def test_doorbell_device_registry(hass): """Test creation of a lock with doorsense and bridge ands up in the registry.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index a7027842b57..24e0cdafd46 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -6,7 +6,7 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_UNLOCK, STATE_LOCKED, - STATE_UNAVAILABLE, + STATE_UNKNOWN, STATE_UNLOCKED, ) @@ -80,6 +80,5 @@ async def test_one_lock_unknown_state(hass): await _create_august_with_devices(hass, lock_details) lock_brokenid_name = hass.states.get("lock.brokenid_name") - # Once we have bridge_is_online support in py-august - # this can change to STATE_UNKNOWN - assert lock_brokenid_name.state == STATE_UNAVAILABLE + + assert lock_brokenid_name.state == STATE_UNKNOWN diff --git a/tests/fixtures/august/get_activity.doorbell_motion.json b/tests/fixtures/august/get_activity.doorbell_motion.json new file mode 100644 index 00000000000..bd9c07afa26 --- /dev/null +++ b/tests/fixtures/august/get_activity.doorbell_motion.json @@ -0,0 +1,58 @@ +[ + { + "otherUser" : { + "FirstName" : "Unknown", + "UserName" : "deleteduser", + "LastName" : "User", + "UserID" : "deleted", + "PhoneNo" : "deleted" + }, + "dateTime" : 1582663119959, + "deviceID" : "K98GiDT45GUL", + "info" : { + "videoUploadProgress" : "in_progress", + "image" : { + "resource_type" : "image", + "etag" : "fdsf", + "created_at" : "2020-02-25T20:38:39Z", + "type" : "upload", + "format" : "jpg", + "version" : 1582663119, + "secure_url" : "https://res.cloudinary.com/updated_image.jpg", + "signature" : "fdfdfd", + "url" : "http://res.cloudinary.com/updated_image.jpg", + "bytes" : 48545, + "placeholder" : false, + "original_filename" : "file", + "width" : 720, + "tags" : [], + "public_id" : "xnsj5gphpzij9brifpf4", + "height" : 576 + }, + "dvrID" : "dvr", + "videoAvailable" : false, + "hasSubscription" : false + }, + "callingUser" : { + "LastName" : "User", + "UserName" : "deleteduser", + "FirstName" : "Unknown", + "UserID" : "deleted", + "PhoneNo" : "deleted" + }, + "house" : { + "houseName" : "K98GiDT45GUL", + "houseID" : "na" + }, + "action" : "doorbell_motion_detected", + "deviceType" : "doorbell", + "entities" : { + "otherUser" : "deleted", + "house" : "na", + "device" : "K98GiDT45GUL", + "activity" : "de5585cfd4eae900bb5ba3dc", + "callingUser" : "deleted" + }, + "deviceName" : "Front Door" + } +] From 5a67d73a376fccc682c665154c724088d2401953 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 26 Feb 2020 11:32:15 +0100 Subject: [PATCH 118/416] Updated frontend to 20200220.4 (#32205) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2b39681af25..b9575b7f21a 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200220.3" + "home-assistant-frontend==20200220.4" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 746b4be0e61..ad8abf3e2c4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.31 -home-assistant-frontend==20200220.3 +home-assistant-frontend==20200220.4 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 9889faf292a..d67d9084b5d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -687,7 +687,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200220.3 +home-assistant-frontend==20200220.4 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0522a3a8ea4..c0510c25d6c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -257,7 +257,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200220.3 +home-assistant-frontend==20200220.4 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 From e435f6eb67e5218896104a392a9e904d97ae1546 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 26 Feb 2020 04:19:14 -0700 Subject: [PATCH 119/416] Fix error where SimpliSafe websocket would disconnect and not reconnect (#32199) * Fix error where SimpliSafe websocket would disconnect and not reconnect * Await --- .../components/simplisafe/__init__.py | 56 +++++++++---------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 38a715d494a..f51a994efb6 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -474,41 +474,39 @@ class SimpliSafe: tasks = [update_system(system) for system in self.systems.values()] - def cancel_tasks(): - """Cancel tasks and ensure their cancellation is processed.""" - for task in tasks: - task.cancel() + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, InvalidCredentialsError): + if self._emergency_refresh_token_used: + _LOGGER.error( + "SimpliSafe authentication disconnected. Please restart HASS." + ) + remove_listener = self._hass.data[DOMAIN][DATA_LISTENER].pop( + self._config_entry.entry_id + ) + remove_listener() + return - try: - await asyncio.gather(*tasks) - except InvalidCredentialsError: - cancel_tasks() + _LOGGER.warning("SimpliSafe cloud error; trying stored refresh token") + self._emergency_refresh_token_used = True + return await self._api.refresh_access_token( + self._config_entry.data[CONF_TOKEN] + ) - if self._emergency_refresh_token_used: - _LOGGER.error( - "SimpliSafe authentication disconnected. Please restart HASS." - ) - remove_listener = self._hass.data[DOMAIN][DATA_LISTENER].pop( - self._config_entry.entry_id - ) - remove_listener() + if isinstance(result, SimplipyError): + _LOGGER.error("SimpliSafe error while updating: %s", result) return - _LOGGER.warning("SimpliSafe cloud error; trying stored refresh token") - self._emergency_refresh_token_used = True - return await self._api.refresh_access_token( - self._config_entry.data[CONF_TOKEN] - ) - except SimplipyError as err: - cancel_tasks() - _LOGGER.error("SimpliSafe error while updating: %s", err) - return - except Exception as err: # pylint: disable=broad-except - cancel_tasks() - _LOGGER.error("Unknown error while updating: %s", err) - return + if isinstance(result, SimplipyError): + _LOGGER.error("Unknown error while updating: %s", result) + return if self._api.refresh_token_dirty: + # Reconnect the websocket: + await self._api.websocket.async_disconnect() + await self._api.websocket.async_connect() + + # Save the new refresh token: _async_save_refresh_token( self._hass, self._config_entry, self._api.refresh_token ) From 92a47f14bbc40831f40f0d05c1b5f19929ca70b1 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Wed, 26 Feb 2020 17:44:04 +0000 Subject: [PATCH 120/416] homekit_controller test cleanups (#32212) --- .../components/homekit_controller/connection.py | 7 ++++--- .../components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homekit_controller/common.py | 7 ++----- .../specific_devices/test_ecobee3.py | 10 ++-------- 6 files changed, 11 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 154f9955779..f49480fc69d 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -3,7 +3,6 @@ import asyncio import datetime import logging -from aiohomekit.controller.ip import IpPairing from aiohomekit.exceptions import ( AccessoryDisconnectedError, AccessoryNotFoundError, @@ -15,7 +14,7 @@ from aiohomekit.model.services import ServicesTypes from homeassistant.core import callback from homeassistant.helpers.event import async_track_time_interval -from .const import DOMAIN, ENTITY_MAP, HOMEKIT_ACCESSORY_DISPATCH +from .const import CONTROLLER, DOMAIN, ENTITY_MAP, HOMEKIT_ACCESSORY_DISPATCH DEFAULT_SCAN_INTERVAL = datetime.timedelta(seconds=60) RETRY_INTERVAL = 60 # seconds @@ -66,7 +65,9 @@ class HKDevice: # don't want to mutate a dict owned by a config entry. self.pairing_data = pairing_data.copy() - self.pairing = IpPairing(self.pairing_data) + self.pairing = hass.data[CONTROLLER].load_pairing( + self.pairing_data["AccessoryPairingID"], self.pairing_data + ) self.accessories = {} self.config_num = 0 diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index cd2d0c67b44..e821efb3a60 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit[IP]==0.2.11"], + "requirements": ["aiohomekit[IP]==0.2.15"], "dependencies": [], "zeroconf": ["_hap._tcp.local."], "codeowners": ["@Jc2k"] diff --git a/requirements_all.txt b/requirements_all.txt index d67d9084b5d..c56cd516fc9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -163,7 +163,7 @@ aioftp==0.12.0 aioharmony==0.1.13 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.11 +aiohomekit[IP]==0.2.15 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c0510c25d6c..368c40f0487 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -62,7 +62,7 @@ aiobotocore==0.11.1 aioesphomeapi==2.6.1 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.11 +aiohomekit[IP]==0.2.15 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 51a12815124..4a6515a2503 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -98,11 +98,8 @@ async def setup_test_accessories(hass, accessories): ) config_entry.add_to_hass(hass) - pairing_cls_loc = "homeassistant.components.homekit_controller.connection.IpPairing" - with mock.patch(pairing_cls_loc) as pairing_cls: - pairing_cls.return_value = pairing - await config_entry.async_setup(hass) - await hass.async_block_till_done() + await config_entry.async_setup(hass) + await hass.async_block_till_done() return config_entry, pairing diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 7a18dad4f5c..ee048f93ca7 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -149,14 +149,8 @@ async def test_ecobee3_setup_connection_failure(hass): # a successful setup. # We just advance time by 5 minutes so that the retry happens, rather - # than manually invoking async_setup_entry - this means we need to - # make sure the IpPairing mock is in place or we'll try to connect to - # a real device. Normally this mocking is done by the helper in - # setup_test_accessories. - pairing_cls_loc = "homeassistant.components.homekit_controller.connection.IpPairing" - with mock.patch(pairing_cls_loc) as pairing_cls: - pairing_cls.return_value = pairing - await time_changed(hass, 5 * 60) + # than manually invoking async_setup_entry. + await time_changed(hass, 5 * 60) climate = entity_registry.async_get("climate.homew") assert climate.unique_id == "homekit-123456789012-16" From c92aa30663bc4607bfab4862636e56be07370783 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 26 Feb 2020 19:24:43 +0100 Subject: [PATCH 121/416] Add missing translations for light actions (#32216) --- homeassistant/components/light/strings.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 77b842ba078..922a4957afd 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -1,6 +1,8 @@ { "device_automation": { "action_type": { + "brightness_decrease": "Decrease {entity_name} brightness", + "brightness_increase": "Increase {entity_name} brightness", "toggle": "Toggle {entity_name}", "turn_on": "Turn on {entity_name}", "turn_off": "Turn off {entity_name}" From 853d6cda257c0524ac47b6bef9ee1abebde4a73e Mon Sep 17 00:00:00 2001 From: Jc2k Date: Wed, 26 Feb 2020 18:35:53 +0000 Subject: [PATCH 122/416] Make homekit_controller a local push integration (#32213) --- .../components/homekit_controller/__init__.py | 7 +++ .../homekit_controller/config_flow.py | 2 +- .../homekit_controller/connection.py | 20 +++++++ tests/components/homekit_controller/common.py | 5 ++ .../homekit_controller/test_light.py | 57 ++++++++++++++++++- 5 files changed, 89 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 3477e23897a..d94405f23e3 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -46,10 +46,12 @@ class HomeKitEntity(Entity): ) self._accessory.add_pollable_characteristics(self.pollable_characteristics) + self._accessory.add_watchable_characteristics(self.watchable_characteristics) async def async_will_remove_from_hass(self): """Prepare to be removed from hass.""" self._accessory.remove_pollable_characteristics(self._aid) + self._accessory.remove_watchable_characteristics(self._aid) for signal_remove in self._signals: signal_remove() @@ -71,6 +73,7 @@ class HomeKitEntity(Entity): characteristic_types = [get_uuid(c) for c in self.get_characteristic_types()] self.pollable_characteristics = [] + self.watchable_characteristics = [] self._chars = {} self._char_names = {} @@ -98,6 +101,10 @@ class HomeKitEntity(Entity): if "pr" in char["perms"]: self.pollable_characteristics.append((self._aid, char["iid"])) + # Build up a list of (aid, iid) tuples to subscribe to + if "ev" in char["perms"]: + self.watchable_characteristics.append((self._aid, char["iid"])) + # Build a map of ctype -> iid short_name = CharacteristicsTypes.get_short(char["type"]) self._chars[short_name] = char["iid"] diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 4b713636beb..dbfb8dcbcd9 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -81,7 +81,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): """Handle a HomeKit config flow.""" VERSION = 1 - CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH def __init__(self): """Initialize the homekit_controller flow.""" diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index f49480fc69d..493ef3ccb86 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -108,6 +108,10 @@ class HKDevice: self._polling_lock = asyncio.Lock() self._polling_lock_warned = False + self.watchable_characteristics = [] + + self.pairing.dispatcher_connect(self.process_new_events) + def add_pollable_characteristics(self, characteristics): """Add (aid, iid) pairs that we need to poll.""" self.pollable_characteristics.extend(characteristics) @@ -118,6 +122,17 @@ class HKDevice: char for char in self.pollable_characteristics if char[0] != accessory_id ] + def add_watchable_characteristics(self, characteristics): + """Add (aid, iid) pairs that we need to poll.""" + self.watchable_characteristics.extend(characteristics) + self.hass.add_job(self.pairing.subscribe(characteristics)) + + def remove_watchable_characteristics(self, accessory_id): + """Remove all pollable characteristics by accessory id.""" + self.watchable_characteristics = [ + char for char in self.watchable_characteristics if char[0] != accessory_id + ] + @callback def async_set_unavailable(self): """Mark state of all entities on this connection as unavailable.""" @@ -163,6 +178,9 @@ class HKDevice: self.add_entities() + if self.watchable_characteristics: + await self.pairing.subscribe(self.watchable_characteristics) + await self.async_update() return True @@ -172,6 +190,8 @@ class HKDevice: if self._polling_interval_remover: self._polling_interval_remover() + await self.pairing.unsubscribe(self.watchable_characteristics) + unloads = [] for platform in self.platforms: unloads.append( diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 4a6515a2503..0a57450a740 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -40,6 +40,11 @@ class Helper: char_name = CharacteristicsTypes.get_short(char.type) self.characteristics[(service_name, char_name)] = char + async def update_named_service(self, service, characteristics): + """Update a service.""" + self.pairing.testing.update_named_service(service, characteristics) + await self.hass.async_block_till_done() + async def poll_and_get_state(self): """Trigger a time based poll and return the current entity state.""" await time_changed(self.hass, 60) diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index d9e1d21e2fe..e443e36b910 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -6,6 +6,9 @@ from homeassistant.components.homekit_controller.const import KNOWN_DEVICES from tests.components.homekit_controller.common import setup_test_component +LIGHT_BULB_NAME = "Light Bulb" +LIGHT_BULB_ENTITY_ID = "light.testdevice" + LIGHT_ON = ("lightbulb", "on") LIGHT_BRIGHTNESS = ("lightbulb", "brightness") LIGHT_HUE = ("lightbulb", "hue") @@ -15,7 +18,7 @@ LIGHT_COLOR_TEMP = ("lightbulb", "color-temperature") def create_lightbulb_service(accessory): """Define lightbulb characteristics.""" - service = accessory.add_service(ServicesTypes.LIGHTBULB) + service = accessory.add_service(ServicesTypes.LIGHTBULB, name=LIGHT_BULB_NAME) on_char = service.add_char(CharacteristicsTypes.ON) on_char.value = 0 @@ -110,6 +113,35 @@ async def test_switch_read_light_state(hass, utcnow): assert state.state == "off" +async def test_switch_push_light_state(hass, utcnow): + """Test that we can read the state of a HomeKit light accessory.""" + helper = await setup_test_component(hass, create_lightbulb_service_with_hs) + + # Initial state is that the light is off + state = hass.states.get(LIGHT_BULB_ENTITY_ID) + assert state.state == "off" + + await helper.update_named_service( + LIGHT_BULB_NAME, + { + CharacteristicsTypes.ON: True, + CharacteristicsTypes.BRIGHTNESS: 100, + CharacteristicsTypes.HUE: 4, + CharacteristicsTypes.SATURATION: 5, + }, + ) + + state = hass.states.get(LIGHT_BULB_ENTITY_ID) + assert state.state == "on" + assert state.attributes["brightness"] == 255 + assert state.attributes["hs_color"] == (4, 5) + + # Simulate that device switched off in the real world not via HA + await helper.update_named_service(LIGHT_BULB_NAME, {CharacteristicsTypes.ON: False}) + state = hass.states.get(LIGHT_BULB_ENTITY_ID) + assert state.state == "off" + + async def test_switch_read_light_state_color_temp(hass, utcnow): """Test that we can read the color_temp of a light accessory.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) @@ -129,6 +161,29 @@ async def test_switch_read_light_state_color_temp(hass, utcnow): assert state.attributes["color_temp"] == 400 +async def test_switch_push_light_state_color_temp(hass, utcnow): + """Test that we can read the state of a HomeKit light accessory.""" + helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) + + # Initial state is that the light is off + state = hass.states.get(LIGHT_BULB_ENTITY_ID) + assert state.state == "off" + + await helper.update_named_service( + LIGHT_BULB_NAME, + { + CharacteristicsTypes.ON: True, + CharacteristicsTypes.BRIGHTNESS: 100, + CharacteristicsTypes.COLOR_TEMPERATURE: 400, + }, + ) + + state = hass.states.get(LIGHT_BULB_ENTITY_ID) + assert state.state == "on" + assert state.attributes["brightness"] == 255 + assert state.attributes["color_temp"] == 400 + + async def test_light_becomes_unavailable_but_recovers(hass, utcnow): """Test transition to and from unavailable state.""" helper = await setup_test_component(hass, create_lightbulb_service_with_color_temp) From 2a88ae559e7bef1f19b23b9ccca21fa7daaf94d4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Feb 2020 11:27:37 -0800 Subject: [PATCH 123/416] Improve debounce cooldown (#32161) --- homeassistant/helpers/debounce.py | 52 ++++++++++++++++++++++++------- tests/helpers/test_debounce.py | 27 ++++++++++++++++ 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index bbaf6dacfeb..6206081dc8c 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -31,6 +31,7 @@ class Debouncer: self.immediate = immediate self._timer_task: Optional[asyncio.TimerHandle] = None self._execute_at_end_of_timer: bool = False + self._execute_lock = asyncio.Lock() async def async_call(self) -> None: """Call the function.""" @@ -42,15 +43,23 @@ class Debouncer: return - if self.immediate: - await self.hass.async_add_job(self.function) # type: ignore - else: - self._execute_at_end_of_timer = True + # Locked means a call is in progress. Any call is good, so abort. + if self._execute_lock.locked(): + return - self._timer_task = self.hass.loop.call_later( - self.cooldown, - lambda: self.hass.async_create_task(self._handle_timer_finish()), - ) + if not self.immediate: + self._execute_at_end_of_timer = True + self._schedule_timer() + return + + async with self._execute_lock: + # Abort if timer got set while we're waiting for the lock. + if self._timer_task: + return + + await self.hass.async_add_job(self.function) # type: ignore + + self._schedule_timer() async def _handle_timer_finish(self) -> None: """Handle a finished timer.""" @@ -63,10 +72,21 @@ class Debouncer: self._execute_at_end_of_timer = False - try: - await self.hass.async_add_job(self.function) # type: ignore - except Exception: # pylint: disable=broad-except - self.logger.exception("Unexpected exception from %s", self.function) + # Locked means a call is in progress. Any call is good, so abort. + if self._execute_lock.locked(): + return + + async with self._execute_lock: + # Abort if timer got set while we're waiting for the lock. + if self._timer_task: + return # type: ignore + + try: + await self.hass.async_add_job(self.function) # type: ignore + except Exception: # pylint: disable=broad-except + self.logger.exception("Unexpected exception from %s", self.function) + + self._schedule_timer() @callback def async_cancel(self) -> None: @@ -76,3 +96,11 @@ class Debouncer: self._timer_task = None self._execute_at_end_of_timer = False + + @callback + def _schedule_timer(self) -> None: + """Schedule a timer.""" + self._timer_task = self.hass.loop.call_later( + self.cooldown, + lambda: self.hass.async_create_task(self._handle_timer_finish()), + ) diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py index 4972fbbc018..d51eb22c90d 100644 --- a/tests/helpers/test_debounce.py +++ b/tests/helpers/test_debounce.py @@ -15,20 +15,24 @@ async def test_immediate_works(hass): function=CoroutineMock(side_effect=lambda: calls.append(None)), ) + # Call when nothing happening await debouncer.async_call() assert len(calls) == 1 assert debouncer._timer_task is not None assert debouncer._execute_at_end_of_timer is False + # Call when cooldown active setting execute at end to True await debouncer.async_call() assert len(calls) == 1 assert debouncer._timer_task is not None assert debouncer._execute_at_end_of_timer is True + # Canceling debounce in cooldown debouncer.async_cancel() assert debouncer._timer_task is None assert debouncer._execute_at_end_of_timer is False + # Call and let timer run out await debouncer.async_call() assert len(calls) == 2 await debouncer._handle_timer_finish() @@ -36,6 +40,14 @@ async def test_immediate_works(hass): assert debouncer._timer_task is None assert debouncer._execute_at_end_of_timer is False + # Test calling doesn't execute/cooldown if currently executing. + await debouncer._execute_lock.acquire() + await debouncer.async_call() + assert len(calls) == 2 + assert debouncer._timer_task is None + assert debouncer._execute_at_end_of_timer is False + debouncer._execute_lock.release() + async def test_not_immediate_works(hass): """Test immediate works.""" @@ -48,23 +60,38 @@ async def test_not_immediate_works(hass): function=CoroutineMock(side_effect=lambda: calls.append(None)), ) + # Call when nothing happening await debouncer.async_call() assert len(calls) == 0 assert debouncer._timer_task is not None assert debouncer._execute_at_end_of_timer is True + # Call while still on cooldown await debouncer.async_call() assert len(calls) == 0 assert debouncer._timer_task is not None assert debouncer._execute_at_end_of_timer is True + # Canceling while on cooldown debouncer.async_cancel() assert debouncer._timer_task is None assert debouncer._execute_at_end_of_timer is False + # Call and let timer run out await debouncer.async_call() assert len(calls) == 0 await debouncer._handle_timer_finish() assert len(calls) == 1 + assert debouncer._timer_task is not None + assert debouncer._execute_at_end_of_timer is False + + # Reset debouncer + debouncer.async_cancel() + + # Test calling doesn't schedule if currently executing. + await debouncer._execute_lock.acquire() + await debouncer.async_call() + assert len(calls) == 1 assert debouncer._timer_task is None assert debouncer._execute_at_end_of_timer is False + debouncer._execute_lock.release() From 92988d60a7b562003ce3e109dde42b94f54194c4 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Thu, 27 Feb 2020 00:31:50 +0000 Subject: [PATCH 124/416] [ci skip] Translation update --- .../ambient_station/.translations/lb.json | 3 ++ .../components/august/.translations/ca.json | 32 +++++++++++++++++++ .../components/august/.translations/lb.json | 32 +++++++++++++++++++ .../components/august/.translations/no.json | 32 +++++++++++++++++++ .../august/.translations/zh-Hant.json | 32 +++++++++++++++++++ .../konnected/.translations/ca.json | 4 +++ .../konnected/.translations/lb.json | 4 +++ .../konnected/.translations/no.json | 4 +++ .../konnected/.translations/ru.json | 2 +- .../konnected/.translations/zh-Hant.json | 6 +++- .../components/light/.translations/en.json | 2 ++ .../components/mqtt/.translations/lb.json | 10 ++++++ .../components/notion/.translations/lb.json | 3 ++ .../rainmachine/.translations/lb.json | 3 ++ .../components/sense/.translations/ca.json | 22 +++++++++++++ .../components/sense/.translations/de.json | 22 +++++++++++++ .../components/sense/.translations/lb.json | 22 +++++++++++++ .../components/sense/.translations/no.json | 22 +++++++++++++ .../sense/.translations/zh-Hant.json | 22 +++++++++++++ .../simplisafe/.translations/lb.json | 3 ++ 20 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/august/.translations/ca.json create mode 100644 homeassistant/components/august/.translations/lb.json create mode 100644 homeassistant/components/august/.translations/no.json create mode 100644 homeassistant/components/august/.translations/zh-Hant.json create mode 100644 homeassistant/components/sense/.translations/ca.json create mode 100644 homeassistant/components/sense/.translations/de.json create mode 100644 homeassistant/components/sense/.translations/lb.json create mode 100644 homeassistant/components/sense/.translations/no.json create mode 100644 homeassistant/components/sense/.translations/zh-Hant.json diff --git a/homeassistant/components/ambient_station/.translations/lb.json b/homeassistant/components/ambient_station/.translations/lb.json index 0f0d60d4458..891051bae00 100644 --- a/homeassistant/components/ambient_station/.translations/lb.json +++ b/homeassistant/components/ambient_station/.translations/lb.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "D\u00ebsen App Schl\u00ebssel g\u00ebtt scho benotzt" + }, "error": { "identifier_exists": "Applikatioun's Schl\u00ebssel an/oder API Schl\u00ebssel ass scho registr\u00e9iert", "invalid_key": "Ong\u00ebltegen API Schl\u00ebssel an/oder Applikatioun's Schl\u00ebssel", diff --git a/homeassistant/components/august/.translations/ca.json b/homeassistant/components/august/.translations/ca.json new file mode 100644 index 00000000000..561b91799be --- /dev/null +++ b/homeassistant/components/august/.translations/ca.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "El compte ja ha estat configurat" + }, + "error": { + "cannot_connect": "No s'ha pogut connectar, torna-ho a provar", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "login_method": "M\u00e8tode d'inici de sessi\u00f3", + "password": "Contrasenya", + "timeout": "Temps d'espera (segons)", + "username": "Nom d'usuari" + }, + "description": "Si el m\u00e8tode d'inici de sessi\u00f3 \u00e9s 'email', el nom d'usuari \u00e9s l'adre\u00e7a de correu electr\u00f2nic. Si el m\u00e8tode d'inici de sessi\u00f3 \u00e9s 'phone', el nom d'usuari \u00e9s el n\u00famero de tel\u00e8fon en el format \"+NNNNNNNNN\".", + "title": "Configuraci\u00f3 de compte August" + }, + "validation": { + "data": { + "code": "Codi de verificaci\u00f3" + }, + "description": "Comprova el teu {login_method} ({username}) i introdueix el codi de verificaci\u00f3 a continuaci\u00f3", + "title": "Autenticaci\u00f3 de dos factors" + } + }, + "title": "August" + } +} \ No newline at end of file diff --git a/homeassistant/components/august/.translations/lb.json b/homeassistant/components/august/.translations/lb.json new file mode 100644 index 00000000000..514ad6786d4 --- /dev/null +++ b/homeassistant/components/august/.translations/lb.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Kont ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.", + "invalid_auth": "Ong\u00eblteg Authentifikatioun", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "user": { + "data": { + "login_method": "Login Method", + "password": "Passwuert", + "timeout": "Z\u00e4itiwwerscheidung (sekonnen)", + "username": "Benotzernumm" + }, + "description": "Wann d'Login Method 'E-Mail' ass, dannn ass de Benotzernumm d'E-Mail Adress. Wann d'Login-Method 'Telefon' ass, ass den Benotzernumm d'Telefonsnummer am Format '+ NNNNNNNNN'.", + "title": "August Kont ariichten" + }, + "validation": { + "data": { + "code": "Verifikatiouns Code" + }, + "description": "Pr\u00e9ift w.e.g. \u00c4re {login_method} ({username}) a gitt de Verifikatiounscode hei dr\u00ebnner an", + "title": "2-Faktor-Authentifikatioun" + } + }, + "title": "August" + } +} \ No newline at end of file diff --git a/homeassistant/components/august/.translations/no.json b/homeassistant/components/august/.translations/no.json new file mode 100644 index 00000000000..61193656b51 --- /dev/null +++ b/homeassistant/components/august/.translations/no.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Kontoen er allerede konfigurert" + }, + "error": { + "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "login_method": "P\u00e5loggingsmetode", + "password": "Passord", + "timeout": "Tidsavbrudd (sekunder)", + "username": "Brukernavn" + }, + "description": "Hvis p\u00e5loggingsmetoden er 'e-post', er brukernavnet e-postadressen. Hvis p\u00e5loggingsmetoden er 'telefon', er brukernavn telefonnummeret i formatet '+ NNNNNNNNN'.", + "title": "Sett opp en August konto" + }, + "validation": { + "data": { + "code": "Bekreftelseskode" + }, + "description": "Kontroller {login_method} ( {username} ) og skriv inn bekreftelseskoden nedenfor", + "title": "To-faktor autentisering" + } + }, + "title": "August" + } +} \ No newline at end of file diff --git a/homeassistant/components/august/.translations/zh-Hant.json b/homeassistant/components/august/.translations/zh-Hant.json new file mode 100644 index 00000000000..193b9a46e3f --- /dev/null +++ b/homeassistant/components/august/.translations/zh-Hant.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "login_method": "\u767b\u5165\u65b9\u5f0f", + "password": "\u5bc6\u78bc", + "timeout": "\u903e\u6642\uff08\u79d2\uff09", + "username": "\u4f7f\u7528\u8005\u540d\u7a31" + }, + "description": "\u5047\u5982\u767b\u5165\u65b9\u5f0f\u70ba\u90f5\u4ef6\u300cemail\u300d\u3001\u4f7f\u7528\u8005\u540d\u7a31\u70ba\u96fb\u5b50\u90f5\u4ef6\u4f4d\u5740\u3002\u5047\u5982\u767b\u5165\u65b9\u5f0f\u70ba\u96fb\u8a71\u300cphone\u300d\u3001\u5247\u4f7f\u7528\u8005\u540d\u7a31\u70ba\u5305\u542b\u570b\u78bc\u4e4b\u96fb\u8a71\u865f\u78bc\uff0c\u5982\u300c+NNNNNNNNN\u300d\u3002", + "title": "\u8a2d\u5b9a August \u5e33\u865f" + }, + "validation": { + "data": { + "code": "\u9a57\u8b49\u78bc" + }, + "description": "\u8acb\u78ba\u8a8d {login_method} ({username}) \u4e26\u65bc\u4e0b\u65b9\u8f38\u5165\u9a57\u8b49\u78bc", + "title": "\u5169\u6b65\u9a5f\u9a57\u8b49" + } + }, + "title": "August" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/ca.json b/homeassistant/components/konnected/.translations/ca.json index ccb03ef7add..fbfa9183941 100644 --- a/homeassistant/components/konnected/.translations/ca.json +++ b/homeassistant/components/konnected/.translations/ca.json @@ -14,6 +14,10 @@ "description": "Model: {model} \nAmfitri\u00f3: {host} \nPort: {port} \n\nPots configurar el comportament de les E/S (I/O) i del panell a la configuraci\u00f3 del panell d\u2019alarma Konnected.", "title": "Dispositiu Konnected llest" }, + "import_confirm": { + "description": "S'ha descobert un panell d'alarma Konnected amb ID {id} a configuration.yaml. Aquest flux et permetr\u00e0 importar-lo a una entrada de configuraci\u00f3.", + "title": "Importaci\u00f3 de dispositiu Konnected" + }, "user": { "data": { "host": "Adre\u00e7a IP del dispositiu Konnected", diff --git a/homeassistant/components/konnected/.translations/lb.json b/homeassistant/components/konnected/.translations/lb.json index 2e37ecb8e92..12493169691 100644 --- a/homeassistant/components/konnected/.translations/lb.json +++ b/homeassistant/components/konnected/.translations/lb.json @@ -14,6 +14,10 @@ "description": "Modell: {model}\nHost: {host}\nPort: {port}\n\nDir k\u00ebnnt den I/O a Panel Verhaalen an de Konnected Alarm Panel Astellunge konfigur\u00e9ieren.", "title": "Konnected Apparat parat" }, + "import_confirm": { + "description": "Ee Konnected Alarm Pael mat der ID {id} gouf an der configuration.yaml entdeckt. D\u00ebsen Oflaf erlaabt et als eng Konfiguratioun z'import\u00e9ieren.", + "title": "Konnected Apparat import\u00e9ieren" + }, "user": { "data": { "host": "Konnected Apparat IP Adress", diff --git a/homeassistant/components/konnected/.translations/no.json b/homeassistant/components/konnected/.translations/no.json index 569dac5756f..9c663537c1a 100644 --- a/homeassistant/components/konnected/.translations/no.json +++ b/homeassistant/components/konnected/.translations/no.json @@ -14,6 +14,10 @@ "description": "Modell: {model}\nVert: {host}\nPort: {port}\n\nDu kan konfigurere IO og panel atferd i Konnected Alarm Panel innstillinger.", "title": "Konnected Enhet klar" }, + "import_confirm": { + "description": "Et Konnected Alarm Panel med ID {id} er oppdaget i configuration.yaml. Denne flyten vil tillate deg \u00e5 importere den til en config-oppf\u00f8ring.", + "title": "Importer Konnected Enhet" + }, "user": { "data": { "host": "Konnected enhet IP-adresse", diff --git a/homeassistant/components/konnected/.translations/ru.json b/homeassistant/components/konnected/.translations/ru.json index 25cb03b1578..25dec57169c 100644 --- a/homeassistant/components/konnected/.translations/ru.json +++ b/homeassistant/components/konnected/.translations/ru.json @@ -20,7 +20,7 @@ "port": "\u041f\u043e\u0440\u0442" }, "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043a \u043f\u0430\u043d\u0435\u043b\u0438 Konnected.", - "title": "\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Konnected" + "title": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Konnected" } }, "title": "Konnected.io" diff --git a/homeassistant/components/konnected/.translations/zh-Hant.json b/homeassistant/components/konnected/.translations/zh-Hant.json index 0ecd6c9fc25..4c1bec691db 100644 --- a/homeassistant/components/konnected/.translations/zh-Hant.json +++ b/homeassistant/components/konnected/.translations/zh-Hant.json @@ -11,9 +11,13 @@ }, "step": { "confirm": { - "description": "\u578b\u865f\uff1a{model}\n\u4e3b\u6a5f\u7aef\uff1a{host}\n\u901a\u8a0a\u57e0\uff1a{port}\n\n\u53ef\u4ee5\u65bc Konncected \u8b66\u5831\u9762\u677f\u8a2d\u5b9a\u4e2d\u8a2d\u5b9a IO \u8207\u9762\u677f\u884c\u70ba\u3002", + "description": "\u578b\u865f\uff1a{model}\nID\uff1a{id}\n\u4e3b\u6a5f\u7aef\uff1a{host}\n\u901a\u8a0a\u57e0\uff1a{port}\n\n\u53ef\u4ee5\u65bc Konncected \u8b66\u5831\u9762\u677f\u8a2d\u5b9a\u4e2d\u8a2d\u5b9a IO \u8207\u9762\u677f\u884c\u70ba\u3002", "title": "Konnected \u8a2d\u5099\u5df2\u5099\u59a5" }, + "import_confirm": { + "description": "\u65bc configuration.yaml \u4e2d\u767c\u73fe Konnected \u8b66\u5831 ID {id}\u3002\u6b64\u6d41\u7a0b\u5c07\u5141\u8a31\u532f\u5165\u81f3\u8a2d\u5b9a\u4e2d\u3002", + "title": "\u532f\u5165 Konnected \u8a2d\u5099" + }, "user": { "data": { "host": "Konnected \u8a2d\u5099 IP \u4f4d\u5740", diff --git a/homeassistant/components/light/.translations/en.json b/homeassistant/components/light/.translations/en.json index 3f37de5331e..788934a7e01 100644 --- a/homeassistant/components/light/.translations/en.json +++ b/homeassistant/components/light/.translations/en.json @@ -1,6 +1,8 @@ { "device_automation": { "action_type": { + "brightness_decrease": "Decrease {entity_name} brightness", + "brightness_increase": "Increase {entity_name} brightness", "toggle": "Toggle {entity_name}", "turn_off": "Turn off {entity_name}", "turn_on": "Turn on {entity_name}" diff --git a/homeassistant/components/mqtt/.translations/lb.json b/homeassistant/components/mqtt/.translations/lb.json index 9467ab8a9a7..25814e1359b 100644 --- a/homeassistant/components/mqtt/.translations/lb.json +++ b/homeassistant/components/mqtt/.translations/lb.json @@ -38,6 +38,16 @@ "button_6": "Sechste Kn\u00e4ppchen", "turn_off": "Ausschalten", "turn_on": "Uschalten" + }, + "trigger_type": { + "button_double_press": "\"{subtype}\" zwee mol gedr\u00e9ckt", + "button_long_press": "\"{subtype}\" permanent gedr\u00e9ckt", + "button_long_release": "\"{subtype}\" no laangem unhalen lassgelooss", + "button_quadruple_press": "\"{subtype}\" v\u00e9ier mol gedr\u00e9ckt", + "button_quintuple_press": "\"{subtype}\" f\u00ebnnef mol gedr\u00e9ckt", + "button_short_press": "\"{subtype}\" gedr\u00e9ckt", + "button_short_release": "\"{subtype}\" lassgelooss", + "button_triple_press": "\"{subtype}\" dr\u00e4imol gedr\u00e9ckt" } } } \ No newline at end of file diff --git a/homeassistant/components/notion/.translations/lb.json b/homeassistant/components/notion/.translations/lb.json index 1dcf1c429eb..bc9fa9633b2 100644 --- a/homeassistant/components/notion/.translations/lb.json +++ b/homeassistant/components/notion/.translations/lb.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "D\u00ebse Benotzernumm g\u00ebtt scho benotzt." + }, "error": { "identifier_exists": "Benotzernumm ass scho registr\u00e9iert", "invalid_credentials": "Ong\u00ebltege Benotzernumm oder Passwuert", diff --git a/homeassistant/components/rainmachine/.translations/lb.json b/homeassistant/components/rainmachine/.translations/lb.json index 4456b105fbc..be25e92080a 100644 --- a/homeassistant/components/rainmachine/.translations/lb.json +++ b/homeassistant/components/rainmachine/.translations/lb.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "D\u00ebse RainMachine Kontroller ass scho konfigur\u00e9iert." + }, "error": { "identifier_exists": "Konto ass scho registr\u00e9iert", "invalid_credentials": "Ong\u00eblteg Login Informatioune" diff --git a/homeassistant/components/sense/.translations/ca.json b/homeassistant/components/sense/.translations/ca.json new file mode 100644 index 00000000000..b1a49974cbd --- /dev/null +++ b/homeassistant/components/sense/.translations/ca.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositiu ja est\u00e0 configurat" + }, + "error": { + "cannot_connect": "No s'ha pogut connectar, torna-ho a provar", + "invalid_auth": "Autenticaci\u00f3 inv\u00e0lida", + "unknown": "Error inesperat" + }, + "step": { + "user": { + "data": { + "email": "Correu electr\u00f2nic", + "password": "Contrasenya" + }, + "title": "Connexi\u00f3 amb Sense Energy Monitor" + } + }, + "title": "Sense" + } +} \ No newline at end of file diff --git a/homeassistant/components/sense/.translations/de.json b/homeassistant/components/sense/.translations/de.json new file mode 100644 index 00000000000..229b26e56bd --- /dev/null +++ b/homeassistant/components/sense/.translations/de.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Ger\u00e4t ist bereits konfiguriert" + }, + "error": { + "cannot_connect": "Verbindung fehlgeschlagen, versuchen Sie es erneut", + "invalid_auth": "Ung\u00fcltige Authentifizierung", + "unknown": "Unerwarteter Fehler" + }, + "step": { + "user": { + "data": { + "email": "E-Mail-Adresse", + "password": "Passwort" + }, + "title": "Stellen Sie eine Verbindung zu Ihrem Sense Energy Monitor her" + } + }, + "title": "Sense" + } +} \ No newline at end of file diff --git a/homeassistant/components/sense/.translations/lb.json b/homeassistant/components/sense/.translations/lb.json new file mode 100644 index 00000000000..74e7615cf5c --- /dev/null +++ b/homeassistant/components/sense/.translations/lb.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Apparat ass scho konfigur\u00e9iert" + }, + "error": { + "cannot_connect": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.", + "invalid_auth": "Ong\u00eblteg Authentifikatiouns", + "unknown": "Onerwaarte Feeler" + }, + "step": { + "user": { + "data": { + "email": "E-Mail Adress", + "password": "Passwuert" + }, + "title": "Verbann d\u00e4in Sense Energie Monitor" + } + }, + "title": "Sense" + } +} \ No newline at end of file diff --git a/homeassistant/components/sense/.translations/no.json b/homeassistant/components/sense/.translations/no.json new file mode 100644 index 00000000000..70bd45558a3 --- /dev/null +++ b/homeassistant/components/sense/.translations/no.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Enheten er allerede konfigurert" + }, + "error": { + "cannot_connect": "Klarte ikke \u00e5 koble til, vennligst pr\u00f8v igjen", + "invalid_auth": "Ugyldig godkjenning", + "unknown": "Uventet feil" + }, + "step": { + "user": { + "data": { + "email": "E-postadresse", + "password": "Passord" + }, + "title": "Koble til din Sense Energi Monitor" + } + }, + "title": "Sense" + } +} \ No newline at end of file diff --git a/homeassistant/components/sense/.translations/zh-Hant.json b/homeassistant/components/sense/.translations/zh-Hant.json new file mode 100644 index 00000000000..1d911576454 --- /dev/null +++ b/homeassistant/components/sense/.translations/zh-Hant.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "cannot_connect": "\u9023\u7dda\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21", + "invalid_auth": "\u9a57\u8b49\u78bc\u7121\u6548", + "unknown": "\u672a\u9810\u671f\u932f\u8aa4" + }, + "step": { + "user": { + "data": { + "email": "\u96fb\u5b50\u90f5\u4ef6\u5730\u5740", + "password": "\u5bc6\u78bc" + }, + "title": "\u9023\u7dda\u81f3 Sense \u80fd\u6e90\u76e3\u63a7" + } + }, + "title": "Sense" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/lb.json b/homeassistant/components/simplisafe/.translations/lb.json index 94c451a49db..c0e9faf08f6 100644 --- a/homeassistant/components/simplisafe/.translations/lb.json +++ b/homeassistant/components/simplisafe/.translations/lb.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "D\u00ebse SimpliSafe Kont g\u00ebtt scho benotzt." + }, "error": { "identifier_exists": "Konto ass scho registr\u00e9iert", "invalid_credentials": "Ong\u00eblteg Login Informatioune" From 483d82227215e7df4ae06f77f11fe763adbd8ead Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 26 Feb 2020 16:43:34 -0800 Subject: [PATCH 125/416] Lovelace resource mgmt (#32224) * Add websockets commands for resource management * Add LL resource management Co-authored-by: Bram Kragten --- homeassistant/components/lovelace/__init__.py | 12 +++- homeassistant/components/lovelace/const.py | 16 ++++- .../components/lovelace/resources.py | 30 +++++++-- tests/components/lovelace/test_resources.py | 61 +++++++++++++++++++ 4 files changed, 112 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index c78356e0dd6..e7c309be719 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -7,7 +7,7 @@ import voluptuous as vol from homeassistant.components import frontend from homeassistant.const import CONF_FILENAME, CONF_ICON from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import collection, config_validation as cv from homeassistant.util import sanitize_filename, slugify from . import dashboard, resources, websocket @@ -17,7 +17,9 @@ from .const import ( LOVELACE_CONFIG_FILE, MODE_STORAGE, MODE_YAML, + RESOURCE_CREATE_FIELDS, RESOURCE_SCHEMA, + RESOURCE_UPDATE_FIELDS, ) _LOGGER = logging.getLogger(__name__) @@ -111,6 +113,14 @@ async def async_setup(hass, config): resource_collection = resources.ResourceStorageCollection(hass, default_config) + collection.StorageCollectionWebsocket( + resource_collection, + "lovelace/resources", + "resource", + RESOURCE_CREATE_FIELDS, + RESOURCE_UPDATE_FIELDS, + ).async_setup(hass, create_list=False) + hass.components.websocket_api.async_register_command( websocket.websocket_lovelace_config ) diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index 2bf2b34098c..1e984b3d82d 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -14,13 +14,27 @@ MODE_STORAGE = "storage" LOVELACE_CONFIG_FILE = "ui-lovelace.yaml" CONF_RESOURCES = "resources" CONF_URL_PATH = "url_path" +CONF_RESOURCE_TYPE_WS = "res_type" + +RESOURCE_TYPES = ["js", "css", "module", "html"] RESOURCE_FIELDS = { - CONF_TYPE: vol.In(["js", "css", "module", "html"]), + CONF_TYPE: vol.In(RESOURCE_TYPES), CONF_URL: cv.string, } + RESOURCE_SCHEMA = vol.Schema(RESOURCE_FIELDS) +RESOURCE_CREATE_FIELDS = { + vol.Required(CONF_RESOURCE_TYPE_WS): vol.In(RESOURCE_TYPES), + vol.Required(CONF_URL): cv.string, +} + +RESOURCE_UPDATE_FIELDS = { + vol.Optional(CONF_RESOURCE_TYPE_WS): vol.In(RESOURCE_TYPES), + vol.Optional(CONF_URL): cv.string, +} + class ConfigNotFound(HomeAssistantError): """When no config available.""" diff --git a/homeassistant/components/lovelace/resources.py b/homeassistant/components/lovelace/resources.py index 4244feb26dd..57acaa487bd 100644 --- a/homeassistant/components/lovelace/resources.py +++ b/homeassistant/components/lovelace/resources.py @@ -5,11 +5,19 @@ import uuid import voluptuous as vol +from homeassistant.const import CONF_TYPE from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, storage -from .const import CONF_RESOURCES, DOMAIN, RESOURCE_SCHEMA +from .const import ( + CONF_RESOURCE_TYPE_WS, + CONF_RESOURCES, + DOMAIN, + RESOURCE_CREATE_FIELDS, + RESOURCE_SCHEMA, + RESOURCE_UPDATE_FIELDS, +) from .dashboard import LovelaceConfig RESOURCE_STORAGE_KEY = f"{DOMAIN}_resources" @@ -36,6 +44,8 @@ class ResourceStorageCollection(collection.StorageCollection): """Collection to store resources.""" loaded = False + CREATE_SCHEMA = vol.Schema(RESOURCE_CREATE_FIELDS) + UPDATE_SCHEMA = vol.Schema(RESOURCE_UPDATE_FIELDS) def __init__(self, hass: HomeAssistant, ll_config: LovelaceConfig): """Initialize the storage collection.""" @@ -84,13 +94,23 @@ class ResourceStorageCollection(collection.StorageCollection): async def _process_create_data(self, data: dict) -> dict: """Validate the config is valid.""" - raise NotImplementedError + data = self.CREATE_SCHEMA(data) + data[CONF_TYPE] = data.pop(CONF_RESOURCE_TYPE_WS) + return data @callback def _get_suggested_id(self, info: dict) -> str: - """Suggest an ID based on the config.""" - raise NotImplementedError + """Return unique ID.""" + return uuid.uuid4().hex async def _update_data(self, data: dict, update_data: dict) -> dict: """Return a new updated data object.""" - raise NotImplementedError + if not self.loaded: + await self.async_load() + self.loaded = True + + update_data = self.UPDATE_SCHEMA(update_data) + if CONF_RESOURCE_TYPE_WS in update_data: + update_data[CONF_TYPE] = update_data.pop(CONF_RESOURCE_TYPE_WS) + + return {**data, **update_data} diff --git a/tests/components/lovelace/test_resources.py b/tests/components/lovelace/test_resources.py index 89464d95350..a44af14d3a0 100644 --- a/tests/components/lovelace/test_resources.py +++ b/tests/components/lovelace/test_resources.py @@ -90,6 +90,67 @@ async def test_storage_resources_import(hass, hass_ws_client, hass_storage): not in hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"]["config"] ) + # Add a resource + await client.send_json( + { + "id": 6, + "type": "lovelace/resources/create", + "res_type": "module", + "url": "/local/yo.js", + } + ) + response = await client.receive_json() + assert response["success"] + + await client.send_json({"id": 7, "type": "lovelace/resources"}) + response = await client.receive_json() + assert response["success"] + + last_item = response["result"][-1] + assert last_item["type"] == "module" + assert last_item["url"] == "/local/yo.js" + + # Update a resource + first_item = response["result"][0] + + await client.send_json( + { + "id": 8, + "type": "lovelace/resources/update", + "resource_id": first_item["id"], + "res_type": "css", + "url": "/local/updated.css", + } + ) + response = await client.receive_json() + assert response["success"] + + await client.send_json({"id": 9, "type": "lovelace/resources"}) + response = await client.receive_json() + assert response["success"] + + first_item = response["result"][0] + assert first_item["type"] == "css" + assert first_item["url"] == "/local/updated.css" + + # Delete resources + await client.send_json( + { + "id": 10, + "type": "lovelace/resources/delete", + "resource_id": first_item["id"], + } + ) + response = await client.receive_json() + assert response["success"] + + await client.send_json({"id": 11, "type": "lovelace/resources"}) + response = await client.receive_json() + assert response["success"] + + assert len(response["result"]) == 2 + assert first_item["id"] not in (item["id"] for item in response["result"]) + async def test_storage_resources_import_invalid(hass, hass_ws_client, hass_storage): """Test importing resources from storage config.""" From f26826d94980c9e5a78ed56e17be6a6a00ed0a86 Mon Sep 17 00:00:00 2001 From: dupondje Date: Thu, 27 Feb 2020 02:02:42 +0100 Subject: [PATCH 126/416] Fix DSMR 5 (#32233) DSMR 5 was broken because some wrong if. if dsmr_version in ("5B"): -> this checks dsmr_version against 5 and B. Not if its 5B. --- homeassistant/components/dsmr/sensor.py | 4 +-- tests/components/dsmr/test_sensor.py | 44 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 6ffc4a3106c..257407bb763 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -96,7 +96,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= # Protocol version specific obis if dsmr_version in ("4", "5"): gas_obis = obis_ref.HOURLY_GAS_METER_READING - elif dsmr_version in ("5B"): + elif dsmr_version in ("5B",): gas_obis = obis_ref.BELGIUM_HOURLY_GAS_METER_READING else: gas_obis = obis_ref.GAS_METER_READING @@ -243,7 +243,7 @@ class DSMREntity(Entity): """Convert 2/1 to normal/low depending on DSMR version.""" # DSMR V5B: Note: In Belgium values are swapped: # Rate code 2 is used for low rate and rate code 1 is used for normal rate. - if dsmr_version in ("5B"): + if dsmr_version in ("5B",): if value == "0001": value = "0002" elif value == "0002": diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 297447b5038..ead9e08d00f 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -188,6 +188,50 @@ async def test_v4_meter(hass, mock_connection_factory): assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS +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, + ) + from dsmr_parser.objects import CosemObject, MBusObject + + config = {"platform": "dsmr", "dsmr_version": "5"} + + telegram = { + HOURLY_GAS_METER_READING: MBusObject( + [ + {"value": datetime.datetime.fromtimestamp(1551642213)}, + {"value": Decimal(745.695), "unit": VOLUME_CUBIC_METERS}, + ] + ), + ELECTRICITY_ACTIVE_TARIFF: CosemObject([{"value": "0001", "unit": ""}]), + } + + with assert_setup_component(1): + await async_setup_component(hass, "sensor", {"sensor": config}) + + telegram_callback = connection_factory.call_args_list[0][0][2] + + # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser + telegram_callback(telegram) + + # after receiving telegram entities need to have the chance to update + await asyncio.sleep(0) + + # tariff should be translated in human readable and have no unit + power_tariff = hass.states.get("sensor.power_tariff") + assert power_tariff.state == "low" + assert power_tariff.attributes.get("unit_of_measurement") == "" + + # check if gas consumption is parsed correctly + gas_consumption = hass.states.get("sensor.gas_consumption") + assert gas_consumption.state == "745.695" + assert gas_consumption.attributes.get("unit_of_measurement") == VOLUME_CUBIC_METERS + + async def test_belgian_meter(hass, mock_connection_factory): """Test if Belgian meter is correctly parsed.""" (connection_factory, transport, protocol) = mock_connection_factory From d207c37c33fc1c2cec16edda5577afd57aae2571 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Thu, 27 Feb 2020 01:10:05 +0000 Subject: [PATCH 127/416] Address homekit_controller feedback on #32212 and #32213 (#32228) --- homeassistant/components/homekit_controller/connection.py | 2 +- tests/components/homekit_controller/common.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 493ef3ccb86..a84c318b6b3 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -125,7 +125,7 @@ class HKDevice: def add_watchable_characteristics(self, characteristics): """Add (aid, iid) pairs that we need to poll.""" self.watchable_characteristics.extend(characteristics) - self.hass.add_job(self.pairing.subscribe(characteristics)) + self.hass.async_create_task(self.pairing.subscribe(characteristics)) def remove_watchable_characteristics(self, accessory_id): """Remove all pollable characteristics by accessory id.""" diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 0a57450a740..f6f2490e48b 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -103,7 +103,7 @@ async def setup_test_accessories(hass, accessories): ) config_entry.add_to_hass(hass) - await config_entry.async_setup(hass) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() return config_entry, pairing From 4c5e364d907960bedd69ecf088a008f1092e5e68 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 Feb 2020 16:48:44 -1000 Subject: [PATCH 128/416] Centralize august activity updates (#32197) * Reduce August doorbell detail updates * Doorbell images now get updates from the activity feed * Tests for activity updates * py-august now provides bridge_is_online for available state * py-august now provides is_standby for available state * py-august now provides get_doorbell_image (eliminate requests) * remove debug * black after merge conflict * Centralize august activity updates * Updates appear significantly more responsive * Should address the community complaints about "lag" * Reduce detail updates (device end points) to one hour interval * Signal entities to update via dispatcher when new activity arrives * Resolves out of sync state (skipped test is now unskipped) * pylint * fix merge conflict * review comments * Remove stray * Address review items that can be done without refactor --- homeassistant/components/august/__init__.py | 117 ++++++--------- homeassistant/components/august/activity.py | 141 ++++++++++++++++++ .../components/august/binary_sensor.py | 86 ++++++++++- homeassistant/components/august/camera.py | 38 ++++- homeassistant/components/august/const.py | 10 +- homeassistant/components/august/lock.py | 39 ++++- homeassistant/components/august/sensor.py | 5 +- tests/components/august/test_binary_sensor.py | 29 ++-- tests/components/august/test_lock.py | 4 +- 9 files changed, 352 insertions(+), 117 deletions(-) create mode 100644 homeassistant/components/august/activity.py diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 6a497920ce3..5774b0b9e9a 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -1,7 +1,6 @@ """Support for August devices.""" import asyncio -from datetime import timedelta -from functools import partial +import itertools import logging from august.api import AugustApiHTTPError @@ -16,10 +15,13 @@ from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import Throttle +from .activity import ActivityStream from .const import ( AUGUST_COMPONENTS, + AUGUST_DEVICE_UPDATE, CONF_ACCESS_TOKEN_CACHE_FILE, CONF_INSTALL_ID, CONF_LOGIN_METHOD, @@ -29,7 +31,6 @@ from .const import ( DEFAULT_TIMEOUT, DOMAIN, LOGIN_METHODS, - MIN_TIME_BETWEEN_ACTIVITY_UPDATES, MIN_TIME_BETWEEN_DETAIL_UPDATES, VERIFICATION_CODE_KEY, ) @@ -40,7 +41,7 @@ _LOGGER = logging.getLogger(__name__) TWO_FA_REVALIDATE = "verify_configurator" -DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) +DEFAULT_SCAN_INTERVAL = MIN_TIME_BETWEEN_DETAIL_UPDATES CONFIG_SCHEMA = vol.Schema( { @@ -133,6 +134,7 @@ async def async_setup_august(hass, config_entry, august_gateway): hass.data[DOMAIN][entry_id][DATA_AUGUST] = await hass.async_add_executor_job( AugustData, hass, august_gateway ) + await hass.data[DOMAIN][entry_id][DATA_AUGUST].activity_stream.async_start() for component in AUGUST_COMPONENTS: hass.async_create_task( @@ -178,6 +180,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" + hass.data[DOMAIN][entry.entry_id][DATA_AUGUST].activity_stream.async_stop() + unload_ok = all( await asyncio.gather( *[ @@ -196,8 +200,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): class AugustData: """August data object.""" - DEFAULT_ACTIVITY_FETCH_LIMIT = 10 - def __init__(self, hass, august_gateway): """Init August data object.""" self._hass = hass @@ -211,12 +213,11 @@ class AugustData: self._api.get_operable_locks(self._august_gateway.access_token) or [] ) self._house_ids = set() - for device in self._doorbells + self._locks: + for device in itertools.chain(self._doorbells, self._locks): self._house_ids.add(device.house_id) self._doorbell_detail_by_id = {} self._lock_detail_by_id = {} - self._activities_by_id = {} # We check the locks right away so we can # remove inoperative ones @@ -224,6 +225,10 @@ class AugustData: self._update_doorbells_detail() self._filter_inoperative_locks() + self.activity_stream = ActivityStream( + hass, self._api, self._august_gateway, self._house_ids + ) + @property def house_ids(self): """Return a list of house_ids.""" @@ -239,49 +244,6 @@ class AugustData: """Return a list of locks.""" return self._locks - async def async_get_device_activities(self, device_id, *activity_types): - """Return a list of activities.""" - _LOGGER.debug("Getting device activities for %s", device_id) - await self._async_update_device_activities() - - activities = self._activities_by_id.get(device_id, []) - if activity_types: - return [a for a in activities if a.activity_type in activity_types] - return activities - - async def async_get_latest_device_activity(self, device_id, *activity_types): - """Return latest activity.""" - activities = await self.async_get_device_activities(device_id, *activity_types) - return next(iter(activities or []), None) - - @Throttle(MIN_TIME_BETWEEN_ACTIVITY_UPDATES) - async def _async_update_device_activities(self, limit=DEFAULT_ACTIVITY_FETCH_LIMIT): - """Update data object with latest from August API.""" - - # This is the only place we refresh the api token - await self._august_gateway.async_refresh_access_token_if_needed() - - return await self._hass.async_add_executor_job( - partial(self._update_device_activities, limit=limit) - ) - - def _update_device_activities(self, limit=DEFAULT_ACTIVITY_FETCH_LIMIT): - _LOGGER.debug("Start retrieving device activities") - for house_id in self.house_ids: - _LOGGER.debug("Updating device activity for house id %s", house_id) - - activities = self._api.get_house_activities( - self._august_gateway.access_token, house_id, limit=limit - ) - - device_ids = {a.device_id for a in activities} - for device_id in device_ids: - self._activities_by_id[device_id] = [ - a for a in activities if a.device_id == device_id - ] - - _LOGGER.debug("Completed retrieving device activities") - async def async_get_device_detail(self, device): """Return the detail for a device.""" if isinstance(device, Lock): @@ -317,11 +279,11 @@ class AugustData: await self._async_update_locks_detail() return self._lock_detail_by_id[device_id] - def get_lock_name(self, device_id): - """Return lock name as August has it stored.""" - for lock in self._locks: - if lock.device_id == device_id: - return lock.device_name + def get_device_name(self, device_id): + """Return doorbell or lock name as August has it stored.""" + for device in itertools.chain(self._locks, self._doorbells): + if device.device_id == device_id: + return device.device_name @Throttle(MIN_TIME_BETWEEN_DETAIL_UPDATES) async def _async_update_locks_detail(self): @@ -354,11 +316,17 @@ class AugustData: _LOGGER.debug("Completed retrieving %s detail", device_type) return detail_by_id + async def async_signal_operation_changed_device_state(self, device_id): + """Signal a device update when an operation changes state.""" + _LOGGER.debug( + "async_dispatcher_send (from operation): AUGUST_DEVICE_UPDATE-%s", device_id + ) + async_dispatcher_send(self._hass, f"{AUGUST_DEVICE_UPDATE}-{device_id}") + def lock(self, device_id): """Lock the device.""" - return _call_api_operation_that_requires_bridge( - self.get_lock_name(device_id), - "lock", + return self._call_api_op_requires_bridge( + device_id, self._api.lock_return_activities, self._august_gateway.access_token, device_id, @@ -366,14 +334,26 @@ class AugustData: def unlock(self, device_id): """Unlock the device.""" - return _call_api_operation_that_requires_bridge( - self.get_lock_name(device_id), - "unlock", + return self._call_api_op_requires_bridge( + device_id, self._api.unlock_return_activities, self._august_gateway.access_token, device_id, ) + def _call_api_op_requires_bridge(self, device_id, func, *args, **kwargs): + """Call an API that requires the bridge to be online and will change the device state.""" + ret = None + try: + ret = func(*args, **kwargs) + except AugustApiHTTPError as err: + device_name = self.get_device_name(device_id) + if device_name is None: + device_name = f"DeviceID: {device_id}" + raise HomeAssistantError(f"{device_name}: {err}") + + return ret + def _filter_inoperative_locks(self): # Remove non-operative locks as there must # be a bridge (August Connect) for them to @@ -400,16 +380,3 @@ class AugustData: operative_locks.append(lock) self._locks = operative_locks - - -def _call_api_operation_that_requires_bridge( - device_name, operation_name, func, *args, **kwargs -): - """Call an API that requires the bridge to be online.""" - ret = None - try: - ret = func(*args, **kwargs) - except AugustApiHTTPError as err: - raise HomeAssistantError(device_name + ": " + str(err)) - - return ret diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py new file mode 100644 index 00000000000..e3d313dc527 --- /dev/null +++ b/homeassistant/components/august/activity.py @@ -0,0 +1,141 @@ +"""Consume the august activity stream.""" +from functools import partial +import logging + +from requests import RequestException + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.dt import utcnow + +from .const import ACTIVITY_UPDATE_INTERVAL, AUGUST_DEVICE_UPDATE + +_LOGGER = logging.getLogger(__name__) + +ACTIVITY_STREAM_FETCH_LIMIT = 10 +ACTIVITY_CATCH_UP_FETCH_LIMIT = 200 + + +class ActivityStream: + """August activity stream handler.""" + + def __init__(self, hass, api, august_gateway, house_ids): + """Init August activity stream object.""" + self._hass = hass + self._august_gateway = august_gateway + self._api = api + self._house_ids = house_ids + self._latest_activities_by_id_type = {} + self._last_update_time = None + self._abort_async_track_time_interval = None + + async def async_start(self): + """Start fetching updates from the activity stream.""" + await self._async_update(utcnow) + self._abort_async_track_time_interval = async_track_time_interval( + self._hass, self._async_update, ACTIVITY_UPDATE_INTERVAL + ) + + @callback + def async_stop(self): + """Stop fetching updates from the activity stream.""" + if self._abort_async_track_time_interval is None: + return + self._abort_async_track_time_interval() + + @callback + def async_get_latest_device_activity(self, device_id, activity_types): + """Return latest activity that is one of the acitivty_types.""" + if device_id not in self._latest_activities_by_id_type: + return None + + latest_device_activities = self._latest_activities_by_id_type[device_id] + latest_activity = None + + for activity_type in activity_types: + if activity_type in latest_device_activities: + if ( + latest_activity is not None + and latest_device_activities[activity_type].activity_start_time + <= latest_activity.activity_start_time + ): + continue + latest_activity = latest_device_activities[activity_type] + + return latest_activity + + async def _async_update(self, time): + """Update the activity stream from August.""" + + # This is the only place we refresh the api token + await self._august_gateway.async_refresh_access_token_if_needed() + await self._update_device_activities(time) + + async def _update_device_activities(self, time): + _LOGGER.debug("Start retrieving device activities") + + limit = ( + ACTIVITY_STREAM_FETCH_LIMIT + if self._last_update_time + else ACTIVITY_CATCH_UP_FETCH_LIMIT + ) + + for house_id in self._house_ids: + _LOGGER.debug("Updating device activity for house id %s", house_id) + try: + activities = await self._hass.async_add_executor_job( + partial( + self._api.get_house_activities, + self._august_gateway.access_token, + house_id, + limit=limit, + ) + ) + except RequestException as ex: + _LOGGER.error( + "Request error trying to retrieve activity for house id %s: %s", + house_id, + ex, + ) + _LOGGER.debug( + "Completed retrieving device activities for house id %s", house_id + ) + + updated_device_ids = self._process_newer_device_activities(activities) + + if updated_device_ids: + for device_id in updated_device_ids: + _LOGGER.debug( + "async_dispatcher_send (from activity stream): AUGUST_DEVICE_UPDATE-%s", + device_id, + ) + async_dispatcher_send( + self._hass, f"{AUGUST_DEVICE_UPDATE}-{device_id}" + ) + + self._last_update_time = time + + def _process_newer_device_activities(self, activities): + updated_device_ids = set() + for activity in activities: + self._latest_activities_by_id_type.setdefault(activity.device_id, {}) + + lastest_activity = self._latest_activities_by_id_type[ + activity.device_id + ].get(activity.activity_type) + + # Ignore activities that are older than the latest one + if ( + lastest_activity + and lastest_activity.activity_start_time >= activity.activity_start_time + ): + continue + + self._latest_activities_by_id_type[activity.device_id][ + activity.activity_type + ] = activity + + updated_device_ids.add(activity.device_id) + + return updated_device_ids diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index c2b5603759d..ea9acd600b2 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -12,12 +12,24 @@ from homeassistant.components.binary_sensor import ( DEVICE_CLASS_OCCUPANCY, BinarySensorDevice, ) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util.dt import utcnow -from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN +from .const import ( + AUGUST_DEVICE_UPDATE, + DATA_AUGUST, + DEFAULT_NAME, + DOMAIN, + MIN_TIME_BETWEEN_DETAIL_UPDATES, +) _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=5) +TIME_TO_DECLARE_DETECTION = timedelta(seconds=60) + +SCAN_INTERVAL = MIN_TIME_BETWEEN_DETAIL_UPDATES async def _async_retrieve_online_state(data, detail): @@ -43,11 +55,13 @@ async def _async_retrieve_ding_state(data, detail): async def _async_activity_time_based_state(data, device_id, activity_types): """Get the latest state of the sensor.""" - latest = await data.async_get_latest_device_activity(device_id, *activity_types) + latest = data.activity_stream.async_get_latest_device_activity( + device_id, activity_types + ) if latest is not None: start = latest.activity_start_time - end = latest.activity_end_time + timedelta(seconds=45) + end = latest.activity_end_time + TIME_TO_DECLARE_DETECTION return start <= datetime.now() <= end return None @@ -98,6 +112,7 @@ class AugustDoorBinarySensor(BinarySensorDevice): def __init__(self, data, sensor_type, door): """Initialize the sensor.""" + self._undo_dispatch_subscription = None self._data = data self._sensor_type = sensor_type self._door = door @@ -128,8 +143,8 @@ class AugustDoorBinarySensor(BinarySensorDevice): async def async_update(self): """Get the latest state of the sensor and update activity.""" - door_activity = await self._data.async_get_latest_device_activity( - self._door.device_id, ActivityType.DOOR_OPERATION + door_activity = self._data.activity_stream.async_get_latest_device_activity( + self._door.device_id, [ActivityType.DOOR_OPERATION] ) detail = await self._data.async_get_lock_detail(self._door.device_id) @@ -162,12 +177,31 @@ class AugustDoorBinarySensor(BinarySensorDevice): "model": self._model, } + async def async_added_to_hass(self): + """Register callbacks.""" + + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._undo_dispatch_subscription = async_dispatcher_connect( + self.hass, f"{AUGUST_DEVICE_UPDATE}-{self._door.device_id}", update + ) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + if self._undo_dispatch_subscription: + self._undo_dispatch_subscription() + class AugustDoorbellBinarySensor(BinarySensorDevice): """Representation of an August binary sensor.""" def __init__(self, data, sensor_type, doorbell): """Initialize the sensor.""" + self._undo_dispatch_subscription = None + self._check_for_off_update_listener = None self._data = data self._sensor_type = sensor_type self._doorbell = doorbell @@ -198,6 +232,7 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): async def async_update(self): """Get the latest state of the sensor.""" + self._cancel_any_pending_updates() async_state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][ SENSOR_STATE_PROVIDER ] @@ -217,6 +252,28 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): self._firmware_version = detail.firmware_version self._model = detail.model self._state = await async_state_provider(self._data, detail) + if self._state and self.device_class != DEVICE_CLASS_CONNECTIVITY: + self._schedule_update_to_recheck_turn_off_sensor() + + def _schedule_update_to_recheck_turn_off_sensor(self): + """Schedule an update to recheck the sensor to see if it is ready to turn off.""" + + @callback + def _scheduled_update(now): + """Timer callback for sensor update.""" + _LOGGER.debug("%s: executing scheduled update", self.entity_id) + self.async_schedule_update_ha_state(True) + self._check_for_off_update_listener = None + + self._check_for_off_update_listener = async_track_point_in_utc_time( + self.hass, _scheduled_update, utcnow() + TIME_TO_DECLARE_DETECTION + ) + + def _cancel_any_pending_updates(self): + """Cancel any updates to recheck a sensor to see if it is ready to turn off.""" + if self._check_for_off_update_listener: + self._check_for_off_update_listener() + self._check_for_off_update_listener = None @property def unique_id(self) -> str: @@ -236,3 +293,20 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): "sw_version": self._firmware_version, "model": self._model, } + + async def async_added_to_hass(self): + """Register callbacks.""" + + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._undo_dispatch_subscription = async_dispatcher_connect( + self.hass, f"{AUGUST_DEVICE_UPDATE}-{self._doorbell.device_id}", update + ) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + if self._undo_dispatch_subscription: + self._undo_dispatch_subscription() diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index 02c3a6b1231..a499c43f0cf 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -1,14 +1,22 @@ -"""Support for August camera.""" -from datetime import timedelta +"""Support for August doorbell camera.""" from august.activity import ActivityType from august.util import update_doorbell_image_from_activity from homeassistant.components.camera import Camera +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import DATA_AUGUST, DEFAULT_NAME, DEFAULT_TIMEOUT, DOMAIN +from .const import ( + AUGUST_DEVICE_UPDATE, + DATA_AUGUST, + DEFAULT_NAME, + DEFAULT_TIMEOUT, + DOMAIN, + MIN_TIME_BETWEEN_DETAIL_UPDATES, +) -SCAN_INTERVAL = timedelta(seconds=5) +SCAN_INTERVAL = MIN_TIME_BETWEEN_DETAIL_UPDATES async def async_setup_entry(hass, config_entry, async_add_entities): @@ -28,6 +36,7 @@ class AugustCamera(Camera): def __init__(self, data, doorbell, timeout): """Initialize a August security camera.""" super().__init__() + self._undo_dispatch_subscription = None self._data = data self._doorbell = doorbell self._doorbell_detail = None @@ -67,8 +76,8 @@ class AugustCamera(Camera): self._doorbell_detail = await self._data.async_get_doorbell_detail( self._doorbell.device_id ) - doorbell_activity = await self._data.async_get_latest_device_activity( - self._doorbell.device_id, ActivityType.DOORBELL_MOTION + doorbell_activity = self._data.activity_stream.async_get_latest_device_activity( + self._doorbell.device_id, [ActivityType.DOORBELL_MOTION] ) if doorbell_activity is not None: @@ -117,3 +126,20 @@ class AugustCamera(Camera): "sw_version": self._firmware_version, "model": self._model, } + + async def async_added_to_hass(self): + """Register callbacks.""" + + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._undo_dispatch_subscription = async_dispatcher_connect( + self.hass, f"{AUGUST_DEVICE_UPDATE}-{self._doorbell.device_id}", update + ) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + if self._undo_dispatch_subscription: + self._undo_dispatch_subscription() diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py index a7ba61efe1f..79ed2d903af 100644 --- a/homeassistant/components/august/const.py +++ b/homeassistant/components/august/const.py @@ -8,6 +8,8 @@ CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file" CONF_LOGIN_METHOD = "login_method" CONF_INSTALL_ID = "install_id" +AUGUST_DEVICE_UPDATE = "august_devices_update" + VERIFICATION_CODE_KEY = "verification_code" NOTIFICATION_ID = "august_notification" @@ -20,16 +22,14 @@ DATA_AUGUST = "data_august" DEFAULT_NAME = "August" DOMAIN = "august" -# Limit battery, online, and hardware updates to 1800 seconds +# Limit battery, online, and hardware updates to hourly # in order to reduce the number of api requests and # avoid hitting rate limits -MIN_TIME_BETWEEN_DETAIL_UPDATES = timedelta(seconds=1800) +MIN_TIME_BETWEEN_DETAIL_UPDATES = timedelta(hours=1) # Activity needs to be checked more frequently as the # doorbell motion and rings are included here -MIN_TIME_BETWEEN_ACTIVITY_UPDATES = timedelta(seconds=10) - -DEFAULT_SCAN_INTERVAL = timedelta(seconds=10) +ACTIVITY_UPDATE_INTERVAL = timedelta(seconds=10) LOGIN_METHODS = ["phone", "email"] diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index c335292ca54..bad5fd78fae 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -1,5 +1,4 @@ """Support for August lock.""" -from datetime import timedelta import logging from august.activity import ActivityType @@ -8,12 +7,20 @@ from august.util import update_lock_detail_from_activity from homeassistant.components.lock import LockDevice from homeassistant.const import ATTR_BATTERY_LEVEL +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN +from .const import ( + AUGUST_DEVICE_UPDATE, + DATA_AUGUST, + DEFAULT_NAME, + DOMAIN, + MIN_TIME_BETWEEN_DETAIL_UPDATES, +) _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=5) +SCAN_INTERVAL = MIN_TIME_BETWEEN_DETAIL_UPDATES async def async_setup_entry(hass, config_entry, async_add_entities): @@ -33,6 +40,7 @@ class AugustLock(LockDevice): def __init__(self, data, lock): """Initialize the lock.""" + self._undo_dispatch_subscription = None self._data = data self._lock = lock self._lock_status = None @@ -58,7 +66,9 @@ class AugustLock(LockDevice): update_lock_detail_from_activity(self._lock_detail, lock_activity) if self._update_lock_status_from_detail(): - self.schedule_update_ha_state() + await self._data.async_signal_operation_changed_device_state( + self._lock.device_id + ) def _update_lock_status_from_detail(self): detail = self._lock_detail @@ -77,8 +87,8 @@ class AugustLock(LockDevice): async def async_update(self): """Get the latest state of the sensor and update activity.""" self._lock_detail = await self._data.async_get_lock_detail(self._lock.device_id) - lock_activity = await self._data.async_get_latest_device_activity( - self._lock.device_id, ActivityType.LOCK_OPERATION + lock_activity = self._data.activity_stream.async_get_latest_device_activity( + self._lock.device_id, [ActivityType.LOCK_OPERATION] ) if lock_activity is not None: @@ -142,3 +152,20 @@ class AugustLock(LockDevice): def unique_id(self) -> str: """Get the unique id of the lock.""" return f"{self._lock.device_id:s}_lock" + + async def async_added_to_hass(self): + """Register callbacks.""" + + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._undo_dispatch_subscription = async_dispatcher_connect( + self.hass, f"{AUGUST_DEVICE_UPDATE}-{self._lock.device_id}", update + ) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + if self._undo_dispatch_subscription: + self._undo_dispatch_subscription() diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 8b54c42352a..3e9bfc5d8de 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -1,11 +1,10 @@ """Support for August sensors.""" -from datetime import timedelta import logging from homeassistant.components.sensor import DEVICE_CLASS_BATTERY from homeassistant.helpers.entity import Entity -from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN +from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES BATTERY_LEVEL_FULL = "Full" BATTERY_LEVEL_MEDIUM = "Medium" @@ -13,7 +12,7 @@ BATTERY_LEVEL_LOW = "Low" _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=5) +SCAN_INTERVAL = MIN_TIME_BETWEEN_DETAIL_UPDATES def _retrieve_device_battery_state(detail): diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 1ecca29985d..565e082f841 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -1,7 +1,5 @@ """The binary_sensor tests for the august platform.""" -import pytest - from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -20,11 +18,6 @@ from tests.components.august.mocks import ( ) -@pytest.mark.skip( - reason="The lock and doorsense can get out of sync due to update intervals, " - + "this is an existing bug which will be fixed with dispatcher events to tell " - + "all linked devices to update." -) async def test_doorsense(hass): """Test creation of a lock with doorsense and bridge.""" lock_one = await _mock_lock_from_fixture( @@ -33,24 +26,32 @@ async def test_doorsense(hass): lock_details = [lock_one] await _create_august_with_devices(hass, lock_details) - binary_sensor_abc_name = hass.states.get("binary_sensor.abc_name_open") - assert binary_sensor_abc_name.state == STATE_ON + binary_sensor_online_with_doorsense_name = hass.states.get( + "binary_sensor.online_with_doorsense_name_open" + ) + assert binary_sensor_online_with_doorsense_name.state == STATE_ON data = {} - data[ATTR_ENTITY_ID] = "lock.abc_name" + data[ATTR_ENTITY_ID] = "lock.online_with_doorsense_name" assert await hass.services.async_call( LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True ) + await hass.async_block_till_done() - binary_sensor_abc_name = hass.states.get("binary_sensor.abc_name_open") - assert binary_sensor_abc_name.state == STATE_ON + binary_sensor_online_with_doorsense_name = hass.states.get( + "binary_sensor.online_with_doorsense_name_open" + ) + assert binary_sensor_online_with_doorsense_name.state == STATE_ON assert await hass.services.async_call( LOCK_DOMAIN, SERVICE_LOCK, data, blocking=True ) + await hass.async_block_till_done() - binary_sensor_abc_name = hass.states.get("binary_sensor.abc_name_open") - assert binary_sensor_abc_name.state == STATE_OFF + binary_sensor_online_with_doorsense_name = hass.states.get( + "binary_sensor.online_with_doorsense_name_open" + ) + assert binary_sensor_online_with_doorsense_name.state == STATE_OFF async def test_create_doorbell(hass): diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 24e0cdafd46..850d9677cfd 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -28,8 +28,8 @@ async def test_lock_device_registry(hass): reg_device = device_registry.async_get_device( identifiers={("august", "online_with_doorsense")}, connections=set() ) - assert "AUG-MD01" == reg_device.model - assert "undefined-4.3.0-1.8.14" == reg_device.sw_version + assert reg_device.model == "AUG-MD01" + assert reg_device.sw_version == "undefined-4.3.0-1.8.14" async def test_one_lock_operation(hass): From df04fe32586d446e645819355c40df55e648f9c4 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 26 Feb 2020 19:52:07 -0700 Subject: [PATCH 129/416] Bump simplisafe-python to 9.0.0 (#32215) --- homeassistant/components/simplisafe/__init__.py | 8 ++------ homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index f51a994efb6..8c75ed5d9f5 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -154,6 +154,7 @@ CONFIG_SCHEMA = vol.Schema( @callback def _async_save_refresh_token(hass, config_entry, token): + """Save a refresh token to the config entry.""" hass.config_entries.async_update_entry( config_entry, data={**config_entry.data, CONF_TOKEN: token} ) @@ -501,12 +502,7 @@ class SimpliSafe: _LOGGER.error("Unknown error while updating: %s", result) return - if self._api.refresh_token_dirty: - # Reconnect the websocket: - await self._api.websocket.async_disconnect() - await self._api.websocket.async_connect() - - # Save the new refresh token: + if self._api.refresh_token != self._config_entry.data[CONF_TOKEN]: _async_save_refresh_token( self._hass, self._config_entry, self._api.refresh_token ) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index e44f39265cb..ad753dfd8b0 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==8.1.1"], + "requirements": ["simplisafe-python==9.0.0"], "dependencies": [], "codeowners": ["@bachya"] } diff --git a/requirements_all.txt b/requirements_all.txt index c56cd516fc9..c24edaf5dc7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1831,7 +1831,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==8.1.1 +simplisafe-python==9.0.0 # homeassistant.components.sisyphus sisyphus-control==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 368c40f0487..91de4ca1689 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -629,7 +629,7 @@ sentry-sdk==0.13.5 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==8.1.1 +simplisafe-python==9.0.0 # homeassistant.components.sleepiq sleepyq==0.7 From 0eb5ca67cdee293064a800b73bae4fc137a77ffc Mon Sep 17 00:00:00 2001 From: Yuki Ueda Date: Thu, 27 Feb 2020 20:19:54 +0900 Subject: [PATCH 130/416] Make heos and transmission config flow tests more robust (#31783) * async_step_user to async_configure * fix for lint * fix for pylint * fix isort and black * fix miss-fixed black * fixing for the python37 coverage * fix transmission definition * fix for Black formatting * fix type to abort * clean up * clean up for the test * fix for the test * refactor * split test_flow_works to three tests * revert the assert * remove whitespaces for flake8 * apply patch function * fix for the patch * fix for the black * remove mock_coro * fix for the black * hue to heos * try to fix import * fix for the black * revert try to fix import * Add a pytest fixture * fix for the black --- tests/components/heos/test_config_flow.py | 51 +++++++++--------- .../transmission/test_config_flow.py | 53 +++++++++++++------ 2 files changed, 63 insertions(+), 41 deletions(-) diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py index 84e5dce1f1c..b83923943bd 100644 --- a/tests/components/heos/test_config_flow.py +++ b/tests/components/heos/test_config_flow.py @@ -1,12 +1,13 @@ """Tests for the Heos config flow module.""" from urllib.parse import urlparse +from asynctest import patch from pyheos import HeosError from homeassistant import data_entry_flow -from homeassistant.components import ssdp +from homeassistant.components import heos, ssdp from homeassistant.components.heos.config_flow import HeosFlowHandler -from homeassistant.components.heos.const import DATA_DISCOVERED_HOSTS, DOMAIN +from homeassistant.components.heos.const import DATA_DISCOVERED_HOSTS from homeassistant.const import CONF_HOST @@ -32,10 +33,10 @@ async def test_no_host_shows_form(hass): async def test_cannot_connect_shows_error_form(hass, controller): """Test form is shown with error when cannot connect.""" - flow = HeosFlowHandler() - flow.hass = hass controller.connect.side_effect = HeosError() - result = await flow.async_step_user({CONF_HOST: "127.0.0.1"}) + result = await hass.config_entries.flow.async_init( + heos.DOMAIN, context={"source": "user"}, data={CONF_HOST: "127.0.0.1"} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["errors"][CONF_HOST] == "connection_failure" @@ -47,36 +48,38 @@ async def test_cannot_connect_shows_error_form(hass, controller): async def test_create_entry_when_host_valid(hass, controller): """Test result type is create entry when host is valid.""" - flow = HeosFlowHandler() - flow.hass = hass data = {CONF_HOST: "127.0.0.1"} - result = await flow.async_step_user(data) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "Controller (127.0.0.1)" - assert result["data"] == data - assert controller.connect.call_count == 1 - assert controller.disconnect.call_count == 1 + with patch("homeassistant.components.heos.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_init( + heos.DOMAIN, context={"source": "user"}, data=data + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Controller (127.0.0.1)" + assert result["data"] == data + assert controller.connect.call_count == 1 + assert controller.disconnect.call_count == 1 async def test_create_entry_when_friendly_name_valid(hass, controller): """Test result type is create entry when friendly name is valid.""" hass.data[DATA_DISCOVERED_HOSTS] = {"Office (127.0.0.1)": "127.0.0.1"} - flow = HeosFlowHandler() - flow.hass = hass data = {CONF_HOST: "Office (127.0.0.1)"} - result = await flow.async_step_user(data) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "Controller (127.0.0.1)" - assert result["data"] == {CONF_HOST: "127.0.0.1"} - assert controller.connect.call_count == 1 - assert controller.disconnect.call_count == 1 - assert DATA_DISCOVERED_HOSTS not in hass.data + with patch("homeassistant.components.heos.async_setup_entry", return_value=True): + result = await hass.config_entries.flow.async_init( + heos.DOMAIN, context={"source": "user"}, data=data + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Controller (127.0.0.1)" + assert result["data"] == {CONF_HOST: "127.0.0.1"} + assert controller.connect.call_count == 1 + assert controller.disconnect.call_count == 1 + assert DATA_DISCOVERED_HOSTS not in hass.data async def test_discovery_shows_create_form(hass, controller, discovery_data): """Test discovery shows form to confirm setup and subsequent abort.""" await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=discovery_data + heos.DOMAIN, context={"source": "ssdp"}, data=discovery_data ) await hass.async_block_till_done() assert len(hass.config_entries.flow.async_progress()) == 1 @@ -86,7 +89,7 @@ async def test_discovery_shows_create_form(hass, controller, discovery_data): discovery_data[ssdp.ATTR_SSDP_LOCATION] = f"http://127.0.0.2:{port}/" discovery_data[ssdp.ATTR_UPNP_FRIENDLY_NAME] = "Bedroom" await hass.config_entries.flow.async_init( - DOMAIN, context={"source": "ssdp"}, data=discovery_data + heos.DOMAIN, context={"source": "ssdp"}, data=discovery_data ) await hass.async_block_till_done() assert len(hass.config_entries.flow.async_progress()) == 1 diff --git a/tests/components/transmission/test_config_flow.py b/tests/components/transmission/test_config_flow.py index 80e6bd55017..bb790b025ea 100644 --- a/tests/components/transmission/test_config_flow.py +++ b/tests/components/transmission/test_config_flow.py @@ -6,12 +6,12 @@ import pytest from transmissionrpc.error import TransmissionError from homeassistant import data_entry_flow +from homeassistant.components import transmission from homeassistant.components.transmission import config_flow from homeassistant.components.transmission.const import ( DEFAULT_NAME, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, - DOMAIN, ) from homeassistant.const import ( CONF_HOST, @@ -73,6 +73,15 @@ def mock_api_unknown_error(): yield +@pytest.fixture(name="transmission_setup", autouse=True) +def transmission_setup_fixture(): + """Mock transmission entry setup.""" + with patch( + "homeassistant.components.transmission.async_setup_entry", return_value=True + ): + yield + + def init_config_flow(hass): """Init a configuration flow.""" flow = config_flow.TransmissionFlowHandler() @@ -80,17 +89,21 @@ def init_config_flow(hass): return flow -async def test_flow_works(hass, api): +async def test_flow_user_config(hass, api): """Test user config.""" - flow = init_config_flow(hass) - - result = await flow.async_step_user() + result = await hass.config_entries.flow.async_init( + transmission.DOMAIN, context={"source": "user"} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - # test with required fields only - result = await flow.async_step_user( - {CONF_NAME: NAME, CONF_HOST: HOST, CONF_PORT: PORT} + +async def test_flow_required_fields(hass, api): + """Test with required fields only.""" + result = await hass.config_entries.flow.async_init( + transmission.DOMAIN, + context={"source": "user"}, + data={CONF_NAME: NAME, CONF_HOST: HOST, CONF_PORT: PORT}, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY @@ -99,8 +112,12 @@ async def test_flow_works(hass, api): assert result["data"][CONF_HOST] == HOST assert result["data"][CONF_PORT] == PORT - # test with all provided - result = await flow.async_step_user(MOCK_ENTRY) + +async def test_flow_all_provided(hass, api): + """Test with all provided.""" + result = await hass.config_entries.flow.async_init( + transmission.DOMAIN, context={"source": "user"}, data=MOCK_ENTRY + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == NAME @@ -114,7 +131,7 @@ async def test_flow_works(hass, api): async def test_options(hass): """Test updating options.""" entry = MockConfigEntry( - domain=DOMAIN, + domain=transmission.DOMAIN, title=CONF_NAME, data=MOCK_ENTRY, options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, @@ -174,13 +191,14 @@ async def test_import(hass, api): async def test_host_already_configured(hass, api): """Test host is already configured.""" entry = MockConfigEntry( - domain=DOMAIN, + domain=transmission.DOMAIN, data=MOCK_ENTRY, options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, ) entry.add_to_hass(hass) - flow = init_config_flow(hass) - result = await flow.async_step_user(MOCK_ENTRY) + result = await hass.config_entries.flow.async_init( + transmission.DOMAIN, context={"source": "user"}, data=MOCK_ENTRY + ) assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -189,7 +207,7 @@ async def test_host_already_configured(hass, api): async def test_name_already_configured(hass, api): """Test name is already configured.""" entry = MockConfigEntry( - domain=DOMAIN, + domain=transmission.DOMAIN, data=MOCK_ENTRY, options={CONF_SCAN_INTERVAL: DEFAULT_SCAN_INTERVAL}, ) @@ -197,8 +215,9 @@ async def test_name_already_configured(hass, api): mock_entry = MOCK_ENTRY.copy() mock_entry[CONF_HOST] = "0.0.0.0" - flow = init_config_flow(hass) - result = await flow.async_step_user(mock_entry) + result = await hass.config_entries.flow.async_init( + transmission.DOMAIN, context={"source": "user"}, data=mock_entry + ) assert result["type"] == "form" assert result["errors"] == {CONF_NAME: "name_exists"} From aab2fd5ec33afbfd346fd2a6f6f73aafb4b785ff Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 27 Feb 2020 12:18:26 -0700 Subject: [PATCH 131/416] Bump simplisafe-python to 9.0.2 (#32273) --- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index ad753dfd8b0..b5f89a65fea 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -3,7 +3,7 @@ "name": "SimpliSafe", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/simplisafe", - "requirements": ["simplisafe-python==9.0.0"], + "requirements": ["simplisafe-python==9.0.2"], "dependencies": [], "codeowners": ["@bachya"] } diff --git a/requirements_all.txt b/requirements_all.txt index c24edaf5dc7..b7ba2c8c22a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1831,7 +1831,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==9.0.0 +simplisafe-python==9.0.2 # homeassistant.components.sisyphus sisyphus-control==2.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 91de4ca1689..ac3d38c6a3f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -629,7 +629,7 @@ sentry-sdk==0.13.5 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==9.0.0 +simplisafe-python==9.0.2 # homeassistant.components.sleepiq sleepyq==0.7 From e695bb55c8101b1addad9f4a774b5ab7d4e8dfbd Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 27 Feb 2020 20:48:01 +0100 Subject: [PATCH 132/416] deCONZ - Race condition on slower systems (#32274) When battery sensors gets created before other platforms loading deconz sensors gets created first the other platform would not create entities related to those battery sensors --- homeassistant/components/deconz/binary_sensor.py | 1 - homeassistant/components/deconz/climate.py | 1 - homeassistant/components/deconz/light.py | 2 +- homeassistant/components/deconz/sensor.py | 1 - 4 files changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 2514a49f23c..6a528a66ba6 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -37,7 +37,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway.option_allow_clip_sensor or not sensor.type.startswith("CLIP") ) - and sensor.deconz_id not in gateway.deconz_ids.values() ): entities.append(DeconzBinarySensor(sensor, gateway)) diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index 34cc0e0b832..7b0f44807ec 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -44,7 +44,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway.option_allow_clip_sensor or not sensor.type.startswith("CLIP") ) - and sensor.deconz_id not in gateway.deconz_ids.values() ): entities.append(DeconzThermostat(sensor, gateway)) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index f62f9315c49..e836f1e4490 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -67,7 +67,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): entities = [] for group in groups: - if group.lights and group.deconz_id not in gateway.deconz_ids.values(): + if group.lights: entities.append(DeconzGroup(group, gateway)) async_add_entities(entities, True) diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 6b88c414243..c32b26f299d 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -68,7 +68,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): gateway.option_allow_clip_sensor or not sensor.type.startswith("CLIP") ) - and sensor.deconz_id not in gateway.deconz_ids.values() ): entities.append(DeconzSensor(sensor, gateway)) From ecd076c5e430f8414987b59287805c309bb35191 Mon Sep 17 00:00:00 2001 From: Jens Nistler Date: Thu, 27 Feb 2020 20:50:34 +0100 Subject: [PATCH 133/416] Mark clients away if they have never been seen. (#32222) --- .../components/unifi/device_tracker.py | 5 +++++ tests/components/unifi/test_device_tracker.py | 17 +++++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 5dd5f0c83ae..52370fb0e3d 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -200,6 +200,11 @@ class UniFiClientTracker(UniFiClient, ScannerEntity): else: self.wired_bug = None + + # A client that has never been seen cannot be connected. + if self.client.last_seen is None: + return False + since_last_seen = dt_util.utcnow() - dt_util.utc_from_timestamp( float(self.client.last_seen) ) diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 608e72b483a..cbef7c31922 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -54,6 +54,14 @@ CLIENT_4 = { "last_seen": 1562600145, "mac": "00:00:00:00:00:04", } +CLIENT_5 = { + "essid": "ssid", + "hostname": "client_5", + "ip": "10.0.0.5", + "is_wired": True, + "last_seen": None, + "mac": "00:00:00:00:00:05", +} DEVICE_1 = { "board_rev": 3, @@ -111,11 +119,11 @@ async def test_tracked_devices(hass): controller = await setup_unifi_integration( hass, options={CONF_SSID_FILTER: ["ssid"]}, - clients_response=[CLIENT_1, CLIENT_2, CLIENT_3, client_4_copy], + clients_response=[CLIENT_1, CLIENT_2, CLIENT_3, CLIENT_5, client_4_copy], devices_response=[DEVICE_1, DEVICE_2], known_wireless_clients=(CLIENT_4["mac"],), ) - assert len(hass.states.async_all()) == 6 + assert len(hass.states.async_all()) == 7 client_1 = hass.states.get("device_tracker.client_1") assert client_1 is not None @@ -134,6 +142,11 @@ async def test_tracked_devices(hass): assert client_4 is not None assert client_4.state == "not_home" + # A client that has never been seen should be marked away. + client_5 = hass.states.get("device_tracker.client_5") + assert client_5 is not None + assert client_5.state == "not_home" + device_1 = hass.states.get("device_tracker.device_1") assert device_1 is not None assert device_1.state == "not_home" From 5a56d0ec1d83e1e74420e96ca6cd078ab3bc54fc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 27 Feb 2020 12:53:36 -0800 Subject: [PATCH 134/416] Catch more Hue errors (#32275) --- homeassistant/components/hue/bridge.py | 13 ++++++++++--- homeassistant/components/hue/light.py | 7 ++++++- homeassistant/components/hue/sensor_base.py | 3 ++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 2c164e5769a..37089e54b00 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -1,6 +1,7 @@ """Code to handle a Hue bridge.""" import asyncio from functools import partial +import logging from aiohttp import client_exceptions import aiohue @@ -24,7 +25,8 @@ SCENE_SCHEMA = vol.Schema( {vol.Required(ATTR_GROUP_NAME): cv.string, vol.Required(ATTR_SCENE_NAME): cv.string} ) # How long should we sleep if the hub is busy -HUB_BUSY_SLEEP = 0.01 +HUB_BUSY_SLEEP = 0.5 +_LOGGER = logging.getLogger(__name__) class HueBridge: @@ -123,9 +125,14 @@ class HueBridge: except ( client_exceptions.ClientOSError, client_exceptions.ClientResponseError, + client_exceptions.ServerDisconnectedError, ) as err: - if tries == 3 or ( - # We only retry if it's a server error. So raise on all 4XX errors. + if tries == 3: + _LOGGER.error("Request failed %s times, giving up.", tries) + raise + + # We only retry if it's a server error. So raise on all 4XX errors. + if ( isinstance(err, client_exceptions.ClientResponseError) and err.status < 500 ): diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 1678dbbfc62..253c0a2069c 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -5,6 +5,7 @@ from functools import partial import logging import random +from aiohttp import client_exceptions import aiohue import async_timeout @@ -172,7 +173,11 @@ async def async_safe_fetch(bridge, fetch_method): except aiohue.Unauthorized: await bridge.handle_unauthorized_error() raise UpdateFailed - except (asyncio.TimeoutError, aiohue.AiohueException): + except ( + asyncio.TimeoutError, + aiohue.AiohueException, + client_exceptions.ClientError, + ): raise UpdateFailed diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py index 0bc7cd53536..ed27cff8eab 100644 --- a/homeassistant/components/hue/sensor_base.py +++ b/homeassistant/components/hue/sensor_base.py @@ -3,6 +3,7 @@ import asyncio from datetime import timedelta import logging +from aiohttp import client_exceptions from aiohue import AiohueException, Unauthorized from aiohue.sensors import TYPE_ZLL_PRESENCE import async_timeout @@ -60,7 +61,7 @@ class SensorManager: except Unauthorized: await self.bridge.handle_unauthorized_error() raise UpdateFailed - except (asyncio.TimeoutError, AiohueException): + except (asyncio.TimeoutError, AiohueException, client_exceptions.ClientError): raise UpdateFailed async def async_register_component(self, binary, async_add_entities): From 75e8d49af1e08fd25ad854840391629920f1fbe4 Mon Sep 17 00:00:00 2001 From: Balazs Sandor Date: Thu, 27 Feb 2020 22:59:30 +0100 Subject: [PATCH 135/416] Show kernel version on linux (#32276) --- homeassistant/helpers/system_info.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py index 7d1d6f2b3e7..26ef3929a3f 100644 --- a/homeassistant/helpers/system_info.py +++ b/homeassistant/helpers/system_info.py @@ -23,14 +23,13 @@ async def async_get_system_info(hass: HomeAssistantType) -> Dict: "arch": platform.machine(), "timezone": str(hass.config.time_zone), "os_name": platform.system(), + "os_version": platform.release(), } if platform.system() == "Windows": info_object["os_version"] = platform.win32_ver()[0] elif platform.system() == "Darwin": info_object["os_version"] = platform.mac_ver()[0] - elif platform.system() == "FreeBSD": - info_object["os_version"] = platform.release() elif platform.system() == "Linux": info_object["docker"] = os.path.isfile("/.dockerenv") From 5bdf450f786897e79a2bb1f107e362562570d792 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 27 Feb 2020 22:44:02 +0000 Subject: [PATCH 136/416] Bump pyipma dependency (fixes bug in 0.106) (#32286) * Bump version * Bump PyIPMA version --- homeassistant/components/ipma/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json index 02d4e459f72..1457ac24195 100644 --- a/homeassistant/components/ipma/manifest.json +++ b/homeassistant/components/ipma/manifest.json @@ -3,7 +3,7 @@ "name": "Instituto Português do Mar e Atmosfera (IPMA)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ipma", - "requirements": ["pyipma==2.0.3"], + "requirements": ["pyipma==2.0.4"], "dependencies": [], "codeowners": ["@dgomes", "@abmantis"] } diff --git a/requirements_all.txt b/requirements_all.txt index b7ba2c8c22a..5e4d37c2ba8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1309,7 +1309,7 @@ pyicloud==0.9.2 pyintesishome==1.6 # homeassistant.components.ipma -pyipma==2.0.3 +pyipma==2.0.4 # homeassistant.components.iqvia pyiqvia==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac3d38c6a3f..c16dbdebb4e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -477,7 +477,7 @@ pyhomematic==0.1.65 pyicloud==0.9.2 # homeassistant.components.ipma -pyipma==2.0.3 +pyipma==2.0.4 # homeassistant.components.iqvia pyiqvia==0.2.1 From 9329bc4ca0a31613952d0df95b6f3376ed15e6e2 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 27 Feb 2020 17:24:14 -0700 Subject: [PATCH 137/416] Remove ability to configure monitored conditions in IQVIA (#32223) * Remove ability to configure monitored conditions in IQVIA * Deprecate instead of removing (until 0.108.0) * Simplify imports * Update version --- homeassistant/components/iqvia/__init__.py | 27 +++++++++++----------- homeassistant/components/iqvia/sensor.py | 2 +- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index 512153fe1c2..3e62eb9b1ee 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -59,13 +59,16 @@ FETCHER_MAPPING = { CONFIG_SCHEMA = vol.Schema( { - DOMAIN: vol.Schema( - { - vol.Required(CONF_ZIP_CODE): str, - vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( - cv.ensure_list, [vol.In(SENSORS)] - ), - } + DOMAIN: vol.All( + cv.deprecated(CONF_MONITORED_CONDITIONS, invalidation_version="0.114.0"), + vol.Schema( + { + vol.Required(CONF_ZIP_CODE): str, + vol.Optional( + CONF_MONITORED_CONDITIONS, default=list(SENSORS) + ): vol.All(cv.ensure_list, [vol.In(SENSORS)]), + } + ), ) }, extra=vol.ALLOW_EXTRA, @@ -100,10 +103,7 @@ async def async_setup_entry(hass, config_entry): websession = aiohttp_client.async_get_clientsession(hass) try: - iqvia = IQVIAData( - Client(config_entry.data[CONF_ZIP_CODE], websession), - config_entry.data.get(CONF_MONITORED_CONDITIONS, list(SENSORS)), - ) + iqvia = IQVIAData(Client(config_entry.data[CONF_ZIP_CODE], websession)) await iqvia.async_update() except InvalidZipError: _LOGGER.error("Invalid ZIP code provided: %s", config_entry.data[CONF_ZIP_CODE]) @@ -143,11 +143,10 @@ async def async_unload_entry(hass, config_entry): class IQVIAData: """Define a data object to retrieve info from IQVIA.""" - def __init__(self, client, sensor_types): + def __init__(self, client): """Initialize.""" self._client = client self.data = {} - self.sensor_types = sensor_types self.zip_code = client.zip_code self.fetchers = Registry() @@ -164,7 +163,7 @@ class IQVIAData: tasks = {} for conditions, fetcher_types in FETCHER_MAPPING.items(): - if not any(c in self.sensor_types for c in conditions): + if not any(c in SENSORS for c in conditions): continue for fetcher_type in fetcher_types: diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index 24ccfa9cdbf..e093556b810 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -66,7 +66,7 @@ async def async_setup_entry(hass, entry, async_add_entities): } sensors = [] - for sensor_type in iqvia.sensor_types: + for sensor_type in SENSORS: klass = sensor_class_mapping[sensor_type] name, icon = SENSORS[sensor_type] sensors.append(klass(iqvia, sensor_type, name, icon, iqvia.zip_code)) From fefbe02d44d6ab6662a8b24c3320664b429f3a9a Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Fri, 28 Feb 2020 00:31:47 +0000 Subject: [PATCH 138/416] [ci skip] Translation update --- .../components/abode/.translations/lv.json | 12 +++++++++ .../components/august/.translations/lv.json | 12 +++++++++ .../components/deconz/.translations/lv.json | 12 +++++++++ .../components/demo/.translations/lv.json | 5 ++++ .../components/elgato/.translations/lv.json | 11 ++++++++ .../garmin_connect/.translations/lv.json | 12 +++++++++ .../huawei_lte/.translations/lv.json | 13 ++++++++++ .../iaqualink/.translations/lv.json | 11 ++++++++ .../components/icloud/.translations/lv.json | 18 +++++++++++++ .../components/ipma/.translations/lv.json | 11 ++++++++ .../components/light/.translations/lv.json | 4 +++ .../components/light/.translations/no.json | 2 ++ .../light/.translations/zh-Hant.json | 2 ++ .../components/linky/.translations/lv.json | 11 ++++++++ .../meteo_france/.translations/lv.json | 14 +++++++++++ .../components/mikrotik/.translations/lv.json | 16 ++++++++++++ .../minecraft_server/.translations/lv.json | 12 +++++++++ .../components/mqtt/.translations/lv.json | 14 +++++++++++ .../components/neato/.translations/lv.json | 14 +++++++++++ .../opentherm_gw/.translations/lv.json | 12 +++++++++ .../components/plex/.translations/lv.json | 25 +++++++++++++++++++ .../components/ring/.translations/lv.json | 12 +++++++++ .../components/sense/.translations/lv.json | 15 +++++++++++ .../components/soma/.translations/lv.json | 11 ++++++++ .../components/spotify/.translations/lv.json | 5 ++++ .../components/starline/.translations/lv.json | 11 ++++++++ .../components/tesla/.translations/lv.json | 12 +++++++++ .../components/vesync/.translations/lv.json | 12 +++++++++ .../components/withings/.translations/lv.json | 13 ++++++++++ 29 files changed, 334 insertions(+) create mode 100644 homeassistant/components/abode/.translations/lv.json create mode 100644 homeassistant/components/august/.translations/lv.json create mode 100644 homeassistant/components/deconz/.translations/lv.json create mode 100644 homeassistant/components/demo/.translations/lv.json create mode 100644 homeassistant/components/elgato/.translations/lv.json create mode 100644 homeassistant/components/garmin_connect/.translations/lv.json create mode 100644 homeassistant/components/huawei_lte/.translations/lv.json create mode 100644 homeassistant/components/iaqualink/.translations/lv.json create mode 100644 homeassistant/components/icloud/.translations/lv.json create mode 100644 homeassistant/components/ipma/.translations/lv.json create mode 100644 homeassistant/components/linky/.translations/lv.json create mode 100644 homeassistant/components/meteo_france/.translations/lv.json create mode 100644 homeassistant/components/mikrotik/.translations/lv.json create mode 100644 homeassistant/components/minecraft_server/.translations/lv.json create mode 100644 homeassistant/components/mqtt/.translations/lv.json create mode 100644 homeassistant/components/neato/.translations/lv.json create mode 100644 homeassistant/components/opentherm_gw/.translations/lv.json create mode 100644 homeassistant/components/plex/.translations/lv.json create mode 100644 homeassistant/components/ring/.translations/lv.json create mode 100644 homeassistant/components/sense/.translations/lv.json create mode 100644 homeassistant/components/soma/.translations/lv.json create mode 100644 homeassistant/components/spotify/.translations/lv.json create mode 100644 homeassistant/components/starline/.translations/lv.json create mode 100644 homeassistant/components/tesla/.translations/lv.json create mode 100644 homeassistant/components/vesync/.translations/lv.json create mode 100644 homeassistant/components/withings/.translations/lv.json diff --git a/homeassistant/components/abode/.translations/lv.json b/homeassistant/components/abode/.translations/lv.json new file mode 100644 index 00000000000..eab98211e14 --- /dev/null +++ b/homeassistant/components/abode/.translations/lv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parole", + "username": "E-pasta adrese" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/august/.translations/lv.json b/homeassistant/components/august/.translations/lv.json new file mode 100644 index 00000000000..b2afeaf0874 --- /dev/null +++ b/homeassistant/components/august/.translations/lv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "login_method": "Pieteik\u0161an\u0101s metode", + "password": "Parole" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/lv.json b/homeassistant/components/deconz/.translations/lv.json new file mode 100644 index 00000000000..aceb121a360 --- /dev/null +++ b/homeassistant/components/deconz/.translations/lv.json @@ -0,0 +1,12 @@ +{ + "device_automation": { + "trigger_subtype": { + "both_buttons": "Abas pogas", + "button_1": "Pirm\u0101 poga", + "button_2": "Otr\u0101 poga", + "button_3": "Tre\u0161\u0101 poga", + "turn_off": "Izsl\u0113gt", + "turn_on": "Iesl\u0113gt" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/demo/.translations/lv.json b/homeassistant/components/demo/.translations/lv.json new file mode 100644 index 00000000000..b7bbb906508 --- /dev/null +++ b/homeassistant/components/demo/.translations/lv.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Demonstr\u0101cija" + } +} \ No newline at end of file diff --git a/homeassistant/components/elgato/.translations/lv.json b/homeassistant/components/elgato/.translations/lv.json new file mode 100644 index 00000000000..5babfa037ac --- /dev/null +++ b/homeassistant/components/elgato/.translations/lv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "port": "Porta numurs" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/garmin_connect/.translations/lv.json b/homeassistant/components/garmin_connect/.translations/lv.json new file mode 100644 index 00000000000..2c205bdd324 --- /dev/null +++ b/homeassistant/components/garmin_connect/.translations/lv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parole", + "username": "Lietot\u0101jv\u0101rds" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/huawei_lte/.translations/lv.json b/homeassistant/components/huawei_lte/.translations/lv.json new file mode 100644 index 00000000000..e276ee03f24 --- /dev/null +++ b/homeassistant/components/huawei_lte/.translations/lv.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parole", + "url": "URL", + "username": "Lietot\u0101jv\u0101rds" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/lv.json b/homeassistant/components/iaqualink/.translations/lv.json new file mode 100644 index 00000000000..0173f3373ad --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/lv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parole" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/icloud/.translations/lv.json b/homeassistant/components/icloud/.translations/lv.json new file mode 100644 index 00000000000..6e642b85933 --- /dev/null +++ b/homeassistant/components/icloud/.translations/lv.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "trusted_device": { + "data": { + "trusted_device": "Uzticama ier\u012bce" + } + }, + "user": { + "data": { + "password": "Parole", + "username": "E-pasts" + } + } + }, + "title": "Apple iCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/ipma/.translations/lv.json b/homeassistant/components/ipma/.translations/lv.json new file mode 100644 index 00000000000..7ee56d5a419 --- /dev/null +++ b/homeassistant/components/ipma/.translations/lv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "mode": "Re\u017e\u012bms" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/lv.json b/homeassistant/components/light/.translations/lv.json index 7668dfa5ac8..1436829ee9a 100644 --- a/homeassistant/components/light/.translations/lv.json +++ b/homeassistant/components/light/.translations/lv.json @@ -1,5 +1,9 @@ { "device_automation": { + "action_type": { + "brightness_decrease": "Samazin\u0101t {entity_name} spilgtumu", + "brightness_increase": "Palielin\u0101t {entity_name} spilgtumu" + }, "trigger_type": { "turned_off": "{entity_name} tika izsl\u0113gta", "turned_on": "{entity_name} tika iesl\u0113gta" diff --git a/homeassistant/components/light/.translations/no.json b/homeassistant/components/light/.translations/no.json index 785e9ca2912..e7901ba51bc 100644 --- a/homeassistant/components/light/.translations/no.json +++ b/homeassistant/components/light/.translations/no.json @@ -1,6 +1,8 @@ { "device_automation": { "action_type": { + "brightness_decrease": "Reduser lysstyrken p\u00e5 {entity_name}", + "brightness_increase": "\u00d8k lysstyrken p\u00e5 {entity_name}", "toggle": "Veksle {entity_name}", "turn_off": "Sl\u00e5 av {entity_name}", "turn_on": "Sl\u00e5 p\u00e5 {entity_name}" diff --git a/homeassistant/components/light/.translations/zh-Hant.json b/homeassistant/components/light/.translations/zh-Hant.json index d8bda90de85..228074abf06 100644 --- a/homeassistant/components/light/.translations/zh-Hant.json +++ b/homeassistant/components/light/.translations/zh-Hant.json @@ -1,6 +1,8 @@ { "device_automation": { "action_type": { + "brightness_decrease": "\u964d\u4f4e{entity_name}\u4eae\u5ea6", + "brightness_increase": "\u589e\u52a0{entity_name}\u4eae\u5ea6", "toggle": "\u5207\u63db{entity_name}", "turn_off": "\u95dc\u9589{entity_name}", "turn_on": "\u958b\u555f{entity_name}" diff --git a/homeassistant/components/linky/.translations/lv.json b/homeassistant/components/linky/.translations/lv.json new file mode 100644 index 00000000000..973833a5470 --- /dev/null +++ b/homeassistant/components/linky/.translations/lv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "E-pasts" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/meteo_france/.translations/lv.json b/homeassistant/components/meteo_france/.translations/lv.json new file mode 100644 index 00000000000..478931242df --- /dev/null +++ b/homeassistant/components/meteo_france/.translations/lv.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Pils\u0113ta jau ir konfigur\u0113ta" + }, + "step": { + "user": { + "data": { + "city": "Pils\u0113ta" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mikrotik/.translations/lv.json b/homeassistant/components/mikrotik/.translations/lv.json new file mode 100644 index 00000000000..232c7d16173 --- /dev/null +++ b/homeassistant/components/mikrotik/.translations/lv.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parole", + "port": "Ports", + "username": "Lietot\u0101jv\u0101rds", + "verify_ssl": "Izmantot SSL" + }, + "title": "Iestat\u012bt Mikrotik mar\u0161rut\u0113t\u0101ju" + } + }, + "title": "Mikrotik" + } +} \ No newline at end of file diff --git a/homeassistant/components/minecraft_server/.translations/lv.json b/homeassistant/components/minecraft_server/.translations/lv.json new file mode 100644 index 00000000000..7de2aaadfc8 --- /dev/null +++ b/homeassistant/components/minecraft_server/.translations/lv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "name": "Nosaukums", + "port": "Ports" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/lv.json b/homeassistant/components/mqtt/.translations/lv.json new file mode 100644 index 00000000000..2ff60e6ad84 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/lv.json @@ -0,0 +1,14 @@ +{ + "device_automation": { + "trigger_subtype": { + "button_1": "Pirm\u0101 poga", + "button_2": "Otr\u0101 poga", + "button_3": "Tre\u0161\u0101 poga", + "button_4": "Ceturt\u0101 poga", + "button_5": "Piekt\u0101 poga", + "button_6": "Sest\u0101 poga", + "turn_off": "Iesl\u0113gt", + "turn_on": "Iesl\u0113gt" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/neato/.translations/lv.json b/homeassistant/components/neato/.translations/lv.json new file mode 100644 index 00000000000..26b0bcb7fd2 --- /dev/null +++ b/homeassistant/components/neato/.translations/lv.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parole", + "username": "Lietot\u0101jv\u0101rds" + }, + "title": "Neato konta inform\u0101cija" + } + }, + "title": "Neato" + } +} \ No newline at end of file diff --git a/homeassistant/components/opentherm_gw/.translations/lv.json b/homeassistant/components/opentherm_gw/.translations/lv.json new file mode 100644 index 00000000000..2c146e9d563 --- /dev/null +++ b/homeassistant/components/opentherm_gw/.translations/lv.json @@ -0,0 +1,12 @@ +{ + "options": { + "step": { + "init": { + "data": { + "floor_temperature": "Gr\u012bdas temperat\u016bra", + "precision": "Precizit\u0101te" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/lv.json b/homeassistant/components/plex/.translations/lv.json new file mode 100644 index 00000000000..23cda3fce4b --- /dev/null +++ b/homeassistant/components/plex/.translations/lv.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "already_in_progress": "Plex tiek konfigur\u0113ts" + }, + "error": { + "not_found": "Plex serveris nav atrasts" + }, + "step": { + "manual_setup": { + "data": { + "port": "Ports", + "ssl": "Izmantot SSL", + "verify_ssl": "P\u0101rbaud\u012bt SSL sertifik\u0101tu" + }, + "title": "Plex serveris" + }, + "select_server": { + "data": { + "server": "Serveris" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ring/.translations/lv.json b/homeassistant/components/ring/.translations/lv.json new file mode 100644 index 00000000000..2c205bdd324 --- /dev/null +++ b/homeassistant/components/ring/.translations/lv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parole", + "username": "Lietot\u0101jv\u0101rds" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/sense/.translations/lv.json b/homeassistant/components/sense/.translations/lv.json new file mode 100644 index 00000000000..85a6742da50 --- /dev/null +++ b/homeassistant/components/sense/.translations/lv.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "unknown": "Neparedz\u0113ta k\u013c\u016bda" + }, + "step": { + "user": { + "data": { + "email": "E-pasta adrese", + "password": "Parole" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/soma/.translations/lv.json b/homeassistant/components/soma/.translations/lv.json new file mode 100644 index 00000000000..a151694b1df --- /dev/null +++ b/homeassistant/components/soma/.translations/lv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "port": "Ports" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/spotify/.translations/lv.json b/homeassistant/components/spotify/.translations/lv.json new file mode 100644 index 00000000000..2721c5fdffa --- /dev/null +++ b/homeassistant/components/spotify/.translations/lv.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Spotify" + } +} \ No newline at end of file diff --git a/homeassistant/components/starline/.translations/lv.json b/homeassistant/components/starline/.translations/lv.json new file mode 100644 index 00000000000..2b01445d8cb --- /dev/null +++ b/homeassistant/components/starline/.translations/lv.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "auth_captcha": { + "data": { + "captcha_code": "Kods no att\u0113la" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tesla/.translations/lv.json b/homeassistant/components/tesla/.translations/lv.json new file mode 100644 index 00000000000..eab98211e14 --- /dev/null +++ b/homeassistant/components/tesla/.translations/lv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parole", + "username": "E-pasta adrese" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vesync/.translations/lv.json b/homeassistant/components/vesync/.translations/lv.json new file mode 100644 index 00000000000..eab98211e14 --- /dev/null +++ b/homeassistant/components/vesync/.translations/lv.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "Parole", + "username": "E-pasta adrese" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/lv.json b/homeassistant/components/withings/.translations/lv.json new file mode 100644 index 00000000000..3f7cf20fdb4 --- /dev/null +++ b/homeassistant/components/withings/.translations/lv.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "profile": "Profils" + }, + "title": "Lietot\u0101ja profils." + } + }, + "title": "Withings" + } +} \ No newline at end of file From 223c01d8428d15611f168ed08e25c674f3ce6d42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2020 17:44:23 -1000 Subject: [PATCH 139/416] Coordinate all august detail and activity updates (#32249) * Removing polling from august * Now using subscribers to the detail and activity * Fix hash to list keys * continue to the next house if one fails * Add async_signal_device_id_update * Fix double initial update * Handle self.hass not being available until after async_added_to_hass * Remove not needed await * Fix regression with device name --- homeassistant/components/august/__init__.py | 213 ++++++++---------- homeassistant/components/august/activity.py | 43 ++-- .../components/august/binary_sensor.py | 211 +++++++---------- homeassistant/components/august/camera.py | 100 ++------ homeassistant/components/august/const.py | 2 - homeassistant/components/august/entity.py | 67 ++++++ homeassistant/components/august/lock.py | 102 +++------ homeassistant/components/august/sensor.py | 42 +--- homeassistant/components/august/subscriber.py | 53 +++++ tests/components/august/test_binary_sensor.py | 6 +- tests/components/august/test_camera.py | 4 +- tests/components/august/test_lock.py | 2 + 12 files changed, 385 insertions(+), 460 deletions(-) create mode 100644 homeassistant/components/august/entity.py create mode 100644 homeassistant/components/august/subscriber.py diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 5774b0b9e9a..b3cbc161dda 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -5,8 +5,6 @@ import logging from august.api import AugustApiHTTPError from august.authenticator import ValidationResult -from august.doorbell import Doorbell -from august.lock import Lock from requests import RequestException import voluptuous as vol @@ -15,13 +13,10 @@ from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.util import Throttle from .activity import ActivityStream from .const import ( AUGUST_COMPONENTS, - AUGUST_DEVICE_UPDATE, CONF_ACCESS_TOKEN_CACHE_FILE, CONF_INSTALL_ID, CONF_LOGIN_METHOD, @@ -36,13 +31,12 @@ from .const import ( ) from .exceptions import InvalidAuth, RequireValidation from .gateway import AugustGateway +from .subscriber import AugustSubscriberMixin _LOGGER = logging.getLogger(__name__) TWO_FA_REVALIDATE = "verify_configurator" -DEFAULT_SCAN_INTERVAL = MIN_TIME_BETWEEN_DETAIL_UPDATES - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -134,7 +128,7 @@ async def async_setup_august(hass, config_entry, august_gateway): hass.data[DOMAIN][entry_id][DATA_AUGUST] = await hass.async_add_executor_job( AugustData, hass, august_gateway ) - await hass.data[DOMAIN][entry_id][DATA_AUGUST].activity_stream.async_start() + await hass.data[DOMAIN][entry_id][DATA_AUGUST].activity_stream.async_setup() for component in AUGUST_COMPONENTS: hass.async_create_task( @@ -180,8 +174,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" - hass.data[DOMAIN][entry.entry_id][DATA_AUGUST].activity_stream.async_stop() - unload_ok = all( await asyncio.gather( *[ @@ -197,131 +189,103 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): return unload_ok -class AugustData: +class AugustData(AugustSubscriberMixin): """August data object.""" def __init__(self, hass, august_gateway): """Init August data object.""" + super().__init__(hass, MIN_TIME_BETWEEN_DETAIL_UPDATES) self._hass = hass self._august_gateway = august_gateway self._api = august_gateway.api + self._device_detail_by_id = {} - self._doorbells = ( - self._api.get_doorbells(self._august_gateway.access_token) or [] + locks = self._api.get_operable_locks(self._august_gateway.access_token) or [] + doorbells = self._api.get_doorbells(self._august_gateway.access_token) or [] + + self._doorbells_by_id = dict((device.device_id, device) for device in doorbells) + self._locks_by_id = dict((device.device_id, device) for device in locks) + self._house_ids = set( + device.house_id for device in itertools.chain(locks, doorbells) ) - self._locks = ( - self._api.get_operable_locks(self._august_gateway.access_token) or [] + + self._refresh_device_detail_by_ids( + [device.device_id for device in itertools.chain(locks, doorbells)] ) - self._house_ids = set() - for device in itertools.chain(self._doorbells, self._locks): - self._house_ids.add(device.house_id) - self._doorbell_detail_by_id = {} - self._lock_detail_by_id = {} - - # We check the locks right away so we can - # remove inoperative ones - self._update_locks_detail() - self._update_doorbells_detail() - self._filter_inoperative_locks() + # We remove all devices that we are missing + # detail as we cannot determine if they are usable. + # This also allows us to avoid checking for + # detail being None all over the place + self._remove_inoperative_locks() + self._remove_inoperative_doorbells() self.activity_stream = ActivityStream( hass, self._api, self._august_gateway, self._house_ids ) - @property - def house_ids(self): - """Return a list of house_ids.""" - return self._house_ids - @property def doorbells(self): - """Return a list of doorbells.""" - return self._doorbells + """Return a list of py-august Doorbell objects.""" + return self._doorbells_by_id.values() @property def locks(self): - """Return a list of locks.""" - return self._locks + """Return a list of py-august Lock objects.""" + return self._locks_by_id.values() - async def async_get_device_detail(self, device): - """Return the detail for a device.""" - if isinstance(device, Lock): - return await self.async_get_lock_detail(device.device_id) - if isinstance(device, Doorbell): - return await self.async_get_doorbell_detail(device.device_id) - raise ValueError + def get_device_detail(self, device_id): + """Return the py-august LockDetail or DoorbellDetail object for a device.""" + return self._device_detail_by_id[device_id] - async def async_get_doorbell_detail(self, device_id): - """Return doorbell detail.""" - await self._async_update_doorbells_detail() - return self._doorbell_detail_by_id.get(device_id) + def _refresh(self, time): + self._refresh_device_detail_by_ids(self._subscriptions.keys()) - @Throttle(MIN_TIME_BETWEEN_DETAIL_UPDATES) - async def _async_update_doorbells_detail(self): - await self._hass.async_add_executor_job(self._update_doorbells_detail) - - def _update_doorbells_detail(self): - self._doorbell_detail_by_id = self._update_device_detail( - "doorbell", self._doorbells, self._api.get_doorbell_detail - ) - - def lock_has_doorsense(self, device_id): - """Determine if a lock has doorsense installed and can tell when the door is open or closed.""" - # We do not update here since this is not expected - # to change until restart - if self._lock_detail_by_id[device_id] is None: - return False - return self._lock_detail_by_id[device_id].doorsense - - async def async_get_lock_detail(self, device_id): - """Return lock detail.""" - await self._async_update_locks_detail() - return self._lock_detail_by_id[device_id] - - def get_device_name(self, device_id): - """Return doorbell or lock name as August has it stored.""" - for device in itertools.chain(self._locks, self._doorbells): - if device.device_id == device_id: - return device.device_name - - @Throttle(MIN_TIME_BETWEEN_DETAIL_UPDATES) - async def _async_update_locks_detail(self): - await self._hass.async_add_executor_job(self._update_locks_detail) - - def _update_locks_detail(self): - self._lock_detail_by_id = self._update_device_detail( - "lock", self._locks, self._api.get_lock_detail - ) - - def _update_device_detail(self, device_type, devices, api_call): - detail_by_id = {} - - _LOGGER.debug("Start retrieving %s detail", device_type) - for device in devices: - device_id = device.device_id - detail_by_id[device_id] = None - try: - detail_by_id[device_id] = api_call( - self._august_gateway.access_token, device_id + def _refresh_device_detail_by_ids(self, device_ids_list): + for device_id in device_ids_list: + if device_id in self._locks_by_id: + self._update_device_detail( + self._locks_by_id[device_id], self._api.get_lock_detail ) - except RequestException as ex: - _LOGGER.error( - "Request error trying to retrieve %s details for %s. %s", - device_type, - device.device_name, - ex, + elif device_id in self._doorbells_by_id: + self._update_device_detail( + self._doorbells_by_id[device_id], self._api.get_doorbell_detail ) + _LOGGER.debug( + "signal_device_id_update (from detail updates): %s", device_id, + ) + self.signal_device_id_update(device_id) - _LOGGER.debug("Completed retrieving %s detail", device_type) - return detail_by_id - - async def async_signal_operation_changed_device_state(self, device_id): - """Signal a device update when an operation changes state.""" + def _update_device_detail(self, device, api_call): _LOGGER.debug( - "async_dispatcher_send (from operation): AUGUST_DEVICE_UPDATE-%s", device_id + "Started retrieving detail for %s (%s)", + device.device_name, + device.device_id, ) - async_dispatcher_send(self._hass, f"{AUGUST_DEVICE_UPDATE}-{device_id}") + + try: + self._device_detail_by_id[device.device_id] = api_call( + self._august_gateway.access_token, device.device_id + ) + except RequestException as ex: + _LOGGER.error( + "Request error trying to retrieve %s details for %s. %s", + device.device_id, + device.device_name, + ex, + ) + _LOGGER.debug( + "Completed retrieving detail for %s (%s)", + device.device_name, + device.device_id, + ) + + def _get_device_name(self, device_id): + """Return doorbell or lock name as August has it stored.""" + if self._locks_by_id.get(device_id): + return self._locks_by_id[device_id].device_name + if self._doorbells_by_id.get(device_id): + return self._doorbells_by_id[device_id].device_name def lock(self, device_id): """Lock the device.""" @@ -347,20 +311,41 @@ class AugustData: try: ret = func(*args, **kwargs) except AugustApiHTTPError as err: - device_name = self.get_device_name(device_id) + device_name = self._get_device_name(device_id) if device_name is None: device_name = f"DeviceID: {device_id}" raise HomeAssistantError(f"{device_name}: {err}") return ret - def _filter_inoperative_locks(self): + def _remove_inoperative_doorbells(self): + doorbells = list(self.doorbells) + for doorbell in doorbells: + device_id = doorbell.device_id + doorbell_is_operative = False + doorbell_detail = self._device_detail_by_id.get(device_id) + if doorbell_detail is None: + _LOGGER.info( + "The doorbell %s could not be setup because the system could not fetch details about the doorbell.", + doorbell.device_name, + ) + else: + doorbell_is_operative = True + + if not doorbell_is_operative: + del self._doorbells_by_id[device_id] + del self._device_detail_by_id[device_id] + + def _remove_inoperative_locks(self): # Remove non-operative locks as there must # be a bridge (August Connect) for them to # be usable - operative_locks = [] - for lock in self._locks: - lock_detail = self._lock_detail_by_id.get(lock.device_id) + locks = list(self.locks) + + for lock in locks: + device_id = lock.device_id + lock_is_operative = False + lock_detail = self._device_detail_by_id.get(device_id) if lock_detail is None: _LOGGER.info( "The lock %s could not be setup because the system could not fetch details about the lock.", @@ -377,6 +362,8 @@ class AugustData: lock.device_name, ) else: - operative_locks.append(lock) + lock_is_operative = True - self._locks = operative_locks + if not lock_is_operative: + del self._locks_by_id[device_id] + del self._device_detail_by_id[device_id] diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index e3d313dc527..c65083363a4 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -4,12 +4,10 @@ import logging from requests import RequestException -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.dt import utcnow -from .const import ACTIVITY_UPDATE_INTERVAL, AUGUST_DEVICE_UPDATE +from .const import ACTIVITY_UPDATE_INTERVAL +from .subscriber import AugustSubscriberMixin _LOGGER = logging.getLogger(__name__) @@ -17,11 +15,12 @@ ACTIVITY_STREAM_FETCH_LIMIT = 10 ACTIVITY_CATCH_UP_FETCH_LIMIT = 200 -class ActivityStream: +class ActivityStream(AugustSubscriberMixin): """August activity stream handler.""" def __init__(self, hass, api, august_gateway, house_ids): """Init August activity stream object.""" + super().__init__(hass, ACTIVITY_UPDATE_INTERVAL) self._hass = hass self._august_gateway = august_gateway self._api = api @@ -30,22 +29,11 @@ class ActivityStream: self._last_update_time = None self._abort_async_track_time_interval = None - async def async_start(self): - """Start fetching updates from the activity stream.""" - await self._async_update(utcnow) - self._abort_async_track_time_interval = async_track_time_interval( - self._hass, self._async_update, ACTIVITY_UPDATE_INTERVAL - ) + async def async_setup(self): + """Token refresh check and catch up the activity stream.""" + await self._refresh(utcnow) - @callback - def async_stop(self): - """Stop fetching updates from the activity stream.""" - if self._abort_async_track_time_interval is None: - return - self._abort_async_track_time_interval() - - @callback - def async_get_latest_device_activity(self, device_id, activity_types): + def get_latest_device_activity(self, device_id, activity_types): """Return latest activity that is one of the acitivty_types.""" if device_id not in self._latest_activities_by_id_type: return None @@ -65,14 +53,14 @@ class ActivityStream: return latest_activity - async def _async_update(self, time): + async def _refresh(self, time): """Update the activity stream from August.""" # This is the only place we refresh the api token await self._august_gateway.async_refresh_access_token_if_needed() - await self._update_device_activities(time) + await self._async_update_device_activities(time) - async def _update_device_activities(self, time): + async def _async_update_device_activities(self, time): _LOGGER.debug("Start retrieving device activities") limit = ( @@ -98,6 +86,9 @@ class ActivityStream: house_id, ex, ) + # Make sure we process the next house if one of them fails + continue + _LOGGER.debug( "Completed retrieving device activities for house id %s", house_id ) @@ -107,12 +98,10 @@ class ActivityStream: if updated_device_ids: for device_id in updated_device_ids: _LOGGER.debug( - "async_dispatcher_send (from activity stream): AUGUST_DEVICE_UPDATE-%s", + "async_signal_device_id_update (from activity stream): %s", device_id, ) - async_dispatcher_send( - self._hass, f"{AUGUST_DEVICE_UPDATE}-{device_id}" - ) + self.async_signal_device_id_update(device_id) self._last_update_time = time diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index ea9acd600b2..109ed425157 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -13,51 +13,45 @@ from homeassistant.components.binary_sensor import ( BinarySensorDevice, ) from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow -from .const import ( - AUGUST_DEVICE_UPDATE, - DATA_AUGUST, - DEFAULT_NAME, - DOMAIN, - MIN_TIME_BETWEEN_DETAIL_UPDATES, -) +from .const import DATA_AUGUST, DOMAIN +from .entity import AugustEntityMixin _LOGGER = logging.getLogger(__name__) TIME_TO_DECLARE_DETECTION = timedelta(seconds=60) -SCAN_INTERVAL = MIN_TIME_BETWEEN_DETAIL_UPDATES - -async def _async_retrieve_online_state(data, detail): +def _retrieve_online_state(data, detail): """Get the latest state of the sensor.""" + # The doorbell will go into standby mode when there is no motion + # for a short while. It will wake by itself when needed so we need + # to consider is available or we will not report motion or dings + return detail.is_online or detail.is_standby -async def _async_retrieve_motion_state(data, detail): +def _retrieve_motion_state(data, detail): - return await _async_activity_time_based_state( + return _activity_time_based_state( data, detail.device_id, [ActivityType.DOORBELL_MOTION, ActivityType.DOORBELL_DING], ) -async def _async_retrieve_ding_state(data, detail): +def _retrieve_ding_state(data, detail): - return await _async_activity_time_based_state( + return _activity_time_based_state( data, detail.device_id, [ActivityType.DOORBELL_DING] ) -async def _async_activity_time_based_state(data, device_id, activity_types): +def _activity_time_based_state(data, device_id, activity_types): """Get the latest state of the sensor.""" - latest = data.activity_stream.async_get_latest_device_activity( - device_id, activity_types - ) + latest = data.activity_stream.get_latest_device_activity(device_id, activity_types) if latest is not None: start = latest.activity_start_time @@ -69,15 +63,17 @@ async def _async_activity_time_based_state(data, device_id, activity_types): SENSOR_NAME = 0 SENSOR_DEVICE_CLASS = 1 SENSOR_STATE_PROVIDER = 2 +SENSOR_STATE_IS_TIME_BASED = 3 -# sensor_type: [name, device_class, async_state_provider] +# sensor_type: [name, device_class, state_provider, is_time_based] SENSOR_TYPES_DOORBELL = { - "doorbell_ding": ["Ding", DEVICE_CLASS_OCCUPANCY, _async_retrieve_ding_state], - "doorbell_motion": ["Motion", DEVICE_CLASS_MOTION, _async_retrieve_motion_state], + "doorbell_ding": ["Ding", DEVICE_CLASS_OCCUPANCY, _retrieve_ding_state, True], + "doorbell_motion": ["Motion", DEVICE_CLASS_MOTION, _retrieve_motion_state, True], "doorbell_online": [ "Online", DEVICE_CLASS_CONNECTIVITY, - _async_retrieve_online_state, + _retrieve_online_state, + False, ], } @@ -88,8 +84,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): devices = [] for door in data.locks: - if not data.lock_has_doorsense(door.device_id): - _LOGGER.debug("Not adding sensor class door for lock %s ", door.device_name) + detail = data.get_device_detail(door.device_id) + if not detail.doorsense: + _LOGGER.debug( + "Not adding sensor class door for lock %s because it does not have doorsense.", + door.device_name, + ) continue _LOGGER.debug("Adding sensor class door for %s", door.device_name) @@ -107,19 +107,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(devices, True) -class AugustDoorBinarySensor(BinarySensorDevice): +class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorDevice): """Representation of an August Door binary sensor.""" - def __init__(self, data, sensor_type, door): + def __init__(self, data, sensor_type, device): """Initialize the sensor.""" - self._undo_dispatch_subscription = None + super().__init__(data, device) self._data = data self._sensor_type = sensor_type - self._door = door + self._device = device self._state = None self._available = False - self._firmware_version = None - self._model = None + self._update_from_data() @property def available(self): @@ -139,76 +138,43 @@ class AugustDoorBinarySensor(BinarySensorDevice): @property def name(self): """Return the name of the binary sensor.""" - return f"{self._door.device_name} Open" + return f"{self._device.device_name} Open" - async def async_update(self): + @callback + def _update_from_data(self): """Get the latest state of the sensor and update activity.""" - door_activity = self._data.activity_stream.async_get_latest_device_activity( - self._door.device_id, [ActivityType.DOOR_OPERATION] + door_activity = self._data.activity_stream.get_latest_device_activity( + self._device_id, [ActivityType.DOOR_OPERATION] ) - detail = await self._data.async_get_lock_detail(self._door.device_id) + detail = self._detail if door_activity is not None: update_lock_detail_from_activity(detail, door_activity) - lock_door_state = None - self._available = False - if detail is not None: - lock_door_state = detail.door_state - self._available = detail.bridge_is_online - self._firmware_version = detail.firmware_version - self._model = detail.model + lock_door_state = detail.door_state + self._available = detail.bridge_is_online self._state = lock_door_state == LockDoorStatus.OPEN @property def unique_id(self) -> str: """Get the unique of the door open binary sensor.""" - return f"{self._door.device_id}_open" - - @property - def device_info(self): - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self._door.device_id)}, - "name": self._door.device_name, - "manufacturer": DEFAULT_NAME, - "sw_version": self._firmware_version, - "model": self._model, - } - - async def async_added_to_hass(self): - """Register callbacks.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - - self._undo_dispatch_subscription = async_dispatcher_connect( - self.hass, f"{AUGUST_DEVICE_UPDATE}-{self._door.device_id}", update - ) - - async def async_will_remove_from_hass(self): - """Undo subscription.""" - if self._undo_dispatch_subscription: - self._undo_dispatch_subscription() + return f"{self._device_id}_open" -class AugustDoorbellBinarySensor(BinarySensorDevice): +class AugustDoorbellBinarySensor(AugustEntityMixin, BinarySensorDevice): """Representation of an August binary sensor.""" - def __init__(self, data, sensor_type, doorbell): + def __init__(self, data, sensor_type, device): """Initialize the sensor.""" - self._undo_dispatch_subscription = None + super().__init__(data, device) self._check_for_off_update_listener = None self._data = data self._sensor_type = sensor_type - self._doorbell = doorbell + self._device = device self._state = None self._available = False - self._firmware_version = None - self._model = None + self._update_from_data() @property def available(self): @@ -228,42 +194,47 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): @property def name(self): """Return the name of the binary sensor.""" - return f"{self._doorbell.device_name} {SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME]}" + return f"{self._device.device_name} {SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME]}" - async def async_update(self): + @property + def _state_provider(self): + """Return the state provider for the binary sensor.""" + return SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_STATE_PROVIDER] + + @property + def _is_time_based(self): + """Return true of false if the sensor is time based.""" + return SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_STATE_IS_TIME_BASED] + + @callback + def _update_from_data(self): """Get the latest state of the sensor.""" self._cancel_any_pending_updates() - async_state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][ - SENSOR_STATE_PROVIDER - ] - detail = await self._data.async_get_doorbell_detail(self._doorbell.device_id) - # The doorbell will go into standby mode when there is no motion - # for a short while. It will wake by itself when needed so we need - # to consider is available or we will not report motion or dings - if self.device_class == DEVICE_CLASS_CONNECTIVITY: - self._available = True - else: - self._available = detail is not None and ( - detail.is_online or detail.is_standby - ) + self._state = self._state_provider(self._data, self._detail) - self._state = None - if detail is not None: - self._firmware_version = detail.firmware_version - self._model = detail.model - self._state = await async_state_provider(self._data, detail) - if self._state and self.device_class != DEVICE_CLASS_CONNECTIVITY: - self._schedule_update_to_recheck_turn_off_sensor() + if self._is_time_based: + self._available = _retrieve_online_state(self._data, self._detail) + self._schedule_update_to_recheck_turn_off_sensor() + else: + self._available = True def _schedule_update_to_recheck_turn_off_sensor(self): """Schedule an update to recheck the sensor to see if it is ready to turn off.""" + # If the sensor is already off there is nothing to do + if not self._state: + return + + # self.hass is only available after setup is completed + # and we will recheck in async_added_to_hass + if not self.hass: + return + @callback def _scheduled_update(now): """Timer callback for sensor update.""" - _LOGGER.debug("%s: executing scheduled update", self.entity_id) - self.async_schedule_update_ha_state(True) self._check_for_off_update_listener = None + self._update_from_data() self._check_for_off_update_listener = async_track_point_in_utc_time( self.hass, _scheduled_update, utcnow() + TIME_TO_DECLARE_DETECTION @@ -272,41 +243,19 @@ class AugustDoorbellBinarySensor(BinarySensorDevice): def _cancel_any_pending_updates(self): """Cancel any updates to recheck a sensor to see if it is ready to turn off.""" if self._check_for_off_update_listener: + _LOGGER.debug("%s: canceled pending update", self.entity_id) self._check_for_off_update_listener() self._check_for_off_update_listener = None + async def async_added_to_hass(self): + """Call the mixin to subscribe and setup an async_track_point_in_utc_time to turn off the sensor if needed.""" + self._schedule_update_to_recheck_turn_off_sensor() + await super().async_added_to_hass() + @property def unique_id(self) -> str: """Get the unique id of the doorbell sensor.""" return ( - f"{self._doorbell.device_id}_" + f"{self._device_id}_" f"{SENSOR_TYPES_DOORBELL[self._sensor_type][SENSOR_NAME].lower()}" ) - - @property - def device_info(self): - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self._doorbell.device_id)}, - "name": self._doorbell.device_name, - "manufacturer": "August", - "sw_version": self._firmware_version, - "model": self._model, - } - - async def async_added_to_hass(self): - """Register callbacks.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - - self._undo_dispatch_subscription = async_dispatcher_connect( - self.hass, f"{AUGUST_DEVICE_UPDATE}-{self._doorbell.device_id}", update - ) - - async def async_will_remove_from_hass(self): - """Undo subscription.""" - if self._undo_dispatch_subscription: - self._undo_dispatch_subscription() diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index a499c43f0cf..ecadbb931c0 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -5,18 +5,9 @@ from august.util import update_doorbell_image_from_activity from homeassistant.components.camera import Camera from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import ( - AUGUST_DEVICE_UPDATE, - DATA_AUGUST, - DEFAULT_NAME, - DEFAULT_TIMEOUT, - DOMAIN, - MIN_TIME_BETWEEN_DETAIL_UPDATES, -) - -SCAN_INTERVAL = MIN_TIME_BETWEEN_DETAIL_UPDATES +from .const import DATA_AUGUST, DEFAULT_NAME, DEFAULT_TIMEOUT, DOMAIN +from .entity import AugustEntityMixin async def async_setup_entry(hass, config_entry, async_add_entities): @@ -30,31 +21,27 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(devices, True) -class AugustCamera(Camera): +class AugustCamera(AugustEntityMixin, Camera): """An implementation of a August security camera.""" - def __init__(self, data, doorbell, timeout): + def __init__(self, data, device, timeout): """Initialize a August security camera.""" - super().__init__() - self._undo_dispatch_subscription = None + super().__init__(data, device) self._data = data - self._doorbell = doorbell - self._doorbell_detail = None + self._device = device self._timeout = timeout self._image_url = None self._image_content = None - self._firmware_version = None - self._model = None @property def name(self): """Return the name of this device.""" - return self._doorbell.device_name + return f"{self._device.device_name} Camera" @property def is_recording(self): """Return true if the device is recording.""" - return self._doorbell.has_subscription + return self._device.has_subscription @property def motion_detection_enabled(self): @@ -69,77 +56,34 @@ class AugustCamera(Camera): @property def model(self): """Return the camera model.""" - return self._model + return self._detail.model - async def async_camera_image(self): - """Return bytes of camera image.""" - self._doorbell_detail = await self._data.async_get_doorbell_detail( - self._doorbell.device_id - ) - doorbell_activity = self._data.activity_stream.async_get_latest_device_activity( - self._doorbell.device_id, [ActivityType.DOORBELL_MOTION] + @callback + def _update_from_data(self): + """Get the latest state of the sensor.""" + doorbell_activity = self._data.activity_stream.get_latest_device_activity( + self._device_id, [ActivityType.DOORBELL_MOTION] ) if doorbell_activity is not None: - update_doorbell_image_from_activity( - self._doorbell_detail, doorbell_activity - ) + update_doorbell_image_from_activity(self._detail, doorbell_activity) - if self._doorbell_detail is None: - return None + async def async_camera_image(self): + """Return bytes of camera image.""" + self._update_from_data() - if self._image_url is not self._doorbell_detail.image_url: - self._image_url = self._doorbell_detail.image_url + if self._image_url is not self._detail.image_url: + self._image_url = self._detail.image_url self._image_content = await self.hass.async_add_executor_job( self._camera_image ) return self._image_content - async def async_update(self): - """Update camera data.""" - self._doorbell_detail = await self._data.async_get_doorbell_detail( - self._doorbell.device_id - ) - - if self._doorbell_detail is None: - return None - - self._firmware_version = self._doorbell_detail.firmware_version - self._model = self._doorbell_detail.model - def _camera_image(self): """Return bytes of camera image.""" - return self._doorbell_detail.get_doorbell_image(timeout=self._timeout) + return self._detail.get_doorbell_image(timeout=self._timeout) @property def unique_id(self) -> str: """Get the unique id of the camera.""" - return f"{self._doorbell.device_id:s}_camera" - - @property - def device_info(self): - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self._doorbell.device_id)}, - "name": self._doorbell.device_name + " Camera", - "manufacturer": DEFAULT_NAME, - "sw_version": self._firmware_version, - "model": self._model, - } - - async def async_added_to_hass(self): - """Register callbacks.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - - self._undo_dispatch_subscription = async_dispatcher_connect( - self.hass, f"{AUGUST_DEVICE_UPDATE}-{self._doorbell.device_id}", update - ) - - async def async_will_remove_from_hass(self): - """Undo subscription.""" - if self._undo_dispatch_subscription: - self._undo_dispatch_subscription() + return f"{self._device_id:s}_camera" diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py index 79ed2d903af..923f90c331e 100644 --- a/homeassistant/components/august/const.py +++ b/homeassistant/components/august/const.py @@ -8,8 +8,6 @@ CONF_ACCESS_TOKEN_CACHE_FILE = "access_token_cache_file" CONF_LOGIN_METHOD = "login_method" CONF_INSTALL_ID = "install_id" -AUGUST_DEVICE_UPDATE = "august_devices_update" - VERIFICATION_CODE_KEY = "verification_code" NOTIFICATION_ID = "august_notification" diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py new file mode 100644 index 00000000000..32e2e7acd10 --- /dev/null +++ b/homeassistant/components/august/entity.py @@ -0,0 +1,67 @@ +"""Base class for August entity.""" + +import logging + +from homeassistant.core import callback + +from . import DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AugustEntityMixin: + """Base implementation for August device.""" + + def __init__(self, data, device): + """Initialize an August device.""" + super().__init__() + self._data = data + self._device = device + self._undo_dispatch_subscription = None + + @property + def should_poll(self): + """Return False, updates are controlled via the hub.""" + return False + + @property + def _device_id(self): + return self._device.device_id + + @property + def _detail(self): + return self._data.get_device_detail(self._device.device_id) + + @property + def device_info(self): + """Return the device_info of the device.""" + return { + "identifiers": {(DOMAIN, self._device_id)}, + "name": self._device.device_name, + "manufacturer": DEFAULT_NAME, + "sw_version": self._detail.firmware_version, + "model": self._detail.model, + } + + @callback + def _update_from_data_and_write_state(self): + self._update_from_data() + self.async_write_ha_state() + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self._data.async_subscribe_device_id( + self._device_id, self._update_from_data_and_write_state + ) + self._data.activity_stream.async_subscribe_device_id( + self._device_id, self._update_from_data_and_write_state + ) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + self._data.async_unsubscribe_device_id( + self._device_id, self._update_from_data_and_write_state + ) + self._data.activity_stream.async_unsubscribe_device_id( + self._device_id, self._update_from_data_and_write_state + ) diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index bad5fd78fae..c072a589f6c 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -8,20 +8,12 @@ from august.util import update_lock_detail_from_activity from homeassistant.components.lock import LockDevice from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import ( - AUGUST_DEVICE_UPDATE, - DATA_AUGUST, - DEFAULT_NAME, - DOMAIN, - MIN_TIME_BETWEEN_DETAIL_UPDATES, -) +from .const import DATA_AUGUST, DOMAIN +from .entity import AugustEntityMixin _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = MIN_TIME_BETWEEN_DETAIL_UPDATES - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up August locks.""" @@ -35,20 +27,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(devices, True) -class AugustLock(LockDevice): +class AugustLock(AugustEntityMixin, LockDevice): """Representation of an August lock.""" - def __init__(self, data, lock): + def __init__(self, data, device): """Initialize the lock.""" - self._undo_dispatch_subscription = None + super().__init__(data, device) self._data = data - self._lock = lock + self._device = device self._lock_status = None - self._lock_detail = None self._changed_by = None self._available = False - self._firmware_version = None - self._model = None + self._update_from_data() async def async_lock(self, **kwargs): """Lock the device.""" @@ -60,52 +50,47 @@ class AugustLock(LockDevice): async def _call_lock_operation(self, lock_operation): activities = await self.hass.async_add_executor_job( - lock_operation, self._lock.device_id + lock_operation, self._device_id ) + detail = self._detail for lock_activity in activities: - update_lock_detail_from_activity(self._lock_detail, lock_activity) + update_lock_detail_from_activity(detail, lock_activity) if self._update_lock_status_from_detail(): - await self._data.async_signal_operation_changed_device_state( - self._lock.device_id + _LOGGER.debug( + "async_signal_device_id_update (from lock operation): %s", + self._device_id, ) + self._data.async_signal_device_id_update(self._device_id) def _update_lock_status_from_detail(self): - detail = self._lock_detail - lock_status = None - self._available = False - - if detail is not None: - lock_status = detail.lock_status - self._available = detail.bridge_is_online + detail = self._detail + lock_status = detail.lock_status + self._available = detail.bridge_is_online if self._lock_status != lock_status: self._lock_status = lock_status return True return False - async def async_update(self): + @callback + def _update_from_data(self): """Get the latest state of the sensor and update activity.""" - self._lock_detail = await self._data.async_get_lock_detail(self._lock.device_id) - lock_activity = self._data.activity_stream.async_get_latest_device_activity( - self._lock.device_id, [ActivityType.LOCK_OPERATION] + lock_detail = self._detail + lock_activity = self._data.activity_stream.get_latest_device_activity( + self._device_id, [ActivityType.LOCK_OPERATION] ) if lock_activity is not None: self._changed_by = lock_activity.operated_by - if self._lock_detail is not None: - update_lock_detail_from_activity(self._lock_detail, lock_activity) - - if self._lock_detail is not None: - self._firmware_version = self._lock_detail.firmware_version - self._model = self._lock_detail.model + update_lock_detail_from_activity(lock_detail, lock_activity) self._update_lock_status_from_detail() @property def name(self): """Return the name of this device.""" - return self._lock.device_name + return self._device.device_name @property def available(self): @@ -127,45 +112,14 @@ class AugustLock(LockDevice): @property def device_state_attributes(self): """Return the device specific state attributes.""" - if self._lock_detail is None: - return None + attributes = {ATTR_BATTERY_LEVEL: self._detail.battery_level} - attributes = {ATTR_BATTERY_LEVEL: self._lock_detail.battery_level} - - if self._lock_detail.keypad is not None: - attributes["keypad_battery_level"] = self._lock_detail.keypad.battery_level + if self._detail.keypad is not None: + attributes["keypad_battery_level"] = self._detail.keypad.battery_level return attributes - @property - def device_info(self): - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self._lock.device_id)}, - "name": self._lock.device_name, - "manufacturer": DEFAULT_NAME, - "sw_version": self._firmware_version, - "model": self._model, - } - @property def unique_id(self) -> str: """Get the unique id of the lock.""" - return f"{self._lock.device_id:s}_lock" - - async def async_added_to_hass(self): - """Register callbacks.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - - self._undo_dispatch_subscription = async_dispatcher_connect( - self.hass, f"{AUGUST_DEVICE_UPDATE}-{self._lock.device_id}", update - ) - - async def async_will_remove_from_hass(self): - """Undo subscription.""" - if self._undo_dispatch_subscription: - self._undo_dispatch_subscription() + return f"{self._device_id:s}_lock" diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 3e9bfc5d8de..6c7af3c0c7e 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -2,9 +2,11 @@ import logging from homeassistant.components.sensor import DEVICE_CLASS_BATTERY +from homeassistant.core import callback from homeassistant.helpers.entity import Entity -from .const import DATA_AUGUST, DEFAULT_NAME, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES +from .const import DATA_AUGUST, DOMAIN +from .entity import AugustEntityMixin BATTERY_LEVEL_FULL = "Full" BATTERY_LEVEL_MEDIUM = "Medium" @@ -12,22 +14,14 @@ BATTERY_LEVEL_LOW = "Low" _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = MIN_TIME_BETWEEN_DETAIL_UPDATES - def _retrieve_device_battery_state(detail): """Get the latest state of the sensor.""" - if detail is None: - return None - return detail.battery_level def _retrieve_linked_keypad_battery_state(detail): """Get the latest state of the sensor.""" - if detail is None: - return None - if detail.keypad is None: return None @@ -73,7 +67,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for sensor_type in SENSOR_TYPES_BATTERY: for device in batteries[sensor_type]: state_provider = SENSOR_TYPES_BATTERY[sensor_type]["state_provider"] - detail = await data.async_get_device_detail(device) + detail = data.get_device_detail(device.device_id) state = state_provider(detail) sensor_name = SENSOR_TYPES_BATTERY[sensor_type]["name"] if state is None: @@ -91,18 +85,18 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(devices, True) -class AugustBatterySensor(Entity): +class AugustBatterySensor(AugustEntityMixin, Entity): """Representation of an August sensor.""" def __init__(self, data, sensor_type, device): """Initialize the sensor.""" + super().__init__(data, device) self._data = data self._sensor_type = sensor_type self._device = device self._state = None self._available = False - self._firmware_version = None - self._model = None + self._update_from_data() @property def available(self): @@ -131,28 +125,14 @@ class AugustBatterySensor(Entity): sensor_name = SENSOR_TYPES_BATTERY[self._sensor_type]["name"] return f"{device_name} {sensor_name}" - async def async_update(self): + @callback + def _update_from_data(self): """Get the latest state of the sensor.""" state_provider = SENSOR_TYPES_BATTERY[self._sensor_type]["state_provider"] - detail = await self._data.async_get_device_detail(self._device) - self._state = state_provider(detail) + self._state = state_provider(self._detail) self._available = self._state is not None - if detail is not None: - self._firmware_version = detail.firmware_version - self._model = detail.model @property def unique_id(self) -> str: """Get the unique id of the device sensor.""" - return f"{self._device.device_id}_{self._sensor_type}" - - @property - def device_info(self): - """Return the device_info of the device.""" - return { - "identifiers": {(DOMAIN, self._device.device_id)}, - "name": self._device.device_name, - "manufacturer": DEFAULT_NAME, - "sw_version": self._firmware_version, - "model": self._model, - } + return f"{self._device_id}_{self._sensor_type}" diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py new file mode 100644 index 00000000000..62861270c30 --- /dev/null +++ b/homeassistant/components/august/subscriber.py @@ -0,0 +1,53 @@ +"""Base class for August entity.""" + + +from homeassistant.core import callback +from homeassistant.helpers.event import async_track_time_interval + + +class AugustSubscriberMixin: + """Base implementation for a subscriber.""" + + def __init__(self, hass, update_interval): + """Initialize an subscriber.""" + super().__init__() + self._hass = hass + self._update_interval = update_interval + self._subscriptions = {} + self._unsub_interval = None + + @callback + def async_subscribe_device_id(self, device_id, update_callback): + """Add an callback subscriber.""" + if not self._subscriptions: + self._unsub_interval = async_track_time_interval( + self._hass, self._refresh, self._update_interval + ) + self._subscriptions.setdefault(device_id, []).append(update_callback) + + @callback + def async_unsubscribe_device_id(self, device_id, update_callback): + """Remove a callback subscriber.""" + self._subscriptions[device_id].remove(update_callback) + if not self._subscriptions[device_id]: + del self._subscriptions[device_id] + if not self._subscriptions: + self._unsub_interval() + self._unsub_interval = None + + @callback + def async_signal_device_id_update(self, device_id): + """Call the callbacks for a device_id.""" + if not self._subscriptions.get(device_id): + return + + for update_callback in self._subscriptions[device_id]: + update_callback() + + def signal_device_id_update(self, device_id): + """Call the callbacks for a device_id.""" + if not self._subscriptions.get(device_id): + return + + for update_callback in self._subscriptions[device_id]: + self._hass.loop.call_soon_threadsafe(update_callback) diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 565e082f841..30b70c3c397 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -130,5 +130,7 @@ async def test_doorbell_device_registry(hass): reg_device = device_registry.async_get_device( identifiers={("august", "tmt100")}, connections=set() ) - assert "hydra1" == reg_device.model - assert "3.1.0-HYDRC75+201909251139" == reg_device.sw_version + assert reg_device.model == "hydra1" + assert reg_device.name == "tmt100 Name" + assert reg_device.manufacturer == "August" + assert reg_device.sw_version == "3.1.0-HYDRC75+201909251139" diff --git a/tests/components/august/test_camera.py b/tests/components/august/test_camera.py index 9ed97ecbc29..4d9d48b0825 100644 --- a/tests/components/august/test_camera.py +++ b/tests/components/august/test_camera.py @@ -14,5 +14,5 @@ async def test_create_doorbell(hass): doorbell_details = [doorbell_one] await _create_august_with_devices(hass, doorbell_details) - camera_k98gidt45gul_name = hass.states.get("camera.k98gidt45gul_name") - assert camera_k98gidt45gul_name.state == STATE_IDLE + camera_k98gidt45gul_name_camera = hass.states.get("camera.k98gidt45gul_name_camera") + assert camera_k98gidt45gul_name_camera.state == STATE_IDLE diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index 850d9677cfd..a620bdd1080 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -30,6 +30,8 @@ async def test_lock_device_registry(hass): ) assert reg_device.model == "AUG-MD01" assert reg_device.sw_version == "undefined-4.3.0-1.8.14" + assert reg_device.name == "online_with_doorsense Name" + assert reg_device.manufacturer == "August" async def test_one_lock_operation(hass): From 3b17e570dfe7059d4a46927fb4ba9fb171f28963 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 28 Feb 2020 06:05:43 +0100 Subject: [PATCH 140/416] Add device actions to cover (#28064) * Add device actions to cover * Remove toggle action, improve tests * lint * lint * Simplify actions * Update homeassistant/components/cover/device_action.py Co-Authored-By: Paulus Schoutsen * Improve tests Co-authored-by: Paulus Schoutsen --- .../components/cover/device_action.py | 176 +++++++ homeassistant/components/cover/strings.json | 8 + tests/components/cover/test_device_action.py | 454 ++++++++++++++++++ .../custom_components/test/cover.py | 50 +- 4 files changed, 686 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/cover/device_action.py create mode 100644 tests/components/cover/test_device_action.py diff --git a/homeassistant/components/cover/device_action.py b/homeassistant/components/cover/device_action.py new file mode 100644 index 00000000000..dba4ff8be89 --- /dev/null +++ b/homeassistant/components/cover/device_action.py @@ -0,0 +1,176 @@ +"""Provides device automations for Cover.""" +from typing import List, Optional + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_TYPE, + SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, + SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import entity_registry +import homeassistant.helpers.config_validation as cv + +from . import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN, + SUPPORT_CLOSE, + SUPPORT_CLOSE_TILT, + SUPPORT_OPEN, + SUPPORT_OPEN_TILT, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, +) + +CMD_ACTION_TYPES = {"open", "close", "open_tilt", "close_tilt"} +POSITION_ACTION_TYPES = {"set_position", "set_tilt_position"} + +CMD_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(CMD_ACTION_TYPES), + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + } +) + +POSITION_ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( + { + vol.Required(CONF_TYPE): vol.In(POSITION_ACTION_TYPES), + vol.Required(CONF_ENTITY_ID): cv.entity_domain(DOMAIN), + vol.Required("position"): vol.All(vol.Coerce(int), vol.Range(min=0, max=100)), + } +) + +ACTION_SCHEMA = vol.Any(CMD_ACTION_SCHEMA, POSITION_ACTION_SCHEMA) + + +async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: + """List device actions for Cover devices.""" + registry = await entity_registry.async_get_registry(hass) + actions = [] + + # Get all the integrations entities for this device + for entry in entity_registry.async_entries_for_device(registry, device_id): + if entry.domain != DOMAIN: + continue + + state = hass.states.get(entry.entity_id) + if not state or ATTR_SUPPORTED_FEATURES not in state.attributes: + continue + + supported_features = state.attributes[ATTR_SUPPORTED_FEATURES] + + # Add actions for each entity that belongs to this integration + if supported_features & SUPPORT_SET_POSITION: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "set_position", + } + ) + else: + if supported_features & SUPPORT_OPEN: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "open", + } + ) + if supported_features & SUPPORT_CLOSE: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "close", + } + ) + + if supported_features & SUPPORT_SET_TILT_POSITION: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "set_tilt_position", + } + ) + else: + if supported_features & SUPPORT_OPEN_TILT: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "open_tilt", + } + ) + if supported_features & SUPPORT_CLOSE_TILT: + actions.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_ENTITY_ID: entry.entity_id, + CONF_TYPE: "close_tilt", + } + ) + + return actions + + +async def async_get_action_capabilities(hass: HomeAssistant, config: dict) -> dict: + """List action capabilities.""" + if config[CONF_TYPE] not in POSITION_ACTION_TYPES: + return {} + + return { + "extra_fields": vol.Schema( + { + vol.Optional("position", default=0): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + } + ) + } + + +async def async_call_action_from_config( + hass: HomeAssistant, config: dict, variables: dict, context: Optional[Context] +) -> None: + """Execute a device action.""" + config = ACTION_SCHEMA(config) + + service_data = {ATTR_ENTITY_ID: config[CONF_ENTITY_ID]} + + if config[CONF_TYPE] == "open": + service = SERVICE_OPEN_COVER + elif config[CONF_TYPE] == "close": + service = SERVICE_CLOSE_COVER + elif config[CONF_TYPE] == "open_tilt": + service = SERVICE_OPEN_COVER_TILT + elif config[CONF_TYPE] == "close_tilt": + service = SERVICE_CLOSE_COVER_TILT + elif config[CONF_TYPE] == "set_position": + service = SERVICE_SET_COVER_POSITION + service_data[ATTR_POSITION] = config["position"] + elif config[CONF_TYPE] == "set_tilt_position": + service = SERVICE_SET_COVER_TILT_POSITION + service_data[ATTR_TILT_POSITION] = config["position"] + + await hass.services.async_call( + DOMAIN, service, service_data, blocking=True, context=context + ) diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index 36492cc5ed5..90dac7c7d02 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -1,5 +1,13 @@ { "device_automation": { + "action_type": { + "open": "Open {entity_name}", + "close": "Close {entity_name}", + "open_tilt": "Open {entity_name} tilt", + "close_tilt": "Close {entity_name} tilt", + "set_position": "Set {entity_name} position", + "set_tilt_position": "Set {entity_name} tilt position" + }, "condition_type": { "is_open": "{entity_name} is open", "is_closed": "{entity_name} is closed", diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py new file mode 100644 index 00000000000..e70c18621f4 --- /dev/null +++ b/tests/components/cover/test_device_action.py @@ -0,0 +1,454 @@ +"""The tests for Cover device actions.""" +import pytest + +import homeassistant.components.automation as automation +from homeassistant.components.cover import DOMAIN +from homeassistant.const import CONF_PLATFORM +from homeassistant.helpers import device_registry +from homeassistant.setup import async_setup_component + +from tests.common import ( + MockConfigEntry, + assert_lists_same, + 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) + + +async def test_get_actions(hass, device_reg, entity_reg): + """Test we get the expected actions from a cover.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[0] + + 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", ent.unique_id, device_id=device_entry.id + ) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + expected_actions = [ + { + "domain": DOMAIN, + "type": "open", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + { + "domain": DOMAIN, + "type": "close", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + ] + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert_lists_same(actions, expected_actions) + + +async def test_get_actions_tilt(hass, device_reg, entity_reg): + """Test we get the expected actions from a cover.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[3] + + 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", ent.unique_id, device_id=device_entry.id + ) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + expected_actions = [ + { + "domain": DOMAIN, + "type": "open", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + { + "domain": DOMAIN, + "type": "close", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + { + "domain": DOMAIN, + "type": "open_tilt", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + { + "domain": DOMAIN, + "type": "close_tilt", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + ] + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert_lists_same(actions, expected_actions) + + +async def test_get_actions_set_pos(hass, device_reg, entity_reg): + """Test we get the expected actions from a cover.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[1] + + 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", ent.unique_id, device_id=device_entry.id + ) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + expected_actions = [ + { + "domain": DOMAIN, + "type": "set_position", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + ] + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert_lists_same(actions, expected_actions) + + +async def test_get_actions_set_tilt_pos(hass, device_reg, entity_reg): + """Test we get the expected actions from a cover.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[2] + + 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", ent.unique_id, device_id=device_entry.id + ) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + expected_actions = [ + { + "domain": DOMAIN, + "type": "open", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + { + "domain": DOMAIN, + "type": "close", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + { + "domain": DOMAIN, + "type": "set_tilt_position", + "device_id": device_entry.id, + "entity_id": ent.entity_id, + }, + ] + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert_lists_same(actions, expected_actions) + + +async def test_get_action_capabilities(hass, device_reg, entity_reg): + """Test we get the expected capabilities from a cover action.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[0] + + 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", ent.unique_id, device_id=device_entry.id + ) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert len(actions) == 2 # open, close + for action in actions: + capabilities = await async_get_device_automation_capabilities( + hass, "action", action + ) + assert capabilities == {"extra_fields": []} + + +async def test_get_action_capabilities_set_pos(hass, device_reg, entity_reg): + """Test we get the expected capabilities from a cover action.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[1] + + 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", ent.unique_id, device_id=device_entry.id + ) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + expected_capabilities = { + "extra_fields": [ + { + "name": "position", + "optional": True, + "type": "integer", + "default": 0, + "valueMax": 100, + "valueMin": 0, + } + ] + } + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert len(actions) == 1 # set_position + for action in actions: + capabilities = await async_get_device_automation_capabilities( + hass, "action", action + ) + if action["type"] == "set_position": + assert capabilities == expected_capabilities + else: + assert capabilities == {"extra_fields": []} + + +async def test_get_action_capabilities_set_tilt_pos(hass, device_reg, entity_reg): + """Test we get the expected capabilities from a cover action.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + ent = platform.ENTITIES[2] + + 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", ent.unique_id, device_id=device_entry.id + ) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + expected_capabilities = { + "extra_fields": [ + { + "name": "position", + "optional": True, + "type": "integer", + "default": 0, + "valueMax": 100, + "valueMin": 0, + } + ] + } + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert len(actions) == 3 # open, close, set_tilt_position + for action in actions: + capabilities = await async_get_device_automation_capabilities( + hass, "action", action + ) + if action["type"] == "set_tilt_position": + assert capabilities == expected_capabilities + else: + assert capabilities == {"extra_fields": []} + + +async def test_action(hass): + """Test for cover actions.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event_open"}, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "cover.entity", + "type": "open", + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event_close"}, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "cover.entity", + "type": "close", + }, + }, + ] + }, + ) + + open_calls = async_mock_service(hass, "cover", "open_cover") + close_calls = async_mock_service(hass, "cover", "close_cover") + + hass.bus.async_fire("test_event_open") + await hass.async_block_till_done() + assert len(open_calls) == 1 + assert len(close_calls) == 0 + + hass.bus.async_fire("test_event_close") + await hass.async_block_till_done() + assert len(open_calls) == 1 + assert len(close_calls) == 1 + + hass.bus.async_fire("test_event_stop") + await hass.async_block_till_done() + assert len(open_calls) == 1 + assert len(close_calls) == 1 + + +async def test_action_tilt(hass): + """Test for cover tilt actions.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event_open"}, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "cover.entity", + "type": "open_tilt", + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event_close"}, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "cover.entity", + "type": "close_tilt", + }, + }, + ] + }, + ) + + open_calls = async_mock_service(hass, "cover", "open_cover_tilt") + close_calls = async_mock_service(hass, "cover", "close_cover_tilt") + + hass.bus.async_fire("test_event_open") + await hass.async_block_till_done() + assert len(open_calls) == 1 + assert len(close_calls) == 0 + + hass.bus.async_fire("test_event_close") + await hass.async_block_till_done() + assert len(open_calls) == 1 + assert len(close_calls) == 1 + + hass.bus.async_fire("test_event_stop") + await hass.async_block_till_done() + assert len(open_calls) == 1 + assert len(close_calls) == 1 + + +async def test_action_set_position(hass): + """Test for cover set position actions.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_pos", + }, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "cover.entity", + "type": "set_position", + "position": 25, + }, + }, + { + "trigger": { + "platform": "event", + "event_type": "test_event_set_tilt_pos", + }, + "action": { + "domain": DOMAIN, + "device_id": "abcdefgh", + "entity_id": "cover.entity", + "type": "set_tilt_position", + "position": 75, + }, + }, + ] + }, + ) + + cover_pos_calls = async_mock_service(hass, "cover", "set_cover_position") + tilt_pos_calls = async_mock_service(hass, "cover", "set_cover_tilt_position") + + hass.bus.async_fire("test_event_set_pos") + await hass.async_block_till_done() + assert len(cover_pos_calls) == 1 + assert cover_pos_calls[0].data["position"] == 25 + assert len(tilt_pos_calls) == 0 + + hass.bus.async_fire("test_event_set_tilt_pos") + await hass.async_block_till_done() + assert len(cover_pos_calls) == 1 + assert len(tilt_pos_calls) == 1 + assert tilt_pos_calls[0].data["tilt_position"] == 75 diff --git a/tests/testing_config/custom_components/test/cover.py b/tests/testing_config/custom_components/test/cover.py index ce5462790bb..bdaacfa4e3c 100644 --- a/tests/testing_config/custom_components/test/cover.py +++ b/tests/testing_config/custom_components/test/cover.py @@ -3,7 +3,17 @@ Provide a mock cover platform. Call init before using it in your tests to ensure clean test data. """ -from homeassistant.components.cover import CoverDevice +from homeassistant.components.cover import ( + SUPPORT_CLOSE, + SUPPORT_CLOSE_TILT, + SUPPORT_OPEN, + SUPPORT_OPEN_TILT, + SUPPORT_SET_POSITION, + SUPPORT_SET_TILT_POSITION, + SUPPORT_STOP, + SUPPORT_STOP_TILT, + CoverDevice, +) from tests.common import MockEntity @@ -18,18 +28,31 @@ def init(empty=False): [] if empty else [ - MockCover(name=f"Simple cover", is_on=True, unique_id=f"unique_cover"), + MockCover( + name=f"Simple cover", + is_on=True, + unique_id=f"unique_cover", + supports_tilt=False, + ), MockCover( name=f"Set position cover", is_on=True, unique_id=f"unique_set_pos_cover", current_cover_position=50, + supports_tilt=False, ), MockCover( name=f"Set tilt position cover", is_on=True, unique_id=f"unique_set_pos_tilt_cover", current_cover_tilt_position=50, + supports_tilt=True, + ), + MockCover( + name=f"Tilt cover", + is_on=True, + unique_id=f"unique_tilt_cover", + supports_tilt=True, ), ] ) @@ -59,3 +82,26 @@ class MockCover(MockEntity, CoverDevice): def current_cover_tilt_position(self): """Return current position of cover tilt.""" return self._handle("current_cover_tilt_position") + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP + + if self._handle("supports_tilt"): + supported_features |= ( + SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT + ) + + if self.current_cover_position is not None: + supported_features |= SUPPORT_SET_POSITION + + if self.current_cover_tilt_position is not None: + supported_features |= ( + SUPPORT_OPEN_TILT + | SUPPORT_CLOSE_TILT + | SUPPORT_STOP_TILT + | SUPPORT_SET_TILT_POSITION + ) + + return supported_features From 0364cd8db5f5fdd1eb9332d66618598add2df9e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 27 Feb 2020 19:23:47 -1000 Subject: [PATCH 141/416] =?UTF-8?q?Add=20usage=20sensors=20for=20each=20de?= =?UTF-8?q?vice=20sense=20detects=20that=20show=20powe=E2=80=A6=20(#32206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * init * const * update docs string * update docs string * restore binary sensors * Restore binary sensors * match name * pylint * Fix bug in conf migration * Fix refactoring error * Address review items Imporve performance * Fix devices never turning back off --- homeassistant/components/sense/__init__.py | 42 ++- .../components/sense/binary_sensor.py | 95 +++--- homeassistant/components/sense/const.py | 52 ++++ homeassistant/components/sense/sensor.py | 279 +++++++++++++++--- 4 files changed, 357 insertions(+), 111 deletions(-) diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index f54e4092178..d7887f7ab01 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -24,11 +24,13 @@ from .const import ( DOMAIN, SENSE_DATA, SENSE_DEVICE_UPDATE, + SENSE_DEVICES_DATA, + SENSE_DISCOVERED_DEVICES_DATA, ) _LOGGER = logging.getLogger(__name__) -PLATFORMS = ["sensor", "binary_sensor"] +PLATFORMS = ["binary_sensor", "sensor"] CONFIG_SCHEMA = vol.Schema( { @@ -44,6 +46,24 @@ CONFIG_SCHEMA = vol.Schema( ) +class SenseDevicesData: + """Data for each sense device.""" + + def __init__(self): + """Create.""" + self._data_by_device = {} + + def set_devices_data(self, devices): + """Store a device update.""" + self._data_by_device = {} + for device in devices: + self._data_by_device[device["id"]] = device + + def get_device_by_id(self, sense_device_id): + """Get the latest device data.""" + return self._data_by_device.get(sense_device_id) + + async def async_setup(hass: HomeAssistant, config: dict): """Set up the Sense component.""" hass.data.setdefault(DOMAIN, {}) @@ -58,7 +78,7 @@ async def async_setup(hass: HomeAssistant, config: dict): data={ CONF_EMAIL: conf[CONF_EMAIL], CONF_PASSWORD: conf[CONF_PASSWORD], - CONF_TIMEOUT: conf.get[CONF_TIMEOUT], + CONF_TIMEOUT: conf[CONF_TIMEOUT], }, ) ) @@ -84,7 +104,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): except SenseAPITimeoutException: raise ConfigEntryNotReady - hass.data[DOMAIN][entry.entry_id] = {SENSE_DATA: gateway} + sense_devices_data = SenseDevicesData() + sense_discovered_devices = await gateway.get_discovered_device_data() + + hass.data[DOMAIN][entry.entry_id] = { + SENSE_DATA: gateway, + SENSE_DEVICES_DATA: sense_devices_data, + SENSE_DISCOVERED_DEVICES_DATA: sense_discovered_devices, + } for component in PLATFORMS: hass.async_create_task( @@ -94,14 +121,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): async def async_sense_update(now): """Retrieve latest state.""" try: - gateway = hass.data[DOMAIN][entry.entry_id][SENSE_DATA] await gateway.update_realtime() - async_dispatcher_send( - hass, f"{SENSE_DEVICE_UPDATE}-{gateway.sense_monitor_id}" - ) except SenseAPITimeoutException: _LOGGER.error("Timeout retrieving data") + data = gateway.get_realtime() + if "devices" in data: + sense_devices_data.set_devices_data(data["devices"]) + async_dispatcher_send(hass, f"{SENSE_DEVICE_UPDATE}-{gateway.sense_monitor_id}") + hass.data[DOMAIN][entry.entry_id][ "track_time_remove_callback" ] = async_track_time_interval( diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index 2ae79d71e5a..50fb3fd7dc7 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -2,69 +2,36 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import DEVICE_CLASS_POWER from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_registry import async_get_registry -from .const import DOMAIN, SENSE_DATA, SENSE_DEVICE_UPDATE +from .const import ( + DOMAIN, + MDI_ICONS, + SENSE_DATA, + SENSE_DEVICE_UPDATE, + SENSE_DEVICES_DATA, + SENSE_DISCOVERED_DEVICES_DATA, +) _LOGGER = logging.getLogger(__name__) -ATTR_WATTS = "watts" -DEVICE_ID_SOLAR = "solar" -BIN_SENSOR_CLASS = "power" -MDI_ICONS = { - "ac": "air-conditioner", - "aquarium": "fish", - "car": "car-electric", - "computer": "desktop-classic", - "cup": "coffee", - "dehumidifier": "water-off", - "dishes": "dishwasher", - "drill": "toolbox", - "fan": "fan", - "freezer": "fridge-top", - "fridge": "fridge-bottom", - "game": "gamepad-variant", - "garage": "garage", - "grill": "stove", - "heat": "fire", - "heater": "radiatior", - "humidifier": "water", - "kettle": "kettle", - "leafblower": "leaf", - "lightbulb": "lightbulb", - "media_console": "set-top-box", - "modem": "router-wireless", - "outlet": "power-socket-us", - "papershredder": "shredder", - "printer": "printer", - "pump": "water-pump", - "settings": "settings", - "skillet": "pot", - "smartcamera": "webcam", - "socket": "power-plug", - "solar_alt": "solar-power", - "sound": "speaker", - "stove": "stove", - "trash": "trash-can", - "tv": "television", - "vacuum": "robot-vacuum", - "washer": "washing-machine", -} - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Sense binary sensor.""" data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DATA] + sense_devices_data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DEVICES_DATA] sense_monitor_id = data.sense_monitor_id - sense_devices = await data.get_discovered_device_data() + sense_devices = hass.data[DOMAIN][config_entry.entry_id][ + SENSE_DISCOVERED_DEVICES_DATA + ] devices = [ - SenseDevice(data, device, sense_monitor_id) + SenseDevice(sense_devices_data, device, sense_monitor_id) for device in sense_devices - if device["id"] == DEVICE_ID_SOLAR - or device["tags"]["DeviceListAllowed"] == "true" + if device["tags"]["DeviceListAllowed"] == "true" ] await _migrate_old_unique_ids(hass, devices) @@ -96,20 +63,27 @@ def sense_to_mdi(sense_icon): class SenseDevice(BinarySensorDevice): """Implementation of a Sense energy device binary sensor.""" - def __init__(self, data, device, sense_monitor_id): + def __init__(self, sense_devices_data, device, sense_monitor_id): """Initialize the Sense binary sensor.""" self._name = device["name"] self._id = device["id"] self._sense_monitor_id = sense_monitor_id self._unique_id = f"{sense_monitor_id}-{self._id}" self._icon = sense_to_mdi(device["icon"]) - self._data = data + self._sense_devices_data = sense_devices_data self._undo_dispatch_subscription = None + self._state = None + self._available = False @property def is_on(self): """Return true if the binary sensor is on.""" - return self._name in self._data.active_devices + return self._state + + @property + def available(self): + """Return the availability of the binary sensor.""" + return self._available @property def name(self): @@ -134,7 +108,7 @@ class SenseDevice(BinarySensorDevice): @property def device_class(self): """Return the device class of the binary sensor.""" - return BIN_SENSOR_CLASS + return DEVICE_CLASS_POWER @property def should_poll(self): @@ -143,17 +117,20 @@ class SenseDevice(BinarySensorDevice): async def async_added_to_hass(self): """Register callbacks.""" - - @callback - def update(): - """Update the state.""" - self.async_schedule_update_ha_state(True) - self._undo_dispatch_subscription = async_dispatcher_connect( - self.hass, f"{SENSE_DEVICE_UPDATE}-{self._sense_monitor_id}", update + self.hass, + f"{SENSE_DEVICE_UPDATE}-{self._sense_monitor_id}", + self._async_update_from_data, ) async def async_will_remove_from_hass(self): """Undo subscription.""" if self._undo_dispatch_subscription: self._undo_dispatch_subscription() + + @callback + def _async_update_from_data(self): + """Get the latest data, update state. Must not do I/O.""" + self._available = True + self._state = bool(self._sense_devices_data.get_device_by_id(self._id)) + self.async_write_ha_state() diff --git a/homeassistant/components/sense/const.py b/homeassistant/components/sense/const.py index cc30591e02a..619956903f2 100644 --- a/homeassistant/components/sense/const.py +++ b/homeassistant/components/sense/const.py @@ -5,3 +5,55 @@ ACTIVE_UPDATE_RATE = 60 DEFAULT_NAME = "Sense" SENSE_DATA = "sense_data" SENSE_DEVICE_UPDATE = "sense_devices_update" +SENSE_DEVICES_DATA = "sense_devices_data" +SENSE_DISCOVERED_DEVICES_DATA = "sense_discovered_devices" + +ACTIVE_NAME = "Energy" +ACTIVE_TYPE = "active" + +CONSUMPTION_NAME = "Usage" +CONSUMPTION_ID = "usage" +PRODUCTION_NAME = "Production" +PRODUCTION_ID = "production" + +ICON = "mdi:flash" + +MDI_ICONS = { + "ac": "air-conditioner", + "aquarium": "fish", + "car": "car-electric", + "computer": "desktop-classic", + "cup": "coffee", + "dehumidifier": "water-off", + "dishes": "dishwasher", + "drill": "toolbox", + "fan": "fan", + "freezer": "fridge-top", + "fridge": "fridge-bottom", + "game": "gamepad-variant", + "garage": "garage", + "grill": "stove", + "heat": "fire", + "heater": "radiatior", + "humidifier": "water", + "kettle": "kettle", + "leafblower": "leaf", + "lightbulb": "lightbulb", + "media_console": "set-top-box", + "modem": "router-wireless", + "outlet": "power-socket-us", + "papershredder": "shredder", + "printer": "printer", + "pump": "water-pump", + "settings": "settings", + "skillet": "pot", + "smartcamera": "webcam", + "socket": "power-plug", + "solar_alt": "solar-power", + "sound": "speaker", + "stove": "stove", + "trash": "trash-can", + "tv": "television", + "vacuum": "robot-vacuum", + "washer": "washing-machine", +} diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 8d3c8f9e171..6fe7b59c46c 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -4,24 +4,32 @@ import logging from sense_energy import SenseAPITimeoutException -from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT +from homeassistant.const import DEVICE_CLASS_POWER, ENERGY_KILO_WATT_HOUR, POWER_WATT +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -from .const import DOMAIN, SENSE_DATA - -_LOGGER = logging.getLogger(__name__) - -ACTIVE_NAME = "Energy" -ACTIVE_TYPE = "active" - -CONSUMPTION_NAME = "Usage" - -ICON = "mdi:flash" +from .const import ( + ACTIVE_NAME, + ACTIVE_TYPE, + CONSUMPTION_ID, + CONSUMPTION_NAME, + DOMAIN, + ICON, + MDI_ICONS, + PRODUCTION_ID, + PRODUCTION_NAME, + SENSE_DATA, + SENSE_DEVICE_UPDATE, + SENSE_DEVICES_DATA, + SENSE_DISCOVERED_DEVICES_DATA, +) MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=300) -PRODUCTION_NAME = "Production" + +_LOGGER = logging.getLogger(__name__) class SensorConfig: @@ -34,8 +42,10 @@ class SensorConfig: # Sensor types/ranges -SENSOR_TYPES = { - "active": SensorConfig(ACTIVE_NAME, ACTIVE_TYPE), +ACTIVE_SENSOR_TYPE = SensorConfig(ACTIVE_NAME, ACTIVE_TYPE) + +# Sensor types/ranges +TRENDS_SENSOR_TYPES = { "daily": SensorConfig("Daily", "DAY"), "weekly": SensorConfig("Weekly", "WEEK"), "monthly": SensorConfig("Monthly", "MONTH"), @@ -43,47 +53,157 @@ SENSOR_TYPES = { } # Production/consumption variants -SENSOR_VARIANTS = [PRODUCTION_NAME.lower(), CONSUMPTION_NAME.lower()] +SENSOR_VARIANTS = [PRODUCTION_ID, CONSUMPTION_ID] + + +def sense_to_mdi(sense_icon): + """Convert sense icon to mdi icon.""" + return "mdi:{}".format(MDI_ICONS.get(sense_icon, "power-plug")) async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Sense sensor.""" data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DATA] + sense_devices_data = hass.data[DOMAIN][config_entry.entry_id][SENSE_DEVICES_DATA] @Throttle(MIN_TIME_BETWEEN_DAILY_UPDATES) async def update_trends(): """Update the daily power usage.""" await data.update_trend_data() - async def update_active(): - """Update the active power usage.""" - await data.update_realtime() - sense_monitor_id = data.sense_monitor_id + sense_devices = hass.data[DOMAIN][config_entry.entry_id][ + SENSE_DISCOVERED_DEVICES_DATA + ] + await data.update_trend_data() - devices = [] - for type_id in SENSOR_TYPES: - typ = SENSOR_TYPES[type_id] + devices = [ + SenseEnergyDevice(sense_devices_data, device, sense_monitor_id) + for device in sense_devices + if device["tags"]["DeviceListAllowed"] == "true" + ] + + for var in SENSOR_VARIANTS: + name = ACTIVE_SENSOR_TYPE.name + sensor_type = ACTIVE_SENSOR_TYPE.sensor_type + is_production = var == PRODUCTION_ID + + unique_id = f"{sense_monitor_id}-active-{var}" + devices.append( + SenseActiveSensor( + data, name, sensor_type, is_production, sense_monitor_id, var, unique_id + ) + ) + + for type_id in TRENDS_SENSOR_TYPES: + typ = TRENDS_SENSOR_TYPES[type_id] for var in SENSOR_VARIANTS: name = typ.name sensor_type = typ.sensor_type - is_production = var == PRODUCTION_NAME.lower() - if sensor_type == ACTIVE_TYPE: - update_call = update_active - else: - update_call = update_trends + is_production = var == PRODUCTION_ID - unique_id = f"{sense_monitor_id}-{type_id}-{var}".lower() + unique_id = f"{sense_monitor_id}-{type_id}-{var}" devices.append( - Sense( - data, name, sensor_type, is_production, update_call, var, unique_id + SenseTrendsSensor( + data, + name, + sensor_type, + is_production, + update_trends, + var, + unique_id, ) ) async_add_entities(devices) -class Sense(Entity): +class SenseActiveSensor(Entity): + """Implementation of a Sense energy sensor.""" + + def __init__( + self, + data, + name, + sensor_type, + is_production, + sense_monitor_id, + sensor_id, + unique_id, + ): + """Initialize the Sense sensor.""" + name_type = PRODUCTION_NAME if is_production else CONSUMPTION_NAME + self._name = f"{name} {name_type}" + self._unique_id = unique_id + self._available = False + self._data = data + self._sense_monitor_id = sense_monitor_id + self._sensor_type = sensor_type + self._is_production = is_production + self._state = None + self._undo_dispatch_subscription = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def available(self): + """Return the availability of the sensor.""" + return self._available + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return POWER_WATT + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + @property + def unique_id(self): + """Return the unique id.""" + return self._unique_id + + @property + def should_poll(self): + """Return the device should not poll for updates.""" + return False + + async def async_added_to_hass(self): + """Register callbacks.""" + self._undo_dispatch_subscription = async_dispatcher_connect( + self.hass, + f"{SENSE_DEVICE_UPDATE}-{self._sense_monitor_id}", + self._async_update_from_data, + ) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + if self._undo_dispatch_subscription: + self._undo_dispatch_subscription() + + @callback + def _async_update_from_data(self): + """Update the sensor from the data. Must not do I/O.""" + self._state = round( + self._data.active_solar_power + if self._is_production + else self._data.active_power + ) + self._available = True + self.async_write_ha_state() + + +class SenseTrendsSensor(Entity): """Implementation of a Sense energy sensor.""" def __init__( @@ -99,11 +219,7 @@ class Sense(Entity): self.update_sensor = update_call self._is_production = is_production self._state = None - - if sensor_type == ACTIVE_TYPE: - self._unit_of_measurement = POWER_WATT - else: - self._unit_of_measurement = ENERGY_KILO_WATT_HOUR + self._unit_of_measurement = ENERGY_KILO_WATT_HOUR @property def name(self): @@ -144,13 +260,86 @@ class Sense(Entity): _LOGGER.error("Timeout retrieving data") return - if self._sensor_type == ACTIVE_TYPE: - if self._is_production: - self._state = round(self._data.active_solar_power) - else: - self._state = round(self._data.active_power) - else: - state = self._data.get_trend(self._sensor_type, self._is_production) - self._state = round(state, 1) - + state = self._data.get_trend(self._sensor_type, self._is_production) + self._state = round(state, 1) self._available = True + + +class SenseEnergyDevice(Entity): + """Implementation of a Sense energy device.""" + + def __init__(self, sense_devices_data, device, sense_monitor_id): + """Initialize the Sense binary sensor.""" + self._name = f"{device['name']} {CONSUMPTION_NAME}" + self._id = device["id"] + self._available = False + self._sense_monitor_id = sense_monitor_id + self._unique_id = f"{sense_monitor_id}-{self._id}-{CONSUMPTION_ID}" + self._icon = sense_to_mdi(device["icon"]) + self._sense_devices_data = sense_devices_data + self._undo_dispatch_subscription = None + self._state = None + + @property + def state(self): + """Return the wattage of the sensor.""" + return self._state + + @property + def available(self): + """Return the availability of the sensor.""" + return self._available + + @property + def name(self): + """Return the name of the power sensor.""" + return self._name + + @property + def unique_id(self): + """Return the unique id of the power sensor.""" + return self._unique_id + + @property + def icon(self): + """Return the icon of the power sensor.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return POWER_WATT + + @property + def device_class(self): + """Return the device class of the power sensor.""" + return DEVICE_CLASS_POWER + + @property + def should_poll(self): + """Return the device should not poll for updates.""" + return False + + async def async_added_to_hass(self): + """Register callbacks.""" + self._undo_dispatch_subscription = async_dispatcher_connect( + self.hass, + f"{SENSE_DEVICE_UPDATE}-{self._sense_monitor_id}", + self._async_update_from_data, + ) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + if self._undo_dispatch_subscription: + self._undo_dispatch_subscription() + + @callback + def _async_update_from_data(self): + """Get the latest data, update state. Must not do I/O.""" + device_data = self._sense_devices_data.get_device_by_id(self._id) + if not device_data or "w" not in device_data: + self._state = 0 + else: + self._state = int(device_data["w"]) + self._available = True + self.async_write_ha_state() From 03d8abe1ba22d5a536039a4881eba702eba1fb9c Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 27 Feb 2020 22:40:51 -0700 Subject: [PATCH 142/416] Fix unhandled exception in Ambient PWS_URL (#32238) * Fix unhandled exception in Ambient PWS_URL * Comment update * Be more explicit --- homeassistant/components/ambient_station/sensor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py index 6dc79cec326..c400d2ec97b 100644 --- a/homeassistant/components/ambient_station/sensor.py +++ b/homeassistant/components/ambient_station/sensor.py @@ -83,7 +83,11 @@ class AmbientWeatherSensor(AmbientWeatherEntity): w_m2_brightness_val = self._ambient.stations[self._mac_address][ ATTR_LAST_DATA ].get(TYPE_SOLARRADIATION) - self._state = round(float(w_m2_brightness_val) / 0.0079) + + if w_m2_brightness_val is None: + self._state = None + else: + self._state = round(float(w_m2_brightness_val) / 0.0079) else: self._state = self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get( self._sensor_type From b9fa32444aa634143b2ff25fab8b6c23c68ffdd4 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 28 Feb 2020 01:04:59 -0500 Subject: [PATCH 143/416] Support vizio pairing through config flow (#31520) * support pairing through config flow * simplify import failure log messages * remove unnecessary list comprehension * bump pyvizio to add passing ClientSession in where it was missed * show different message if user completes pairing through import * remove dupe failure message since reasons for failure are the same in both instances * remove extra constant * add host reachability check during pairing workflow * revert redundant connection check since check is implicitly done during pairing process * fix rebase errors * fix string * updates based on review * update docstring * missed commit * update import confirmation message to be less wordy * use ConfigFlow _abort_if_unique_id_configured * fix test --- homeassistant/components/vizio/__init__.py | 22 +- homeassistant/components/vizio/config_flow.py | 200 ++++++++++++++---- homeassistant/components/vizio/strings.json | 21 +- tests/components/vizio/conftest.py | 48 ++++- tests/components/vizio/const.py | 25 +++ tests/components/vizio/test_config_flow.py | 139 +++++++++++- 6 files changed, 388 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index 436ad829d94..88d600abce6 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -3,34 +3,14 @@ import asyncio import voluptuous as vol -from homeassistant.components.media_player import DEVICE_CLASS_TV from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, HomeAssistantType from .const import DOMAIN, VIZIO_SCHEMA - -def validate_auth(config: ConfigType) -> ConfigType: - """Validate presence of CONF_ACCESS_TOKEN when CONF_DEVICE_CLASS == DEVICE_CLASS_TV.""" - token = config.get(CONF_ACCESS_TOKEN) - if config[CONF_DEVICE_CLASS] == DEVICE_CLASS_TV and not token: - raise vol.Invalid( - f"When '{CONF_DEVICE_CLASS}' is '{DEVICE_CLASS_TV}' then " - f"'{CONF_ACCESS_TOKEN}' is required.", - path=[CONF_ACCESS_TOKEN], - ) - - return config - - CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.ensure_list, [vol.All(vol.Schema(VIZIO_SCHEMA), validate_auth)] - ) - }, + {DOMAIN: vol.All(cv.ensure_list, [vol.Schema(VIZIO_SCHEMA)])}, extra=vol.ALLOW_EXTRA, ) diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 969d387a26b..71fd606322e 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -1,4 +1,5 @@ """Config flow for Vizio.""" +import copy import logging from typing import Any, Dict @@ -7,24 +8,25 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.media_player import DEVICE_CLASS_SPEAKER, DEVICE_CLASS_TV -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_ZEROCONF, ConfigEntry from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME, + CONF_PIN, CONF_PORT, CONF_TYPE, ) from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession -from . import validate_auth from .const import ( CONF_VOLUME_STEP, DEFAULT_DEVICE_CLASS, DEFAULT_NAME, DEFAULT_VOLUME_STEP, + DEVICE_ID, DOMAIN, ) @@ -42,7 +44,7 @@ def _get_config_schema(input_dict: Dict[str, Any] = None) -> vol.Schema: CONF_NAME, default=input_dict.get(CONF_NAME, DEFAULT_NAME) ): str, vol.Required(CONF_HOST, default=input_dict.get(CONF_HOST)): str, - vol.Optional( + vol.Required( CONF_DEVICE_CLASS, default=input_dict.get(CONF_DEVICE_CLASS, DEFAULT_DEVICE_CLASS), ): vol.All(str, vol.Lower, vol.In([DEVICE_CLASS_TV, DEVICE_CLASS_SPEAKER])), @@ -54,6 +56,17 @@ def _get_config_schema(input_dict: Dict[str, Any] = None) -> vol.Schema: ) +def _get_pairing_schema(input_dict: Dict[str, Any] = None) -> vol.Schema: + """Return schema defaults for pairing data based on user input. Retain info already provided for future form views by setting them as defaults in schema.""" + if input_dict is None: + input_dict = {} + + return vol.Schema( + {vol.Required(CONF_PIN, default=input_dict.get(CONF_PIN, "")): str}, + extra=vol.ALLOW_EXTRA, + ) + + def _host_is_same(host1: str, host2: str) -> bool: """Check if host1 and host2 are the same.""" return host1.split(":")[0] == host2.split(":")[0] @@ -101,6 +114,27 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Initialize config flow.""" self._user_schema = None self._must_show_form = None + self._ch_type = None + self._pairing_token = None + self._data = None + + async def _create_entry_if_unique( + self, input_dict: Dict[str, Any] + ) -> Dict[str, Any]: + """Check if unique_id doesn't already exist. If it does, abort. If it doesn't, create entry.""" + unique_id = await VizioAsync.get_unique_id( + input_dict[CONF_HOST], + input_dict.get(CONF_ACCESS_TOKEN), + input_dict[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 self.async_create_entry(title=input_dict[CONF_NAME], data=input_dict) async def async_step_user( self, user_input: Dict[str, Any] = None @@ -116,17 +150,18 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): for entry in self.hass.config_entries.async_entries(DOMAIN): if _host_is_same(entry.data[CONF_HOST], user_input[CONF_HOST]): errors[CONF_HOST] = "host_exists" - break - if entry.data[CONF_NAME] == user_input[CONF_NAME]: errors[CONF_NAME] = "name_exists" - break if not errors: - try: - # Ensure schema passes custom validation, otherwise catch exception and add error - validate_auth(user_input) - + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + if self._must_show_form and self.context["source"] == SOURCE_ZEROCONF: + # Discovery should always display the config form before trying to + # create entry so that user can update default config options + self._must_show_form = False + elif user_input[ + CONF_DEVICE_CLASS + ] == DEVICE_CLASS_SPEAKER or user_input.get(CONF_ACCESS_TOKEN): # Ensure config is valid for a device if not await VizioAsync.validate_ha_config( user_input[CONF_HOST], @@ -135,38 +170,38 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): session=async_get_clientsession(self.hass, False), ): errors["base"] = "cant_connect" - except vol.Invalid: - errors["base"] = "tv_needs_token" - if not errors: - # Skip validating config and creating entry if form must be shown - if self._must_show_form: + if not errors: + return await self._create_entry_if_unique(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 + # wasn't included but is needed so that the user can choose to update + # their configuration.yaml or to proceed with config flow pairing. We + # will also provide contextual message to user explaining why + _LOGGER.warning( + "Couldn't complete configuration.yaml import: '%s' key is missing. To " + "complete setup, '%s' can be obtained by going through pairing process " + "via frontend Integrations menu; to avoid re-pairing your device in the " + "future, once you have finished pairing, it is recommended to add " + "obtained value to your config ", + CONF_ACCESS_TOKEN, + CONF_ACCESS_TOKEN, + ) self._must_show_form = False else: - # Abort flow if existing entry with same unique ID matches new config entry. - # Since name and host check have already passed, if an entry already exists, - # It is likely a reconfigured device. - 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), - ) + self._data = copy.deepcopy(user_input) + return await self.async_step_pair_tv() - if await self.async_set_unique_id( - unique_id=unique_id, raise_on_progress=True - ): - return self.async_abort( - reason="already_setup_with_diff_host_and_name" - ) - - return self.async_create_entry( - title=user_input[CONF_NAME], data=user_input - ) - - # Use user_input params as default values for schema if user_input is non-empty, otherwise use default schema schema = self._user_schema or _get_config_schema() + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + if errors and self.context["source"] == SOURCE_IMPORT: + # Log an error message if import config flow fails since otherwise failure is silent + _LOGGER.error( + "configuration.yaml import failure: %s", ", ".join(errors.values()) + ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def async_step_import(self, import_config: Dict[str, Any]) -> Dict[str, Any]: @@ -201,6 +236,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_setup") + self._must_show_form = True return await self.async_step_user(user_input=import_config) async def async_step_zeroconf( @@ -231,7 +267,95 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): discovery_info[CONF_HOST] ) - # Form must be shown after discovery so user can confirm/update configuration before ConfigEntry creation. + # Form must be shown after discovery so user can confirm/update configuration + # before ConfigEntry creation. self._must_show_form = True - return await self.async_step_user(user_input=discovery_info) + + async def async_step_pair_tv( + self, user_input: Dict[str, Any] = None + ) -> Dict[str, Any]: + """Start pairing process and ask user for PIN to complete pairing process.""" + errors = {} + + # Start pairing process if it hasn't already started + if not self._ch_type and not self._pairing_token: + dev = VizioAsync( + DEVICE_ID, + self._data[CONF_HOST], + self._data[CONF_NAME], + None, + self._data[CONF_DEVICE_CLASS], + session=async_get_clientsession(self.hass, False), + ) + pair_data = await dev.start_pair() + + if pair_data: + self._ch_type = pair_data.ch_type + self._pairing_token = pair_data.token + return await self.async_step_pair_tv() + + return self.async_show_form( + step_id="user", + data_schema=_get_config_schema(self._data), + errors={"base": "cant_connect"}, + ) + + # Complete pairing process if PIN has been provided + if user_input and user_input.get(CONF_PIN): + dev = VizioAsync( + DEVICE_ID, + self._data[CONF_HOST], + self._data[CONF_NAME], + None, + self._data[CONF_DEVICE_CLASS], + session=async_get_clientsession(self.hass, False), + ) + pair_data = await dev.pair( + self._ch_type, self._pairing_token, user_input[CONF_PIN] + ) + + if pair_data: + self._data[CONF_ACCESS_TOKEN] = pair_data.auth_token + self._must_show_form = True + + # 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 + return await self.async_step_pairing_complete_import() + + return await self.async_step_pairing_complete() + + # If no data was retrieved, it's assumed that the pairing attempt was not + # successful + errors[CONF_PIN] = "complete_pairing_failed" + + return self.async_show_form( + step_id="pair_tv", + data_schema=_get_pairing_schema(user_input), + errors=errors, + ) + + 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) + + self._must_show_form = False + return self.async_show_form( + step_id=step_id, + data_schema=vol.Schema({}), + description_placeholders={"access_token": self._data[CONF_ACCESS_TOKEN]}, + ) + + async def async_step_pairing_complete( + self, user_input: Dict[str, Any] = None + ) -> Dict[str, Any]: + """Complete non-import config flow by displaying final message to confirm pairing.""" + return await self._pairing_complete("pairing_complete") + + async def async_step_pairing_complete_import( + self, user_input: Dict[str, Any] = None + ) -> Dict[str, Any]: + """Complete import config flow by displaying final message to show user access token and give further instructions.""" + return await self._pairing_complete("pairing_complete_import") diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index d1890ee49ed..08414b7fe53 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -4,23 +4,38 @@ "step": { "user": { "title": "Setup Vizio SmartCast Device", + "description": "All fields are required except Access Token. If you choose not to provide an Access Token, and your Device Type is 'tv', you will go through a pairing process with your device so an Access Token can be retrieved.\n\nTo go through the pairing process, before clicking Submit, ensure your TV is powered on and connected to the network. You also need to be able to see the screen.", "data": { "name": "Name", "host": ":", "device_class": "Device Type", "access_token": "Access Token" } + }, + "pair_tv": { + "title": "Complete Pairing Process", + "description": "Your TV should be displaying a code. Enter that code into the form and then continue to the next step to complete the pairing.", + "data": { + "pin": "PIN" + } + }, + "pairing_complete": { + "title": "Pairing Complete", + "description": "Your Vizio SmartCast device is now connected to Home Assistant." + }, + "pairing_complete_import": { + "title": "Pairing Complete", + "description": "Your Vizio SmartCast device is now connected to Home Assistant.\n\nYour Access Token is '**{access_token}**'." } }, "error": { "host_exists": "Vizio device with specified host already configured.", "name_exists": "Vizio device with specified name already configured.", - "cant_connect": "Could not connect to the device. [Review the docs](https://www.home-assistant.io/integrations/vizio/) and re-verify that:\n- The device is powered on\n- The device is connected to the network\n- The values you filled in are accurate\nbefore attempting to resubmit.", - "tv_needs_token": "When Device Type is `tv` then a valid Access Token is needed." + "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.", + "cant_connect": "Could not connect to the device. [Review the docs](https://www.home-assistant.io/integrations/vizio/) and re-verify that:\n- The device is powered on\n- The device is connected to the network\n- The values you filled in are accurate\nbefore attempting to resubmit." }, "abort": { "already_setup": "This entry has already been setup.", - "already_setup_with_diff_host_and_name": "This entry appears to have already been setup with a different host and name based on its serial number. Please remove any old entries from your configuration.yaml and from the Integrations menu before reattempting to add this device.", "updated_entry": "This entry has already been setup but the name and/or options defined in the configuration do not match the previously imported configuration, so the configuration entry has been updated accordingly." } }, diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index c42f03db064..e0b1b727f3d 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -3,7 +3,18 @@ from asynctest import patch import pytest from pyvizio.const import DEVICE_CLASS_SPEAKER, MAX_VOLUME -from .const import CURRENT_INPUT, INPUT_LIST, MODEL, UNIQUE_ID, VERSION +from .const import ( + ACCESS_TOKEN, + CH_TYPE, + CURRENT_INPUT, + INPUT_LIST, + MODEL, + RESPONSE_TOKEN, + UNIQUE_ID, + VERSION, + MockCompletePairingResponse, + MockStartPairingResponse, +) class MockInput: @@ -42,6 +53,41 @@ def vizio_connect_fixture(): yield +@pytest.fixture(name="vizio_complete_pairing") +def vizio_complete_pairing_fixture(): + """Mock complete vizio pairing workflow.""" + with patch( + "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=MockCompletePairingResponse(ACCESS_TOKEN), + ): + yield + + +@pytest.fixture(name="vizio_start_pairing_failure") +def vizio_start_pairing_failure_fixture(): + """Mock vizio start pairing failure.""" + with patch( + "homeassistant.components.vizio.config_flow.VizioAsync.start_pair", + return_value=None, + ): + yield + + +@pytest.fixture(name="vizio_invalid_pin_failure") +def vizio_invalid_pin_failure_fixture(): + """Mock vizio failure due to invalid pin.""" + with patch( + "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, + ): + yield + + @pytest.fixture(name="vizio_bypass_setup") def vizio_bypass_setup_fixture(): """Mock component setup.""" diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index c241394737e..d3b6089a023 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME, + CONF_PIN, CONF_PORT, CONF_TYPE, ) @@ -29,6 +30,30 @@ UNIQUE_ID = "testid" MODEL = "model" VERSION = "version" +CH_TYPE = 1 +RESPONSE_TOKEN = 1234 +PIN = "abcd" + + +class MockStartPairingResponse(object): + """Mock Vizio start pairing response.""" + + def __init__(self, ch_type: int, token: int) -> None: + """Initialize mock start pairing response.""" + self.ch_type = ch_type + self.token = token + + +class MockCompletePairingResponse(object): + """Mock Vizio complete pairing response.""" + + def __init__(self, auth_token: str) -> None: + """Initialize mock complete pairing response.""" + self.auth_token = auth_token + + +MOCK_PIN_CONFIG = {CONF_PIN: PIN} + MOCK_USER_VALID_TV_CONFIG = { CONF_NAME: NAME, CONF_HOST: HOST, diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index 2dd32800c2d..c65c0eb46c2 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -1,4 +1,6 @@ """Tests for Vizio config flow.""" +import logging + import pytest import voluptuous as vol @@ -17,6 +19,7 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME, + CONF_PIN, ) from homeassistant.helpers.typing import HomeAssistantType @@ -25,6 +28,7 @@ from .const import ( HOST, HOST2, MOCK_IMPORT_VALID_TV_CONFIG, + MOCK_PIN_CONFIG, MOCK_SPEAKER_CONFIG, MOCK_TV_CONFIG_NO_TOKEN, MOCK_USER_VALID_TV_CONFIG, @@ -37,6 +41,8 @@ from .const import ( from tests.common import MockConfigEntry +_LOGGER = logging.getLogger(__name__) + async def test_user_flow_minimum_fields( hass: HomeAssistantType, @@ -197,7 +203,7 @@ async def test_user_esn_already_exists( ) assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "already_setup_with_diff_host_and_name" + assert result["reason"] == "already_configured" async def test_user_error_on_could_not_connect( @@ -212,18 +218,73 @@ async def test_user_error_on_could_not_connect( assert result["errors"] == {"base": "cant_connect"} -async def test_user_error_on_tv_needs_token( +async def test_user_tv_pairing( hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_bypass_setup: pytest.fixture, + vizio_complete_pairing: pytest.fixture, ) -> None: - """Test when config fails custom validation for non null access token when device_class = tv during user setup.""" + """Test pairing config flow when access token not provided for tv during user entry.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=MOCK_TV_CONFIG_NO_TOKEN ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {"base": "tv_needs_token"} + assert result["step_id"] == "pair_tv" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_PIN_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pairing_complete" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == NAME + assert result["data"][CONF_NAME] == NAME + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV + + +async def test_user_start_pairing_failure( + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_bypass_setup: pytest.fixture, + vizio_start_pairing_failure: pytest.fixture, +) -> None: + """Test failure to start pairing from user config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=MOCK_TV_CONFIG_NO_TOKEN + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cant_connect"} + + +async def test_user_invalid_pin( + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_bypass_setup: pytest.fixture, + vizio_invalid_pin_failure: pytest.fixture, +) -> None: + """Test failure to complete pairing from user config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=MOCK_TV_CONFIG_NO_TOKEN + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pair_tv" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_PIN_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pair_tv" + assert result["errors"] == {CONF_PIN: "complete_pairing_failed"} async def test_import_flow_minimum_fields( @@ -354,6 +415,76 @@ async def test_import_flow_update_name( assert hass.config_entries.async_get_entry(entry_id).data[CONF_NAME] == NAME2 +async def test_import_needs_pairing( + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_bypass_setup: pytest.fixture, + vizio_complete_pairing: pytest.fixture, +) -> None: + """Test pairing config flow when access token not provided for tv during import.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=MOCK_TV_CONFIG_NO_TOKEN + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_TV_CONFIG_NO_TOKEN + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pair_tv" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_PIN_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pairing_complete_import" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == NAME + assert result["data"][CONF_NAME] == NAME + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV + + +async def test_import_error( + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_bypass_setup: pytest.fixture, + caplog: pytest.fixture, +) -> None: + """Test that error is logged when import config has an error.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=vol.Schema(VIZIO_SCHEMA)(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_IMPORT}, + data=vol.Schema(VIZIO_SCHEMA)(fail_entry), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + # Ensure error gets logged + vizio_log_list = [ + log + for log in caplog.records + if log.name == "homeassistant.components.vizio.config_flow" + ] + assert len(vizio_log_list) == 1 + + async def test_zeroconf_flow( hass: HomeAssistantType, vizio_connect: pytest.fixture, From 02c170b9615f4bd0b9ba7f36b335341a9ba104a3 Mon Sep 17 00:00:00 2001 From: Ziv <16467659+ziv1234@users.noreply.github.com> Date: Fri, 28 Feb 2020 01:05:55 -0500 Subject: [PATCH 144/416] Dynalite listener for config entry update (#32116) * added entry listener that reloads the component * fixed update with existing entry * fixed import in test * fixes * library version * removed unique_id * fix * fixed for no entries in hass.data * fixed return value on abort * moved to use async_entries * removed unused import --- homeassistant/components/dynalite/__init__.py | 20 +++++++++++------ homeassistant/components/dynalite/bridge.py | 8 ++++++- .../components/dynalite/config_flow.py | 7 ++++-- .../components/dynalite/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/dynalite/common.py | 9 ++++++++ tests/components/dynalite/test_bridge.py | 2 +- tests/components/dynalite/test_config_flow.py | 22 ++++++++++++++----- 9 files changed, 54 insertions(+), 20 deletions(-) create mode 100755 tests/components/dynalite/common.py diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index f4fc65b8261..618268206b0 100755 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -108,22 +108,28 @@ async def async_setup(hass, config): return True +async def async_entry_changed(hass, entry): + """Reload entry since the data has changed.""" + LOGGER.debug("Reconfiguring entry %s", entry.data) + bridge = hass.data[DOMAIN][entry.entry_id] + await bridge.reload_config(entry.data) + LOGGER.debug("Reconfiguring entry finished %s", entry.data) + + async def async_setup_entry(hass, entry): """Set up a bridge from a config entry.""" LOGGER.debug("Setting up entry %s", entry.data) - bridge = DynaliteBridge(hass, entry.data) - + hass.data[DOMAIN][entry.entry_id] = bridge + entry.add_update_listener(async_entry_changed) if not await bridge.async_setup(): LOGGER.error("Could not set up bridge for entry %s", entry.data) + hass.data[DOMAIN].pop(entry.entry_id) return False - if not await bridge.try_connection(): - LOGGER.errot("Could not connect with entry %s", entry) + LOGGER.error("Could not connect with entry %s", entry) + hass.data[DOMAIN].pop(entry.entry_id) raise ConfigEntryNotReady - - hass.data[DOMAIN][entry.entry_id] = bridge - hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, "light") ) diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index cbe08fdadb5..f2ffe447d6c 100755 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -25,16 +25,22 @@ class DynaliteBridge: self.host = config[CONF_HOST] # Configure the dynalite devices self.dynalite_devices = DynaliteDevices( - config=config, newDeviceFunc=self.add_devices_when_registered, updateDeviceFunc=self.update_device, ) + self.dynalite_devices.configure(config) async def async_setup(self): """Set up a Dynalite bridge.""" # Configure the dynalite devices + LOGGER.debug("Setting up bridge - host %s", self.host) return await self.dynalite_devices.async_setup() + async def reload_config(self, config): + """Reconfigure a bridge when config changes.""" + LOGGER.debug("Setting up bridge - host %s, config %s", self.host, config) + self.dynalite_devices.configure(config) + def update_signal(self, device=None): """Create signal to use to trigger entity update.""" if device: diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index aac42172181..c5508bc8db2 100755 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -22,8 +22,11 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Import a new bridge as a config entry.""" LOGGER.debug("Starting async_step_import - %s", import_info) host = import_info[CONF_HOST] - await self.async_set_unique_id(host) - self._abort_if_unique_id_configured(import_info) + for entry in self.hass.config_entries.async_entries(DOMAIN): + if entry.data[CONF_HOST] == host: + if entry.data != import_info: + self.hass.config_entries.async_update_entry(entry, data=import_info) + return self.async_abort(reason="already_configured") # New entry bridge = DynaliteBridge(self.hass, import_info) if not await bridge.async_setup(): diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json index 95667733d38..fc552ea6ad1 100755 --- a/homeassistant/components/dynalite/manifest.json +++ b/homeassistant/components/dynalite/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/dynalite", "dependencies": [], "codeowners": ["@ziv1234"], - "requirements": ["dynalite_devices==0.1.22"] + "requirements": ["dynalite_devices==0.1.26"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5e4d37c2ba8..b7f02fec5ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -460,7 +460,7 @@ dsmr_parser==0.18 dweepy==0.3.0 # homeassistant.components.dynalite -dynalite_devices==0.1.22 +dynalite_devices==0.1.26 # homeassistant.components.rainforest_eagle eagle200_reader==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c16dbdebb4e..4f92b1ac027 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -171,7 +171,7 @@ distro==1.4.0 dsmr_parser==0.18 # homeassistant.components.dynalite -dynalite_devices==0.1.22 +dynalite_devices==0.1.26 # homeassistant.components.ee_brightbox eebrightbox==0.0.4 diff --git a/tests/components/dynalite/common.py b/tests/components/dynalite/common.py new file mode 100755 index 00000000000..3bdf3a60dd7 --- /dev/null +++ b/tests/components/dynalite/common.py @@ -0,0 +1,9 @@ +"""Common functions for the Dynalite tests.""" + +from homeassistant.components import dynalite + + +def get_bridge_from_hass(hass_obj): + """Get the bridge from hass.data.""" + key = next(iter(hass_obj.data[dynalite.DOMAIN])) + return hass_obj.data[dynalite.DOMAIN][key] diff --git a/tests/components/dynalite/test_bridge.py b/tests/components/dynalite/test_bridge.py index 133e03d9f3d..7b3a8312402 100755 --- a/tests/components/dynalite/test_bridge.py +++ b/tests/components/dynalite/test_bridge.py @@ -2,7 +2,7 @@ from unittest.mock import Mock, call from asynctest import patch -from dynalite_lib import CONF_ALL +from dynalite_devices_lib import CONF_ALL import pytest from homeassistant.components import dynalite diff --git a/tests/components/dynalite/test_config_flow.py b/tests/components/dynalite/test_config_flow.py index 1f8be61f646..fb8530aec1e 100755 --- a/tests/components/dynalite/test_config_flow.py +++ b/tests/components/dynalite/test_config_flow.py @@ -4,6 +4,8 @@ from asynctest import patch from homeassistant import config_entries from homeassistant.components import dynalite +from .common import get_bridge_from_hass + from tests.common import MockConfigEntry @@ -70,21 +72,29 @@ async def test_existing(hass): async def test_existing_update(hass): """Test when the entry exists with the same config.""" host = "1.2.3.4" - mock_entry = MockConfigEntry( - domain=dynalite.DOMAIN, unique_id=host, data={dynalite.CONF_HOST: host} - ) - mock_entry.add_to_hass(hass) + port1 = 7777 + port2 = 8888 with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", return_value=True, ), patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.available", True ): + assert await hass.config_entries.flow.async_init( + dynalite.DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={dynalite.CONF_HOST: host, dynalite.CONF_PORT: port1}, + ) + await hass.async_block_till_done() + old_bridge = get_bridge_from_hass(hass) + assert old_bridge.dynalite_devices.port == port1 result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data={dynalite.CONF_HOST: host, "aaa": "bbb"}, + data={dynalite.CONF_HOST: host, dynalite.CONF_PORT: port2}, ) + await hass.async_block_till_done() assert result["type"] == "abort" assert result["reason"] == "already_configured" - assert mock_entry.data.get("aaa") == "bbb" + bridge = get_bridge_from_hass(hass) + assert bridge.dynalite_devices.port == port2 From 1d962aeb65654cc9da7ef7712fe36de6d18c6792 Mon Sep 17 00:00:00 2001 From: Tim van Cann Date: Fri, 28 Feb 2020 07:12:01 +0100 Subject: [PATCH 145/416] Add Avri waste collection sensor (#31134) * Add Avri waste collection sensor * Apply black formatting * Update manifest * Add requirements * Add sensor to coverage * Update import order * Bump dependency to include todays pickup * Bump avri version in requirements_all.txt * Code review comments * Reduce scan interval to 4 hours This makes sure that no matter what happens, in the morning the correct dates have been pulled without the old ones lingering for too long. * Better logging * Made scan interval a timedelta * Fix import order * Update homeassistant/components/avri/sensor.py Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> * Update homeassistant/components/avri/sensor.py Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> * Use filter instead of break statement * Use positive int for house number extension * Switch voluptuous types for house number and house number extension * Update homeassistant/components/avri/sensor.py Co-Authored-By: Paulus Schoutsen * Implement `available` * Bump avri api * Code review comments * Replace `postcode` with `zip_code` * Update logic for `available` * Remove variable for delimiter Co-authored-by: springstan <46536646+springstan@users.noreply.github.com> Co-authored-by: Paulus Schoutsen --- .coveragerc | 1 + CODEOWNERS | 1 + homeassistant/components/avri/__init__.py | 1 + homeassistant/components/avri/manifest.json | 8 ++ homeassistant/components/avri/sensor.py | 116 ++++++++++++++++++++ requirements_all.txt | 3 + 6 files changed, 130 insertions(+) create mode 100644 homeassistant/components/avri/__init__.py create mode 100644 homeassistant/components/avri/manifest.json create mode 100644 homeassistant/components/avri/sensor.py diff --git a/.coveragerc b/.coveragerc index fe5242327dc..94b7deea82b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -65,6 +65,7 @@ omit = homeassistant/components/automatic/device_tracker.py homeassistant/components/avea/light.py homeassistant/components/avion/light.py + homeassistant/components/avri/sensor.py homeassistant/components/azure_event_hub/* homeassistant/components/azure_service_bus/* homeassistant/components/baidu/tts.py diff --git a/CODEOWNERS b/CODEOWNERS index 411c615e857..d72697f65c0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -41,6 +41,7 @@ homeassistant/components/auth/* @home-assistant/core homeassistant/components/automatic/* @armills homeassistant/components/automation/* @home-assistant/core homeassistant/components/avea/* @pattyland +homeassistant/components/avri/* @timvancann homeassistant/components/awair/* @danielsjf homeassistant/components/aws/* @awarecan @robbiet480 homeassistant/components/axis/* @kane610 diff --git a/homeassistant/components/avri/__init__.py b/homeassistant/components/avri/__init__.py new file mode 100644 index 00000000000..4d99b2ed0e4 --- /dev/null +++ b/homeassistant/components/avri/__init__.py @@ -0,0 +1 @@ +"""The avri component.""" diff --git a/homeassistant/components/avri/manifest.json b/homeassistant/components/avri/manifest.json new file mode 100644 index 00000000000..4ff77ccd2f6 --- /dev/null +++ b/homeassistant/components/avri/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "avri", + "name": "Avri", + "documentation": "https://www.home-assistant.io/integrations/avri", + "requirements": ["avri-api==0.1.6"], + "dependencies": [], + "codeowners": ["@timvancann"] +} diff --git a/homeassistant/components/avri/sensor.py b/homeassistant/components/avri/sensor.py new file mode 100644 index 00000000000..a221147f065 --- /dev/null +++ b/homeassistant/components/avri/sensor.py @@ -0,0 +1,116 @@ +"""Support for Avri waste curbside collection pickup.""" +from datetime import timedelta +import logging + +from avri.api import Avri, AvriException +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) +CONF_COUNTRY_CODE = "country_code" +CONF_ZIP_CODE = "zip_code" +CONF_HOUSE_NUMBER = "house_number" +CONF_HOUSE_NUMBER_EXTENSION = "house_number_extension" +DEFAULT_NAME = "avri" +ICON = "mdi:trash-can-outline" +SCAN_INTERVAL = timedelta(hours=4) +DEFAULT_COUNTRY_CODE = "NL" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_ZIP_CODE): cv.string, + vol.Required(CONF_HOUSE_NUMBER): cv.positive_int, + vol.Optional(CONF_HOUSE_NUMBER_EXTENSION): cv.string, + vol.Optional(CONF_COUNTRY_CODE, default=DEFAULT_COUNTRY_CODE): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Avri Waste platform.""" + client = Avri( + postal_code=config[CONF_ZIP_CODE], + house_nr=config[CONF_HOUSE_NUMBER], + house_nr_extension=config.get(CONF_HOUSE_NUMBER_EXTENSION), + country_code=config[CONF_COUNTRY_CODE], + ) + + try: + each_upcoming = client.upcoming_of_each() + except AvriException as ex: + raise PlatformNotReady from ex + else: + entities = [ + AvriWasteUpcoming(config[CONF_NAME], client, upcoming.name) + for upcoming in each_upcoming + ] + add_entities(entities, True) + + +class AvriWasteUpcoming(Entity): + """Avri Waste Sensor.""" + + def __init__(self, name: str, client: Avri, waste_type: str): + """Initialize the sensor.""" + self._waste_type = waste_type + self._name = f"{name}_{self._waste_type}" + self._state = None + self._client = client + self._state_available = False + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return ( + f"{self._waste_type}" + f"-{self._client.country_code}" + f"-{self._client.postal_code}" + f"-{self._client.house_nr}" + f"-{self._client.house_nr_extension}" + ) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def available(self): + """Return True if entity is available.""" + return self._state_available + + @property + def icon(self): + """Icon to use in the frontend.""" + return ICON + + def update(self): + """Update device state.""" + try: + pickup_events = self._client.upcoming_of_each() + except AvriException as ex: + _LOGGER.error( + "There was an error retrieving upcoming garbage pickups: %s", ex + ) + self._state_available = False + self._state = None + else: + self._state_available = True + matched_events = list( + filter(lambda event: event.name == self._waste_type, pickup_events) + ) + if not matched_events: + self._state = None + else: + self._state = matched_events[0].day.date() diff --git a/requirements_all.txt b/requirements_all.txt index b7f02fec5ca..cf22961e506 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -278,6 +278,9 @@ avea==1.4 # homeassistant.components.avion # avion==0.10 +# homeassistant.components.avri +avri-api==0.1.6 + # homeassistant.components.axis axis==25 From 2c1c395c28a6fa10fcd9d56f6592ef14bab68f66 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Fri, 28 Feb 2020 03:49:47 -0500 Subject: [PATCH 146/416] Set min and max temp only if it is not None (#32262) --- homeassistant/components/generic_thermostat/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py index 23ee049052c..8714ddcfbe6 100644 --- a/homeassistant/components/generic_thermostat/climate.py +++ b/homeassistant/components/generic_thermostat/climate.py @@ -335,7 +335,7 @@ class GenericThermostat(ClimateDevice, RestoreEntity): @property def min_temp(self): """Return the minimum temperature.""" - if self._min_temp: + if self._min_temp is not None: return self._min_temp # get default temp from super class @@ -344,7 +344,7 @@ class GenericThermostat(ClimateDevice, RestoreEntity): @property def max_temp(self): """Return the maximum temperature.""" - if self._max_temp: + if self._max_temp is not None: return self._max_temp # Get default temp from super class From 2390a7f365a78bf9c33f0711ddd4a7adfa3e77c8 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 28 Feb 2020 11:46:06 +0100 Subject: [PATCH 147/416] Pass in aiohttp session for onvif (#32045) --- homeassistant/components/onvif/camera.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index acedf229bdb..f87da72936d 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -11,6 +11,7 @@ from haffmpeg.tools import IMAGE_JPEG, ImageFrame import onvif from onvif import ONVIFCamera, exceptions import voluptuous as vol +from zeep.asyncio import AsyncTransport from zeep.exceptions import Fault from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera @@ -25,7 +26,10 @@ from homeassistant.const import ( CONF_USERNAME, ) from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.helpers.aiohttp_client import ( + async_aiohttp_proxy_stream, + async_get_clientsession, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import async_extract_entity_ids import homeassistant.util.dt as dt_util @@ -143,12 +147,15 @@ class ONVIFHassCamera(Camera): "Setting up the ONVIF camera device @ '%s:%s'", self._host, self._port ) + session = async_get_clientsession(hass) + transport = AsyncTransport(None, session=session) self._camera = ONVIFCamera( self._host, self._port, self._username, self._password, "{}/wsdl/".format(os.path.dirname(onvif.__file__)), + transport=transport, ) async def async_initialize(self): From adb3bb3653efa678ad23c8ba1e3bde5f8c077a28 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Fri, 28 Feb 2020 20:47:49 +1000 Subject: [PATCH 148/416] Explicitly set unique ids for GDACS integration (#32203) * explicitly set unique ids * use config flow's unique id * use config's unique id --- homeassistant/components/gdacs/__init__.py | 6 +++++- homeassistant/components/gdacs/geo_location.py | 12 +++++++++--- homeassistant/components/gdacs/sensor.py | 10 ++++++++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/gdacs/__init__.py b/homeassistant/components/gdacs/__init__.py index 8b00b2b3ff1..8144b7667ca 100644 --- a/homeassistant/components/gdacs/__init__.py +++ b/homeassistant/components/gdacs/__init__.py @@ -190,7 +190,11 @@ class GdacsFeedEntityManager: async def _generate_entity(self, external_id): """Generate new entity.""" async_dispatcher_send( - self._hass, self.async_event_new_entity(), self, external_id + self._hass, + self.async_event_new_entity(), + self, + self._config_entry.unique_id, + external_id, ) async def _update_entity(self, external_id): diff --git a/homeassistant/components/gdacs/geo_location.py b/homeassistant/components/gdacs/geo_location.py index 616be5a5e18..31c3ba4138c 100644 --- a/homeassistant/components/gdacs/geo_location.py +++ b/homeassistant/components/gdacs/geo_location.py @@ -49,9 +49,9 @@ async def async_setup_entry(hass, entry, async_add_entities): manager = hass.data[DOMAIN][FEED][entry.entry_id] @callback - def async_add_geolocation(feed_manager, external_id): + def async_add_geolocation(feed_manager, integration_id, external_id): """Add gelocation entity from feed.""" - new_entity = GdacsEvent(feed_manager, external_id) + new_entity = GdacsEvent(feed_manager, integration_id, external_id) _LOGGER.debug("Adding geolocation %s", new_entity) async_add_entities([new_entity], True) @@ -69,9 +69,10 @@ async def async_setup_entry(hass, entry, async_add_entities): class GdacsEvent(GeolocationEvent): """This represents an external event with GDACS feed data.""" - def __init__(self, feed_manager, external_id): + def __init__(self, feed_manager, integration_id, external_id): """Initialize entity with data from feed entry.""" self._feed_manager = feed_manager + self._integration_id = integration_id self._external_id = external_id self._title = None self._distance = None @@ -162,6 +163,11 @@ class GdacsEvent(GeolocationEvent): self._vulnerability = round(self._vulnerability, 1) self._version = feed_entry.version + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID containing latitude/longitude and external id.""" + return f"{self._integration_id}_{self._external_id}" + @property def icon(self): """Return the icon to use in the frontend, if any.""" diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 7ef2855a9be..fbbb199499b 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -28,7 +28,7 @@ PARALLEL_UPDATES = 0 async def async_setup_entry(hass, entry, async_add_entities): """Set up the GDACS Feed platform.""" manager = hass.data[DOMAIN][FEED][entry.entry_id] - sensor = GdacsSensor(entry.entry_id, entry.title, manager) + sensor = GdacsSensor(entry.entry_id, entry.unique_id, entry.title, manager) async_add_entities([sensor]) _LOGGER.debug("Sensor setup done") @@ -36,9 +36,10 @@ async def async_setup_entry(hass, entry, async_add_entities): class GdacsSensor(Entity): """This is a status sensor for the GDACS integration.""" - def __init__(self, config_entry_id, config_title, manager): + def __init__(self, config_entry_id, config_unique_id, config_title, manager): """Initialize entity.""" self._config_entry_id = config_entry_id + self._config_unique_id = config_unique_id self._config_title = config_title self._manager = manager self._status = None @@ -107,6 +108,11 @@ class GdacsSensor(Entity): """Return the state of the sensor.""" return self._total + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID containing latitude/longitude.""" + return self._config_unique_id + @property def name(self) -> Optional[str]: """Return the name of the entity.""" From 7714160f4cc751326e9f50da6afc730829270854 Mon Sep 17 00:00:00 2001 From: Marius <33354141+Mariusthvdb@users.noreply.github.com> Date: Fri, 28 Feb 2020 12:34:39 +0100 Subject: [PATCH 149/416] Change github sensor state to short commit sha (#31581) * change state to latest_release_url to have a meaningful frontend representation. self._github_data.latest_commit_sha is still available in the attributes, so no info is lost * template state to show only the tag and not the full url to the tag * add guard to update(self) for state * add empty line 150 black... * Update sensor.py * add SHA if no release url * Correct sha to 7 digits * take out fallback on state --- homeassistant/components/github/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index c77cf7930b8..406876de807 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -132,12 +132,12 @@ class GitHubSensor(Entity): self._github_data.update() self._name = self._github_data.name - self._state = self._github_data.latest_commit_sha self._repository_path = self._github_data.repository_path self._available = self._github_data.available self._latest_commit_message = self._github_data.latest_commit_message self._latest_commit_sha = self._github_data.latest_commit_sha self._latest_release_url = self._github_data.latest_release_url + self._state = self._github_data.latest_commit_sha[0:8] self._open_issue_count = self._github_data.open_issue_count self._latest_open_issue_url = self._github_data.latest_open_issue_url self._pull_request_count = self._github_data.pull_request_count From 157f972d722d4772e9b20f37829e10968fcb6ca8 Mon Sep 17 00:00:00 2001 From: springstan <46536646+springstan@users.noreply.github.com> Date: Fri, 28 Feb 2020 12:39:29 +0100 Subject: [PATCH 150/416] Use f-strings in integrations starting with "H" - "L" (#32265) * Use f-strings in integrations starting with "H" * Use f-strings in integrations starting with "I" * Use f-strings in integrations starting with "J" * Use f-strings in integrations starting with "K" * Use f-strings in integrations starting with "L" * Fix lint error * Use join instead of f-string in homekit_controller * Use local variables with f-strings * Fix lint error * Escape the characters in f-string * Sort imports with isort in homeworks light * Fix pylint error * Fix broken tests * Fix broken tests v2 --- homeassistant/components/habitica/__init__.py | 2 +- homeassistant/components/harmony/remote.py | 4 +--- .../components/haveibeenpwned/sensor.py | 10 ++++---- homeassistant/components/heos/config_flow.py | 4 +--- .../components/here_travel_time/sensor.py | 4 ++-- .../components/hikvision/binary_sensor.py | 2 +- homeassistant/components/hlk_sw16/__init__.py | 8 +++---- .../components/homekit/type_media_players.py | 4 ++-- homeassistant/components/homekit/util.py | 8 +++---- .../homekit_controller/config_flow.py | 2 +- homeassistant/components/homematic/entity.py | 2 +- .../components/homeworks/__init__.py | 5 ++-- homeassistant/components/homeworks/light.py | 11 ++------- homeassistant/components/hp_ilo/sensor.py | 4 +--- homeassistant/components/html5/notify.py | 2 +- homeassistant/components/http/auth.py | 18 ++++---------- homeassistant/components/http/ban.py | 4 +--- homeassistant/components/http/view.py | 4 +--- .../components/huawei_lte/__init__.py | 4 +--- .../components/hydrawise/__init__.py | 9 ++----- .../components/ign_sismologia/geo_location.py | 11 ++++----- homeassistant/components/ihc/__init__.py | 3 +-- homeassistant/components/ihc/binary_sensor.py | 4 ++-- homeassistant/components/ihc/light.py | 4 ++-- homeassistant/components/ihc/sensor.py | 4 ++-- homeassistant/components/ihc/switch.py | 4 ++-- .../components/imap_email_content/sensor.py | 2 +- .../components/incomfort/binary_sensor.py | 7 ++++-- homeassistant/components/incomfort/climate.py | 4 ++-- homeassistant/components/incomfort/sensor.py | 4 ++-- .../components/incomfort/water_heater.py | 7 ++++-- homeassistant/components/influxdb/sensor.py | 4 +--- .../components/input_boolean/__init__.py | 4 +--- .../components/input_datetime/__init__.py | 3 +-- .../components/input_number/__init__.py | 3 +-- .../components/input_select/__init__.py | 7 ++---- .../components/input_text/__init__.py | 3 +-- homeassistant/components/insteon/utils.py | 24 +++++++------------ .../components/integration/sensor.py | 4 ++-- homeassistant/components/ios/notify.py | 4 ++-- homeassistant/components/ios/sensor.py | 4 ++-- homeassistant/components/iperf3/sensor.py | 2 +- homeassistant/components/ipma/const.py | 3 +-- homeassistant/components/iqvia/sensor.py | 2 +- .../components/islamic_prayer_times/sensor.py | 8 +++---- homeassistant/components/isy994/__init__.py | 2 +- .../components/isy994/binary_sensor.py | 2 +- homeassistant/components/izone/climate.py | 2 +- homeassistant/components/juicenet/__init__.py | 2 +- homeassistant/components/juicenet/sensor.py | 2 +- homeassistant/components/knx/__init__.py | 4 ++-- homeassistant/components/kodi/media_player.py | 8 +++---- homeassistant/components/kodi/notify.py | 2 +- .../components/konnected/binary_sensor.py | 4 ++-- homeassistant/components/konnected/const.py | 1 - .../components/konnected/handlers.py | 10 ++++---- homeassistant/components/konnected/panel.py | 5 +--- homeassistant/components/konnected/sensor.py | 8 +++---- homeassistant/components/konnected/switch.py | 5 ++-- homeassistant/components/lastfm/sensor.py | 2 +- homeassistant/components/lcn/const.py | 4 +--- homeassistant/components/life360/__init__.py | 4 +--- .../components/life360/device_tracker.py | 12 +++++----- homeassistant/components/lifx_cloud/scene.py | 5 ++-- homeassistant/components/light/intent.py | 6 ++--- homeassistant/components/locative/__init__.py | 4 +--- homeassistant/components/lockitron/lock.py | 11 +++++---- homeassistant/components/logbook/__init__.py | 12 +++------- .../components/logi_circle/__init__.py | 15 +++++------- .../components/logi_circle/sensor.py | 10 ++++---- .../components/lovelace/dashboard.py | 2 +- homeassistant/components/luftdaten/sensor.py | 2 +- homeassistant/components/lupusec/__init__.py | 4 +--- homeassistant/components/lutron/scene.py | 4 +--- homeassistant/components/lyft/sensor.py | 2 +- tests/components/homekit_controller/common.py | 2 +- .../components/universal/test_media_player.py | 6 ++--- 77 files changed, 158 insertions(+), 246 deletions(-) diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 52326555aab..78c47bf9635 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -80,7 +80,7 @@ SERVICE_API_CALL = "api_call" ATTR_NAME = CONF_NAME ATTR_PATH = CONF_PATH ATTR_ARGS = "args" -EVENT_API_CALL_SUCCESS = "{0}_{1}_{2}".format(DOMAIN, SERVICE_API_CALL, "success") +EVENT_API_CALL_SUCCESS = f"{DOMAIN}_{SERVICE_API_CALL}_success" SERVICE_API_CALL_SCHEMA = vol.Schema( { diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index bcc9d72ad08..126ce0ff992 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -111,9 +111,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= activity, ) - harmony_conf_file = hass.config.path( - "{}{}{}".format("harmony_", slugify(name), ".conf") - ) + harmony_conf_file = hass.config.path(f"harmony_{slugify(name)}.conf") try: device = HarmonyRemote( name, address, port, activity, harmony_conf_file, delay_secs diff --git a/homeassistant/components/haveibeenpwned/sensor.py b/homeassistant/components/haveibeenpwned/sensor.py index 99f94499478..00a39aae8f4 100644 --- a/homeassistant/components/haveibeenpwned/sensor.py +++ b/homeassistant/components/haveibeenpwned/sensor.py @@ -81,13 +81,11 @@ class HaveIBeenPwnedSensor(Entity): return val for idx, value in enumerate(self._data.data[self._email]): - tmpname = "breach {}".format(idx + 1) - tmpvalue = "{} {}".format( - value["Title"], - dt_util.as_local(dt_util.parse_datetime(value["AddedDate"])).strftime( - DATE_STR_FORMAT - ), + tmpname = f"breach {idx + 1}" + datetime_local = dt_util.as_local( + dt_util.parse_datetime(value["AddedDate"]) ) + tmpvalue = f"{value['Title']} {datetime_local.strftime(DATE_STR_FORMAT)}" val[tmpname] = tmpvalue return val diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py index 7e7fe067874..91dbc19ac95 100644 --- a/homeassistant/components/heos/config_flow.py +++ b/homeassistant/components/heos/config_flow.py @@ -27,9 +27,7 @@ class HeosFlowHandler(config_entries.ConfigFlow): """Handle a discovered Heos device.""" # Store discovered host hostname = urlparse(discovery_info[ssdp.ATTR_SSDP_LOCATION]).hostname - friendly_name = "{} ({})".format( - discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME], hostname - ) + friendly_name = f"{discovery_info[ssdp.ATTR_UPNP_FRIENDLY_NAME]} ({hostname})" self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {}) self.hass.data[DATA_DISCOVERED_HOSTS][friendly_name] = hostname # Abort if other flows in progress or an entry already exists diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 8113548b5ca..4c7652484d6 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -315,7 +315,7 @@ class HERETravelTimeSensor(Entity): return self._get_location_from_attributes(entity) # Check if device is in a zone - zone_entity = self.hass.states.get("zone.{}".format(entity.state)) + zone_entity = self.hass.states.get(f"zone.{entity.state}") if location.has_location(zone_entity): _LOGGER.debug( "%s is in %s, getting zone location", entity_id, zone_entity.entity_id @@ -348,7 +348,7 @@ class HERETravelTimeSensor(Entity): def _get_location_from_attributes(entity: State) -> str: """Get the lat/long string from an entities attributes.""" attr = entity.attributes - return "{},{}".format(attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)) + return f"{attr.get(ATTR_LATITUDE)},{attr.get(ATTR_LONGITUDE)}" class HERETravelTimeData: diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py index 9db91217300..140f6908dce 100644 --- a/homeassistant/components/hikvision/binary_sensor.py +++ b/homeassistant/components/hikvision/binary_sensor.py @@ -109,7 +109,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for channel in channel_list: # Build sensor name, then parse customize config. if data.type == "NVR": - sensor_name = "{}_{}".format(sensor.replace(" ", "_"), channel[1]) + sensor_name = f"{sensor.replace(' ', '_')}_{channel[1]}" else: sensor_name = sensor.replace(" ", "_") diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py index 5ab16ed17e6..1750e9b0ff4 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -30,8 +30,6 @@ DEFAULT_PORT = 8080 DOMAIN = "hlk_sw16" -SIGNAL_AVAILABILITY = "hlk_sw16_device_available_{}" - SWITCH_SCHEMA = vol.Schema({vol.Optional(CONF_NAME): cv.string}) RELAY_ID = vol.All( @@ -74,13 +72,13 @@ async def async_setup(hass, config): def disconnected(): """Schedule reconnect after connection has been lost.""" _LOGGER.warning("HLK-SW16 %s disconnected", device) - async_dispatcher_send(hass, SIGNAL_AVAILABILITY.format(device), False) + async_dispatcher_send(hass, f"hlk_sw16_device_available_{device}", False) @callback def reconnected(): """Schedule reconnect after connection has been lost.""" _LOGGER.warning("HLK-SW16 %s connected", device) - async_dispatcher_send(hass, SIGNAL_AVAILABILITY.format(device), True) + async_dispatcher_send(hass, f"hlk_sw16_device_available_{device}", True) async def connect(): """Set up connection and hook it into HA for reconnect/shutdown.""" @@ -168,6 +166,6 @@ class SW16Device(Entity): self._is_on = await self._client.status(self._device_port) async_dispatcher_connect( self.hass, - SIGNAL_AVAILABILITY.format(self._device_id), + f"hlk_sw16_device_available_{self._device_id}", self._availability_callback, ) diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py index 3c5dce4fa7a..e07e9cb4749 100644 --- a/homeassistant/components/homekit/type_media_players.py +++ b/homeassistant/components/homekit/type_media_players.py @@ -146,7 +146,7 @@ class MediaPlayer(HomeAccessory): def generate_service_name(self, mode): """Generate name for individual service.""" - return "{} {}".format(self.display_name, MODE_FRIENDLY_NAME[mode]) + return f"{self.display_name} {MODE_FRIENDLY_NAME[mode]}" def set_on_off(self, value): """Move switch state to value if call came from HomeKit.""" @@ -287,7 +287,7 @@ class TelevisionMediaPlayer(HomeAccessory): ) serv_tv.add_linked_service(serv_speaker) - name = "{} {}".format(self.display_name, "Volume") + name = f"{self.display_name} Volume" serv_speaker.configure_char(CHAR_NAME, value=name) serv_speaker.configure_char(CHAR_ACTIVE, value=1) diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 0fe97cfca63..c12f49e1b9c 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -102,9 +102,7 @@ def validate_entity_config(values): domain, _ = split_entity_id(entity) if not isinstance(config, dict): - raise vol.Invalid( - "The configuration for {} must be a dictionary.".format(entity) - ) + raise vol.Invalid(f"The configuration for {entity} must be a dictionary.") if domain in ("alarm_control_panel", "lock"): config = CODE_SCHEMA(config) @@ -212,8 +210,8 @@ def show_setup_message(hass, pincode): pin = pincode.decode() _LOGGER.info("Pincode: %s", pin) message = ( - "To set up Home Assistant in the Home App, enter the " - "following code:\n### {}".format(pin) + f"To set up Home Assistant in the Home App, enter the " + f"following code:\n### {pin}" ) hass.components.persistent_notification.create( message, "HomeKit Setup", HOMEKIT_NOTIFY_ID diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index dbfb8dcbcd9..81dcfdc8f9a 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -73,7 +73,7 @@ def ensure_pin_format(pin): match = PIN_FORMAT.search(pin) if not match: raise aiohomekit.exceptions.MalformedPinError(f"Invalid PIN code f{pin}") - return "{}-{}-{}".format(*match.groups()) + return "-".join(match.groups()) @config_entries.HANDLERS.register(DOMAIN) diff --git a/homeassistant/components/homematic/entity.py b/homeassistant/components/homematic/entity.py index 54811c3ccdf..49d3ee1f170 100644 --- a/homeassistant/components/homematic/entity.py +++ b/homeassistant/components/homematic/entity.py @@ -200,7 +200,7 @@ class HMHub(Entity): def __init__(self, hass, homematic, name): """Initialize HomeMatic hub.""" self.hass = hass - self.entity_id = "{}.{}".format(DOMAIN, name.lower()) + self.entity_id = f"{DOMAIN}.{name.lower()}" self._homematic = homematic self._variables = {} self._name = name diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py index c6296d8f4c6..7ae3d30de90 100644 --- a/homeassistant/components/homeworks/__init__.py +++ b/homeassistant/components/homeworks/__init__.py @@ -22,7 +22,6 @@ _LOGGER = logging.getLogger(__name__) DOMAIN = "homeworks" HOMEWORKS_CONTROLLER = "homeworks" -ENTITY_SIGNAL = "homeworks_entity_{}" EVENT_BUTTON_PRESS = "homeworks_button_press" EVENT_BUTTON_RELEASE = "homeworks_button_release" @@ -71,7 +70,7 @@ def setup(hass, base_config): """Dispatch state changes.""" _LOGGER.debug("callback: %s, %s", msg_type, values) addr = values[0] - signal = ENTITY_SIGNAL.format(addr) + signal = f"homeworks_entity_{addr}" dispatcher_send(hass, signal, msg_type, values) config = base_config.get(DOMAIN) @@ -132,7 +131,7 @@ class HomeworksKeypadEvent: self._addr = addr self._name = name self._id = slugify(self._name) - signal = ENTITY_SIGNAL.format(self._addr) + signal = f"homeworks_entity_{self._addr}" async_dispatcher_connect(self._hass, signal, self._update_callback) @callback diff --git a/homeassistant/components/homeworks/light.py b/homeassistant/components/homeworks/light.py index 2c0034ee986..56d5bcacc47 100644 --- a/homeassistant/components/homeworks/light.py +++ b/homeassistant/components/homeworks/light.py @@ -8,14 +8,7 @@ from homeassistant.const import CONF_NAME from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import ( - CONF_ADDR, - CONF_DIMMERS, - CONF_RATE, - ENTITY_SIGNAL, - HOMEWORKS_CONTROLLER, - HomeworksDevice, -) +from . import CONF_ADDR, CONF_DIMMERS, CONF_RATE, HOMEWORKS_CONTROLLER, HomeworksDevice _LOGGER = logging.getLogger(__name__) @@ -47,7 +40,7 @@ class HomeworksLight(HomeworksDevice, Light): async def async_added_to_hass(self): """Call when entity is added to hass.""" - signal = ENTITY_SIGNAL.format(self._addr) + signal = f"homeworks_entity_{self._addr}" _LOGGER.debug("connecting %s", signal) async_dispatcher_connect(self.hass, signal, self._update_callback) self._controller.request_dimmer_level(self._addr) diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py index 04c715dc010..888fa2423ad 100644 --- a/homeassistant/components/hp_ilo/sensor.py +++ b/homeassistant/components/hp_ilo/sensor.py @@ -90,9 +90,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): new_device = HpIloSensor( hass=hass, hp_ilo_data=hp_ilo_data, - sensor_name="{} {}".format( - config.get(CONF_NAME), monitored_variable[CONF_NAME] - ), + sensor_name=f"{config.get(CONF_NAME)} {monitored_variable[CONF_NAME]}", sensor_type=monitored_variable[CONF_SENSOR_TYPE], sensor_value_template=monitored_variable.get(CONF_VALUE_TEMPLATE), unit_of_measurement=monitored_variable.get(CONF_UNIT_OF_MEASUREMENT), diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py index b966f5ae6a1..679968d1b8d 100644 --- a/homeassistant/components/html5/notify.py +++ b/homeassistant/components/html5/notify.py @@ -392,7 +392,7 @@ class HTML5PushCallbackView(HomeAssistantView): humanize_error(event_payload, ex), ) - event_name = "{}.{}".format(NOTIFY_CALLBACK_EVENT, event_payload[ATTR_TYPE]) + event_name = f"{NOTIFY_CALLBACK_EVENT}.{event_payload[ATTR_TYPE]}" request.app["hass"].bus.fire(event_name, event_payload) return self.json({"status": "ok", "event": event_payload[ATTR_TYPE]}) diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 58814b77e2d..18d8ce72d91 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -29,20 +29,12 @@ def async_sign_path(hass, refresh_token_id, path, expiration): secret = hass.data[DATA_SIGN_SECRET] = secrets.token_hex() now = dt_util.utcnow() - return "{}?{}={}".format( - path, - SIGN_QUERY_PARAM, - jwt.encode( - { - "iss": refresh_token_id, - "path": path, - "iat": now, - "exp": now + expiration, - }, - secret, - algorithm="HS256", - ).decode(), + encoded = jwt.encode( + {"iss": refresh_token_id, "path": path, "iat": now, "exp": now + expiration}, + secret, + algorithm="HS256", ) + return f"{path}?{SIGN_QUERY_PARAM}=" f"{encoded.decode()}" @callback diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index da406c071e4..38eda8e9b3f 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -96,9 +96,7 @@ async def process_wrong_login(request): """ remote_addr = request[KEY_REAL_IP] - msg = "Login attempt or request with invalid authentication from {}".format( - remote_addr - ) + msg = f"Login attempt or request with invalid authentication from {remote_addr}" _LOGGER.warning(msg) hass = request.app["hass"] diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index e60091684d3..bb7c5816c77 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -144,9 +144,7 @@ def request_handler_factory(view, handler): elif not isinstance(result, bytes): assert ( False - ), "Result should be None, string, bytes or Response. Got: {}".format( - result - ) + ), f"Result should be None, string, bytes or Response. Got: {result}" return web.Response(body=result, status=status_code) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index d3b2d5b1abd..1bcdd7129c7 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -81,8 +81,6 @@ _LOGGER = logging.getLogger(__name__) # https://github.com/quandyfactory/dicttoxml/issues/60 logging.getLogger("dicttoxml").setLevel(logging.WARNING) -DEFAULT_NAME_TEMPLATE = "Huawei {} {}" - SCAN_INTERVAL = timedelta(seconds=10) NOTIFY_SCHEMA = vol.Any( @@ -567,7 +565,7 @@ class HuaweiLteBaseEntity(Entity): @property def name(self) -> str: """Return entity name.""" - return DEFAULT_NAME_TEMPLATE.format(self.router.device_name, self._entity_name) + return f"Huawei {self.router.device_name} {self._entity_name}" @property def available(self) -> bool: diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index b8ed596d286..65b7b1f6f6e 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -84,9 +84,7 @@ def setup(hass, config): except (ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), + f"Error: {ex}
You will need to restart hass after fixing.", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) @@ -119,10 +117,7 @@ class HydrawiseEntity(Entity): """Initialize the Hydrawise entity.""" self.data = data self._sensor_type = sensor_type - self._name = "{0} {1}".format( - self.data["name"], - DEVICE_MAP[self._sensor_type][DEVICE_MAP_INDEX.index("KEY_INDEX")], - ) + self._name = f"{self.data['name']} {DEVICE_MAP[self._sensor_type][DEVICE_MAP_INDEX.index('KEY_INDEX')]}" self._state = None @property diff --git a/homeassistant/components/ign_sismologia/geo_location.py b/homeassistant/components/ign_sismologia/geo_location.py index deecc389e7e..21e8e1c7412 100644 --- a/homeassistant/components/ign_sismologia/geo_location.py +++ b/homeassistant/components/ign_sismologia/geo_location.py @@ -37,9 +37,6 @@ DEFAULT_UNIT_OF_MEASUREMENT = "km" SCAN_INTERVAL = timedelta(minutes=5) -SIGNAL_DELETE_ENTITY = "ign_sismologia_delete_{}" -SIGNAL_UPDATE_ENTITY = "ign_sismologia_update_{}" - SOURCE = "ign_sismologia" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -124,11 +121,11 @@ class IgnSismologiaFeedEntityManager: def _update_entity(self, external_id): """Update entity.""" - dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id)) + dispatcher_send(self._hass, f"ign_sismologia_update_{external_id}") def _remove_entity(self, external_id): """Remove entity.""" - dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id)) + dispatcher_send(self._hass, f"ign_sismologia_delete_{external_id}") class IgnSismologiaLocationEvent(GeolocationEvent): @@ -154,12 +151,12 @@ class IgnSismologiaLocationEvent(GeolocationEvent): """Call when entity is added to hass.""" self._remove_signal_delete = async_dispatcher_connect( self.hass, - SIGNAL_DELETE_ENTITY.format(self._external_id), + f"ign_sismologia_delete_{self._external_id}", self._delete_callback, ) self._remove_signal_update = async_dispatcher_connect( self.hass, - SIGNAL_UPDATE_ENTITY.format(self._external_id), + f"ign_sismologia_update_{self._external_id}", self._update_callback, ) diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index 9acf710a58e..f200c9651f0 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -53,7 +53,6 @@ AUTO_SETUP_YAML = "ihc_auto_setup.yaml" DOMAIN = "ihc" IHC_CONTROLLER = "controller" -IHC_DATA = "ihc{}" IHC_INFO = "info" IHC_PLATFORMS = ("binary_sensor", "light", "sensor", "switch") @@ -236,7 +235,7 @@ def ihc_setup(hass, config, conf, controller_id): # Manual configuration get_manual_configuration(hass, config, conf, ihc_controller, controller_id) # Store controller configuration - ihc_key = IHC_DATA.format(controller_id) + ihc_key = f"ihc{controller_id}" hass.data[ihc_key] = {IHC_CONTROLLER: ihc_controller, IHC_INFO: conf[CONF_INFO]} setup_service_functions(hass, ihc_controller) return True diff --git a/homeassistant/components/ihc/binary_sensor.py b/homeassistant/components/ihc/binary_sensor.py index 00e43008342..3f59d7981fb 100644 --- a/homeassistant/components/ihc/binary_sensor.py +++ b/homeassistant/components/ihc/binary_sensor.py @@ -2,7 +2,7 @@ from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.const import CONF_TYPE -from . import IHC_CONTROLLER, IHC_DATA, IHC_INFO +from . import IHC_CONTROLLER, IHC_INFO from .const import CONF_INVERTING from .ihcdevice import IHCDevice @@ -18,7 +18,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): product = device["product"] # Find controller that corresponds with device id ctrl_id = device["ctrl_id"] - ihc_key = IHC_DATA.format(ctrl_id) + ihc_key = f"ihc{ctrl_id}" info = hass.data[ihc_key][IHC_INFO] ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] diff --git a/homeassistant/components/ihc/light.py b/homeassistant/components/ihc/light.py index cb8bb424c8e..af6b62c42ff 100644 --- a/homeassistant/components/ihc/light.py +++ b/homeassistant/components/ihc/light.py @@ -3,7 +3,7 @@ import logging from homeassistant.components.light import ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light -from . import IHC_CONTROLLER, IHC_DATA, IHC_INFO +from . import IHC_CONTROLLER, IHC_INFO from .const import CONF_DIMMABLE, CONF_OFF_ID, CONF_ON_ID from .ihcdevice import IHCDevice from .util import async_pulse, async_set_bool, async_set_int @@ -22,7 +22,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): product = device["product"] # Find controller that corresponds with device id ctrl_id = device["ctrl_id"] - ihc_key = IHC_DATA.format(ctrl_id) + ihc_key = f"ihc{ctrl_id}" info = hass.data[ihc_key][IHC_INFO] ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] ihc_off_id = product_cfg.get(CONF_OFF_ID) diff --git a/homeassistant/components/ihc/sensor.py b/homeassistant/components/ihc/sensor.py index 71c9fa12ba1..cb1688bc7be 100644 --- a/homeassistant/components/ihc/sensor.py +++ b/homeassistant/components/ihc/sensor.py @@ -2,7 +2,7 @@ from homeassistant.const import CONF_UNIT_OF_MEASUREMENT from homeassistant.helpers.entity import Entity -from . import IHC_CONTROLLER, IHC_DATA, IHC_INFO +from . import IHC_CONTROLLER, IHC_INFO from .ihcdevice import IHCDevice @@ -17,7 +17,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): product = device["product"] # Find controller that corresponds with device id ctrl_id = device["ctrl_id"] - ihc_key = IHC_DATA.format(ctrl_id) + ihc_key = f"ihc{ctrl_id}" info = hass.data[ihc_key][IHC_INFO] ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] unit = product_cfg[CONF_UNIT_OF_MEASUREMENT] diff --git a/homeassistant/components/ihc/switch.py b/homeassistant/components/ihc/switch.py index ebe9fcce37b..15994f13eb2 100644 --- a/homeassistant/components/ihc/switch.py +++ b/homeassistant/components/ihc/switch.py @@ -1,7 +1,7 @@ """Support for IHC switches.""" from homeassistant.components.switch import SwitchDevice -from . import IHC_CONTROLLER, IHC_DATA, IHC_INFO +from . import IHC_CONTROLLER, IHC_INFO from .const import CONF_OFF_ID, CONF_ON_ID from .ihcdevice import IHCDevice from .util import async_pulse, async_set_bool @@ -18,7 +18,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): product = device["product"] # Find controller that corresponds with device id ctrl_id = device["ctrl_id"] - ihc_key = IHC_DATA.format(ctrl_id) + ihc_key = f"ihc{ctrl_id}" info = hass.data[ihc_key][IHC_INFO] ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] ihc_off_id = product_cfg.get(CONF_OFF_ID) diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py index 307d5a22c1e..a97d2a1d02b 100644 --- a/homeassistant/components/imap_email_content/sensor.py +++ b/homeassistant/components/imap_email_content/sensor.py @@ -113,7 +113,7 @@ class EmailReader: self.connection.select(self._folder, readonly=True) if not self._unread_ids: - search = "SINCE {0:%d-%b-%Y}".format(datetime.date.today()) + search = f"SINCE {datetime.date.today():%d-%b-%Y}" if self._last_id is not None: search = f"UID {self._last_id}:*" diff --git a/homeassistant/components/incomfort/binary_sensor.py b/homeassistant/components/incomfort/binary_sensor.py index 150515cbbf5..f15c2298b9d 100644 --- a/homeassistant/components/incomfort/binary_sensor.py +++ b/homeassistant/components/incomfort/binary_sensor.py @@ -1,7 +1,10 @@ """Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway.""" from typing import Any, Dict, Optional -from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDevice, +) from . import DOMAIN, IncomfortChild @@ -25,7 +28,7 @@ class IncomfortFailed(IncomfortChild, BinarySensorDevice): super().__init__() self._unique_id = f"{heater.serial_no}_failed" - self.entity_id = ENTITY_ID_FORMAT.format(f"{DOMAIN}_failed") + self.entity_id = f"{BINARY_SENSOR_DOMAIN}.{DOMAIN}_failed" self._name = "Boiler Fault" self._client = client diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py index 23bda6b2fdf..7d91ca012b9 100644 --- a/homeassistant/components/incomfort/climate.py +++ b/homeassistant/components/incomfort/climate.py @@ -1,7 +1,7 @@ """Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway.""" from typing import Any, Dict, List, Optional -from homeassistant.components.climate import ENTITY_ID_FORMAT, ClimateDevice +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN, ClimateDevice from homeassistant.components.climate.const import ( HVAC_MODE_HEAT, SUPPORT_TARGET_TEMPERATURE, @@ -32,7 +32,7 @@ class InComfortClimate(IncomfortChild, ClimateDevice): super().__init__() self._unique_id = f"{heater.serial_no}_{room.room_no}" - self.entity_id = ENTITY_ID_FORMAT.format(f"{DOMAIN}_{room.room_no}") + self.entity_id = f"{CLIMATE_DOMAIN}.{DOMAIN}_{room.room_no}" self._name = f"Thermostat {room.room_no}" self._client = client diff --git a/homeassistant/components/incomfort/sensor.py b/homeassistant/components/incomfort/sensor.py index 4164225b0d7..692eecf2317 100644 --- a/homeassistant/components/incomfort/sensor.py +++ b/homeassistant/components/incomfort/sensor.py @@ -1,7 +1,7 @@ """Support for an Intergas heater via an InComfort/InTouch Lan2RF gateway.""" from typing import Any, Dict, Optional -from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, @@ -49,7 +49,7 @@ class IncomfortSensor(IncomfortChild): self._heater = heater self._unique_id = f"{heater.serial_no}_{slugify(name)}" - self.entity_id = ENTITY_ID_FORMAT.format(f"{DOMAIN}_{slugify(name)}") + self.entity_id = f"{SENSOR_DOMAIN}.{DOMAIN}_{slugify(name)}" self._name = f"Boiler {name}" self._device_class = None diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py index 9096a7cb72c..88370acf166 100644 --- a/homeassistant/components/incomfort/water_heater.py +++ b/homeassistant/components/incomfort/water_heater.py @@ -5,7 +5,10 @@ from typing import Any, Dict from aiohttp import ClientResponseError -from homeassistant.components.water_heater import ENTITY_ID_FORMAT, WaterHeaterDevice +from homeassistant.components.water_heater import ( + DOMAIN as WATER_HEATER_DOMAIN, + WaterHeaterDevice, +) from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -35,7 +38,7 @@ class IncomfortWaterHeater(IncomfortEntity, WaterHeaterDevice): super().__init__() self._unique_id = f"{heater.serial_no}" - self.entity_id = ENTITY_ID_FORMAT.format(DOMAIN) + self.entity_id = f"{WATER_HEATER_DOMAIN}.{DOMAIN}" self._name = "Boiler" self._client = client diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py index 4a169453e35..9d0eaa84340 100644 --- a/homeassistant/components/influxdb/sensor.py +++ b/homeassistant/components/influxdb/sensor.py @@ -196,9 +196,7 @@ class InfluxSensorData: _LOGGER.error("Could not render where clause template: %s", ex) return - self.query = "select {}({}) as value from {} where {}".format( - self.group, self.field, self.measurement, where_clause - ) + self.query = f"select {self.group}({self.field}) as value from {self.measurement} where {where_clause}" _LOGGER.info("Running query: %s", self.query) diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index daadfac3705..603aa826123 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -28,8 +28,6 @@ from homeassistant.loader import bind_hass DOMAIN = "input_boolean" -ENTITY_ID_FORMAT = DOMAIN + ".{}" - _LOGGER = logging.getLogger(__name__) CONF_INITIAL = "initial" @@ -155,7 +153,7 @@ class InputBoolean(ToggleEntity, RestoreEntity): self._state = config.get(CONF_INITIAL) if from_yaml: self._editable = False - self.entity_id = ENTITY_ID_FORMAT.format(self.unique_id) + self.entity_id = f"{DOMAIN}.{self.unique_id}" @property def should_poll(self): diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 371e0dea185..575f607dadd 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -27,7 +27,6 @@ from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) DOMAIN = "input_datetime" -ENTITY_ID_FORMAT = DOMAIN + ".{}" CONF_HAS_DATE = "has_date" CONF_HAS_TIME = "has_time" @@ -219,7 +218,7 @@ class InputDatetime(RestoreEntity): def from_yaml(cls, config: typing.Dict) -> "InputDatetime": """Return entity instance initialized from yaml storage.""" input_dt = cls(config) - input_dt.entity_id = ENTITY_ID_FORMAT.format(config[CONF_ID]) + input_dt.entity_id = f"{DOMAIN}.{config[CONF_ID]}" input_dt.editable = False return input_dt diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index eb781baf2ca..8ec26ea3956 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -26,7 +26,6 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType, ServiceC _LOGGER = logging.getLogger(__name__) DOMAIN = "input_number" -ENTITY_ID_FORMAT = DOMAIN + ".{}" CONF_INITIAL = "initial" CONF_MIN = "min" @@ -209,7 +208,7 @@ class InputNumber(RestoreEntity): def from_yaml(cls, config: typing.Dict) -> "InputNumber": """Return entity instance initialized from yaml storage.""" input_num = cls(config) - input_num.entity_id = ENTITY_ID_FORMAT.format(config[CONF_ID]) + input_num.entity_id = f"{DOMAIN}.{config[CONF_ID]}" input_num.editable = False return input_num diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index 6044375d8a8..9269dc3a7f9 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -23,7 +23,6 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType, ServiceC _LOGGER = logging.getLogger(__name__) DOMAIN = "input_select" -ENTITY_ID_FORMAT = DOMAIN + ".{}" CONF_INITIAL = "initial" CONF_OPTIONS = "options" @@ -58,9 +57,7 @@ def _cv_input_select(cfg): initial = cfg.get(CONF_INITIAL) if initial is not None and initial not in options: raise vol.Invalid( - 'initial state "{}" is not part of the options: {}'.format( - initial, ",".join(options) - ) + f"initial state {initial} is not part of the options: {','.join(options)}" ) return cfg @@ -201,7 +198,7 @@ class InputSelect(RestoreEntity): def from_yaml(cls, config: typing.Dict) -> "InputSelect": """Return entity instance initialized from yaml storage.""" input_select = cls(config) - input_select.entity_id = ENTITY_ID_FORMAT.format(config[CONF_ID]) + input_select.entity_id = f"{DOMAIN}.{config[CONF_ID]}" input_select.editable = False return input_select diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index bdb3e8a4bc9..692a0101249 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -26,7 +26,6 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType, ServiceC _LOGGER = logging.getLogger(__name__) DOMAIN = "input_text" -ENTITY_ID_FORMAT = DOMAIN + ".{}" CONF_INITIAL = "initial" CONF_MIN = "min" @@ -212,7 +211,7 @@ class InputText(RestoreEntity): **config, } input_text = cls(config) - input_text.entity_id = ENTITY_ID_FORMAT.format(config[CONF_ID]) + input_text.entity_id = f"{DOMAIN}.{config[CONF_ID]}" input_text.editable = False return input_text diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index f195a458477..339189d3564 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -189,13 +189,13 @@ def async_register_services(hass, config, insteon_modem): ) hass.services.async_register(DOMAIN, SRV_PRINT_IM_ALDB, print_im_aldb, schema=None) hass.services.async_register( - DOMAIN, SRV_X10_ALL_UNITS_OFF, x10_all_units_off, schema=X10_HOUSECODE_SCHEMA, + DOMAIN, SRV_X10_ALL_UNITS_OFF, x10_all_units_off, schema=X10_HOUSECODE_SCHEMA ) hass.services.async_register( - DOMAIN, SRV_X10_ALL_LIGHTS_OFF, x10_all_lights_off, schema=X10_HOUSECODE_SCHEMA, + DOMAIN, SRV_X10_ALL_LIGHTS_OFF, x10_all_lights_off, schema=X10_HOUSECODE_SCHEMA ) hass.services.async_register( - DOMAIN, SRV_X10_ALL_LIGHTS_ON, x10_all_lights_on, schema=X10_HOUSECODE_SCHEMA, + DOMAIN, SRV_X10_ALL_LIGHTS_ON, x10_all_lights_on, schema=X10_HOUSECODE_SCHEMA ) hass.services.async_register( DOMAIN, SRV_SCENE_ON, scene_on, schema=TRIGGER_SCENE_SCHEMA @@ -223,17 +223,9 @@ def print_aldb_to_log(aldb): in_use = "Y" if rec.control_flags.is_in_use else "N" mode = "C" if rec.control_flags.is_controller else "R" hwm = "Y" if rec.control_flags.is_high_water_mark else "N" - _LOGGER.info( - " {:04x} {:s} {:s} {:s} {:3d} {:s}" - " {:3d} {:3d} {:3d}".format( - rec.mem_addr, - in_use, - mode, - hwm, - rec.group, - rec.address.human, - rec.data1, - rec.data2, - rec.data3, - ) + log_msg = ( + f" {rec.mem_addr:04x} {in_use:s} {mode:s} {hwm:s} " + f"{rec.group:3d} {rec.address.human:s} {rec.data1:3d} " + f"{rec.data2:3d} {rec.data3:3d}" ) + _LOGGER.info(log_msg) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index dea7a5083dc..57ec6fefe29 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -105,8 +105,8 @@ class IntegrationSensor(RestoreEntity): self._name = name if name is not None else f"{source_entity} integral" if unit_of_measurement is None: - self._unit_template = "{}{}{}".format( - "" if unit_prefix is None else unit_prefix, "{}", unit_time + self._unit_template = ( + f"{'' if unit_prefix is None else unit_prefix}{{}}{unit_time}" ) # we postpone the definition of unit_of_measurement to later self._unit_of_measurement = None diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index 63ed6a6ee26..62dd72973da 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -92,8 +92,8 @@ class iOSNotificationService(BaseNotificationService): if req.status_code != 201: fallback_error = req.json().get("errorMessage", "Unknown error") - fallback_message = "Internal server error, please try again later: {}".format( - fallback_error + fallback_message = ( + f"Internal server error, please try again later: {fallback_error}" ) message = req.json().get("message", fallback_message) if req.status_code == 429: diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index 47c54c3face..85a0a81fdf3 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -30,7 +30,7 @@ class IOSSensor(Entity): def __init__(self, sensor_type, device_name, device): """Initialize the sensor.""" self._device_name = device_name - self._name = "{} {}".format(device_name, SENSOR_TYPES[sensor_type][0]) + self._name = f"{device_name} {SENSOR_TYPES[sensor_type][0]}" self._device = device self.type = sensor_type self._state = None @@ -56,7 +56,7 @@ class IOSSensor(Entity): def name(self): """Return the name of the iOS sensor.""" device_name = self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_NAME] - return "{} {}".format(device_name, SENSOR_TYPES[self.type][0]) + return f"{device_name} {SENSOR_TYPES[self.type][0]}" @property def state(self): diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index b298d356df8..70a15a0dac5 100644 --- a/homeassistant/components/iperf3/sensor.py +++ b/homeassistant/components/iperf3/sensor.py @@ -28,7 +28,7 @@ class Iperf3Sensor(RestoreEntity): def __init__(self, iperf3_data, sensor_type): """Initialize the sensor.""" - self._name = "{} {}".format(SENSOR_TYPES[sensor_type][0], iperf3_data.host) + self._name = f"{SENSOR_TYPES[sensor_type][0]} {iperf3_data.host}" self._state = None self._sensor_type = sensor_type self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] diff --git a/homeassistant/components/ipma/const.py b/homeassistant/components/ipma/const.py index e68c12de13c..04064db2b88 100644 --- a/homeassistant/components/ipma/const.py +++ b/homeassistant/components/ipma/const.py @@ -7,7 +7,6 @@ DOMAIN = "ipma" HOME_LOCATION_NAME = "Home" -ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".ipma_{}" -ENTITY_ID_SENSOR_FORMAT_HOME = ENTITY_ID_SENSOR_FORMAT.format(HOME_LOCATION_NAME) +ENTITY_ID_SENSOR_FORMAT_HOME = f"{WEATHER_DOMAIN}.ipma_{HOME_LOCATION_NAME}" _LOGGER = logging.getLogger(".") diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py index e093556b810..1aae63a4908 100644 --- a/homeassistant/components/iqvia/sensor.py +++ b/homeassistant/components/iqvia/sensor.py @@ -198,6 +198,6 @@ class IndexSensor(IQVIAEntity): ) elif self._type == TYPE_DISEASE_TODAY: for attrs in period["Triggers"]: - self._attrs["{0}_index".format(attrs["Name"].lower())] = attrs["Index"] + self._attrs[f"{attrs['Name'].lower()}_index"] = attrs["Index"] self._state = period["Index"] diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py index 3f7de535407..076718e83a2 100644 --- a/homeassistant/components/islamic_prayer_times/sensor.py +++ b/homeassistant/components/islamic_prayer_times/sensor.py @@ -97,7 +97,7 @@ async def schedule_future_update(hass, sensors, midnight_time, prayer_times_data now = dt_util.as_local(dt_util.now()) today = now.date() - midnight_dt_str = "{}::{}".format(str(today), midnight_time) + midnight_dt_str = f"{today}::{midnight_time}" midnight_dt = datetime.strptime(midnight_dt_str, "%Y-%m-%d::%H:%M") if now > dt_util.as_local(midnight_dt): @@ -166,12 +166,10 @@ class IslamicPrayerTimesData: class IslamicPrayerTimeSensor(Entity): """Representation of an Islamic prayer time sensor.""" - ENTITY_ID_FORMAT = "sensor.islamic_prayer_time_{}" - def __init__(self, sensor_type, prayer_times_data): """Initialize the Islamic prayer time sensor.""" self.sensor_type = sensor_type - self.entity_id = self.ENTITY_ID_FORMAT.format(self.sensor_type) + self.entity_id = f"sensor.islamic_prayer_time_{self.sensor_type}" self.prayer_times_data = prayer_times_data self._name = self.sensor_type.capitalize() self._device_class = DEVICE_CLASS_TIMESTAMP @@ -208,7 +206,7 @@ class IslamicPrayerTimeSensor(Entity): def get_prayer_time_as_dt(prayer_time): """Create a datetime object for the respective prayer time.""" today = datetime.today().strftime("%Y-%m-%d") - date_time_str = "{} {}".format(str(today), prayer_time) + date_time_str = f"{today} {prayer_time}" pt_dt = dt_util.parse_datetime(date_time_str) return pt_dt diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index c1474334a8e..3cffbdb1214 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -529,5 +529,5 @@ class ISYDevice(Entity): attr = {} if hasattr(self._node, "aux_properties"): for name, val in self._node.aux_properties.items(): - attr[name] = "{} {}".format(val.get("value"), val.get("uom")) + attr[name] = f"{val.get('value')} {val.get('uom')}" return attr diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index 7e69feb2f70..917dedd5c53 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -84,7 +84,7 @@ def _detect_device_type(node) -> str: split_type = device_type.split(".") for device_class, ids in ISY_DEVICE_TYPES.items(): - if "{}.{}".format(split_type[0], split_type[1]) in ids: + if f"{split_type[0]}.{split_type[1]}" in ids: return device_class return None diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index b80dfc2542f..20673312fa7 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -437,7 +437,7 @@ class ZoneDevice(ClimateDevice): @property def unique_id(self): """Return the ID of the controller device.""" - return "{}_z{}".format(self._controller.unique_id, self._zone.index + 1) + return f"{self._controller.unique_id}_z{self._zone.index + 1}" @property def name(self) -> str: diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py index 55bf91ac398..969e193bac8 100644 --- a/homeassistant/components/juicenet/__init__.py +++ b/homeassistant/components/juicenet/__init__.py @@ -65,4 +65,4 @@ class JuicenetDevice(Entity): @property def unique_id(self): """Return a unique ID.""" - return "{}-{}".format(self.device.id(), self.type) + return f"{self.device.id()}-{self.type}" diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index 67a04d39556..6ddb8279811 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -43,7 +43,7 @@ class JuicenetSensorDevice(JuicenetDevice, Entity): @property def name(self): """Return the name of the device.""" - return "{} {}".format(self.device.name(), self._name) + return f"{self.device.name()} {self._name}" @property def icon(self): diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 5640106eefa..edd42678a1f 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -102,7 +102,7 @@ async def async_setup(hass, config): except XKNXException as ex: _LOGGER.warning("Can't connect to KNX interface: %s", ex) hass.components.persistent_notification.async_create( - "Can't connect to KNX interface:
{0}".format(ex), title="KNX" + f"Can't connect to KNX interface:
{ex}", title="KNX" ) for component, discovery_type in ( @@ -291,7 +291,7 @@ class KNXAutomation: """Initialize Automation class.""" self.hass = hass self.device = device - script_name = "{} turn ON script".format(device.get_name()) + script_name = f"{device.get_name()} turn ON script" self.script = Script(hass, action, script_name) self.action = ActionCallback( diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index f326ba60375..4fd86d078a0 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -183,7 +183,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= encryption = config.get(CONF_PROXY_SSL) websocket = config.get(CONF_ENABLE_WEBSOCKET) else: - name = "{} ({})".format(DEFAULT_NAME, discovery_info.get("hostname")) + name = f"{DEFAULT_NAME} ({discovery_info.get('hostname')})" host = discovery_info.get("host") port = discovery_info.get("port") tcp_port = DEFAULT_TCP_PORT @@ -286,9 +286,7 @@ class KodiDevice(MediaPlayerDevice): ws_protocol = "wss" if encryption else "ws" self._http_url = f"{http_protocol}://{host}:{port}/jsonrpc" - self._image_url = "{}://{}{}:{}/image".format( - http_protocol, image_auth_string, host, port - ) + self._image_url = f"{http_protocol}://{image_auth_string}{host}:{port}/image" self._ws_url = f"{ws_protocol}://{host}:{tcp_port}/jsonrpc" self._http_server = jsonrpc_async.Server(self._http_url, **kwargs) @@ -577,7 +575,7 @@ class KodiDevice(MediaPlayerDevice): url_components = urllib.parse.urlparse(thumbnail) if url_components.scheme == "image": - return "{}/{}".format(self._image_url, urllib.parse.quote_plus(thumbnail)) + return f"{self._image_url}/{urllib.parse.quote_plus(thumbnail)}" @property def media_title(self): diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py index aa3fe0610a7..e431763b8bd 100644 --- a/homeassistant/components/kodi/notify.py +++ b/homeassistant/components/kodi/notify.py @@ -44,7 +44,7 @@ ATTR_DISPLAYTIME = "displaytime" async def async_get_service(hass, config, discovery_info=None): """Return the notify service.""" - url = "{}:{}".format(config.get(CONF_HOST), config.get(CONF_PORT)) + url = f"{config.get(CONF_HOST)}:{config.get(CONF_PORT)}" username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py index dc4dae7787f..50f897e3a85 100644 --- a/homeassistant/components/konnected/binary_sensor.py +++ b/homeassistant/components/konnected/binary_sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import DOMAIN as KONNECTED_DOMAIN, SIGNAL_SENSOR_UPDATE +from .const import DOMAIN as KONNECTED_DOMAIN _LOGGER = logging.getLogger(__name__) @@ -80,7 +80,7 @@ class KonnectedBinarySensor(BinarySensorDevice): """Store entity_id and register state change callback.""" self._data[ATTR_ENTITY_ID] = self.entity_id async_dispatcher_connect( - self.hass, SIGNAL_SENSOR_UPDATE.format(self.entity_id), self.async_set_state + self.hass, f"konnected.{self.entity_id}.update", self.async_set_state ) @callback diff --git a/homeassistant/components/konnected/const.py b/homeassistant/components/konnected/const.py index d6819dcf71f..7cb0ffc5f80 100644 --- a/homeassistant/components/konnected/const.py +++ b/homeassistant/components/konnected/const.py @@ -46,5 +46,4 @@ ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()} ENDPOINT_ROOT = "/api/konnected" UPDATE_ENDPOINT = ENDPOINT_ROOT + r"/device/{device_id:[a-zA-Z0-9]+}" -SIGNAL_SENSOR_UPDATE = "konnected.{}.update" SIGNAL_DS18B20_NEW = "konnected.ds18b20.new" diff --git a/homeassistant/components/konnected/handlers.py b/homeassistant/components/konnected/handlers.py index a8914853e84..923e5d63899 100644 --- a/homeassistant/components/konnected/handlers.py +++ b/homeassistant/components/konnected/handlers.py @@ -10,7 +10,7 @@ from homeassistant.const import ( from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import decorator -from .const import CONF_INVERSE, SIGNAL_DS18B20_NEW, SIGNAL_SENSOR_UPDATE +from .const import CONF_INVERSE, SIGNAL_DS18B20_NEW _LOGGER = logging.getLogger(__name__) HANDLERS = decorator.Registry() @@ -25,7 +25,7 @@ async def async_handle_state_update(hass, context, msg): if context.get(CONF_INVERSE): state = not state - async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state) + async_dispatcher_send(hass, f"konnected.{entity_id}.update", state) @HANDLERS.register("temp") @@ -34,7 +34,7 @@ async def async_handle_temp_update(hass, context, msg): _LOGGER.debug("[temp handler] context: %s msg: %s", context, msg) entity_id, temp = context.get(DEVICE_CLASS_TEMPERATURE), msg.get("temp") if entity_id: - async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE.format(entity_id), temp) + async_dispatcher_send(hass, f"konnected.{entity_id}.update", temp) @HANDLERS.register("humi") @@ -43,7 +43,7 @@ async def async_handle_humi_update(hass, context, msg): _LOGGER.debug("[humi handler] context: %s msg: %s", context, msg) entity_id, humi = context.get(DEVICE_CLASS_HUMIDITY), msg.get("humi") if entity_id: - async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE.format(entity_id), humi) + async_dispatcher_send(hass, f"konnected.{entity_id}.update", humi) @HANDLERS.register("addr") @@ -53,7 +53,7 @@ async def async_handle_addr_update(hass, context, msg): addr, temp = msg.get("addr"), msg.get("temp") entity_id = context.get(addr) if entity_id: - async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE.format(entity_id), temp) + async_dispatcher_send(hass, f"konnected.{entity_id}.update", temp) else: msg["device_id"] = context.get("device_id") msg["temperature"] = temp diff --git a/homeassistant/components/konnected/panel.py b/homeassistant/components/konnected/panel.py index 2668a382ccc..783aa78b8b1 100644 --- a/homeassistant/components/konnected/panel.py +++ b/homeassistant/components/konnected/panel.py @@ -39,7 +39,6 @@ from .const import ( CONF_REPEAT, DOMAIN, ENDPOINT_ROOT, - SIGNAL_SENSOR_UPDATE, STATE_LOW, ZONE_TO_PIN, ) @@ -290,9 +289,7 @@ class AlarmPanel: if sensor_config.get(CONF_INVERSE): state = not state - async_dispatcher_send( - self.hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state - ) + async_dispatcher_send(self.hass, f"konnected.{entity_id}.update", state) @callback def async_desired_settings_payload(self): diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index d189ac8809a..4fa238166f8 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -15,7 +15,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from .const import DOMAIN as KONNECTED_DOMAIN, SIGNAL_DS18B20_NEW, SIGNAL_SENSOR_UPDATE +from .const import DOMAIN as KONNECTED_DOMAIN, SIGNAL_DS18B20_NEW _LOGGER = logging.getLogger(__name__) @@ -84,9 +84,7 @@ class KonnectedSensor(Entity): self._type = sensor_type self._zone_num = self._data.get(CONF_ZONE) self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] - self._unique_id = addr or "{}-{}-{}".format( - device_id, self._zone_num, sensor_type - ) + self._unique_id = addr or f"{device_id}-{self._zone_num}-{sensor_type}" # set initial state if known at initialization self._state = initial_state @@ -130,7 +128,7 @@ class KonnectedSensor(Entity): entity_id_key = self._addr or self._type self._data[entity_id_key] = self.entity_id async_dispatcher_connect( - self.hass, SIGNAL_SENSOR_UPDATE.format(self.entity_id), self.async_set_state + self.hass, f"konnected.{self.entity_id}.update", self.async_set_state ) @callback diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py index d16051eb8da..b8ddec20440 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -48,8 +48,9 @@ class KonnectedSwitch(ToggleEntity): self._repeat = self._data.get(CONF_REPEAT) self._state = self._boolean_state(self._data.get(ATTR_STATE)) self._name = self._data.get(CONF_NAME) - self._unique_id = "{}-{}-{}-{}-{}".format( - device_id, self._zone_num, self._momentary, self._pause, self._repeat + self._unique_id = ( + f"{device_id}-{self._zone_num}-{self._momentary}-" + f"{self._pause}-{self._repeat}" ) @property diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index 1a5b7a56e8e..80a72f1d6fd 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -89,7 +89,7 @@ class LastfmSensor(Entity): top = self._user.get_top_tracks(limit=1)[0] toptitle = re.search("', '(.+?)',", str(top)) topartist = re.search("'(.+?)',", str(top)) - self._topplayed = "{} - {}".format(topartist.group(1), toptitle.group(1)) + self._topplayed = f"{topartist.group(1)} - {toptitle.group(1)}" if self._user.get_now_playing() is None: self._state = "Not Scrobbling" return diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index c49319abf42..ff7de1987da 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -92,9 +92,7 @@ BINSENSOR_PORTS = [ "BINSENSOR8", ] -KEYS = [ - "{:s}{:d}".format(t[0], t[1]) for t in product(["A", "B", "C", "D"], range(1, 9)) -] +KEYS = [f"{t[0]:s}{t[1]:d}" for t in product(["A", "B", "C", "D"], range(1, 9))] VARIABLES = [ "VAR1ORTVAR", diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index a0c40a59a46..50117c210a2 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -67,9 +67,7 @@ def _thresholds(config): if error_threshold and warning_threshold: if error_threshold <= warning_threshold: raise vol.Invalid( - "{} must be larger than {}".format( - CONF_ERROR_THRESHOLD, CONF_WARNING_THRESHOLD - ) + f"{CONF_ERROR_THRESHOLD} must be larger than {CONF_WARNING_THRESHOLD}" ) elif not error_threshold and warning_threshold: config[CONF_ERROR_THRESHOLD] = warning_threshold + 1 diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py index 6f4255735e0..b6cd67c2627 100644 --- a/homeassistant/components/life360/device_tracker.py +++ b/homeassistant/components/life360/device_tracker.py @@ -72,7 +72,7 @@ def _include_name(filter_dict, name): def _exc_msg(exc): - return "{}: {}".format(exc.__class__.__name__, str(exc)) + return f"{exc.__class__.__name__}: {exc}" def _dump_filter(filter_dict, desc, func=lambda x: x): @@ -253,7 +253,7 @@ class Life360Scanner: msg = f"Updating {dev_id}" if prev_seen: - msg += "; Time since last update: {}".format(last_seen - prev_seen) + msg += f"; Time since last update: {last_seen - prev_seen}" _LOGGER.debug(msg) if self._max_gps_accuracy is not None and gps_accuracy > self._max_gps_accuracy: @@ -402,10 +402,10 @@ class Life360Scanner: places = api.get_circle_places(circle_id) place_data = "Circle's Places:" for place in places: - place_data += "\n- name: {}".format(place["name"]) - place_data += "\n latitude: {}".format(place["latitude"]) - place_data += "\n longitude: {}".format(place["longitude"]) - place_data += "\n radius: {}".format(place["radius"]) + place_data += f"\n- name: {place['name']}" + place_data += f"\n latitude: {place['latitude']}" + place_data += f"\n longitude: {place['longitude']}" + place_data += f"\n radius: {place['radius']}" if not places: place_data += " None" _LOGGER.debug(place_data) diff --git a/homeassistant/components/lifx_cloud/scene.py b/homeassistant/components/lifx_cloud/scene.py index 4068ff20fe2..08e044a46e0 100644 --- a/homeassistant/components/lifx_cloud/scene.py +++ b/homeassistant/components/lifx_cloud/scene.py @@ -14,7 +14,6 @@ import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -LIFX_API_URL = "https://api.lifx.com/v1/{0}" DEFAULT_TIMEOUT = 10 PLATFORM_SCHEMA = vol.Schema( @@ -33,7 +32,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= headers = {AUTHORIZATION: f"Bearer {token}"} - url = LIFX_API_URL.format("scenes") + url = "https://api.lifx.com/v1/scenes" try: httpsession = async_get_clientsession(hass) @@ -78,7 +77,7 @@ class LifxCloudScene(Scene): async def async_activate(self): """Activate the scene.""" - url = LIFX_API_URL.format("scenes/scene_id:%s/activate" % self._uuid) + url = f"https://api.lifx.com/v1/scenes/scene_id:{self._uuid}/activate" try: httpsession = async_get_clientsession(self.hass) diff --git a/homeassistant/components/light/intent.py b/homeassistant/components/light/intent.py index c172ac1330a..58f74d8a422 100644 --- a/homeassistant/components/light/intent.py +++ b/homeassistant/components/light/intent.py @@ -51,14 +51,12 @@ class SetIntentHandler(intent.IntentHandler): service_data[ATTR_RGB_COLOR] = slots["color"]["value"] # Use original passed in value of the color because we don't have # human readable names for that internally. - speech_parts.append( - "the color {}".format(intent_obj.slots["color"]["value"]) - ) + speech_parts.append(f"the color {intent_obj.slots['color']['value']}") if "brightness" in slots: intent.async_test_feature(state, SUPPORT_BRIGHTNESS, "changing brightness") service_data[ATTR_BRIGHTNESS_PCT] = slots["brightness"]["value"] - speech_parts.append("{}% brightness".format(slots["brightness"]["value"])) + speech_parts.append(f"{slots['brightness']['value']}% brightness") await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, service_data, context=intent_obj.context diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py index ea36aa9f7fb..978f50b0ffd 100644 --- a/homeassistant/components/locative/__init__.py +++ b/homeassistant/components/locative/__init__.py @@ -93,9 +93,7 @@ async def handle_webhook(hass, webhook_id, request): # before the previous zone was exited. The enter message will # be sent first, then the exit message will be sent second. return web.Response( - text="Ignoring exit from {} (already in {})".format( - location_name, current_state - ), + text=f"Ignoring exit from {location_name} (already in {current_state})", status=HTTP_OK, ) diff --git a/homeassistant/components/lockitron/lock.py b/homeassistant/components/lockitron/lock.py index 5840c7f5537..8ff8f430355 100644 --- a/homeassistant/components/lockitron/lock.py +++ b/homeassistant/components/lockitron/lock.py @@ -16,15 +16,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Required(CONF_ID): cv.string} ) BASE_URL = "https://api.lockitron.com" -API_STATE_URL = BASE_URL + "/v2/locks/{}?access_token={}" -API_ACTION_URL = BASE_URL + "/v2/locks/{}?access_token={}&state={}" def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Lockitron platform.""" access_token = config.get(CONF_ACCESS_TOKEN) device_id = config.get(CONF_ID) - response = requests.get(API_STATE_URL.format(device_id, access_token), timeout=5) + response = requests.get( + f"{BASE_URL}/v2/locks/{device_id}?access_token={access_token}", timeout=5 + ) if response.status_code == 200: add_entities([Lockitron(response.json()["state"], access_token, device_id)]) else: @@ -64,7 +64,8 @@ class Lockitron(LockDevice): def update(self): """Update the internal state of the device.""" response = requests.get( - API_STATE_URL.format(self.device_id, self.access_token), timeout=5 + f"{BASE_URL}/v2/locks/{self.device_id}?access_token={self.access_token}", + timeout=5, ) if response.status_code == 200: self._state = response.json()["state"] @@ -74,7 +75,7 @@ class Lockitron(LockDevice): def do_change_request(self, requested_state): """Execute the change request and pull out the new state.""" response = requests.put( - API_ACTION_URL.format(self.device_id, self.access_token, requested_state), + f"{BASE_URL}/v2/locks/{self.device_id}?access_token={self.access_token}&state={requested_state}", timeout=5, ) if response.status_code == 200: diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index ac45a636bf7..266ff3601eb 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -318,13 +318,9 @@ def humanify(hass, events): if entity_id: state = hass.states.get(entity_id) name = state.name if state else entity_id - message = "send command {}/{} for {}".format( - data["request"]["namespace"], data["request"]["name"], name - ) + message = f"send command {data['request']['namespace']}/{data['request']['name']} for {name}" else: - message = "send command {}/{}".format( - data["request"]["namespace"], data["request"]["name"] - ) + message = f"send command {data['request']['namespace']}/{data['request']['name']}" yield { "when": event.time_fired, @@ -342,9 +338,7 @@ def humanify(hass, events): value = data.get(ATTR_VALUE) value_msg = f" to {value}" if value else "" - message = "send command {}{} for {}".format( - data[ATTR_SERVICE], value_msg, data[ATTR_DISPLAY_NAME] - ) + message = f"send command {data[ATTR_SERVICE]}{value_msg} for {data[ATTR_DISPLAY_NAME]}" yield { "when": event.time_fired, diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py index b77f17101a8..0a6d471889f 100644 --- a/homeassistant/components/logi_circle/__init__.py +++ b/homeassistant/components/logi_circle/__init__.py @@ -130,9 +130,10 @@ async def async_setup_entry(hass, entry): if not logi_circle.authorized: hass.components.persistent_notification.create( - "Error: The cached access tokens are missing from {}.
" - "Please unload then re-add the Logi Circle integration to resolve." - "".format(DEFAULT_CACHEDB), + ( + f"Error: The cached access tokens are missing from {DEFAULT_CACHEDB}.
" + f"Please unload then re-add the Logi Circle integration to resolve." + ), title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) @@ -158,18 +159,14 @@ async def async_setup_entry(hass, entry): # string, so we'll handle it separately. err = f"{_TIMEOUT}s timeout exceeded when connecting to Logi Circle API" hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(err), + f"Error: {err}
You will need to restart hass after fixing.", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) return False except ClientResponseError as ex: hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), + f"Error: {ex}
You will need to restart hass after fixing.", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) diff --git a/homeassistant/components/logi_circle/sensor.py b/homeassistant/components/logi_circle/sensor.py index fc5ad7155b4..4a5fedaf57a 100644 --- a/homeassistant/components/logi_circle/sensor.py +++ b/homeassistant/components/logi_circle/sensor.py @@ -50,10 +50,8 @@ class LogiSensor(Entity): self._sensor_type = sensor_type self._camera = camera self._id = f"{self._camera.mac_address}-{self._sensor_type}" - self._icon = "mdi:{}".format(SENSOR_TYPES.get(self._sensor_type)[2]) - self._name = "{0} {1}".format( - self._camera.name, SENSOR_TYPES.get(self._sensor_type)[0] - ) + self._icon = f"mdi:{SENSOR_TYPES.get(self._sensor_type)[2]}" + self._name = f"{self._camera.name} {SENSOR_TYPES.get(self._sensor_type)[0]}" self._activity = {} self._state = None self._tz = time_zone @@ -127,8 +125,8 @@ class LogiSensor(Entity): last_activity = await self._camera.get_last_activity(force_refresh=True) if last_activity is not None: last_activity_time = as_local(last_activity.end_time_utc) - self._state = "{0:0>2}:{1:0>2}".format( - last_activity_time.hour, last_activity_time.minute + self._state = ( + f"{last_activity_time.hour:0>2}:{last_activity_time.minute:0>2}" ) else: state = getattr(self._camera, self._sensor_type, None) diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index dcd7a6c4e52..a04a2376c74 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -138,7 +138,7 @@ class LovelaceYAML(LovelaceConfig): except ConfigNotFound: return { "mode": self.mode, - "error": "{} not found".format(self.path), + "error": f"{self.path} not found", } return _config_info(self.mode, config) diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py index 6fc48081adc..cfde5bba872 100644 --- a/homeassistant/components/luftdaten/sensor.py +++ b/homeassistant/components/luftdaten/sensor.py @@ -80,7 +80,7 @@ class LuftdatenSensor(Entity): def unique_id(self) -> str: """Return a unique, friendly identifier for this entity.""" if self._data is not None: - return "{0}_{1}".format(self._data["sensor_id"], self.sensor_type) + return f"{self._data['sensor_id']}_{self.sensor_type}" @property def device_state_attributes(self): diff --git a/homeassistant/components/lupusec/__init__.py b/homeassistant/components/lupusec/__init__.py index 60f3a192b07..3ae07bd8105 100644 --- a/homeassistant/components/lupusec/__init__.py +++ b/homeassistant/components/lupusec/__init__.py @@ -47,9 +47,7 @@ def setup(hass, config): _LOGGER.error(ex) hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart hass after fixing." - "".format(ex), + f"Error: {ex}
You will need to restart hass after fixing.", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py index 3f625a7c28b..4e06c5df626 100644 --- a/homeassistant/components/lutron/scene.py +++ b/homeassistant/components/lutron/scene.py @@ -37,6 +37,4 @@ class LutronScene(LutronDevice, Scene): @property def name(self): """Return the name of the device.""" - return "{} {}: {}".format( - self._area_name, self._keypad_name, self._lutron_device.name - ) + return f"{self._area_name} {self._keypad_name}: {self._lutron_device.name}" diff --git a/homeassistant/components/lyft/sensor.py b/homeassistant/components/lyft/sensor.py index d76fe9f0dc5..5e8555f857d 100644 --- a/homeassistant/components/lyft/sensor.py +++ b/homeassistant/components/lyft/sensor.py @@ -85,7 +85,7 @@ class LyftSensor(Entity): self._product_id = product_id self._product = product self._sensortype = sensorType - self._name = "{} {}".format(self._product["display_name"], self._sensortype) + self._name = f"{self._product['display_name']} {self._sensortype}" if "lyft" not in self._name.lower(): self._name = f"Lyft{self._name}" if self._sensortype == "time": diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index f6f2490e48b..7e27d1a970b 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -166,5 +166,5 @@ async def setup_test_component(hass, setup_accessory, capitalize=False, suffix=N assert domain, "Cannot map test homekit services to Home Assistant domain" config_entry, pairing = await setup_test_accessories(hass, [accessory]) - entity = "testdevice" if suffix is None else "testdevice_{}".format(suffix) + entity = "testdevice" if suffix is None else f"testdevice_{suffix}" return Helper(hass, ".".join((domain, entity)), pairing, accessory, config_entry) diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py index cf985621351..cf3fc8fcb33 100644 --- a/tests/components/universal/test_media_player.py +++ b/tests/components/universal/test_media_player.py @@ -184,13 +184,13 @@ class TestMediaPlayer(unittest.TestCase): self.mock_state_switch_id = switch.ENTITY_ID_FORMAT.format("state") self.hass.states.set(self.mock_state_switch_id, STATE_OFF) - self.mock_volume_id = input_number.ENTITY_ID_FORMAT.format("volume_level") + self.mock_volume_id = f"{input_number.DOMAIN}.volume_level" self.hass.states.set(self.mock_volume_id, 0) - self.mock_source_list_id = input_select.ENTITY_ID_FORMAT.format("source_list") + self.mock_source_list_id = f"{input_select.DOMAIN}.source_list" self.hass.states.set(self.mock_source_list_id, ["dvd", "htpc"]) - self.mock_source_id = input_select.ENTITY_ID_FORMAT.format("source") + self.mock_source_id = f"{input_select.DOMAIN}.source" self.hass.states.set(self.mock_source_id, "dvd") self.mock_shuffle_switch_id = switch.ENTITY_ID_FORMAT.format("shuffle") From 6852ccd8de66d7cf647f609b437aa87c9a9545c8 Mon Sep 17 00:00:00 2001 From: Malte Franken Date: Fri, 28 Feb 2020 21:41:21 +1000 Subject: [PATCH 151/416] change log level (#32244) --- homeassistant/components/feedreader/__init__.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 2643607c3a8..548654c11c0 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -102,11 +102,13 @@ class FeedManager: # during the initial parsing of the XML, but it doesn't indicate # whether this is an unrecoverable error. In this case the # feedparser lib is trying a less strict parsing approach. - # If an error is detected here, log error message but continue + # If an error is detected here, log warning message but continue # processing the feed entries if present. if self._feed.bozo != 0: - _LOGGER.error( - "Error parsing feed %s: %s", self._url, self._feed.bozo_exception + _LOGGER.warning( + "Possible issue parsing feed %s: %s", + self._url, + self._feed.bozo_exception, ) # Using etag and modified, if there's no new data available, # the entries list will be empty From 3dc1ece33ce68f79c425fb50697091c775e5af91 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 28 Feb 2020 16:56:39 +0100 Subject: [PATCH 152/416] Updated frontend to 20200220.5 (#32312) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b9575b7f21a..b957ef13895 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200220.4" + "home-assistant-frontend==20200220.5" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ad8abf3e2c4..f5eeba0a3c4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.31 -home-assistant-frontend==20200220.4 +home-assistant-frontend==20200220.5 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index cf22961e506..f22d6aaa777 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -690,7 +690,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200220.4 +home-assistant-frontend==20200220.5 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4f92b1ac027..8b034cbc7f5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -257,7 +257,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200220.4 +home-assistant-frontend==20200220.5 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 From f17462b1597615e10b5f51a405dfc021dc5438b7 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 28 Feb 2020 19:28:03 +0100 Subject: [PATCH 153/416] =?UTF-8?q?UniFi=20-=20Temporary=20workaround=20to?= =?UTF-8?q?=20get=20device=20tracker=20to=20mark=20cli=E2=80=A6=20(#32321)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/unifi/device_tracker.py | 2 +- homeassistant/components/unifi/unifi_client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 52370fb0e3d..b398dad488b 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -338,4 +338,4 @@ class UniFiDeviceTracker(ScannerEntity): @property def should_poll(self): """No polling needed.""" - return False + return True diff --git a/homeassistant/components/unifi/unifi_client.py b/homeassistant/components/unifi/unifi_client.py index 2e18f55a57b..f9e77d47c0e 100644 --- a/homeassistant/components/unifi/unifi_client.py +++ b/homeassistant/components/unifi/unifi_client.py @@ -62,4 +62,4 @@ class UniFiClient(Entity): @property def should_poll(self) -> bool: """No polling needed.""" - return False + return True From 601f2c693d383d98edc2c012b0c0de9718c3084f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Feb 2020 11:12:16 -0800 Subject: [PATCH 154/416] Prevent saving/deleting Lovelace config in safe mode (#32319) --- homeassistant/components/lovelace/dashboard.py | 6 ++++++ tests/components/lovelace/test_dashboard.py | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index a04a2376c74..11cb3266755 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -101,6 +101,9 @@ class LovelaceStorage(LovelaceConfig): async def async_save(self, config): """Save config.""" + if self.hass.config.safe_mode: + raise HomeAssistantError("Saving not supported in safe mode") + if self._data is None: await self._load() self._data["config"] = config @@ -109,6 +112,9 @@ class LovelaceStorage(LovelaceConfig): async def async_delete(self): """Delete config.""" + if self.hass.config.safe_mode: + raise HomeAssistantError("Deleting not supported in safe mode") + await self.async_save(None) async def _load(self): diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 9511e001197..1d385ba3bec 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -50,6 +50,16 @@ async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage): assert not response["success"] assert response["error"]["code"] == "config_not_found" + await client.send_json( + {"id": 9, "type": "lovelace/config/save", "config": {"yo": "hello"}} + ) + response = await client.receive_json() + assert not response["success"] + + await client.send_json({"id": 10, "type": "lovelace/config/delete"}) + response = await client.receive_json() + assert not response["success"] + async def test_lovelace_from_storage_save_before_load( hass, hass_ws_client, hass_storage From c7f128f2864197cb0e576754e7fccd9eac6923c3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 28 Feb 2020 09:41:41 -1000 Subject: [PATCH 155/416] =?UTF-8?q?Ensure=20rest=20sensors=20are=20marked?= =?UTF-8?q?=20unavailable=20when=20http=20requests=E2=80=A6=20(#32309)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/rest/sensor.py | 22 ++++++++++--------- tests/components/rest/test_sensor.py | 29 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 70424325241..7c8cfb9d3d0 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -202,17 +202,19 @@ class RestSensor(Entity): self.rest.update() value = self.rest.data _LOGGER.debug("Data fetched from resource: %s", value) - content_type = self.rest.headers.get("content-type") + if self.rest.headers is not None: + # If the http request failed, headers will be None + content_type = self.rest.headers.get("content-type") - if content_type and content_type.startswith("text/xml"): - try: - value = json.dumps(xmltodict.parse(value)) - _LOGGER.debug("JSON converted from XML: %s", value) - except ExpatError: - _LOGGER.warning( - "REST xml result could not be parsed and converted to JSON." - ) - _LOGGER.debug("Erroneous XML: %s", value) + if content_type and content_type.startswith("text/xml"): + try: + value = json.dumps(xmltodict.parse(value)) + _LOGGER.debug("JSON converted from XML: %s", value) + except ExpatError: + _LOGGER.warning( + "REST xml result could not be parsed and converted to JSON." + ) + _LOGGER.debug("Erroneous XML: %s", value) if self._json_attrs: self._attributes = {} diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 30eeae9a8e3..5018418f493 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -589,6 +589,35 @@ class TestRestSensor(unittest.TestCase): assert mock_logger.warning.called assert mock_logger.debug.called + @patch("homeassistant.components.rest.sensor._LOGGER") + def test_update_with_failed_get(self, mock_logger): + """Test attributes get extracted from a XML result with bad xml.""" + value_template = template("{{ value_json.toplevel.master_value }}") + value_template.hass = self.hass + + self.rest.update = Mock( + "rest.RestData.update", side_effect=self.update_side_effect(None, None), + ) + self.sensor = rest.RestSensor( + self.hass, + self.rest, + self.name, + self.unit_of_measurement, + self.device_class, + value_template, + ["key"], + self.force_update, + self.resource_template, + self.json_attrs_path, + ) + + self.sensor.update() + assert {} == self.sensor.device_state_attributes + assert mock_logger.warning.called + assert mock_logger.debug.called + assert self.sensor.state is None + assert self.sensor.available is False + class TestRestData(unittest.TestCase): """Tests for RestData.""" From f1a0ca7cd3ec384c4f6d71aefead59815b7f68e9 Mon Sep 17 00:00:00 2001 From: springstan <46536646+springstan@users.noreply.github.com> Date: Fri, 28 Feb 2020 20:46:48 +0100 Subject: [PATCH 156/416] Add and use percentage constant (#32094) * Add and use percentage constant * Fix pylint error and broken test --- homeassistant/components/adguard/sensor.py | 4 +- homeassistant/components/airly/sensor.py | 5 +-- .../components/ambient_station/__init__.py | 45 ++++++++++--------- homeassistant/components/amcrest/sensor.py | 4 +- homeassistant/components/apcupsd/sensor.py | 17 +++---- homeassistant/components/aqualogic/sensor.py | 9 +++- homeassistant/components/arlo/sensor.py | 5 ++- homeassistant/components/awair/sensor.py | 5 ++- .../components/beewi_smartclim/sensor.py | 5 ++- homeassistant/components/bloomsky/sensor.py | 11 +++-- homeassistant/components/bme280/sensor.py | 9 +++- homeassistant/components/bme680/sensor.py | 11 +++-- .../components/bmw_connected_drive/sensor.py | 5 ++- homeassistant/components/bom/sensor.py | 3 +- homeassistant/components/broadlink/sensor.py | 3 +- homeassistant/components/brother/const.py | 31 +++++++------ homeassistant/components/buienradar/sensor.py | 23 +++++----- homeassistant/components/canary/sensor.py | 6 +-- .../components/climate/device_trigger.py | 3 +- .../components/comfoconnect/sensor.py | 15 ++++--- homeassistant/components/cups/sensor.py | 4 +- .../components/danfoss_air/sensor.py | 19 ++++++-- homeassistant/components/darksky/sensor.py | 31 ++++++------- homeassistant/components/deconz/sensor.py | 9 +++- homeassistant/components/demo/sensor.py | 8 +++- homeassistant/components/dht/sensor.py | 9 +++- homeassistant/components/dovado/sensor.py | 9 +++- homeassistant/components/dyson/sensor.py | 4 +- homeassistant/components/ebox/sensor.py | 4 +- homeassistant/components/ecobee/sensor.py | 3 +- .../components/eight_sleep/sensor.py | 10 +++-- homeassistant/components/enocean/sensor.py | 3 +- .../components/epsonworkforce/sensor.py | 14 +++--- homeassistant/components/fibaro/sensor.py | 8 +++- homeassistant/components/fitbit/sensor.py | 5 ++- homeassistant/components/foobot/sensor.py | 5 ++- .../components/garmin_connect/const.py | 40 ++++++++++------- homeassistant/components/geniushub/sensor.py | 4 +- homeassistant/components/glances/const.py | 17 ++++--- .../components/history_stats/sensor.py | 7 ++- homeassistant/components/homekit/__init__.py | 3 +- .../components/homekit_controller/sensor.py | 6 +-- homeassistant/components/homematic/sensor.py | 3 +- .../components/homematicip_cloud/sensor.py | 7 +-- homeassistant/components/htu21d/sensor.py | 4 +- homeassistant/components/icloud/sensor.py | 4 +- homeassistant/components/ios/sensor.py | 6 ++- homeassistant/components/isy994/__init__.py | 3 +- homeassistant/components/isy994/sensor.py | 3 +- homeassistant/components/kaiterra/const.py | 3 +- homeassistant/components/konnected/sensor.py | 7 ++- homeassistant/components/lacrosse/sensor.py | 3 +- homeassistant/components/lcn/const.py | 4 +- .../components/linux_battery/sensor.py | 9 +++- homeassistant/components/logi_circle/const.py | 5 ++- .../components/luftdaten/__init__.py | 3 +- .../components/meteo_france/const.py | 15 ++++--- homeassistant/components/metoffice/sensor.py | 5 ++- homeassistant/components/microsoft/tts.py | 6 +-- homeassistant/components/miflora/sensor.py | 5 ++- homeassistant/components/mitemp_bt/sensor.py | 5 ++- .../components/mold_indicator/sensor.py | 5 ++- homeassistant/components/mychevy/sensor.py | 5 ++- homeassistant/components/mysensors/sensor.py | 9 ++-- homeassistant/components/neato/sensor.py | 3 +- homeassistant/components/nest/sensor.py | 3 +- homeassistant/components/netatmo/sensor.py | 10 ++++- homeassistant/components/netdata/sensor.py | 3 +- .../components/netgear_lte/sensor_types.py | 4 +- .../components/nfandroidtv/notify.py | 11 ++++- .../components/nissan_leaf/sensor.py | 4 +- homeassistant/components/nut/sensor.py | 23 +++++++--- .../components/octoprint/__init__.py | 9 +++- homeassistant/components/octoprint/sensor.py | 4 +- homeassistant/components/onewire/sensor.py | 14 +++--- .../components/opentherm_gw/const.py | 10 ++--- .../components/openweathermap/sensor.py | 5 ++- homeassistant/components/pi_hole/const.py | 4 +- homeassistant/components/plaato/sensor.py | 3 +- homeassistant/components/plant/__init__.py | 8 +++- homeassistant/components/point/sensor.py | 3 +- .../components/prometheus/__init__.py | 3 +- homeassistant/components/qnap/sensor.py | 7 +-- .../components/raincloud/__init__.py | 3 +- homeassistant/components/repetier/__init__.py | 3 +- homeassistant/components/rfxtrx/__init__.py | 3 +- homeassistant/components/ring/sensor.py | 3 +- homeassistant/components/sensehat/sensor.py | 9 +++- homeassistant/components/sht31/sensor.py | 3 +- homeassistant/components/skybeacon/sensor.py | 3 +- homeassistant/components/smappee/sensor.py | 19 ++++++-- .../components/smartthings/sensor.py | 11 +++-- homeassistant/components/solarlog/const.py | 4 +- homeassistant/components/starline/sensor.py | 4 +- homeassistant/components/startca/sensor.py | 5 +-- .../components/surepetcare/sensor.py | 23 +++++----- homeassistant/components/syncthru/sensor.py | 6 +-- .../components/synologydsm/sensor.py | 19 ++++---- .../components/systemmonitor/sensor.py | 9 ++-- homeassistant/components/tado/sensor.py | 6 +-- homeassistant/components/tahoma/sensor.py | 4 +- .../components/tank_utility/sensor.py | 5 +-- homeassistant/components/teksavvy/sensor.py | 5 +-- .../components/tellduslive/sensor.py | 3 +- homeassistant/components/tellstick/sensor.py | 12 ++++- .../components/thinkingcleaner/sensor.py | 3 +- homeassistant/components/toon/const.py | 1 - homeassistant/components/toon/sensor.py | 6 +-- homeassistant/components/tradfri/sensor.py | 4 +- .../trafikverket_weatherstation/sensor.py | 3 +- homeassistant/components/vallox/sensor.py | 5 ++- homeassistant/components/vera/sensor.py | 4 +- homeassistant/components/verisure/sensor.py | 4 +- homeassistant/components/vilfo/const.py | 6 +-- .../components/waterfurnace/sensor.py | 8 ++-- .../components/wirelesstag/__init__.py | 3 +- homeassistant/components/withings/const.py | 1 - homeassistant/components/withings/sensor.py | 13 ++++-- .../components/worxlandroid/sensor.py | 4 +- .../components/wunderground/sensor.py | 31 ++++++++++--- .../components/xiaomi_aqara/sensor.py | 3 +- homeassistant/components/yr/sensor.py | 13 +++--- homeassistant/components/yweather/sensor.py | 3 +- homeassistant/components/zamg/sensor.py | 5 ++- homeassistant/components/zha/sensor.py | 5 ++- homeassistant/components/zigbee/__init__.py | 3 +- homeassistant/const.py | 2 + tests/components/arlo/test_sensor.py | 3 +- tests/components/awair/test_sensor.py | 5 ++- tests/components/canary/test_sensor.py | 5 ++- tests/components/dyson/test_sensor.py | 14 ++++-- tests/components/foobot/test_sensor.py | 5 ++- .../homekit/test_get_accessories.py | 3 +- tests/components/homekit/test_type_lights.py | 9 ++-- tests/components/homekit/test_type_sensors.py | 3 +- .../homematicip_cloud/test_sensor.py | 7 +-- tests/components/influxdb/test_init.py | 12 +++-- tests/components/min_max/test_sensor.py | 3 +- tests/components/mobile_app/test_entity.py | 7 +-- .../components/mold_indicator/test_sensor.py | 25 +++++++---- tests/components/rflink/test_sensor.py | 11 +++-- tests/components/rfxtrx/test_sensor.py | 8 ++-- .../sensor/test_device_condition.py | 6 +-- .../components/sensor/test_device_trigger.py | 6 +-- tests/components/smartthings/test_sensor.py | 3 +- tests/components/sonarr/test_sensor.py | 7 ++- tests/components/spaceapi/test_init.py | 9 +++- tests/components/startca/test_sensor.py | 6 +-- tests/components/teksavvy/test_sensor.py | 6 +-- tests/components/vera/test_sensor.py | 21 ++++----- tests/components/yr/test_sensor.py | 10 ++--- tests/components/zha/test_sensor.py | 3 +- tests/components/zwave/test_sensor.py | 4 +- tests/helpers/test_entity_platform.py | 5 ++- .../custom_components/test/sensor.py | 5 ++- 155 files changed, 735 insertions(+), 459 deletions(-) diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py index 9d0d5245d80..5abff10739a 100644 --- a/homeassistant/components/adguard/sensor.py +++ b/homeassistant/components/adguard/sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.adguard.const import ( DOMAIN, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import TIME_MILLISECONDS +from homeassistant.const import TIME_MILLISECONDS, UNIT_PERCENTAGE from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.typing import HomeAssistantType @@ -134,7 +134,7 @@ class AdGuardHomePercentageBlockedSensor(AdGuardHomeSensor): "AdGuard DNS Queries Blocked Ratio", "mdi:magnify-close", "blocked_percentage", - "%", + UNIT_PERCENTAGE, ) async def _adguard_update(self) -> None: diff --git a/homeassistant/components/airly/sensor.py b/homeassistant/components/airly/sensor.py index 2d42dac5614..a6754b4a00d 100644 --- a/homeassistant/components/airly/sensor.py +++ b/homeassistant/components/airly/sensor.py @@ -9,6 +9,7 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, PRESSURE_HPA, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.helpers.entity import Entity @@ -27,8 +28,6 @@ ATTR_ICON = "icon" ATTR_LABEL = "label" ATTR_UNIT = "unit" -HUMI_PERCENT = "%" - SENSOR_TYPES = { ATTR_API_PM1: { ATTR_DEVICE_CLASS: None, @@ -40,7 +39,7 @@ SENSOR_TYPES = { ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, ATTR_ICON: None, ATTR_LABEL: ATTR_API_HUMIDITY.capitalize(), - ATTR_UNIT: HUMI_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, }, ATTR_API_PRESSURE: { ATTR_DEVICE_CLASS: DEVICE_CLASS_PRESSURE, diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 63c00b05038..4fd6590b286 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, SPEED_MILES_PER_HOUR, + UNIT_PERCENTAGE, ) from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady @@ -154,18 +155,18 @@ SENSOR_TYPES = { TYPE_EVENTRAININ: ("Event Rain", "in", TYPE_SENSOR, None), TYPE_FEELSLIKE: ("Feels Like", "°F", TYPE_SENSOR, "temperature"), TYPE_HOURLYRAININ: ("Hourly Rain Rate", "in/hr", TYPE_SENSOR, None), - TYPE_HUMIDITY10: ("Humidity 10", "%", TYPE_SENSOR, "humidity"), - TYPE_HUMIDITY1: ("Humidity 1", "%", TYPE_SENSOR, "humidity"), - TYPE_HUMIDITY2: ("Humidity 2", "%", TYPE_SENSOR, "humidity"), - TYPE_HUMIDITY3: ("Humidity 3", "%", TYPE_SENSOR, "humidity"), - TYPE_HUMIDITY4: ("Humidity 4", "%", TYPE_SENSOR, "humidity"), - TYPE_HUMIDITY5: ("Humidity 5", "%", TYPE_SENSOR, "humidity"), - TYPE_HUMIDITY6: ("Humidity 6", "%", TYPE_SENSOR, "humidity"), - TYPE_HUMIDITY7: ("Humidity 7", "%", TYPE_SENSOR, "humidity"), - TYPE_HUMIDITY8: ("Humidity 8", "%", TYPE_SENSOR, "humidity"), - TYPE_HUMIDITY9: ("Humidity 9", "%", TYPE_SENSOR, "humidity"), - TYPE_HUMIDITY: ("Humidity", "%", TYPE_SENSOR, "humidity"), - TYPE_HUMIDITYIN: ("Humidity In", "%", TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY10: ("Humidity 10", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY1: ("Humidity 1", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY2: ("Humidity 2", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY3: ("Humidity 3", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY4: ("Humidity 4", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY5: ("Humidity 5", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY6: ("Humidity 6", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY7: ("Humidity 7", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY8: ("Humidity 8", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY9: ("Humidity 9", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITY: ("Humidity", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_HUMIDITYIN: ("Humidity In", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), TYPE_LASTRAIN: ("Last Rain", None, TYPE_SENSOR, "timestamp"), TYPE_MAXDAILYGUST: ("Max Gust", SPEED_MILES_PER_HOUR, TYPE_SENSOR, None), TYPE_MONTHLYRAININ: ("Monthly Rain", "in", TYPE_SENSOR, None), @@ -179,16 +180,16 @@ SENSOR_TYPES = { TYPE_RELAY7: ("Relay 7", None, TYPE_BINARY_SENSOR, "connectivity"), TYPE_RELAY8: ("Relay 8", None, TYPE_BINARY_SENSOR, "connectivity"), TYPE_RELAY9: ("Relay 9", None, TYPE_BINARY_SENSOR, "connectivity"), - TYPE_SOILHUM10: ("Soil Humidity 10", "%", TYPE_SENSOR, "humidity"), - TYPE_SOILHUM1: ("Soil Humidity 1", "%", TYPE_SENSOR, "humidity"), - TYPE_SOILHUM2: ("Soil Humidity 2", "%", TYPE_SENSOR, "humidity"), - TYPE_SOILHUM3: ("Soil Humidity 3", "%", TYPE_SENSOR, "humidity"), - TYPE_SOILHUM4: ("Soil Humidity 4", "%", TYPE_SENSOR, "humidity"), - TYPE_SOILHUM5: ("Soil Humidity 5", "%", TYPE_SENSOR, "humidity"), - TYPE_SOILHUM6: ("Soil Humidity 6", "%", TYPE_SENSOR, "humidity"), - TYPE_SOILHUM7: ("Soil Humidity 7", "%", TYPE_SENSOR, "humidity"), - TYPE_SOILHUM8: ("Soil Humidity 8", "%", TYPE_SENSOR, "humidity"), - TYPE_SOILHUM9: ("Soil Humidity 9", "%", TYPE_SENSOR, "humidity"), + TYPE_SOILHUM10: ("Soil Humidity 10", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM1: ("Soil Humidity 1", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM2: ("Soil Humidity 2", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM3: ("Soil Humidity 3", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM4: ("Soil Humidity 4", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM5: ("Soil Humidity 5", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM6: ("Soil Humidity 6", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM7: ("Soil Humidity 7", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM8: ("Soil Humidity 8", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), + TYPE_SOILHUM9: ("Soil Humidity 9", UNIT_PERCENTAGE, TYPE_SENSOR, "humidity"), TYPE_SOILTEMP10F: ("Soil Temp 10", "°F", TYPE_SENSOR, "temperature"), TYPE_SOILTEMP1F: ("Soil Temp 1", "°F", TYPE_SENSOR, "temperature"), TYPE_SOILTEMP2F: ("Soil Temp 2", "°F", TYPE_SENSOR, "temperature"), diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py index bcff1775879..18abf28bdd5 100644 --- a/homeassistant/components/amcrest/sensor.py +++ b/homeassistant/components/amcrest/sensor.py @@ -4,7 +4,7 @@ import logging from amcrest import AmcrestError -from homeassistant.const import CONF_NAME, CONF_SENSORS +from homeassistant.const import CONF_NAME, CONF_SENSORS, UNIT_PERCENTAGE from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -20,7 +20,7 @@ SENSOR_SDCARD = "sdcard" # Sensor types are defined like: Name, units, icon SENSORS = { SENSOR_PTZ_PRESET: ["PTZ Preset", None, "mdi:camera-iris"], - SENSOR_SDCARD: ["SD Used", "%", "mdi:sd"], + SENSOR_SDCARD: ["SD Used", UNIT_PERCENTAGE, "mdi:sd"], } diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 7947ba75999..e39696cc37a 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( TEMP_CELSIUS, TIME_MINUTES, TIME_SECONDS, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -28,7 +29,7 @@ SENSOR_TYPES = { "battdate": ["Battery Replaced", "", "mdi:calendar-clock"], "battstat": ["Battery Status", "", "mdi:information-outline"], "battv": ["Battery Voltage", "V", "mdi:flash"], - "bcharge": ["Battery", "%", "mdi:battery"], + "bcharge": ["Battery", UNIT_PERCENTAGE, "mdi:battery"], "cable": ["Cable Type", "", "mdi:ethernet-cable"], "cumonbatt": ["Total Time on Battery", "", "mdi:timer"], "date": ["Status Date", "", "mdi:calendar-clock"], @@ -42,20 +43,20 @@ SENSOR_TYPES = { "firmware": ["Firmware Version", "", "mdi:information-outline"], "hitrans": ["Transfer High", "V", "mdi:flash"], "hostname": ["Hostname", "", "mdi:information-outline"], - "humidity": ["Ambient Humidity", "%", "mdi:water-percent"], + "humidity": ["Ambient Humidity", UNIT_PERCENTAGE, "mdi:water-percent"], "itemp": ["Internal Temperature", TEMP_CELSIUS, "mdi:thermometer"], "lastxfer": ["Last Transfer", "", "mdi:transfer"], "linefail": ["Input Voltage Status", "", "mdi:information-outline"], "linefreq": ["Line Frequency", "Hz", "mdi:information-outline"], "linev": ["Input Voltage", "V", "mdi:flash"], - "loadpct": ["Load", "%", "mdi:gauge"], - "loadapnt": ["Load Apparent Power", "%", "mdi:gauge"], + "loadpct": ["Load", UNIT_PERCENTAGE, "mdi:gauge"], + "loadapnt": ["Load Apparent Power", UNIT_PERCENTAGE, "mdi:gauge"], "lotrans": ["Transfer Low", "V", "mdi:flash"], "mandate": ["Manufacture Date", "", "mdi:calendar"], "masterupd": ["Master Update", "", "mdi:information-outline"], "maxlinev": ["Input Voltage High", "V", "mdi:flash"], "maxtime": ["Battery Timeout", "", "mdi:timer-off"], - "mbattchg": ["Battery Shutdown", "%", "mdi:battery-alert"], + "mbattchg": ["Battery Shutdown", UNIT_PERCENTAGE, "mdi:battery-alert"], "minlinev": ["Input Voltage Low", "V", "mdi:flash"], "mintimel": ["Shutdown Time", "", "mdi:timer"], "model": ["Model", "", "mdi:information-outline"], @@ -70,7 +71,7 @@ SENSOR_TYPES = { "reg1": ["Register 1 Fault", "", "mdi:information-outline"], "reg2": ["Register 2 Fault", "", "mdi:information-outline"], "reg3": ["Register 3 Fault", "", "mdi:information-outline"], - "retpct": ["Restore Requirement", "%", "mdi:battery-alert"], + "retpct": ["Restore Requirement", UNIT_PERCENTAGE, "mdi:battery-alert"], "selftest": ["Last Self Test", "", "mdi:calendar-clock"], "sense": ["Sensitivity", "", "mdi:information-outline"], "serialno": ["Serial Number", "", "mdi:information-outline"], @@ -92,14 +93,14 @@ SPECIFIC_UNITS = {"ITEMP": TEMP_CELSIUS} INFERRED_UNITS = { " Minutes": TIME_MINUTES, " Seconds": TIME_SECONDS, - " Percent": "%", + " Percent": UNIT_PERCENTAGE, " Volts": "V", " Ampere": "A", " Volt-Ampere": "VA", " Watts": POWER_WATT, " Hz": "Hz", " C": TEMP_CELSIUS, - " Percent Load Capacity": "%", + " Percent Load Capacity": UNIT_PERCENTAGE, } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py index 7fff009baa5..dde092dd1fa 100644 --- a/homeassistant/components/aqualogic/sensor.py +++ b/homeassistant/components/aqualogic/sensor.py @@ -4,7 +4,12 @@ import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, +) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -14,7 +19,7 @@ from . import DOMAIN, UPDATE_TOPIC _LOGGER = logging.getLogger(__name__) TEMP_UNITS = [TEMP_CELSIUS, TEMP_FAHRENHEIT] -PERCENT_UNITS = ["%", "%"] +PERCENT_UNITS = [UNIT_PERCENTAGE, UNIT_PERCENTAGE] SALT_UNITS = ["g/L", "PPM"] WATT_UNITS = ["W", "W"] NO_UNITS = [None, None] diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py index 5d11e9bc891..03e4437b257 100644 --- a/homeassistant/components/arlo/sensor.py +++ b/homeassistant/components/arlo/sensor.py @@ -11,6 +11,7 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -27,10 +28,10 @@ SENSOR_TYPES = { "last_capture": ["Last", None, "run-fast"], "total_cameras": ["Arlo Cameras", None, "video"], "captured_today": ["Captured Today", None, "file-video"], - "battery_level": ["Battery Level", "%", "battery-50"], + "battery_level": ["Battery Level", UNIT_PERCENTAGE, "battery-50"], "signal_strength": ["Signal Strength", None, "signal"], "temperature": ["Temperature", TEMP_CELSIUS, "thermometer"], - "humidity": ["Humidity", "%", "water-percent"], + "humidity": ["Humidity", UNIT_PERCENTAGE, "water-percent"], "air_quality": ["Air Quality", CONCENTRATION_PARTS_PER_MILLION, "biohazard"], } diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 18fb3f2cd54..301055c7e61 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -48,7 +49,7 @@ SENSOR_TYPES = { }, "HUMID": { "device_class": DEVICE_CLASS_HUMIDITY, - "unit_of_measurement": "%", + "unit_of_measurement": UNIT_PERCENTAGE, "icon": "mdi:water-percent", }, "CO2": { @@ -80,7 +81,7 @@ SENSOR_TYPES = { }, "score": { "device_class": DEVICE_CLASS_SCORE, - "unit_of_measurement": "%", + "unit_of_measurement": UNIT_PERCENTAGE, "icon": "mdi:percent", }, } diff --git a/homeassistant/components/beewi_smartclim/sensor.py b/homeassistant/components/beewi_smartclim/sensor.py index 187ca411988..ec124b24971 100644 --- a/homeassistant/components/beewi_smartclim/sensor.py +++ b/homeassistant/components/beewi_smartclim/sensor.py @@ -12,6 +12,7 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -24,8 +25,8 @@ DEFAULT_NAME = "BeeWi SmartClim" # Sensor config SENSOR_TYPES = [ [DEVICE_CLASS_TEMPERATURE, "Temperature", TEMP_CELSIUS], - [DEVICE_CLASS_HUMIDITY, "Humidity", "%"], - [DEVICE_CLASS_BATTERY, "Battery", "%"], + [DEVICE_CLASS_HUMIDITY, "Humidity", UNIT_PERCENTAGE], + [DEVICE_CLASS_BATTERY, "Battery", UNIT_PERCENTAGE], ] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py index 2ffdb8efab0..b07bca948bd 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -4,7 +4,12 @@ import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -25,7 +30,7 @@ SENSOR_TYPES = [ # Sensor units - these do not currently align with the API documentation SENSOR_UNITS_IMPERIAL = { "Temperature": TEMP_FAHRENHEIT, - "Humidity": "%", + "Humidity": UNIT_PERCENTAGE, "Pressure": "inHg", "Luminance": "cd/m²", "Voltage": "mV", @@ -34,7 +39,7 @@ SENSOR_UNITS_IMPERIAL = { # Metric units SENSOR_UNITS_METRIC = { "Temperature": TEMP_CELSIUS, - "Humidity": "%", + "Humidity": UNIT_PERCENTAGE, "Pressure": "mbar", "Luminance": "cd/m²", "Voltage": "mV", diff --git a/homeassistant/components/bme280/sensor.py b/homeassistant/components/bme280/sensor.py index e1e33210c9b..28e22c10f20 100644 --- a/homeassistant/components/bme280/sensor.py +++ b/homeassistant/components/bme280/sensor.py @@ -8,7 +8,12 @@ import smbus # pylint: disable=import-error import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_FAHRENHEIT +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, + CONF_NAME, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -44,7 +49,7 @@ SENSOR_HUMID = "humidity" SENSOR_PRESS = "pressure" SENSOR_TYPES = { SENSOR_TEMP: ["Temperature", None], - SENSOR_HUMID: ["Humidity", "%"], + SENSOR_HUMID: ["Humidity", UNIT_PERCENTAGE], SENSOR_PRESS: ["Pressure", "mb"], } DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS] diff --git a/homeassistant/components/bme680/sensor.py b/homeassistant/components/bme680/sensor.py index 43430f724bb..19d6fbd3086 100644 --- a/homeassistant/components/bme680/sensor.py +++ b/homeassistant/components/bme680/sensor.py @@ -8,7 +8,12 @@ from smbus import SMBus # pylint: disable=import-error import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_FAHRENHEIT +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, + CONF_NAME, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util.temperature import celsius_to_fahrenheit @@ -50,10 +55,10 @@ SENSOR_GAS = "gas" SENSOR_AQ = "airquality" SENSOR_TYPES = { SENSOR_TEMP: ["Temperature", None], - SENSOR_HUMID: ["Humidity", "%"], + SENSOR_HUMID: ["Humidity", UNIT_PERCENTAGE], SENSOR_PRESS: ["Pressure", "mb"], SENSOR_GAS: ["Gas Resistance", "Ohms"], - SENSOR_AQ: ["Air Quality", "%"], + SENSOR_AQ: ["Air Quality", UNIT_PERCENTAGE], } DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS, SENSOR_AQ] OVERSAMPLING_VALUES = set([0, 1, 2, 4, 8, 16]) diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 7fb3da8b883..30c12d7d7a0 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -8,6 +8,7 @@ from homeassistant.const import ( LENGTH_KILOMETERS, LENGTH_MILES, TIME_HOURS, + UNIT_PERCENTAGE, VOLUME_GALLONS, VOLUME_LITERS, ) @@ -28,7 +29,7 @@ ATTR_TO_HA_METRIC = { "charging_time_remaining": ["mdi:update", TIME_HOURS], "charging_status": ["mdi:battery-charging", None], # No icon as this is dealt with directly as a special case in icon() - "charging_level_hv": [None, "%"], + "charging_level_hv": [None, UNIT_PERCENTAGE], } ATTR_TO_HA_IMPERIAL = { @@ -41,7 +42,7 @@ ATTR_TO_HA_IMPERIAL = { "charging_time_remaining": ["mdi:update", TIME_HOURS], "charging_status": ["mdi:battery-charging", None], # No icon as this is dealt with directly as a special case in icon() - "charging_level_hv": [None, "%"], + "charging_level_hv": [None, UNIT_PERCENTAGE], } diff --git a/homeassistant/components/bom/sensor.py b/homeassistant/components/bom/sensor.py index 836a2a79509..5ecd9c1009f 100644 --- a/homeassistant/components/bom/sensor.py +++ b/homeassistant/components/bom/sensor.py @@ -21,6 +21,7 @@ from homeassistant.const import ( CONF_NAME, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -68,7 +69,7 @@ SENSOR_TYPES = { "press_msl": ["Pressure msl", "msl"], "press_tend": ["Pressure Tend", None], "rain_trace": ["Rain Today", "mm"], - "rel_hum": ["Relative Humidity", "%"], + "rel_hum": ["Relative Humidity", UNIT_PERCENTAGE], "sea_state": ["Sea State", None], "swell_dir_worded": ["Swell Direction", None], "swell_height": ["Swell Height", "m"], diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py index 408593e337d..dbff4108a3f 100644 --- a/homeassistant/components/broadlink/sensor.py +++ b/homeassistant/components/broadlink/sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( CONF_SCAN_INTERVAL, CONF_TIMEOUT, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -29,7 +30,7 @@ SCAN_INTERVAL = timedelta(seconds=300) SENSOR_TYPES = { "temperature": ["Temperature", TEMP_CELSIUS], "air_quality": ["Air Quality", " "], - "humidity": ["Humidity", "%"], + "humidity": ["Humidity", UNIT_PERCENTAGE], "light": ["Light", " "], "noise": ["Noise", " "], } diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py index d3b7c5e2a78..e887ad6de21 100644 --- a/homeassistant/components/brother/const.py +++ b/homeassistant/components/brother/const.py @@ -1,5 +1,5 @@ """Constants for Brother integration.""" -from homeassistant.const import TIME_DAYS +from homeassistant.const import TIME_DAYS, UNIT_PERCENTAGE ATTR_BELT_UNIT_REMAINING_LIFE = "belt_unit_remaining_life" ATTR_BLACK_INK_REMAINING = "black_ink_remaining" @@ -30,7 +30,6 @@ ATTR_YELLOW_TONER_REMAINING = "yellow_toner_remaining" DOMAIN = "brother" UNIT_PAGES = "p" -UNIT_PERCENT = "%" PRINTER_TYPES = ["laser", "ink"] @@ -58,72 +57,72 @@ SENSOR_TYPES = { ATTR_DRUM_REMAINING_LIFE: { ATTR_ICON: "mdi:chart-donut", ATTR_LABEL: ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: UNIT_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, }, ATTR_BELT_UNIT_REMAINING_LIFE: { ATTR_ICON: "mdi:current-ac", ATTR_LABEL: ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: UNIT_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, }, ATTR_FUSER_REMAINING_LIFE: { ATTR_ICON: "mdi:water-outline", ATTR_LABEL: ATTR_FUSER_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: UNIT_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, }, ATTR_LASER_REMAINING_LIFE: { ATTR_ICON: "mdi:spotlight-beam", ATTR_LABEL: ATTR_LASER_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: UNIT_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, }, ATTR_PF_KIT_1_REMAINING_LIFE: { ATTR_ICON: "mdi:printer-3d", ATTR_LABEL: ATTR_PF_KIT_1_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: UNIT_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, }, ATTR_PF_KIT_MP_REMAINING_LIFE: { ATTR_ICON: "mdi:printer-3d", ATTR_LABEL: ATTR_PF_KIT_MP_REMAINING_LIFE.replace("_", " ").title(), - ATTR_UNIT: UNIT_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, }, ATTR_BLACK_TONER_REMAINING: { ATTR_ICON: "mdi:printer-3d-nozzle", ATTR_LABEL: ATTR_BLACK_TONER_REMAINING.replace("_", " ").title(), - ATTR_UNIT: UNIT_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, }, ATTR_CYAN_TONER_REMAINING: { ATTR_ICON: "mdi:printer-3d-nozzle", ATTR_LABEL: ATTR_CYAN_TONER_REMAINING.replace("_", " ").title(), - ATTR_UNIT: UNIT_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, }, ATTR_MAGENTA_TONER_REMAINING: { ATTR_ICON: "mdi:printer-3d-nozzle", ATTR_LABEL: ATTR_MAGENTA_TONER_REMAINING.replace("_", " ").title(), - ATTR_UNIT: UNIT_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, }, ATTR_YELLOW_TONER_REMAINING: { ATTR_ICON: "mdi:printer-3d-nozzle", ATTR_LABEL: ATTR_YELLOW_TONER_REMAINING.replace("_", " ").title(), - ATTR_UNIT: UNIT_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, }, ATTR_BLACK_INK_REMAINING: { ATTR_ICON: "mdi:printer-3d-nozzle", ATTR_LABEL: ATTR_BLACK_INK_REMAINING.replace("_", " ").title(), - ATTR_UNIT: UNIT_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, }, ATTR_CYAN_INK_REMAINING: { ATTR_ICON: "mdi:printer-3d-nozzle", ATTR_LABEL: ATTR_CYAN_INK_REMAINING.replace("_", " ").title(), - ATTR_UNIT: UNIT_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, }, ATTR_MAGENTA_INK_REMAINING: { ATTR_ICON: "mdi:printer-3d-nozzle", ATTR_LABEL: ATTR_MAGENTA_INK_REMAINING.replace("_", " ").title(), - ATTR_UNIT: UNIT_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, }, ATTR_YELLOW_INK_REMAINING: { ATTR_ICON: "mdi:printer-3d-nozzle", ATTR_LABEL: ATTR_YELLOW_INK_REMAINING.replace("_", " ").title(), - ATTR_UNIT: UNIT_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, }, ATTR_UPTIME: { ATTR_ICON: "mdi:timer", diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 32ecf50ed9d..5d709ab1e63 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -31,6 +31,7 @@ from homeassistant.const import ( SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, TIME_HOURS, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -67,7 +68,7 @@ SENSOR_TYPES = { "symbol": ["Symbol", None, None], # new in json api (>1.0.0): "feeltemperature": ["Feel temperature", TEMP_CELSIUS, "mdi:thermometer"], - "humidity": ["Humidity", "%", "mdi:water-percent"], + "humidity": ["Humidity", UNIT_PERCENTAGE, "mdi:water-percent"], "temperature": ["Temperature", TEMP_CELSIUS, "mdi:thermometer"], "groundtemperature": ["Ground temperature", TEMP_CELSIUS, "mdi:thermometer"], "windspeed": ["Wind speed", SPEED_KILOMETERS_PER_HOUR, "mdi:weather-windy"], @@ -120,16 +121,16 @@ SENSOR_TYPES = { "maxrain_3d": ["Maximum rain 3d", "mm", "mdi:weather-pouring"], "maxrain_4d": ["Maximum rain 4d", "mm", "mdi:weather-pouring"], "maxrain_5d": ["Maximum rain 5d", "mm", "mdi:weather-pouring"], - "rainchance_1d": ["Rainchance 1d", "%", "mdi:weather-pouring"], - "rainchance_2d": ["Rainchance 2d", "%", "mdi:weather-pouring"], - "rainchance_3d": ["Rainchance 3d", "%", "mdi:weather-pouring"], - "rainchance_4d": ["Rainchance 4d", "%", "mdi:weather-pouring"], - "rainchance_5d": ["Rainchance 5d", "%", "mdi:weather-pouring"], - "sunchance_1d": ["Sunchance 1d", "%", "mdi:weather-partly-cloudy"], - "sunchance_2d": ["Sunchance 2d", "%", "mdi:weather-partly-cloudy"], - "sunchance_3d": ["Sunchance 3d", "%", "mdi:weather-partly-cloudy"], - "sunchance_4d": ["Sunchance 4d", "%", "mdi:weather-partly-cloudy"], - "sunchance_5d": ["Sunchance 5d", "%", "mdi:weather-partly-cloudy"], + "rainchance_1d": ["Rainchance 1d", UNIT_PERCENTAGE, "mdi:weather-pouring"], + "rainchance_2d": ["Rainchance 2d", UNIT_PERCENTAGE, "mdi:weather-pouring"], + "rainchance_3d": ["Rainchance 3d", UNIT_PERCENTAGE, "mdi:weather-pouring"], + "rainchance_4d": ["Rainchance 4d", UNIT_PERCENTAGE, "mdi:weather-pouring"], + "rainchance_5d": ["Rainchance 5d", UNIT_PERCENTAGE, "mdi:weather-pouring"], + "sunchance_1d": ["Sunchance 1d", UNIT_PERCENTAGE, "mdi:weather-partly-cloudy"], + "sunchance_2d": ["Sunchance 2d", UNIT_PERCENTAGE, "mdi:weather-partly-cloudy"], + "sunchance_3d": ["Sunchance 3d", UNIT_PERCENTAGE, "mdi:weather-partly-cloudy"], + "sunchance_4d": ["Sunchance 4d", UNIT_PERCENTAGE, "mdi:weather-partly-cloudy"], + "sunchance_5d": ["Sunchance 5d", UNIT_PERCENTAGE, "mdi:weather-partly-cloudy"], "windforce_1d": ["Wind force 1d", "Bft", "mdi:weather-windy"], "windforce_2d": ["Wind force 2d", "Bft", "mdi:weather-windy"], "windforce_3d": ["Wind force 3d", "Bft", "mdi:weather-windy"], diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 67654c99f3e..09413f9cb61 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -1,7 +1,7 @@ """Support for Canary sensors.""" from canary.api import SensorType -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level @@ -14,10 +14,10 @@ ATTR_AIR_QUALITY = "air_quality" # sensor type name, unit_of_measurement, icon SENSOR_TYPES = [ ["temperature", TEMP_CELSIUS, "mdi:thermometer", ["Canary"]], - ["humidity", "%", "mdi:water-percent", ["Canary"]], + ["humidity", UNIT_PERCENTAGE, "mdi:water-percent", ["Canary"]], ["air_quality", None, "mdi:weather-windy", ["Canary"]], ["wifi", "dBm", "mdi:wifi", ["Canary Flex"]], - ["battery", "%", "mdi:battery-50", ["Canary Flex"]], + ["battery", UNIT_PERCENTAGE, "mdi:battery-50", ["Canary Flex"]], ] STATE_AIR_QUALITY_NORMAL = "normal" diff --git a/homeassistant/components/climate/device_trigger.py b/homeassistant/components/climate/device_trigger.py index 4c5dcb0ee04..187f50bc984 100644 --- a/homeassistant/components/climate/device_trigger.py +++ b/homeassistant/components/climate/device_trigger.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_FOR, CONF_PLATFORM, CONF_TYPE, + UNIT_PERCENTAGE, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry @@ -177,7 +178,7 @@ async def async_get_trigger_capabilities(hass: HomeAssistant, config): if trigger_type == "current_temperature_changed": unit_of_measurement = hass.config.units.temperature_unit else: - unit_of_measurement = "%" + unit_of_measurement = UNIT_PERCENTAGE return { "extra_fields": vol.Schema( diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py index 1d189508960..5c8c0d6a75c 100644 --- a/homeassistant/components/comfoconnect/sensor.py +++ b/homeassistant/components/comfoconnect/sensor.py @@ -33,6 +33,7 @@ from homeassistant.const import ( TEMP_CELSIUS, TIME_DAYS, TIME_HOURS, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -78,7 +79,7 @@ SENSOR_TYPES = { ATTR_CURRENT_HUMIDITY: { ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, ATTR_LABEL: "Inside Humidity", - ATTR_UNIT: "%", + ATTR_UNIT: UNIT_PERCENTAGE, ATTR_ICON: "mdi:water-percent", ATTR_ID: SENSOR_HUMIDITY_EXTRACT, }, @@ -93,7 +94,7 @@ SENSOR_TYPES = { ATTR_OUTSIDE_HUMIDITY: { ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, ATTR_LABEL: "Outside Humidity", - ATTR_UNIT: "%", + ATTR_UNIT: UNIT_PERCENTAGE, ATTR_ICON: "mdi:water-percent", ATTR_ID: SENSOR_HUMIDITY_OUTDOOR, }, @@ -108,7 +109,7 @@ SENSOR_TYPES = { ATTR_SUPPLY_HUMIDITY: { ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, ATTR_LABEL: "Supply Humidity", - ATTR_UNIT: "%", + ATTR_UNIT: UNIT_PERCENTAGE, ATTR_ICON: "mdi:water-percent", ATTR_ID: SENSOR_HUMIDITY_SUPPLY, }, @@ -122,7 +123,7 @@ SENSOR_TYPES = { ATTR_SUPPLY_FAN_DUTY: { ATTR_DEVICE_CLASS: None, ATTR_LABEL: "Supply Fan Duty", - ATTR_UNIT: "%", + ATTR_UNIT: UNIT_PERCENTAGE, ATTR_ICON: "mdi:fan", ATTR_ID: SENSOR_FAN_SUPPLY_DUTY, }, @@ -136,7 +137,7 @@ SENSOR_TYPES = { ATTR_EXHAUST_FAN_DUTY: { ATTR_DEVICE_CLASS: None, ATTR_LABEL: "Exhaust Fan Duty", - ATTR_UNIT: "%", + ATTR_UNIT: UNIT_PERCENTAGE, ATTR_ICON: "mdi:fan", ATTR_ID: SENSOR_FAN_EXHAUST_DUTY, }, @@ -151,7 +152,7 @@ SENSOR_TYPES = { ATTR_EXHAUST_HUMIDITY: { ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, ATTR_LABEL: "Exhaust Humidity", - ATTR_UNIT: "%", + ATTR_UNIT: UNIT_PERCENTAGE, ATTR_ICON: "mdi:water-percent", ATTR_ID: SENSOR_HUMIDITY_EXHAUST, }, @@ -172,7 +173,7 @@ SENSOR_TYPES = { ATTR_BYPASS_STATE: { ATTR_DEVICE_CLASS: None, ATTR_LABEL: "Bypass State", - ATTR_UNIT: "%", + ATTR_UNIT: UNIT_PERCENTAGE, ATTR_ICON: "mdi:camera-iris", ATTR_ID: SENSOR_BYPASS_STATE, }, diff --git a/homeassistant/components/cups/sensor.py b/homeassistant/components/cups/sensor.py index 7581891af6a..ac158388242 100644 --- a/homeassistant/components/cups/sensor.py +++ b/homeassistant/components/cups/sensor.py @@ -6,7 +6,7 @@ import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT, UNIT_PERCENTAGE from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -268,7 +268,7 @@ class MarkerSensor(Entity): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return "%" + return UNIT_PERCENTAGE @property def device_state_attributes(self): diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py index 247eb955154..73305d5c625 100644 --- a/homeassistant/components/danfoss_air/sensor.py +++ b/homeassistant/components/danfoss_air/sensor.py @@ -6,6 +6,7 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.helpers.entity import Entity @@ -45,14 +46,24 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ReadCommand.extractTemperature, DEVICE_CLASS_TEMPERATURE, ], - ["Danfoss Air Remaining Filter", "%", ReadCommand.filterPercent, None], - ["Danfoss Air Humidity", "%", ReadCommand.humidity, DEVICE_CLASS_HUMIDITY], - ["Danfoss Air Fan Step", "%", ReadCommand.fan_step, None], + [ + "Danfoss Air Remaining Filter", + UNIT_PERCENTAGE, + ReadCommand.filterPercent, + None, + ], + [ + "Danfoss Air Humidity", + UNIT_PERCENTAGE, + ReadCommand.humidity, + DEVICE_CLASS_HUMIDITY, + ], + ["Danfoss Air Fan Step", UNIT_PERCENTAGE, ReadCommand.fan_step, None], ["Danfoss Air Exhaust Fan Speed", "RPM", ReadCommand.exhaust_fan_speed, None], ["Danfoss Air Supply Fan Speed", "RPM", ReadCommand.supply_fan_speed, None], [ "Danfoss Air Dial Battery", - "%", + UNIT_PERCENTAGE, ReadCommand.battery_percent, DEVICE_CLASS_BATTERY, ], diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py index 46741e3aca7..26ba590888f 100644 --- a/homeassistant/components/darksky/sensor.py +++ b/homeassistant/components/darksky/sensor.py @@ -19,6 +19,7 @@ from homeassistant.const import ( SPEED_METERS_PER_SECOND, SPEED_MILES_PER_HOUR, TIME_HOURS, + UNIT_PERCENTAGE, UNIT_UV_INDEX, ) import homeassistant.helpers.config_validation as cv @@ -113,11 +114,11 @@ SENSOR_TYPES = { ], "precip_probability": [ "Precip Probability", - "%", - "%", - "%", - "%", - "%", + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, "mdi:water-percent", ["currently", "minutely", "hourly", "daily"], ], @@ -193,21 +194,21 @@ SENSOR_TYPES = { ], "cloud_cover": [ "Cloud Coverage", - "%", - "%", - "%", - "%", - "%", + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, "mdi:weather-partly-cloudy", ["currently", "hourly", "daily"], ], "humidity": [ "Humidity", - "%", - "%", - "%", - "%", - "%", + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, + UNIT_PERCENTAGE, "mdi:water-percent", ["currently", "hourly", "daily"], ], diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index c32b26f299d..fd8ffeeaaf0 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -9,7 +9,12 @@ from pydeconz.sensor import ( Thermostat, ) -from homeassistant.const import ATTR_TEMPERATURE, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY +from homeassistant.const import ( + ATTR_TEMPERATURE, + ATTR_VOLTAGE, + DEVICE_CLASS_BATTERY, + UNIT_PERCENTAGE, +) from homeassistant.core import callback from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -194,7 +199,7 @@ class DeconzBattery(DeconzDevice): @property def unit_of_measurement(self): """Return the unit of measurement of this entity.""" - return "%" + return UNIT_PERCENTAGE @property def device_state_attributes(self): diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py index d2b2464468b..6805ebb5b56 100644 --- a/homeassistant/components/demo/sensor.py +++ b/homeassistant/components/demo/sensor.py @@ -4,6 +4,7 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.helpers.entity import Entity @@ -23,7 +24,12 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= 12, ), DemoSensor( - "sensor_2", "Outside Humidity", 54, DEVICE_CLASS_HUMIDITY, "%", None + "sensor_2", + "Outside Humidity", + 54, + DEVICE_CLASS_HUMIDITY, + UNIT_PERCENTAGE, + None, ), ] ) diff --git a/homeassistant/components/dht/sensor.py b/homeassistant/components/dht/sensor.py index 26b0493cb99..b9461fae7d7 100644 --- a/homeassistant/components/dht/sensor.py +++ b/homeassistant/components/dht/sensor.py @@ -6,7 +6,12 @@ import Adafruit_DHT # pylint: disable=import-error import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_FAHRENHEIT +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, + CONF_NAME, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -28,7 +33,7 @@ SENSOR_TEMPERATURE = "temperature" SENSOR_HUMIDITY = "humidity" SENSOR_TYPES = { SENSOR_TEMPERATURE: ["Temperature", None], - SENSOR_HUMIDITY: ["Humidity", "%"], + SENSOR_HUMIDITY: ["Humidity", UNIT_PERCENTAGE], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py index 5e3745b27ed..8328df8bd7f 100644 --- a/homeassistant/components/dovado/sensor.py +++ b/homeassistant/components/dovado/sensor.py @@ -6,7 +6,7 @@ import re import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_SENSORS, DATA_GIGABYTES +from homeassistant.const import CONF_SENSORS, DATA_GIGABYTES, UNIT_PERCENTAGE import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -24,7 +24,12 @@ SENSOR_SMS_UNREAD = "sms" SENSORS = { SENSOR_NETWORK: ("signal strength", "Network", None, "mdi:access-point-network"), - SENSOR_SIGNAL: ("signal strength", "Signal Strength", "%", "mdi:signal"), + SENSOR_SIGNAL: ( + "signal strength", + "Signal Strength", + UNIT_PERCENTAGE, + "mdi:signal", + ), SENSOR_SMS_UNREAD: ("sms unread", "SMS unread", "", "mdi:message-text-outline"), SENSOR_UPLOAD: ("traffic modem tx", "Sent", DATA_GIGABYTES, "mdi:cloud-upload"), SENSOR_DOWNLOAD: ( diff --git a/homeassistant/components/dyson/sensor.py b/homeassistant/components/dyson/sensor.py index c7f61422a2e..55f2ff69314 100644 --- a/homeassistant/components/dyson/sensor.py +++ b/homeassistant/components/dyson/sensor.py @@ -4,7 +4,7 @@ import logging from libpurecool.dyson_pure_cool import DysonPureCool from libpurecool.dyson_pure_cool_link import DysonPureCoolLink -from homeassistant.const import STATE_OFF, TEMP_CELSIUS, TIME_HOURS +from homeassistant.const import STATE_OFF, TEMP_CELSIUS, TIME_HOURS, UNIT_PERCENTAGE from homeassistant.helpers.entity import Entity from . import DYSON_DEVICES @@ -13,7 +13,7 @@ SENSOR_UNITS = { "air_quality": None, "dust": None, "filter_life": TIME_HOURS, - "humidity": "%", + "humidity": UNIT_PERCENTAGE, } SENSOR_ICONS = { diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index 208ffb99543..dc150109cf7 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_USERNAME, DATA_GIGABITS, TIME_DAYS, + UNIT_PERCENTAGE, ) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -27,7 +28,6 @@ from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) PRICE = "CAD" -PERCENT = "%" DEFAULT_NAME = "EBox" @@ -36,7 +36,7 @@ SCAN_INTERVAL = timedelta(minutes=15) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) SENSOR_TYPES = { - "usage": ["Usage", PERCENT, "mdi:percent"], + "usage": ["Usage", UNIT_PERCENTAGE, "mdi:percent"], "balance": ["Balance", PRICE, "mdi:square-inc-cash"], "limit": ["Data limit", DATA_GIGABITS, "mdi:download"], "days_left": ["Days left", TIME_DAYS, "mdi:calendar-today"], diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py index ca3e7732e1b..e510cc976a6 100644 --- a/homeassistant/components/ecobee/sensor.py +++ b/homeassistant/components/ecobee/sensor.py @@ -5,6 +5,7 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, ) from homeassistant.helpers.entity import Entity @@ -12,7 +13,7 @@ from .const import _LOGGER, DOMAIN, ECOBEE_MODEL_TO_NAME, MANUFACTURER SENSOR_TYPES = { "temperature": ["Temperature", TEMP_FAHRENHEIT], - "humidity": ["Humidity", "%"], + "humidity": ["Humidity", UNIT_PERCENTAGE], } diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index af6de2657ce..457323d3bd5 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -1,6 +1,8 @@ """Support for Eight Sleep sensors.""" import logging +from homeassistant.const import UNIT_PERCENTAGE + from . import ( CONF_SENSORS, DATA_EIGHT, @@ -18,9 +20,9 @@ ATTR_AVG_RESP_RATE = "Average Respiratory Rate" ATTR_HEART_RATE = "Heart Rate" ATTR_AVG_HEART_RATE = "Average Heart Rate" ATTR_SLEEP_DUR = "Time Slept" -ATTR_LIGHT_PERC = "Light Sleep %" -ATTR_DEEP_PERC = "Deep Sleep %" -ATTR_REM_PERC = "REM Sleep %" +ATTR_LIGHT_PERC = f"Light Sleep {UNIT_PERCENTAGE}" +ATTR_DEEP_PERC = f"Deep Sleep {UNIT_PERCENTAGE}" +ATTR_REM_PERC = f"REM Sleep {UNIT_PERCENTAGE}" ATTR_TNT = "Tosses & Turns" ATTR_SLEEP_STAGE = "Sleep Stage" ATTR_TARGET_HEAT = "Target Heating Level" @@ -100,7 +102,7 @@ class EightHeatSensor(EightSleepHeatEntity): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return "%" + return UNIT_PERCENTAGE async def async_update(self): """Retrieve latest state.""" diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index 5cf908a33a1..45a20197a4a 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( STATE_CLOSED, STATE_OPEN, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv @@ -36,7 +37,7 @@ SENSOR_TYPE_WINDOWHANDLE = "windowhandle" SENSOR_TYPES = { SENSOR_TYPE_HUMIDITY: { "name": "Humidity", - "unit": "%", + "unit": UNIT_PERCENTAGE, "icon": "mdi:water-percent", "class": DEVICE_CLASS_HUMIDITY, }, diff --git a/homeassistant/components/epsonworkforce/sensor.py b/homeassistant/components/epsonworkforce/sensor.py index 3bb90eb0644..b2164325547 100644 --- a/homeassistant/components/epsonworkforce/sensor.py +++ b/homeassistant/components/epsonworkforce/sensor.py @@ -6,19 +6,19 @@ from epsonprinter_pkg.epsonprinterapi import EpsonPrinterAPI import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_HOST, CONF_MONITORED_CONDITIONS +from homeassistant.const import CONF_HOST, CONF_MONITORED_CONDITIONS, UNIT_PERCENTAGE from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) MONITORED_CONDITIONS = { - "black": ["Ink level Black", "%", "mdi:water"], - "photoblack": ["Ink level Photoblack", "%", "mdi:water"], - "magenta": ["Ink level Magenta", "%", "mdi:water"], - "cyan": ["Ink level Cyan", "%", "mdi:water"], - "yellow": ["Ink level Yellow", "%", "mdi:water"], - "clean": ["Cleaning level", "%", "mdi:water"], + "black": ["Ink level Black", UNIT_PERCENTAGE, "mdi:water"], + "photoblack": ["Ink level Photoblack", UNIT_PERCENTAGE, "mdi:water"], + "magenta": ["Ink level Magenta", UNIT_PERCENTAGE, "mdi:water"], + "cyan": ["Ink level Cyan", UNIT_PERCENTAGE, "mdi:water"], + "yellow": ["Ink level Yellow", UNIT_PERCENTAGE, "mdi:water"], + "clean": ["Cleaning level", UNIT_PERCENTAGE, "mdi:water"], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index 5fce0da7a2b..68a39431a98 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -9,6 +9,7 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, ) from homeassistant.helpers.entity import Entity @@ -28,7 +29,12 @@ SENSOR_TYPES = { None, ], "CO2": ["CO2", CONCENTRATION_PARTS_PER_MILLION, "mdi:cloud", None], - "com.fibaro.humiditySensor": ["Humidity", "%", None, DEVICE_CLASS_HUMIDITY], + "com.fibaro.humiditySensor": [ + "Humidity", + UNIT_PERCENTAGE, + None, + DEVICE_CLASS_HUMIDITY, + ], "com.fibaro.lightSensor": ["Light", "lx", None, DEVICE_CLASS_ILLUMINANCE], } diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py index b6a4fe550c9..66c283f20ef 100644 --- a/homeassistant/components/fitbit/sensor.py +++ b/homeassistant/components/fitbit/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( MASS_MILLIGRAMS, TIME_MILLISECONDS, TIME_MINUTES, + UNIT_PERCENTAGE, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -91,11 +92,11 @@ FITBIT_RESOURCES_LIST = { ], "activities/tracker/steps": ["Tracker Steps", "steps", "walk"], "body/bmi": ["BMI", "BMI", "human"], - "body/fat": ["Body Fat", "%", "human"], + "body/fat": ["Body Fat", UNIT_PERCENTAGE, "human"], "body/weight": ["Weight", "", "human"], "devices/battery": ["Battery", None, None], "sleep/awakeningsCount": ["Awakenings Count", "times awaken", "sleep"], - "sleep/efficiency": ["Sleep Efficiency", "%", "sleep"], + "sleep/efficiency": ["Sleep Efficiency", UNIT_PERCENTAGE, "sleep"], "sleep/minutesAfterWakeup": ["Minutes After Wakeup", TIME_MINUTES, "sleep"], "sleep/minutesAsleep": ["Sleep Minutes Asleep", TIME_MINUTES, "sleep"], "sleep/minutesAwake": ["Sleep Minutes Awake", TIME_MINUTES, "sleep"], diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py index 80d17b4f23b..e0322ccbab7 100644 --- a/homeassistant/components/foobot/sensor.py +++ b/homeassistant/components/foobot/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_USERNAME, TEMP_CELSIUS, TIME_SECONDS, + UNIT_PERCENTAGE, ) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -37,7 +38,7 @@ SENSOR_TYPES = { "time": [ATTR_TIME, TIME_SECONDS], "pm": [ATTR_PM2_5, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, "mdi:cloud"], "tmp": [ATTR_TEMPERATURE, TEMP_CELSIUS, "mdi:thermometer"], - "hum": [ATTR_HUMIDITY, "%", "mdi:water-percent"], + "hum": [ATTR_HUMIDITY, UNIT_PERCENTAGE, "mdi:water-percent"], "co2": [ ATTR_CARBON_DIOXIDE, CONCENTRATION_PARTS_PER_MILLION, @@ -48,7 +49,7 @@ SENSOR_TYPES = { CONCENTRATION_PARTS_PER_BILLION, "mdi:cloud", ], - "allpollu": [ATTR_FOOBOT_INDEX, "%", "mdi:percent"], + "allpollu": [ATTR_FOOBOT_INDEX, UNIT_PERCENTAGE, "mdi:percent"], } SCAN_INTERVAL = timedelta(minutes=10) diff --git a/homeassistant/components/garmin_connect/const.py b/homeassistant/components/garmin_connect/const.py index c01f11464a1..38245ff5eb8 100644 --- a/homeassistant/components/garmin_connect/const.py +++ b/homeassistant/components/garmin_connect/const.py @@ -1,5 +1,5 @@ """Constants for the Garmin Connect integration.""" -from homeassistant.const import DEVICE_CLASS_TIMESTAMP, TIME_MINUTES +from homeassistant.const import DEVICE_CLASS_TIMESTAMP, TIME_MINUTES, UNIT_PERCENTAGE DOMAIN = "garmin_connect" ATTRIBUTION = "Data provided by garmin.com" @@ -159,45 +159,51 @@ GARMIN_ENTITY_LIST = { None, True, ], - "stressPercentage": ["Stress Percentage", "%", "mdi:flash-alert", None, False], + "stressPercentage": [ + "Stress Percentage", + UNIT_PERCENTAGE, + "mdi:flash-alert", + None, + False, + ], "restStressPercentage": [ "Rest Stress Percentage", - "%", + UNIT_PERCENTAGE, "mdi:flash-alert", None, False, ], "activityStressPercentage": [ "Activity Stress Percentage", - "%", + UNIT_PERCENTAGE, "mdi:flash-alert", None, False, ], "uncategorizedStressPercentage": [ "Uncat. Stress Percentage", - "%", + UNIT_PERCENTAGE, "mdi:flash-alert", None, False, ], "lowStressPercentage": [ "Low Stress Percentage", - "%", + UNIT_PERCENTAGE, "mdi:flash-alert", None, False, ], "mediumStressPercentage": [ "Medium Stress Percentage", - "%", + UNIT_PERCENTAGE, "mdi:flash-alert", None, False, ], "highStressPercentage": [ "High Stress Percentage", - "%", + UNIT_PERCENTAGE, "mdi:flash-alert", None, False, @@ -225,42 +231,42 @@ GARMIN_ENTITY_LIST = { ], "bodyBatteryChargedValue": [ "Body Battery Charged", - "%", + UNIT_PERCENTAGE, "mdi:battery-charging-100", None, True, ], "bodyBatteryDrainedValue": [ "Body Battery Drained", - "%", + UNIT_PERCENTAGE, "mdi:battery-alert-variant-outline", None, True, ], "bodyBatteryHighestValue": [ "Body Battery Highest", - "%", + UNIT_PERCENTAGE, "mdi:battery-heart", None, True, ], "bodyBatteryLowestValue": [ "Body Battery Lowest", - "%", + UNIT_PERCENTAGE, "mdi:battery-heart-outline", None, True, ], "bodyBatteryMostRecentValue": [ "Body Battery Most Recent", - "%", + UNIT_PERCENTAGE, "mdi:battery-positive", None, True, ], - "averageSpo2": ["Average SPO2", "%", "mdi:diabetes", None, True], - "lowestSpo2": ["Lowest SPO2", "%", "mdi:diabetes", None, True], - "latestSpo2": ["Latest SPO2", "%", "mdi:diabetes", None, True], + "averageSpo2": ["Average SPO2", UNIT_PERCENTAGE, "mdi:diabetes", None, True], + "lowestSpo2": ["Lowest SPO2", UNIT_PERCENTAGE, "mdi:diabetes", None, True], + "latestSpo2": ["Latest SPO2", UNIT_PERCENTAGE, "mdi:diabetes", None, True], "latestSpo2ReadingTimeLocal": [ "Latest SPO2 Time", "", @@ -270,7 +276,7 @@ GARMIN_ENTITY_LIST = { ], "averageMonitoringEnvironmentAltitude": [ "Average Altitude", - "%", + UNIT_PERCENTAGE, "mdi:image-filter-hdr", None, False, diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py index bd73c700e65..196cba7212e 100644 --- a/homeassistant/components/geniushub/sensor.py +++ b/homeassistant/components/geniushub/sensor.py @@ -2,7 +2,7 @@ from datetime import timedelta from typing import Any, Dict -from homeassistant.const import DEVICE_CLASS_BATTERY +from homeassistant.const import DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE from homeassistant.helpers.typing import ConfigType, HomeAssistantType import homeassistant.util.dt as dt_util @@ -77,7 +77,7 @@ class GeniusBattery(GeniusDevice): @property def unit_of_measurement(self) -> str: """Return the unit of measurement of the sensor.""" - return "%" + return UNIT_PERCENTAGE @property def state(self) -> str: diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index 31a3f0f69e4..53dc6352049 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -1,5 +1,10 @@ """Constants for Glances component.""" -from homeassistant.const import DATA_GIBIBYTES, DATA_MEBIBYTES, TEMP_CELSIUS +from homeassistant.const import ( + DATA_GIBIBYTES, + DATA_MEBIBYTES, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) DOMAIN = "glances" CONF_VERSION = "version" @@ -14,13 +19,13 @@ DATA_UPDATED = "glances_data_updated" SUPPORTED_VERSIONS = [2, 3] SENSOR_TYPES = { - "disk_use_percent": ["fs", "used percent", "%", "mdi:harddisk"], + "disk_use_percent": ["fs", "used percent", UNIT_PERCENTAGE, "mdi:harddisk"], "disk_use": ["fs", "used", DATA_GIBIBYTES, "mdi:harddisk"], "disk_free": ["fs", "free", DATA_GIBIBYTES, "mdi:harddisk"], - "memory_use_percent": ["mem", "RAM used percent", "%", "mdi:memory"], + "memory_use_percent": ["mem", "RAM used percent", UNIT_PERCENTAGE, "mdi:memory"], "memory_use": ["mem", "RAM used", DATA_MEBIBYTES, "mdi:memory"], "memory_free": ["mem", "RAM free", DATA_MEBIBYTES, "mdi:memory"], - "swap_use_percent": ["memswap", "Swap used percent", "%", "mdi:memory"], + "swap_use_percent": ["memswap", "Swap used percent", UNIT_PERCENTAGE, "mdi:memory"], "swap_use": ["memswap", "Swap used", DATA_GIBIBYTES, "mdi:memory"], "swap_free": ["memswap", "Swap free", DATA_GIBIBYTES, "mdi:memory"], "processor_load": ["load", "CPU load", "15 min", "mdi:memory"], @@ -28,10 +33,10 @@ SENSOR_TYPES = { "process_total": ["processcount", "Total", "Count", "mdi:memory"], "process_thread": ["processcount", "Thread", "Count", "mdi:memory"], "process_sleeping": ["processcount", "Sleeping", "Count", "mdi:memory"], - "cpu_use_percent": ["cpu", "CPU used", "%", "mdi:memory"], + "cpu_use_percent": ["cpu", "CPU used", UNIT_PERCENTAGE, "mdi:memory"], "sensor_temp": ["sensors", "Temp", TEMP_CELSIUS, "mdi:thermometer"], "docker_active": ["docker", "Containers active", "", "mdi:docker"], - "docker_cpu_use": ["docker", "Containers CPU used", "%", "mdi:docker"], + "docker_cpu_use": ["docker", "Containers CPU used", UNIT_PERCENTAGE, "mdi:docker"], "docker_memory_use": [ "docker", "Containers RAM used", diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 0b1289ab05f..48d65145219 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_TYPE, EVENT_HOMEASSISTANT_START, TIME_HOURS, + UNIT_PERCENTAGE, ) from homeassistant.core import callback from homeassistant.exceptions import TemplateError @@ -36,7 +37,11 @@ CONF_TYPE_COUNT = "count" CONF_TYPE_KEYS = [CONF_TYPE_TIME, CONF_TYPE_RATIO, CONF_TYPE_COUNT] DEFAULT_NAME = "unnamed statistics" -UNITS = {CONF_TYPE_TIME: TIME_HOURS, CONF_TYPE_RATIO: "%", CONF_TYPE_COUNT: ""} +UNITS = { + CONF_TYPE_TIME: TIME_HOURS, + CONF_TYPE_RATIO: UNIT_PERCENTAGE, + CONF_TYPE_COUNT: "", +} ICON = "mdi:chart-line" ATTR_VALUE = "value" diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index ca5a601068a..c46bd754319 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -23,6 +23,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA @@ -236,7 +237,7 @@ def get_accessory(hass, driver, state, aid, config): TEMP_FAHRENHEIT, ): a_type = "TemperatureSensor" - elif device_class == DEVICE_CLASS_HUMIDITY and unit == "%": + elif device_class == DEVICE_CLASS_HUMIDITY and unit == UNIT_PERCENTAGE: a_type = "HumiditySensor" elif device_class == DEVICE_CLASS_PM25 or DEVICE_CLASS_PM25 in state.entity_id: a_type = "AirQualitySensor" diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index ab8a6fa6672..636c10dbe79 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -8,6 +8,7 @@ from homeassistant.const import ( DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.core import callback @@ -18,7 +19,6 @@ TEMP_C_ICON = "mdi:thermometer" BRIGHTNESS_ICON = "mdi:brightness-6" CO2_ICON = "mdi:periodic-table-co2" -UNIT_PERCENT = "%" UNIT_LUX = "lux" @@ -52,7 +52,7 @@ class HomeKitHumiditySensor(HomeKitEntity): @property def unit_of_measurement(self): """Return units for the sensor.""" - return UNIT_PERCENT + return UNIT_PERCENTAGE def _update_relative_humidity_current(self, value): self._state = value @@ -234,7 +234,7 @@ class HomeKitBatterySensor(HomeKitEntity): @property def unit_of_measurement(self): """Return units for the sensor.""" - return UNIT_PERCENT + return UNIT_PERCENTAGE def _update_battery_level(self, value): self._state = value diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index 09d1e2f59cf..e8b97477546 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -9,6 +9,7 @@ from homeassistant.const import ( ENERGY_WATT_HOUR, POWER_WATT, SPEED_KILOMETERS_PER_HOUR, + UNIT_PERCENTAGE, VOLUME_CUBIC_METERS, ) @@ -32,7 +33,7 @@ HM_STATE_HA_CAST = { } HM_UNIT_HA_CAST = { - "HUMIDITY": "%", + "HUMIDITY": UNIT_PERCENTAGE, "TEMPERATURE": "°C", "ACTUAL_TEMPERATURE": "°C", "BRIGHTNESS": "#", diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 4335eebb8b8..a45591ecc30 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -33,6 +33,7 @@ from homeassistant.const import ( POWER_WATT, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.helpers.typing import HomeAssistantType @@ -155,7 +156,7 @@ class HomematicipAccesspointStatus(HomematicipGenericDevice): @property def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" - return "%" + return UNIT_PERCENTAGE @property def device_state_attributes(self) -> Dict[str, Any]: @@ -194,7 +195,7 @@ class HomematicipHeatingThermostat(HomematicipGenericDevice): @property def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" - return "%" + return UNIT_PERCENTAGE class HomematicipHumiditySensor(HomematicipGenericDevice): @@ -217,7 +218,7 @@ class HomematicipHumiditySensor(HomematicipGenericDevice): @property def unit_of_measurement(self) -> str: """Return the unit this state is expressed in.""" - return "%" + return UNIT_PERCENTAGE class HomematicipTemperatureSensor(HomematicipGenericDevice): diff --git a/homeassistant/components/htu21d/sensor.py b/homeassistant/components/htu21d/sensor.py index 954ba60abbf..5bd77d4dcb2 100644 --- a/homeassistant/components/htu21d/sensor.py +++ b/homeassistant/components/htu21d/sensor.py @@ -8,7 +8,7 @@ import smbus # pylint: disable=import-error import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME, TEMP_FAHRENHEIT +from homeassistant.const import CONF_NAME, TEMP_FAHRENHEIT, UNIT_PERCENTAGE import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -50,7 +50,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= dev = [ HTU21DSensor(sensor_handler, name, SENSOR_TEMPERATURE, temp_unit), - HTU21DSensor(sensor_handler, name, SENSOR_HUMIDITY, "%"), + HTU21DSensor(sensor_handler, name, SENSOR_HUMIDITY, UNIT_PERCENTAGE), ] async_add_entities(dev) diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index e24016795d3..5438b9ce810 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -3,7 +3,7 @@ import logging from typing import Dict from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME, DEVICE_CLASS_BATTERY +from homeassistant.const import CONF_USERNAME, DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level @@ -61,7 +61,7 @@ class IcloudDeviceBatterySensor(Entity): @property def unit_of_measurement(self) -> str: """Battery state measured in percentage.""" - return "%" + return UNIT_PERCENTAGE @property def icon(self) -> str: diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index 85a0a81fdf3..e12ab9b4a40 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -1,9 +1,13 @@ """Support for Home Assistant iOS app sensors.""" from homeassistant.components import ios +from homeassistant.const import UNIT_PERCENTAGE from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level -SENSOR_TYPES = {"level": ["Battery Level", "%"], "state": ["Battery State", None]} +SENSOR_TYPES = { + "level": ["Battery Level", UNIT_PERCENTAGE], + "state": ["Battery State", None], +} DEFAULT_ICON_LEVEL = "mdi:battery" DEFAULT_ICON_STATE = "mdi:power-plug" diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 3cffbdb1214..6615fc6c569 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -12,6 +12,7 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, + UNIT_PERCENTAGE, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv, discovery @@ -101,7 +102,7 @@ NODE_FILTERS = { }, "light": { "uom": ["51"], - "states": ["on", "off", "%"], + "states": ["on", "off", UNIT_PERCENTAGE], "node_def_id": [ "DimmerLampSwitch", "DimmerLampSwitch_ADV", diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 42590b0ea13..9e2f3e90957 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( TIME_MONTHS, TIME_SECONDS, TIME_YEARS, + UNIT_PERCENTAGE, UNIT_UV_INDEX, ) from homeassistant.helpers.typing import ConfigType @@ -74,7 +75,7 @@ UOM_FRIENDLY_NAME = { "48": SPEED_MILES_PER_HOUR, "49": SPEED_METERS_PER_SECOND, "50": "ohm", - "51": "%", + "51": UNIT_PERCENTAGE, "52": "lb", "53": "power factor", "54": CONCENTRATION_PARTS_PER_MILLION, diff --git a/homeassistant/components/kaiterra/const.py b/homeassistant/components/kaiterra/const.py index 6c3ea4d6f01..7f7eff99444 100644 --- a/homeassistant/components/kaiterra/const.py +++ b/homeassistant/components/kaiterra/const.py @@ -7,6 +7,7 @@ from homeassistant.const import ( CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, + UNIT_PERCENTAGE, ) DOMAIN = "kaiterra" @@ -53,7 +54,7 @@ ATTR_AQI_POLLUTANT = "air_quality_index_pollutant" AVAILABLE_AQI_STANDARDS = ["us", "cn", "in"] AVAILABLE_UNITS = [ "x", - "%", + UNIT_PERCENTAGE, "C", "F", CONCENTRATION_MILLIGRAMS_PER_CUBIC_METER, diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index 4fa238166f8..e936898d7fb 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -10,6 +10,7 @@ from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -21,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = { DEVICE_CLASS_TEMPERATURE: ["Temperature", TEMP_CELSIUS], - DEVICE_CLASS_HUMIDITY: ["Humidity", "%"], + DEVICE_CLASS_HUMIDITY: ["Humidity", UNIT_PERCENTAGE], } @@ -119,9 +120,7 @@ class KonnectedSensor(Entity): @property def device_info(self): """Return the device info.""" - return { - "identifiers": {(KONNECTED_DOMAIN, self._device_id)}, - } + return {"identifiers": {(KONNECTED_DOMAIN, self._device_id)}} async def async_added_to_hass(self): """Store entity_id and register state change callback.""" diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py index b8bde797b39..1d38764710a 100644 --- a/homeassistant/components/lacrosse/sensor.py +++ b/homeassistant/components/lacrosse/sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( CONF_TYPE, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -193,7 +194,7 @@ class LaCrosseHumidity(LaCrosseSensor): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return "%" + return UNIT_PERCENTAGE @property def state(self): diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py index ff7de1987da..0a46190fbf9 100644 --- a/homeassistant/components/lcn/const.py +++ b/homeassistant/components/lcn/const.py @@ -1,7 +1,7 @@ """Constants for the LCN component.""" from itertools import product -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_PERCENTAGE DOMAIN = "lcn" DATA_LCN = "lcn" @@ -153,7 +153,7 @@ VAR_UNITS = [ "LX", "M/S", "METERPERSECOND", - "%", + UNIT_PERCENTAGE, "PERCENT", "PPM", "VOLT", diff --git a/homeassistant/components/linux_battery/sensor.py b/homeassistant/components/linux_battery/sensor.py index bc02affdaed..bb1dd34dc00 100644 --- a/homeassistant/components/linux_battery/sensor.py +++ b/homeassistant/components/linux_battery/sensor.py @@ -6,7 +6,12 @@ from batinfo import Batteries import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_NAME, CONF_NAME, DEVICE_CLASS_BATTERY +from homeassistant.const import ( + ATTR_NAME, + CONF_NAME, + DEVICE_CLASS_BATTERY, + UNIT_PERCENTAGE, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -98,7 +103,7 @@ class LinuxBatterySensor(Entity): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return "%" + return UNIT_PERCENTAGE @property def device_state_attributes(self): diff --git a/homeassistant/components/logi_circle/const.py b/homeassistant/components/logi_circle/const.py index 4be4823f8d7..333a85e9b77 100644 --- a/homeassistant/components/logi_circle/const.py +++ b/homeassistant/components/logi_circle/const.py @@ -1,4 +1,5 @@ """Constants in Logi Circle component.""" +from homeassistant.const import UNIT_PERCENTAGE CONF_CLIENT_ID = "client_id" CONF_CLIENT_SECRET = "client_secret" @@ -15,11 +16,11 @@ RECORDING_MODE_KEY = "RECORDING_MODE" # Sensor types: Name, unit of measure, icon per sensor key. LOGI_SENSORS = { - "battery_level": ["Battery", "%", "battery-50"], + "battery_level": ["Battery", UNIT_PERCENTAGE, "battery-50"], "last_activity_time": ["Last Activity", None, "history"], "recording": ["Recording Mode", None, "eye"], "signal_strength_category": ["WiFi Signal Category", None, "wifi"], - "signal_strength_percentage": ["WiFi Signal Strength", "%", "wifi"], + "signal_strength_percentage": ["WiFi Signal Strength", UNIT_PERCENTAGE, "wifi"], "streaming": ["Streaming Mode", None, "camera"], } diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py index a722829a4a2..f7ed2d72f16 100644 --- a/homeassistant/components/luftdaten/__init__.py +++ b/homeassistant/components/luftdaten/__init__.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_SENSORS, CONF_SHOW_ON_MAP, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady @@ -42,7 +43,7 @@ TOPIC_UPDATE = f"{DOMAIN}_data_update" SENSORS = { SENSOR_TEMPERATURE: ["Temperature", "mdi:thermometer", TEMP_CELSIUS], - SENSOR_HUMIDITY: ["Humidity", "mdi:water-percent", "%"], + SENSOR_HUMIDITY: ["Humidity", "mdi:water-percent", UNIT_PERCENTAGE], SENSOR_PRESSURE: ["Pressure", "mdi:arrow-down-bold", "Pa"], SENSOR_PRESSURE_AT_SEALEVEL: ["Pressure at sealevel", "mdi:download", "Pa"], SENSOR_PM10: [ diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py index 9fde6f38b51..b7647f5d97b 100644 --- a/homeassistant/components/meteo_france/const.py +++ b/homeassistant/components/meteo_france/const.py @@ -1,6 +1,11 @@ """Meteo-France component constants.""" -from homeassistant.const import SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, TIME_MINUTES +from homeassistant.const import ( + SPEED_KILOMETERS_PER_HOUR, + TEMP_CELSIUS, + TIME_MINUTES, + UNIT_PERCENTAGE, +) DOMAIN = "meteo_france" PLATFORMS = ["sensor", "weather"] @@ -17,25 +22,25 @@ SENSOR_TYPE_CLASS = "device_class" SENSOR_TYPES = { "rain_chance": { SENSOR_TYPE_NAME: "Rain chance", - SENSOR_TYPE_UNIT: "%", + SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, SENSOR_TYPE_ICON: "mdi:weather-rainy", SENSOR_TYPE_CLASS: None, }, "freeze_chance": { SENSOR_TYPE_NAME: "Freeze chance", - SENSOR_TYPE_UNIT: "%", + SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, SENSOR_TYPE_ICON: "mdi:snowflake", SENSOR_TYPE_CLASS: None, }, "thunder_chance": { SENSOR_TYPE_NAME: "Thunder chance", - SENSOR_TYPE_UNIT: "%", + SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, SENSOR_TYPE_ICON: "mdi:weather-lightning", SENSOR_TYPE_CLASS: None, }, "snow_chance": { SENSOR_TYPE_NAME: "Snow chance", - SENSOR_TYPE_UNIT: "%", + SENSOR_TYPE_UNIT: UNIT_PERCENTAGE, SENSOR_TYPE_ICON: "mdi:weather-snowy", SENSOR_TYPE_CLASS: None, }, diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 6e6fde06d8c..1c4ca83e5bd 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( CONF_NAME, SPEED_MILES_PER_HOUR, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -71,8 +72,8 @@ SENSOR_TYPES = { "visibility": ["Visibility", None], "visibility_distance": ["Visibility Distance", "km"], "uv": ["UV", None], - "precipitation": ["Probability of Precipitation", "%"], - "humidity": ["Humidity", "%"], + "precipitation": ["Probability of Precipitation", UNIT_PERCENTAGE], + "humidity": ["Humidity", UNIT_PERCENTAGE], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/microsoft/tts.py b/homeassistant/components/microsoft/tts.py index 074605e07fe..c857cc731f4 100644 --- a/homeassistant/components/microsoft/tts.py +++ b/homeassistant/components/microsoft/tts.py @@ -6,7 +6,7 @@ from pycsspeechtts import pycsspeechtts import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider -from homeassistant.const import CONF_API_KEY, CONF_TYPE +from homeassistant.const import CONF_API_KEY, CONF_TYPE, UNIT_PERCENTAGE import homeassistant.helpers.config_validation as cv CONF_GENDER = "gender" @@ -122,8 +122,8 @@ class MicrosoftProvider(Provider): self._gender = gender self._type = ttype self._output = DEFAULT_OUTPUT - self._rate = f"{rate}%" - self._volume = f"{volume}%" + self._rate = f"{rate}{UNIT_PERCENTAGE}" + self._volume = f"{volume}{UNIT_PERCENTAGE}" self._pitch = pitch self._contour = contour self._region = region diff --git a/homeassistant/components/miflora/sensor.py b/homeassistant/components/miflora/sensor.py index 815a6e97bb8..bd551517562 100644 --- a/homeassistant/components/miflora/sensor.py +++ b/homeassistant/components/miflora/sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( CONF_NAME, CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START, + UNIT_PERCENTAGE, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -43,9 +44,9 @@ SCAN_INTERVAL = timedelta(seconds=1200) SENSOR_TYPES = { "temperature": ["Temperature", "°C", "mdi:thermometer"], "light": ["Light intensity", "lx", "mdi:white-balance-sunny"], - "moisture": ["Moisture", "%", "mdi:water-percent"], + "moisture": ["Moisture", UNIT_PERCENTAGE, "mdi:water-percent"], "conductivity": ["Conductivity", "µS/cm", "mdi:flash-circle"], - "battery": ["Battery", "%", "mdi:battery-charging"], + "battery": ["Battery", UNIT_PERCENTAGE, "mdi:battery-charging"], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/mitemp_bt/sensor.py b/homeassistant/components/mitemp_bt/sensor.py index b536149680d..febfb93cf1d 100644 --- a/homeassistant/components/mitemp_bt/sensor.py +++ b/homeassistant/components/mitemp_bt/sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -46,8 +47,8 @@ DEFAULT_TIMEOUT = 10 # Sensor types are defined like: Name, units SENSOR_TYPES = { "temperature": [DEVICE_CLASS_TEMPERATURE, "Temperature", "°C"], - "humidity": [DEVICE_CLASS_HUMIDITY, "Humidity", "%"], - "battery": [DEVICE_CLASS_BATTERY, "Battery", "%"], + "humidity": [DEVICE_CLASS_HUMIDITY, "Humidity", UNIT_PERCENTAGE], + "battery": [DEVICE_CLASS_BATTERY, "Battery", UNIT_PERCENTAGE], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index 0d6c6f55284..b5c72fdce29 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, ) from homeassistant.core import callback import homeassistant.helpers.config_validation as cv @@ -245,7 +246,7 @@ class MoldIndicator(Entity): ) return None - if unit != "%": + if unit != UNIT_PERCENTAGE: _LOGGER.error( "Humidity sensor %s has unsupported unit: %s %s", state.entity_id, @@ -360,7 +361,7 @@ class MoldIndicator(Entity): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return "%" + return UNIT_PERCENTAGE @property def state(self): diff --git a/homeassistant/components/mychevy/sensor.py b/homeassistant/components/mychevy/sensor.py index 844c301fe49..56335bb1650 100644 --- a/homeassistant/components/mychevy/sensor.py +++ b/homeassistant/components/mychevy/sensor.py @@ -2,6 +2,7 @@ import logging from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.const import UNIT_PERCENTAGE from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level @@ -25,7 +26,9 @@ SENSORS = [ EVSensorConfig("Electric Range", "electricRange", "miles", "mdi:speedometer"), EVSensorConfig("Charged By", "estimatedFullChargeBy"), EVSensorConfig("Charge Mode", "chargeMode"), - EVSensorConfig("Battery Level", BATTERY_SENSOR, "%", "mdi:battery", ["charging"]), + EVSensorConfig( + "Battery Level", BATTERY_SENSOR, UNIT_PERCENTAGE, "mdi:battery", ["charging"] + ), ] diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 2b8cf208c14..997728ed495 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -7,13 +7,14 @@ from homeassistant.const import ( POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, ) SENSORS = { "V_TEMP": [None, "mdi:thermometer"], - "V_HUM": ["%", "mdi:water-percent"], - "V_DIMMER": ["%", "mdi:percent"], - "V_PERCENTAGE": ["%", "mdi:percent"], + "V_HUM": [UNIT_PERCENTAGE, "mdi:water-percent"], + "V_DIMMER": [UNIT_PERCENTAGE, "mdi:percent"], + "V_PERCENTAGE": [UNIT_PERCENTAGE, "mdi:percent"], "V_PRESSURE": [None, "mdi:gauge"], "V_FORECAST": [None, "mdi:weather-partly-cloudy"], "V_RAIN": [None, "mdi:weather-rainy"], @@ -26,7 +27,7 @@ SENSORS = { "V_IMPEDANCE": ["ohm", None], "V_WATT": [POWER_WATT, None], "V_KWH": [ENERGY_KILO_WATT_HOUR, None], - "V_LIGHT_LEVEL": ["%", "mdi:white-balance-sunny"], + "V_LIGHT_LEVEL": [UNIT_PERCENTAGE, "mdi:white-balance-sunny"], "V_FLOW": ["m", "mdi:gauge"], "V_VOLUME": ["m³", None], "V_LEVEL": { diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py index 70d273fe690..5573e280a99 100644 --- a/homeassistant/components/neato/sensor.py +++ b/homeassistant/components/neato/sensor.py @@ -5,6 +5,7 @@ import logging from pybotvac.exceptions import NeatoRobotException from homeassistant.components.sensor import DEVICE_CLASS_BATTERY +from homeassistant.const import UNIT_PERCENTAGE from homeassistant.helpers.entity import Entity from .const import NEATO_DOMAIN, NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES @@ -83,7 +84,7 @@ class NeatoSensor(Entity): @property def unit_of_measurement(self): """Return unit of measurement.""" - return "%" + return UNIT_PERCENTAGE @property def device_info(self): diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py index d52df4c6586..225caee0a90 100644 --- a/homeassistant/components/nest/sensor.py +++ b/homeassistant/components/nest/sensor.py @@ -8,6 +8,7 @@ from homeassistant.const import ( STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, ) from . import CONF_SENSORS, DATA_NEST, DATA_NEST_CONFIG, NestSensorDevice @@ -41,7 +42,7 @@ _VALID_SENSOR_TYPES = ( + STRUCTURE_CAMERA_SENSOR_TYPES ) -SENSOR_UNITS = {"humidity": "%"} +SENSOR_UNITS = {"humidity": UNIT_PERCENTAGE} SENSOR_DEVICE_CLASSES = {"humidity": DEVICE_CLASS_HUMIDITY} diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index 9254f2f45ab..f52b6797a7a 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -11,6 +11,7 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -57,13 +58,18 @@ SENSOR_TYPES = { "co2": ["CO2", CONCENTRATION_PARTS_PER_MILLION, "mdi:periodic-table-co2", None], "pressure": ["Pressure", "mbar", "mdi:gauge", None], "noise": ["Noise", "dB", "mdi:volume-high", None], - "humidity": ["Humidity", "%", "mdi:water-percent", DEVICE_CLASS_HUMIDITY], + "humidity": [ + "Humidity", + UNIT_PERCENTAGE, + "mdi:water-percent", + DEVICE_CLASS_HUMIDITY, + ], "rain": ["Rain", "mm", "mdi:weather-rainy", None], "sum_rain_1": ["sum_rain_1", "mm", "mdi:weather-rainy", None], "sum_rain_24": ["sum_rain_24", "mm", "mdi:weather-rainy", None], "battery_vp": ["Battery", "", "mdi:battery", None], "battery_lvl": ["Battery_lvl", "", "mdi:battery", None], - "battery_percent": ["battery_percent", "%", None, DEVICE_CLASS_BATTERY], + "battery_percent": ["battery_percent", UNIT_PERCENTAGE, None, DEVICE_CLASS_BATTERY], "min_temp": ["Min Temp.", TEMP_CELSIUS, "mdi:thermometer", None], "max_temp": ["Max Temp.", TEMP_CELSIUS, "mdi:thermometer", None], "windangle": ["Angle", "", "mdi:compass", None], diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index edabef9535c..4406734b094 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PORT, CONF_RESOURCES, + UNIT_PERCENTAGE, ) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -78,7 +79,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= try: resource_data = netdata.api.metrics[sensor] unit = ( - "%" + UNIT_PERCENTAGE if resource_data["units"] == "percentage" else resource_data["units"] ) diff --git a/homeassistant/components/netgear_lte/sensor_types.py b/homeassistant/components/netgear_lte/sensor_types.py index a744937dacd..883b4803544 100644 --- a/homeassistant/components/netgear_lte/sensor_types.py +++ b/homeassistant/components/netgear_lte/sensor_types.py @@ -1,7 +1,7 @@ """Define possible sensor types.""" from homeassistant.components.binary_sensor import DEVICE_CLASS_CONNECTIVITY -from homeassistant.const import DATA_MEBIBYTES +from homeassistant.const import DATA_MEBIBYTES, UNIT_PERCENTAGE SENSOR_SMS = "sms" SENSOR_SMS_TOTAL = "sms_total" @@ -11,7 +11,7 @@ SENSOR_UNITS = { SENSOR_SMS: "unread", SENSOR_SMS_TOTAL: "messages", SENSOR_USAGE: DATA_MEBIBYTES, - "radio_quality": "%", + "radio_quality": UNIT_PERCENTAGE, "rx_level": "dBm", "tx_level": "dBm", "upstream": None, diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py index 52f0af607bc..280733affd2 100644 --- a/homeassistant/components/nfandroidtv/notify.py +++ b/homeassistant/components/nfandroidtv/notify.py @@ -14,7 +14,7 @@ from homeassistant.components.notify import ( PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import CONF_HOST, CONF_TIMEOUT +from homeassistant.const import CONF_HOST, CONF_TIMEOUT, UNIT_PERCENTAGE import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -66,7 +66,14 @@ POSITIONS = { "center": 4, } -TRANSPARENCIES = {"default": 0, "0%": 1, "25%": 2, "50%": 3, "75%": 4, "100%": 5} +TRANSPARENCIES = { + "default": 0, + f"0{UNIT_PERCENTAGE}": 1, + f"25{UNIT_PERCENTAGE}": 2, + f"50{UNIT_PERCENTAGE}": 3, + f"75{UNIT_PERCENTAGE}": 4, + f"100{UNIT_PERCENTAGE}": 5, +} COLORS = { "grey": "#607d8b", diff --git a/homeassistant/components/nissan_leaf/sensor.py b/homeassistant/components/nissan_leaf/sensor.py index bbdb8ad7527..33b0efa5d60 100644 --- a/homeassistant/components/nissan_leaf/sensor.py +++ b/homeassistant/components/nissan_leaf/sensor.py @@ -1,7 +1,7 @@ """Battery Charge and Range Support for the Nissan Leaf.""" import logging -from homeassistant.const import DEVICE_CLASS_BATTERY +from homeassistant.const import DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util.distance import LENGTH_KILOMETERS, LENGTH_MILES from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM @@ -56,7 +56,7 @@ class LeafBatterySensor(LeafEntity): @property def unit_of_measurement(self): """Battery state measured in percentage.""" - return "%" + return UNIT_PERCENTAGE @property def icon(self): diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 96db220f5ea..15dee84dd9b 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -19,6 +19,7 @@ from homeassistant.const import ( STATE_UNKNOWN, TEMP_CELSIUS, TIME_SECONDS, + UNIT_PERCENTAGE, ) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -51,8 +52,8 @@ SENSOR_TYPES = { "ups.firmware": ["Firmware Version", "", "mdi:information-outline"], "ups.firmware.aux": ["Firmware Version 2", "", "mdi:information-outline"], "ups.temperature": ["UPS Temperature", TEMP_CELSIUS, "mdi:thermometer"], - "ups.load": ["Load", "%", "mdi:gauge"], - "ups.load.high": ["Overload Setting", "%", "mdi:gauge"], + "ups.load": ["Load", UNIT_PERCENTAGE, "mdi:gauge"], + "ups.load.high": ["Overload Setting", UNIT_PERCENTAGE, "mdi:gauge"], "ups.id": ["System identifier", "", "mdi:information-outline"], "ups.delay.start": ["Load Restart Delay", TIME_SECONDS, "mdi:timer"], "ups.delay.reboot": ["UPS Reboot Delay", TIME_SECONDS, "mdi:timer"], @@ -65,7 +66,7 @@ SENSOR_TYPES = { "ups.test.date": ["Self-Test Date", "", "mdi:calendar"], "ups.display.language": ["Language", "", "mdi:information-outline"], "ups.contacts": ["External Contacts", "", "mdi:information-outline"], - "ups.efficiency": ["Efficiency", "%", "mdi:gauge"], + "ups.efficiency": ["Efficiency", UNIT_PERCENTAGE, "mdi:gauge"], "ups.power": ["Current Apparent Power", "VA", "mdi:flash"], "ups.power.nominal": ["Nominal Power", "VA", "mdi:flash"], "ups.realpower": ["Current Real Power", POWER_WATT, "mdi:flash"], @@ -77,10 +78,18 @@ SENSOR_TYPES = { "ups.start.battery": ["Start on Battery", "", "mdi:information-outline"], "ups.start.reboot": ["Reboot on Battery", "", "mdi:information-outline"], "ups.shutdown": ["Shutdown Ability", "", "mdi:information-outline"], - "battery.charge": ["Battery Charge", "%", "mdi:gauge"], - "battery.charge.low": ["Low Battery Setpoint", "%", "mdi:gauge"], - "battery.charge.restart": ["Minimum Battery to Start", "%", "mdi:gauge"], - "battery.charge.warning": ["Warning Battery Setpoint", "%", "mdi:gauge"], + "battery.charge": ["Battery Charge", UNIT_PERCENTAGE, "mdi:gauge"], + "battery.charge.low": ["Low Battery Setpoint", UNIT_PERCENTAGE, "mdi:gauge"], + "battery.charge.restart": [ + "Minimum Battery to Start", + UNIT_PERCENTAGE, + "mdi:gauge", + ], + "battery.charge.warning": [ + "Warning Battery Setpoint", + UNIT_PERCENTAGE, + "mdi:gauge", + ], "battery.charger.status": ["Charging Status", "", "mdi:information-outline"], "battery.voltage": ["Battery Voltage", "V", "mdi:flash"], "battery.voltage.nominal": ["Nominal Battery Voltage", "V", "mdi:flash"], diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py index 06a8ae44f1f..dba7e656aff 100644 --- a/homeassistant/components/octoprint/__init__.py +++ b/homeassistant/components/octoprint/__init__.py @@ -20,6 +20,7 @@ from homeassistant.const import ( CONTENT_TYPE_JSON, TEMP_CELSIUS, TIME_SECONDS, + UNIT_PERCENTAGE, ) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv @@ -71,7 +72,13 @@ SENSOR_TYPES = { # API Endpoint, Group, Key, unit, icon "Temperatures": ["printer", "temperature", "*", TEMP_CELSIUS], "Current State": ["printer", "state", "text", None, "mdi:printer-3d"], - "Job Percentage": ["job", "progress", "completion", "%", "mdi:file-percent"], + "Job Percentage": [ + "job", + "progress", + "completion", + UNIT_PERCENTAGE, + "mdi:file-percent", + ], "Time Remaining": [ "job", "progress", diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 98d878fc2ea..83b247c39cb 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -3,7 +3,7 @@ import logging import requests -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE from homeassistant.helpers.entity import Entity from . import DOMAIN as COMPONENT_DOMAIN, SENSOR_TYPES @@ -111,7 +111,7 @@ class OctoPrintSensor(Entity): def state(self): """Return the state of the sensor.""" sensor_unit = self.unit_of_measurement - if sensor_unit in (TEMP_CELSIUS, "%"): + if sensor_unit in (TEMP_CELSIUS, UNIT_PERCENTAGE): # API sometimes returns null and not 0 if self._state is None: self._state = 0 diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py index 6a7f282ac87..41f41a6e93d 100644 --- a/homeassistant/components/onewire/sensor.py +++ b/homeassistant/components/onewire/sensor.py @@ -8,7 +8,7 @@ from pyownet import protocol import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_HOST, CONF_PORT, TEMP_CELSIUS +from homeassistant.const import CONF_HOST, CONF_PORT, TEMP_CELSIUS, UNIT_PERCENTAGE import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -60,14 +60,14 @@ HOBBYBOARD_EF = { SENSOR_TYPES = { # SensorType: [ Measured unit, Unit ] "temperature": ["temperature", TEMP_CELSIUS], - "humidity": ["humidity", "%"], - "humidity_raw": ["humidity", "%"], + "humidity": ["humidity", UNIT_PERCENTAGE], + "humidity_raw": ["humidity", UNIT_PERCENTAGE], "pressure": ["pressure", "mb"], "illuminance": ["illuminance", "lux"], - "wetness_0": ["wetness", "%"], - "wetness_1": ["wetness", "%"], - "wetness_2": ["wetness", "%"], - "wetness_3": ["wetness", "%"], + "wetness_0": ["wetness", UNIT_PERCENTAGE], + "wetness_1": ["wetness", UNIT_PERCENTAGE], + "wetness_2": ["wetness", UNIT_PERCENTAGE], + "wetness_3": ["wetness", UNIT_PERCENTAGE], "moisture_0": ["moisture", "cb"], "moisture_1": ["moisture", "cb"], "moisture_2": ["moisture", "cb"], diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index 580f9f7b1a4..b8d427ba193 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -6,6 +6,7 @@ from homeassistant.const import ( TEMP_CELSIUS, TIME_HOURS, TIME_MINUTES, + UNIT_PERCENTAGE, ) ATTR_GW_ID = "gateway_id" @@ -38,7 +39,6 @@ SERVICE_SET_SB_TEMP = "set_setback_temperature" UNIT_BAR = "bar" UNIT_KW = "kW" UNIT_L_MIN = f"L/{TIME_MINUTES}" -UNIT_PERCENT = "%" BINARY_SENSOR_INFO = { # [device_class, friendly_name format] @@ -121,7 +121,7 @@ SENSOR_INFO = { gw_vars.DATA_MASTER_MEMBERID: [None, None, "Thermostat Member ID {}"], gw_vars.DATA_SLAVE_MEMBERID: [None, None, "Boiler Member ID {}"], gw_vars.DATA_SLAVE_OEM_FAULT: [None, None, "Boiler OEM Fault Code {}"], - gw_vars.DATA_COOLING_CONTROL: [None, UNIT_PERCENT, "Cooling Control Signal {}"], + gw_vars.DATA_COOLING_CONTROL: [None, UNIT_PERCENTAGE, "Cooling Control Signal {}"], gw_vars.DATA_CONTROL_SETPOINT_2: [ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, @@ -134,13 +134,13 @@ SENSOR_INFO = { ], gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD: [ None, - UNIT_PERCENT, + UNIT_PERCENTAGE, "Boiler Maximum Relative Modulation {}", ], gw_vars.DATA_SLAVE_MAX_CAPACITY: [None, UNIT_KW, "Boiler Maximum Capacity {}"], gw_vars.DATA_SLAVE_MIN_MOD_LEVEL: [ None, - UNIT_PERCENT, + UNIT_PERCENTAGE, "Boiler Minimum Modulation Level {}", ], gw_vars.DATA_ROOM_SETPOINT: [ @@ -148,7 +148,7 @@ SENSOR_INFO = { TEMP_CELSIUS, "Room Setpoint {}", ], - gw_vars.DATA_REL_MOD_LEVEL: [None, UNIT_PERCENT, "Relative Modulation Level {}"], + gw_vars.DATA_REL_MOD_LEVEL: [None, UNIT_PERCENTAGE, "Relative Modulation Level {}"], gw_vars.DATA_CH_WATER_PRESS: [None, UNIT_BAR, "Central Heating Water Pressure {}"], gw_vars.DATA_DHW_FLOW_RATE: [None, UNIT_L_MIN, "Hot Water Flow Rate {}"], gw_vars.DATA_ROOM_SETPOINT_2: [ diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py index 5908ccfff06..ce32458f640 100644 --- a/homeassistant/components/openweathermap/sensor.py +++ b/homeassistant/components/openweathermap/sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( SPEED_METERS_PER_SECOND, TEMP_CELSIUS, TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -36,9 +37,9 @@ SENSOR_TYPES = { "temperature": ["Temperature", None], "wind_speed": ["Wind speed", SPEED_METERS_PER_SECOND], "wind_bearing": ["Wind bearing", "°"], - "humidity": ["Humidity", "%"], + "humidity": ["Humidity", UNIT_PERCENTAGE], "pressure": ["Pressure", "mbar"], - "clouds": ["Cloud coverage", "%"], + "clouds": ["Cloud coverage", UNIT_PERCENTAGE], "rain": ["Rain", "mm"], "snow": ["Snow", "mm"], "weather_code": ["Weather code", None], diff --git a/homeassistant/components/pi_hole/const.py b/homeassistant/components/pi_hole/const.py index ca4eea32bd6..94f687d9bfa 100644 --- a/homeassistant/components/pi_hole/const.py +++ b/homeassistant/components/pi_hole/const.py @@ -1,6 +1,8 @@ """Constants for the pi_hole integration.""" from datetime import timedelta +from homeassistant.const import UNIT_PERCENTAGE + DOMAIN = "pi_hole" CONF_LOCATION = "location" @@ -26,7 +28,7 @@ SENSOR_DICT = { "ads_blocked_today": ["Ads Blocked Today", "ads", "mdi:close-octagon-outline"], "ads_percentage_today": [ "Ads Percentage Blocked Today", - "%", + UNIT_PERCENTAGE, "mdi:close-octagon-outline", ], "clients_ever_seen": ["Seen Clients", "clients", "mdi:account-outline"], diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py index e7c8033f2ac..34a2a1a42b6 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -2,6 +2,7 @@ import logging +from homeassistant.const import UNIT_PERCENTAGE from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -145,7 +146,7 @@ class PlaatoSensor(Entity): if self._type == ATTR_BPM: return "bpm" if self._type == ATTR_ABV: - return "%" + return UNIT_PERCENTAGE return "" diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py index 408c7d1bf36..30542db5e23 100644 --- a/homeassistant/components/plant/__init__.py +++ b/homeassistant/components/plant/__init__.py @@ -16,6 +16,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -131,14 +132,17 @@ class Plant(Entity): """ READINGS = { - READING_BATTERY: {ATTR_UNIT_OF_MEASUREMENT: "%", "min": CONF_MIN_BATTERY_LEVEL}, + READING_BATTERY: { + ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE, + "min": CONF_MIN_BATTERY_LEVEL, + }, READING_TEMPERATURE: { ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, "min": CONF_MIN_TEMPERATURE, "max": CONF_MAX_TEMPERATURE, }, READING_MOISTURE: { - ATTR_UNIT_OF_MEASUREMENT: "%", + ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE, "min": CONF_MIN_MOISTURE, "max": CONF_MAX_MOISTURE, }, diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index d8ad22bb470..324565aae50 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -7,6 +7,7 @@ from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util.dt import parse_datetime @@ -21,7 +22,7 @@ DEVICE_CLASS_SOUND = "sound_level" SENSOR_TYPES = { DEVICE_CLASS_TEMPERATURE: (None, 1, TEMP_CELSIUS), DEVICE_CLASS_PRESSURE: (None, 0, "hPa"), - DEVICE_CLASS_HUMIDITY: (None, 1, "%"), + DEVICE_CLASS_HUMIDITY: (None, 1, UNIT_PERCENTAGE), DEVICE_CLASS_SOUND: ("mdi:ear-hearing", 1, "dBa"), } diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index d77cb4f56da..314695458b0 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -17,6 +17,7 @@ from homeassistant.const import ( EVENT_STATE_CHANGED, TEMP_CELSIUS, TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, ) from homeassistant.helpers import entityfilter, state as state_helper import homeassistant.helpers.config_validation as cv @@ -349,7 +350,7 @@ class PrometheusMetrics: units = { TEMP_CELSIUS: "c", TEMP_FAHRENHEIT: "c", # F should go into C metric - "%": "percent", + UNIT_PERCENTAGE: "percent", } default = unit.replace("/", "_per_") default = default.lower() diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py index 1ad53f4db48..475e02aba86 100644 --- a/homeassistant/components/qnap/sensor.py +++ b/homeassistant/components/qnap/sensor.py @@ -19,6 +19,7 @@ from homeassistant.const import ( DATA_GIBIBYTES, DATA_RATE_MEBIBYTES_PER_SECOND, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv @@ -61,12 +62,12 @@ _SYSTEM_MON_COND = { } _CPU_MON_COND = { "cpu_temp": ["CPU Temperature", TEMP_CELSIUS, "mdi:thermometer"], - "cpu_usage": ["CPU Usage", "%", "mdi:chip"], + "cpu_usage": ["CPU Usage", UNIT_PERCENTAGE, "mdi:chip"], } _MEMORY_MON_COND = { "memory_free": ["Memory Available", DATA_GIBIBYTES, "mdi:memory"], "memory_used": ["Memory Used", DATA_GIBIBYTES, "mdi:memory"], - "memory_percent_used": ["Memory Usage", "%", "mdi:memory"], + "memory_percent_used": ["Memory Usage", UNIT_PERCENTAGE, "mdi:memory"], } _NETWORK_MON_COND = { "network_link_status": ["Network Link", None, "mdi:checkbox-marked-circle-outline"], @@ -80,7 +81,7 @@ _DRIVE_MON_COND = { _VOLUME_MON_COND = { "volume_size_used": ["Used Space", DATA_GIBIBYTES, "mdi:chart-pie"], "volume_size_free": ["Free Space", DATA_GIBIBYTES, "mdi:chart-pie"], - "volume_percentage_used": ["Volume Used", "%", "mdi:chart-pie"], + "volume_percentage_used": ["Volume Used", UNIT_PERCENTAGE, "mdi:chart-pie"], } _MONITORED_CONDITIONS = ( diff --git a/homeassistant/components/raincloud/__init__.py b/homeassistant/components/raincloud/__init__.py index 41fefbe8fca..4f9ae7fb733 100644 --- a/homeassistant/components/raincloud/__init__.py +++ b/homeassistant/components/raincloud/__init__.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_USERNAME, TIME_DAYS, TIME_MINUTES, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send @@ -58,7 +59,7 @@ ICON_MAP = { UNIT_OF_MEASUREMENT_MAP = { "auto_watering": "", - "battery": "%", + "battery": UNIT_PERCENTAGE, "is_watering": "", "manual_watering": "", "next_cycle": "", diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py index 1d6026a8754..a5a9224464d 100644 --- a/homeassistant/components/repetier/__init__.py +++ b/homeassistant/components/repetier/__init__.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_PORT, CONF_SENSORS, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform @@ -123,7 +124,7 @@ SENSOR_TYPES = { "_chamber_", ], "current_state": ["state", None, "mdi:printer-3d", ""], - "current_job": ["progress", "%", "mdi:file-percent", "_current_job"], + "current_job": ["progress", UNIT_PERCENTAGE, "mdi:file-percent", "_current_job"], "job_end": ["progress", None, "mdi:clock-end", "_job_end"], "job_start": ["progress", None, "mdi:clock-start", "_job_start"], } diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index ceba82cf544..39cbde08c01 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -18,6 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, POWER_WATT, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -47,7 +48,7 @@ DATA_TYPES = OrderedDict( [ ("Temperature", TEMP_CELSIUS), ("Temperature2", TEMP_CELSIUS), - ("Humidity", "%"), + ("Humidity", UNIT_PERCENTAGE), ("Barometer", ""), ("Wind direction", ""), ("Rain rate", ""), diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index 329077a18e7..84edaf67c22 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -1,6 +1,7 @@ """This component provides HA sensor support for Ring Door Bell/Chimes.""" import logging +from homeassistant.const import UNIT_PERCENTAGE from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level @@ -203,7 +204,7 @@ SENSOR_TYPES = { "battery": [ "Battery", ["doorbots", "authorized_doorbots", "stickup_cams"], - "%", + UNIT_PERCENTAGE, None, None, "battery", diff --git a/homeassistant/components/sensehat/sensor.py b/homeassistant/components/sensehat/sensor.py index 980c23f8555..29aa4af967e 100644 --- a/homeassistant/components/sensehat/sensor.py +++ b/homeassistant/components/sensehat/sensor.py @@ -7,7 +7,12 @@ from sense_hat import SenseHat import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_DISPLAY_OPTIONS, CONF_NAME, TEMP_CELSIUS +from homeassistant.const import ( + CONF_DISPLAY_OPTIONS, + CONF_NAME, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -21,7 +26,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) SENSOR_TYPES = { "temperature": ["temperature", TEMP_CELSIUS], - "humidity": ["humidity", "%"], + "humidity": ["humidity", UNIT_PERCENTAGE], "pressure": ["pressure", "mb"], } diff --git a/homeassistant/components/sht31/sensor.py b/homeassistant/components/sht31/sensor.py index 8a520377896..3a66b47688c 100644 --- a/homeassistant/components/sht31/sensor.py +++ b/homeassistant/components/sht31/sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_NAME, PRECISION_TENTHS, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -140,7 +141,7 @@ class SHTSensorHumidity(SHTSensor): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return "%" + return UNIT_PERCENTAGE def update(self): """Fetch humidity from the sensor.""" diff --git a/homeassistant/components/skybeacon/sensor.py b/homeassistant/components/skybeacon/sensor.py index cbf394edf47..9bd02aec7c4 100644 --- a/homeassistant/components/skybeacon/sensor.py +++ b/homeassistant/components/skybeacon/sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -82,7 +83,7 @@ class SkybeaconHumid(Entity): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return "%" + return UNIT_PERCENTAGE @property def device_state_attributes(self): diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 4ff0bb5b853..8f1e034dcac 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -2,7 +2,12 @@ from datetime import timedelta import logging -from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT, VOLUME_CUBIC_METERS +from homeassistant.const import ( + ENERGY_KILO_WATT_HOUR, + POWER_WATT, + UNIT_PERCENTAGE, + VOLUME_CUBIC_METERS, +) from homeassistant.helpers.entity import Entity from . import DATA_SMAPPEE @@ -21,7 +26,13 @@ SENSOR_TYPES = { ], "current": ["Current", "mdi:gauge", "local", "A", "current"], "voltage": ["Voltage", "mdi:gauge", "local", "V", "voltage"], - "active_cosfi": ["Power Factor", "mdi:gauge", "local", "%", "active_cosfi"], + "active_cosfi": [ + "Power Factor", + "mdi:gauge", + "local", + UNIT_PERCENTAGE, + "active_cosfi", + ], "alwayson_today": [ "Always On Today", "mdi:gauge", @@ -68,14 +79,14 @@ SENSOR_TYPES = { "Water Sensor Humidity", "mdi:water-percent", "water", - "%", + UNIT_PERCENTAGE, "humidity", ], "water_sensor_battery": [ "Water Sensor Battery", "mdi:battery", "water", - "%", + UNIT_PERCENTAGE, "battery", ], } diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index fb04c01c682..630fbaadd3a 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, ) from . import SmartThingsEntity @@ -34,8 +35,10 @@ 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", "%", None)], - Capability.battery: [Map(Attribute.battery, "Battery", "%", DEVICE_CLASS_BATTERY)], + Capability.audio_volume: [Map(Attribute.volume, "Volume", UNIT_PERCENTAGE, None)], + Capability.battery: [ + Map(Attribute.battery, "Battery", UNIT_PERCENTAGE, DEVICE_CLASS_BATTERY) + ], Capability.body_mass_index_measurement: [ Map(Attribute.bmi_measurement, "Body Mass Index", "kg/m^2", None) ], @@ -109,7 +112,7 @@ CAPABILITY_TO_SENSORS = { Map(Attribute.illuminance, "Illuminance", "lux", DEVICE_CLASS_ILLUMINANCE) ], Capability.infrared_level: [ - Map(Attribute.infrared_level, "Infrared Level", "%", None) + Map(Attribute.infrared_level, "Infrared Level", UNIT_PERCENTAGE, None) ], Capability.media_input_source: [ Map(Attribute.input_source, "Media Input Source", None, None) @@ -147,7 +150,7 @@ CAPABILITY_TO_SENSORS = { Map( Attribute.humidity, "Relative Humidity Measurement", - "%", + UNIT_PERCENTAGE, DEVICE_CLASS_HUMIDITY, ) ], diff --git a/homeassistant/components/solarlog/const.py b/homeassistant/components/solarlog/const.py index 933f8014090..26176e97e46 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 +from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT, UNIT_PERCENTAGE DOMAIN = "solarlog" @@ -77,7 +77,7 @@ SENSOR_TYPES = { POWER_WATT, "mdi:solar-power", ], - "capacity": ["CAPACITY", "capacity", "%", "mdi:solar-power"], + "capacity": ["CAPACITY", "capacity", UNIT_PERCENTAGE, "mdi:solar-power"], "efficiency": ["EFFICIENCY", "efficiency", "% W/Wp", "mdi:solar-power"], "power_available": [ "powerAVAILABLE", diff --git a/homeassistant/components/starline/sensor.py b/homeassistant/components/starline/sensor.py index 0c6cd8de683..8e17caad86c 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 +from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE 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, "%", None], + "gsm_lvl": ["GSM Signal", None, UNIT_PERCENTAGE, None], } diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index 82106c2da57..b6d90b99302 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_MONITORED_VARIABLES, CONF_NAME, DATA_GIGABYTES, + UNIT_PERCENTAGE, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -24,13 +25,11 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "Start.ca" CONF_TOTAL_BANDWIDTH = "total_bandwidth" -PERCENT = "%" - MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1) REQUEST_TIMEOUT = 5 # seconds SENSOR_TYPES = { - "usage": ["Usage Ratio", PERCENT, "mdi:percent"], + "usage": ["Usage Ratio", UNIT_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/surepetcare/sensor.py b/homeassistant/components/surepetcare/sensor.py index 8dc9cf30e3c..9c1416479cb 100644 --- a/homeassistant/components/surepetcare/sensor.py +++ b/homeassistant/components/surepetcare/sensor.py @@ -4,7 +4,13 @@ from typing import Any, Dict, Optional from surepy import SureLockStateID, SureProductID -from homeassistant.const import ATTR_VOLTAGE, CONF_ID, CONF_TYPE, DEVICE_CLASS_BATTERY +from homeassistant.const import ( + ATTR_VOLTAGE, + CONF_ID, + CONF_TYPE, + DEVICE_CLASS_BATTERY, + UNIT_PERCENTAGE, +) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -40,10 +46,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ]: entities.append(SureBattery(entity[CONF_ID], sure_type, spc)) - if sure_type in [ - SureProductID.CAT_FLAP, - SureProductID.PET_FLAP, - ]: + if sure_type in [SureProductID.CAT_FLAP, SureProductID.PET_FLAP]: entities.append(Flap(entity[CONF_ID], sure_type, spc)) async_add_entities(entities, True) @@ -52,9 +55,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= class SurePetcareSensor(Entity): """A binary sensor implementation for Sure Petcare Entities.""" - def __init__( - self, _id: int, sure_type: SureProductID, spc: SurePetcareAPI, - ): + def __init__(self, _id: int, sure_type: SureProductID, spc: SurePetcareAPI): """Initialize a Sure Petcare sensor.""" self._id = _id @@ -128,9 +129,7 @@ class Flap(SurePetcareSensor): """Return the state attributes of the device.""" attributes = None if self._state: - attributes = { - "learn_mode": bool(self._state["learn_mode"]), - } + attributes = {"learn_mode": bool(self._state["learn_mode"])} return attributes @@ -182,4 +181,4 @@ class SureBattery(SurePetcareSensor): @property def unit_of_measurement(self) -> str: """Return the unit of measurement.""" - return "%" + return UNIT_PERCENTAGE diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index e981154a81a..7cd99bdb261 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -6,7 +6,7 @@ from pysyncthru import SyncThru import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_RESOURCE +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_RESOURCE, UNIT_PERCENTAGE from homeassistant.helpers import aiohttp_client import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -174,7 +174,7 @@ class SyncThruTonerSensor(SyncThruSensor): super().__init__(syncthru, name) self._name = f"{name} Toner {color}" self._color = color - self._unit_of_measurement = "%" + self._unit_of_measurement = UNIT_PERCENTAGE self._id_suffix = f"_toner_{color}" def update(self): @@ -194,7 +194,7 @@ class SyncThruDrumSensor(SyncThruSensor): super().__init__(syncthru, name) self._name = f"{name} Drum {color}" self._color = color - self._unit_of_measurement = "%" + self._unit_of_measurement = UNIT_PERCENTAGE self._id_suffix = f"_drum_{color}" def update(self): diff --git a/homeassistant/components/synologydsm/sensor.py b/homeassistant/components/synologydsm/sensor.py index d10ecaa15ed..84921b3b8d3 100644 --- a/homeassistant/components/synologydsm/sensor.py +++ b/homeassistant/components/synologydsm/sensor.py @@ -21,6 +21,7 @@ from homeassistant.const import ( DATA_RATE_KILOBYTES_PER_SECOND, EVENT_HOMEASSISTANT_START, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -37,14 +38,14 @@ DEFAULT_PORT = 5001 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) _UTILISATION_MON_COND = { - "cpu_other_load": ["CPU Load (Other)", "%", "mdi:chip"], - "cpu_user_load": ["CPU Load (User)", "%", "mdi:chip"], - "cpu_system_load": ["CPU Load (System)", "%", "mdi:chip"], - "cpu_total_load": ["CPU Load (Total)", "%", "mdi:chip"], - "cpu_1min_load": ["CPU Load (1 min)", "%", "mdi:chip"], - "cpu_5min_load": ["CPU Load (5 min)", "%", "mdi:chip"], - "cpu_15min_load": ["CPU Load (15 min)", "%", "mdi:chip"], - "memory_real_usage": ["Memory Usage (Real)", "%", "mdi:memory"], + "cpu_other_load": ["CPU Load (Other)", UNIT_PERCENTAGE, "mdi:chip"], + "cpu_user_load": ["CPU Load (User)", UNIT_PERCENTAGE, "mdi:chip"], + "cpu_system_load": ["CPU Load (System)", UNIT_PERCENTAGE, "mdi:chip"], + "cpu_total_load": ["CPU Load (Total)", UNIT_PERCENTAGE, "mdi:chip"], + "cpu_1min_load": ["CPU Load (1 min)", UNIT_PERCENTAGE, "mdi:chip"], + "cpu_5min_load": ["CPU Load (5 min)", UNIT_PERCENTAGE, "mdi:chip"], + "cpu_15min_load": ["CPU Load (15 min)", UNIT_PERCENTAGE, "mdi:chip"], + "memory_real_usage": ["Memory Usage (Real)", UNIT_PERCENTAGE, "mdi:memory"], "memory_size": ["Memory Size", DATA_MEGABYTES, "mdi:memory"], "memory_cached": ["Memory Cached", DATA_MEGABYTES, "mdi:memory"], "memory_available_swap": ["Memory Available (Swap)", DATA_MEGABYTES, "mdi:memory"], @@ -59,7 +60,7 @@ _STORAGE_VOL_MON_COND = { "volume_device_type": ["Type", None, "mdi:harddisk"], "volume_size_total": ["Total Size", None, "mdi:chart-pie"], "volume_size_used": ["Used Space", None, "mdi:chart-pie"], - "volume_percentage_used": ["Volume Used", "%", "mdi:chart-pie"], + "volume_percentage_used": ["Volume Used", UNIT_PERCENTAGE, "mdi:chart-pie"], "volume_disk_temp_avg": ["Average Disk Temp", None, "mdi:thermometer"], "volume_disk_temp_max": ["Maximum Disk Temp", None, "mdi:thermometer"], } diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 1ea8a409052..bae42c2f50b 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( DATA_RATE_MEGABYTES_PER_SECOND, STATE_OFF, STATE_ON, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -29,7 +30,7 @@ CONF_ARG = "arg" 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)", "%", "mdi:harddisk", None], + "disk_use_percent": ["Disk use (percent)", UNIT_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"], @@ -38,7 +39,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)", "%", "mdi:memory", None], + "memory_use_percent": ["Memory use (percent)", UNIT_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,10 +57,10 @@ SENSOR_TYPES = { None, ], "process": ["Process", " ", "mdi:memory", None], - "processor_use": ["Processor use", "%", "mdi:memory", None], + "processor_use": ["Processor use", UNIT_PERCENTAGE, "mdi:memory", 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)", "%", "mdi:harddisk", None], + "swap_use_percent": ["Swap use (percent)", UNIT_PERCENTAGE, "mdi:harddisk", None], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index f5f32a6ed1a..2cd40bee3fa 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -1,7 +1,7 @@ """Support for Tado sensors for each zone.""" import logging -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity @@ -134,9 +134,9 @@ class TadoSensor(Entity): if self.zone_variable == "temperature": return self.hass.config.units.temperature_unit if self.zone_variable == "humidity": - return "%" + return UNIT_PERCENTAGE if self.zone_variable == "heating": - return "%" + return UNIT_PERCENTAGE if self.zone_variable == "ac": return "" diff --git a/homeassistant/components/tahoma/sensor.py b/homeassistant/components/tahoma/sensor.py index fb8c61607c7..20364a243b3 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 +from homeassistant.const import ATTR_BATTERY_LEVEL, TEMP_CELSIUS, UNIT_PERCENTAGE 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 "%" + return UNIT_PERCENTAGE if self.tahoma_device.type == "rtds:RTDSContactSensor": return None if self.tahoma_device.type == "rtds:RTDSMotionSensor": diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py index 61a3d7367bf..5847eecc8a8 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 +from homeassistant.const import CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD, UNIT_PERCENTAGE import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -26,7 +26,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( SENSOR_TYPE = "tank" SENSOR_ROUNDING_PRECISION = 1 -SENSOR_UNIT_OF_MEASUREMENT = "%" SENSOR_ATTRS = [ "name", "address", @@ -74,7 +73,7 @@ class TankUtilitySensor(Entity): self._device = device self._state = None self._name = f"Tank Utility {self.device}" - self._unit_of_measurement = SENSOR_UNIT_OF_MEASUREMENT + self._unit_of_measurement = UNIT_PERCENTAGE self._attributes = {} @property diff --git a/homeassistant/components/teksavvy/sensor.py b/homeassistant/components/teksavvy/sensor.py index f340f4a3971..0d2d290fed9 100644 --- a/homeassistant/components/teksavvy/sensor.py +++ b/homeassistant/components/teksavvy/sensor.py @@ -11,6 +11,7 @@ from homeassistant.const import ( CONF_MONITORED_VARIABLES, CONF_NAME, DATA_GIGABYTES, + UNIT_PERCENTAGE, ) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -22,13 +23,11 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "TekSavvy" CONF_TOTAL_BANDWIDTH = "total_bandwidth" -PERCENT = "%" - MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1) REQUEST_TIMEOUT = 5 # seconds SENSOR_TYPES = { - "usage": ["Usage Ratio", PERCENT, "mdi:percent"], + "usage": ["Usage Ratio", UNIT_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/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index 7aafa38c94f..11411e1d6ea 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -10,6 +10,7 @@ from homeassistant.const import ( SPEED_METERS_PER_SECOND, TEMP_CELSIUS, TIME_HOURS, + UNIT_PERCENTAGE, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -37,7 +38,7 @@ SENSOR_TYPES = { None, DEVICE_CLASS_TEMPERATURE, ], - SENSOR_TYPE_HUMIDITY: ["Humidity", "%", None, DEVICE_CLASS_HUMIDITY], + SENSOR_TYPE_HUMIDITY: ["Humidity", UNIT_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/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py index 1a55e67ac43..4a3ff75b864 100644 --- a/homeassistant/components/tellstick/sensor.py +++ b/homeassistant/components/tellstick/sensor.py @@ -7,7 +7,13 @@ import tellcore.constants as tellcore_constants import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_ID, CONF_NAME, CONF_PROTOCOL, TEMP_CELSIUS +from homeassistant.const import ( + CONF_ID, + CONF_NAME, + CONF_PROTOCOL, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -55,7 +61,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): tellcore_constants.TELLSTICK_TEMPERATURE: DatatypeDescription( "temperature", config.get(CONF_TEMPERATURE_SCALE) ), - tellcore_constants.TELLSTICK_HUMIDITY: DatatypeDescription("humidity", "%"), + tellcore_constants.TELLSTICK_HUMIDITY: DatatypeDescription( + "humidity", UNIT_PERCENTAGE + ), tellcore_constants.TELLSTICK_RAINRATE: DatatypeDescription("rain rate", ""), tellcore_constants.TELLSTICK_RAINTOTAL: DatatypeDescription("rain total", ""), tellcore_constants.TELLSTICK_WINDDIRECTION: DatatypeDescription( diff --git a/homeassistant/components/thinkingcleaner/sensor.py b/homeassistant/components/thinkingcleaner/sensor.py index 7a45be7eb61..26175b5368a 100644 --- a/homeassistant/components/thinkingcleaner/sensor.py +++ b/homeassistant/components/thinkingcleaner/sensor.py @@ -5,6 +5,7 @@ import logging from pythinkingcleaner import Discovery from homeassistant import util +from homeassistant.const import UNIT_PERCENTAGE from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -13,7 +14,7 @@ MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) SENSOR_TYPES = { - "battery": ["Battery", "%", "mdi:battery"], + "battery": ["Battery", UNIT_PERCENTAGE, "mdi:battery"], "state": ["State", None, None], "capacity": ["Capacity", None, None], } diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py index a22835ec215..239359c1fdf 100644 --- a/homeassistant/components/toon/const.py +++ b/homeassistant/components/toon/const.py @@ -18,6 +18,5 @@ DEFAULT_MAX_TEMP = 30.0 DEFAULT_MIN_TEMP = 6.0 CURRENCY_EUR = "EUR" -RATIO_PERCENT = "%" VOLUME_CM3 = "CM3" VOLUME_M3 = "M3" diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 79a8fa28540..a5e88bb3d2f 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -2,7 +2,7 @@ import logging from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT +from homeassistant.const import ENERGY_KILO_WATT_HOUR, POWER_WATT, UNIT_PERCENTAGE from homeassistant.helpers.typing import HomeAssistantType from . import ( @@ -13,7 +13,7 @@ from . import ( ToonGasMeterDeviceEntity, ToonSolarDeviceEntity, ) -from .const import CURRENCY_EUR, DATA_TOON, DOMAIN, RATIO_PERCENT, VOLUME_CM3, VOLUME_M3 +from .const import CURRENCY_EUR, DATA_TOON, DOMAIN, VOLUME_CM3, VOLUME_M3 _LOGGER = logging.getLogger(__name__) @@ -195,7 +195,7 @@ async def async_setup_entry( "current_modulation_level", "Boiler Modulation Level", "mdi:percent", - RATIO_PERCENT, + UNIT_PERCENTAGE, ) ] ) diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index c3a08ab1675..db12ab0a5cb 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -1,6 +1,6 @@ """Support for IKEA Tradfri sensors.""" -from homeassistant.const import DEVICE_CLASS_BATTERY +from homeassistant.const import DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE from .base_class import TradfriBaseDevice from .const import CONF_GATEWAY_ID, KEY_API, KEY_GATEWAY @@ -47,4 +47,4 @@ class TradfriSensor(TradfriBaseDevice): @property def unit_of_measurement(self): """Return the unit_of_measurement of the device.""" - return "%" + return UNIT_PERCENTAGE diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index 78f5bbbb8ca..f2e7387aa6b 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, 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,7 +82,7 @@ SENSOR_TYPES = { ], "humidity": [ "Humidity", - "%", + UNIT_PERCENTAGE, "humidity", "mdi:water-percent", DEVICE_CLASS_HUMIDITY, diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index f7be502cecb..5bf9b8061ad 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -8,6 +8,7 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -39,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_of_measurement=UNIT_PERCENTAGE, icon="mdi:fan", ), ValloxSensor( @@ -79,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_of_measurement=UNIT_PERCENTAGE, icon=None, ), ValloxFilterRemainingSensor( diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py index e409a123887..9ac0a36ff9c 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -5,7 +5,7 @@ import logging import pyvera as veraApi from homeassistant.components.sensor import ENTITY_ID_FORMAT -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_PERCENTAGE from homeassistant.helpers.entity import Entity from homeassistant.util import convert @@ -54,7 +54,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 "%" + return UNIT_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 5735cea335e..384042b7210 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 +from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE 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 "%" + return UNIT_PERCENTAGE # pylint: disable=no-self-use def update(self): diff --git a/homeassistant/components/vilfo/const.py b/homeassistant/components/vilfo/const.py index 1a40b8430d7..0c6414ce99a 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 +from homeassistant.const import DEVICE_CLASS_TIMESTAMP, UNIT_PERCENTAGE DOMAIN = "vilfo" @@ -18,12 +18,10 @@ ROUTER_DEFAULT_MODEL = "Vilfo Router" ROUTER_DEFAULT_NAME = "Vilfo Router" ROUTER_MANUFACTURER = "Vilfo AB" -UNIT_PERCENT = "%" - SENSOR_TYPES = { ATTR_LOAD: { ATTR_LABEL: "Load", - ATTR_UNIT: UNIT_PERCENT, + ATTR_UNIT: UNIT_PERCENTAGE, ATTR_ICON: "mdi:memory", ATTR_API_DATA_FIELD: ATTR_API_DATA_FIELD_LOAD, }, diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py index bf6817c2dfe..e2c92d07f9c 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 TEMP_FAHRENHEIT +from homeassistant.const import TEMP_FAHRENHEIT, UNIT_PERCENTAGE from homeassistant.core import callback from homeassistant.helpers.entity import Entity from homeassistant.util import slugify @@ -34,9 +34,11 @@ SENSORS = [ "Loop Temp", "enteringwatertemp", "mdi:thermometer", TEMP_FAHRENHEIT ), WFSensorConfig( - "Humidity Set Point", "tstathumidsetpoint", "mdi:water-percent", "%" + "Humidity Set Point", "tstathumidsetpoint", "mdi:water-percent", UNIT_PERCENTAGE + ), + WFSensorConfig( + "Humidity", "tstatrelativehumidity", "mdi:water-percent", UNIT_PERCENTAGE ), - WFSensorConfig("Humidity", "tstatrelativehumidity", "mdi:water-percent", "%"), WFSensorConfig("Compressor Power", "compressorpower", "mdi:flash", "W"), WFSensorConfig("Fan Power", "fanpower", "mdi:flash", "W"), WFSensorConfig("Aux Power", "auxpower", "mdi:flash", "W"), diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py index c0a30a8867f..396ca093eec 100644 --- a/homeassistant/components/wirelesstag/__init__.py +++ b/homeassistant/components/wirelesstag/__init__.py @@ -11,6 +11,7 @@ from homeassistant.const import ( ATTR_VOLTAGE, CONF_PASSWORD, CONF_USERNAME, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send @@ -284,5 +285,5 @@ class WirelessTagBaseSensor(Entity): ATTR_VOLTAGE: f"{self._tag.battery_volts:.2f}V", 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}%", + ATTR_TAG_POWER_CONSUMPTION: f"{self._tag.power_consumption:.2f}{UNIT_PERCENTAGE}", } diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index ea3814d3b3a..781420b347a 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -62,6 +62,5 @@ UOM_BEATS_PER_MINUTE = "bpm" UOM_BREATHS_PER_MINUTE = f"br/{const.TIME_MINUTES}" UOM_FREQUENCY = "times" UOM_MMHG = "mmhg" -UOM_PERCENT = "%" UOM_LENGTH_M = const.LENGTH_METERS UOM_TEMP_C = const.TEMP_CELSIUS diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 0fee2271067..7e58beb4419 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -13,7 +13,12 @@ from withings_api.common import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import MASS_KILOGRAMS, SPEED_METERS_PER_SECOND, TIME_SECONDS +from homeassistant.const import ( + MASS_KILOGRAMS, + SPEED_METERS_PER_SECOND, + TIME_SECONDS, + UNIT_PERCENTAGE, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow from homeassistant.helpers.entity import Entity @@ -150,7 +155,7 @@ WITHINGS_ATTRIBUTES = [ const.MEAS_FAT_RATIO_PCT, MeasureType.FAT_RATIO, "Fat Ratio", - const.UOM_PERCENT, + UNIT_PERCENTAGE, None, ), WithingsMeasureAttribute( @@ -175,13 +180,13 @@ WITHINGS_ATTRIBUTES = [ "mdi:heart-pulse", ), WithingsMeasureAttribute( - const.MEAS_SPO2_PCT, MeasureType.SP02, "SP02", const.UOM_PERCENT, None + const.MEAS_SPO2_PCT, MeasureType.SP02, "SP02", UNIT_PERCENTAGE, None ), WithingsMeasureAttribute( const.MEAS_HYDRATION, MeasureType.HYDRATION, "Hydration", - const.UOM_PERCENT, + UNIT_PERCENTAGE, "mdi:water", ), WithingsMeasureAttribute( diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py index fa2cae53f52..8d651800a77 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 +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_TIMEOUT, UNIT_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 "%" + return UNIT_PERCENTAGE return None async def async_update(self): diff --git a/homeassistant/components/wunderground/sensor.py b/homeassistant/components/wunderground/sensor.py index 6a1112c028b..de1e48c9c14 100644 --- a/homeassistant/components/wunderground/sensor.py +++ b/homeassistant/components/wunderground/sensor.py @@ -26,6 +26,7 @@ from homeassistant.const import ( SPEED_MILES_PER_HOUR, TEMP_CELSIUS, TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, ) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -416,7 +417,7 @@ SENSOR_TYPES = { "Relative Humidity", "conditions", value=lambda wu: int(wu.data["current_observation"]["relative_humidity"][:-1]), - unit_of_measurement="%", + unit_of_measurement=UNIT_PERCENTAGE, icon="mdi:water-percent", device_class="humidity", ), @@ -916,16 +917,36 @@ SENSOR_TYPES = { "mdi:umbrella", ), "precip_1d": WUDailySimpleForecastSensorConfig( - "Precipitation Probability Today", 0, "pop", None, "%", "mdi:umbrella" + "Precipitation Probability Today", + 0, + "pop", + None, + UNIT_PERCENTAGE, + "mdi:umbrella", ), "precip_2d": WUDailySimpleForecastSensorConfig( - "Precipitation Probability Tomorrow", 1, "pop", None, "%", "mdi:umbrella" + "Precipitation Probability Tomorrow", + 1, + "pop", + None, + UNIT_PERCENTAGE, + "mdi:umbrella", ), "precip_3d": WUDailySimpleForecastSensorConfig( - "Precipitation Probability in 3 Days", 2, "pop", None, "%", "mdi:umbrella" + "Precipitation Probability in 3 Days", + 2, + "pop", + None, + UNIT_PERCENTAGE, + "mdi:umbrella", ), "precip_4d": WUDailySimpleForecastSensorConfig( - "Precipitation Probability in 4 Days", 3, "pop", None, "%", "mdi:umbrella" + "Precipitation Probability in 4 Days", + 3, + "pop", + None, + UNIT_PERCENTAGE, + "mdi:umbrella", ), } diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py index 5ad29af0aaf..d793f920349 100644 --- a/homeassistant/components/xiaomi_aqara/sensor.py +++ b/homeassistant/components/xiaomi_aqara/sensor.py @@ -7,6 +7,7 @@ from homeassistant.const import ( DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from . import PY_XIAOMI_GATEWAY, XiaomiDevice @@ -15,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = { "temperature": [TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], - "humidity": ["%", None, DEVICE_CLASS_HUMIDITY], + "humidity": [UNIT_PERCENTAGE, None, DEVICE_CLASS_HUMIDITY], "illumination": ["lm", None, DEVICE_CLASS_ILLUMINANCE], "lux": ["lx", None, DEVICE_CLASS_ILLUMINANCE], "pressure": ["hPa", None, DEVICE_CLASS_PRESSURE], diff --git a/homeassistant/components/yr/sensor.py b/homeassistant/components/yr/sensor.py index 5e7f4ed1db5..c6aaeea7ac9 100644 --- a/homeassistant/components/yr/sensor.py +++ b/homeassistant/components/yr/sensor.py @@ -23,6 +23,7 @@ from homeassistant.const import ( 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 @@ -46,12 +47,12 @@ SENSOR_TYPES = { "windGust": ["Wind gust", SPEED_METERS_PER_SECOND, None], "pressure": ["Pressure", PRESSURE_HPA, DEVICE_CLASS_PRESSURE], "windDirection": ["Wind direction", "°", None], - "humidity": ["Humidity", "%", DEVICE_CLASS_HUMIDITY], - "fog": ["Fog", "%", None], - "cloudiness": ["Cloudiness", "%", None], - "lowClouds": ["Low clouds", "%", None], - "mediumClouds": ["Medium clouds", "%", None], - "highClouds": ["High clouds", "%", 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, diff --git a/homeassistant/components/yweather/sensor.py b/homeassistant/components/yweather/sensor.py index c7f752a8836..db21c430c4d 100644 --- a/homeassistant/components/yweather/sensor.py +++ b/homeassistant/components/yweather/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -40,7 +41,7 @@ SENSOR_TYPES = { "temp_min": ["Temperature min", "temperature"], "temp_max": ["Temperature max", "temperature"], "wind_speed": ["Wind speed", "speed"], - "humidity": ["Humidity", "%"], + "humidity": ["Humidity", UNIT_PERCENTAGE], "pressure": ["Pressure", "pressure"], "visibility": ["Visibility", "distance"], } diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py index 74335a2ccdd..a5eb90df218 100644 --- a/homeassistant/components/zamg/sensor.py +++ b/homeassistant/components/zamg/sensor.py @@ -18,6 +18,7 @@ from homeassistant.const import ( CONF_MONITORED_CONDITIONS, CONF_NAME, SPEED_KILOMETERS_PER_HOUR, + UNIT_PERCENTAGE, __version__, ) import homeassistant.helpers.config_validation as cv @@ -39,7 +40,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", "%", "RF %", int), + "humidity": ("Humidity", UNIT_PERCENTAGE, "RF %", int), "wind_speed": ( "Wind Speed", SPEED_KILOMETERS_PER_HOUR, @@ -54,7 +55,7 @@ SENSOR_TYPES = { float, ), "wind_max_bearing": ("Top Wind Bearing", "°", "WSR °", int), - "sun_last_hour": ("Sun Last Hour", "%", "SO %", int), + "sun_last_hour": ("Sun Last Hour", UNIT_PERCENTAGE, f"SO {UNIT_PERCENTAGE}", int), "temperature": ("Temperature", "°C", "T °C", float), "precipitation": ("Precipitation", "l/m²", "N l/m²", float), "dewpoint": ("Dew Point", "°C", "TP °C", float), diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index b98c50d1fa4..e4788acfc53 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( POWER_WATT, STATE_UNKNOWN, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -161,7 +162,7 @@ class Battery(Sensor): """Battery sensor of power configuration cluster.""" _device_class = DEVICE_CLASS_BATTERY - _unit = "%" + _unit = UNIT_PERCENTAGE @staticmethod def formatter(value): @@ -231,7 +232,7 @@ class Humidity(Sensor): _device_class = DEVICE_CLASS_HUMIDITY _divisor = 100 - _unit = "%" + _unit = UNIT_PERCENTAGE @STRICT_MATCH(channel_names=CHANNEL_ILLUMINANCE) diff --git a/homeassistant/components/zigbee/__init__.py b/homeassistant/components/zigbee/__init__.py index 3a4fa9029bd..48c96b7591f 100644 --- a/homeassistant/components/zigbee/__init__.py +++ b/homeassistant/components/zigbee/__init__.py @@ -15,6 +15,7 @@ from homeassistant.const import ( CONF_NAME, CONF_PIN, EVENT_HOMEASSISTANT_STOP, + UNIT_PERCENTAGE, ) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send @@ -448,7 +449,7 @@ class ZigBeeAnalogIn(Entity): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "%" + return UNIT_PERCENTAGE def update(self): """Get the latest reading from the ADC.""" diff --git a/homeassistant/const.py b/homeassistant/const.py index 155eff79bb3..4e4be408b40 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -395,6 +395,8 @@ MASS_POUNDS: str = "lb" # UV Index units UNIT_UV_INDEX: str = "UV index" +# Percentage units +UNIT_PERCENTAGE = "%" # Irradiation units IRRADIATION_WATTS_PER_SQUARE_METER = f"{POWER_WATT}/{AREA_SQUARE_METERS}" diff --git a/tests/components/arlo/test_sensor.py b/tests/components/arlo/test_sensor.py index ac64ad8f272..15b85959ff0 100644 --- a/tests/components/arlo/test_sensor.py +++ b/tests/components/arlo/test_sensor.py @@ -9,6 +9,7 @@ from homeassistant.const import ( ATTR_ATTRIBUTION, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, + UNIT_PERCENTAGE, ) @@ -168,7 +169,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 == "%" + assert battery_sensor.unit_of_measurement == UNIT_PERCENTAGE def test_device_class(default_sensor, temperature_sensor, humidity_sensor): diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py index 03d3f71d5f9..2d88a5019b1 100644 --- a/tests/components/awair/test_sensor.py +++ b/tests/components/awair/test_sensor.py @@ -23,6 +23,7 @@ from homeassistant.const import ( DEVICE_CLASS_TEMPERATURE, STATE_UNAVAILABLE, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.setup import async_setup_component from homeassistant.util.dt import parse_datetime, utcnow @@ -156,7 +157,7 @@ async def test_awair_score(hass): sensor = hass.states.get("sensor.awair_score") assert sensor.state == "78" assert sensor.attributes["device_class"] == DEVICE_CLASS_SCORE - assert sensor.attributes["unit_of_measurement"] == "%" + assert sensor.attributes["unit_of_measurement"] == UNIT_PERCENTAGE async def test_awair_temp(hass): @@ -176,7 +177,7 @@ async def test_awair_humid(hass): sensor = hass.states.get("sensor.awair_humidity") assert sensor.state == "32.7" assert sensor.attributes["device_class"] == DEVICE_CLASS_HUMIDITY - assert sensor.attributes["unit_of_measurement"] == "%" + assert sensor.attributes["unit_of_measurement"] == UNIT_PERCENTAGE async def test_awair_co2(hass): diff --git a/tests/components/canary/test_sensor.py b/tests/components/canary/test_sensor.py index 7d5e829a347..b9d8be50b90 100644 --- a/tests/components/canary/test_sensor.py +++ b/tests/components/canary/test_sensor.py @@ -12,6 +12,7 @@ from homeassistant.components.canary.sensor import ( STATE_AIR_QUALITY_VERY_ABNORMAL, CanarySensor, ) +from homeassistant.const import UNIT_PERCENTAGE from tests.common import get_test_home_assistant from tests.components.canary.test_init import mock_device, mock_location @@ -97,7 +98,7 @@ class TestCanarySensorSetup(unittest.TestCase): sensor.update() assert "Home Family Room Humidity" == sensor.name - assert "%" == sensor.unit_of_measurement + assert UNIT_PERCENTAGE == sensor.unit_of_measurement assert 50.46 == sensor.state assert "mdi:water-percent" == sensor.icon @@ -184,7 +185,7 @@ class TestCanarySensorSetup(unittest.TestCase): sensor.update() assert "Home Family Room Battery" == sensor.name - assert "%" == sensor.unit_of_measurement + assert UNIT_PERCENTAGE == sensor.unit_of_measurement assert 70.46 == sensor.state assert "mdi:battery-70" == sensor.icon diff --git a/tests/components/dyson/test_sensor.py b/tests/components/dyson/test_sensor.py index e540d304731..b036e3bbbdb 100644 --- a/tests/components/dyson/test_sensor.py +++ b/tests/components/dyson/test_sensor.py @@ -8,7 +8,13 @@ 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 STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, TIME_HOURS +from homeassistant.const import ( + STATE_OFF, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + TIME_HOURS, + UNIT_PERCENTAGE, +) from homeassistant.helpers import discovery from homeassistant.setup import async_setup_component @@ -169,7 +175,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 == "%" + assert sensor.unit_of_measurement == UNIT_PERCENTAGE assert sensor.name == "Device_name Humidity" assert sensor.entity_id == "sensor.dyson_1" @@ -180,7 +186,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 == "%" + assert sensor.unit_of_measurement == UNIT_PERCENTAGE assert sensor.name == "Device_name Humidity" assert sensor.entity_id == "sensor.dyson_1" @@ -191,7 +197,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 == "%" + assert sensor.unit_of_measurement == UNIT_PERCENTAGE assert sensor.name == "Device_name Humidity" assert sensor.entity_id == "sensor.dyson_1" diff --git a/tests/components/foobot/test_sensor.py b/tests/components/foobot/test_sensor.py index a843f9a3012..f8cf38395f2 100644 --- a/tests/components/foobot/test_sensor.py +++ b/tests/components/foobot/test_sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.exceptions import PlatformNotReady from homeassistant.setup import async_setup_component @@ -40,10 +41,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", "%"], + "humidity": ["49.5", UNIT_PERCENTAGE], "pm2_5": ["144.8", CONCENTRATION_MICROGRAMS_PER_CUBIC_METER], "voc": ["340.7", CONCENTRATION_PARTS_PER_BILLION], - "index": ["138.9", "%"], + "index": ["138.9", UNIT_PERCENTAGE], } for name, value in metrics.items(): diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index e6bf185c93b..08a04d5b88e 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -26,6 +26,7 @@ from homeassistant.const import ( CONF_TYPE, TEMP_CELSIUS, TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, ) from homeassistant.core import State @@ -180,7 +181,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: "%"}, + {ATTR_DEVICE_CLASS: "humidity", ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}, ), ("LightSensor", "sensor.light", "900", {ATTR_DEVICE_CLASS: "illuminance"}), ("LightSensor", "sensor.light", "900", {ATTR_UNIT_OF_MEASUREMENT: "lm"}), diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index f357702040b..8834f730bce 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -21,6 +21,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, STATE_UNKNOWN, + UNIT_PERCENTAGE, ) from homeassistant.core import CoreState from homeassistant.helpers import entity_registry @@ -127,7 +128,7 @@ async def test_light_brightness(hass, hk_driver, cls, events): assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 assert len(events) == 1 - assert events[-1].data[ATTR_VALUE] == "brightness at 20%" + assert events[-1].data[ATTR_VALUE] == f"brightness at 20{UNIT_PERCENTAGE}" await hass.async_add_job(acc.char_on.client_update_value, 1) await hass.async_add_job(acc.char_brightness.client_update_value, 40) @@ -136,7 +137,7 @@ async def test_light_brightness(hass, hk_driver, cls, events): assert call_turn_on[1].data[ATTR_ENTITY_ID] == entity_id assert call_turn_on[1].data[ATTR_BRIGHTNESS_PCT] == 40 assert len(events) == 2 - assert events[-1].data[ATTR_VALUE] == "brightness at 40%" + assert events[-1].data[ATTR_VALUE] == f"brightness at 40{UNIT_PERCENTAGE}" await hass.async_add_job(acc.char_on.client_update_value, 1) await hass.async_add_job(acc.char_brightness.client_update_value, 0) @@ -235,9 +236,7 @@ async def test_light_restore(hass, hk_driver, cls, events): registry = await entity_registry.async_get_registry(hass) - registry.async_get_or_create( - "light", "hue", "1234", suggested_object_id="simple", - ) + registry.async_get_or_create("light", "hue", "1234", suggested_object_id="simple") registry.async_get_or_create( "light", "hue", diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index 969ea0bddc8..79d807b77c5 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -26,6 +26,7 @@ from homeassistant.const import ( STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, ) from homeassistant.core import CoreState from homeassistant.helpers import entity_registry @@ -287,7 +288,7 @@ async def test_sensor_restore(hass, hk_driver, events): "12345", suggested_object_id="humidity", device_class="humidity", - unit_of_measurement="%", + unit_of_measurement=UNIT_PERCENTAGE, ) hass.bus.async_fire(EVENT_HOMEASSISTANT_START, {}) await hass.async_block_till_done() diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index c5dbef1a499..c55a4682216 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -27,6 +27,7 @@ from homeassistant.const import ( POWER_WATT, SPEED_KILOMETERS_PER_HOUR, TEMP_CELSIUS, + UNIT_PERCENTAGE, ) from homeassistant.setup import async_setup_component @@ -55,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] == "%" + assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UNIT_PERCENTAGE await async_manipulate_test_data(hass, hmip_device, "dutyCycle", 17.3) @@ -77,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] == "%" + assert ha_state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UNIT_PERCENTAGE await async_manipulate_test_data(hass, hmip_device, "valvePosition", 0.37) ha_state = hass.states.get(entity_id) assert ha_state.state == "37" @@ -111,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"] == "%" + assert ha_state.attributes["unit_of_measurement"] == UNIT_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/influxdb/test_init.py b/tests/components/influxdb/test_init.py index 1dd2681b7f2..2bb9faad8e5 100644 --- a/tests/components/influxdb/test_init.py +++ b/tests/components/influxdb/test_init.py @@ -4,7 +4,13 @@ import unittest from unittest import mock import homeassistant.components.influxdb as influxdb -from homeassistant.const import EVENT_STATE_CHANGED, STATE_OFF, STATE_ON, STATE_STANDBY +from homeassistant.const import ( + EVENT_STATE_CHANGED, + STATE_OFF, + STATE_ON, + STATE_STANDBY, + UNIT_PERCENTAGE, +) from homeassistant.setup import setup_component from tests.common import get_test_home_assistant @@ -102,7 +108,7 @@ class TestInfluxDB(unittest.TestCase): "unit_of_measurement": "foobars", "longitude": "1.1", "latitude": "2.2", - "battery_level": "99%", + "battery_level": f"99{UNIT_PERCENTAGE}", "temperature": "20c", "last_seen": "Last seen 23 minutes ago", "updated_at": datetime.datetime(2017, 1, 1, 0, 0), @@ -124,7 +130,7 @@ class TestInfluxDB(unittest.TestCase): "fields": { "longitude": 1.1, "latitude": 2.2, - "battery_level_str": "99%", + "battery_level_str": f"99{UNIT_PERCENTAGE}", "battery_level": 99.0, "temperature_str": "20c", "temperature": 20.0, diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py index fcc9a72cf7b..38b725226e7 100644 --- a/tests/components/min_max/test_sensor.py +++ b/tests/components/min_max/test_sensor.py @@ -7,6 +7,7 @@ from homeassistant.const import ( STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, ) from homeassistant.setup import setup_component @@ -233,7 +234,7 @@ class TestMinMaxSensor(unittest.TestCase): assert "ERR" == state.attributes.get("unit_of_measurement") self.hass.states.set( - entity_ids[2], self.values[2], {ATTR_UNIT_OF_MEASUREMENT: "%"} + entity_ids[2], self.values[2], {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE} ) self.hass.block_till_done() diff --git a/tests/components/mobile_app/test_entity.py b/tests/components/mobile_app/test_entity.py index 65dc328186d..27ce29ecd15 100644 --- a/tests/components/mobile_app/test_entity.py +++ b/tests/components/mobile_app/test_entity.py @@ -2,6 +2,7 @@ import logging +from homeassistant.const import UNIT_PERCENTAGE from homeassistant.helpers import device_registry _LOGGER = logging.getLogger(__name__) @@ -24,7 +25,7 @@ async def test_sensor(hass, create_registrations, webhook_client): "state": 100, "type": "sensor", "unique_id": "battery_state", - "unit_of_measurement": "%", + "unit_of_measurement": UNIT_PERCENTAGE, }, }, ) @@ -40,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"] == "%" + assert entity.attributes["unit_of_measurement"] == UNIT_PERCENTAGE assert entity.attributes["foo"] == "bar" assert entity.domain == "sensor" assert entity.name == "Test 1 Battery State" @@ -104,7 +105,7 @@ async def test_sensor_id_no_dupes(hass, create_registrations, webhook_client): "state": 100, "type": "sensor", "unique_id": "battery_state", - "unit_of_measurement": "%", + "unit_of_measurement": UNIT_PERCENTAGE, }, } diff --git a/tests/components/mold_indicator/test_sensor.py b/tests/components/mold_indicator/test_sensor.py index ce0450d3304..bad7430e9b2 100644 --- a/tests/components/mold_indicator/test_sensor.py +++ b/tests/components/mold_indicator/test_sensor.py @@ -6,7 +6,12 @@ from homeassistant.components.mold_indicator.sensor import ( ATTR_DEWPOINT, ) import homeassistant.components.sensor as sensor -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNKNOWN, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) from homeassistant.setup import setup_component from tests.common import get_test_home_assistant @@ -25,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: "%"} + "test.indoorhumidity", "50", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE} ) def tearDown(self): @@ -50,7 +55,7 @@ class TestSensorMoldIndicator(unittest.TestCase): moldind = self.hass.states.get("sensor.mold_indicator") assert moldind - assert "%" == moldind.attributes.get("unit_of_measurement") + assert UNIT_PERCENTAGE == moldind.attributes.get("unit_of_measurement") def test_invalidcalib(self): """Test invalid sensor values.""" @@ -61,7 +66,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: "%"} + "test.indoorhumidity", "0", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE} ) assert setup_component( @@ -94,7 +99,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: "%"} + "test.indoorhumidity", "-1", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE} ) assert setup_component( @@ -120,7 +125,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: "%"} + "test.indoorhumidity", "A", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE} ) self.hass.block_till_done() moldind = self.hass.states.get("sensor.mold_indicator") @@ -220,7 +225,9 @@ class TestSensorMoldIndicator(unittest.TestCase): "test.outdoortemp", "25", {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS} ) self.hass.states.set( - "test.indoorhumidity", STATE_UNKNOWN, {ATTR_UNIT_OF_MEASUREMENT: "%"} + "test.indoorhumidity", + STATE_UNKNOWN, + {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE}, ) self.hass.block_till_done() moldind = self.hass.states.get("sensor.mold_indicator") @@ -230,7 +237,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: "%"} + "test.indoorhumidity", "20", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE} ) self.hass.block_till_done() moldind = self.hass.states.get("sensor.mold_indicator") @@ -276,7 +283,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: "%"} + "test.indoorhumidity", "20", {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE} ) self.hass.block_till_done() assert self.hass.states.get("sensor.mold_indicator").state == "23" diff --git a/tests/components/rflink/test_sensor.py b/tests/components/rflink/test_sensor.py index b68e1f959f1..a4904f5bc9b 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 +from homeassistant.const import STATE_UNKNOWN, UNIT_PERCENTAGE from tests.components.rflink.test_init import mock_rflink @@ -141,7 +141,12 @@ async def test_aliases(hass, monkeypatch): # test event for config sensor event_callback( - {"id": "test_alias_02_0", "sensor": "humidity", "value": 65, "unit": "%"} + { + "id": "test_alias_02_0", + "sensor": "humidity", + "value": 65, + "unit": UNIT_PERCENTAGE, + } ) await hass.async_block_till_done() @@ -149,7 +154,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"] == "%" + assert updated_sensor.attributes["unit_of_measurement"] == UNIT_PERCENTAGE async def test_race_condition(hass, monkeypatch): diff --git a/tests/components/rfxtrx/test_sensor.py b/tests/components/rfxtrx/test_sensor.py index 652c823e0cf..e258ebb9aa1 100644 --- a/tests/components/rfxtrx/test_sensor.py +++ b/tests/components/rfxtrx/test_sensor.py @@ -4,7 +4,7 @@ import unittest import pytest from homeassistant.components import rfxtrx as rfxtrx_core -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, UNIT_PERCENTAGE from homeassistant.setup import setup_component from tests.common import get_test_home_assistant, mock_component @@ -137,7 +137,7 @@ class TestSensorRfxtrx(unittest.TestCase): assert len(rfxtrx_core.RFX_DEVICES[id]) == 2 _entity_temp = rfxtrx_core.RFX_DEVICES[id]["Temperature"] _entity_hum = rfxtrx_core.RFX_DEVICES[id]["Humidity"] - assert "%" == _entity_hum.unit_of_measurement + assert UNIT_PERCENTAGE == _entity_hum.unit_of_measurement assert "Bath" == _entity_hum.__str__() assert _entity_hum.state is None assert TEMP_CELSIUS == _entity_temp.unit_of_measurement @@ -271,7 +271,7 @@ class TestSensorRfxtrx(unittest.TestCase): assert len(rfxtrx_core.RFX_DEVICES[id]) == 2 _entity_temp = rfxtrx_core.RFX_DEVICES[id]["Temperature"] _entity_hum = rfxtrx_core.RFX_DEVICES[id]["Humidity"] - assert "%" == _entity_hum.unit_of_measurement + assert UNIT_PERCENTAGE == _entity_hum.unit_of_measurement assert "Bath" == _entity_hum.__str__() assert _entity_temp.state is None assert TEMP_CELSIUS == _entity_temp.unit_of_measurement @@ -303,7 +303,7 @@ class TestSensorRfxtrx(unittest.TestCase): assert len(rfxtrx_core.RFX_DEVICES[id]) == 2 _entity_temp = rfxtrx_core.RFX_DEVICES[id]["Temperature"] _entity_hum = rfxtrx_core.RFX_DEVICES[id]["Humidity"] - assert "%" == _entity_hum.unit_of_measurement + assert UNIT_PERCENTAGE == _entity_hum.unit_of_measurement assert 15 == _entity_hum.state assert { "Battery numeric": 9, diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index f9d8bb640c3..5d25e666110 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 +from homeassistant.const import CONF_PLATFORM, STATE_UNKNOWN, UNIT_PERCENTAGE from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -97,13 +97,13 @@ async def test_get_condition_capabilities(hass, device_reg, entity_reg): expected_capabilities = { "extra_fields": [ { - "description": {"suffix": "%"}, + "description": {"suffix": UNIT_PERCENTAGE}, "name": "above", "optional": True, "type": "float", }, { - "description": {"suffix": "%"}, + "description": {"suffix": UNIT_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 8e4b5d1792a..54c0c8f7bd0 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 +from homeassistant.const import CONF_PLATFORM, STATE_UNKNOWN, UNIT_PERCENTAGE from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -102,13 +102,13 @@ async def test_get_trigger_capabilities(hass, device_reg, entity_reg): expected_capabilities = { "extra_fields": [ { - "description": {"suffix": "%"}, + "description": {"suffix": UNIT_PERCENTAGE}, "name": "above", "optional": True, "type": "float", }, { - "description": {"suffix": "%"}, + "description": {"suffix": UNIT_PERCENTAGE}, "name": "below", "optional": True, "type": "float", diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index f285bc65d8d..fd8bfdca44c 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, + UNIT_PERCENTAGE, ) from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -37,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] == "%" + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UNIT_PERCENTAGE assert state.attributes[ATTR_FRIENDLY_NAME] == device.label + " Battery" diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py index 300d201079b..1629a3d29c2 100644 --- a/tests/components/sonarr/test_sensor.py +++ b/tests/components/sonarr/test_sensor.py @@ -6,7 +6,7 @@ import unittest import pytest import homeassistant.components.sonarr.sensor as sonarr -from homeassistant.const import DATA_GIGABYTES +from homeassistant.const import DATA_GIGABYTES, UNIT_PERCENTAGE from tests.common import get_test_home_assistant @@ -569,7 +569,10 @@ class TestSonarrSetup(unittest.TestCase): assert "mdi:download" == device.icon assert "Episodes" == device.unit_of_measurement assert "Sonarr Queue" == device.name - assert "100.00%" == device.device_state_attributes["Game of Thrones S03E08"] + assert ( + f"100.00{UNIT_PERCENTAGE}" + == device.device_state_attributes["Game of Thrones S03E08"] + ) @unittest.mock.patch("requests.get", side_effect=mocked_requests_get) def test_series(self, req_mock): diff --git a/tests/components/spaceapi/test_init.py b/tests/components/spaceapi/test_init.py index 840931d073b..78c7991d9b3 100644 --- a/tests/components/spaceapi/test_init.py +++ b/tests/components/spaceapi/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import patch import pytest from homeassistant.components.spaceapi import DOMAIN, SPACEAPI_VERSION, URL_API_SPACEAPI +from homeassistant.const import UNIT_PERCENTAGE from homeassistant.setup import async_setup_component from tests.common import mock_coro @@ -62,7 +63,9 @@ SENSOR_OUTPUT = { {"location": "Home", "name": "temp1", "unit": "°C", "value": "25"}, {"location": "Home", "name": "temp2", "unit": "°C", "value": "23"}, ], - "humidity": [{"location": "Home", "name": "hum1", "unit": "%", "value": "88"}], + "humidity": [ + {"location": "Home", "name": "hum1", "unit": UNIT_PERCENTAGE, "value": "88"} + ], } @@ -74,7 +77,9 @@ def mock_client(hass, hass_client): hass.states.async_set("test.temp1", 25, attributes={"unit_of_measurement": "°C"}) hass.states.async_set("test.temp2", 23, attributes={"unit_of_measurement": "°C"}) - hass.states.async_set("test.hum1", 88, attributes={"unit_of_measurement": "%"}) + hass.states.async_set( + "test.hum1", 88, attributes={"unit_of_measurement": UNIT_PERCENTAGE} + ) return hass.loop.run_until_complete(hass_client()) diff --git a/tests/components/startca/test_sensor.py b/tests/components/startca/test_sensor.py index 82748c122ab..23cc892900b 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 +from homeassistant.const import DATA_GIGABYTES, UNIT_PERCENTAGE from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -52,7 +52,7 @@ async def test_capped_setup(hass, aioclient_mock): await async_setup_component(hass, "sensor", {"sensor": config}) state = hass.states.get("sensor.start_ca_usage_ratio") - assert state.attributes.get("unit_of_measurement") == "%" + assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE assert state.state == "76.24" state = hass.states.get("sensor.start_ca_usage") @@ -147,7 +147,7 @@ async def test_unlimited_setup(hass, aioclient_mock): await async_setup_component(hass, "sensor", {"sensor": config}) state = hass.states.get("sensor.start_ca_usage_ratio") - assert state.attributes.get("unit_of_measurement") == "%" + assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE assert state.state == "0" state = hass.states.get("sensor.start_ca_usage") diff --git a/tests/components/teksavvy/test_sensor.py b/tests/components/teksavvy/test_sensor.py index 641112e6362..e3e7eae36a8 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 +from homeassistant.const import DATA_GIGABYTES, UNIT_PERCENTAGE from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -74,7 +74,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") == "%" + assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE assert state.state == "56.69" state = hass.states.get("sensor.teksavvy_usage") @@ -159,7 +159,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") == "%" + assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE assert state.state == "0" state = hass.states.get("sensor.teksavvy_remaining") diff --git a/tests/components/vera/test_sensor.py b/tests/components/vera/test_sensor.py index da28fc225e0..9e84815d636 100644 --- a/tests/components/vera/test_sensor.py +++ b/tests/components/vera/test_sensor.py @@ -13,6 +13,7 @@ from pyvera import ( VeraSensor, ) +from homeassistant.const import UNIT_PERCENTAGE from homeassistant.core import HomeAssistant from .common import ComponentFactory @@ -64,7 +65,7 @@ async def test_temperature_sensor_f( vera_component_factory=vera_component_factory, category=CATEGORY_TEMPERATURE_SENSOR, class_property="temperature", - assert_states=(("33", "1"), ("44", "7"),), + assert_states=(("33", "1"), ("44", "7")), setup_callback=setup_callback, ) @@ -78,7 +79,7 @@ async def test_temperature_sensor_c( vera_component_factory=vera_component_factory, category=CATEGORY_TEMPERATURE_SENSOR, class_property="temperature", - assert_states=(("33", "33"), ("44", "44"),), + assert_states=(("33", "33"), ("44", "44")), ) @@ -91,7 +92,7 @@ async def test_light_sensor( vera_component_factory=vera_component_factory, category=CATEGORY_LIGHT_SENSOR, class_property="light", - assert_states=(("12", "12"), ("13", "13"),), + assert_states=(("12", "12"), ("13", "13")), assert_unit_of_measurement="lx", ) @@ -105,7 +106,7 @@ async def test_uv_sensor( vera_component_factory=vera_component_factory, category=CATEGORY_UV_SENSOR, class_property="light", - assert_states=(("12", "12"), ("13", "13"),), + assert_states=(("12", "12"), ("13", "13")), assert_unit_of_measurement="level", ) @@ -119,8 +120,8 @@ async def test_humidity_sensor( vera_component_factory=vera_component_factory, category=CATEGORY_HUMIDITY_SENSOR, class_property="humidity", - assert_states=(("12", "12"), ("13", "13"),), - assert_unit_of_measurement="%", + assert_states=(("12", "12"), ("13", "13")), + assert_unit_of_measurement=UNIT_PERCENTAGE, ) @@ -133,7 +134,7 @@ async def test_power_meter_sensor( vera_component_factory=vera_component_factory, category=CATEGORY_POWER_METER, class_property="power", - assert_states=(("12", "12"), ("13", "13"),), + assert_states=(("12", "12"), ("13", "13")), assert_unit_of_measurement="watts", ) @@ -151,7 +152,7 @@ async def test_trippable_sensor( vera_component_factory=vera_component_factory, category=999, class_property="is_tripped", - assert_states=((True, "Tripped"), (False, "Not Tripped"), (True, "Tripped"),), + assert_states=((True, "Tripped"), (False, "Not Tripped"), (True, "Tripped")), setup_callback=setup_callback, ) @@ -169,7 +170,7 @@ async def test_unknown_sensor( vera_component_factory=vera_component_factory, category=999, class_property="is_tripped", - assert_states=((True, "Unknown"), (False, "Unknown"), (True, "Unknown"),), + assert_states=((True, "Unknown"), (False, "Unknown"), (True, "Unknown")), setup_callback=setup_callback, ) @@ -187,7 +188,7 @@ async def test_scene_controller_sensor( entity_id = "sensor.dev1_1" component_data = await vera_component_factory.configure_component( - hass=hass, devices=(vera_device,), + hass=hass, devices=(vera_device,) ) controller = component_data.controller update_callback = controller.register.call_args_list[0][0][1] diff --git a/tests/components/yr/test_sensor.py b/tests/components/yr/test_sensor.py index d3e3bd6286f..398acd7d554 100644 --- a/tests/components/yr/test_sensor.py +++ b/tests/components/yr/test_sensor.py @@ -3,7 +3,7 @@ from datetime import datetime from unittest.mock import patch from homeassistant.bootstrap import async_setup_component -from homeassistant.const import SPEED_METERS_PER_SECOND +from homeassistant.const import SPEED_METERS_PER_SECOND, UNIT_PERCENTAGE import homeassistant.util.dt as dt_util from tests.common import assert_setup_component, load_fixture @@ -63,11 +63,11 @@ async def test_custom_setup(hass, aioclient_mock): assert state.state == "103.6" state = hass.states.get("sensor.yr_humidity") - assert state.attributes.get("unit_of_measurement") == "%" + 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") == "%" + assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE assert state.state == "0.0" state = hass.states.get("sensor.yr_wind_speed") @@ -109,11 +109,11 @@ async def test_forecast_setup(hass, aioclient_mock): assert state.state == "148.9" state = hass.states.get("sensor.yr_humidity") - assert state.attributes.get("unit_of_measurement") == "%" + 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") == "%" + assert state.attributes.get("unit_of_measurement") == UNIT_PERCENTAGE assert state.state == "0.0" state = hass.states.get("sensor.yr_wind_speed") diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index b81e8f02c12..fce882c6949 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -19,6 +19,7 @@ from homeassistant.const import ( STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, ) from homeassistant.helpers import restore_state from homeassistant.util import dt as dt_util @@ -35,7 +36,7 @@ from .common import ( async def async_test_humidity(hass, cluster, entity_id): """Test humidity sensor.""" await send_attribute_report(hass, cluster, 0, 1000) - assert_state(hass, entity_id, "10.0", "%") + assert_state(hass, entity_id, "10.0", UNIT_PERCENTAGE) async def async_test_temperature(hass, cluster, entity_id): diff --git a/tests/components/zwave/test_sensor.py b/tests/components/zwave/test_sensor.py index 74e1ef2cd03..d7503eb10fb 100644 --- a/tests/components/zwave/test_sensor.py +++ b/tests/components/zwave/test_sensor.py @@ -151,12 +151,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="%") + value = MockValue(data=12.34, node=node, units=homeassistant.const.UNIT_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 == "%" + assert device.unit_of_measurement == homeassistant.const.UNIT_PERCENTAGE value.data = 45.67 value_changed(value) assert device.state == 45.67 diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index ee43f5d4f1d..d9cbbb31561 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -7,6 +7,7 @@ from unittest.mock import MagicMock, Mock, patch import asynctest import pytest +from homeassistant.const import UNIT_PERCENTAGE from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import entity_platform, entity_registry from homeassistant.helpers.entity import async_generate_entity_id @@ -800,7 +801,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_of_measurement=UNIT_PERCENTAGE, ) await component.async_add_entities([entity_default]) @@ -812,7 +813,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 == "%" + assert entry_default.unit_of_measurement == UNIT_PERCENTAGE async def test_override_restored_entities(hass): diff --git a/tests/testing_config/custom_components/test/sensor.py b/tests/testing_config/custom_components/test/sensor.py index 26497b16a16..38bf9653938 100644 --- a/tests/testing_config/custom_components/test/sensor.py +++ b/tests/testing_config/custom_components/test/sensor.py @@ -4,6 +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 tests.common import MockEntity @@ -11,8 +12,8 @@ DEVICE_CLASSES = list(sensor.DEVICE_CLASSES) DEVICE_CLASSES.append("none") UNITS_OF_MEASUREMENT = { - sensor.DEVICE_CLASS_BATTERY: "%", # % of battery that is left - sensor.DEVICE_CLASS_HUMIDITY: "%", # % of humidity in the air + 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_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) From deda2f86e7e138fd22f2853bc42ca72a6387976a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 28 Feb 2020 12:43:17 -0800 Subject: [PATCH 157/416] Allow managing Lovelace storage dashboards (#32241) * Allow managing Lovelace storage dashboards * Make sure we do not allow duplicate url paths * Allow setting sidebar to None * Fix tests * Delete storage file on delete * List all dashboards --- homeassistant/components/frontend/__init__.py | 4 +- homeassistant/components/lovelace/__init__.py | 139 +++++++++----- homeassistant/components/lovelace/const.py | 50 ++++- .../components/lovelace/dashboard.py | 87 +++++++-- .../components/lovelace/websocket.py | 15 ++ homeassistant/components/zone/__init__.py | 4 +- homeassistant/helpers/collection.py | 30 ++- homeassistant/helpers/storage.py | 7 + tests/common.py | 8 + tests/components/lovelace/test_dashboard.py | 177 +++++++++++++++++- tests/helpers/test_collection.py | 16 +- 11 files changed, 447 insertions(+), 90 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 5864c642fa9..1e3dea98619 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -171,6 +171,8 @@ def async_register_built_in_panel( frontend_url_path=None, config=None, require_admin=False, + *, + update=False, ): """Register a built-in panel.""" panel = Panel( @@ -184,7 +186,7 @@ def async_register_built_in_panel( panels = hass.data.setdefault(DATA_PANELS, {}) - if panel.frontend_url_path in panels: + if not update and panel.frontend_url_path in panels: raise ValueError(f"Overwriting panel {panel.frontend_url_path}") panels[panel.frontend_url_path] = panel diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index e7c309be719..65c3b11b369 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -1,65 +1,48 @@ """Support for the Lovelace UI.""" import logging -from typing import Any import voluptuous as vol from homeassistant.components import frontend -from homeassistant.const import CONF_FILENAME, CONF_ICON +from homeassistant.const import CONF_FILENAME +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, config_validation as cv -from homeassistant.util import sanitize_filename, slugify +from homeassistant.util import sanitize_filename from . import dashboard, resources, websocket from .const import ( + CONF_ICON, + CONF_MODE, + CONF_REQUIRE_ADMIN, CONF_RESOURCES, + CONF_SIDEBAR, + CONF_TITLE, + CONF_URL_PATH, + DASHBOARD_BASE_CREATE_FIELDS, DOMAIN, - LOVELACE_CONFIG_FILE, MODE_STORAGE, MODE_YAML, RESOURCE_CREATE_FIELDS, RESOURCE_SCHEMA, RESOURCE_UPDATE_FIELDS, + STORAGE_DASHBOARD_CREATE_FIELDS, + STORAGE_DASHBOARD_UPDATE_FIELDS, + url_slug, ) _LOGGER = logging.getLogger(__name__) -CONF_MODE = "mode" - CONF_DASHBOARDS = "dashboards" -CONF_SIDEBAR = "sidebar" -CONF_TITLE = "title" -CONF_REQUIRE_ADMIN = "require_admin" -DASHBOARD_BASE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean, - vol.Optional(CONF_SIDEBAR): { - vol.Required(CONF_ICON): cv.icon, - vol.Required(CONF_TITLE): cv.string, - }, - } -) - -YAML_DASHBOARD_SCHEMA = DASHBOARD_BASE_SCHEMA.extend( +YAML_DASHBOARD_SCHEMA = vol.Schema( { + **DASHBOARD_BASE_CREATE_FIELDS, vol.Required(CONF_MODE): MODE_YAML, vol.Required(CONF_FILENAME): vol.All(cv.string, sanitize_filename), } ) - -def url_slug(value: Any) -> str: - """Validate value is a valid url slug.""" - if value is None: - raise vol.Invalid("Slug should not be None") - str_value = str(value) - slg = slugify(str_value, separator="-") - if str_value == slg: - return str_value - raise vol.Invalid(f"invalid slug {value} (try {slg})") - - CONFIG_SCHEMA = vol.Schema( { vol.Optional(DOMAIN, default={}): vol.Schema( @@ -80,14 +63,13 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass, config): """Set up the Lovelace commands.""" - # Pass in default to `get` because defaults not set if loaded as dep mode = config[DOMAIN][CONF_MODE] yaml_resources = config[DOMAIN].get(CONF_RESOURCES) frontend.async_register_built_in_panel(hass, DOMAIN, config={"mode": mode}) if mode == MODE_YAML: - default_config = dashboard.LovelaceYAML(hass, None, LOVELACE_CONFIG_FILE) + default_config = dashboard.LovelaceYAML(hass, None, None) if yaml_resources is None: try: @@ -134,6 +116,10 @@ async def async_setup(hass, config): websocket.websocket_lovelace_resources ) + hass.components.websocket_api.async_register_command( + websocket.websocket_lovelace_dashboards + ) + hass.components.system_health.async_register_info(DOMAIN, system_health_info) hass.data[DOMAIN] = { @@ -142,34 +128,87 @@ async def async_setup(hass, config): "resources": resource_collection, } - if hass.config.safe_mode or CONF_DASHBOARDS not in config[DOMAIN]: + if hass.config.safe_mode: return True - for url_path, dashboard_conf in config[DOMAIN][CONF_DASHBOARDS].items(): + # Process YAML dashboards + for url_path, dashboard_conf in config[DOMAIN].get(CONF_DASHBOARDS, {}).items(): # For now always mode=yaml - config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf[CONF_FILENAME]) + config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf) hass.data[DOMAIN]["dashboards"][url_path] = config - kwargs = { - "hass": hass, - "component_name": DOMAIN, - "frontend_url_path": url_path, - "require_admin": dashboard_conf[CONF_REQUIRE_ADMIN], - "config": {"mode": dashboard_conf[CONF_MODE]}, - } - - if CONF_SIDEBAR in dashboard_conf: - kwargs["sidebar_title"] = dashboard_conf[CONF_SIDEBAR][CONF_TITLE] - kwargs["sidebar_icon"] = dashboard_conf[CONF_SIDEBAR][CONF_ICON] - try: - frontend.async_register_built_in_panel(**kwargs) + _register_panel(hass, url_path, MODE_YAML, dashboard_conf, False) except ValueError: _LOGGER.warning("Panel url path %s is not unique", url_path) + # Process storage dashboards + dashboards_collection = dashboard.DashboardsCollection(hass) + + async def storage_dashboard_changed(change_type, item_id, item): + """Handle a storage dashboard change.""" + url_path = item[CONF_URL_PATH] + + if change_type == collection.CHANGE_REMOVED: + frontend.async_remove_panel(hass, url_path) + await hass.data[DOMAIN]["dashboards"].pop(url_path).async_delete() + return + + if change_type == collection.CHANGE_ADDED: + existing = hass.data[DOMAIN]["dashboards"].get(url_path) + + if existing: + _LOGGER.warning( + "Cannot register panel at %s, it is already defined in %s", + url_path, + existing, + ) + return + + hass.data[DOMAIN]["dashboards"][url_path] = dashboard.LovelaceStorage( + hass, item + ) + + update = False + else: + update = True + + try: + _register_panel(hass, url_path, MODE_STORAGE, item, update) + except ValueError: + _LOGGER.warning("Failed to %s panel %s from storage", change_type, url_path) + + dashboards_collection.async_add_listener(storage_dashboard_changed) + await dashboards_collection.async_load() + + collection.StorageCollectionWebsocket( + dashboards_collection, + "lovelace/dashboards", + "dashboard", + STORAGE_DASHBOARD_CREATE_FIELDS, + STORAGE_DASHBOARD_UPDATE_FIELDS, + ).async_setup(hass, create_list=False) + return True async def system_health_info(hass): """Get info for the info page.""" return await hass.data[DOMAIN]["dashboards"][None].async_get_info() + + +@callback +def _register_panel(hass, url_path, mode, config, update): + """Register a panel.""" + kwargs = { + "frontend_url_path": url_path, + "require_admin": config[CONF_REQUIRE_ADMIN], + "config": {"mode": mode}, + "update": update, + } + + if CONF_SIDEBAR in config: + kwargs["sidebar_title"] = config[CONF_SIDEBAR][CONF_TITLE] + kwargs["sidebar_icon"] = config[CONF_SIDEBAR][CONF_ICON] + + frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs) diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index 1e984b3d82d..de6aa99894a 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -1,13 +1,17 @@ """Constants for Lovelace.""" +from typing import Any + import voluptuous as vol -from homeassistant.const import CONF_TYPE, CONF_URL +from homeassistant.const import CONF_ICON, CONF_TYPE, CONF_URL from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv +from homeassistant.util import slugify DOMAIN = "lovelace" EVENT_LOVELACE_UPDATED = "lovelace_updated" +CONF_MODE = "mode" MODE_YAML = "yaml" MODE_STORAGE = "storage" @@ -35,6 +39,50 @@ RESOURCE_UPDATE_FIELDS = { vol.Optional(CONF_URL): cv.string, } +CONF_SIDEBAR = "sidebar" +CONF_TITLE = "title" +CONF_REQUIRE_ADMIN = "require_admin" + +SIDEBAR_FIELDS = { + vol.Required(CONF_ICON): cv.icon, + vol.Required(CONF_TITLE): cv.string, +} + +DASHBOARD_BASE_CREATE_FIELDS = { + vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean, + vol.Optional(CONF_SIDEBAR): SIDEBAR_FIELDS, +} + + +DASHBOARD_BASE_UPDATE_FIELDS = { + vol.Optional(CONF_REQUIRE_ADMIN): cv.boolean, + vol.Optional(CONF_SIDEBAR): vol.Any(None, SIDEBAR_FIELDS), +} + + +STORAGE_DASHBOARD_CREATE_FIELDS = { + **DASHBOARD_BASE_CREATE_FIELDS, + vol.Required(CONF_URL_PATH): cv.string, + # For now we write "storage" as all modes. + # In future we can adjust this to be other modes. + vol.Optional(CONF_MODE, default=MODE_STORAGE): MODE_STORAGE, +} + +STORAGE_DASHBOARD_UPDATE_FIELDS = { + **DASHBOARD_BASE_UPDATE_FIELDS, +} + + +def url_slug(value: Any) -> str: + """Validate value is a valid url slug.""" + if value is None: + raise vol.Invalid("Slug should not be None") + str_value = str(value) + slg = slugify(str_value, separator="-") + if str_value == slg: + return str_value + raise vol.Invalid(f"invalid slug {value} (try {slg})") + class ConfigNotFound(HomeAssistantError): """When no config available.""" diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 11cb3266755..cd0d4a6fea8 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -1,32 +1,53 @@ """Lovelace dashboard support.""" from abc import ABC, abstractmethod +import logging import os import time +import voluptuous as vol + +from homeassistant.const import CONF_FILENAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import storage +from homeassistant.helpers import collection, storage from homeassistant.util.yaml import load_yaml from .const import ( + CONF_SIDEBAR, + CONF_URL_PATH, DOMAIN, EVENT_LOVELACE_UPDATED, + LOVELACE_CONFIG_FILE, MODE_STORAGE, MODE_YAML, + STORAGE_DASHBOARD_CREATE_FIELDS, + STORAGE_DASHBOARD_UPDATE_FIELDS, ConfigNotFound, ) CONFIG_STORAGE_KEY_DEFAULT = DOMAIN +CONFIG_STORAGE_KEY = "lovelace.{}" CONFIG_STORAGE_VERSION = 1 +DASHBOARDS_STORAGE_KEY = f"{DOMAIN}_dashboards" +DASHBOARDS_STORAGE_VERSION = 1 +_LOGGER = logging.getLogger(__name__) class LovelaceConfig(ABC): """Base class for Lovelace config.""" - def __init__(self, hass, url_path): + def __init__(self, hass, url_path, config): """Initialize Lovelace config.""" self.hass = hass - self.url_path = url_path + if config: + self.config = {**config, CONF_URL_PATH: url_path} + else: + self.config = None + + @property + def url_path(self) -> str: + """Return url path.""" + return self.config[CONF_URL_PATH] if self.config else None @property @abstractmethod @@ -58,13 +79,16 @@ class LovelaceConfig(ABC): class LovelaceStorage(LovelaceConfig): """Class to handle Storage based Lovelace config.""" - def __init__(self, hass, url_path): + def __init__(self, hass, config): """Initialize Lovelace config based on storage helper.""" - super().__init__(hass, url_path) - if url_path is None: + if config is None: + url_path = None storage_key = CONFIG_STORAGE_KEY_DEFAULT else: - raise ValueError("Storage-based dashboards are not supported") + url_path = config[CONF_URL_PATH] + storage_key = CONFIG_STORAGE_KEY.format(url_path) + + super().__init__(hass, url_path, config) self._store = storage.Store(hass, CONFIG_STORAGE_VERSION, storage_key) self._data = None @@ -115,7 +139,9 @@ class LovelaceStorage(LovelaceConfig): if self.hass.config.safe_mode: raise HomeAssistantError("Deleting not supported in safe mode") - await self.async_save(None) + await self._store.async_remove() + self._data = None + self._config_updated() async def _load(self): """Load the config.""" @@ -126,10 +152,13 @@ class LovelaceStorage(LovelaceConfig): class LovelaceYAML(LovelaceConfig): """Class to handle YAML-based Lovelace config.""" - def __init__(self, hass, url_path, path): + def __init__(self, hass, url_path, config): """Initialize the YAML config.""" - super().__init__(hass, url_path) - self.path = hass.config.path(path) + super().__init__(hass, url_path, config) + + self.path = hass.config.path( + config[CONF_FILENAME] if config else LOVELACE_CONFIG_FILE + ) self._cache = None @property @@ -185,3 +214,39 @@ def _config_info(mode, config): "resources": len(config.get("resources", [])), "views": len(config.get("views", [])), } + + +class DashboardsCollection(collection.StorageCollection): + """Collection of dashboards.""" + + CREATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_CREATE_FIELDS) + UPDATE_SCHEMA = vol.Schema(STORAGE_DASHBOARD_UPDATE_FIELDS) + + def __init__(self, hass): + """Initialize the dashboards collection.""" + super().__init__( + storage.Store(hass, DASHBOARDS_STORAGE_VERSION, DASHBOARDS_STORAGE_KEY), + _LOGGER, + ) + + async def _process_create_data(self, data: dict) -> dict: + """Validate the config is valid.""" + if data[CONF_URL_PATH] in self.hass.data[DOMAIN]["dashboards"]: + raise vol.Invalid("Dashboard url path needs to be unique") + + return self.CREATE_SCHEMA(data) + + @callback + def _get_suggested_id(self, info: dict) -> str: + """Suggest an ID based on the config.""" + return info[CONF_URL_PATH] + + async def _update_data(self, data: dict, update_data: dict) -> dict: + """Return a new updated data object.""" + update_data = self.UPDATE_SCHEMA(update_data) + updated = {**data, **update_data} + + if CONF_SIDEBAR in updated and updated[CONF_SIDEBAR] is None: + updated.pop(CONF_SIDEBAR) + + return updated diff --git a/homeassistant/components/lovelace/websocket.py b/homeassistant/components/lovelace/websocket.py index d80764f4ed9..a4e67fda929 100644 --- a/homeassistant/components/lovelace/websocket.py +++ b/homeassistant/components/lovelace/websocket.py @@ -4,6 +4,7 @@ from functools import wraps import voluptuous as vol from homeassistant.components import websocket_api +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -96,3 +97,17 @@ async def websocket_lovelace_save_config(hass, connection, msg, config): async def websocket_lovelace_delete_config(hass, connection, msg, config): """Delete Lovelace UI configuration.""" await config.async_delete() + + +@websocket_api.websocket_command({"type": "lovelace/dashboards/list"}) +@callback +def websocket_lovelace_dashboards(hass, connection, msg): + """Delete Lovelace UI configuration.""" + connection.send_result( + msg["id"], + [ + dashboard.config + for dashboard in hass.data[DOMAIN]["dashboards"].values() + if dashboard.config + ], + ) diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index d14e31273b7..c71026ea79c 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -189,9 +189,7 @@ async def async_setup(hass: HomeAssistant, config: Dict) -> bool: storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS ).async_setup(hass) - async def _collection_changed( - change_type: str, item_id: str, config: Optional[Dict] - ) -> None: + async def _collection_changed(change_type: str, item_id: str, config: Dict) -> None: """Handle a collection change: clean up entity registry on removals.""" if change_type != collection.CHANGE_REMOVED: return diff --git a/homeassistant/helpers/collection.py b/homeassistant/helpers/collection.py index d03469e20bb..8234dd6ec87 100644 --- a/homeassistant/helpers/collection.py +++ b/homeassistant/helpers/collection.py @@ -31,8 +31,8 @@ ChangeListener = Callable[ str, # Item ID str, - # New config (None if removed) - Optional[dict], + # New or removed config + dict, ], Awaitable[None], ] @@ -104,9 +104,7 @@ class ObservableCollection(ABC): """ self.listeners.append(listener) - async def notify_change( - self, change_type: str, item_id: str, item: Optional[dict] - ) -> None: + async def notify_change(self, change_type: str, item_id: str, item: dict) -> None: """Notify listeners of a change.""" self.logger.debug("%s %s: %s", change_type, item_id, item) for listener in self.listeners: @@ -136,8 +134,8 @@ class YamlCollection(ObservableCollection): await self.notify_change(event, item_id, item) for item_id in old_ids: - self.data.pop(item_id) - await self.notify_change(CHANGE_REMOVED, item_id, None) + + await self.notify_change(CHANGE_REMOVED, item_id, self.data.pop(item_id)) class StorageCollection(ObservableCollection): @@ -219,10 +217,10 @@ class StorageCollection(ObservableCollection): if item_id not in self.data: raise ItemNotFound(item_id) - self.data.pop(item_id) + item = self.data.pop(item_id) self._async_schedule_save() - await self.notify_change(CHANGE_REMOVED, item_id, None) + await self.notify_change(CHANGE_REMOVED, item_id, item) @callback def _async_schedule_save(self) -> None: @@ -242,8 +240,8 @@ class IDLessCollection(ObservableCollection): async def async_load(self, data: List[dict]) -> None: """Load the collection. Overrides existing data.""" - for item_id in list(self.data): - await self.notify_change(CHANGE_REMOVED, item_id, None) + for item_id, item in list(self.data.items()): + await self.notify_change(CHANGE_REMOVED, item_id, item) self.data.clear() @@ -264,12 +262,10 @@ def attach_entity_component_collection( """Map a collection to an entity component.""" entities = {} - async def _collection_changed( - change_type: str, item_id: str, config: Optional[dict] - ) -> None: + async def _collection_changed(change_type: str, item_id: str, config: dict) -> None: """Handle a collection change.""" if change_type == CHANGE_ADDED: - entity = create_entity(cast(dict, config)) + entity = create_entity(config) await entity_component.async_add_entities([entity]) # type: ignore entities[item_id] = entity return @@ -294,9 +290,7 @@ def attach_entity_registry_cleaner( ) -> None: """Attach a listener to clean up entity registry on collection changes.""" - async def _collection_changed( - change_type: str, item_id: str, config: Optional[Dict] - ) -> None: + async def _collection_changed(change_type: str, item_id: str, config: Dict) -> None: """Handle a collection change: clean up entity registry on removals.""" if change_type != CHANGE_REMOVED: return diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py index aed6da37518..1cad8eec473 100644 --- a/homeassistant/helpers/storage.py +++ b/homeassistant/helpers/storage.py @@ -210,3 +210,10 @@ class Store: async def _async_migrate_func(self, old_version, old_data): """Migrate to the new version.""" raise NotImplementedError + + async def async_remove(self): + """Remove all data.""" + try: + await self.hass.async_add_executor_job(os.unlink, self.path) + except FileNotFoundError: + pass diff --git a/tests/common.py b/tests/common.py index 4581c96b52a..8fdcc9b8f86 100644 --- a/tests/common.py +++ b/tests/common.py @@ -992,6 +992,10 @@ def mock_storage(data=None): # To ensure that the data can be serialized data[store.key] = json.loads(json.dumps(data_to_write, cls=store._encoder)) + async def mock_remove(store): + """Remove data.""" + data.pop(store.key, None) + with patch( "homeassistant.helpers.storage.Store._async_load", side_effect=mock_async_load, @@ -1000,6 +1004,10 @@ def mock_storage(data=None): "homeassistant.helpers.storage.Store._write_data", side_effect=mock_write_data, autospec=True, + ), patch( + "homeassistant.helpers.storage.Store.async_remove", + side_effect=mock_remove, + autospec=True, ): yield data diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 1d385ba3bec..0b6d6806cb0 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -98,9 +98,7 @@ async def test_lovelace_from_storage_delete(hass, hass_ws_client, hass_storage): await client.send_json({"id": 7, "type": "lovelace/config/delete"}) response = await client.receive_json() assert response["success"] - assert hass_storage[dashboard.CONFIG_STORAGE_KEY_DEFAULT]["data"] == { - "config": None - } + assert dashboard.CONFIG_STORAGE_KEY_DEFAULT not in hass_storage # Fetch data await client.send_json({"id": 8, "type": "lovelace/config"}) @@ -212,8 +210,9 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path): "mode": "yaml", "filename": "bla.yaml", "sidebar": {"title": "Test Panel", "icon": "mdi:test-icon"}, + "require_admin": True, }, - "test-panel-no-sidebar": {"mode": "yaml", "filename": "bla.yaml"}, + "test-panel-no-sidebar": {"mode": "yaml", "filename": "bla2.yaml"}, } } }, @@ -225,6 +224,25 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path): client = await hass_ws_client(hass) + # List dashboards + await client.send_json({"id": 4, "type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]) == 2 + with_sb, without_sb = response["result"] + + assert with_sb["mode"] == "yaml" + assert with_sb["filename"] == "bla.yaml" + assert with_sb["sidebar"] == {"title": "Test Panel", "icon": "mdi:test-icon"} + assert with_sb["require_admin"] is True + assert with_sb["url_path"] == "test-panel" + + assert without_sb["mode"] == "yaml" + assert without_sb["filename"] == "bla2.yaml" + assert "sidebar" not in without_sb + assert without_sb["require_admin"] is False + assert without_sb["url_path"] == "test-panel-no-sidebar" + # Fetch data await client.send_json({"id": 5, "type": "lovelace/config", "url_path": url_path}) response = await client.receive_json() @@ -275,3 +293,154 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path): assert response["result"] == {"hello": "yo2"} assert len(events) == 1 + + +async def test_storage_dashboards(hass, hass_ws_client, hass_storage): + """Test we load lovelace config from storage.""" + assert await async_setup_component(hass, "lovelace", {}) + assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "storage"} + + client = await hass_ws_client(hass) + + # Fetch data + await client.send_json({"id": 5, "type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [] + + # Add a dashboard + await client.send_json( + { + "id": 6, + "type": "lovelace/dashboards/create", + "url_path": "created_url_path", + "require_admin": True, + "sidebar": {"title": "Updated Title", "icon": "mdi:map"}, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["require_admin"] is True + assert response["result"]["sidebar"] == { + "title": "Updated Title", + "icon": "mdi:map", + } + + dashboard_id = response["result"]["id"] + + assert "created_url_path" in hass.data[frontend.DATA_PANELS] + + await client.send_json({"id": 7, "type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]) == 1 + assert response["result"][0]["mode"] == "storage" + assert response["result"][0]["sidebar"] == { + "title": "Updated Title", + "icon": "mdi:map", + } + assert response["result"][0]["require_admin"] is True + + # Fetch config + await client.send_json( + {"id": 8, "type": "lovelace/config", "url_path": "created_url_path"} + ) + response = await client.receive_json() + assert not response["success"] + assert response["error"]["code"] == "config_not_found" + + # Store new config + events = async_capture_events(hass, const.EVENT_LOVELACE_UPDATED) + + await client.send_json( + { + "id": 9, + "type": "lovelace/config/save", + "url_path": "created_url_path", + "config": {"yo": "hello"}, + } + ) + response = await client.receive_json() + assert response["success"] + assert hass_storage[dashboard.CONFIG_STORAGE_KEY.format(dashboard_id)]["data"] == { + "config": {"yo": "hello"} + } + assert len(events) == 1 + assert events[0].data["url_path"] == "created_url_path" + + await client.send_json( + {"id": 10, "type": "lovelace/config", "url_path": "created_url_path"} + ) + response = await client.receive_json() + assert response["success"] + assert response["result"] == {"yo": "hello"} + + # Update a dashboard + await client.send_json( + { + "id": 11, + "type": "lovelace/dashboards/update", + "dashboard_id": dashboard_id, + "require_admin": False, + "sidebar": None, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["result"]["require_admin"] is False + assert "sidebar" not in response["result"] + + # Add dashboard with existing url path + await client.send_json( + {"id": 12, "type": "lovelace/dashboards/create", "url_path": "created_url_path"} + ) + response = await client.receive_json() + assert not response["success"] + + # Delete dashboards + await client.send_json( + {"id": 13, "type": "lovelace/dashboards/delete", "dashboard_id": dashboard_id} + ) + response = await client.receive_json() + assert response["success"] + + assert "created_url_path" not in hass.data[frontend.DATA_PANELS] + assert dashboard.CONFIG_STORAGE_KEY.format(dashboard_id) not in hass_storage + + +async def test_websocket_list_dashboards(hass, hass_ws_client): + """Test listing dashboards both storage + YAML.""" + assert await async_setup_component( + hass, + "lovelace", + { + "lovelace": { + "dashboards": { + "test-panel-no-sidebar": {"mode": "yaml", "filename": "bla.yaml"}, + } + } + }, + ) + + client = await hass_ws_client(hass) + + # Create a storage dashboard + await client.send_json( + {"id": 6, "type": "lovelace/dashboards/create", "url_path": "created_url_path"} + ) + response = await client.receive_json() + assert response["success"] + + # List dashboards + await client.send_json({"id": 7, "type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]) == 2 + with_sb, without_sb = response["result"] + + assert with_sb["mode"] == "yaml" + assert with_sb["filename"] == "bla.yaml" + assert with_sb["url_path"] == "test-panel-no-sidebar" + + assert without_sb["mode"] == "storage" + assert without_sb["url_path"] == "created_url_path" diff --git a/tests/helpers/test_collection.py b/tests/helpers/test_collection.py index 1cc600c3f01..f2224858bb5 100644 --- a/tests/helpers/test_collection.py +++ b/tests/helpers/test_collection.py @@ -133,7 +133,11 @@ async def test_yaml_collection(): "mock-3", {"id": "mock-3", "name": "Mock 3"}, ) - assert changes[4] == (collection.CHANGE_REMOVED, "mock-2", None,) + assert changes[4] == ( + collection.CHANGE_REMOVED, + "mock-2", + {"id": "mock-2", "name": "Mock 2"}, + ) async def test_yaml_collection_skipping_duplicate_ids(): @@ -370,4 +374,12 @@ async def test_storage_collection_websocket(hass, hass_ws_client): assert response["success"] assert len(changes) == 3 - assert changes[2] == (collection.CHANGE_REMOVED, "initial_name", None) + assert changes[2] == ( + collection.CHANGE_REMOVED, + "initial_name", + { + "id": "initial_name", + "immutable_string": "no-changes", + "name": "Updated name", + }, + ) From 4a95eee40f430d7482c985b5dbaa4ca3a9390cc8 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Fri, 28 Feb 2020 22:39:46 +0100 Subject: [PATCH 158/416] Fixed TypeError with old server versions (#32329) --- homeassistant/components/minecraft_server/__init__.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index 789e4d8f1b8..a025c44e33c 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -47,7 +47,7 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry) await server.async_update() server.start_periodic_update() - # Set up platform(s). + # Set up platforms. for platform in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(config_entry, platform) @@ -103,7 +103,6 @@ class MinecraftServer: self._mc_status = MCStatus(self.host, self.port) # Data provided by 3rd party library - self.description = None self.version = None self.protocol_version = None self.latency_time = None @@ -168,7 +167,6 @@ class MinecraftServer: ) # Got answer to request, update properties. - self.description = status_response.description["text"] self.version = status_response.version.name self.protocol_version = status_response.version.protocol self.players_online = status_response.players.online @@ -185,7 +183,6 @@ class MinecraftServer: self._last_status_request_failed = False except OSError as error: # No answer to request, set all properties to unknown. - self.description = None self.version = None self.protocol_version = None self.players_online = None From 0670b4f457610c13be405fa20c2ba933904beac6 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Fri, 28 Feb 2020 17:06:39 -0500 Subject: [PATCH 159/416] Use collection helpers for counter integration (#32295) * Refactor counter to use config dict. * Use collection helpers for counter integration. * Update tests. * Use callbacks were applicable. --- homeassistant/components/counter/__init__.py | 220 ++++++++++------ tests/components/counter/test_init.py | 250 ++++++++++++++++++- 2 files changed, 397 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 5580518a9a3..ad5e4000116 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -1,12 +1,24 @@ """Component to count within automations.""" import logging +from typing import Dict, Optional import voluptuous as vol -from homeassistant.const import CONF_ICON, CONF_MAXIMUM, CONF_MINIMUM, CONF_NAME +from homeassistant.const import ( + ATTR_EDITABLE, + CONF_ICON, + CONF_ID, + CONF_MAXIMUM, + CONF_MINIMUM, + CONF_NAME, +) +from homeassistant.core import callback +from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import ConfigType, HomeAssistantType _LOGGER = logging.getLogger(__name__) @@ -31,6 +43,29 @@ SERVICE_INCREMENT = "increment" SERVICE_RESET = "reset" SERVICE_CONFIGURE = "configure" +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + +CREATE_FIELDS = { + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_INITIAL, default=DEFAULT_INITIAL): cv.positive_int, + vol.Required(CONF_NAME): vol.All(cv.string, vol.Length(min=1)), + vol.Optional(CONF_MAXIMUM, default=None): vol.Any(None, vol.Coerce(int)), + vol.Optional(CONF_MINIMUM, default=None): vol.Any(None, vol.Coerce(int)), + vol.Optional(CONF_RESTORE, default=True): cv.boolean, + vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.positive_int, +} + +UPDATE_FIELDS = { + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_INITIAL): cv.positive_int, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MAXIMUM): vol.Any(None, vol.Coerce(int)), + vol.Optional(CONF_MINIMUM): vol.Any(None, vol.Coerce(int)), + vol.Optional(CONF_RESTORE): cv.boolean, + vol.Optional(CONF_STEP): cv.positive_int, +} + def _none_to_empty_dict(value): if value is None: @@ -65,30 +100,38 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Set up the counters.""" component = EntityComponent(_LOGGER, DOMAIN, hass) + id_manager = collection.IDManager() - entities = [] + yaml_collection = collection.YamlCollection( + logging.getLogger(f"{__name__}.yaml_collection"), id_manager + ) + collection.attach_entity_component_collection( + component, yaml_collection, Counter.from_yaml + ) - for object_id, cfg in config[DOMAIN].items(): - if not cfg: - cfg = {} + storage_collection = CounterStorageCollection( + Store(hass, STORAGE_VERSION, STORAGE_KEY), + logging.getLogger(f"{__name__}.storage_collection"), + id_manager, + ) + collection.attach_entity_component_collection( + component, storage_collection, Counter + ) - name = cfg.get(CONF_NAME) - initial = cfg[CONF_INITIAL] - restore = cfg[CONF_RESTORE] - step = cfg[CONF_STEP] - icon = cfg.get(CONF_ICON) - minimum = cfg[CONF_MINIMUM] - maximum = cfg[CONF_MAXIMUM] + await yaml_collection.async_load( + [{CONF_ID: id_, **(conf or {})} for id_, conf in config.get(DOMAIN, {}).items()] + ) + await storage_collection.async_load() - entities.append( - Counter(object_id, name, initial, minimum, maximum, restore, step, icon) - ) + collection.StorageCollectionWebsocket( + storage_collection, DOMAIN, DOMAIN, CREATE_FIELDS, UPDATE_FIELDS + ).async_setup(hass) - if not entities: - return False + collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, yaml_collection) + collection.attach_entity_registry_cleaner(hass, DOMAIN, DOMAIN, storage_collection) component.async_register_entity_service(SERVICE_INCREMENT, {}, "async_increment") component.async_register_entity_service(SERVICE_DECREMENT, {}, "async_decrement") @@ -105,104 +148,137 @@ async def async_setup(hass, config): "async_configure", ) - await component.async_add_entities(entities) return True +class CounterStorageCollection(collection.StorageCollection): + """Input storage based collection.""" + + CREATE_SCHEMA = vol.Schema(CREATE_FIELDS) + UPDATE_SCHEMA = vol.Schema(UPDATE_FIELDS) + + async def _process_create_data(self, data: Dict) -> Dict: + """Validate the config is valid.""" + return self.CREATE_SCHEMA(data) + + @callback + 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: Dict) -> Dict: + """Return a new updated data object.""" + update_data = self.UPDATE_SCHEMA(update_data) + return {**data, **update_data} + + class Counter(RestoreEntity): """Representation of a counter.""" - def __init__(self, object_id, name, initial, minimum, maximum, restore, step, icon): + def __init__(self, config: Dict): """Initialize a counter.""" - self.entity_id = ENTITY_ID_FORMAT.format(object_id) - self._name = name - self._restore = restore - self._step = step - self._state = self._initial = initial - self._min = minimum - self._max = maximum - self._icon = icon + self._config: Dict = config + self._state: Optional[int] = config[CONF_INITIAL] + self.editable: bool = True + + @classmethod + def from_yaml(cls, config: Dict) -> "Counter": + """Create counter instance from yaml config.""" + counter = cls(config) + counter.editable = False + counter.entity_id = ENTITY_ID_FORMAT.format(config[CONF_ID]) + return counter @property - def should_poll(self): + def should_poll(self) -> bool: """If entity should be polled.""" return False @property - def name(self): + def name(self) -> Optional[str]: """Return name of the counter.""" - return self._name + return self._config.get(CONF_NAME) @property - def icon(self): + def icon(self) -> Optional[str]: """Return the icon to be used for this entity.""" - return self._icon + return self._config.get(CONF_ICON) @property - def state(self): + def state(self) -> Optional[int]: """Return the current value of the counter.""" return self._state @property - def state_attributes(self): + def state_attributes(self) -> Dict: """Return the state attributes.""" - ret = {ATTR_INITIAL: self._initial, ATTR_STEP: self._step} - if self._min is not None: - ret[CONF_MINIMUM] = self._min - if self._max is not None: - ret[CONF_MAXIMUM] = self._max + ret = { + ATTR_EDITABLE: self.editable, + ATTR_INITIAL: self._config[CONF_INITIAL], + ATTR_STEP: self._config[CONF_STEP], + } + if self._config[CONF_MINIMUM] is not None: + ret[CONF_MINIMUM] = self._config[CONF_MINIMUM] + if self._config[CONF_MAXIMUM] is not None: + ret[CONF_MAXIMUM] = self._config[CONF_MAXIMUM] return ret - def compute_next_state(self, state): + @property + def unique_id(self) -> Optional[str]: + """Return unique id of the entity.""" + return self._config[CONF_ID] + + def compute_next_state(self, state) -> int: """Keep the state within the range of min/max values.""" - if self._min is not None: - state = max(self._min, state) - if self._max is not None: - state = min(self._max, state) + if self._config[CONF_MINIMUM] is not None: + state = max(self._config[CONF_MINIMUM], state) + if self._config[CONF_MAXIMUM] is not None: + state = min(self._config[CONF_MAXIMUM], state) return state - async def async_added_to_hass(self): + async def async_added_to_hass(self) -> None: """Call when entity about to be added to Home Assistant.""" await super().async_added_to_hass() # __init__ will set self._state to self._initial, only override # if needed. - if self._restore: + if self._config[CONF_RESTORE]: state = await self.async_get_last_state() if state is not None: self._state = self.compute_next_state(int(state.state)) - self._initial = state.attributes.get(ATTR_INITIAL) - self._max = state.attributes.get(ATTR_MAXIMUM) - self._min = state.attributes.get(ATTR_MINIMUM) - self._step = state.attributes.get(ATTR_STEP) + self._config[CONF_INITIAL] = state.attributes.get(ATTR_INITIAL) + self._config[CONF_MAXIMUM] = state.attributes.get(ATTR_MAXIMUM) + self._config[CONF_MINIMUM] = state.attributes.get(ATTR_MINIMUM) + self._config[CONF_STEP] = state.attributes.get(ATTR_STEP) - async def async_decrement(self): + @callback + def async_decrement(self) -> None: """Decrement the counter.""" - self._state = self.compute_next_state(self._state - self._step) - await self.async_update_ha_state() + self._state = self.compute_next_state(self._state - self._config[CONF_STEP]) + self.async_write_ha_state() - async def async_increment(self): + @callback + def async_increment(self) -> None: """Increment a counter.""" - self._state = self.compute_next_state(self._state + self._step) - await self.async_update_ha_state() + self._state = self.compute_next_state(self._state + self._config[CONF_STEP]) + self.async_write_ha_state() - async def async_reset(self): + @callback + def async_reset(self) -> None: """Reset a counter.""" - self._state = self.compute_next_state(self._initial) - await self.async_update_ha_state() + self._state = self.compute_next_state(self._config[CONF_INITIAL]) + self.async_write_ha_state() - async def async_configure(self, **kwargs): + @callback + def async_configure(self, **kwargs) -> None: """Change the counter's settings with a service.""" - if CONF_MINIMUM in kwargs: - self._min = kwargs[CONF_MINIMUM] - if CONF_MAXIMUM in kwargs: - self._max = kwargs[CONF_MAXIMUM] - if CONF_STEP in kwargs: - self._step = kwargs[CONF_STEP] - if CONF_INITIAL in kwargs: - self._initial = kwargs[CONF_INITIAL] - if VALUE in kwargs: - self._state = kwargs[VALUE] + new_state = kwargs.pop(VALUE, self._state) + self._config = {**self._config, **kwargs} + self._state = self.compute_next_state(new_state) + self.async_write_ha_state() + async def async_update_config(self, config: Dict) -> None: + """Change the counter's settings WS CRUD.""" + self._config = config self._state = self.compute_next_state(self._state) - await self.async_update_ha_state() + self.async_write_ha_state() diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index f5ff825e7fb..d6a41af6deb 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -2,8 +2,13 @@ # pylint: disable=protected-access import logging +import pytest + from homeassistant.components.counter import ( + ATTR_EDITABLE, ATTR_INITIAL, + ATTR_MAXIMUM, + ATTR_MINIMUM, ATTR_STEP, CONF_ICON, CONF_INITIAL, @@ -14,8 +19,9 @@ from homeassistant.components.counter import ( DEFAULT_STEP, DOMAIN, ) -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON +from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_NAME from homeassistant.core import Context, CoreState, State +from homeassistant.helpers import entity_registry from homeassistant.setup import async_setup_component from tests.common import mock_restore_cache @@ -28,6 +34,42 @@ from tests.components.counter.common import ( _LOGGER = logging.getLogger(__name__) +@pytest.fixture +def storage_setup(hass, hass_storage): + """Storage setup.""" + + async def _storage(items=None, config=None): + if items is None: + hass_storage[DOMAIN] = { + "key": DOMAIN, + "version": 1, + "data": { + "items": [ + { + "id": "from_storage", + "initial": 10, + "name": "from storage", + "maximum": 100, + "minimum": 3, + "step": 2, + "restore": False, + } + ] + }, + } + else: + hass_storage[DOMAIN] = { + "key": DOMAIN, + "version": 1, + "data": {"items": items}, + } + if config is None: + config = {DOMAIN: {}} + return await async_setup_component(hass, DOMAIN, config) + + return _storage + + async def test_config(hass): """Test config.""" invalid_configs = [None, 1, {}, {"name with space": None}] @@ -452,3 +494,209 @@ async def test_configure(hass, hass_admin_user): assert 0 == state.attributes.get("minimum") assert 9 == state.attributes.get("maximum") assert 6 == state.attributes.get("initial") + + +async def test_load_from_storage(hass, storage_setup): + """Test set up from storage.""" + assert await storage_setup() + state = hass.states.get(f"{DOMAIN}.from_storage") + assert int(state.state) == 10 + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "from storage" + assert state.attributes.get(ATTR_EDITABLE) + + +async def test_editable_state_attribute(hass, storage_setup): + """Test editable attribute.""" + assert await storage_setup( + config={ + DOMAIN: { + "from_yaml": { + "minimum": 1, + "maximum": 10, + "initial": 5, + "step": 1, + "restore": False, + } + } + } + ) + + state = hass.states.get(f"{DOMAIN}.from_storage") + assert int(state.state) == 10 + assert state.attributes[ATTR_FRIENDLY_NAME] == "from storage" + assert state.attributes[ATTR_EDITABLE] is True + + state = hass.states.get(f"{DOMAIN}.from_yaml") + assert int(state.state) == 5 + assert state.attributes[ATTR_EDITABLE] is False + + +async def test_ws_list(hass, hass_ws_client, storage_setup): + """Test listing via WS.""" + assert await storage_setup( + config={ + DOMAIN: { + "from_yaml": { + "minimum": 1, + "maximum": 10, + "initial": 5, + "step": 1, + "restore": False, + } + } + } + ) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 6, "type": f"{DOMAIN}/list"}) + resp = await client.receive_json() + assert resp["success"] + + storage_ent = "from_storage" + yaml_ent = "from_yaml" + result = {item["id"]: item for item in resp["result"]} + + assert len(result) == 1 + assert storage_ent in result + assert yaml_ent not in result + assert result[storage_ent][ATTR_NAME] == "from storage" + + +async def test_ws_delete(hass, hass_ws_client, storage_setup): + """Test WS delete cleans up entity registry.""" + assert await storage_setup() + + input_id = "from_storage" + input_entity_id = f"{DOMAIN}.{input_id}" + ent_reg = await entity_registry.async_get_registry(hass) + + state = hass.states.get(input_entity_id) + assert state is not None + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + + client = await hass_ws_client(hass) + + await client.send_json( + {"id": 6, "type": f"{DOMAIN}/delete", f"{DOMAIN}_id": f"{input_id}"} + ) + resp = await client.receive_json() + assert resp["success"] + + state = hass.states.get(input_entity_id) + assert state is None + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is None + + +async def test_update_min_max(hass, hass_ws_client, storage_setup): + """Test updating min/max updates the state.""" + + items = [ + { + "id": "from_storage", + "initial": 15, + "name": "from storage", + "maximum": 100, + "minimum": 10, + "step": 3, + "restore": True, + } + ] + assert await storage_setup(items) + + input_id = "from_storage" + input_entity_id = f"{DOMAIN}.{input_id}" + ent_reg = await entity_registry.async_get_registry(hass) + + state = hass.states.get(input_entity_id) + assert state is not None + assert int(state.state) == 15 + assert state.attributes[ATTR_MAXIMUM] == 100 + assert state.attributes[ATTR_MINIMUM] == 10 + assert state.attributes[ATTR_STEP] == 3 + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, input_id) is not None + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 6, + "type": f"{DOMAIN}/update", + f"{DOMAIN}_id": f"{input_id}", + "minimum": 19, + } + ) + resp = await client.receive_json() + assert resp["success"] + + state = hass.states.get(input_entity_id) + assert int(state.state) == 19 + assert state.attributes[ATTR_MINIMUM] == 19 + assert state.attributes[ATTR_MAXIMUM] == 100 + assert state.attributes[ATTR_STEP] == 3 + + await client.send_json( + { + "id": 7, + "type": f"{DOMAIN}/update", + f"{DOMAIN}_id": f"{input_id}", + "maximum": 5, + "minimum": 2, + "step": 5, + } + ) + resp = await client.receive_json() + assert resp["success"] + + state = hass.states.get(input_entity_id) + assert int(state.state) == 5 + assert state.attributes[ATTR_MINIMUM] == 2 + assert state.attributes[ATTR_MAXIMUM] == 5 + assert state.attributes[ATTR_STEP] == 5 + + await client.send_json( + { + "id": 8, + "type": f"{DOMAIN}/update", + f"{DOMAIN}_id": f"{input_id}", + "maximum": None, + "minimum": None, + "step": 6, + } + ) + resp = await client.receive_json() + assert resp["success"] + + state = hass.states.get(input_entity_id) + assert int(state.state) == 5 + assert ATTR_MINIMUM not in state.attributes + assert ATTR_MAXIMUM not in state.attributes + assert state.attributes[ATTR_STEP] == 6 + + +async def test_create(hass, hass_ws_client, storage_setup): + """Test creating counter using WS.""" + + items = [] + + assert await storage_setup(items) + + counter_id = "new_counter" + input_entity_id = f"{DOMAIN}.{counter_id}" + ent_reg = await entity_registry.async_get_registry(hass) + + state = hass.states.get(input_entity_id) + assert state is None + assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, counter_id) is None + + client = await hass_ws_client(hass) + + await client.send_json({"id": 6, "type": f"{DOMAIN}/create", "name": "new counter"}) + resp = await client.receive_json() + assert resp["success"] + + state = hass.states.get(input_entity_id) + assert int(state.state) == 0 + assert ATTR_MINIMUM not in state.attributes + assert ATTR_MAXIMUM not in state.attributes + assert state.attributes[ATTR_STEP] == 1 From 5fbaaf41dce1fdbbc124bafec62d6e94dcd198c2 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Fri, 28 Feb 2020 19:13:49 -0500 Subject: [PATCH 160/416] Bump up ZHA depdendency (#32336) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 16c5604587d..786de20f3da 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -8,7 +8,7 @@ "zha-quirks==0.0.33", "zigpy-cc==0.1.0", "zigpy-deconz==0.7.0", - "zigpy-homeassistant==0.13.2", + "zigpy-homeassistant==0.14.0", "zigpy-xbee-homeassistant==0.9.0", "zigpy-zigate==0.5.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index f22d6aaa777..16fbee8b005 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2152,7 +2152,7 @@ zigpy-cc==0.1.0 zigpy-deconz==0.7.0 # homeassistant.components.zha -zigpy-homeassistant==0.13.2 +zigpy-homeassistant==0.14.0 # homeassistant.components.zha zigpy-xbee-homeassistant==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8b034cbc7f5..3b8be5bf18a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -744,7 +744,7 @@ zigpy-cc==0.1.0 zigpy-deconz==0.7.0 # homeassistant.components.zha -zigpy-homeassistant==0.13.2 +zigpy-homeassistant==0.14.0 # homeassistant.components.zha zigpy-xbee-homeassistant==0.9.0 From 2d4ee01c1a1227aae23ea2bc0f4c3acf3eb756e8 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Sat, 29 Feb 2020 00:31:43 +0000 Subject: [PATCH 161/416] [ci skip] Translation update --- .../ambient_station/.translations/ru.json | 3 ++ .../components/august/.translations/ru.json | 32 +++++++++++++++++++ .../components/cover/.translations/en.json | 8 +++++ .../components/cover/.translations/lb.json | 5 +++ .../components/cover/.translations/no.json | 8 +++++ .../components/cover/.translations/ru.json | 6 ++++ .../cover/.translations/zh-Hant.json | 8 +++++ .../konnected/.translations/ru.json | 6 +++- .../components/light/.translations/ru.json | 2 ++ .../components/notion/.translations/ru.json | 3 ++ .../components/sense/.translations/ru.json | 22 +++++++++++++ .../components/vizio/.translations/de.json | 12 +++++++ .../components/vizio/.translations/en.json | 17 ++++++++++ .../components/vizio/.translations/lb.json | 17 ++++++++++ .../components/vizio/.translations/no.json | 17 ++++++++++ .../vizio/.translations/zh-Hant.json | 17 ++++++++++ 16 files changed, 182 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/august/.translations/ru.json create mode 100644 homeassistant/components/sense/.translations/ru.json diff --git a/homeassistant/components/ambient_station/.translations/ru.json b/homeassistant/components/ambient_station/.translations/ru.json index 438b1cf87a7..07f3907eea1 100644 --- a/homeassistant/components/ambient_station/.translations/ru.json +++ b/homeassistant/components/ambient_station/.translations/ru.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e\u0442 \u043a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f." + }, "error": { "identifier_exists": "\u041a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0438/\u0438\u043b\u0438 \u043a\u043b\u044e\u0447 API \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d.", "invalid_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API \u0438/\u0438\u043b\u0438 \u043a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f.", diff --git a/homeassistant/components/august/.translations/ru.json b/homeassistant/components/august/.translations/ru.json new file mode 100644 index 00000000000..fc90b3e8bb5 --- /dev/null +++ b/homeassistant/components/august/.translations/ru.json @@ -0,0 +1,32 @@ +{ + "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." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "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": { + "login_method": "\u0421\u043f\u043e\u0441\u043e\u0431 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "timeout": "\u0422\u0430\u0439\u043c-\u0430\u0443\u0442 (\u0432 \u0441\u0435\u043a\u0443\u043d\u0434\u0430\u0445)", + "username": "\u041b\u043e\u0433\u0438\u043d" + }, + "description": "\u0415\u0441\u043b\u0438 \u0432 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0430 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0432\u044b\u0431\u0440\u0430\u043d\u043e 'email', \u0442\u043e \u043b\u043e\u0433\u0438\u043d\u043e\u043c \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0430\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b. \u0415\u0441\u043b\u0438 \u0432 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u0435 \u0441\u043f\u043e\u0441\u043e\u0431\u0430 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0432\u044b\u0431\u0440\u0430\u043d\u043e 'phone', \u0442\u043e \u043b\u043e\u0433\u0438\u043d\u043e\u043c \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043d\u043e\u043c\u0435\u0440 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430 \u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0435 '+NNNNNNNNN'.", + "title": "August" + }, + "validation": { + "data": { + "code": "\u041a\u043e\u0434 \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0436\u0434\u0435\u043d\u0438\u044f" + }, + "description": "\u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 {login_method} ({username}) \u0438 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043d\u044b\u0439 \u043f\u0440\u043e\u0432\u0435\u0440\u043e\u0447\u043d\u044b\u0439 \u043a\u043e\u0434.", + "title": "\u0414\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f" + } + }, + "title": "August" + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/en.json b/homeassistant/components/cover/.translations/en.json index 27710f79436..e529d6e77d7 100644 --- a/homeassistant/components/cover/.translations/en.json +++ b/homeassistant/components/cover/.translations/en.json @@ -1,5 +1,13 @@ { "device_automation": { + "action_type": { + "close": "Close {entity_name}", + "close_tilt": "Close {entity_name} tilt", + "open": "Open {entity_name}", + "open_tilt": "Open {entity_name} tilt", + "set_position": "Set {entity_name} position", + "set_tilt_position": "Set {entity_name} tilt position" + }, "condition_type": { "is_closed": "{entity_name} is closed", "is_closing": "{entity_name} is closing", diff --git a/homeassistant/components/cover/.translations/lb.json b/homeassistant/components/cover/.translations/lb.json index b2645f3e001..41c29adf91d 100644 --- a/homeassistant/components/cover/.translations/lb.json +++ b/homeassistant/components/cover/.translations/lb.json @@ -1,5 +1,10 @@ { "device_automation": { + "action_type": { + "open": "{entity_name} opmaachen", + "set_position": "{entity_name} positioun programm\u00e9ieren", + "set_tilt_position": "{entity_name} kipp positioun programm\u00e9ieren" + }, "condition_type": { "is_closed": "{entity_name} ass zou", "is_closing": "{entity_name} g\u00ebtt zougemaach", diff --git a/homeassistant/components/cover/.translations/no.json b/homeassistant/components/cover/.translations/no.json index cc045e43624..369d6b30cb8 100644 --- a/homeassistant/components/cover/.translations/no.json +++ b/homeassistant/components/cover/.translations/no.json @@ -1,5 +1,13 @@ { "device_automation": { + "action_type": { + "close": "Lukk {entity_name}", + "close_tilt": "Lukk {entity_name} tilt", + "open": "\u00c5pne {entity_name}", + "open_tilt": "\u00c5pne {entity_name} tilt", + "set_position": "Angi {entity_name} posisjon", + "set_tilt_position": "Angi {entity_name} tilt posisjon" + }, "condition_type": { "is_closed": "{entity_name} er stengt", "is_closing": "{entity_name} stenges", diff --git a/homeassistant/components/cover/.translations/ru.json b/homeassistant/components/cover/.translations/ru.json index ebe81486cf5..97a8a8ba1bb 100644 --- a/homeassistant/components/cover/.translations/ru.json +++ b/homeassistant/components/cover/.translations/ru.json @@ -1,5 +1,11 @@ { "device_automation": { + "action_type": { + "close": "\u0417\u0430\u043a\u0440\u044b\u0442\u044c {entity_name}", + "open": "\u041e\u0442\u043a\u0440\u044b\u0442\u044c {entity_name}", + "set_position": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 {entity_name}", + "set_tilt_position": "\u0423\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c \u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043d\u0430\u043a\u043b\u043e\u043d\u0430 {entity_name}" + }, "condition_type": { "is_closed": "{entity_name} \u0432 \u0437\u0430\u043a\u0440\u044b\u0442\u043e\u043c \u0441\u043e\u0441\u0442\u043e\u044f\u043d\u0438\u0438", "is_closing": "{entity_name} \u0437\u0430\u043a\u0440\u044b\u0432\u0430\u0435\u0442\u0441\u044f", diff --git a/homeassistant/components/cover/.translations/zh-Hant.json b/homeassistant/components/cover/.translations/zh-Hant.json index 790df01d9fc..d91010e974e 100644 --- a/homeassistant/components/cover/.translations/zh-Hant.json +++ b/homeassistant/components/cover/.translations/zh-Hant.json @@ -1,5 +1,13 @@ { "device_automation": { + "action_type": { + "close": "\u95dc\u9589{entity_name}", + "close_tilt": "\u95dc\u9589{entity_name}\u7a97\u7c3e", + "open": "\u958b\u555f{entity_name}", + "open_tilt": "\u958b\u555f{entity_name}\u7a97\u7c3e", + "set_position": "\u8a2d\u5b9a{entity_name}\u4f4d\u7f6e", + "set_tilt_position": "\u8a2d\u5b9a{entity_name}\u5e8a\u7c3e\u4f4d\u7f6e" + }, "condition_type": { "is_closed": "{entity_name}\u5df2\u95dc\u9589", "is_closing": "{entity_name}\u6b63\u5728\u95dc\u9589", diff --git a/homeassistant/components/konnected/.translations/ru.json b/homeassistant/components/konnected/.translations/ru.json index 25dec57169c..ba1b3c6abc9 100644 --- a/homeassistant/components/konnected/.translations/ru.json +++ b/homeassistant/components/konnected/.translations/ru.json @@ -11,9 +11,13 @@ }, "step": { "confirm": { - "description": "\u041c\u043e\u0434\u0435\u043b\u044c: {model}\n\u0425\u043e\u0441\u0442: {host}\n\u041f\u043e\u0440\u0442: {port}\n\n\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435 \u043b\u043e\u0433\u0438\u043a\u0438 \u0440\u0430\u0431\u043e\u0442\u044b \u043f\u0430\u043d\u0435\u043b\u0438, \u0430 \u0442\u0430\u043a\u0436\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0432\u0445\u043e\u0434\u043e\u0432 \u0438 \u0432\u044b\u0445\u043e\u0434\u043e\u0432 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f \u0432 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u043f\u0430\u043d\u0435\u043b\u0438 \u0441\u0438\u0433\u043d\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 Konnected.", + "description": "\u041c\u043e\u0434\u0435\u043b\u044c: {model}\nID: {id}\n\u0425\u043e\u0441\u0442: {host}\n\u041f\u043e\u0440\u0442: {port}\n\n\u0418\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u0435 \u043b\u043e\u0433\u0438\u043a\u0438 \u0440\u0430\u0431\u043e\u0442\u044b \u043f\u0430\u043d\u0435\u043b\u0438, \u0430 \u0442\u0430\u043a\u0436\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0432\u0445\u043e\u0434\u043e\u0432 \u0438 \u0432\u044b\u0445\u043e\u0434\u043e\u0432 \u0432\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f \u0432 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u0445 \u043f\u0430\u043d\u0435\u043b\u0438 \u0441\u0438\u0433\u043d\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 Konnected.", "title": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Konnected \u0433\u043e\u0442\u043e\u0432\u043e \u043a \u0440\u0430\u0431\u043e\u0442\u0435." }, + "import_confirm": { + "description": "\u041f\u0430\u043d\u0435\u043b\u044c \u0441\u0438\u0433\u043d\u0430\u043b\u0438\u0437\u0430\u0446\u0438\u0438 Konnected ID {id} \u0440\u0430\u043d\u0435\u0435 \u0443\u0436\u0435 \u0431\u044b\u043b\u0430 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430 \u0447\u0435\u0440\u0435\u0437 configuration.yaml. \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0438\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u044d\u0442\u0443 \u0437\u0430\u043f\u0438\u0441\u044c \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u0434\u0430\u043d\u043d\u043e\u0433\u043e \u043c\u0430\u0441\u0442\u0435\u0440\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438.", + "title": "\u0418\u043c\u043f\u043e\u0440\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Konnected" + }, "user": { "data": { "host": "\u0414\u043e\u043c\u0435\u043d\u043d\u043e\u0435 \u0438\u043c\u044f \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441", diff --git a/homeassistant/components/light/.translations/ru.json b/homeassistant/components/light/.translations/ru.json index 8ca964606ae..d6c3d037531 100644 --- a/homeassistant/components/light/.translations/ru.json +++ b/homeassistant/components/light/.translations/ru.json @@ -1,6 +1,8 @@ { "device_automation": { "action_type": { + "brightness_decrease": "\u0423\u043c\u0435\u043d\u044c\u0448\u0438\u0442\u044c \u044f\u0440\u043a\u043e\u0441\u0442\u044c {entity_name}", + "brightness_increase": "\u0423\u0432\u0435\u043b\u0438\u0447\u0438\u0442\u044c \u044f\u0440\u043a\u043e\u0441\u0442\u044c {entity_name}", "toggle": "\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}", "turn_off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}", "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}" diff --git a/homeassistant/components/notion/.translations/ru.json b/homeassistant/components/notion/.translations/ru.json index 15b540732a7..6e64ebbe7aa 100644 --- a/homeassistant/components/notion/.translations/ru.json +++ b/homeassistant/components/notion/.translations/ru.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "\u042d\u0442\u0430 \u0443\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f." + }, "error": { "identifier_exists": "\u0423\u0447\u0451\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430.", "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u044c.", diff --git a/homeassistant/components/sense/.translations/ru.json b/homeassistant/components/sense/.translations/ru.json new file mode 100644 index 00000000000..6a609a05f6d --- /dev/null +++ b/homeassistant/components/sense/.translations/ru.json @@ -0,0 +1,22 @@ +{ + "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." + }, + "error": { + "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "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": { + "email": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b", + "password": "\u041f\u0430\u0440\u043e\u043b\u044c" + }, + "title": "Sense Energy Monitor" + } + }, + "title": "Sense" + } +} \ No newline at end of file diff --git a/homeassistant/components/vizio/.translations/de.json b/homeassistant/components/vizio/.translations/de.json index ead4ed4828b..6162a27805e 100644 --- a/homeassistant/components/vizio/.translations/de.json +++ b/homeassistant/components/vizio/.translations/de.json @@ -15,6 +15,18 @@ "tv_needs_token": "Wenn der Ger\u00e4tetyp \"TV\" ist, wird ein g\u00fcltiger Zugriffstoken ben\u00f6tigt." }, "step": { + "pair_tv": { + "data": { + "pin": "PIN" + }, + "title": "Schlie\u00dfen Sie den Pairing-Prozess ab" + }, + "pairing_complete": { + "title": "Kopplung abgeschlossen" + }, + "pairing_complete_import": { + "title": "Kopplung abgeschlossen" + }, "user": { "data": { "access_token": "Zugangstoken", diff --git a/homeassistant/components/vizio/.translations/en.json b/homeassistant/components/vizio/.translations/en.json index cee436c9647..294025fddc8 100644 --- a/homeassistant/components/vizio/.translations/en.json +++ b/homeassistant/components/vizio/.translations/en.json @@ -12,11 +12,27 @@ }, "error": { "cant_connect": "Could not connect to the device. [Review the docs](https://www.home-assistant.io/integrations/vizio/) and re-verify that:\n- The device is powered on\n- The device is connected to the network\n- The values you filled in are accurate\nbefore attempting to resubmit.", + "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.", "host_exists": "Vizio device with specified host already configured.", "name_exists": "Vizio device with specified name already configured.", "tv_needs_token": "When Device Type is `tv` then a valid Access Token is needed." }, "step": { + "pair_tv": { + "data": { + "pin": "PIN" + }, + "description": "Your TV should be displaying a code. Enter that code into the form and then continue to the next step to complete the pairing.", + "title": "Complete Pairing Process" + }, + "pairing_complete": { + "description": "Your Vizio SmartCast device is now connected to Home Assistant.", + "title": "Pairing Complete" + }, + "pairing_complete_import": { + "description": "Your Vizio SmartCast device is now connected to Home Assistant.\n\nYour Access Token is '**{access_token}**'.", + "title": "Pairing Complete" + }, "user": { "data": { "access_token": "Access Token", @@ -24,6 +40,7 @@ "host": ":", "name": "Name" }, + "description": "All fields are required except Access Token. If you choose not to provide an Access Token, and your Device Type is 'tv', you will go through a pairing process with your device so an Access Token can be retrieved.\n\nTo go through the pairing process, before clicking Submit, ensure your TV is powered on and connected to the network. You also need to be able to see the screen.", "title": "Setup Vizio SmartCast Device" } }, diff --git a/homeassistant/components/vizio/.translations/lb.json b/homeassistant/components/vizio/.translations/lb.json index 809ae6d4eb5..11df333ce4b 100644 --- a/homeassistant/components/vizio/.translations/lb.json +++ b/homeassistant/components/vizio/.translations/lb.json @@ -12,11 +12,27 @@ }, "error": { "cant_connect": "Konnt sech net mam Apparat verbannen. [Iwwerpr\u00e9ift Dokumentatioun] (https://www.home-assistant.io/integrations/vizio/) a stellt s\u00e9cher dass:\n- Den Apparat ass un\n- Den Apparat ass mam Netzwierk verbonnen\n- D'Optiounen d\u00e9i dir aginn hutt si korrekt\nier dir d'Verbindung nees prob\u00e9iert", + "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.", "host_exists": "Vizio Apparat mat d\u00ebsem Host ass scho konfigur\u00e9iert.", "name_exists": "Vizio Apparat mat d\u00ebsen Numm ass scho konfigur\u00e9iert.", "tv_needs_token": "Wann den Typ vum Apparat `tv`ass da g\u00ebtt ee g\u00ebltegen Acc\u00e8s Jeton ben\u00e9idegt." }, "step": { + "pair_tv": { + "data": { + "pin": "PIN" + }, + "description": "Um TV sollt e Code ugewisen ginn. G\u00ebff d\u00ebse Code an d'Form a fuer weider mam n\u00e4chste Schr\u00ebtt fir d'Kopplung ofzeschl\u00e9issen.", + "title": "Kopplungs Prozess ofschl\u00e9issen" + }, + "pairing_complete": { + "description": "D\u00e4in Visio SmartCast Apparat ass elo mam Home Assistant verbonnen.", + "title": "Kopplung ofgeschloss" + }, + "pairing_complete_import": { + "description": "D\u00e4in Visio SmartCast Apparat ass elo mam Home Assistant verbonnen.\n\nD\u00e4in Acc\u00e8s Jeton ass '**{access_token}**'.", + "title": "Kopplung ofgeschloss" + }, "user": { "data": { "access_token": "Acc\u00e8ss Jeton", @@ -24,6 +40,7 @@ "host": ":", "name": "Numm" }, + "description": "All Felder sinn noutwendeg ausser Acc\u00e8s Jeton. Wann keen Acc\u00e8s Jeton uginn ass, an den Typ vun Apparat ass 'TV', da g\u00ebtt e Kopplungs Prozess mam Apparat gestart fir een Acc\u00e8s Jeton z'erstellen.\n\nFir de Kopplung Prozess ofzesch\u00e9issen,ier op \"ofsch\u00e9cken\" klickt, pr\u00e9ift datt de Fernsee ugeschalt a mam Netzwierk verbonnen ass. Du muss och k\u00ebnnen op de Bildschierm gesinn.", "title": "Vizo Smartcast ariichten" } }, diff --git a/homeassistant/components/vizio/.translations/no.json b/homeassistant/components/vizio/.translations/no.json index 0b92497a5e7..dababdd53f2 100644 --- a/homeassistant/components/vizio/.translations/no.json +++ b/homeassistant/components/vizio/.translations/no.json @@ -12,11 +12,27 @@ }, "error": { "cant_connect": "Kunne ikke koble til enheten. [Se gjennom dokumentene] (https://www.home-assistant.io/integrations/vizio/) og bekreft at: \n - Enheten er sl\u00e5tt p\u00e5 \n - Enheten er koblet til nettverket \n - Verdiene du fylte ut er n\u00f8yaktige \n f\u00f8r du pr\u00f8ver \u00e5 sende inn p\u00e5 nytt.", + "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.", "host_exists": "Vizio-enhet med spesifisert vert allerede konfigurert.", "name_exists": "Vizio-enhet med spesifisert navn allerede konfigurert.", "tv_needs_token": "N\u00e5r enhetstype er `tv`, er det n\u00f8dvendig med en gyldig tilgangstoken." }, "step": { + "pair_tv": { + "data": { + "pin": "PIN" + }, + "description": "TVen skal vise en kode. Skriv inn denne koden i skjemaet, og fortsett deretter til neste trinn for \u00e5 fullf\u00f8re paringen.", + "title": "Fullf\u00f8r Sammenkoblings Prosessen" + }, + "pairing_complete": { + "description": "Din Vizio SmartCast-enheten er n\u00e5 koblet til Home Assistant.", + "title": "Sammenkoblingen Er Fullf\u00f8rt" + }, + "pairing_complete_import": { + "description": "Vizio SmartCast-enheten er n\u00e5 koblet til Home Assistant.\n\nTilgangstokenet er **{access_token}**.", + "title": "Sammenkoblingen Er Fullf\u00f8rt" + }, "user": { "data": { "access_token": "Tilgangstoken", @@ -24,6 +40,7 @@ "host": ":", "name": "Navn" }, + "description": "Alle felt er obligatoriske unntatt Access Token. Hvis du velger \u00e5 ikke oppgi et Access-token, og enhetstypen din er \u00abtv\u00bb, g\u00e5r du gjennom en sammenkoblingsprosess med enheten slik at et Tilgangstoken kan hentes.\n\nHvis du vil g\u00e5 gjennom paringsprosessen, m\u00e5 du kontrollere at TV-en er sl\u00e5tt p\u00e5 og koblet til nettverket f\u00f8r du klikker p\u00e5 Send. Du m\u00e5 ogs\u00e5 kunne se skjermen.", "title": "Sett opp Vizio SmartCast-enhet" } }, diff --git a/homeassistant/components/vizio/.translations/zh-Hant.json b/homeassistant/components/vizio/.translations/zh-Hant.json index 24128bb1b9e..d2404a80620 100644 --- a/homeassistant/components/vizio/.translations/zh-Hant.json +++ b/homeassistant/components/vizio/.translations/zh-Hant.json @@ -12,11 +12,27 @@ }, "error": { "cant_connect": "\u7121\u6cd5\u9023\u7dda\u81f3\u8a2d\u5099\u3002[\u8acb\u53c3\u8003\u8aaa\u660e\u6587\u4ef6](https://www.home-assistant.io/integrations/vizio/) \u4e26\u78ba\u8a8d\u4ee5\u4e0b\u9805\u76ee\uff1a\n- \u8a2d\u5099\u5df2\u958b\u6a5f\n- \u8a2d\u5099\u5df2\u9023\u7dda\u81f3\u7db2\u8def\n- \u586b\u5beb\u8cc7\u6599\u6b63\u78ba\n\u7136\u5f8c\u518d\u91cd\u65b0\u50b3\u9001\u3002", + "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", "host_exists": "\u4f9d\u4e3b\u6a5f\u7aef\u4e4b Vizio \u5143\u4ef6\u8a2d\u5b9a\u5df2\u8a2d\u5b9a\u5b8c\u6210\u3002", "name_exists": "\u4f9d\u540d\u7a31\u4e4b Vizio \u5143\u4ef6\u8a2d\u5b9a\u5df2\u8a2d\u5b9a\u5b8c\u6210\u3002", "tv_needs_token": "\u7576\u8a2d\u5099\u985e\u5225\u70ba\u300cTV\u300d\u6642\uff0c\u9700\u8981\u5b58\u53d6\u5bc6\u9470\u3002" }, "step": { + "pair_tv": { + "data": { + "pin": "PIN" + }, + "description": "\u96fb\u8996\u4e0a\u61c9\u8a72\u6703\u986f\u793a\u4e00\u7d44\u4ee3\u78bc\u3002\u65bc\u8868\u683c\u4e2d\u8f38\u5165\u4ee3\u78bc\uff0c\u7136\u5f8c\u7e7c\u7e8c\u4e0b\u4e00\u6b65\u4ee5\u5b8c\u6210\u914d\u5c0d\u3002", + "title": "\u5b8c\u6210\u914d\u5c0d\u904e\u7a0b" + }, + "pairing_complete": { + "description": "Vizio SmartCast \u8a2d\u5099\u5df2\u7d93\u9023\u7dda\u81f3 Home Assistant\u3002", + "title": "\u914d\u5c0d\u5b8c\u6210" + }, + "pairing_complete_import": { + "description": "Vizio SmartCast \u8a2d\u5099\u5df2\u9023\u7dda\u81f3 Home Assistant\u3002\n\n\u5b58\u53d6\u5bc6\u9470\u70ba\u300c**{access_token}**\u300d\u3002", + "title": "\u914d\u5c0d\u5b8c\u6210" + }, "user": { "data": { "access_token": "\u5b58\u53d6\u5bc6\u9470", @@ -24,6 +40,7 @@ "host": "<\u4e3b\u6a5f\u7aef/IP>:", "name": "\u540d\u7a31" }, + "description": "\u9664\u4e86\u5b58\u53d6\u5bc6\u9470\u5916\u3001\u8207\u8a2d\u5099\u985e\u5225\u70ba\u300cTV\u300d\u5916\u3001\u6240\u6709\u6b04\u4f4d\u90fd\u70ba\u5fc5\u586b\u3002\u5c07\u6703\u4ee5\u8a2d\u5099\u9032\u884c\u914d\u5c0d\u904e\u7a0b\uff0c\u56e0\u6b64\u5b58\u53d6\u5bc6\u9470\u53ef\u4ee5\u6536\u56de\u3002\n\n\u6b32\u5b8c\u6210\u914d\u5c0d\u904e\u7a0b\uff0c\u50b3\u9001\u524d\u3001\u8acb\u5148\u78ba\u5b9a\u96fb\u8996\u5df2\u7d93\u958b\u6a5f\u3001\u4e26\u9023\u7dda\u81f3\u7db2\u8def\u3002\u540c\u6642\u3001\u4f60\u4e5f\u5fc5\u9808\u80fd\u770b\u5230\u96fb\u8996\u756b\u9762\u3002", "title": "\u8a2d\u5b9a Vizio SmartCast \u8a2d\u5099" } }, From 3ea29635a0dd9a0a2e6806f6ec6cec2762a8c4a7 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sat, 29 Feb 2020 01:55:27 +0100 Subject: [PATCH 162/416] Updated frontend to 20200228.0 (#32334) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index b957ef13895..8fc45b3320a 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200220.5" + "home-assistant-frontend==20200228.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f5eeba0a3c4..73696282dfd 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.31 -home-assistant-frontend==20200220.5 +home-assistant-frontend==20200228.0 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 16fbee8b005..ffb0d5e45bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -690,7 +690,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200220.5 +home-assistant-frontend==20200228.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3b8be5bf18a..6e03c104c1c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -257,7 +257,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200220.5 +home-assistant-frontend==20200228.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 From bf33144c2bb9402c5be391d3dac7dc762383f153 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sat, 29 Feb 2020 01:37:34 +0000 Subject: [PATCH 163/416] Bump pyipma to 2.0.5 (#32337) * bump pyipma version to 2.0.5 * bump pyipma version to 2.0.5 --- homeassistant/components/ipma/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json index 1457ac24195..63c041f28c3 100644 --- a/homeassistant/components/ipma/manifest.json +++ b/homeassistant/components/ipma/manifest.json @@ -3,7 +3,7 @@ "name": "Instituto Português do Mar e Atmosfera (IPMA)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ipma", - "requirements": ["pyipma==2.0.4"], + "requirements": ["pyipma==2.0.5"], "dependencies": [], "codeowners": ["@dgomes", "@abmantis"] } diff --git a/requirements_all.txt b/requirements_all.txt index ffb0d5e45bb..fbe0cee42f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1312,7 +1312,7 @@ pyicloud==0.9.2 pyintesishome==1.6 # homeassistant.components.ipma -pyipma==2.0.4 +pyipma==2.0.5 # homeassistant.components.iqvia pyiqvia==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6e03c104c1c..50d27d9fdbb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -477,7 +477,7 @@ pyhomematic==0.1.65 pyicloud==0.9.2 # homeassistant.components.ipma -pyipma==2.0.4 +pyipma==2.0.5 # homeassistant.components.iqvia pyiqvia==0.2.1 From e9a7b66df67426f00529d76cafedc6300dc03ee3 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 28 Feb 2020 20:14:17 -0700 Subject: [PATCH 164/416] Add config entry for AirVisual (#32072) * Add config entry for AirVisual * Update coverage * Catch invalid API key from config schema * Rename geographies to stations * Revert "Rename geographies to stations" This reverts commit 5477f89c24cb3f58965351985b1021fc5fc794a5. * Update strings * Update CONNECTION_CLASS * Remove options (subsequent PR) * Handle import step separately * Code review comments and simplification * Move default geography logic to config flow * Register domain in config flow init * Add tests * Update strings * Bump requirements * Update homeassistant/components/airvisual/config_flow.py * Update homeassistant/components/airvisual/config_flow.py * Make schemas stricter * Linting * Linting * Code review comments * Put config flow unique ID logic into a method * Fix tests * Streamline * Linting * show_on_map in options with default value * Code review comments * Default options * Update tests * Test update * Move config entry into data object (in prep for options flow) * Empty commit to re-trigger build --- .coveragerc | 1 + .../airvisual/.translations/en.json | 23 ++ .../components/airvisual/__init__.py | 200 +++++++++++++++ .../components/airvisual/config_flow.py | 92 +++++++ homeassistant/components/airvisual/const.py | 14 ++ .../components/airvisual/manifest.json | 1 + homeassistant/components/airvisual/sensor.py | 228 +++++++----------- .../components/airvisual/strings.json | 23 ++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/airvisual/__init__.py | 1 + .../components/airvisual/test_config_flow.py | 87 +++++++ 12 files changed, 527 insertions(+), 147 deletions(-) create mode 100644 homeassistant/components/airvisual/.translations/en.json create mode 100644 homeassistant/components/airvisual/config_flow.py create mode 100644 homeassistant/components/airvisual/const.py create mode 100644 homeassistant/components/airvisual/strings.json create mode 100644 tests/components/airvisual/__init__.py create mode 100644 tests/components/airvisual/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 94b7deea82b..44ca6709eca 100644 --- a/.coveragerc +++ b/.coveragerc @@ -29,6 +29,7 @@ omit = homeassistant/components/airly/air_quality.py homeassistant/components/airly/sensor.py homeassistant/components/airly/const.py + homeassistant/components/airvisual/__init__.py homeassistant/components/airvisual/sensor.py homeassistant/components/aladdin_connect/cover.py homeassistant/components/alarmdecoder/* diff --git a/homeassistant/components/airvisual/.translations/en.json b/homeassistant/components/airvisual/.translations/en.json new file mode 100644 index 00000000000..844174220b1 --- /dev/null +++ b/homeassistant/components/airvisual/.translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "This API key is already in use." + }, + "error": { + "invalid_api_key": "Invalid API key" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "latitude": "Latitude", + "longitude": "Longitude", + "show_on_map": "Show monitored geography on the map" + }, + "description": "Monitor air quality in a geographical location.", + "title": "Configure AirVisual" + } + }, + "title": "AirVisual" + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index b1f79d17241..24d8257d041 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -1 +1,201 @@ """The airvisual component.""" +import asyncio +import logging + +from pyairvisual import Client +from pyairvisual.errors import AirVisualError, InvalidKeyError +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_SHOW_ON_MAP, + CONF_STATE, +) +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +from .const import ( + CONF_CITY, + CONF_COUNTRY, + CONF_GEOGRAPHIES, + DATA_CLIENT, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + TOPIC_UPDATE, +) + +_LOGGER = logging.getLogger(__name__) + +DATA_LISTENER = "listener" + +DEFAULT_OPTIONS = {CONF_SHOW_ON_MAP: True} + +CONF_NODE_ID = "node_id" + +GEOGRAPHY_COORDINATES_SCHEMA = vol.Schema( + { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + } +) + +GEOGRAPHY_PLACE_SCHEMA = vol.Schema( + { + vol.Required(CONF_CITY): cv.string, + vol.Required(CONF_STATE): cv.string, + vol.Required(CONF_COUNTRY): cv.string, + } +) + +CLOUD_API_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_GEOGRAPHIES, default=[]): vol.All( + cv.ensure_list, + [vol.Any(GEOGRAPHY_COORDINATES_SCHEMA, GEOGRAPHY_PLACE_SCHEMA)], + ), + } +) + +CONFIG_SCHEMA = vol.Schema({DOMAIN: CLOUD_API_SCHEMA}, extra=vol.ALLOW_EXTRA) + + +@callback +def async_get_geography_id(geography_dict): + """Generate a unique ID from a geography dict.""" + if CONF_CITY in geography_dict: + return ",".join( + ( + geography_dict[CONF_CITY], + geography_dict[CONF_STATE], + geography_dict[CONF_COUNTRY], + ) + ) + return ",".join( + (str(geography_dict[CONF_LATITUDE]), str(geography_dict[CONF_LONGITUDE])) + ) + + +async def async_setup(hass, config): + """Set up the AirVisual component.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CLIENT] = {} + hass.data[DOMAIN][DATA_LISTENER] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up AirVisual as config entry.""" + entry_updates = {} + if not config_entry.unique_id: + # If the config entry doesn't already have a unique ID, set one: + entry_updates["unique_id"] = config_entry.data[CONF_API_KEY] + if not config_entry.options: + # If the config entry doesn't already have any options set, set defaults: + entry_updates["options"] = DEFAULT_OPTIONS + + if entry_updates: + hass.config_entries.async_update_entry(config_entry, **entry_updates) + + websession = aiohttp_client.async_get_clientsession(hass) + + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = AirVisualData( + hass, Client(websession, api_key=config_entry.data[CONF_API_KEY]), config_entry + ) + + try: + await hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id].async_update() + except InvalidKeyError: + _LOGGER.error("Invalid API key provided") + raise ConfigEntryNotReady + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, "sensor") + ) + + async def refresh(event_time): + """Refresh data from AirVisual.""" + await hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id].async_update() + + hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = async_track_time_interval( + hass, refresh, DEFAULT_SCAN_INTERVAL + ) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an AirVisual config entry.""" + hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) + + remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(config_entry.entry_id) + remove_listener() + + await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") + + return True + + +class AirVisualData: + """Define a class to manage data from the AirVisual cloud API.""" + + def __init__(self, hass, client, config_entry): + """Initialize.""" + self._client = client + self._hass = hass + self.data = {} + self.show_on_map = config_entry.options[CONF_SHOW_ON_MAP] + + self.geographies = { + async_get_geography_id(geography): geography + for geography in config_entry.data[CONF_GEOGRAPHIES] + } + + async def async_update(self): + """Get new data for all locations from the AirVisual cloud API.""" + tasks = [] + + for geography in self.geographies.values(): + if CONF_CITY in geography: + tasks.append( + self._client.api.city( + geography[CONF_CITY], + geography[CONF_STATE], + geography[CONF_COUNTRY], + ) + ) + else: + tasks.append( + self._client.api.nearest_city( + geography[CONF_LATITUDE], geography[CONF_LONGITUDE], + ) + ) + + results = await asyncio.gather(*tasks, return_exceptions=True) + for geography_id, result in zip(self.geographies, results): + if isinstance(result, AirVisualError): + _LOGGER.error("Error while retrieving data: %s", result) + self.data[geography_id] = {} + continue + self.data[geography_id] = result + + _LOGGER.debug("Received new data") + async_dispatcher_send(self._hass, TOPIC_UPDATE) diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py new file mode 100644 index 00000000000..4dd7fb80de8 --- /dev/null +++ b/homeassistant/components/airvisual/config_flow.py @@ -0,0 +1,92 @@ +"""Define a config flow manager for AirVisual.""" +from pyairvisual import Client +from pyairvisual.errors import InvalidKeyError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv + +from .const import CONF_GEOGRAPHIES, DOMAIN # pylint: disable=unused-import + + +class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a AirVisual config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + @property + def cloud_api_schema(self): + """Return the data schema for the cloud API.""" + return vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + } + ) + + async def _async_set_unique_id(self, unique_id): + """Set the unique ID of the config flow and abort if it already exists.""" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + @callback + async def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id="user", data_schema=self.cloud_api_schema, errors=errors or {}, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + await self._async_set_unique_id(import_config[CONF_API_KEY]) + + data = {**import_config} + if not data.get(CONF_GEOGRAPHIES): + data[CONF_GEOGRAPHIES] = [ + { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } + ] + + return self.async_create_entry( + title=f"Cloud API (API key: {import_config[CONF_API_KEY][:4]}...)", + data=data, + ) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + if not user_input: + return await self._show_form() + + await self._async_set_unique_id(user_input[CONF_API_KEY]) + + websession = aiohttp_client.async_get_clientsession(self.hass) + + client = Client(websession, api_key=user_input[CONF_API_KEY]) + + try: + await client.api.nearest_city() + except InvalidKeyError: + return await self._show_form(errors={CONF_API_KEY: "invalid_api_key"}) + + return self.async_create_entry( + title=f"Cloud API (API key: {user_input[CONF_API_KEY][:4]}...)", + data={ + CONF_API_KEY: user_input[CONF_API_KEY], + CONF_GEOGRAPHIES: [ + { + CONF_LATITUDE: user_input[CONF_LATITUDE], + CONF_LONGITUDE: user_input[CONF_LONGITUDE], + } + ], + }, + ) diff --git a/homeassistant/components/airvisual/const.py b/homeassistant/components/airvisual/const.py new file mode 100644 index 00000000000..ab54e191116 --- /dev/null +++ b/homeassistant/components/airvisual/const.py @@ -0,0 +1,14 @@ +"""Define AirVisual constants.""" +from datetime import timedelta + +DOMAIN = "airvisual" + +CONF_CITY = "city" +CONF_COUNTRY = "country" +CONF_GEOGRAPHIES = "geographies" + +DATA_CLIENT = "client" + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) + +TOPIC_UPDATE = f"{DOMAIN}_update" diff --git a/homeassistant/components/airvisual/manifest.json b/homeassistant/components/airvisual/manifest.json index a689ee6acf0..756fb56acc1 100644 --- a/homeassistant/components/airvisual/manifest.json +++ b/homeassistant/components/airvisual/manifest.json @@ -1,6 +1,7 @@ { "domain": "airvisual", "name": "AirVisual", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airvisual", "requirements": ["pyairvisual==3.0.1"], "dependencies": [], diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index a7bf3f4dd1b..a25114b7f02 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -1,30 +1,23 @@ """Support for AirVisual air quality sensors.""" -from datetime import timedelta from logging import getLogger -from pyairvisual import Client -from pyairvisual.errors import AirVisualError -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, + ATTR_STATE, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, - CONF_MONITORED_CONDITIONS, - CONF_SCAN_INTERVAL, - CONF_SHOW_ON_MAP, CONF_STATE, ) -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle + +from .const import CONF_CITY, CONF_COUNTRY, DATA_CLIENT, DOMAIN, TOPIC_UPDATE _LOGGER = getLogger(__name__) @@ -34,19 +27,19 @@ ATTR_POLLUTANT_SYMBOL = "pollutant_symbol" ATTR_POLLUTANT_UNIT = "pollutant_unit" ATTR_REGION = "region" -CONF_CITY = "city" -CONF_COUNTRY = "country" - DEFAULT_ATTRIBUTION = "Data provided by AirVisual" -DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) -SENSOR_TYPE_LEVEL = "air_pollution_level" -SENSOR_TYPE_AQI = "air_quality_index" -SENSOR_TYPE_POLLUTANT = "main_pollutant" +MASS_PARTS_PER_MILLION = "ppm" +MASS_PARTS_PER_BILLION = "ppb" +VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m3" + +SENSOR_KIND_LEVEL = "air_pollution_level" +SENSOR_KIND_AQI = "air_quality_index" +SENSOR_KIND_POLLUTANT = "main_pollutant" SENSORS = [ - (SENSOR_TYPE_LEVEL, "Air Pollution Level", "mdi:gauge", None), - (SENSOR_TYPE_AQI, "Air Quality Index", "mdi:chart-line", "AQI"), - (SENSOR_TYPE_POLLUTANT, "Main Pollutant", "mdi:chemical-weapon", None), + (SENSOR_KIND_LEVEL, "Air Pollution Level", "mdi:gauge", None), + (SENSOR_KIND_AQI, "Air Quality Index", "mdi:chart-line", "AQI"), + (SENSOR_KIND_POLLUTANT, "Main Pollutant", "mdi:chemical-weapon", None), ] POLLUTANT_LEVEL_MAPPING = [ @@ -79,102 +72,67 @@ POLLUTANT_MAPPING = { SENSOR_LOCALES = {"cn": "Chinese", "us": "U.S."} -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_LOCALES)): vol.All( - cv.ensure_list, [vol.In(SENSOR_LOCALES)] - ), - vol.Inclusive(CONF_CITY, "city"): cv.string, - vol.Inclusive(CONF_COUNTRY, "city"): cv.string, - vol.Inclusive(CONF_LATITUDE, "coords"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "coords"): cv.longitude, - vol.Optional(CONF_SHOW_ON_MAP, default=True): cv.boolean, - vol.Inclusive(CONF_STATE, "city"): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period, - } -) +async def async_setup_entry(hass, entry, async_add_entities): + """Set up AirVisual sensors based on a config entry.""" + airvisual = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Configure the platform and add the sensors.""" - - city = config.get(CONF_CITY) - state = config.get(CONF_STATE) - country = config.get(CONF_COUNTRY) - - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - - websession = aiohttp_client.async_get_clientsession(hass) - - if city and state and country: - _LOGGER.debug( - "Using city, state, and country: %s, %s, %s", city, state, country - ) - location_id = ",".join((city, state, country)) - data = AirVisualData( - Client(websession, api_key=config[CONF_API_KEY]), - city=city, - state=state, - country=country, - show_on_map=config[CONF_SHOW_ON_MAP], - scan_interval=config[CONF_SCAN_INTERVAL], - ) - else: - _LOGGER.debug("Using latitude and longitude: %s, %s", latitude, longitude) - location_id = ",".join((str(latitude), str(longitude))) - data = AirVisualData( - Client(websession, api_key=config[CONF_API_KEY]), - latitude=latitude, - longitude=longitude, - show_on_map=config[CONF_SHOW_ON_MAP], - scan_interval=config[CONF_SCAN_INTERVAL], - ) - - await data.async_update() - - sensors = [] - for locale in config[CONF_MONITORED_CONDITIONS]: - for kind, name, icon, unit in SENSORS: - sensors.append( - AirVisualSensor(data, kind, name, icon, unit, locale, location_id) - ) - - async_add_entities(sensors, True) + async_add_entities( + [ + AirVisualSensor(airvisual, kind, name, icon, unit, locale, geography_id) + for geography_id in airvisual.data + for locale in SENSOR_LOCALES + for kind, name, icon, unit in SENSORS + ], + True, + ) class AirVisualSensor(Entity): """Define an AirVisual sensor.""" - def __init__(self, airvisual, kind, name, icon, unit, locale, location_id): + def __init__(self, airvisual, kind, name, icon, unit, locale, geography_id): """Initialize.""" - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._airvisual = airvisual + self._async_unsub_dispatcher_connects = [] + self._geography_id = geography_id self._icon = icon + self._kind = kind self._locale = locale - self._location_id = location_id self._name = name self._state = None - self._type = kind self._unit = unit - self.airvisual = airvisual - @property - def device_state_attributes(self): - """Return the device state attributes.""" - if self.airvisual.show_on_map: - self._attrs[ATTR_LATITUDE] = self.airvisual.latitude - self._attrs[ATTR_LONGITUDE] = self.airvisual.longitude - else: - self._attrs["lati"] = self.airvisual.latitude - self._attrs["long"] = self.airvisual.longitude + self._attrs = { + ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION, + ATTR_CITY: airvisual.data[geography_id].get(CONF_CITY), + ATTR_STATE: airvisual.data[geography_id].get(CONF_STATE), + ATTR_COUNTRY: airvisual.data[geography_id].get(CONF_COUNTRY), + } - return self._attrs + geography = airvisual.geographies[geography_id] + if geography.get(CONF_LATITUDE): + if airvisual.show_on_map: + self._attrs[ATTR_LATITUDE] = geography[CONF_LATITUDE] + self._attrs[ATTR_LONGITUDE] = geography[CONF_LONGITUDE] + else: + self._attrs["lati"] = geography[CONF_LATITUDE] + self._attrs["long"] = geography[CONF_LONGITUDE] @property def available(self): """Return True if entity is available.""" - return bool(self.airvisual.pollution_info) + try: + return bool( + self._airvisual.data[self._geography_id]["current"]["pollution"] + ) + except KeyError: + return False + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return self._attrs @property def icon(self): @@ -194,22 +152,33 @@ class AirVisualSensor(Entity): @property def unique_id(self): """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._location_id}_{self._locale}_{self._type}" + return f"{self._geography_id}_{self._locale}_{self._kind}" @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit + async def async_added_to_hass(self): + """Register callbacks.""" + + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connects.append( + async_dispatcher_connect(self.hass, TOPIC_UPDATE, update) + ) + async def async_update(self): """Update the sensor.""" - await self.airvisual.async_update() - data = self.airvisual.pollution_info - - if not data: + try: + data = self._airvisual.data[self._geography_id]["current"]["pollution"] + except KeyError: return - if self._type == SENSOR_TYPE_LEVEL: + if self._kind == SENSOR_KIND_LEVEL: aqi = data[f"aqi{self._locale}"] [level] = [ i @@ -218,9 +187,9 @@ class AirVisualSensor(Entity): ] self._state = level["label"] self._icon = level["icon"] - elif self._type == SENSOR_TYPE_AQI: + elif self._kind == SENSOR_KIND_AQI: self._state = data[f"aqi{self._locale}"] - elif self._type == SENSOR_TYPE_POLLUTANT: + elif self._kind == SENSOR_KIND_POLLUTANT: symbol = data[f"main{self._locale}"] self._state = POLLUTANT_MAPPING[symbol]["label"] self._attrs.update( @@ -230,43 +199,8 @@ class AirVisualSensor(Entity): } ) - -class AirVisualData: - """Define an object to hold sensor data.""" - - def __init__(self, client, **kwargs): - """Initialize.""" - self._client = client - self.city = kwargs.get(CONF_CITY) - self.country = kwargs.get(CONF_COUNTRY) - self.latitude = kwargs.get(CONF_LATITUDE) - self.longitude = kwargs.get(CONF_LONGITUDE) - self.pollution_info = {} - self.show_on_map = kwargs.get(CONF_SHOW_ON_MAP) - self.state = kwargs.get(CONF_STATE) - - self.async_update = Throttle(kwargs[CONF_SCAN_INTERVAL])(self._async_update) - - async def _async_update(self): - """Update AirVisual data.""" - - try: - if self.city and self.state and self.country: - resp = await self._client.api.city(self.city, self.state, self.country) - self.longitude, self.latitude = resp["location"]["coordinates"] - else: - resp = await self._client.api.nearest_city( - self.latitude, self.longitude - ) - - _LOGGER.debug("New data retrieved: %s", resp) - - self.pollution_info = resp["current"]["pollution"] - except (KeyError, AirVisualError) as err: - if self.city and self.state and self.country: - location = (self.city, self.state, self.country) - else: - location = (self.latitude, self.longitude) - - _LOGGER.error("Can't retrieve data for location: %s (%s)", location, err) - self.pollution_info = {} + async def async_will_remove_from_hass(self) -> None: + """Disconnect dispatcher listener when removed.""" + for cancel in self._async_unsub_dispatcher_connects: + cancel() + self._async_unsub_dispatcher_connects = [] diff --git a/homeassistant/components/airvisual/strings.json b/homeassistant/components/airvisual/strings.json new file mode 100644 index 00000000000..6c3f17c92dd --- /dev/null +++ b/homeassistant/components/airvisual/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "AirVisual", + "step": { + "user": { + "title": "Configure AirVisual", + "description": "Monitor air quality in a geographical location.", + "data": { + "api_key": "API Key", + "latitude": "Latitude", + "longitude": "Longitude", + "show_on_map": "Show monitored geography on the map" + } + } + }, + "error": { + "invalid_api_key": "Invalid API key" + }, + "abort": { + "already_configured": "This API key is already in use." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d0162f84737..55c013b46ad 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -9,6 +9,7 @@ FLOWS = [ "abode", "adguard", "airly", + "airvisual", "almond", "ambiclimate", "ambient_station", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 50d27d9fdbb..59b51e13062 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -418,6 +418,9 @@ py_nextbusnext==0.1.4 # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.4 +# homeassistant.components.airvisual +pyairvisual==3.0.1 + # homeassistant.components.almond pyalmond==0.0.2 diff --git a/tests/components/airvisual/__init__.py b/tests/components/airvisual/__init__.py new file mode 100644 index 00000000000..4c116d75d0f --- /dev/null +++ b/tests/components/airvisual/__init__.py @@ -0,0 +1 @@ +"""Define tests for the AirVisual component.""" diff --git a/tests/components/airvisual/test_config_flow.py b/tests/components/airvisual/test_config_flow.py new file mode 100644 index 00000000000..fb4325bd6ee --- /dev/null +++ b/tests/components/airvisual/test_config_flow.py @@ -0,0 +1,87 @@ +"""Define tests for the AirVisual config flow.""" +from unittest.mock import patch + +from pyairvisual.errors import InvalidKeyError + +from homeassistant import data_entry_flow +from homeassistant.components.airvisual import CONF_GEOGRAPHIES, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE + +from tests.common import MockConfigEntry, mock_coro + + +async def test_duplicate_error(hass): + """Test that errors are shown when duplicates are added.""" + conf = {CONF_API_KEY: "abcde12345"} + + MockConfigEntry(domain=DOMAIN, unique_id="abcde12345", data=conf).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_invalid_api_key(hass): + """Test that invalid credentials throws an error.""" + conf = {CONF_API_KEY: "abcde12345"} + + with patch( + "pyairvisual.api.API.nearest_city", + return_value=mock_coro(exception=InvalidKeyError), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + 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"] == "user" + + +async def test_step_import(hass): + """Test that the import step works.""" + conf = {CONF_API_KEY: "abcde12345"} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Cloud API (API key: abcd...)" + assert result["data"] == { + CONF_API_KEY: "abcde12345", + CONF_GEOGRAPHIES: [{CONF_LATITUDE: 32.87336, CONF_LONGITUDE: -117.22743}], + } + + +async def test_step_user(hass): + """Test that the user step works.""" + conf = { + CONF_API_KEY: "abcde12345", + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + } + + with patch( + "pyairvisual.api.API.nearest_city", return_value=mock_coro(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Cloud API (API key: abcd...)" + assert result["data"] == { + CONF_API_KEY: "abcde12345", + CONF_GEOGRAPHIES: [{CONF_LATITUDE: 32.87336, CONF_LONGITUDE: -117.22743}], + } From be14b94705aebc7ed8dd395bbfd49e8ba8e7d5b2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 29 Feb 2020 01:12:50 -1000 Subject: [PATCH 165/416] Restore august lock changed_by state on restart (#32340) * Various code review items from previous PRs * Add a test for fetching the doorbell camera image * Switch to using UNIT_PERCENTAGE for battery charge unit * Add tests for changed_by --- .../components/august/binary_sensor.py | 17 +++------- homeassistant/components/august/entity.py | 3 +- homeassistant/components/august/lock.py | 30 ++++++++++------ homeassistant/components/august/sensor.py | 3 +- tests/components/august/test_binary_sensor.py | 18 ++++------ tests/components/august/test_camera.py | 27 ++++++++++++--- tests/components/august/test_lock.py | 30 +++++++++++----- tests/fixtures/august/get_activity.lock.json | 34 +++++++++++++++++++ 8 files changed, 112 insertions(+), 50 deletions(-) create mode 100644 tests/fixtures/august/get_activity.lock.json diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py index 109ed425157..e61de730302 100644 --- a/homeassistant/components/august/binary_sensor.py +++ b/homeassistant/components/august/binary_sensor.py @@ -8,6 +8,7 @@ from august.util import update_lock_detail_from_activity from homeassistant.components.binary_sensor import ( DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_DOOR, DEVICE_CLASS_MOTION, DEVICE_CLASS_OCCUPANCY, BinarySensorDevice, @@ -116,24 +117,22 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorDevice): self._data = data self._sensor_type = sensor_type self._device = device - self._state = None - self._available = False self._update_from_data() @property def available(self): """Return the availability of this sensor.""" - return self._available + return self._detail.bridge_is_online @property def is_on(self): """Return true if the binary sensor is on.""" - return self._state + return self._detail.door_state == LockDoorStatus.OPEN @property def device_class(self): """Return the class of this device.""" - return "door" + return DEVICE_CLASS_DOOR @property def name(self): @@ -146,15 +145,9 @@ class AugustDoorBinarySensor(AugustEntityMixin, BinarySensorDevice): door_activity = self._data.activity_stream.get_latest_device_activity( self._device_id, [ActivityType.DOOR_OPERATION] ) - detail = self._detail if door_activity is not None: - update_lock_detail_from_activity(detail, door_activity) - - lock_door_state = detail.door_state - self._available = detail.bridge_is_online - - self._state = lock_door_state == LockDoorStatus.OPEN + update_lock_detail_from_activity(self._detail, door_activity) @property def unique_id(self) -> str: diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index 32e2e7acd10..03c7698770c 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -3,13 +3,14 @@ import logging from homeassistant.core import callback +from homeassistant.helpers.entity import Entity from . import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) -class AugustEntityMixin: +class AugustEntityMixin(Entity): """Base implementation for August device.""" def __init__(self, data, device): diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index c072a589f6c..8e10f957ce6 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -5,9 +5,10 @@ from august.activity import ActivityType from august.lock import LockStatus from august.util import update_lock_detail_from_activity -from homeassistant.components.lock import LockDevice +from homeassistant.components.lock import ATTR_CHANGED_BY, LockDevice from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import callback +from homeassistant.helpers.restore_state import RestoreEntity from .const import DATA_AUGUST, DOMAIN from .entity import AugustEntityMixin @@ -27,7 +28,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities(devices, True) -class AugustLock(AugustEntityMixin, LockDevice): +class AugustLock(AugustEntityMixin, RestoreEntity, LockDevice): """Representation of an August lock.""" def __init__(self, data, device): @@ -52,9 +53,8 @@ class AugustLock(AugustEntityMixin, LockDevice): activities = await self.hass.async_add_executor_job( lock_operation, self._device_id ) - detail = self._detail for lock_activity in activities: - update_lock_detail_from_activity(detail, lock_activity) + update_lock_detail_from_activity(self._detail, lock_activity) if self._update_lock_status_from_detail(): _LOGGER.debug( @@ -64,26 +64,23 @@ class AugustLock(AugustEntityMixin, LockDevice): self._data.async_signal_device_id_update(self._device_id) def _update_lock_status_from_detail(self): - detail = self._detail - lock_status = detail.lock_status - self._available = detail.bridge_is_online + self._available = self._detail.bridge_is_online - if self._lock_status != lock_status: - self._lock_status = lock_status + if self._lock_status != self._detail.lock_status: + self._lock_status = self._detail.lock_status return True return False @callback def _update_from_data(self): """Get the latest state of the sensor and update activity.""" - lock_detail = self._detail lock_activity = self._data.activity_stream.get_latest_device_activity( self._device_id, [ActivityType.LOCK_OPERATION] ) if lock_activity is not None: self._changed_by = lock_activity.operated_by - update_lock_detail_from_activity(lock_detail, lock_activity) + update_lock_detail_from_activity(self._detail, lock_activity) self._update_lock_status_from_detail() @@ -119,6 +116,17 @@ class AugustLock(AugustEntityMixin, LockDevice): return attributes + async def async_added_to_hass(self): + """Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log.""" + await super().async_added_to_hass() + + last_state = await self.async_get_last_state() + if not last_state: + return + + if ATTR_CHANGED_BY in last_state.attributes: + self._changed_by = last_state.attributes[ATTR_CHANGED_BY] + @property def unique_id(self) -> str: """Get the unique id of the lock.""" diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 6c7af3c0c7e..8c5682b8e42 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -2,6 +2,7 @@ import logging from homeassistant.components.sensor import DEVICE_CLASS_BATTERY +from homeassistant.const import UNIT_PERCENTAGE from homeassistant.core import callback from homeassistant.helpers.entity import Entity @@ -111,7 +112,7 @@ class AugustBatterySensor(AugustEntityMixin, Entity): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return "%" # UNIT_PERCENTAGE will be available after PR#32094 + return UNIT_PERCENTAGE @property def device_class(self): diff --git a/tests/components/august/test_binary_sensor.py b/tests/components/august/test_binary_sensor.py index 30b70c3c397..87fc6e5eec9 100644 --- a/tests/components/august/test_binary_sensor.py +++ b/tests/components/august/test_binary_sensor.py @@ -23,16 +23,14 @@ async def test_doorsense(hass): lock_one = await _mock_lock_from_fixture( hass, "get_lock.online_with_doorsense.json" ) - lock_details = [lock_one] - await _create_august_with_devices(hass, lock_details) + await _create_august_with_devices(hass, [lock_one]) binary_sensor_online_with_doorsense_name = hass.states.get( "binary_sensor.online_with_doorsense_name_open" ) assert binary_sensor_online_with_doorsense_name.state == STATE_ON - data = {} - data[ATTR_ENTITY_ID] = "lock.online_with_doorsense_name" + data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} assert await hass.services.async_call( LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True ) @@ -57,8 +55,7 @@ async def test_doorsense(hass): async def test_create_doorbell(hass): """Test creation of a doorbell.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") - doorbell_details = [doorbell_one] - await _create_august_with_devices(hass, doorbell_details) + await _create_august_with_devices(hass, [doorbell_one]) binary_sensor_k98gidt45gul_name_motion = hass.states.get( "binary_sensor.k98gidt45gul_name_motion" @@ -81,8 +78,7 @@ async def test_create_doorbell(hass): async def test_create_doorbell_offline(hass): """Test creation of a doorbell that is offline.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") - doorbell_details = [doorbell_one] - await _create_august_with_devices(hass, doorbell_details) + await _create_august_with_devices(hass, [doorbell_one]) binary_sensor_tmt100_name_motion = hass.states.get( "binary_sensor.tmt100_name_motion" @@ -99,11 +95,10 @@ async def test_create_doorbell_offline(hass): async def test_create_doorbell_with_motion(hass): """Test creation of a doorbell.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") - doorbell_details = [doorbell_one] activities = await _mock_activities_from_fixture( hass, "get_activity.doorbell_motion.json" ) - await _create_august_with_devices(hass, doorbell_details, activities=activities) + await _create_august_with_devices(hass, [doorbell_one], activities=activities) binary_sensor_k98gidt45gul_name_motion = hass.states.get( "binary_sensor.k98gidt45gul_name_motion" @@ -122,8 +117,7 @@ async def test_create_doorbell_with_motion(hass): async def test_doorbell_device_registry(hass): """Test creation of a lock with doorsense and bridge ands up in the registry.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.offline.json") - doorbell_details = [doorbell_one] - await _create_august_with_devices(hass, doorbell_details) + await _create_august_with_devices(hass, [doorbell_one]) device_registry = await hass.helpers.device_registry.async_get_registry() diff --git a/tests/components/august/test_camera.py b/tests/components/august/test_camera.py index 4d9d48b0825..632525c0c4e 100644 --- a/tests/components/august/test_camera.py +++ b/tests/components/august/test_camera.py @@ -1,5 +1,7 @@ """The camera tests for the august platform.""" +from asynctest import mock + from homeassistant.const import STATE_IDLE from tests.components.august.mocks import ( @@ -8,11 +10,26 @@ from tests.components.august.mocks import ( ) -async def test_create_doorbell(hass): +async def test_create_doorbell(hass, aiohttp_client): """Test creation of a doorbell.""" doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") - doorbell_details = [doorbell_one] - await _create_august_with_devices(hass, doorbell_details) - camera_k98gidt45gul_name_camera = hass.states.get("camera.k98gidt45gul_name_camera") - assert camera_k98gidt45gul_name_camera.state == STATE_IDLE + with mock.patch.object( + doorbell_one, "get_doorbell_image", create=False, return_value="image" + ): + await _create_august_with_devices(hass, [doorbell_one]) + + camera_k98gidt45gul_name_camera = hass.states.get( + "camera.k98gidt45gul_name_camera" + ) + assert camera_k98gidt45gul_name_camera.state == STATE_IDLE + + url = hass.states.get("camera.k98gidt45gul_name_camera").attributes[ + "entity_picture" + ] + + client = await aiohttp_client(hass.http.app) + resp = await client.get(url) + assert resp.status == 200 + body = await resp.text() + assert body == "image" diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index a620bdd1080..ef8518e0bbc 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -12,6 +12,7 @@ from homeassistant.const import ( from tests.components.august.mocks import ( _create_august_with_devices, + _mock_activities_from_fixture, _mock_doorsense_enabled_august_lock_detail, _mock_lock_from_fixture, ) @@ -20,8 +21,7 @@ from tests.components.august.mocks import ( async def test_lock_device_registry(hass): """Test creation of a lock with doorsense and bridge ands up in the registry.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) - lock_details = [lock_one] - await _create_august_with_devices(hass, lock_details) + await _create_august_with_devices(hass, [lock_one]) device_registry = await hass.helpers.device_registry.async_get_registry() @@ -34,11 +34,27 @@ async def test_lock_device_registry(hass): assert reg_device.manufacturer == "August" +async def test_lock_changed_by(hass): + """Test creation of a lock with doorsense and bridge.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture(hass, "get_activity.lock.json") + await _create_august_with_devices(hass, [lock_one], activities=activities) + + lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") + + assert lock_online_with_doorsense_name.state == STATE_LOCKED + + assert ( + lock_online_with_doorsense_name.attributes.get("changed_by") + == "Your favorite elven princess" + ) + + async def test_one_lock_operation(hass): """Test creation of a lock with doorsense and bridge.""" lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) - lock_details = [lock_one] - await _create_august_with_devices(hass, lock_details) + await _create_august_with_devices(hass, [lock_one]) lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") @@ -50,8 +66,7 @@ async def test_one_lock_operation(hass): == "online_with_doorsense Name" ) - data = {} - data[ATTR_ENTITY_ID] = "lock.online_with_doorsense_name" + data = {ATTR_ENTITY_ID: "lock.online_with_doorsense_name"} assert await hass.services.async_call( LOCK_DOMAIN, SERVICE_UNLOCK, data, blocking=True ) @@ -78,8 +93,7 @@ async def test_one_lock_unknown_state(hass): lock_one = await _mock_lock_from_fixture( hass, "get_lock.online.unknown_state.json", ) - lock_details = [lock_one] - await _create_august_with_devices(hass, lock_details) + await _create_august_with_devices(hass, [lock_one]) lock_brokenid_name = hass.states.get("lock.brokenid_name") diff --git a/tests/fixtures/august/get_activity.lock.json b/tests/fixtures/august/get_activity.lock.json new file mode 100644 index 00000000000..e0e61cb36b3 --- /dev/null +++ b/tests/fixtures/august/get_activity.lock.json @@ -0,0 +1,34 @@ +[{ + "entities" : { + "activity" : "mockActivity2", + "house" : "123", + "device" : "online_with_doorsense", + "callingUser" : "mockUserId2", + "otherUser" : "deleted" + }, + "callingUser" : { + "LastName" : "elven princess", + "UserID" : "mockUserId2", + "FirstName" : "Your favorite" + }, + "otherUser" : { + "LastName" : "User", + "UserName" : "deleteduser", + "FirstName" : "Unknown", + "UserID" : "deleted", + "PhoneNo" : "deleted" + }, + "deviceType" : "lock", + "deviceName" : "MockHouseTDoor", + "action" : "lock", + "dateTime" : 1582007218000, + "info" : { + "remote" : true, + "DateLogActionID" : "ABC+Time" + }, + "deviceID" : "online_with_doorsense", + "house" : { + "houseName" : "MockHouse", + "houseID" : "123" + } +}] From c4ac3155e479b39718da0fe4918582fed57f3934 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 29 Feb 2020 13:33:20 +0100 Subject: [PATCH 166/416] Add brightness to light device turn_on action (#32219) * Add support for setting brightness * Remove default brightness --- .../components/light/device_action.py | 44 +++++++- tests/components/light/test_device_action.py | 103 ++++++++++++++++-- 2 files changed, 135 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/light/device_action.py b/homeassistant/components/light/device_action.py index 99f5b6b12bc..5ee2785a700 100644 --- a/homeassistant/components/light/device_action.py +++ b/homeassistant/components/light/device_action.py @@ -15,7 +15,7 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import ATTR_BRIGHTNESS_STEP_PCT, DOMAIN, SUPPORT_BRIGHTNESS +from . import ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_STEP_PCT, DOMAIN, SUPPORT_BRIGHTNESS TYPE_BRIGHTNESS_INCREASE = "brightness_increase" TYPE_BRIGHTNESS_DECREASE = "brightness_decrease" @@ -28,6 +28,9 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( toggle_entity.DEVICE_ACTION_TYPES + [TYPE_BRIGHTNESS_INCREASE, TYPE_BRIGHTNESS_DECREASE] ), + vol.Optional(ATTR_BRIGHTNESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ), } ) @@ -39,7 +42,10 @@ async def async_call_action_from_config( context: Context, ) -> None: """Change state based on configuration.""" - if config[CONF_TYPE] in toggle_entity.DEVICE_ACTION_TYPES: + if ( + config[CONF_TYPE] in toggle_entity.DEVICE_ACTION_TYPES + and config[CONF_TYPE] != toggle_entity.CONF_TURN_ON + ): await toggle_entity.async_call_action_from_config( hass, config, variables, context, DOMAIN ) @@ -49,8 +55,10 @@ async def async_call_action_from_config( if config[CONF_TYPE] == TYPE_BRIGHTNESS_INCREASE: data[ATTR_BRIGHTNESS_STEP_PCT] = 10 - else: + elif config[CONF_TYPE] == TYPE_BRIGHTNESS_DECREASE: data[ATTR_BRIGHTNESS_STEP_PCT] = -10 + elif ATTR_BRIGHTNESS in config: + data[ATTR_BRIGHTNESS] = config[ATTR_BRIGHTNESS] await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, data, blocking=True, context=context @@ -93,3 +101,33 @@ async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: ) return actions + + +async def async_get_action_capabilities(hass: HomeAssistant, config: dict) -> dict: + """List action capabilities.""" + if config[CONF_TYPE] != toggle_entity.CONF_TURN_ON: + return {} + + registry = await entity_registry.async_get_registry(hass) + entry = registry.async_get(config[ATTR_ENTITY_ID]) + state = hass.states.get(config[ATTR_ENTITY_ID]) + + supported_features = 0 + + if state: + supported_features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + elif entry: + supported_features = entry.supported_features + + if not supported_features & SUPPORT_BRIGHTNESS: + return {} + + return { + "extra_fields": vol.Schema( + { + vol.Optional(ATTR_BRIGHTNESS): vol.All( + vol.Coerce(int), vol.Range(min=0, max=100) + ) + } + ) + } diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index 3ac8171ce7d..610f61dea52 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -9,6 +9,7 @@ from homeassistant.setup import async_setup_component from tests.common import ( MockConfigEntry, + async_get_device_automation_capabilities, async_get_device_automations, async_mock_service, mock_device_registry, @@ -85,6 +86,66 @@ async def test_get_actions(hass, device_reg, entity_reg): assert actions == expected_actions +async def test_get_action_capabilities(hass, device_reg, entity_reg): + """Test we get the expected capabilities from a light action.""" + 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, + ) + + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert len(actions) == 3 + for action in actions: + capabilities = await async_get_device_automation_capabilities( + hass, "action", action + ) + assert capabilities == {"extra_fields": []} + + +async def test_get_action_capabilities_brightness(hass, device_reg, entity_reg): + """Test we get the expected capabilities from a light action.""" + 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, + supported_features=SUPPORT_BRIGHTNESS, + ) + + expected_capabilities = { + "extra_fields": [ + { + "name": "brightness", + "optional": True, + "type": "integer", + "valueMax": 100, + "valueMin": 0, + } + ] + } + actions = await async_get_device_automations(hass, "action", device_entry.id) + assert len(actions) == 5 + for action in actions: + capabilities = await async_get_device_automation_capabilities( + hass, "action", action + ) + if action["type"] == "turn_on": + assert capabilities == expected_capabilities + else: + assert capabilities == {"extra_fields": []} + + async def test_action(hass, calls): """Test for turn_on and turn_off actions.""" platform = getattr(hass.components, f"test.{DOMAIN}") @@ -100,7 +161,7 @@ async def test_action(hass, calls): { automation.DOMAIN: [ { - "trigger": {"platform": "event", "event_type": "test_event1"}, + "trigger": {"platform": "event", "event_type": "test_off"}, "action": { "domain": DOMAIN, "device_id": "", @@ -109,7 +170,7 @@ async def test_action(hass, calls): }, }, { - "trigger": {"platform": "event", "event_type": "test_event2"}, + "trigger": {"platform": "event", "event_type": "test_on"}, "action": { "domain": DOMAIN, "device_id": "", @@ -118,7 +179,7 @@ async def test_action(hass, calls): }, }, { - "trigger": {"platform": "event", "event_type": "test_event3"}, + "trigger": {"platform": "event", "event_type": "test_toggle"}, "action": { "domain": DOMAIN, "device_id": "", @@ -150,6 +211,16 @@ async def test_action(hass, calls): "type": "brightness_decrease", }, }, + { + "trigger": {"platform": "event", "event_type": "test_brightness"}, + "action": { + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "turn_on", + "brightness": 75, + }, + }, ] }, ) @@ -157,27 +228,27 @@ async def test_action(hass, calls): 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_off") await hass.async_block_till_done() assert hass.states.get(ent1.entity_id).state == STATE_OFF - hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_off") await hass.async_block_till_done() assert hass.states.get(ent1.entity_id).state == STATE_OFF - hass.bus.async_fire("test_event2") + hass.bus.async_fire("test_on") await hass.async_block_till_done() assert hass.states.get(ent1.entity_id).state == STATE_ON - hass.bus.async_fire("test_event2") + hass.bus.async_fire("test_on") await hass.async_block_till_done() assert hass.states.get(ent1.entity_id).state == STATE_ON - hass.bus.async_fire("test_event3") + hass.bus.async_fire("test_toggle") await hass.async_block_till_done() assert hass.states.get(ent1.entity_id).state == STATE_OFF - hass.bus.async_fire("test_event3") + hass.bus.async_fire("test_toggle") await hass.async_block_till_done() assert hass.states.get(ent1.entity_id).state == STATE_ON @@ -196,3 +267,17 @@ async def test_action(hass, calls): assert len(turn_on_calls) == 2 assert turn_on_calls[1].data["entity_id"] == ent1.entity_id assert turn_on_calls[1].data["brightness_step_pct"] == -10 + + hass.bus.async_fire("test_brightness") + await hass.async_block_till_done() + + assert len(turn_on_calls) == 3 + assert turn_on_calls[2].data["entity_id"] == ent1.entity_id + assert turn_on_calls[2].data["brightness"] == 75 + + hass.bus.async_fire("test_on") + await hass.async_block_till_done() + + assert len(turn_on_calls) == 4 + assert turn_on_calls[3].data["entity_id"] == ent1.entity_id + assert "brightness" not in turn_on_calls[3].data From 3ab04118f632f0699c8f298010d9b8369f59196c Mon Sep 17 00:00:00 2001 From: Marius <33354141+Mariusthvdb@users.noreply.github.com> Date: Sat, 29 Feb 2020 14:35:28 +0100 Subject: [PATCH 167/416] Fix github sensor short SHA (#32316) --- homeassistant/components/github/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index 406876de807..72a8189f915 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -137,7 +137,7 @@ class GitHubSensor(Entity): self._latest_commit_message = self._github_data.latest_commit_message self._latest_commit_sha = self._github_data.latest_commit_sha self._latest_release_url = self._github_data.latest_release_url - self._state = self._github_data.latest_commit_sha[0:8] + self._state = self._github_data.latest_commit_sha[0:7] self._open_issue_count = self._github_data.open_issue_count self._latest_open_issue_url = self._github_data.latest_open_issue_url self._pull_request_count = self._github_data.pull_request_count From 8e3492d4f5c3baae3854647f94b1de70b816c656 Mon Sep 17 00:00:00 2001 From: Robert Chmielowiec Date: Sun, 1 Mar 2020 00:37:06 +0100 Subject: [PATCH 168/416] ZHA: Support light flashing (#32234) --- .../components/zha/core/channels/__init__.py | 14 ++++++ .../components/zha/core/channels/general.py | 8 ++- homeassistant/components/zha/core/const.py | 1 + homeassistant/components/zha/light.py | 16 +++++- tests/components/zha/test_light.py | 50 +++++++++++++++++-- 5 files changed, 82 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index ea838a05665..d884f359d47 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -39,6 +39,7 @@ class Channels: """Initialize instance.""" self._pools: List[zha_typing.ChannelPoolType] = [] self._power_config = None + self._identify = None self._semaphore = asyncio.Semaphore(3) self._unique_id = str(zha_device.ieee) self._zdo_channel = base.ZDOChannel(zha_device.device.endpoints[0], zha_device) @@ -60,6 +61,17 @@ class Channels: if self._power_config is None: self._power_config = channel + @property + def identify_ch(self) -> zha_typing.ChannelType: + """Return power configuration channel.""" + return self._identify + + @identify_ch.setter + def identify_ch(self, channel: zha_typing.ChannelType) -> None: + """Power configuration channel setter.""" + if self._identify is None: + self._identify = channel + @property def semaphore(self) -> asyncio.Semaphore: """Return semaphore for concurrent tasks.""" @@ -242,6 +254,8 @@ class ChannelPool: # on power configuration channel per device continue self._channels.power_configuration_ch = channel + elif channel.name == const.CHANNEL_IDENTIFY: + self._channels.identify_ch = channel self.all_channels[channel.id] = channel diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 3e41e961f0a..28bc9c7d763 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -153,7 +153,13 @@ class Groups(ZigbeeChannel): class Identify(ZigbeeChannel): """Identify channel.""" - pass + @callback + def cluster_command(self, tsn, command_id, args): + """Handle commands received to this cluster.""" + cmd = parse_and_log_command(self, tsn, command_id, args) + + if cmd == "trigger_effect": + self.async_send_signal(f"{self.unique_id}_{cmd}", args[0]) @registries.BINDABLE_CLUSTERS.register(general.LevelControl.cluster_id) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index cb0ac2182ec..3204fa76e2a 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -62,6 +62,7 @@ CHANNEL_EVENT_RELAY = "event_relay" CHANNEL_FAN = "fan" CHANNEL_HUMIDITY = "humidity" CHANNEL_IAS_WD = "ias_wd" +CHANNEL_IDENTIFY = "identify" CHANNEL_ILLUMINANCE = "illuminance" CHANNEL_LEVEL = ATTR_LEVEL CHANNEL_MULTISTATE_INPUT = "multistate_input" diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index dc2e156dbf5..4264fded26b 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -24,6 +24,7 @@ from .core.const import ( SIGNAL_SET_LEVEL, ) from .core.registries import ZHA_ENTITIES +from .core.typing import ZhaDeviceType from .entity import ZhaEntity _LOGGER = logging.getLogger(__name__) @@ -37,6 +38,8 @@ UPDATE_COLORLOOP_DIRECTION = 0x2 UPDATE_COLORLOOP_TIME = 0x4 UPDATE_COLORLOOP_HUE = 0x8 +FLASH_EFFECTS = {light.FLASH_SHORT: 0x00, light.FLASH_LONG: 0x01} + UNSUPPORTED_ATTRIBUTE = 0x86 SCAN_INTERVAL = timedelta(minutes=60) STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, light.DOMAIN) @@ -61,7 +64,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class Light(ZhaEntity, light.Light): """Representation of a ZHA or ZLL light.""" - def __init__(self, unique_id, zha_device, channels, **kwargs): + def __init__(self, unique_id, zha_device: ZhaDeviceType, channels, **kwargs): """Initialize the ZHA light.""" super().__init__(unique_id, zha_device, channels, **kwargs) self._supported_features = 0 @@ -74,6 +77,7 @@ class Light(ZhaEntity, light.Light): self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF) self._level_channel = self.cluster_channels.get(CHANNEL_LEVEL) self._color_channel = self.cluster_channels.get(CHANNEL_COLOR) + self._identify_channel = self.zha_device.channels.identify_ch if self._level_channel: self._supported_features |= light.SUPPORT_BRIGHTNESS @@ -93,6 +97,9 @@ class Light(ZhaEntity, light.Light): self._supported_features |= light.SUPPORT_EFFECT self._effect_list.append(light.EFFECT_COLORLOOP) + if self._identify_channel: + self._supported_features |= light.SUPPORT_FLASH + @property def is_on(self) -> bool: """Return true if entity is on.""" @@ -188,6 +195,7 @@ class Light(ZhaEntity, light.Light): duration = transition * 10 if transition else 0 brightness = kwargs.get(light.ATTR_BRIGHTNESS) effect = kwargs.get(light.ATTR_EFFECT) + flash = kwargs.get(light.ATTR_FLASH) if brightness is None and self._off_brightness is not None: brightness = self._off_brightness @@ -277,6 +285,12 @@ class Light(ZhaEntity, light.Light): t_log["color_loop_set"] = result self._effect = None + if flash is not None and self._supported_features & light.SUPPORT_FLASH: + result = await self._identify_channel.trigger_effect( + FLASH_EFFECTS[flash], 0 # effect identifier, effect variant + ) + t_log["trigger_effect"] = result + self._off_brightness = None self.debug("turned on: %s", t_log) self.async_schedule_update_ha_state() diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index e21c22d30cf..726def23fc3 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -9,7 +9,8 @@ import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.lighting as lighting import zigpy.zcl.foundation as zcl_f -from homeassistant.components.light import DOMAIN +from homeassistant.components.light import DOMAIN, FLASH_LONG, FLASH_SHORT +from homeassistant.components.zha.light import FLASH_EFFECTS from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from .common import ( @@ -26,7 +27,11 @@ OFF = 0 LIGHT_ON_OFF = { 1: { "device_type": zigpy.profiles.zha.DeviceType.ON_OFF_LIGHT, - "in_clusters": [general.Basic.cluster_id, general.OnOff.cluster_id], + "in_clusters": [ + general.Basic.cluster_id, + general.Identify.cluster_id, + general.OnOff.cluster_id, + ], "out_clusters": [general.Ota.cluster_id], } } @@ -48,6 +53,7 @@ LIGHT_COLOR = { "device_type": zigpy.profiles.zha.DeviceType.COLOR_DIMMABLE_LIGHT, "in_clusters": [ general.Basic.cluster_id, + general.Identify.cluster_id, general.LevelControl.cluster_id, general.OnOff.cluster_id, lighting.Color.cluster_id, @@ -61,6 +67,10 @@ LIGHT_COLOR = { "zigpy.zcl.clusters.lighting.Color.request", new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), ) +@asynctest.patch( + "zigpy.zcl.clusters.general.Identify.request", + new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), +) @asynctest.patch( "zigpy.zcl.clusters.general.LevelControl.request", new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), @@ -88,6 +98,7 @@ async def test_light( cluster_on_off = zigpy_device.endpoints[1].on_off cluster_level = getattr(zigpy_device.endpoints[1], "level", None) cluster_color = getattr(zigpy_device.endpoints[1], "light_color", None) + cluster_identify = getattr(zigpy_device.endpoints[1], "identify", None) # test that the lights were created and that they are unavailable assert hass.states.get(entity_id).state == STATE_UNAVAILABLE @@ -104,6 +115,11 @@ async def test_light( # test turning the lights on and off from the HA await async_test_on_off_from_hass(hass, cluster_on_off, entity_id) + # test short flashing the lights from the HA + if cluster_identify: + await async_test_flash_from_hass(hass, cluster_identify, entity_id, FLASH_SHORT) + + # test turning the lights on and off from the HA if cluster_level: await async_test_level_on_off_from_hass( hass, cluster_on_off, cluster_level, entity_id @@ -124,6 +140,10 @@ async def test_light( clusters.append(cluster_color) await async_test_rejoin(hass, zigpy_device, clusters, reporting) + # test long flashing the lights from the HA + if cluster_identify: + await async_test_flash_from_hass(hass, cluster_identify, entity_id, FLASH_LONG) + async def async_test_on_off_from_light(hass, cluster, entity_id): """Test on off functionality from the light.""" @@ -197,7 +217,7 @@ async def async_test_level_on_off_from_hass( assert level_cluster.request.call_count == 0 assert level_cluster.request.await_count == 0 assert on_off_cluster.request.call_args == call( - False, 1, (), expect_reply=True, manufacturer=None + False, ON, (), expect_reply=True, manufacturer=None ) on_off_cluster.request.reset_mock() level_cluster.request.reset_mock() @@ -210,7 +230,7 @@ async def async_test_level_on_off_from_hass( assert level_cluster.request.call_count == 1 assert level_cluster.request.await_count == 1 assert on_off_cluster.request.call_args == call( - False, 1, (), expect_reply=True, manufacturer=None + False, ON, (), expect_reply=True, manufacturer=None ) assert level_cluster.request.call_args == call( False, @@ -232,7 +252,7 @@ async def async_test_level_on_off_from_hass( assert level_cluster.request.call_count == 1 assert level_cluster.request.await_count == 1 assert on_off_cluster.request.call_args == call( - False, 1, (), expect_reply=True, manufacturer=None + False, ON, (), expect_reply=True, manufacturer=None ) assert level_cluster.request.call_args == call( False, @@ -260,3 +280,23 @@ async def async_test_dimmer_from_light(hass, cluster, entity_id, level, expected if level == 0: level = None assert hass.states.get(entity_id).attributes.get("brightness") == level + + +async def async_test_flash_from_hass(hass, cluster, entity_id, flash): + """Test flash functionality from hass.""" + # turn on via UI + cluster.request.reset_mock() + await hass.services.async_call( + DOMAIN, "turn_on", {"entity_id": entity_id, "flash": flash}, blocking=True + ) + assert cluster.request.call_count == 1 + assert cluster.request.await_count == 1 + assert cluster.request.call_args == call( + False, + 64, + (zigpy.types.uint8_t, zigpy.types.uint8_t), + FLASH_EFFECTS[flash], + 0, + expect_reply=True, + manufacturer=None, + ) From c6e85cac0bb796e457f4d9cb2d29489f39fe8937 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 29 Feb 2020 20:37:06 -0800 Subject: [PATCH 169/416] Require IFTTT to send data as dictionary (#32317) * Require IFTTT to send data as dictionary * Update logging --- homeassistant/components/ifttt/__init__.py | 15 ++++++++++++--- tests/components/ifttt/test_init.py | 8 ++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 3011f5a2a0a..72c905497c0 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -93,10 +93,19 @@ async def handle_webhook(hass, webhook_id, request): try: data = json.loads(body) if body else {} except ValueError: - return None + _LOGGER.error( + "Received invalid data from IFTTT. Data needs to be formatted as JSON: %s", + body, + ) + return - if isinstance(data, dict): - data["webhook_id"] = webhook_id + if not isinstance(data, dict): + _LOGGER.error( + "Received invalid data from IFTTT. Data needs to be a dictionary: %s", data + ) + return + + data["webhook_id"] = webhook_id hass.bus.async_fire(EVENT_RECEIVED, data) diff --git a/tests/components/ifttt/test_init.py b/tests/components/ifttt/test_init.py index 74d12ba44f4..ab5aa7ea1ad 100644 --- a/tests/components/ifttt/test_init.py +++ b/tests/components/ifttt/test_init.py @@ -33,3 +33,11 @@ async def test_config_flow_registers_webhook(hass, aiohttp_client): assert len(ifttt_events) == 1 assert ifttt_events[0].data["webhook_id"] == webhook_id assert ifttt_events[0].data["hello"] == "ifttt" + + # Invalid JSON + await client.post("/api/webhook/{}".format(webhook_id), data="not a dict") + assert len(ifttt_events) == 1 + + # Not a dict + await client.post("/api/webhook/{}".format(webhook_id), json="not a dict") + assert len(ifttt_events) == 1 From f0b5e132afe50836959fcb4a01a198b56aa5228c Mon Sep 17 00:00:00 2001 From: Tim van Cann Date: Sun, 1 Mar 2020 15:04:18 +0100 Subject: [PATCH 170/416] Bump avri api version (#32373) * Bump avri api version * Trigger CI --- homeassistant/components/avri/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/avri/manifest.json b/homeassistant/components/avri/manifest.json index 4ff77ccd2f6..7c9e7bc348c 100644 --- a/homeassistant/components/avri/manifest.json +++ b/homeassistant/components/avri/manifest.json @@ -2,7 +2,7 @@ "domain": "avri", "name": "Avri", "documentation": "https://www.home-assistant.io/integrations/avri", - "requirements": ["avri-api==0.1.6"], + "requirements": ["avri-api==0.1.7"], "dependencies": [], "codeowners": ["@timvancann"] } diff --git a/requirements_all.txt b/requirements_all.txt index fbe0cee42f0..30ea96358fc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -279,7 +279,7 @@ avea==1.4 # avion==0.10 # homeassistant.components.avri -avri-api==0.1.6 +avri-api==0.1.7 # homeassistant.components.axis axis==25 From e9206b26adc3f1f23b2906897dd2b3d6b0bd824e Mon Sep 17 00:00:00 2001 From: mezz64 <2854333+mezz64@users.noreply.github.com> Date: Sun, 1 Mar 2020 09:05:37 -0500 Subject: [PATCH 171/416] Bump pyeight to 0.1.4 (#32363) --- homeassistant/components/eight_sleep/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index 6372967b42b..ac7a11eed3c 100644 --- a/homeassistant/components/eight_sleep/manifest.json +++ b/homeassistant/components/eight_sleep/manifest.json @@ -2,7 +2,7 @@ "domain": "eight_sleep", "name": "Eight Sleep", "documentation": "https://www.home-assistant.io/integrations/eight_sleep", - "requirements": ["pyeight==0.1.3"], + "requirements": ["pyeight==0.1.4"], "dependencies": [], "codeowners": ["@mezz64"] } diff --git a/requirements_all.txt b/requirements_all.txt index 30ea96358fc..43fca2727cb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1227,7 +1227,7 @@ pyeconet==0.0.11 pyedimax==0.2.1 # homeassistant.components.eight_sleep -pyeight==0.1.3 +pyeight==0.1.4 # homeassistant.components.emby pyemby==1.6 From 7a7fdc5de6785c6bc37408642503e8632157fcf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20RAMAGE?= Date: Sun, 1 Mar 2020 15:07:14 +0100 Subject: [PATCH 172/416] Add sms support to pushbullet notification (#32347) * add sms support * fix formating * fix formating * fix whitespace * fix format --- homeassistant/components/pushbullet/notify.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py index 28cb08cc69c..a64517d2f48 100644 --- a/homeassistant/components/pushbullet/notify.py +++ b/homeassistant/components/pushbullet/notify.py @@ -95,10 +95,18 @@ class PushBulletNotificationService(BaseNotificationService): # Target is email, send directly, don't use a target object. # This also seems to work to send to all devices in own account. if ttype == "email": - self._push_data(message, title, data, self.pushbullet, tname) + self._push_data(message, title, data, self.pushbullet, email=tname) _LOGGER.info("Sent notification to email %s", tname) continue + # Target is sms, send directly, don't use a target object. + if ttype == "sms": + self._push_data( + message, title, data, self.pushbullet, phonenumber=tname + ) + _LOGGER.info("Sent sms notification to %s", tname) + continue + # Refresh if name not found. While awaiting periodic refresh # solution in component, poor mans refresh. if ttype not in self.pbtargets: @@ -120,7 +128,7 @@ class PushBulletNotificationService(BaseNotificationService): _LOGGER.error("No such target: %s/%s", ttype, tname) continue - def _push_data(self, message, title, data, pusher, email=None): + def _push_data(self, message, title, data, pusher, email=None, phonenumber=None): """Create the message content.""" if data is None: @@ -133,7 +141,10 @@ class PushBulletNotificationService(BaseNotificationService): email_kwargs = {} if email: email_kwargs["email"] = email - if url: + if phonenumber: + device = pusher.devices[0] + pusher.push_sms(device, phonenumber, message) + elif url: pusher.push_link(title, url, body=message, **email_kwargs) elif filepath: if not self.hass.config.is_allowed_path(filepath): From a9a523b05c73a06057545ed051541127936cffcf Mon Sep 17 00:00:00 2001 From: Josef Schlehofer Date: Sun, 1 Mar 2020 15:13:56 +0100 Subject: [PATCH 173/416] Upgrade youtube_dl to version 2020.03.01 (#32376) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 815a00c5223..7adf2fff505 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -2,7 +2,7 @@ "domain": "media_extractor", "name": "Media Extractor", "documentation": "https://www.home-assistant.io/integrations/media_extractor", - "requirements": ["youtube_dl==2020.02.16"], + "requirements": ["youtube_dl==2020.03.01"], "dependencies": ["media_player"], "codeowners": [], "quality_scale": "internal" diff --git a/requirements_all.txt b/requirements_all.txt index 43fca2727cb..ea649fc93a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2128,7 +2128,7 @@ yeelight==0.5.0 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2020.02.16 +youtube_dl==2020.03.01 # homeassistant.components.zengge zengge==0.2 From 0431b983d2f69452c034523e888cdfb82affab0f Mon Sep 17 00:00:00 2001 From: Colin Robbins Date: Sun, 1 Mar 2020 15:42:26 +0000 Subject: [PATCH 174/416] Add optimistic mode for somfy covers that do not support position (#31407) * Add a shadow for covers that do not support postion * Rename shadow as optimistic * Set optimisic default mode * fix black error * Remove redundant check * optimisitc variable name consistency with config --- homeassistant/components/somfy/__init__.py | 4 ++++ homeassistant/components/somfy/cover.py | 16 +++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/somfy/__init__.py b/homeassistant/components/somfy/__init__.py index aa288e13ac7..1b2722882e6 100644 --- a/homeassistant/components/somfy/__init__.py +++ b/homeassistant/components/somfy/__init__.py @@ -27,6 +27,7 @@ DOMAIN = "somfy" CONF_CLIENT_ID = "client_id" CONF_CLIENT_SECRET = "client_secret" +CONF_OPTIMISTIC = "optimisitic" SOMFY_AUTH_CALLBACK_PATH = "/auth/somfy/callback" SOMFY_AUTH_START = "/auth/somfy" @@ -37,6 +38,7 @@ CONFIG_SCHEMA = vol.Schema( { vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, } ) }, @@ -53,6 +55,8 @@ async def async_setup(hass, config): if DOMAIN not in config: return True + hass.data[DOMAIN][CONF_OPTIMISTIC] = config[DOMAIN][CONF_OPTIMISTIC] + config_flow.SomfyFlowHandler.async_register_implementation( hass, config_entry_oauth2_flow.LocalOAuth2Implementation( diff --git a/homeassistant/components/somfy/cover.py b/homeassistant/components/somfy/cover.py index b48e326162d..d0e555ed55c 100644 --- a/homeassistant/components/somfy/cover.py +++ b/homeassistant/components/somfy/cover.py @@ -8,7 +8,7 @@ from homeassistant.components.cover import ( CoverDevice, ) -from . import API, DEVICES, DOMAIN, SomfyEntity +from . import API, CONF_OPTIMISTIC, DEVICES, DOMAIN, SomfyEntity async def async_setup_entry(hass, config_entry, async_add_entities): @@ -25,7 +25,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): devices = hass.data[DOMAIN][DEVICES] return [ - SomfyCover(cover, hass.data[DOMAIN][API]) + SomfyCover( + cover, hass.data[DOMAIN][API], hass.data[DOMAIN][CONF_OPTIMISTIC] + ) for cover in devices if categories & set(cover.categories) ] @@ -36,10 +38,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class SomfyCover(SomfyEntity, CoverDevice): """Representation of a Somfy cover device.""" - def __init__(self, device, api): + def __init__(self, device, api, optimistic): """Initialize the Somfy device.""" super().__init__(device, api) self.cover = Blind(self.device, self.api) + self.optimistic = optimistic + self._closed = None async def async_update(self): """Update the device with the latest data.""" @@ -48,10 +52,14 @@ class SomfyCover(SomfyEntity, CoverDevice): def close_cover(self, **kwargs): """Close the cover.""" + if self.optimistic: + self._closed = True self.cover.close() def open_cover(self, **kwargs): """Open the cover.""" + if self.optimistic: + self._closed = False self.cover.open() def stop_cover(self, **kwargs): @@ -76,6 +84,8 @@ class SomfyCover(SomfyEntity, CoverDevice): is_closed = None if self.has_capability("position"): is_closed = self.cover.is_closed() + elif self.optimistic: + is_closed = self._closed return is_closed @property From f53c94ed2aee76123e4d0fd6b67de2d3728b8080 Mon Sep 17 00:00:00 2001 From: guillempages Date: Sun, 1 Mar 2020 16:49:07 +0100 Subject: [PATCH 175/416] Add Tankerkoenig integration (#28661) * Initial version Parse configuration, but return a fixed value * Add basic functionality Request real data from the server Currently the prices are not getting updated, but the petrol station data is real * Update values regularly The tankerkoenig values get updated regularly with real data. * Move base functionality for the sensor to a base class And move that to an own file, so that it can be inherited * Reduce calls to tankerkoenig api Use a master/slave concept for sensors; one master gets the data and updates it into the slaves. * Update requirements files * Update all gas stations at once * Remove tests directory Currently there are no tests for the integration; will be added in a future commit. * Fix slaves not being updated Let the base class regularly poll, so that slaves are also updated * Refactor entity creation Create an auxiliary method to add a station to the entity list, in preparation to allowing extra stations. * Add possibility to manually add stations Add a new configuration option "stations" to manually add extra stations * Fix style issues Make the code more pythonic * Remove redundant code Implement suggestions from the code review * Change to platform component Remove the master/slave concept, in favor of a platform with dummy sensors. The platform takes care of contacting the server and fetching updates atomically, and updating the data on the sensors. * Rename ATTR_STATE Rename the attribute to "IS_OPEN", to avoid confusion with the sensor state. * Minor updates Combine two consecutive error logs into a single one. Update the sensor's icon * Separate address into different fields * Style updates Use "[]" syntax instead of ".get()" for required parameters Use warning log level for not available fuel types * Implement review comments Fix style issues Improve error messages Remove redundant options * Refactor using DataUpdateCoordinator Use the new DataUpdateCoordinator to fetch the global data from the API, instead of implementing an own method. Also fix comments from the PR * Implement PR comments Implement suggestions to improve code readability and keep the Home Assistant style. Also separate fetching data to an async thread --- .coveragerc | 1 + CODEOWNERS | 1 + .../components/tankerkoenig/__init__.py | 203 ++++++++++++++++++ .../components/tankerkoenig/const.py | 9 + .../components/tankerkoenig/manifest.json | 10 + .../components/tankerkoenig/sensor.py | 150 +++++++++++++ requirements_all.txt | 3 + 7 files changed, 377 insertions(+) create mode 100755 homeassistant/components/tankerkoenig/__init__.py create mode 100644 homeassistant/components/tankerkoenig/const.py create mode 100755 homeassistant/components/tankerkoenig/manifest.json create mode 100755 homeassistant/components/tankerkoenig/sensor.py diff --git a/.coveragerc b/.coveragerc index 44ca6709eca..56084a049a0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -701,6 +701,7 @@ omit = homeassistant/components/tado/device_tracker.py homeassistant/components/tahoma/* homeassistant/components/tank_utility/sensor.py + homeassistant/components/tankerkoenig/* homeassistant/components/tapsaff/binary_sensor.py homeassistant/components/tautulli/sensor.py homeassistant/components/ted5000/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index d72697f65c0..30611cbf757 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -347,6 +347,7 @@ homeassistant/components/synology_srm/* @aerialls homeassistant/components/syslog/* @fabaff homeassistant/components/tado/* @michaelarnauts homeassistant/components/tahoma/* @philklei +homeassistant/components/tankerkoenig/* @guillempages homeassistant/components/tautulli/* @ludeeus homeassistant/components/tellduslive/* @fredrike homeassistant/components/template/* @PhracturedBlue @tetienne diff --git a/homeassistant/components/tankerkoenig/__init__.py b/homeassistant/components/tankerkoenig/__init__.py new file mode 100755 index 00000000000..fde2f1c57cd --- /dev/null +++ b/homeassistant/components/tankerkoenig/__init__.py @@ -0,0 +1,203 @@ +"""Ask tankerkoenig.de for petrol price information.""" +from datetime import timedelta +import logging + +import pytankerkoenig +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, +) +from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import async_load_platform + +from .const import CONF_FUEL_TYPES, CONF_STATIONS, DOMAIN, FUEL_TYPES + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_RADIUS = 2 +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + vol.Optional(CONF_FUEL_TYPES, default=FUEL_TYPES): vol.All( + cv.ensure_list, [vol.In(FUEL_TYPES)] + ), + vol.Inclusive( + CONF_LATITUDE, + "coordinates", + "Latitude and longitude must exist together", + ): cv.latitude, + vol.Inclusive( + CONF_LONGITUDE, + "coordinates", + "Latitude and longitude must exist together", + ): cv.longitude, + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.All( + cv.positive_int, vol.Range(min=1) + ), + vol.Optional(CONF_STATIONS, default=[]): vol.All( + cv.ensure_list, [cv.string] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set the tankerkoenig component up.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + _LOGGER.debug("Setting up integration") + + tankerkoenig = TankerkoenigData(hass, conf) + + latitude = conf.get(CONF_LATITUDE, hass.config.latitude) + longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) + radius = conf[CONF_RADIUS] + additional_stations = conf[CONF_STATIONS] + + setup_ok = await hass.async_add_executor_job( + tankerkoenig.setup, latitude, longitude, radius, additional_stations + ) + if not setup_ok: + _LOGGER.error("Could not setup integration") + return False + + hass.data[DOMAIN] = tankerkoenig + + hass.async_create_task( + async_load_platform( + hass, + SENSOR_DOMAIN, + DOMAIN, + discovered=tankerkoenig.stations, + hass_config=conf, + ) + ) + + return True + + +class TankerkoenigData: + """Get the latest data from the API.""" + + def __init__(self, hass, conf): + """Initialize the data object.""" + self._api_key = conf[CONF_API_KEY] + self.stations = {} + self.fuel_types = conf[CONF_FUEL_TYPES] + self.update_interval = conf[CONF_SCAN_INTERVAL] + self._hass = hass + + def setup(self, latitude, longitude, radius, additional_stations): + """Set up the tankerkoenig API. + + Read the initial data from the server, to initialize the list of fuel stations to monitor. + """ + _LOGGER.debug("Fetching data for (%s, %s) rad: %s", latitude, longitude, radius) + try: + data = pytankerkoenig.getNearbyStations( + self._api_key, latitude, longitude, radius, "all", "dist" + ) + except pytankerkoenig.customException as err: + data = {"ok": False, "message": err, "exception": True} + _LOGGER.debug("Received data: %s", data) + if not data["ok"]: + _LOGGER.error( + "Setup for sensors was unsuccessful. Error occurred while fetching data from tankerkoenig.de: %s", + data["message"], + ) + return False + + # Add stations found via location + radius + nearby_stations = data["stations"] + if not nearby_stations: + if not additional_stations: + _LOGGER.error( + "Could not find any station in range." + "Try with a bigger radius or manually specify stations in additional_stations" + ) + return False + _LOGGER.warning( + "Could not find any station in range. Will only use manually specified stations" + ) + else: + for station in data["stations"]: + self.add_station(station) + + # Add manually specified additional stations + for station_id in additional_stations: + try: + additional_station_data = pytankerkoenig.getStationData( + self._api_key, station_id + ) + except pytankerkoenig.customException as err: + additional_station_data = { + "ok": False, + "message": err, + "exception": True, + } + + if not additional_station_data["ok"]: + _LOGGER.error( + "Error when adding station %s:\n %s", + station_id, + additional_station_data["message"], + ) + return False + self.add_station(additional_station_data["station"]) + return True + + async def fetch_data(self): + """Get the latest data from tankerkoenig.de.""" + _LOGGER.debug("Fetching new data from tankerkoenig.de") + station_ids = list(self.stations) + data = await self._hass.async_add_executor_job( + pytankerkoenig.getPriceList, self._api_key, station_ids + ) + + if data["ok"]: + _LOGGER.debug("Received data: %s", data) + if "prices" not in data: + _LOGGER.error("Did not receive price information from tankerkoenig.de") + raise TankerkoenigError("No prices in data") + else: + _LOGGER.error( + "Error fetching data from tankerkoenig.de: %s", data["message"] + ) + raise TankerkoenigError(data["message"]) + return data["prices"] + + def add_station(self, station: dict): + """Add fuel station to the entity list.""" + station_id = station["id"] + if station_id in self.stations: + _LOGGER.warning( + "Sensor for station with id %s was already created", station_id + ) + return + + self.stations[station_id] = station + _LOGGER.debug("add_station called for station: %s", station) + + +class TankerkoenigError(HomeAssistantError): + """An error occurred while contacting tankerkoenig.de.""" diff --git a/homeassistant/components/tankerkoenig/const.py b/homeassistant/components/tankerkoenig/const.py new file mode 100644 index 00000000000..04e6e08ba37 --- /dev/null +++ b/homeassistant/components/tankerkoenig/const.py @@ -0,0 +1,9 @@ +"""Constants for the tankerkoenig integration.""" + +DOMAIN = "tankerkoenig" +NAME = "tankerkoenig" + +CONF_FUEL_TYPES = "fuel_types" +CONF_STATIONS = "stations" + +FUEL_TYPES = ["e5", "e10", "diesel"] diff --git a/homeassistant/components/tankerkoenig/manifest.json b/homeassistant/components/tankerkoenig/manifest.json new file mode 100755 index 00000000000..1b22e62d5ef --- /dev/null +++ b/homeassistant/components/tankerkoenig/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "tankerkoenig", + "name": "Tankerkoenig", + "documentation": "https://www.home-assistant.io/integrations/tankerkoenig", + "requirements": ["pytankerkoenig==0.0.6"], + "dependencies": [], + "codeowners": [ + "@guillempages" + ] +} diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py new file mode 100755 index 00000000000..c9e25d94a4b --- /dev/null +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -0,0 +1,150 @@ +"""Tankerkoenig sensor integration.""" + +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 .const import DOMAIN, NAME + +_LOGGER = logging.getLogger(__name__) + +ATTR_BRAND = "brand" +ATTR_CITY = "city" +ATTR_FUEL_TYPE = "fuel_type" +ATTR_HOUSE_NUMBER = "house_number" +ATTR_IS_OPEN = "is_open" +ATTR_POSTCODE = "postcode" +ATTR_STATION_NAME = "station_name" +ATTR_STREET = "street" +ATTRIBUTION = "Data provided by https://creativecommons.tankerkoenig.de" + +ICON = "mdi:gas-station" + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the tankerkoenig sensors.""" + + if discovery_info is None: + return + + tankerkoenig = hass.data[DOMAIN] + + async def async_update_data(): + """Fetch data from API endpoint.""" + try: + return await tankerkoenig.fetch_data() + except LookupError: + raise UpdateFailed + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=NAME, + update_method=async_update_data, + update_interval=tankerkoenig.update_interval, + ) + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + stations = discovery_info.values() + entities = [] + for station in stations: + for fuel in tankerkoenig.fuel_types: + if fuel not in station: + _LOGGER.warning( + "Station %s does not offer %s fuel", station["id"], fuel + ) + continue + sensor = FuelPriceSensor( + fuel, station, coordinator, f"{NAME}_{station['name']}_{fuel}" + ) + entities.append(sensor) + _LOGGER.debug("Added sensors %s", entities) + + async_add_entities(entities) + + +class FuelPriceSensor(Entity): + """Contains prices for fuel in a given station.""" + + def __init__(self, fuel_type, station, coordinator, name): + """Initialize the sensor.""" + 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"] + self._city = station["place"] + self._house_number = station["houseNumber"] + self._postcode = station["postCode"] + self._street = station["street"] + self._price = station[fuel_type] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Icon to use in the frontend.""" + return ICON + + @property + def unit_of_measurement(self): + """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) + + @property + def device_state_attributes(self): + """Return the attributes of the device.""" + data = self._coordinator.data[self._station_id] + + attrs = { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_BRAND: self._station["brand"], + ATTR_FUEL_TYPE: self._fuel_type, + ATTR_STATION_NAME: self._station["name"], + ATTR_STREET: self._street, + ATTR_HOUSE_NUMBER: self._house_number, + ATTR_POSTCODE: self._postcode, + ATTR_CITY: self._city, + ATTR_LATITUDE: self._latitude, + ATTR_LONGITUDE: self._longitude, + } + 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/requirements_all.txt b/requirements_all.txt index ea649fc93a0..7a120543c36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1547,6 +1547,9 @@ pysupla==0.0.3 # homeassistant.components.syncthru pysyncthru==0.5.0 +# homeassistant.components.tankerkoenig +pytankerkoenig==0.0.6 + # homeassistant.components.tautulli pytautulli==0.5.0 From 9da5bc9dcec0b2ddec47c6b7c5937545b676cdcf Mon Sep 17 00:00:00 2001 From: AJ Schmidt Date: Sun, 1 Mar 2020 14:25:16 -0500 Subject: [PATCH 176/416] Fix AlarmDecoder Integration to use Instant Mode for alarm_arm_night (#32292) --- homeassistant/components/alarmdecoder/alarm_control_panel.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index e217bcb6cf9..06783df674d 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -177,7 +177,7 @@ class AlarmDecoderAlarmPanel(AlarmControlPanel): def alarm_arm_night(self, code=None): """Send arm night command.""" if code: - self.hass.data[DATA_AD].send(f"{code!s}33") + self.hass.data[DATA_AD].send(f"{code!s}7") def alarm_toggle_chime(self, code=None): """Send toggle chime command.""" From fc98faa4252ee0b37b95d35064580c06f2d23a2d Mon Sep 17 00:00:00 2001 From: David Nielsen Date: Sun, 1 Mar 2020 16:07:44 -0500 Subject: [PATCH 177/416] Add Media Stop Support to BraviaTV Mediaplayer (#32220) * Add media_stop to braviatv mediaplayer * Fix typo in media_stop() Co-authored-by: springstan <46536646+springstan@users.noreply.github.com> --- homeassistant/components/braviatv/manifest.json | 2 +- homeassistant/components/braviatv/media_player.py | 7 +++++++ requirements_all.txt | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json index 8bfa48b9195..4945614ed7f 100644 --- a/homeassistant/components/braviatv/manifest.json +++ b/homeassistant/components/braviatv/manifest.json @@ -2,7 +2,7 @@ "domain": "braviatv", "name": "Sony Bravia TV", "documentation": "https://www.home-assistant.io/integrations/braviatv", - "requirements": ["bravia-tv==1.0", "getmac==0.8.1"], + "requirements": ["bravia-tv==1.0.1", "getmac==0.8.1"], "dependencies": ["configurator"], "codeowners": ["@robbiet480"] } diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py index 67feb8bfc48..2916bb319f8 100644 --- a/homeassistant/components/braviatv/media_player.py +++ b/homeassistant/components/braviatv/media_player.py @@ -13,6 +13,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, + SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, @@ -47,6 +48,7 @@ SUPPORT_BRAVIA = ( | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE | SUPPORT_PLAY + | SUPPORT_STOP ) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -351,6 +353,11 @@ class BraviaTVDevice(MediaPlayerDevice): self._playing = False self._braviarc.media_pause() + def media_stop(self): + """Send media stop command to media player.""" + self._playing = False + self._braviarc.media_stop() + def media_next_track(self): """Send next track command.""" self._braviarc.media_next_track() diff --git a/requirements_all.txt b/requirements_all.txt index 7a120543c36..9d42de37cda 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -344,7 +344,7 @@ bomradarloop==0.1.3 boto3==1.9.252 # homeassistant.components.braviatv -bravia-tv==1.0 +bravia-tv==1.0.1 # homeassistant.components.broadlink broadlink==0.12.0 From e13d5bdc10341d14400ccccedd67ef38ade51120 Mon Sep 17 00:00:00 2001 From: Ziv <16467659+ziv1234@users.noreply.github.com> Date: Sun, 1 Mar 2020 16:44:24 -0500 Subject: [PATCH 178/416] Refactor dynalite integration for multi-platform (#32335) * refactoring for multi platform * adopted test_bridge to refactoring * refactoring tests for multi-platform additional coverage in config and init * comment for clarity * more specific imports from lib * library version bump * removed async_update * changed parameter order to start with hass * removed pylint disable * unsubscribe from signal dispatcher inherit from Entity * use device.unique_id * changed hass_obj to hass * added test for remove entity bug fix from the test * removed the polling try_connect. hate polling... it is now part of the async_setup() significantly makes the code clearer and simplifies the tests * removed leftover debug logs in the library * changed tests to get the entry_id from hass * changed place to assign hass.data only after success * fixes for test_init * removed assert * removed device_info * removed bridge internal from common * modified test_bridge to work without the bridge directly * removed bridge from test_existing_update * changed update to not use bridge internals * dyn_bridge fixture no longer used - removed --- homeassistant/components/dynalite/__init__.py | 7 +- homeassistant/components/dynalite/bridge.py | 48 +++---- .../components/dynalite/config_flow.py | 2 - homeassistant/components/dynalite/const.py | 3 + .../components/dynalite/dynalitebase.py | 85 +++++++++++++ homeassistant/components/dynalite/light.py | 66 +--------- .../components/dynalite/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/dynalite/common.py | 65 +++++++++- tests/components/dynalite/test_bridge.py | 120 +++++++++--------- tests/components/dynalite/test_config_flow.py | 64 +++++----- tests/components/dynalite/test_init.py | 77 +++++++---- tests/components/dynalite/test_light.py | 85 ++++--------- 14 files changed, 348 insertions(+), 280 deletions(-) create mode 100755 homeassistant/components/dynalite/dynalitebase.py diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 618268206b0..1c4ba99d1a4 100755 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -120,16 +120,11 @@ async def async_setup_entry(hass, entry): """Set up a bridge from a config entry.""" LOGGER.debug("Setting up entry %s", entry.data) bridge = DynaliteBridge(hass, entry.data) - hass.data[DOMAIN][entry.entry_id] = bridge entry.add_update_listener(async_entry_changed) if not await bridge.async_setup(): LOGGER.error("Could not set up bridge for entry %s", entry.data) - hass.data[DOMAIN].pop(entry.entry_id) - return False - if not await bridge.try_connection(): - LOGGER.error("Could not connect with entry %s", entry) - hass.data[DOMAIN].pop(entry.entry_id) raise ConfigEntryNotReady + hass.data[DOMAIN][entry.entry_id] = bridge hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, "light") ) diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index f2ffe447d6c..85a187249df 100755 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -1,16 +1,11 @@ """Code to handle a Dynalite bridge.""" -import asyncio - -from dynalite_devices_lib import DynaliteDevices +from dynalite_devices_lib.dynalite_devices import DynaliteDevices from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import CONF_ALL, CONF_HOST, LOGGER - -CONNECT_TIMEOUT = 30 -CONNECT_INTERVAL = 1 +from .const import CONF_ALL, CONF_HOST, ENTITY_PLATFORMS, LOGGER class DynaliteBridge: @@ -20,8 +15,8 @@ class DynaliteBridge: """Initialize the system based on host parameter.""" self.hass = hass self.area = {} - self.async_add_devices = None - self.waiting_devices = [] + self.async_add_devices = {} + self.waiting_devices = {} self.host = config[CONF_HOST] # Configure the dynalite devices self.dynalite_devices = DynaliteDevices( @@ -38,7 +33,7 @@ class DynaliteBridge: async def reload_config(self, config): """Reconfigure a bridge when config changes.""" - LOGGER.debug("Setting up bridge - host %s, config %s", self.host, config) + LOGGER.debug("Reloading bridge - host %s, config %s", self.host, config) self.dynalite_devices.configure(config) def update_signal(self, device=None): @@ -62,27 +57,22 @@ class DynaliteBridge: else: async_dispatcher_send(self.hass, self.update_signal(device)) - async def try_connection(self): - """Try to connect to dynalite with timeout.""" - # Currently by polling. Future - will need to change the library to be proactive - for _ in range(0, CONNECT_TIMEOUT): - if self.dynalite_devices.available: - return True - await asyncio.sleep(CONNECT_INTERVAL) - return False - @callback - def register_add_devices(self, async_add_devices): + def register_add_devices(self, platform, async_add_devices): """Add an async_add_entities for a category.""" - self.async_add_devices = async_add_devices - if self.waiting_devices: - self.async_add_devices(self.waiting_devices) + self.async_add_devices[platform] = async_add_devices + if platform in self.waiting_devices: + self.async_add_devices[platform](self.waiting_devices[platform]) def add_devices_when_registered(self, devices): """Add the devices to HA if the add devices callback was registered, otherwise queue until it is.""" - if not devices: - return - if self.async_add_devices: - self.async_add_devices(devices) - else: # handle it later when it is registered - self.waiting_devices.extend(devices) + for platform in ENTITY_PLATFORMS: + platform_devices = [ + device for device in devices if device.category == platform + ] + if platform in self.async_add_devices: + self.async_add_devices[platform](platform_devices) + else: # handle it later when it is registered + if platform not in self.waiting_devices: + self.waiting_devices[platform] = [] + self.waiting_devices[platform].extend(platform_devices) diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index c5508bc8db2..10d66c82d52 100755 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -31,8 +31,6 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): bridge = DynaliteBridge(self.hass, import_info) if not await bridge.async_setup(): LOGGER.error("Unable to setup bridge - import info=%s", import_info) - return self.async_abort(reason="bridge_setup_failed") - if not await bridge.try_connection(): return self.async_abort(reason="no_connection") LOGGER.debug("Creating entry for the bridge - %s", import_info) return self.async_create_entry(title=host, data=import_info) diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py index f7795554465..2e86d49c825 100755 --- a/homeassistant/components/dynalite/const.py +++ b/homeassistant/components/dynalite/const.py @@ -4,6 +4,8 @@ import logging LOGGER = logging.getLogger(__package__) DOMAIN = "dynalite" +ENTITY_PLATFORMS = ["light"] + CONF_ACTIVE = "active" CONF_ALL = "ALL" CONF_AREA = "area" @@ -17,5 +19,6 @@ CONF_NAME = "name" CONF_POLLTIMER = "polltimer" CONF_PORT = "port" + DEFAULT_NAME = "dynalite" DEFAULT_PORT = 12345 diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py new file mode 100755 index 00000000000..8bb1ab2dc42 --- /dev/null +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -0,0 +1,85 @@ +"""Support for the Dynalite devices as entities.""" +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, LOGGER + + +def async_setup_entry_base( + hass, config_entry, async_add_entities, platform, entity_from_device +): + """Record the async_add_entities function to add them later when received from Dynalite.""" + LOGGER.debug("Setting up %s entry = %s", platform, config_entry.data) + bridge = hass.data[DOMAIN][config_entry.entry_id] + + @callback + def async_add_entities_platform(devices): + # assumes it is called with a single platform + added_entities = [] + for device in devices: + if device.category == platform: + added_entities.append(entity_from_device(device, bridge)) + if added_entities: + async_add_entities(added_entities) + + bridge.register_add_devices(platform, async_add_entities_platform) + + +class DynaliteBase(Entity): + """Base class for the Dynalite entities.""" + + def __init__(self, device, bridge): + """Initialize the base class.""" + self._device = device + self._bridge = bridge + self._unsub_dispatchers = [] + + @property + def name(self): + """Return the name of the entity.""" + return self._device.name + + @property + def unique_id(self): + """Return the unique ID of the entity.""" + return self._device.unique_id + + @property + def available(self): + """Return if entity is available.""" + return self._device.available + + @property + def device_info(self): + """Device info for this entity.""" + return { + "identifiers": {(DOMAIN, self._device.unique_id)}, + "name": self.name, + "manufacturer": "Dynalite", + } + + async def async_added_to_hass(self): + """Added to hass so need to register to dispatch.""" + # register for device specific update + self._unsub_dispatchers.append( + async_dispatcher_connect( + self.hass, + self._bridge.update_signal(self._device), + self.async_schedule_update_ha_state, + ) + ) + # register for wide update + self._unsub_dispatchers.append( + async_dispatcher_connect( + self.hass, + self._bridge.update_signal(), + self.async_schedule_update_ha_state, + ) + ) + + async def async_will_remove_from_hass(self): + """Unregister signal dispatch listeners when being removed.""" + for unsub in self._unsub_dispatchers: + unsub() + self._unsub_dispatchers = [] diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py index 652a6178705..caa39ad573a 100755 --- a/homeassistant/components/dynalite/light.py +++ b/homeassistant/components/dynalite/light.py @@ -1,64 +1,25 @@ """Support for Dynalite channels as lights.""" from homeassistant.components.light import SUPPORT_BRIGHTNESS, Light from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from .const import DOMAIN, LOGGER +from .dynalitebase import DynaliteBase, async_setup_entry_base async def async_setup_entry(hass, config_entry, async_add_entities): """Record the async_add_entities function to add them later when received from Dynalite.""" - LOGGER.debug("Setting up light entry = %s", config_entry.data) - bridge = hass.data[DOMAIN][config_entry.entry_id] @callback - def async_add_lights(devices): - added_lights = [] - for device in devices: - if device.category == "light": - added_lights.append(DynaliteLight(device, bridge)) - if added_lights: - async_add_entities(added_lights) + def light_from_device(device, bridge): + return DynaliteLight(device, bridge) - bridge.register_add_devices(async_add_lights) + async_setup_entry_base( + hass, config_entry, async_add_entities, "light", light_from_device + ) -class DynaliteLight(Light): +class DynaliteLight(DynaliteBase, Light): """Representation of a Dynalite Channel as a Home Assistant Light.""" - def __init__(self, device, bridge): - """Initialize the base class.""" - self._device = device - self._bridge = bridge - - @property - def name(self): - """Return the name of the entity.""" - return self._device.name - - @property - def unique_id(self): - """Return the unique ID of the entity.""" - return self._device.unique_id - - @property - def available(self): - """Return if entity is available.""" - return self._device.available - - async def async_update(self): - """Update the entity.""" - return - - @property - def device_info(self): - """Device info for this entity.""" - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "manufacturer": "Dynalite", - } - @property def brightness(self): """Return the brightness of this light between 0..255.""" @@ -81,16 +42,3 @@ class DynaliteLight(Light): def supported_features(self): """Flag supported features.""" return SUPPORT_BRIGHTNESS - - async def async_added_to_hass(self): - """Added to hass so need to register to dispatch.""" - # register for device specific update - async_dispatcher_connect( - self.hass, - self._bridge.update_signal(self._device), - self.async_schedule_update_ha_state, - ) - # register for wide update - async_dispatcher_connect( - self.hass, self._bridge.update_signal(), self.async_schedule_update_ha_state - ) diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json index fc552ea6ad1..18f1ebed919 100755 --- a/homeassistant/components/dynalite/manifest.json +++ b/homeassistant/components/dynalite/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/dynalite", "dependencies": [], "codeowners": ["@ziv1234"], - "requirements": ["dynalite_devices==0.1.26"] + "requirements": ["dynalite_devices==0.1.30"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9d42de37cda..b17cfce1ff4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -463,7 +463,7 @@ dsmr_parser==0.18 dweepy==0.3.0 # homeassistant.components.dynalite -dynalite_devices==0.1.26 +dynalite_devices==0.1.30 # homeassistant.components.rainforest_eagle eagle200_reader==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 59b51e13062..de43f8f9d21 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -171,7 +171,7 @@ distro==1.4.0 dsmr_parser==0.18 # homeassistant.components.dynalite -dynalite_devices==0.1.26 +dynalite_devices==0.1.30 # homeassistant.components.ee_brightbox eebrightbox==0.0.4 diff --git a/tests/components/dynalite/common.py b/tests/components/dynalite/common.py index 3bdf3a60dd7..97750140811 100755 --- a/tests/components/dynalite/common.py +++ b/tests/components/dynalite/common.py @@ -1,9 +1,64 @@ -"""Common functions for the Dynalite tests.""" +"""Common functions for tests.""" +from asynctest import CoroutineMock, Mock, call, patch from homeassistant.components import dynalite +from homeassistant.helpers import entity_registry + +from tests.common import MockConfigEntry + +ATTR_SERVICE = "service" +ATTR_METHOD = "method" +ATTR_ARGS = "args" -def get_bridge_from_hass(hass_obj): - """Get the bridge from hass.data.""" - key = next(iter(hass_obj.data[dynalite.DOMAIN])) - return hass_obj.data[dynalite.DOMAIN][key] +def create_mock_device(platform, spec): + """Create a dynalite mock device for a platform according to a spec.""" + device = Mock(spec=spec) + device.category = platform + device.unique_id = "UNIQUE" + device.name = "NAME" + device.device_class = "Device Class" + return device + + +async def get_entry_id_from_hass(hass): + """Get the config entry id from hass.""" + ent_reg = await entity_registry.async_get_registry(hass) + assert ent_reg + conf_entries = hass.config_entries.async_entries(dynalite.DOMAIN) + assert len(conf_entries) == 1 + return conf_entries[0].entry_id + + +async def create_entity_from_device(hass, device): + """Set up the component and platform and create a light based on the device provided.""" + 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 = CoroutineMock(return_value=True) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + new_device_func = mock_dyn_dev.mock_calls[1][2]["newDeviceFunc"] + new_device_func([device]) + await hass.async_block_till_done() + + +async def run_service_tests(hass, device, platform, services): + """Run a series of service calls and check that the entity and device behave correctly.""" + for cur_item in services: + service = cur_item[ATTR_SERVICE] + args = cur_item.get(ATTR_ARGS, {}) + service_data = {"entity_id": f"{platform}.name", **args} + await hass.services.async_call(platform, service, service_data, blocking=True) + await hass.async_block_till_done() + for check_item in services: + check_method = getattr(device, check_item[ATTR_METHOD]) + if check_item[ATTR_SERVICE] == service: + check_method.assert_called_once() + assert check_method.mock_calls == [call(**args)] + check_method.reset_mock() + else: + check_method.assert_not_called() diff --git a/tests/components/dynalite/test_bridge.py b/tests/components/dynalite/test_bridge.py index 7b3a8312402..0c9ea517992 100755 --- a/tests/components/dynalite/test_bridge.py +++ b/tests/components/dynalite/test_bridge.py @@ -1,81 +1,85 @@ """Test Dynalite bridge.""" -from unittest.mock import Mock, call -from asynctest import patch -from dynalite_devices_lib import CONF_ALL -import pytest +from asynctest import CoroutineMock, Mock, patch +from dynalite_devices_lib.const import CONF_ALL from homeassistant.components import dynalite +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from tests.common import MockConfigEntry -@pytest.fixture -def dyn_bridge(): - """Define a basic mock bridge.""" - hass = Mock() +async def test_update_device(hass): + """Test that update works.""" host = "1.2.3.4" - bridge = dynalite.DynaliteBridge(hass, {dynalite.CONF_HOST: host}) - return bridge - - -async def test_update_device(dyn_bridge): - """Test a successful setup.""" - async_dispatch = Mock() - + entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host}) + entry.add_to_hass(hass) with patch( - "homeassistant.components.dynalite.bridge.async_dispatcher_send", async_dispatch - ): - dyn_bridge.update_device(CONF_ALL) - async_dispatch.assert_called_once() - assert async_dispatch.mock_calls[0] == call( - dyn_bridge.hass, f"dynalite-update-{dyn_bridge.host}" - ) - async_dispatch.reset_mock() - device = Mock - device.unique_id = "abcdef" - dyn_bridge.update_device(device) - async_dispatch.assert_called_once() - assert async_dispatch.mock_calls[0] == call( - dyn_bridge.hass, f"dynalite-update-{dyn_bridge.host}-{device.unique_id}" - ) + "homeassistant.components.dynalite.bridge.DynaliteDevices" + ) as mock_dyn_dev: + mock_dyn_dev().async_setup = CoroutineMock(return_value=True) + assert await hass.config_entries.async_setup(entry.entry_id) + # Not waiting so it add the devices before registration + update_device_func = mock_dyn_dev.mock_calls[1][2]["updateDeviceFunc"] + device = Mock() + device.unique_id = "abcdef" + wide_func = Mock() + async_dispatcher_connect(hass, f"dynalite-update-{host}", wide_func) + specific_func = Mock() + async_dispatcher_connect( + hass, f"dynalite-update-{host}-{device.unique_id}", specific_func + ) + update_device_func(CONF_ALL) + await hass.async_block_till_done() + wide_func.assert_called_once() + specific_func.assert_not_called() + update_device_func(device) + await hass.async_block_till_done() + wide_func.assert_called_once() + specific_func.assert_called_once() -async def test_add_devices_then_register(dyn_bridge): +async def test_add_devices_then_register(hass): """Test that add_devices work.""" - # First test empty - dyn_bridge.add_devices_when_registered([]) - assert not dyn_bridge.waiting_devices + 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 = CoroutineMock(return_value=True) + assert await hass.config_entries.async_setup(entry.entry_id) + # Not waiting so it add the devices before registration + new_device_func = mock_dyn_dev.mock_calls[1][2]["newDeviceFunc"] # Now with devices device1 = Mock() device1.category = "light" + device1.name = "NAME" device2 = Mock() device2.category = "switch" - dyn_bridge.add_devices_when_registered([device1, device2]) - reg_func = Mock() - dyn_bridge.register_add_devices(reg_func) - reg_func.assert_called_once() - assert reg_func.mock_calls[0][1][0][0] is device1 + new_device_func([device1, device2]) + await hass.async_block_till_done() + assert hass.states.get("light.name") -async def test_register_then_add_devices(dyn_bridge): +async def test_register_then_add_devices(hass): """Test that add_devices work after register_add_entities.""" + 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 = CoroutineMock(return_value=True) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + new_device_func = mock_dyn_dev.mock_calls[1][2]["newDeviceFunc"] + # Now with devices device1 = Mock() device1.category = "light" + device1.name = "NAME" device2 = Mock() device2.category = "switch" - reg_func = Mock() - dyn_bridge.register_add_devices(reg_func) - dyn_bridge.add_devices_when_registered([device1, device2]) - reg_func.assert_called_once() - assert reg_func.mock_calls[0][1][0][0] is device1 - - -async def test_try_connection(dyn_bridge): - """Test that try connection works.""" - # successful - with patch.object(dyn_bridge.dynalite_devices, "connected", True): - assert await dyn_bridge.try_connection() - # unsuccessful - with patch.object(dyn_bridge.dynalite_devices, "connected", False), patch( - "homeassistant.components.dynalite.bridge.CONNECT_INTERVAL", 0 - ): - assert not await dyn_bridge.try_connection() + new_device_func([device1, device2]) + await hass.async_block_till_done() + assert hass.states.get("light.name") diff --git a/tests/components/dynalite/test_config_flow.py b/tests/components/dynalite/test_config_flow.py index fb8530aec1e..96e361e260f 100755 --- a/tests/components/dynalite/test_config_flow.py +++ b/tests/components/dynalite/test_config_flow.py @@ -1,53 +1,50 @@ """Test Dynalite config flow.""" -from asynctest import patch + +from asynctest import CoroutineMock, patch from homeassistant import config_entries from homeassistant.components import dynalite -from .common import get_bridge_from_hass - from tests.common import MockConfigEntry -async def run_flow(hass, setup, connection): +async def run_flow(hass, connection): """Run a flow with or without errors and return result.""" host = "1.2.3.4" with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", - return_value=setup, - ), patch( - "homeassistant.components.dynalite.bridge.DynaliteDevices.available", connection - ), patch( - "homeassistant.components.dynalite.bridge.CONNECT_INTERVAL", 0 + side_effect=connection, ): result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={dynalite.CONF_HOST: host}, ) + await hass.async_block_till_done() return result async def test_flow_works(hass): """Test a successful config flow.""" - result = await run_flow(hass, True, True) + result = await run_flow(hass, [True, True]) assert result["type"] == "create_entry" + assert result["result"].state == "loaded" async def test_flow_setup_fails(hass): """Test a flow where async_setup fails.""" - result = await run_flow(hass, False, True) - assert result["type"] == "abort" - assert result["reason"] == "bridge_setup_failed" - - -async def test_flow_no_connection(hass): - """Test a flow where connection times out.""" - result = await run_flow(hass, True, False) + result = await run_flow(hass, [False]) assert result["type"] == "abort" assert result["reason"] == "no_connection" +async def test_flow_setup_fails_in_setup_entry(hass): + """Test a flow where the initial check works but inside setup_entry, the bridge setup fails.""" + result = await run_flow(hass, [True, False]) + assert result["type"] == "create_entry" + assert result["result"].state == "setup_retry" + + async def test_existing(hass): """Test when the entry exists with the same config.""" host = "1.2.3.4" @@ -57,8 +54,6 @@ async def test_existing(hass): with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", return_value=True, - ), patch( - "homeassistant.components.dynalite.bridge.DynaliteDevices.available", True ): result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, @@ -70,31 +65,30 @@ async def test_existing(hass): async def test_existing_update(hass): - """Test when the entry exists with the same config.""" + """Test when the entry exists with a different config.""" host = "1.2.3.4" port1 = 7777 port2 = 8888 + entry = MockConfigEntry( + domain=dynalite.DOMAIN, + data={dynalite.CONF_HOST: host, dynalite.CONF_PORT: port1}, + ) + entry.add_to_hass(hass) with patch( - "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", - return_value=True, - ), patch( - "homeassistant.components.dynalite.bridge.DynaliteDevices.available", True - ): - assert await hass.config_entries.flow.async_init( - dynalite.DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={dynalite.CONF_HOST: host, dynalite.CONF_PORT: port1}, - ) + "homeassistant.components.dynalite.bridge.DynaliteDevices" + ) as mock_dyn_dev: + mock_dyn_dev().async_setup = CoroutineMock(return_value=True) + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - old_bridge = get_bridge_from_hass(hass) - assert old_bridge.dynalite_devices.port == port1 + mock_dyn_dev().configure.assert_called_once() + assert mock_dyn_dev().configure.mock_calls[0][1][0][dynalite.CONF_PORT] == port1 result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data={dynalite.CONF_HOST: host, dynalite.CONF_PORT: port2}, ) await hass.async_block_till_done() + assert mock_dyn_dev().configure.call_count == 2 + assert mock_dyn_dev().configure.mock_calls[1][1][0][dynalite.CONF_PORT] == port2 assert result["type"] == "abort" assert result["reason"] == "already_configured" - bridge = get_bridge_from_hass(hass) - assert bridge.dynalite_devices.port == port2 diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py index d8ef0d7d259..6c9309cb4e5 100755 --- a/tests/components/dynalite/test_init.py +++ b/tests/components/dynalite/test_init.py @@ -1,6 +1,7 @@ """Test Dynalite __init__.""" -from asynctest import patch + +from asynctest import call, patch from homeassistant.components import dynalite from homeassistant.setup import async_setup_component @@ -12,51 +13,75 @@ async def test_empty_config(hass): """Test with an empty config.""" assert await async_setup_component(hass, dynalite.DOMAIN, {}) is True assert len(hass.config_entries.flow.async_progress()) == 0 - assert hass.data[dynalite.DOMAIN] == {} + assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 0 async def test_async_setup(hass): """Test a successful setup.""" host = "1.2.3.4" with patch( - "dynalite_devices_lib.DynaliteDevices.async_setup", return_value=True - ), patch("dynalite_devices_lib.DynaliteDevices.available", True): + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): assert await async_setup_component( hass, dynalite.DOMAIN, - {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}}, + { + dynalite.DOMAIN: { + dynalite.CONF_BRIDGES: [ + { + dynalite.CONF_HOST: host, + dynalite.CONF_AREA: {"1": {dynalite.CONF_NAME: "Name"}}, + } + ] + } + }, ) - - assert len(hass.data[dynalite.DOMAIN]) == 1 + await hass.async_block_till_done() + assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 1 -async def test_async_setup_failed(hass): - """Test a setup when DynaliteBridge.async_setup fails.""" +async def test_async_setup_bad_config2(hass): + """Test a successful with bad config on numbers.""" host = "1.2.3.4" - with patch("dynalite_devices_lib.DynaliteDevices.async_setup", return_value=False): - assert await async_setup_component( + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert not await async_setup_component( hass, dynalite.DOMAIN, - {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}}, + { + dynalite.DOMAIN: { + dynalite.CONF_BRIDGES: [ + { + dynalite.CONF_HOST: host, + dynalite.CONF_AREA: {"WRONG": {dynalite.CONF_NAME: "Name"}}, + } + ] + } + }, ) - assert hass.data[dynalite.DOMAIN] == {} + await hass.async_block_till_done() + assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 0 async def test_unload_entry(hass): """Test being able to unload an entry.""" host = "1.2.3.4" - entry = MockConfigEntry(domain=dynalite.DOMAIN, data={"host": host}) + entry = MockConfigEntry(domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host}) entry.add_to_hass(hass) - with patch( - "dynalite_devices_lib.DynaliteDevices.async_setup", return_value=True - ), patch("dynalite_devices_lib.DynaliteDevices.available", True): - assert await async_setup_component( - hass, - dynalite.DOMAIN, - {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}}, - ) - assert hass.data[dynalite.DOMAIN].get(entry.entry_id) - - assert await hass.config_entries.async_unload(entry.entry_id) - assert not hass.data[dynalite.DOMAIN].get(entry.entry_id) + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert len(hass.config_entries.async_entries(dynalite.DOMAIN)) == 1 + with patch.object( + hass.config_entries, "async_forward_entry_unload", return_value=True + ) as mock_unload: + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + mock_unload.assert_called_once() + assert mock_unload.mock_calls == [call(entry, "light")] diff --git a/tests/components/dynalite/test_light.py b/tests/components/dynalite/test_light.py index 9934bac8720..deea32d2e34 100755 --- a/tests/components/dynalite/test_light.py +++ b/tests/components/dynalite/test_light.py @@ -1,78 +1,49 @@ """Test Dynalite light.""" -from unittest.mock import Mock -from asynctest import CoroutineMock, patch +from dynalite_devices_lib.light import DynaliteChannelLightDevice import pytest -from homeassistant.components import dynalite from homeassistant.components.light import SUPPORT_BRIGHTNESS -from homeassistant.setup import async_setup_component + +from .common import ( + ATTR_METHOD, + ATTR_SERVICE, + create_entity_from_device, + create_mock_device, + get_entry_id_from_hass, + run_service_tests, +) @pytest.fixture def mock_device(): """Mock a Dynalite device.""" - device = Mock() - device.category = "light" - device.unique_id = "UNIQUE" - device.name = "NAME" - device.device_info = { - "identifiers": {(dynalite.DOMAIN, device.unique_id)}, - "name": device.name, - "manufacturer": "Dynalite", - } - return device - - -async def create_light_from_device(hass, device): - """Set up the component and platform and create a light based on the device provided.""" - host = "1.2.3.4" - with patch( - "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", - return_value=True, - ), patch( - "homeassistant.components.dynalite.bridge.DynaliteDevices.available", True - ): - assert await async_setup_component( - hass, - dynalite.DOMAIN, - {dynalite.DOMAIN: {dynalite.CONF_BRIDGES: [{dynalite.CONF_HOST: host}]}}, - ) - await hass.async_block_till_done() - # Find the bridge - bridge = None - assert len(hass.data[dynalite.DOMAIN]) == 1 - key = next(iter(hass.data[dynalite.DOMAIN])) - bridge = hass.data[dynalite.DOMAIN][key] - bridge.dynalite_devices.newDeviceFunc([device]) - await hass.async_block_till_done() + return create_mock_device("light", DynaliteChannelLightDevice) async def test_light_setup(hass, mock_device): """Test a successful setup.""" - await create_light_from_device(hass, mock_device) + await create_entity_from_device(hass, mock_device) entity_state = hass.states.get("light.name") + assert entity_state.attributes["friendly_name"] == mock_device.name assert entity_state.attributes["brightness"] == mock_device.brightness assert entity_state.attributes["supported_features"] == SUPPORT_BRIGHTNESS - - -async def test_turn_on(hass, mock_device): - """Test turning a light on.""" - mock_device.async_turn_on = CoroutineMock(return_value=True) - await create_light_from_device(hass, mock_device) - await hass.services.async_call( - "light", "turn_on", {"entity_id": "light.name"}, blocking=True + await run_service_tests( + hass, + mock_device, + "light", + [ + {ATTR_SERVICE: "turn_on", ATTR_METHOD: "async_turn_on"}, + {ATTR_SERVICE: "turn_off", ATTR_METHOD: "async_turn_off"}, + ], ) - await hass.async_block_till_done() - mock_device.async_turn_on.assert_awaited_once() -async def test_turn_off(hass, mock_device): - """Test turning a light off.""" - mock_device.async_turn_off = CoroutineMock(return_value=True) - await create_light_from_device(hass, mock_device) - await hass.services.async_call( - "light", "turn_off", {"entity_id": "light.name"}, blocking=True - ) +async def test_remove_entity(hass, mock_device): + """Test when an entity is removed from HA.""" + await create_entity_from_device(hass, mock_device) + assert hass.states.get("light.name") + entry_id = await get_entry_id_from_hass(hass) + assert await hass.config_entries.async_unload(entry_id) await hass.async_block_till_done() - mock_device.async_turn_off.assert_awaited_once() + assert not hass.states.get("light.name") From c361358c6df5ddab515517b73ba74cee2ae01b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sun, 1 Mar 2020 22:56:31 +0100 Subject: [PATCH 179/416] Upgrade Tibber library to 0.13.0 (#32369) --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 9f8579e3e18..2f325f7fe9b 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -2,7 +2,7 @@ "domain": "tibber", "name": "Tibber", "documentation": "https://www.home-assistant.io/integrations/tibber", - "requirements": ["pyTibber==0.12.2"], + "requirements": ["pyTibber==0.13.0"], "dependencies": [], "codeowners": ["@danielhiversen"], "quality_scale": "silver" diff --git a/requirements_all.txt b/requirements_all.txt index b17cfce1ff4..7304a9ccb77 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1113,7 +1113,7 @@ pyRFXtrx==0.25 # pySwitchmate==0.4.6 # homeassistant.components.tibber -pyTibber==0.12.2 +pyTibber==0.13.0 # homeassistant.components.dlink pyW215==0.6.0 From da959c8f7bd918853cc79d072348a2e7a6d6942d Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Mon, 2 Mar 2020 00:31:47 +0000 Subject: [PATCH 180/416] [ci skip] Translation update --- .../airvisual/.translations/ca.json | 23 +++++++++++++ .../airvisual/.translations/de.json | 21 ++++++++++++ .../airvisual/.translations/it.json | 23 +++++++++++++ .../airvisual/.translations/ru.json | 23 +++++++++++++ .../airvisual/.translations/zh-Hant.json | 23 +++++++++++++ .../ambient_station/.translations/it.json | 3 ++ .../components/august/.translations/it.json | 32 +++++++++++++++++++ .../components/cover/.translations/ca.json | 6 ++++ .../components/cover/.translations/it.json | 8 +++++ .../konnected/.translations/it.json | 6 +++- .../components/light/.translations/ca.json | 2 ++ .../components/light/.translations/it.json | 2 ++ .../components/notion/.translations/it.json | 3 ++ .../rainmachine/.translations/it.json | 3 ++ .../components/sense/.translations/it.json | 22 +++++++++++++ .../simplisafe/.translations/it.json | 3 ++ .../components/vizio/.translations/ca.json | 13 ++++++++ .../components/vizio/.translations/it.json | 19 ++++++++++- .../components/vizio/.translations/ru.json | 17 ++++++++++ 19 files changed, 250 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/airvisual/.translations/ca.json create mode 100644 homeassistant/components/airvisual/.translations/de.json create mode 100644 homeassistant/components/airvisual/.translations/it.json create mode 100644 homeassistant/components/airvisual/.translations/ru.json create mode 100644 homeassistant/components/airvisual/.translations/zh-Hant.json create mode 100644 homeassistant/components/august/.translations/it.json create mode 100644 homeassistant/components/sense/.translations/it.json diff --git a/homeassistant/components/airvisual/.translations/ca.json b/homeassistant/components/airvisual/.translations/ca.json new file mode 100644 index 00000000000..b80386dc75b --- /dev/null +++ b/homeassistant/components/airvisual/.translations/ca.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Aquesta clau API ja est\u00e0 sent utilitzada." + }, + "error": { + "invalid_api_key": "Clau API inv\u00e0lida" + }, + "step": { + "user": { + "data": { + "api_key": "Clau API", + "latitude": "Latitud", + "longitude": "Longitud", + "show_on_map": "Mostra al mapa l'\u00e0rea geogr\u00e0fica monitoritzada" + }, + "description": "Monitoritzaci\u00f3 de la qualitat de l'aire per ubicaci\u00f3 geogr\u00e0fica.", + "title": "Configura AirVisual" + } + }, + "title": "AirVisual" + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/.translations/de.json b/homeassistant/components/airvisual/.translations/de.json new file mode 100644 index 00000000000..0c624614610 --- /dev/null +++ b/homeassistant/components/airvisual/.translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Dieser API-Schl\u00fcssel wird bereits verwendet." + }, + "error": { + "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel" + }, + "step": { + "user": { + "data": { + "api_key": "API-Schl\u00fcssel", + "latitude": "Breitengrad", + "longitude": "L\u00e4ngengrad" + }, + "title": "Konfigurieren Sie AirVisual" + } + }, + "title": "AirVisual" + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/.translations/it.json b/homeassistant/components/airvisual/.translations/it.json new file mode 100644 index 00000000000..860a1e3e577 --- /dev/null +++ b/homeassistant/components/airvisual/.translations/it.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Questa chiave API \u00e8 gi\u00e0 in uso." + }, + "error": { + "invalid_api_key": "Chiave API non valida" + }, + "step": { + "user": { + "data": { + "api_key": "Chiave API", + "latitude": "Latitudine", + "longitude": "Logitudine", + "show_on_map": "Mostra l'area geografica monitorata sulla mappa" + }, + "description": "Monitorare la qualit\u00e0 dell'aria in una posizione geografica.", + "title": "Configura AirVisual" + } + }, + "title": "AirVisual" + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/.translations/ru.json b/homeassistant/components/airvisual/.translations/ru.json new file mode 100644 index 00000000000..2eac29c9ecc --- /dev/null +++ b/homeassistant/components/airvisual/.translations/ru.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u042d\u0442\u043e\u0442 \u043a\u043b\u044e\u0447 API \u0443\u0436\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f." + }, + "error": { + "invalid_api_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API." + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API", + "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430", + "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430", + "show_on_map": "\u041f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0442\u044c \u043e\u0442\u0441\u043b\u0435\u0436\u0438\u0432\u0430\u0435\u043c\u0443\u044e \u043e\u0431\u043b\u0430\u0441\u0442\u044c \u043d\u0430 \u043a\u0430\u0440\u0442\u0435" + }, + "description": "\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u0438\u0440\u0443\u0439\u0442\u0435 \u043a\u0430\u0447\u0435\u0441\u0442\u0432\u043e \u0432\u043e\u0437\u0434\u0443\u0445\u0430 \u0432 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u043e\u043c \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0438.", + "title": "AirVisual" + } + }, + "title": "AirVisual" + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/.translations/zh-Hant.json b/homeassistant/components/airvisual/.translations/zh-Hant.json new file mode 100644 index 00000000000..3f62c06a9e2 --- /dev/null +++ b/homeassistant/components/airvisual/.translations/zh-Hant.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64 API \u5bc6\u9470\u5df2\u88ab\u4f7f\u7528\u3002" + }, + "error": { + "invalid_api_key": "API \u5bc6\u78bc\u7121\u6548" + }, + "step": { + "user": { + "data": { + "api_key": "API \u5bc6\u9470", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6", + "show_on_map": "\u65bc\u5730\u5716\u4e0a\u986f\u793a\u76e3\u63a7\u4f4d\u7f6e\u3002" + }, + "description": "\u4f9d\u5730\u7406\u4f4d\u7f6e\u76e3\u63a7\u7a7a\u6c23\u54c1\u8cea\u3002", + "title": "\u8a2d\u5b9a AirVisual" + } + }, + "title": "AirVisual" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/it.json b/homeassistant/components/ambient_station/.translations/it.json index b468ba3673c..6bfaaac8f01 100644 --- a/homeassistant/components/ambient_station/.translations/it.json +++ b/homeassistant/components/ambient_station/.translations/it.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Questa chiave dell'app \u00e8 gi\u00e0 in uso." + }, "error": { "identifier_exists": "API Key e/o Application Key gi\u00e0 registrata", "invalid_key": "API Key e/o Application Key non valida", diff --git a/homeassistant/components/august/.translations/it.json b/homeassistant/components/august/.translations/it.json new file mode 100644 index 00000000000..98445345f96 --- /dev/null +++ b/homeassistant/components/august/.translations/it.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "L'account \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare.", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "login_method": "Metodo di accesso", + "password": "Password", + "timeout": "Timeout (in secondi)", + "username": "Nome utente" + }, + "description": "Se il metodo di accesso \u00e8 \"e-mail\", il nome utente \u00e8 l'indirizzo e-mail. Se il metodo di accesso \u00e8 \"telefono\", il nome utente \u00e8 il numero di telefono nel formato \"+NNNNNNNNN\".", + "title": "Configura un account di August" + }, + "validation": { + "data": { + "code": "Codice di verifica" + }, + "description": "Controlla il tuo {login_method} ({username}) e inserisci il codice di verifica seguente", + "title": "Autenticazione a due fattori" + } + }, + "title": "August" + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/ca.json b/homeassistant/components/cover/.translations/ca.json index b2c2371db5c..bbff63722d2 100644 --- a/homeassistant/components/cover/.translations/ca.json +++ b/homeassistant/components/cover/.translations/ca.json @@ -1,5 +1,11 @@ { "device_automation": { + "action_type": { + "close": "Tanca {entity_name}", + "open": "Obre {entity_name}", + "set_position": "Estableix la posici\u00f3 de {entity_name}", + "set_tilt_position": "Estableix la inclinaci\u00f3 de {entity_name}" + }, "condition_type": { "is_closed": "{entity_name} est\u00e0 tancat/da", "is_closing": "{entity_name} est\u00e0 tancant-se", diff --git a/homeassistant/components/cover/.translations/it.json b/homeassistant/components/cover/.translations/it.json index bc9413d4a00..1e2e85821a9 100644 --- a/homeassistant/components/cover/.translations/it.json +++ b/homeassistant/components/cover/.translations/it.json @@ -1,5 +1,13 @@ { "device_automation": { + "action_type": { + "close": "Chiudi {entity_name}", + "close_tilt": "Chiudi l'inclinazione di {entity_name}", + "open": "Apri {entity_name}", + "open_tilt": "Apri l'inclinazione di {entity_name}", + "set_position": "Imposta la posizione di {entity_name}", + "set_tilt_position": "Imposta la posizione di inclinazione di {entity_name}" + }, "condition_type": { "is_closed": "{entity_name} \u00e8 chiuso", "is_closing": "{entity_name} si sta chiudendo", diff --git a/homeassistant/components/konnected/.translations/it.json b/homeassistant/components/konnected/.translations/it.json index fb18ece10f8..08b15e031a5 100644 --- a/homeassistant/components/konnected/.translations/it.json +++ b/homeassistant/components/konnected/.translations/it.json @@ -11,9 +11,13 @@ }, "step": { "confirm": { - "description": "Modello: {model}\nHost: {host}\nPorta: {port}\n\n\u00c8 possibile configurare il comportamento di I/O e del pannello nelle impostazioni del Pannello Allarmi di Konnected.", + "description": "Modello: {model}\nID: {id}\nHost: {host}\nPorta: {port}\n\n\u00c8 possibile configurare il comportamento di I/O e del pannello nelle impostazioni del Pannello Allarmi di Konnected.", "title": "Dispositivo Konnected pronto" }, + "import_confirm": { + "description": "In configuration.yaml \u00e8 stato individuato un pannello di allarme Konnected con ID {id}. Questo flusso consentir\u00e0 di importarlo in una voce di configurazione.", + "title": "Importa dispositivo Konnected" + }, "user": { "data": { "host": "Indirizzo IP del dispositivo Konnected", diff --git a/homeassistant/components/light/.translations/ca.json b/homeassistant/components/light/.translations/ca.json index b8b3bbb8125..ec7641e1cab 100644 --- a/homeassistant/components/light/.translations/ca.json +++ b/homeassistant/components/light/.translations/ca.json @@ -1,6 +1,8 @@ { "device_automation": { "action_type": { + "brightness_decrease": "Redueix la brillantor de {entity_name}", + "brightness_increase": "Augmenta la brillantor de {entity_name}", "toggle": "Commuta {entity_name}", "turn_off": "Apaga {entity_name}", "turn_on": "Enc\u00e9n {entity_name}" diff --git a/homeassistant/components/light/.translations/it.json b/homeassistant/components/light/.translations/it.json index 2f4d2ca121f..ae1492d514e 100644 --- a/homeassistant/components/light/.translations/it.json +++ b/homeassistant/components/light/.translations/it.json @@ -1,6 +1,8 @@ { "device_automation": { "action_type": { + "brightness_decrease": "Riduci la luminosit\u00e0 di {entity_name}", + "brightness_increase": "Aumenta la luminosit\u00e0 di {entity_name}", "toggle": "Commuta {entity_name}", "turn_off": "Spegnere {entity_name}", "turn_on": "Accendere {entity_name}" diff --git a/homeassistant/components/notion/.translations/it.json b/homeassistant/components/notion/.translations/it.json index 035c0c38952..18ad0987aa7 100644 --- a/homeassistant/components/notion/.translations/it.json +++ b/homeassistant/components/notion/.translations/it.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Questo nome utente \u00e8 gi\u00e0 in uso." + }, "error": { "identifier_exists": "Nome utente gi\u00e0 registrato", "invalid_credentials": "Nome utente o password non validi", diff --git a/homeassistant/components/rainmachine/.translations/it.json b/homeassistant/components/rainmachine/.translations/it.json index 40b49a926c7..e0bdd7a2e1d 100644 --- a/homeassistant/components/rainmachine/.translations/it.json +++ b/homeassistant/components/rainmachine/.translations/it.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Questo controller RainMachine \u00e8 gi\u00e0 configurato." + }, "error": { "identifier_exists": "Account gi\u00e0 registrato", "invalid_credentials": "Credenziali non valide" diff --git a/homeassistant/components/sense/.translations/it.json b/homeassistant/components/sense/.translations/it.json new file mode 100644 index 00000000000..8bcbbb835a1 --- /dev/null +++ b/homeassistant/components/sense/.translations/it.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato" + }, + "error": { + "cannot_connect": "Impossibile connettersi, si prega di riprovare.", + "invalid_auth": "Autenticazione non valida", + "unknown": "Errore imprevisto" + }, + "step": { + "user": { + "data": { + "email": "Indirizzo E-Mail", + "password": "Password" + }, + "title": "Connettiti al tuo Sense Energy Monitor" + } + }, + "title": "Sense" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/it.json b/homeassistant/components/simplisafe/.translations/it.json index 6f0e403a356..f153ec36959 100644 --- a/homeassistant/components/simplisafe/.translations/it.json +++ b/homeassistant/components/simplisafe/.translations/it.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Questo account SimpliSafe \u00e8 gi\u00e0 in uso." + }, "error": { "identifier_exists": "Account gi\u00e0 registrato", "invalid_credentials": "Credenziali non valide" diff --git a/homeassistant/components/vizio/.translations/ca.json b/homeassistant/components/vizio/.translations/ca.json index 834138e9221..007834a08e3 100644 --- a/homeassistant/components/vizio/.translations/ca.json +++ b/homeassistant/components/vizio/.translations/ca.json @@ -12,11 +12,24 @@ }, "error": { "cant_connect": "No s'ha pogut connectar amb el dispositiu. [Comprova la documentaci\u00f3](https://www.home-assistant.io/integrations/vizio/) i torna a verificar que: \n - El dispositiu est\u00e0 engegat \n - El dispositiu est\u00e0 connectat a la xarxa \n - Els valors que has intridu\u00eft s\u00f3n correctes\n abans d\u2019intentar tornar a presentar.", + "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.", "host_exists": "Dispositiu Vizio amb aquest nom d'amfitri\u00f3 ja configurat.", "name_exists": "Dispositiu Vizio amb aquest nom ja configurat.", "tv_needs_token": "Si el tipus de dispositiu \u00e9s 'tv', cal un testimoni d'acc\u00e9s v\u00e0lid (token)." }, "step": { + "pair_tv": { + "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." + }, + "pairing_complete": { + "description": "El dispositiu Vizio SmartCast est\u00e0 connectat a Home Assistant." + }, + "pairing_complete_import": { + "description": "El dispositiu Vizio SmartCast est\u00e0 connectat a Home Assistant.\n\nEl testimoni d'acc\u00e9s (Access Token) \u00e9s '**{access_token}**'." + }, "user": { "data": { "access_token": "Testimoni d'acc\u00e9s", diff --git a/homeassistant/components/vizio/.translations/it.json b/homeassistant/components/vizio/.translations/it.json index dd27133453e..77c905d7cf5 100644 --- a/homeassistant/components/vizio/.translations/it.json +++ b/homeassistant/components/vizio/.translations/it.json @@ -12,11 +12,27 @@ }, "error": { "cant_connect": "Impossibile connettersi al dispositivo. [Esamina i documenti] (https://www.home-assistant.io/integrations/vizio/) e verifica nuovamente che: \n - Il dispositivo sia acceso \n - Il dispositivo sia collegato alla rete \n - I valori inseriti siano corretti \n prima di ritentare.", + "complete_pairing failed": "Impossibile completare l'associazione. Assicurarsi che il PIN fornito sia corretto e che il televisore sia ancora alimentato e connesso alla rete prima di inviarlo di nuovo.", "host_exists": "Dispositivo Vizio con host specificato gi\u00e0 configurato.", "name_exists": "Dispositivo Vizio con il nome specificato gi\u00e0 configurato.", "tv_needs_token": "Quando Device Type \u00e8 `tv`, \u00e8 necessario un token di accesso valido." }, "step": { + "pair_tv": { + "data": { + "pin": "PIN" + }, + "description": "La TV dovrebbe visualizzare un codice. Immettere quel codice nel modulo e quindi continuare con il passaggio successivo per completare l'associazione.", + "title": "Processo di associazione completo" + }, + "pairing_complete": { + "description": "Il dispositivo Vizio SmartCast \u00e8 ora connesso a Home Assistant.", + "title": "Associazione completata" + }, + "pairing_complete_import": { + "description": "Il dispositivo Vizio SmartCast \u00e8 ora connesso a Home Assistant. \n\n Il token di accesso \u00e8 '**{access_token}**'.", + "title": "Associazione completata" + }, "user": { "data": { "access_token": "Token di accesso", @@ -24,7 +40,8 @@ "host": "< Host / IP >: ", "name": "Nome" }, - "title": "Installazione del client Vizio SmartCast" + "description": "Tutti i campi sono obbligatori tranne il token di accesso. Se si sceglie di non fornire un token di accesso e il tipo di dispositivo \u00e8 \"tv\", si passer\u00e0 attraverso un processo di associazione con il dispositivo in modo da poter recuperare un token di accesso. \n\n Per completare il processo di associazione, prima di fare clic su Invia, assicurarsi che il televisore sia acceso e collegato alla rete. Devi anche essere in grado di vedere lo schermo.", + "title": "Configurazione del dispositivo SmartCast Vizio" } }, "title": "Vizio SmartCast" diff --git a/homeassistant/components/vizio/.translations/ru.json b/homeassistant/components/vizio/.translations/ru.json index e8f14e796ba..3e14dd3d750 100644 --- a/homeassistant/components/vizio/.translations/ru.json +++ b/homeassistant/components/vizio/.translations/ru.json @@ -12,11 +12,27 @@ }, "error": { "cant_connect": "\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. \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 \u0432 \u0442\u043e\u043c, \u0447\u0442\u043e:\n- \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043e;\n- \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043e \u043a \u0441\u0435\u0442\u0438;\n- \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e \u0432\u0432\u0435\u043b\u0438 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f.\n\n\u041e\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/integrations/vizio/) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438.", + "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.", "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.", "tv_needs_token": "\u0414\u043b\u044f \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u044f `tv` \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430." }, "step": { + "pair_tv": { + "data": { + "pin": "PIN-\u043a\u043e\u0434" + }, + "description": "\u0412\u0430\u0448 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440 \u0441\u0435\u0439\u0447\u0430\u0441 \u0434\u043e\u043b\u0436\u0435\u043d \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u043a\u043e\u0434. \u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u044d\u0442\u043e\u0442 \u043a\u043e\u0434 \u0432 \u0444\u043e\u0440\u043c\u0443, \u0430 \u0437\u0430\u0442\u0435\u043c \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043a \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0435\u043c\u0443 \u0448\u0430\u0433\u0443, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435.", + "title": "\u0417\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0438\u0435 \u043f\u0440\u043e\u0446\u0435\u0441\u0441\u0430 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f" + }, + "pairing_complete": { + "description": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Vizio SmartCast \u0442\u0435\u043f\u0435\u0440\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043e \u043a Home Assistant.", + "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043e" + }, + "pairing_complete_import": { + "description": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e Vizio SmartCast \u0442\u0435\u043f\u0435\u0440\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043e \u043a Home Assistant. \n\n\u0412\u0430\u0448 \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430 - '**{access_token}**'.", + "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u043e" + }, "user": { "data": { "access_token": "\u0422\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430", @@ -24,6 +40,7 @@ "host": "<\u0425\u043e\u0441\u0442/IP>:<\u041f\u043e\u0440\u0442>", "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435" }, + "description": "\u0412\u0441\u0435 \u043f\u043e\u043b\u044f \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u044b \u0434\u043b\u044f \u0437\u0430\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u044f, \u043a\u0440\u043e\u043c\u0435 \u0442\u043e\u043a\u0435\u043d\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430. \u0415\u0441\u043b\u0438 \u0432\u044b \u0440\u0435\u0448\u0438\u0442\u0435 \u043d\u0435 \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0442\u044c \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430, \u0430 \u0442\u0438\u043f \u0432\u0430\u0448\u0435\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 - 'tv', \u0412\u044b \u043f\u0440\u043e\u0439\u0434\u0435\u0442\u0435 \u043f\u0440\u043e\u0446\u0435\u0441\u0441 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f \u0441 \u0432\u0430\u0448\u0438\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c, \u0447\u0442\u043e\u0431\u044b \u043c\u043e\u0436\u043d\u043e \u0431\u044b\u043b\u043e \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0442\u043e\u043a\u0435\u043d \u0434\u043e\u0441\u0442\u0443\u043f\u0430. \n\n\u0427\u0442\u043e\u0431\u044b \u043f\u0440\u043e\u0439\u0442\u0438 \u0447\u0435\u0440\u0435\u0437 \u043f\u0440\u043e\u0446\u0435\u0441\u0441 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f, \u043f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043d\u0430\u0436\u0430\u0442\u044c '\u041e\u0442\u043f\u0440\u0430\u0432\u0438\u0442\u044c', \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \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. \u0412\u044b \u0442\u0430\u043a\u0436\u0435 \u0434\u043e\u043b\u0436\u043d\u044b \u0438\u043c\u0435\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u044d\u043a\u0440\u0430\u043d\u0443 \u0442\u0435\u043b\u0435\u0432\u0438\u0437\u043e\u0440\u0430.", "title": "Vizio SmartCast" } }, From 924c313c8a36579c812e0bbc87f0078b1c26f3d8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Mar 2020 01:22:01 -0600 Subject: [PATCH 181/416] =?UTF-8?q?Add=20application/xml=20as=20an=20XML?= =?UTF-8?q?=20to=20JSON=20auto=20converted=20mime=20type=E2=80=A6=20(#3228?= =?UTF-8?q?9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Resolves issue #32280 --- homeassistant/components/rest/sensor.py | 5 +++- tests/components/rest/test_sensor.py | 33 +++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index 7c8cfb9d3d0..dbe1d75f6af 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -206,7 +206,10 @@ class RestSensor(Entity): # If the http request failed, headers will be None content_type = self.rest.headers.get("content-type") - if content_type and content_type.startswith("text/xml"): + if content_type and ( + content_type.startswith("text/xml") + or content_type.startswith("application/xml") + ): try: value = json.dumps(xmltodict.parse(value)) _LOGGER.debug("JSON converted from XML: %s", value) diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 5018418f493..74f7faae4b3 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -559,6 +559,39 @@ class TestRestSensor(unittest.TestCase): assert "12556" == self.sensor.device_state_attributes["ver"] assert "bogus" == self.sensor.state + def test_update_with_application_xml_convert_json_attrs_with_jsonattr_template( + self, + ): + """Test attributes get extracted from a JSON result that was converted from XML with application/xml mime type.""" + json_attrs_path = "$.main" + value_template = template("{{ value_json.main.dog }}") + value_template.hass = self.hass + + self.rest.update = Mock( + "rest.RestData.update", + side_effect=self.update_side_effect( + "
13
", + CaseInsensitiveDict({"Content-Type": "application/xml"}), + ), + ) + self.sensor = rest.RestSensor( + self.hass, + self.rest, + self.name, + self.unit_of_measurement, + self.device_class, + value_template, + ["dog", "cat"], + self.force_update, + self.resource_template, + json_attrs_path, + ) + + self.sensor.update() + assert "3" == self.sensor.device_state_attributes["cat"] + assert "1" == self.sensor.device_state_attributes["dog"] + assert "1" == self.sensor.state + @patch("homeassistant.components.rest.sensor._LOGGER") def test_update_with_xml_convert_bad_xml(self, mock_logger): """Test attributes get extracted from a XML result with bad xml.""" From 7ca4665711ca81492903eeb2da2fde9620da7c82 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 2 Mar 2020 01:22:28 -0600 Subject: [PATCH 182/416] Move keypad battery implementation details into py-august (#32349) * Move keypad battery implementation details into py-august * Upgrade to py-august 0.22.0 which also adds gen2 doorbell battery data * remove cruft from previous refactor --- homeassistant/components/august/entity.py | 1 - homeassistant/components/august/manifest.json | 2 +- homeassistant/components/august/sensor.py | 15 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/august/test_sensor.py | 39 +++++++ .../august/get_lock.low_keypad_battery.json | 103 ++++++++++++++++++ 7 files changed, 146 insertions(+), 18 deletions(-) create mode 100644 tests/fixtures/august/get_lock.low_keypad_battery.json diff --git a/homeassistant/components/august/entity.py b/homeassistant/components/august/entity.py index 03c7698770c..a3f72da44be 100644 --- a/homeassistant/components/august/entity.py +++ b/homeassistant/components/august/entity.py @@ -18,7 +18,6 @@ class AugustEntityMixin(Entity): super().__init__() self._data = data self._device = device - self._undo_dispatch_subscription = None @property def should_poll(self): diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index 523cb5a361f..ef1df806575 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -3,7 +3,7 @@ "name": "August", "documentation": "https://www.home-assistant.io/integrations/august", "requirements": [ - "py-august==0.21.0" + "py-august==0.22.0" ], "dependencies": [ "configurator" diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 8c5682b8e42..6e8571c343a 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -9,10 +9,6 @@ from homeassistant.helpers.entity import Entity from .const import DATA_AUGUST, DOMAIN from .entity import AugustEntityMixin -BATTERY_LEVEL_FULL = "Full" -BATTERY_LEVEL_MEDIUM = "Medium" -BATTERY_LEVEL_LOW = "Low" - _LOGGER = logging.getLogger(__name__) @@ -26,16 +22,7 @@ def _retrieve_linked_keypad_battery_state(detail): if detail.keypad is None: return None - battery_level = detail.keypad.battery_level - - if battery_level == BATTERY_LEVEL_FULL: - return 100 - if battery_level == BATTERY_LEVEL_MEDIUM: - return 60 - if battery_level == BATTERY_LEVEL_LOW: - return 10 - - return 0 + return detail.keypad.battery_percentage SENSOR_TYPES_BATTERY = { diff --git a/requirements_all.txt b/requirements_all.txt index 7304a9ccb77..5c96ae181b4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1079,7 +1079,7 @@ pushover_complete==1.1.1 pwmled==1.5.0 # homeassistant.components.august -py-august==0.21.0 +py-august==0.22.0 # homeassistant.components.canary py-canary==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index de43f8f9d21..b9b035610e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -391,7 +391,7 @@ pure-python-adb==0.2.2.dev0 pushbullet.py==0.11.0 # homeassistant.components.august -py-august==0.21.0 +py-august==0.22.0 # homeassistant.components.canary py-canary==0.5.0 diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py index a0c1a2ea7bb..dfcae6dd362 100644 --- a/tests/components/august/test_sensor.py +++ b/tests/components/august/test_sensor.py @@ -82,3 +82,42 @@ async def test_create_lock_with_linked_keypad(hass): ) assert entry assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_linked_keypad_battery" + + +async def test_create_lock_with_low_battery_linked_keypad(hass): + """Test creation of a lock with a linked keypad that both have a battery.""" + lock_one = await _mock_lock_from_fixture(hass, "get_lock.low_keypad_battery.json") + await _create_august_with_devices(hass, [lock_one]) + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + sensor_a6697750d607098bae8d6baa11ef8063_name_battery = hass.states.get( + "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" + ) + assert sensor_a6697750d607098bae8d6baa11ef8063_name_battery.state == "88" + assert ( + sensor_a6697750d607098bae8d6baa11ef8063_name_battery.attributes[ + "unit_of_measurement" + ] + == "%" + ) + entry = entity_registry.async_get( + "sensor.a6697750d607098bae8d6baa11ef8063_name_battery" + ) + assert entry + assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_device_battery" + + sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery = hass.states.get( + "sensor.a6697750d607098bae8d6baa11ef8063_name_keypad_battery" + ) + assert sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery.state == "10" + assert ( + sensor_a6697750d607098bae8d6baa11ef8063_name_keypad_battery.attributes[ + "unit_of_measurement" + ] + == "%" + ) + entry = entity_registry.async_get( + "sensor.a6697750d607098bae8d6baa11ef8063_name_keypad_battery" + ) + assert entry + assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_linked_keypad_battery" diff --git a/tests/fixtures/august/get_lock.low_keypad_battery.json b/tests/fixtures/august/get_lock.low_keypad_battery.json new file mode 100644 index 00000000000..f848a8d30eb --- /dev/null +++ b/tests/fixtures/august/get_lock.low_keypad_battery.json @@ -0,0 +1,103 @@ +{ + "LockName": "Front Door Lock", + "Type": 2, + "Created": "2017-12-10T03:12:09.210Z", + "Updated": "2017-12-10T03:12:09.210Z", + "LockID": "A6697750D607098BAE8D6BAA11EF8063", + "HouseID": "000000000000", + "HouseName": "My House", + "Calibrated": false, + "skuNumber": "AUG-SL02-M02-S02", + "timeZone": "America/Vancouver", + "battery": 0.88, + "SerialNumber": "X2FSW05DGA", + "LockStatus": { + "status": "locked", + "doorState": "closed", + "dateTime": "2017-12-10T04:48:30.272Z", + "isLockStatusChanged": true, + "valid": true + }, + "currentFirmwareVersion": "109717e9-3.0.44-3.0.30", + "homeKitEnabled": false, + "zWaveEnabled": false, + "isGalileo": false, + "Bridge": { + "_id": "aaacab87f7efxa0015884999", + "mfgBridgeID": "AAGPP102XX", + "deviceModel": "august-doorbell", + "firmwareVersion": "2.3.0-RC153+201711151527", + "operative": true + }, + "keypad": { + "_id": "5bc65c24e6ef2a263e1450a8", + "serialNumber": "K1GXB0054Z", + "lockID": "92412D1B44004595B5DEB134E151A8D3", + "currentFirmwareVersion": "2.27.0", + "battery": {}, + "batteryLevel": "Low", + "batteryRaw": 170 + }, + "OfflineKeys": { + "created": [], + "loaded": [ + { + "UserID": "cccca94e-373e-aaaa-bbbb-333396827777", + "slot": 1, + "key": "kkk01d4300c1dcxxx1c330f794941111", + "created": "2017-12-10T03:12:09.215Z", + "loaded": "2017-12-10T03:12:54.391Z" + } + ], + "deleted": [], + "loadedhk": [ + { + "key": "kkk01d4300c1dcxxx1c330f794941222", + "slot": 256, + "UserID": "cccca94e-373e-aaaa-bbbb-333396827777", + "created": "2017-12-10T03:12:09.218Z", + "loaded": "2017-12-10T03:12:55.563Z" + } + ] + }, + "parametersToSet": {}, + "users": { + "cccca94e-373e-aaaa-bbbb-333396827777": { + "UserType": "superuser", + "FirstName": "Foo", + "LastName": "Bar", + "identifiers": [ + "email:foo@bar.com", + "phone:+177777777777" + ], + "imageInfo": { + "original": { + "width": 948, + "height": 949, + "format": "jpg", + "url": "http://www.image.com/foo.jpeg", + "secure_url": "https://www.image.com/foo.jpeg" + }, + "thumbnail": { + "width": 128, + "height": 128, + "format": "jpg", + "url": "http://www.image.com/foo.jpeg", + "secure_url": "https://www.image.com/foo.jpeg" + } + } + } + }, + "pubsubChannel": "3333a674-ffff-aaaa-b351-b3a4473f3333", + "ruleHash": {}, + "cameras": [], + "geofenceLimits": { + "ios": { + "debounceInterval": 90, + "gpsAccuracyMultiplier": 2.5, + "maximumGeofence": 5000, + "minimumGeofence": 100, + "minGPSAccuracyRequired": 80 + } + } +} From eb90cefd84847c278e287641785a47c256dc236f Mon Sep 17 00:00:00 2001 From: kuchel77 <52343790+kuchel77@users.noreply.github.com> Date: Mon, 2 Mar 2020 23:59:11 +1100 Subject: [PATCH 183/416] Keeping adding in Github repositories after error (#32393) --- homeassistant/components/github/sensor.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index 72a8189f915..96dfd4de58c 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -61,8 +61,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): "Error setting up GitHub platform. %s", "Check previous errors for details", ) - return - sensors.append(GitHubSensor(data)) + else: + sensors.append(GitHubSensor(data)) add_entities(sensors, True) @@ -170,7 +170,6 @@ class GitHubData: return self.name = repository.get(CONF_NAME, repo.name) - self.available = False self.latest_commit_message = None self.latest_commit_sha = None From df3f7687d4157bad18a87b0442d6590152eebb48 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 2 Mar 2020 07:44:24 -0600 Subject: [PATCH 184/416] Refactor Certificate Expiry Sensor (#32066) * Cert Expiry refactor * Unused parameter * Reduce delay * Deprecate 'name' config * Use config entry unique_id * Fix logic bugs found with tests * Rewrite tests to use config flow core interfaces, validate created sensors * Update strings * Minor consistency fix * Review fixes, complete test coverage * Move error handling to helper * Subclass exceptions * Better tests * Use first object reference * Fix docstring --- .coveragerc | 1 - .../components/cert_expiry/config_flow.py | 95 +++--- homeassistant/components/cert_expiry/const.py | 1 - .../components/cert_expiry/errors.py | 26 ++ .../components/cert_expiry/helper.py | 30 +- .../components/cert_expiry/sensor.py | 120 +++---- .../components/cert_expiry/strings.json | 7 +- tests/components/cert_expiry/const.py | 3 + .../cert_expiry/test_config_flow.py | 298 +++++++++++------- tests/components/cert_expiry/test_init.py | 96 ++++++ tests/components/cert_expiry/test_sensors.py | 211 +++++++++++++ 11 files changed, 652 insertions(+), 236 deletions(-) create mode 100644 homeassistant/components/cert_expiry/errors.py create mode 100644 tests/components/cert_expiry/const.py create mode 100644 tests/components/cert_expiry/test_init.py create mode 100644 tests/components/cert_expiry/test_sensors.py diff --git a/.coveragerc b/.coveragerc index 56084a049a0..9bffb4350f9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -109,7 +109,6 @@ omit = homeassistant/components/canary/alarm_control_panel.py homeassistant/components/canary/camera.py homeassistant/components/cast/* - homeassistant/components/cert_expiry/sensor.py homeassistant/components/cert_expiry/helper.py homeassistant/components/channels/* homeassistant/components/cisco_ios/device_tracker.py diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py index f3bd2f07d63..3f77701906f 100644 --- a/homeassistant/components/cert_expiry/config_flow.py +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -1,29 +1,23 @@ """Config flow for the Cert Expiry platform.""" import logging -import socket -import ssl import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import CONF_HOST, CONF_PORT -from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN -from .helper import get_cert +from .const import DEFAULT_PORT, DOMAIN # pylint: disable=unused-import +from .errors import ( + ConnectionRefused, + ConnectionTimeout, + ResolveFailed, + ValidationFailure, +) +from .helper import get_cert_time_to_expiry _LOGGER = logging.getLogger(__name__) -@callback -def certexpiry_entries(hass: HomeAssistant): - """Return the host,port tuples for the domain.""" - return set( - (entry.data[CONF_HOST], entry.data[CONF_PORT]) - for entry in hass.config_entries.async_entries(DOMAIN) - ) - - class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow.""" @@ -34,59 +28,47 @@ class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Initialize the config flow.""" self._errors = {} - def _prt_in_configuration_exists(self, user_input) -> bool: - """Return True if host, port combination exists in configuration.""" - host = user_input[CONF_HOST] - port = user_input.get(CONF_PORT, DEFAULT_PORT) - if (host, port) in certexpiry_entries(self.hass): - return True - return False - async def _test_connection(self, user_input=None): - """Test connection to the server and try to get the certtificate.""" - host = user_input[CONF_HOST] + """Test connection to the server and try to get the certificate.""" try: - await self.hass.async_add_executor_job( - get_cert, host, user_input.get(CONF_PORT, DEFAULT_PORT) + await get_cert_time_to_expiry( + self.hass, + user_input[CONF_HOST], + user_input.get(CONF_PORT, DEFAULT_PORT), ) return True - except socket.gaierror: - _LOGGER.error("Host cannot be resolved: %s", host) + except ResolveFailed: self._errors[CONF_HOST] = "resolve_failed" - except socket.timeout: - _LOGGER.error("Timed out connecting to %s", host) + except ConnectionTimeout: self._errors[CONF_HOST] = "connection_timeout" - except ssl.CertificateError as err: - if "doesn't match" in err.args[0]: - _LOGGER.error("Certificate does not match host: %s", host) - self._errors[CONF_HOST] = "wrong_host" - else: - _LOGGER.error("Certificate could not be validated: %s", host) - self._errors[CONF_HOST] = "certificate_error" - except ssl.SSLError: - _LOGGER.error("Certificate could not be validated: %s", host) - self._errors[CONF_HOST] = "certificate_error" + except ConnectionRefused: + self._errors[CONF_HOST] = "connection_refused" + except ValidationFailure: + return True return False async def async_step_user(self, user_input=None): """Step when user initializes a integration.""" self._errors = {} if user_input is not None: - # set some defaults in case we need to return to the form - if self._prt_in_configuration_exists(user_input): - self._errors[CONF_HOST] = "host_port_exists" - else: - if await self._test_connection(user_input): - return self.async_create_entry( - title=user_input.get(CONF_NAME, DEFAULT_NAME), - data={ - CONF_HOST: user_input[CONF_HOST], - CONF_PORT: user_input.get(CONF_PORT, DEFAULT_PORT), - }, - ) + host = user_input[CONF_HOST] + port = user_input.get(CONF_PORT, DEFAULT_PORT) + await self.async_set_unique_id(f"{host}:{port}") + self._abort_if_unique_id_configured() + + if await self._test_connection(user_input): + title_port = f":{port}" if port != DEFAULT_PORT else "" + title = f"{host}{title_port}" + return self.async_create_entry( + title=title, data={CONF_HOST: host, CONF_PORT: port}, + ) + if ( # pylint: disable=no-member + self.context["source"] == config_entries.SOURCE_IMPORT + ): + _LOGGER.error("Config import failed for %s", user_input[CONF_HOST]) + return self.async_abort(reason="import_failed") else: user_input = {} - user_input[CONF_NAME] = DEFAULT_NAME user_input[CONF_HOST] = "" user_input[CONF_PORT] = DEFAULT_PORT @@ -94,9 +76,6 @@ class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=vol.Schema( { - vol.Required( - CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) - ): str, vol.Required(CONF_HOST, default=user_input[CONF_HOST]): str, vol.Required( CONF_PORT, default=user_input.get(CONF_PORT, DEFAULT_PORT) @@ -111,6 +90,4 @@ class CertexpiryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): Only host was required in the yaml file all other fields are optional """ - if self._prt_in_configuration_exists(user_input): - return self.async_abort(reason="host_port_exists") return await self.async_step_user(user_input) diff --git a/homeassistant/components/cert_expiry/const.py b/homeassistant/components/cert_expiry/const.py index 4129781f2a0..00d5ac9e923 100644 --- a/homeassistant/components/cert_expiry/const.py +++ b/homeassistant/components/cert_expiry/const.py @@ -1,6 +1,5 @@ """Const for Cert Expiry.""" DOMAIN = "cert_expiry" -DEFAULT_NAME = "SSL Certificate Expiry" DEFAULT_PORT = 443 TIMEOUT = 10.0 diff --git a/homeassistant/components/cert_expiry/errors.py b/homeassistant/components/cert_expiry/errors.py new file mode 100644 index 00000000000..a3b73c84f2a --- /dev/null +++ b/homeassistant/components/cert_expiry/errors.py @@ -0,0 +1,26 @@ +"""Errors for the cert_expiry integration.""" +from homeassistant.exceptions import HomeAssistantError + + +class CertExpiryException(HomeAssistantError): + """Base class for cert_expiry exceptions.""" + + +class TemporaryFailure(CertExpiryException): + """Temporary failure has occurred.""" + + +class ValidationFailure(CertExpiryException): + """Certificate validation failure has occurred.""" + + +class ResolveFailed(TemporaryFailure): + """Name resolution failed.""" + + +class ConnectionTimeout(TemporaryFailure): + """Network connection timed out.""" + + +class ConnectionRefused(TemporaryFailure): + """Network connection refused.""" diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py index cd49588ec89..bb9f2762f3a 100644 --- a/homeassistant/components/cert_expiry/helper.py +++ b/homeassistant/components/cert_expiry/helper.py @@ -1,12 +1,19 @@ """Helper functions for the Cert Expiry platform.""" +from datetime import datetime import socket import ssl from .const import TIMEOUT +from .errors import ( + ConnectionRefused, + ConnectionTimeout, + ResolveFailed, + ValidationFailure, +) def get_cert(host, port): - """Get the ssl certificate for the host and port combination.""" + """Get the certificate for the host and port combination.""" ctx = ssl.create_default_context() address = (host, port) with socket.create_connection(address, timeout=TIMEOUT) as sock: @@ -14,3 +21,24 @@ def get_cert(host, port): # pylint disable: https://github.com/PyCQA/pylint/issues/3166 cert = ssock.getpeercert() # pylint: disable=no-member return cert + + +async def get_cert_time_to_expiry(hass, hostname, port): + """Return the certificate's time to expiry in days.""" + try: + cert = await hass.async_add_executor_job(get_cert, hostname, port) + except socket.gaierror: + raise ResolveFailed(f"Cannot resolve hostname: {hostname}") + except socket.timeout: + raise ConnectionTimeout(f"Connection timeout with server: {hostname}:{port}") + except ConnectionRefusedError: + raise ConnectionRefused(f"Connection refused by server: {hostname}:{port}") + except ssl.CertificateError as err: + raise ValidationFailure(err.verify_message) + except ssl.SSLError as err: + raise ValidationFailure(err.args[0]) + + ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"]) + timestamp = datetime.fromtimestamp(ts_seconds) + expiry = timestamp - datetime.today() + return expiry.days diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index b4437ca5834..39ec2c35ac7 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -1,8 +1,6 @@ """Counter for the days until an HTTPS (TLS) certificate will expire.""" -from datetime import datetime, timedelta +from datetime import timedelta import logging -import socket -import ssl import voluptuous as vol @@ -16,47 +14,71 @@ from homeassistant.const import ( TIME_DAYS, ) from homeassistant.core import callback +from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_call_later -from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN -from .helper import get_cert +from .const import DEFAULT_PORT, DOMAIN +from .errors import TemporaryFailure, ValidationFailure +from .helper import get_cert_time_to_expiry _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(hours=12) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - } +PLATFORM_SCHEMA = vol.All( + cv.deprecated(CONF_NAME, invalidation_version="0.109"), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } + ), ) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up certificate expiry sensor.""" + @callback + def schedule_import(_): + """Schedule delayed import after HA is fully started.""" + async_call_later(hass, 10, do_import) + @callback def do_import(_): - """Process YAML import after HA is fully started.""" + """Process YAML import.""" hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=dict(config) ) ) - # Delay to avoid validation during setup in case we're checking our own cert. - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, do_import) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, schedule_import) async def async_setup_entry(hass, entry, async_add_entities): """Add cert-expiry entry.""" + days = 0 + error = None + hostname = entry.data[CONF_HOST] + port = entry.data[CONF_PORT] + + if entry.unique_id is None: + hass.config_entries.async_update_entry(entry, unique_id=f"{hostname}:{port}") + + try: + days = await get_cert_time_to_expiry(hass, hostname, port) + except TemporaryFailure as err: + _LOGGER.error(err) + raise PlatformNotReady + except ValidationFailure as err: + error = err + async_add_entities( - [SSLCertificate(entry.title, entry.data[CONF_HOST], entry.data[CONF_PORT])], - False, - # Don't update in case we're checking our own cert. + [SSLCertificate(hostname, port, days, error)], False, ) return True @@ -64,14 +86,18 @@ async def async_setup_entry(hass, entry, async_add_entities): class SSLCertificate(Entity): """Implementation of the certificate expiry sensor.""" - def __init__(self, sensor_name, server_name, server_port): + def __init__(self, server_name, server_port, days, error): """Initialize the sensor.""" self.server_name = server_name self.server_port = server_port - self._name = sensor_name - self._state = None - self._available = False + display_port = f":{server_port}" if server_port != DEFAULT_PORT else "" + self._name = f"Cert Expiry ({self.server_name}{display_port})" + self._available = True + self._error = error + self._state = days self._valid = False + if error is None: + self._valid = True @property def name(self): @@ -103,50 +129,38 @@ class SSLCertificate(Entity): """Return the availability of the sensor.""" return self._available - async def async_added_to_hass(self): - """Once the entity is added we should update to get the initial data loaded.""" - - @callback - def do_update(_): - """Run the update method when the start event was fired.""" - self.async_schedule_update_ha_state(True) - - if self.hass.is_running: - self.async_schedule_update_ha_state(True) - else: - # Delay until HA is fully started in case we're checking our own cert. - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, do_update) - - def update(self): + async def async_update(self): """Fetch the certificate information.""" try: - cert = get_cert(self.server_name, self.server_port) - except socket.gaierror: - _LOGGER.error("Cannot resolve hostname: %s", self.server_name) + days_to_expiry = await get_cert_time_to_expiry( + self.hass, self.server_name, self.server_port + ) + except TemporaryFailure as err: + _LOGGER.error(err.args[0]) self._available = False - self._valid = False return - except socket.timeout: - _LOGGER.error("Connection timeout with server: %s", self.server_name) - self._available = False - self._valid = False - return - except (ssl.CertificateError, ssl.SSLError): + except ValidationFailure as err: + _LOGGER.error( + "Certificate validation error: %s [%s]", self.server_name, err + ) self._available = True + self._error = err self._state = 0 self._valid = False return + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Unknown error checking %s:%s", self.server_name, self.server_port + ) + self._available = False + return - ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"]) - timestamp = datetime.fromtimestamp(ts_seconds) - expiry = timestamp - datetime.today() self._available = True - self._state = expiry.days + self._error = None + self._state = days_to_expiry self._valid = True @property def device_state_attributes(self): """Return additional sensor state attributes.""" - attr = {"is_valid": self._valid} - - return attr + return {"is_valid": self._valid, "error": str(self._error)} diff --git a/homeassistant/components/cert_expiry/strings.json b/homeassistant/components/cert_expiry/strings.json index e5e670d214f..4d4982a19af 100644 --- a/homeassistant/components/cert_expiry/strings.json +++ b/homeassistant/components/cert_expiry/strings.json @@ -12,14 +12,13 @@ } }, "error": { - "host_port_exists": "This host and port combination is already configured", "resolve_failed": "This host can not be resolved", "connection_timeout": "Timeout when connecting to this host", - "certificate_error": "Certificate could not be validated", - "wrong_host": "Certificate does not match hostname" + "connection_refused": "Connection refused when connecting to host" }, "abort": { - "host_port_exists": "This host and port combination is already configured" + "already_configured": "This host and port combination is already configured", + "import_failed": "Import from config failed" } } } diff --git a/tests/components/cert_expiry/const.py b/tests/components/cert_expiry/const.py new file mode 100644 index 00000000000..9ddbeca61c3 --- /dev/null +++ b/tests/components/cert_expiry/const.py @@ -0,0 +1,3 @@ +"""Constants for cert_expiry tests.""" +PORT = 443 +HOST = "example.com" diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py index 71005672fdb..1b2cc175dcb 100644 --- a/tests/components/cert_expiry/test_config_flow.py +++ b/tests/components/cert_expiry/test_config_flow.py @@ -1,154 +1,218 @@ """Tests for the Cert Expiry config flow.""" import socket import ssl -from unittest.mock import patch -import pytest +from asynctest import patch from homeassistant import data_entry_flow -from homeassistant.components.cert_expiry import config_flow -from homeassistant.components.cert_expiry.const import DEFAULT_NAME, DEFAULT_PORT +from homeassistant.components.cert_expiry.const import DEFAULT_PORT, DOMAIN from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT -from tests.common import MockConfigEntry, mock_coro +from .const import HOST, PORT -NAME = "Cert Expiry test 1 2 3" -PORT = 443 -HOST = "example.com" +from tests.common import MockConfigEntry -@pytest.fixture(name="test_connect") -def mock_controller(): - """Mock a successful _prt_in_configuration_exists.""" - with patch( - "homeassistant.components.cert_expiry.config_flow.CertexpiryConfigFlow._test_connection", - side_effect=lambda *_: mock_coro(True), - ): - yield - - -def init_config_flow(hass): - """Init a configuration flow.""" - flow = config_flow.CertexpiryConfigFlow() - flow.hass = hass - return flow - - -async def test_user(hass, test_connect): +async def test_user(hass): """Test user config.""" - flow = init_config_flow(hass) - - result = await flow.async_step_user() + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - # tets with all provided - result = await flow.async_step_user( - {CONF_NAME: NAME, CONF_HOST: HOST, CONF_PORT: PORT} - ) + with patch( + "homeassistant.components.cert_expiry.config_flow.get_cert_time_to_expiry" + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: HOST, CONF_PORT: PORT} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == NAME + assert result["title"] == HOST assert result["data"][CONF_HOST] == HOST assert result["data"][CONF_PORT] == PORT + assert result["result"].unique_id == f"{HOST}:{PORT}" + + with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"): + await hass.async_block_till_done() -async def test_import(hass, test_connect): - """Test import step.""" - flow = init_config_flow(hass) - - # import with only host - result = await flow.async_step_import({CONF_HOST: HOST}) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == DEFAULT_NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == DEFAULT_PORT - - # import with host and name - result = await flow.async_step_import({CONF_HOST: HOST, CONF_NAME: NAME}) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == DEFAULT_PORT - - # improt with host and port - result = await flow.async_step_import({CONF_HOST: HOST, CONF_PORT: PORT}) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == DEFAULT_NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == PORT - - # import with all - result = await flow.async_step_import( - {CONF_HOST: HOST, CONF_PORT: PORT, CONF_NAME: NAME} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == NAME - assert result["data"][CONF_HOST] == HOST - assert result["data"][CONF_PORT] == PORT - - -async def test_abort_if_already_setup(hass, test_connect): - """Test we abort if the cert is already setup.""" - flow = init_config_flow(hass) - MockConfigEntry( - domain="cert_expiry", - data={CONF_PORT: DEFAULT_PORT, CONF_NAME: NAME, CONF_HOST: HOST}, - ).add_to_hass(hass) - - # Should fail, same HOST and PORT (default) - result = await flow.async_step_import( - {CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: DEFAULT_PORT} - ) - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "host_port_exists" - - # Should be the same HOST and PORT (default) - result = await flow.async_step_user( - {CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: DEFAULT_PORT} +async def test_user_with_bad_cert(hass): + """Test user config with bad certificate.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {CONF_HOST: "host_port_exists"} + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.cert_expiry.helper.get_cert", + side_effect=ssl.SSLError("some error"), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: HOST, CONF_PORT: PORT} + ) - # SHOULD pass, same Host diff PORT - result = await flow.async_step_import( - {CONF_HOST: HOST, CONF_NAME: NAME, CONF_PORT: 888} - ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == NAME + assert result["title"] == HOST + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + assert result["result"].unique_id == f"{HOST}:{PORT}" + + with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"): + await hass.async_block_till_done() + + +async def test_import_host_only(hass): + """Test import with host only.""" + with patch( + "homeassistant.components.cert_expiry.config_flow.get_cert_time_to_expiry", + return_value=1, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "import"}, data={CONF_HOST: HOST} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == HOST + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == DEFAULT_PORT + assert result["result"].unique_id == f"{HOST}:{DEFAULT_PORT}" + + with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"): + await hass.async_block_till_done() + + +async def test_import_host_and_port(hass): + """Test import with host and port.""" + with patch( + "homeassistant.components.cert_expiry.config_flow.get_cert_time_to_expiry", + return_value=1, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "import"}, + data={CONF_HOST: HOST, CONF_PORT: PORT}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == HOST + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + assert result["result"].unique_id == f"{HOST}:{PORT}" + + with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"): + await hass.async_block_till_done() + + +async def test_import_non_default_port(hass): + """Test import with host and non-default port.""" + with patch( + "homeassistant.components.cert_expiry.config_flow.get_cert_time_to_expiry" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "import"}, data={CONF_HOST: HOST, CONF_PORT: 888} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == f"{HOST}:888" assert result["data"][CONF_HOST] == HOST assert result["data"][CONF_PORT] == 888 + assert result["result"].unique_id == f"{HOST}:888" + + with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"): + await hass.async_block_till_done() + + +async def test_import_with_name(hass): + """Test import with name (deprecated).""" + with patch( + "homeassistant.components.cert_expiry.config_flow.get_cert_time_to_expiry", + return_value=1, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": "import"}, + data={CONF_NAME: "legacy", CONF_HOST: HOST, CONF_PORT: PORT}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == HOST + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_PORT] == PORT + assert result["result"].unique_id == f"{HOST}:{PORT}" + + with patch("homeassistant.components.cert_expiry.sensor.async_setup_entry"): + await hass.async_block_till_done() + + +async def test_bad_import(hass): + """Test import step.""" + with patch( + "homeassistant.components.cert_expiry.helper.get_cert", + side_effect=ConnectionRefusedError(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "import"}, data={CONF_HOST: HOST} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "import_failed" + + +async def test_abort_if_already_setup(hass): + """Test we abort if the cert is already setup.""" + MockConfigEntry( + domain="cert_expiry", + data={CONF_HOST: HOST, CONF_PORT: PORT}, + unique_id=f"{HOST}:{PORT}", + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "import"}, data={CONF_HOST: HOST, CONF_PORT: PORT} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data={CONF_HOST: HOST, CONF_PORT: PORT} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" async def test_abort_on_socket_failed(hass): """Test we abort of we have errors during socket creation.""" - flow = init_config_flow(hass) - - with patch("socket.create_connection", side_effect=socket.gaierror()): - result = await flow.async_step_user({CONF_HOST: HOST}) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {CONF_HOST: "resolve_failed"} - - with patch("socket.create_connection", side_effect=socket.timeout()): - result = await flow.async_step_user({CONF_HOST: HOST}) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {CONF_HOST: "connection_timeout"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) with patch( - "socket.create_connection", - side_effect=ssl.CertificateError(f"{HOST} doesn't match somethingelse.com"), + "homeassistant.components.cert_expiry.helper.get_cert", + side_effect=socket.gaierror(), ): - result = await flow.async_step_user({CONF_HOST: HOST}) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {CONF_HOST: "wrong_host"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: HOST} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "resolve_failed"} with patch( - "socket.create_connection", side_effect=ssl.CertificateError("different error") + "homeassistant.components.cert_expiry.helper.get_cert", + side_effect=socket.timeout(), ): - result = await flow.async_step_user({CONF_HOST: HOST}) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {CONF_HOST: "certificate_error"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: HOST} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "connection_timeout"} - with patch("socket.create_connection", side_effect=ssl.SSLError()): - result = await flow.async_step_user({CONF_HOST: HOST}) - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["errors"] == {CONF_HOST: "certificate_error"} + with patch( + "homeassistant.components.cert_expiry.helper.get_cert", + side_effect=ConnectionRefusedError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: HOST} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_HOST: "connection_refused"} diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py new file mode 100644 index 00000000000..d4419b48370 --- /dev/null +++ b/tests/components/cert_expiry/test_init.py @@ -0,0 +1,96 @@ +"""Tests for Cert Expiry setup.""" +from datetime import timedelta + +from asynctest import patch + +from homeassistant.components.cert_expiry.const import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ENTRY_STATE_LOADED, ENTRY_STATE_NOT_LOADED +from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_START +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from .const import HOST, PORT + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_setup_with_config(hass): + """Test setup component with config.""" + config = { + SENSOR_DOMAIN: [ + {"platform": DOMAIN, CONF_HOST: HOST, CONF_PORT: PORT}, + {"platform": DOMAIN, CONF_HOST: HOST, CONF_PORT: 888}, + ], + } + assert await async_setup_component(hass, SENSOR_DOMAIN, config) is True + await hass.async_block_till_done() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + next_update = dt_util.utcnow() + timedelta(seconds=20) + async_fire_time_changed(hass, next_update) + + with patch( + "homeassistant.components.cert_expiry.config_flow.get_cert_time_to_expiry", + return_value=100, + ), patch( + "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry", + return_value=100, + ): + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 2 + + +async def test_update_unique_id(hass): + """Test updating a config entry without a unique_id.""" + entry = MockConfigEntry(domain=DOMAIN, data={CONF_HOST: HOST, CONF_PORT: PORT}) + entry.add_to_hass(hass) + + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert entry is config_entries[0] + assert not entry.unique_id + + with patch( + "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry", + return_value=100, + ): + assert await async_setup_component(hass, DOMAIN, {}) is True + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_LOADED + assert entry.unique_id == f"{HOST}:{PORT}" + + +async def test_unload_config_entry(hass): + """Test unloading a config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: HOST, CONF_PORT: PORT}, + unique_id=f"{HOST}:{PORT}", + ) + entry.add_to_hass(hass) + + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + assert entry is config_entries[0] + + with patch( + "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry", + return_value=100, + ): + assert await async_setup_component(hass, DOMAIN, {}) is True + await hass.async_block_till_done() + + assert entry.state == ENTRY_STATE_LOADED + state = hass.states.get("sensor.cert_expiry_example_com") + assert state.state == "100" + assert state.attributes.get("error") == "None" + assert state.attributes.get("is_valid") + + await hass.config_entries.async_unload(entry.entry_id) + + assert entry.state == ENTRY_STATE_NOT_LOADED + state = hass.states.get("sensor.cert_expiry_example_com") + assert state is None diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py new file mode 100644 index 00000000000..6594b0988e7 --- /dev/null +++ b/tests/components/cert_expiry/test_sensors.py @@ -0,0 +1,211 @@ +"""Tests for the Cert Expiry sensors.""" +from datetime import timedelta +import socket +import ssl + +from asynctest import patch + +from homeassistant.const import CONF_HOST, CONF_PORT, STATE_UNAVAILABLE +import homeassistant.util.dt as dt_util + +from .const import HOST, PORT + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_async_setup_entry(hass): + """Test async_setup_entry.""" + entry = MockConfigEntry( + domain="cert_expiry", + data={CONF_HOST: HOST, CONF_PORT: PORT}, + unique_id=f"{HOST}:{PORT}", + ) + + with patch( + "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry", + return_value=100, + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.cert_expiry_example_com") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == "100" + assert state.attributes.get("error") == "None" + assert state.attributes.get("is_valid") + + +async def test_async_setup_entry_bad_cert(hass): + """Test async_setup_entry with a bad/expired cert.""" + entry = MockConfigEntry( + domain="cert_expiry", + data={CONF_HOST: HOST, CONF_PORT: PORT}, + unique_id=f"{HOST}:{PORT}", + ) + + with patch( + "homeassistant.components.cert_expiry.helper.get_cert", + side_effect=ssl.SSLError("some error"), + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.cert_expiry_example_com") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == "0" + assert state.attributes.get("error") == "some error" + assert not state.attributes.get("is_valid") + + +async def test_async_setup_entry_host_unavailable(hass): + """Test async_setup_entry when host is unavailable.""" + entry = MockConfigEntry( + domain="cert_expiry", + data={CONF_HOST: HOST, CONF_PORT: PORT}, + unique_id=f"{HOST}:{PORT}", + ) + + with patch( + "homeassistant.components.cert_expiry.helper.get_cert", + side_effect=socket.gaierror, + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.cert_expiry_example_com") + assert state is None + + next_update = dt_util.utcnow() + timedelta(seconds=45) + async_fire_time_changed(hass, next_update) + with patch( + "homeassistant.components.cert_expiry.helper.get_cert", + side_effect=socket.gaierror, + ): + await hass.async_block_till_done() + + state = hass.states.get("sensor.cert_expiry_example_com") + assert state is None + + +async def test_update_sensor(hass): + """Test async_update for sensor.""" + entry = MockConfigEntry( + domain="cert_expiry", + data={CONF_HOST: HOST, CONF_PORT: PORT}, + unique_id=f"{HOST}:{PORT}", + ) + + with patch( + "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry", + return_value=100, + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.cert_expiry_example_com") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == "100" + assert state.attributes.get("error") == "None" + assert state.attributes.get("is_valid") + + next_update = dt_util.utcnow() + timedelta(hours=12) + async_fire_time_changed(hass, next_update) + + with patch( + "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry", + return_value=99, + ): + await hass.async_block_till_done() + + state = hass.states.get("sensor.cert_expiry_example_com") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == "99" + assert state.attributes.get("error") == "None" + assert state.attributes.get("is_valid") + + +async def test_update_sensor_network_errors(hass): + """Test async_update for sensor.""" + entry = MockConfigEntry( + domain="cert_expiry", + data={CONF_HOST: HOST, CONF_PORT: PORT}, + unique_id=f"{HOST}:{PORT}", + ) + + with patch( + "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry", + return_value=100, + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.cert_expiry_example_com") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == "100" + assert state.attributes.get("error") == "None" + assert state.attributes.get("is_valid") + + next_update = dt_util.utcnow() + timedelta(hours=12) + async_fire_time_changed(hass, next_update) + + with patch( + "homeassistant.components.cert_expiry.helper.get_cert", + side_effect=socket.gaierror, + ): + await hass.async_block_till_done() + + state = hass.states.get("sensor.cert_expiry_example_com") + assert state.state == STATE_UNAVAILABLE + + next_update = dt_util.utcnow() + timedelta(hours=12) + async_fire_time_changed(hass, next_update) + + with patch( + "homeassistant.components.cert_expiry.sensor.get_cert_time_to_expiry", + return_value=99, + ): + await hass.async_block_till_done() + + state = hass.states.get("sensor.cert_expiry_example_com") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == "99" + assert state.attributes.get("error") == "None" + assert state.attributes.get("is_valid") + + next_update = dt_util.utcnow() + timedelta(hours=12) + async_fire_time_changed(hass, next_update) + + with patch( + "homeassistant.components.cert_expiry.helper.get_cert", + side_effect=ssl.SSLError("something bad"), + ): + await hass.async_block_till_done() + + state = hass.states.get("sensor.cert_expiry_example_com") + assert state is not None + assert state.state != STATE_UNAVAILABLE + assert state.state == "0" + assert state.attributes.get("error") == "something bad" + assert not state.attributes.get("is_valid") + + next_update = dt_util.utcnow() + timedelta(hours=12) + async_fire_time_changed(hass, next_update) + + with patch( + "homeassistant.components.cert_expiry.helper.get_cert", side_effect=Exception() + ): + await hass.async_block_till_done() + + state = hass.states.get("sensor.cert_expiry_example_com") + assert state.state == STATE_UNAVAILABLE From 049897365cecb206ea9b68ca2f28c0be32f55b9b Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 2 Mar 2020 16:33:11 +0100 Subject: [PATCH 185/416] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 4c6a353d775..4a9c16782ef 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -148,7 +148,7 @@ stages: . venv/bin/activate pytest --timeout=9 --durations=10 -n auto --dist=loadfile --cov homeassistant --cov-report html -qq -o console_output_style=count -p no:sugar tests - codecov --token $(codecovToken) + codecov --token $(codecovToken) script/check_dirty displayName: 'Run pytest for python $(python.container) / coverage' condition: and(succeeded(), eq(variables['python.container'], variables['PythonMain'])) From 87abd193ee4db2f929df86866fbb4254da31146d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 2 Mar 2020 16:33:39 +0100 Subject: [PATCH 186/416] Update azure-pipelines-ci.yml for Azure Pipelines --- azure-pipelines-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 4a9c16782ef..4c6a353d775 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -148,7 +148,7 @@ stages: . venv/bin/activate pytest --timeout=9 --durations=10 -n auto --dist=loadfile --cov homeassistant --cov-report html -qq -o console_output_style=count -p no:sugar tests - codecov --token $(codecovToken) + codecov --token $(codecovToken) script/check_dirty displayName: 'Run pytest for python $(python.container) / coverage' condition: and(succeeded(), eq(variables['python.container'], variables['PythonMain'])) From 2ba514253c4981062853f3e1981903d3e4aa803c Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 2 Mar 2020 11:26:37 -0500 Subject: [PATCH 187/416] Bump ZHA Quirks to 0.0.34 (#32401) * bump zha quirks version * add required parts to support alt opple remote --- .../components/zha/core/channels/manufacturerspecific.py | 8 ++++++++ homeassistant/components/zha/manifest.json | 2 +- homeassistant/components/zha/strings.json | 8 ++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 19 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 90f81513ec4..2f30421822c 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -39,6 +39,14 @@ class OsramButton(ZigbeeChannel): REPORT_CONFIG = [] +@registries.CHANNEL_ONLY_CLUSTERS.register(0xFCC0) +@registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFCC0) +class OppleRemote(ZigbeeChannel): + """Opple button channel.""" + + REPORT_CONFIG = [] + + @registries.ZIGBEE_CHANNEL_REGISTRY.register( registries.SMARTTHINGS_ACCELERATION_CLUSTER ) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 786de20f3da..01a204a282c 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ "bellows-homeassistant==0.13.2", - "zha-quirks==0.0.33", + "zha-quirks==0.0.34", "zigpy-cc==0.1.0", "zigpy-deconz==0.7.0", "zigpy-homeassistant==0.14.0", diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index a41f6de24be..a015ca30770 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -31,6 +31,14 @@ "remote_button_triple_press": "\"{subtype}\" button triple clicked", "remote_button_quadruple_press": "\"{subtype}\" button quadruple clicked", "remote_button_quintuple_press": "\"{subtype}\" button quintuple clicked", + "remote_button_alt_short_press": "\"{subtype}\" button pressed (Alternate mode)", + "remote_button_alt_short_release": "\"{subtype}\" button released (Alternate mode)", + "remote_button_alt_long_press": "\"{subtype}\" button continuously pressed (Alternate mode)", + "remote_button_alt_long_release": "\"{subtype}\" button released after long press (Alternate mode)", + "remote_button_alt_double_press": "\"{subtype}\" button double clicked (Alternate mode)", + "remote_button_alt_triple_press": "\"{subtype}\" button triple clicked (Alternate mode)", + "remote_button_alt_quadruple_press": "\"{subtype}\" button quadruple clicked (Alternate mode)", + "remote_button_alt_quintuple_press": "\"{subtype}\" button quintuple clicked (Alternate mode)", "device_rotated": "Device rotated \"{subtype}\"", "device_shaken": "Device shaken", "device_slid": "Device slid \"{subtype}\"", diff --git a/requirements_all.txt b/requirements_all.txt index 5c96ae181b4..247ab2b9dac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2140,7 +2140,7 @@ zengge==0.2 zeroconf==0.24.4 # homeassistant.components.zha -zha-quirks==0.0.33 +zha-quirks==0.0.34 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9b035610e8..e16665d0913 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -738,7 +738,7 @@ yahooweather==0.10 zeroconf==0.24.4 # homeassistant.components.zha -zha-quirks==0.0.33 +zha-quirks==0.0.34 # homeassistant.components.zha zigpy-cc==0.1.0 From 1603f7ac2173e22ab2d30d31ac75bf6fc56f5319 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 2 Mar 2020 13:40:11 -0800 Subject: [PATCH 188/416] Add coronavirus integration (#32413) * Add coronavirus integration * Update homeassistant/components/coronavirus/manifest.json Co-Authored-By: Franck Nijhof Co-authored-by: Franck Nijhof --- CODEOWNERS | 1 + .../coronavirus/.translations/en.json | 13 ++++ .../components/coronavirus/__init__.py | 75 +++++++++++++++++++ .../components/coronavirus/config_flow.py | 41 ++++++++++ homeassistant/components/coronavirus/const.py | 6 ++ .../components/coronavirus/manifest.json | 12 +++ .../components/coronavirus/sensor.py | 69 +++++++++++++++++ .../components/coronavirus/strings.json | 13 ++++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/coronavirus/__init__.py | 1 + .../coronavirus/test_config_flow.py | 33 ++++++++ 13 files changed, 271 insertions(+) create mode 100644 homeassistant/components/coronavirus/.translations/en.json create mode 100644 homeassistant/components/coronavirus/__init__.py create mode 100644 homeassistant/components/coronavirus/config_flow.py create mode 100644 homeassistant/components/coronavirus/const.py create mode 100644 homeassistant/components/coronavirus/manifest.json create mode 100644 homeassistant/components/coronavirus/sensor.py create mode 100644 homeassistant/components/coronavirus/strings.json create mode 100644 tests/components/coronavirus/__init__.py create mode 100644 tests/components/coronavirus/test_config_flow.py diff --git a/CODEOWNERS b/CODEOWNERS index 30611cbf757..35ff288879c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -69,6 +69,7 @@ homeassistant/components/config/* @home-assistant/core homeassistant/components/configurator/* @home-assistant/core homeassistant/components/conversation/* @home-assistant/core homeassistant/components/coolmaster/* @OnFreund +homeassistant/components/coronavirus/* @home_assistant/core homeassistant/components/counter/* @fabaff homeassistant/components/cover/* @home-assistant/core homeassistant/components/cpuspeed/* @fabaff diff --git a/homeassistant/components/coronavirus/.translations/en.json b/homeassistant/components/coronavirus/.translations/en.json new file mode 100644 index 00000000000..ad7a3cf2cdf --- /dev/null +++ b/homeassistant/components/coronavirus/.translations/en.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "country": "Country" + }, + "title": "Pick a country to monitor" + } + }, + "title": "Coronavirus" + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py new file mode 100644 index 00000000000..95c3cd1c024 --- /dev/null +++ b/homeassistant/components/coronavirus/__init__.py @@ -0,0 +1,75 @@ +"""The Coronavirus integration.""" +import asyncio +from datetime import timedelta +import logging + +import aiohttp +import async_timeout +import coronavirus + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client, update_coordinator + +from .const import DOMAIN + +PLATFORMS = ["sensor"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Coronavirus component.""" + # Make sure coordinator is initialized. + await get_coordinator(hass) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Coronavirus from a config 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 a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + return unload_ok + + +async def get_coordinator(hass): + """Get the data update coordinator.""" + if DOMAIN in hass.data: + return hass.data[DOMAIN] + + async def async_get_cases(): + try: + with async_timeout.timeout(10): + return { + case.id: case + for case in await coronavirus.get_cases( + aiohttp_client.async_get_clientsession(hass) + ) + } + except (asyncio.TimeoutError, aiohttp.ClientError): + raise update_coordinator.UpdateFailed + + hass.data[DOMAIN] = update_coordinator.DataUpdateCoordinator( + hass, + logging.getLogger(__name__), + name=DOMAIN, + update_method=async_get_cases, + update_interval=timedelta(hours=1), + ) + await hass.data[DOMAIN].async_refresh() + return hass.data[DOMAIN] diff --git a/homeassistant/components/coronavirus/config_flow.py b/homeassistant/components/coronavirus/config_flow.py new file mode 100644 index 00000000000..59d25e16709 --- /dev/null +++ b/homeassistant/components/coronavirus/config_flow.py @@ -0,0 +1,41 @@ +"""Config flow for Coronavirus integration.""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries + +from . import get_coordinator +from .const import DOMAIN, OPTION_WORLDWIDE # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Coronavirus.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + _options = None + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + + if self._options is None: + self._options = {OPTION_WORLDWIDE: "Worldwide"} + coordinator = await get_coordinator(self.hass) + for case_id in sorted(coordinator.data): + self._options[case_id] = coordinator.data[case_id].country + + if user_input is not None: + return self.async_create_entry( + title=self._options[user_input["country"]], data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required("country"): vol.In(self._options)}), + errors=errors, + ) diff --git a/homeassistant/components/coronavirus/const.py b/homeassistant/components/coronavirus/const.py new file mode 100644 index 00000000000..e1ffa64e88c --- /dev/null +++ b/homeassistant/components/coronavirus/const.py @@ -0,0 +1,6 @@ +"""Constants for the Coronavirus integration.""" +from coronavirus import DEFAULT_SOURCE + +DOMAIN = "coronavirus" +OPTION_WORLDWIDE = "__worldwide" +ATTRIBUTION = f"Data provided by {DEFAULT_SOURCE.NAME}" diff --git a/homeassistant/components/coronavirus/manifest.json b/homeassistant/components/coronavirus/manifest.json new file mode 100644 index 00000000000..d99a9b621a2 --- /dev/null +++ b/homeassistant/components/coronavirus/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "coronavirus", + "name": "Coronavirus (COVID-19)", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/coronavirus", + "requirements": ["coronavirus==1.0.1"], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": ["@home_assistant/core"] +} diff --git a/homeassistant/components/coronavirus/sensor.py b/homeassistant/components/coronavirus/sensor.py new file mode 100644 index 00000000000..770ab78b43e --- /dev/null +++ b/homeassistant/components/coronavirus/sensor.py @@ -0,0 +1,69 @@ +"""Sensor platform for the Corona virus.""" +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.entity import Entity + +from . import get_coordinator +from .const import ATTRIBUTION, OPTION_WORLDWIDE + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Defer sensor setup to the shared sensor module.""" + coordinator = await get_coordinator(hass) + + async_add_entities( + CoronavirusSensor(coordinator, config_entry.data["country"], info_type) + for info_type in ("confirmed", "recovered", "deaths", "current") + ) + + +class CoronavirusSensor(Entity): + """Sensor representing corona virus data.""" + + name = None + unique_id = None + + def __init__(self, coordinator, country, info_type): + """Initialize coronavirus sensor.""" + if country == OPTION_WORLDWIDE: + self.name = f"Worldwide {info_type}" + else: + self.name = f"{coordinator.data[country].country} {info_type}" + self.unique_id = f"{country}-{info_type}" + self.coordinator = coordinator + self.country = country + self.info_type = info_type + + @property + def available(self): + """Return if sensor is available.""" + return self.coordinator.last_update_success and ( + self.country in self.coordinator.data or self.country == OPTION_WORLDWIDE + ) + + @property + def state(self): + """State of the sensor.""" + if self.country == OPTION_WORLDWIDE: + return sum( + getattr(case, self.info_type) for case in self.coordinator.data.values() + ) + + return getattr(self.coordinator.data[self.country], self.info_type) + + @property + def unit_of_measurement(self): + """Return unit of measurement.""" + return "people" + + @property + def device_state_attributes(self): + """Return device attributes.""" + return {ATTR_ATTRIBUTION: ATTRIBUTION} + + 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/coronavirus/strings.json b/homeassistant/components/coronavirus/strings.json new file mode 100644 index 00000000000..13cd5f04012 --- /dev/null +++ b/homeassistant/components/coronavirus/strings.json @@ -0,0 +1,13 @@ +{ + "config": { + "title": "Coronavirus", + "step": { + "user": { + "title": "Pick a country to monitor", + "data": { + "country": "Country" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 55c013b46ad..cb5d7105131 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -19,6 +19,7 @@ FLOWS = [ "cast", "cert_expiry", "coolmaster", + "coronavirus", "daikin", "deconz", "dialogflow", diff --git a/requirements_all.txt b/requirements_all.txt index 247ab2b9dac..6787b02969d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -405,6 +405,9 @@ connect-box==0.2.5 # homeassistant.components.xiaomi_miio construct==2.9.45 +# homeassistant.components.coronavirus +coronavirus==1.0.1 + # homeassistant.scripts.credstash # credstash==1.15.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e16665d0913..aceaddba194 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -146,6 +146,9 @@ colorlog==4.1.0 # homeassistant.components.xiaomi_miio construct==2.9.45 +# homeassistant.components.coronavirus +coronavirus==1.0.1 + # homeassistant.scripts.credstash # credstash==1.15.0 diff --git a/tests/components/coronavirus/__init__.py b/tests/components/coronavirus/__init__.py new file mode 100644 index 00000000000..2274a51506d --- /dev/null +++ b/tests/components/coronavirus/__init__.py @@ -0,0 +1 @@ +"""Tests for the Coronavirus integration.""" diff --git a/tests/components/coronavirus/test_config_flow.py b/tests/components/coronavirus/test_config_flow.py new file mode 100644 index 00000000000..6d940d8e53d --- /dev/null +++ b/tests/components/coronavirus/test_config_flow.py @@ -0,0 +1,33 @@ +"""Test the Coronavirus config flow.""" +from asynctest import patch + +from homeassistant import config_entries, setup +from homeassistant.components.coronavirus.const import DOMAIN, OPTION_WORLDWIDE + + +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("coronavirus.get_cases", return_value=[],), patch( + "homeassistant.components.coronavirus.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.coronavirus.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"country": OPTION_WORLDWIDE}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Worldwide" + assert result2["data"] == { + "country": OPTION_WORLDWIDE, + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 From 66d70195c9726c24aff15087e5e0461d913f3b8d Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 2 Mar 2020 22:55:57 +0100 Subject: [PATCH 189/416] Update azure-pipelines-release.yml for Azure Pipelines --- azure-pipelines-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index c98f12dfac6..34827897749 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -41,7 +41,7 @@ stages: jq curl release="$(Build.SourceBranchName)" - created_by="$(curl -s https://api.github.com/repos/home-assistant/home-assistant/releases/tags/${release} | jq --raw-output '.author.login')" + created_by="$(curl -s https://api.github.com/repos/home-assistant/core/releases/tags/${release} | jq --raw-output '.author.login')" if [[ "${created_by}" =~ ^(balloob|pvizeli|fabaff|robbiet480|bramkragten|frenck)$ ]]; then exit 0 From a25b94cd2da9f495253a6b52710eaecd1cf5826f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 2 Mar 2020 14:01:40 -0800 Subject: [PATCH 190/416] Update azure-pipelines-wheels.yml --- azure-pipelines-wheels.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml index b537aa3bf53..cd04feb4638 100644 --- a/azure-pipelines-wheels.yml +++ b/azure-pipelines-wheels.yml @@ -40,7 +40,7 @@ jobs: if [[ "$(Build.Reason)" =~ (Schedule|Manual) ]]; then touch requirements_diff.txt else - curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt + curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/core/master/requirements_all.txt fi requirement_files="requirements_wheels.txt requirements_diff.txt" From ee7ce478602929a13a7b5b6ca29a0b178e3cfdbb Mon Sep 17 00:00:00 2001 From: Matt Snyder Date: Mon, 2 Mar 2020 18:10:02 -0600 Subject: [PATCH 191/416] Add QVR Pro integration (#31173) * Initial working commit * Create const file. Load camera from component. * Handle failed authentication. Bump library version. * Remove line break * Camera attributes and recording services * Add services, manifest, constant update, and exclude_channels. Prefix channel name. Update service argument. * Update codeowners * Update coveragerc * Remove codeowners line * Update codeowners again from python3 -m script.hassfest * Update homeassistant/components/qvrpro/__init__.py Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> * Requested changes * Fix typo * Update to use exception. Bump library version. * Support stream component * Update module header * Missing property wrapper * Partial requested changes * Update coveragerc and codeowners * Move constants to const file. Add SHORT_NAME * Add conf variable * Use camera domain * More requested changes * Requested changes * Requested changes * Update prefix * Handle error condition when camera is not configured to support live streaming * Move method to camera setup. Disable stream component support. * Move auth string to library to prevent private member access Co-authored-by: springstan <46536646+springstan@users.noreply.github.com> --- .coveragerc | 1 + CODEOWNERS | 1 + homeassistant/components/qvr_pro/__init__.py | 100 +++++++++++++++++ homeassistant/components/qvr_pro/camera.py | 102 ++++++++++++++++++ homeassistant/components/qvr_pro/const.py | 9 ++ .../components/qvr_pro/manifest.json | 8 ++ .../components/qvr_pro/services.yaml | 13 +++ requirements_all.txt | 3 + 8 files changed, 237 insertions(+) create mode 100644 homeassistant/components/qvr_pro/__init__.py create mode 100644 homeassistant/components/qvr_pro/camera.py create mode 100644 homeassistant/components/qvr_pro/const.py create mode 100644 homeassistant/components/qvr_pro/manifest.json create mode 100644 homeassistant/components/qvr_pro/services.yaml diff --git a/.coveragerc b/.coveragerc index 9bffb4350f9..221b43998c4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -567,6 +567,7 @@ omit = homeassistant/components/qnap/sensor.py homeassistant/components/qrcode/image_processing.py homeassistant/components/quantum_gateway/device_tracker.py + homeassistant/components/qvr_pro/* homeassistant/components/qwikswitch/* homeassistant/components/rachio/* homeassistant/components/radarr/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 35ff288879c..8f821f43fec 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -278,6 +278,7 @@ homeassistant/components/pvoutput/* @fabaff homeassistant/components/qld_bushfire/* @exxamalte homeassistant/components/qnap/* @colinodell homeassistant/components/quantum_gateway/* @cisasteelersfan +homeassistant/components/qvr_pro/* @oblogic7 homeassistant/components/qwikswitch/* @kellerza homeassistant/components/rainbird/* @konikvranik homeassistant/components/raincloud/* @vanstinator diff --git a/homeassistant/components/qvr_pro/__init__.py b/homeassistant/components/qvr_pro/__init__.py new file mode 100644 index 00000000000..f2840d49299 --- /dev/null +++ b/homeassistant/components/qvr_pro/__init__.py @@ -0,0 +1,100 @@ +"""Support for QVR Pro NVR software by QNAP.""" + +import logging + +from pyqvrpro import Client +from pyqvrpro.client import AuthenticationError, InsufficientPermissionsError +import voluptuous as vol + +from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform + +from .const import ( + CONF_EXCLUDE_CHANNELS, + DOMAIN, + SERVICE_START_RECORD, + SERVICE_STOP_RECORD, +) + +SERVICE_CHANNEL_GUID = "guid" + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_EXCLUDE_CHANNELS, default=[]): vol.All( + cv.ensure_list_csv, [cv.positive_int] + ), + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + +SERVICE_CHANNEL_RECORD_SCHEMA = vol.Schema( + {vol.Required(SERVICE_CHANNEL_GUID): cv.string} +) + + +def setup(hass, config): + """Set up the QVR Pro component.""" + conf = config[DOMAIN] + user = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] + host = conf[CONF_HOST] + excluded_channels = conf[CONF_EXCLUDE_CHANNELS] + + try: + qvrpro = Client(user, password, host) + + channel_resp = qvrpro.get_channel_list() + + except InsufficientPermissionsError: + _LOGGER.error("User must have Surveillance Management permission") + return False + except AuthenticationError: + _LOGGER.error("Authentication failed") + return False + + channels = [] + + for channel in channel_resp["channels"]: + if channel["channel_index"] + 1 in excluded_channels: + continue + + channels.append(channel) + + hass.data[DOMAIN] = {"channels": channels, "client": qvrpro} + + load_platform(hass, CAMERA_DOMAIN, DOMAIN, {}, config) + + # Register services + def handle_start_record(call): + guid = call.data[SERVICE_CHANNEL_GUID] + qvrpro.start_recording(guid) + + def handle_stop_record(call): + guid = call.data[SERVICE_CHANNEL_GUID] + qvrpro.stop_recording(guid) + + hass.services.register( + DOMAIN, + SERVICE_START_RECORD, + handle_start_record, + schema=SERVICE_CHANNEL_RECORD_SCHEMA, + ) + hass.services.register( + DOMAIN, + SERVICE_STOP_RECORD, + handle_stop_record, + schema=SERVICE_CHANNEL_RECORD_SCHEMA, + ) + + return True diff --git a/homeassistant/components/qvr_pro/camera.py b/homeassistant/components/qvr_pro/camera.py new file mode 100644 index 00000000000..28f607165a7 --- /dev/null +++ b/homeassistant/components/qvr_pro/camera.py @@ -0,0 +1,102 @@ +"""Support for QVR Pro streams.""" + +import logging + +from pyqvrpro.client import QVRResponseError + +from homeassistant.components.camera import Camera + +from .const import DOMAIN, SHORT_NAME + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the QVR Pro camera platform.""" + if discovery_info is None: + return + + client = hass.data[DOMAIN]["client"] + + entities = [] + + for channel in hass.data[DOMAIN]["channels"]: + + stream_source = get_stream_source(channel["guid"], client) + entities.append( + QVRProCamera(**channel, stream_source=stream_source, client=client) + ) + + add_entities(entities) + + +def get_stream_source(guid, client): + """Get channel stream source.""" + try: + resp = client.get_channel_live_stream(guid, protocol="rtsp") + + full_url = resp["resourceUris"] + + protocol = full_url[:7] + auth = f"{client.get_auth_string()}@" + url = full_url[7:] + + return f"{protocol}{auth}{url}" + + except QVRResponseError as ex: + _LOGGER.error(ex) + return None + + +class QVRProCamera(Camera): + """Representation of a QVR Pro camera.""" + + def __init__(self, name, model, brand, channel_index, guid, stream_source, client): + """Init QVR Pro camera.""" + + self._name = f"{SHORT_NAME} {name}" + self._model = model + self._brand = brand + self.index = channel_index + self.guid = guid + self._client = client + self._stream_source = stream_source + + self._supported_features = 0 + + super().__init__() + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def model(self): + """Return the model of the entity.""" + return self._model + + @property + def brand(self): + """Return the brand of the entity.""" + return self._brand + + @property + def device_state_attributes(self): + """Get the state attributes.""" + attrs = {"qvr_guid": self.guid} + + return attrs + + def camera_image(self): + """Get image bytes from camera.""" + return self._client.get_snapshot(self.guid) + + async def stream_source(self): + """Get stream source.""" + return self._stream_source + + @property + def supported_features(self): + """Get supported features.""" + return self._supported_features diff --git a/homeassistant/components/qvr_pro/const.py b/homeassistant/components/qvr_pro/const.py new file mode 100644 index 00000000000..eadf756a1c2 --- /dev/null +++ b/homeassistant/components/qvr_pro/const.py @@ -0,0 +1,9 @@ +"""Constants for QVR Pro component.""" + +DOMAIN = "qvr_pro" +SHORT_NAME = "QVR" + +CONF_EXCLUDE_CHANNELS = "exclude_channels" + +SERVICE_STOP_RECORD = "stop_record" +SERVICE_START_RECORD = "start_record" diff --git a/homeassistant/components/qvr_pro/manifest.json b/homeassistant/components/qvr_pro/manifest.json new file mode 100644 index 00000000000..3bef827a019 --- /dev/null +++ b/homeassistant/components/qvr_pro/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "qvr_pro", + "name": "QVR Pro", + "documentation": "https://www.home-assistant.io/integrations/qvr_pro", + "requirements": ["pyqvrpro==0.51"], + "dependencies": [], + "codeowners": ["@oblogic7"] +} diff --git a/homeassistant/components/qvr_pro/services.yaml b/homeassistant/components/qvr_pro/services.yaml new file mode 100644 index 00000000000..cc6866fee63 --- /dev/null +++ b/homeassistant/components/qvr_pro/services.yaml @@ -0,0 +1,13 @@ +start_record: + description: Start QVR Pro recording on specified channel. + fields: + guid: + description: GUID of the channel to start recording. + example: '245EBE933C0A597EBE865C0A245E0002' + +stop_record: + description: Stop QVR Pro recording on specified channel. + fields: + guid: + description: GUID of the channel to stop recording. + example: '245EBE933C0A597EBE865C0A245E0002' \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index 6787b02969d..628f98404ee 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1472,6 +1472,9 @@ pypoint==1.1.2 # homeassistant.components.ps4 pyps4-2ndscreen==1.0.7 +# homeassistant.components.qvr_pro +pyqvrpro==0.51 + # homeassistant.components.qwikswitch pyqwikswitch==0.93 From fcbea47c7491947b7f3543cc4769910c5970ae89 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 2 Mar 2020 17:59:32 -0800 Subject: [PATCH 192/416] Coronavirus updates (#32417) * Sort countries alphabetically * Update sensor name * Add migration to stable unique IDs * Update sensor.py --- .../components/coronavirus/__init__.py | 23 +++++++- .../components/coronavirus/config_flow.py | 6 +- .../components/coronavirus/sensor.py | 4 +- homeassistant/helpers/entity_registry.py | 20 ++++++- tests/components/coronavirus/test_init.py | 55 +++++++++++++++++++ 5 files changed, 100 insertions(+), 8 deletions(-) create mode 100644 tests/components/coronavirus/test_init.py diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py index 95c3cd1c024..d5dbcd9f3f4 100644 --- a/homeassistant/components/coronavirus/__init__.py +++ b/homeassistant/components/coronavirus/__init__.py @@ -8,8 +8,8 @@ import async_timeout import coronavirus from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client, update_coordinator +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import aiohttp_client, entity_registry, update_coordinator from .const import DOMAIN @@ -25,6 +25,23 @@ async def async_setup(hass: HomeAssistant, config: dict): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up Coronavirus from a config entry.""" + if isinstance(entry.data["country"], int): + hass.config_entries.async_update_entry( + entry, data={**entry.data, "country": entry.title} + ) + + @callback + def _async_migrator(entity_entry: entity_registry.RegistryEntry): + """Migrate away from unstable ID.""" + country, info_type = entity_entry.unique_id.rsplit("-", 1) + if not country.isnumeric(): + return None + return {"new_unique_id": f"{entry.title}-{info_type}"} + + await entity_registry.async_migrate_entries( + hass, entry.entry_id, _async_migrator + ) + for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) @@ -56,7 +73,7 @@ async def get_coordinator(hass): try: with async_timeout.timeout(10): return { - case.id: case + case.country: case for case in await coronavirus.get_cases( aiohttp_client.async_get_clientsession(hass) ) diff --git a/homeassistant/components/coronavirus/config_flow.py b/homeassistant/components/coronavirus/config_flow.py index 59d25e16709..4a313a6837f 100644 --- a/homeassistant/components/coronavirus/config_flow.py +++ b/homeassistant/components/coronavirus/config_flow.py @@ -26,8 +26,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if self._options is None: self._options = {OPTION_WORLDWIDE: "Worldwide"} coordinator = await get_coordinator(self.hass) - for case_id in sorted(coordinator.data): - self._options[case_id] = coordinator.data[case_id].country + for case in sorted( + coordinator.data.values(), key=lambda case: case.country + ): + self._options[case.country] = case.country if user_input is not None: return self.async_create_entry( diff --git a/homeassistant/components/coronavirus/sensor.py b/homeassistant/components/coronavirus/sensor.py index 770ab78b43e..20f18896431 100644 --- a/homeassistant/components/coronavirus/sensor.py +++ b/homeassistant/components/coronavirus/sensor.py @@ -25,9 +25,9 @@ class CoronavirusSensor(Entity): def __init__(self, coordinator, country, info_type): """Initialize coronavirus sensor.""" if country == OPTION_WORLDWIDE: - self.name = f"Worldwide {info_type}" + self.name = f"Worldwide Coronavirus {info_type}" else: - self.name = f"{coordinator.data[country].country} {info_type}" + self.name = f"{coordinator.data[country].country} Coronavirus {info_type}" self.unique_id = f"{country}-{info_type}" self.coordinator = coordinator self.country = country diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 5996fb6eaf7..87383d45635 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -11,7 +11,7 @@ import asyncio from collections import OrderedDict from itertools import chain import logging -from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, cast +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional, cast import attr @@ -560,3 +560,21 @@ def async_setup_entity_restore( states.async_set(entry.entity_id, STATE_UNAVAILABLE, attrs) hass.bus.async_listen(EVENT_HOMEASSISTANT_START, _write_unavailable_states) + + +async def async_migrate_entries( + hass: HomeAssistantType, + config_entry_id: str, + entry_callback: Callable[[RegistryEntry], Optional[dict]], +) -> None: + """Migrator of unique IDs.""" + ent_reg = await async_get_registry(hass) + + for entry in ent_reg.entities.values(): + if entry.config_entry_id != config_entry_id: + continue + + updates = entry_callback(entry) + + if updates is not None: + ent_reg.async_update_entity(entry.entity_id, **updates) # type: ignore diff --git a/tests/components/coronavirus/test_init.py b/tests/components/coronavirus/test_init.py new file mode 100644 index 00000000000..05a14f2f296 --- /dev/null +++ b/tests/components/coronavirus/test_init.py @@ -0,0 +1,55 @@ +"""Test init of Coronavirus integration.""" +from asynctest import Mock, patch + +from homeassistant.components.coronavirus.const import DOMAIN, OPTION_WORLDWIDE +from homeassistant.helpers import entity_registry +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, mock_registry + + +async def test_migration(hass): + """Test that we can migrate coronavirus to stable unique ID.""" + nl_entry = MockConfigEntry(domain=DOMAIN, title="Netherlands", data={"country": 34}) + nl_entry.add_to_hass(hass) + worldwide_entry = MockConfigEntry( + domain=DOMAIN, title="Worldwide", data={"country": OPTION_WORLDWIDE} + ) + worldwide_entry.add_to_hass(hass) + mock_registry( + hass, + { + "sensor.netherlands_confirmed": entity_registry.RegistryEntry( + entity_id="sensor.netherlands_confirmed", + unique_id="34-confirmed", + platform="coronavirus", + config_entry_id=nl_entry.entry_id, + ), + "sensor.worldwide_confirmed": entity_registry.RegistryEntry( + entity_id="sensor.worldwide_confirmed", + unique_id="__worldwide-confirmed", + platform="coronavirus", + config_entry_id=worldwide_entry.entry_id, + ), + }, + ) + with patch( + "coronavirus.get_cases", + return_value=[ + Mock(country="Netherlands", confirmed=10, recovered=8, deaths=1, current=1), + Mock(country="Germany", confirmed=1, recovered=0, deaths=0, current=0), + ], + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + ent_reg = await entity_registry.async_get_registry(hass) + + sensor_nl = ent_reg.async_get("sensor.netherlands_confirmed") + assert sensor_nl.unique_id == "Netherlands-confirmed" + + sensor_worldwide = ent_reg.async_get("sensor.worldwide_confirmed") + assert sensor_worldwide.unique_id == "__worldwide-confirmed" + + assert hass.states.get("sensor.netherlands_confirmed").state == "10" + assert hass.states.get("sensor.worldwide_confirmed").state == "11" From aacdc1bc2d7cdb28c86b9ee59984a30ba1fcb693 Mon Sep 17 00:00:00 2001 From: mezz64 <2854333+mezz64@users.noreply.github.com> Date: Mon, 2 Mar 2020 21:01:39 -0500 Subject: [PATCH 193/416] Catch Eight Sleep API errors, don't round None type (#32410) * Catch API errors, don't round None type * Specify error type --- .../components/eight_sleep/sensor.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py index 457323d3bd5..dcee52db592 100644 --- a/homeassistant/components/eight_sleep/sensor.py +++ b/homeassistant/components/eight_sleep/sensor.py @@ -263,14 +263,26 @@ class EightUserSensor(EightSleepUserEntity): bed_temp = None if "current" in self._sensor_root: - state_attr[ATTR_RESP_RATE] = round(self._attr["resp_rate"], 2) - state_attr[ATTR_HEART_RATE] = round(self._attr["heart_rate"], 2) + try: + state_attr[ATTR_RESP_RATE] = round(self._attr["resp_rate"], 2) + except TypeError: + state_attr[ATTR_RESP_RATE] = None + try: + state_attr[ATTR_HEART_RATE] = round(self._attr["heart_rate"], 2) + except TypeError: + state_attr[ATTR_HEART_RATE] = None state_attr[ATTR_SLEEP_STAGE] = self._attr["stage"] state_attr[ATTR_ROOM_TEMP] = room_temp state_attr[ATTR_BED_TEMP] = bed_temp elif "last" in self._sensor_root: - state_attr[ATTR_AVG_RESP_RATE] = round(self._attr["resp_rate"], 2) - state_attr[ATTR_AVG_HEART_RATE] = round(self._attr["heart_rate"], 2) + try: + state_attr[ATTR_AVG_RESP_RATE] = round(self._attr["resp_rate"], 2) + except TypeError: + state_attr[ATTR_AVG_RESP_RATE] = None + try: + state_attr[ATTR_AVG_HEART_RATE] = round(self._attr["heart_rate"], 2) + except TypeError: + state_attr[ATTR_AVG_HEART_RATE] = None state_attr[ATTR_AVG_ROOM_TEMP] = room_temp state_attr[ATTR_AVG_BED_TEMP] = bed_temp From c62961f40ca255bc58401636a18ea91b52f99ee7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 2 Mar 2020 18:10:38 -0800 Subject: [PATCH 194/416] Add unique ID to coronavirus (#32423) --- homeassistant/components/coronavirus/__init__.py | 3 +++ homeassistant/components/coronavirus/config_flow.py | 2 ++ homeassistant/components/coronavirus/strings.json | 3 +++ tests/components/coronavirus/test_config_flow.py | 2 +- tests/components/coronavirus/test_init.py | 3 +++ 5 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py index d5dbcd9f3f4..04976a1e4c5 100644 --- a/homeassistant/components/coronavirus/__init__.py +++ b/homeassistant/components/coronavirus/__init__.py @@ -42,6 +42,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass, entry.entry_id, _async_migrator ) + if not entry.unique_id: + hass.config_entries.async_update_entry(entry, unique_id=entry.data["country"]) + for component in PLATFORMS: hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, component) diff --git a/homeassistant/components/coronavirus/config_flow.py b/homeassistant/components/coronavirus/config_flow.py index 4a313a6837f..49183dd028e 100644 --- a/homeassistant/components/coronavirus/config_flow.py +++ b/homeassistant/components/coronavirus/config_flow.py @@ -32,6 +32,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._options[case.country] = case.country if user_input is not None: + await self.async_set_unique_id(user_input["country"]) + self._abort_if_unique_id_configured() return self.async_create_entry( title=self._options[user_input["country"]], data=user_input ) diff --git a/homeassistant/components/coronavirus/strings.json b/homeassistant/components/coronavirus/strings.json index 13cd5f04012..fd4873c808c 100644 --- a/homeassistant/components/coronavirus/strings.json +++ b/homeassistant/components/coronavirus/strings.json @@ -8,6 +8,9 @@ "country": "Country" } } + }, + "abort": { + "already_configured": "This country is already configured." } } } diff --git a/tests/components/coronavirus/test_config_flow.py b/tests/components/coronavirus/test_config_flow.py index 6d940d8e53d..ef04d0df07a 100644 --- a/tests/components/coronavirus/test_config_flow.py +++ b/tests/components/coronavirus/test_config_flow.py @@ -22,9 +22,9 @@ async def test_form(hass): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], {"country": OPTION_WORLDWIDE}, ) - assert result2["type"] == "create_entry" assert result2["title"] == "Worldwide" + assert result2["result"].unique_id == OPTION_WORLDWIDE assert result2["data"] == { "country": OPTION_WORLDWIDE, } diff --git a/tests/components/coronavirus/test_init.py b/tests/components/coronavirus/test_init.py index 05a14f2f296..57293635570 100644 --- a/tests/components/coronavirus/test_init.py +++ b/tests/components/coronavirus/test_init.py @@ -53,3 +53,6 @@ async def test_migration(hass): assert hass.states.get("sensor.netherlands_confirmed").state == "10" assert hass.states.get("sensor.worldwide_confirmed").state == "11" + + assert nl_entry.unique_id == "Netherlands" + assert worldwide_entry.unique_id == OPTION_WORLDWIDE From 558da56d7543ed990dea1a627792b6846ebd6ad4 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 3 Mar 2020 11:11:38 +0100 Subject: [PATCH 195/416] Update config.yml --- .github/ISSUE_TEMPLATE/config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 5b0b8c46e96..2440cb7ff29 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,7 +1,7 @@ blank_issues_enabled: false contact_links: - name: Report a bug with the UI, Frontend or Lovelace - url: https://github.com/home-assistant/home-assistant-polymer/issues + url: https://github.com/home-assistant/frontend/issues about: This is the issue tracker for our backend. Please report issues with the UI in the frontend repository. - name: Report incorrect or missing information on our website url: https://github.com/home-assistant/home-assistant.io/issues From 5bbbe60635d16a4c61b4bfc3bb06223e52284de6 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 3 Mar 2020 16:06:17 +0100 Subject: [PATCH 196/416] Disable codecov because it stop working after renaming. Come back with GitHub action migration --- azure-pipelines-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 4c6a353d775..8fb014f80a7 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -148,7 +148,7 @@ stages: . venv/bin/activate pytest --timeout=9 --durations=10 -n auto --dist=loadfile --cov homeassistant --cov-report html -qq -o console_output_style=count -p no:sugar tests - codecov --token $(codecovToken) + #codecov --token $(codecovToken) script/check_dirty displayName: 'Run pytest for python $(python.container) / coverage' condition: and(succeeded(), eq(variables['python.container'], variables['PythonMain'])) From 2f8381b1bf7583f89d3c3694b2f90fddeda97df4 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 3 Mar 2020 16:59:02 +0100 Subject: [PATCH 197/416] Bump brother library (#32436) --- homeassistant/components/brother/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/fixtures/brother_printer_data.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/brother/manifest.json b/homeassistant/components/brother/manifest.json index 51e6c3284ff..ec87adacb5f 100644 --- a/homeassistant/components/brother/manifest.json +++ b/homeassistant/components/brother/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/brother", "dependencies": [], "codeowners": ["@bieniu"], - "requirements": ["brother==0.1.6"], + "requirements": ["brother==0.1.8"], "zeroconf": ["_printer._tcp.local."], "config_flow": true } diff --git a/requirements_all.txt b/requirements_all.txt index 628f98404ee..ec23b2d51b6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -350,7 +350,7 @@ bravia-tv==1.0.1 broadlink==0.12.0 # homeassistant.components.brother -brother==0.1.6 +brother==0.1.8 # homeassistant.components.brottsplatskartan brottsplatskartan==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aceaddba194..8177ffa5039 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -127,7 +127,7 @@ bomradarloop==0.1.3 broadlink==0.12.0 # homeassistant.components.brother -brother==0.1.6 +brother==0.1.8 # homeassistant.components.buienradar buienradar==1.0.1 diff --git a/tests/fixtures/brother_printer_data.json b/tests/fixtures/brother_printer_data.json index 977953dd3bd..d1b631d7548 100644 --- a/tests/fixtures/brother_printer_data.json +++ b/tests/fixtures/brother_printer_data.json @@ -10,7 +10,7 @@ "81010400000050", "8601040000000a" ], - "1.3.6.1.4.1.2435.2.4.3.2435.5.13.3.0": "Brother HL-L2340DW", + "1.3.6.1.4.1.2435.2.3.9.1.1.7.0": "MFG:Brother;CMD:PJL,HBP,URF;MDL:HL-L2340DW series;CLS:PRINTER;CID:Brother Laser Type1;URF:W8,CP1,IS4-1,MT1-3-4-5-8,OB10,PQ4,RS300-600,V1.3,DM1;", "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.11.0": ["82010400002b06"], "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.5.1.0": "0123456789", "1.3.6.1.4.1.2435.2.3.9.4.2.1.5.4.5.2.0": "WAITING " From 59b4e42f8b285d64d7207956eb07a19e3efa9b06 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Tue, 3 Mar 2020 07:59:32 -0800 Subject: [PATCH 198/416] Remove SUPPORT_PLAY_MEDIA from Roku (#32378) --- homeassistant/components/roku/media_player.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index f3ae60ecbea..ce275aff92c 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -9,7 +9,6 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_MOVIE, SUPPORT_NEXT_TRACK, SUPPORT_PLAY, - SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, @@ -32,7 +31,6 @@ _LOGGER = logging.getLogger(__name__) SUPPORT_ROKU = ( SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK - | SUPPORT_PLAY_MEDIA | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE From 1119da7e8a6bdce2ea70e09bbbad07d1a1c24eb4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Mar 2020 11:22:26 -0600 Subject: [PATCH 199/416] =?UTF-8?q?Flume=20Cleanups=20(unique=20id,=20fix?= =?UTF-8?q?=20missing=20timeout,=20http=20session,=E2=80=A6=20(#32384)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Flume Cleanups * Sensors now show the name * include_devices and exclude_devices are now available in the config. * Address review items * bump as 0.3.0 is published --- homeassistant/components/flume/manifest.json | 2 +- homeassistant/components/flume/sensor.py | 44 +++++++++++++++++--- requirements_all.txt | 2 +- 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/flume/manifest.json b/homeassistant/components/flume/manifest.json index d03c6330f20..2264df2db06 100644 --- a/homeassistant/components/flume/manifest.json +++ b/homeassistant/components/flume/manifest.json @@ -2,7 +2,7 @@ "domain": "flume", "name": "flume", "documentation": "https://www.home-assistant.io/integrations/flume/", - "requirements": ["pyflume==0.2.4"], + "requirements": ["pyflume==0.3.0"], "dependencies": [], "codeowners": ["@ChrisMandich"] } diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index e96ce0d96ef..2694842134f 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta import logging from pyflume import FlumeData, FlumeDeviceList +from requests import Session import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -42,23 +43,37 @@ def setup_platform(hass, config, add_entities, discovery_info=None): name = config[CONF_NAME] flume_entity_list = [] + http_session = Session() + flume_devices = FlumeDeviceList( - username, password, client_id, client_secret, flume_token_file + username, + password, + client_id, + client_secret, + flume_token_file, + http_session=http_session, ) for device in flume_devices.device_list: if device["type"] == FLUME_TYPE_SENSOR: + device_id = device["id"] + device_name = device["location"]["name"] + flume = FlumeData( username, password, client_id, client_secret, - device["id"], + device_id, time_zone, SCAN_INTERVAL, flume_token_file, + update_on_init=False, + http_session=http_session, + ) + flume_entity_list.append( + FlumeSensor(flume, f"{name} {device_name}", device_id) ) - flume_entity_list.append(FlumeSensor(flume, f"{name} {device['id']}")) if flume_entity_list: add_entities(flume_entity_list, True) @@ -67,11 +82,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class FlumeSensor(Entity): """Representation of the Flume sensor.""" - def __init__(self, flume, name): + def __init__(self, flume, name, device_id): """Initialize the Flume sensor.""" self.flume = flume self._name = name + self._device_id = device_id self._state = None + self._available = False @property def name(self): @@ -86,9 +103,24 @@ class FlumeSensor(Entity): @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return "gal" + # This is in gallons per SCAN_INTERVAL + return "gal/m" + + @property + def available(self): + """Device is available.""" + return self._available + + @property + def unique_id(self): + """Device unique ID.""" + return self._device_id def update(self): """Get the latest data and updates the states.""" + self._available = False self.flume.update() - self._state = self.flume.value + new_value = self.flume.value + if new_value is not None: + self._available = True + self._state = new_value diff --git a/requirements_all.txt b/requirements_all.txt index ec23b2d51b6..cba7ea06545 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1260,7 +1260,7 @@ pyflexit==0.3 pyflic-homeassistant==0.4.dev0 # homeassistant.components.flume -pyflume==0.2.4 +pyflume==0.3.0 # homeassistant.components.flunearyou pyflunearyou==1.0.3 From cfa61a6b74ca9d7e496c804ab506ec7fe2406aab Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 3 Mar 2020 11:25:50 -0600 Subject: [PATCH 200/416] =?UTF-8?q?Properly=20define=20dependency=20for=20?= =?UTF-8?q?pvoutput=20integration=20on=20rest=20in=E2=80=A6=20(#32435)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/pvoutput/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json index 1cc1f7aa2f6..0ca7af3485d 100644 --- a/homeassistant/components/pvoutput/manifest.json +++ b/homeassistant/components/pvoutput/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/pvoutput", "requirements": [], "dependencies": [], + "after_dependencies": ["rest"], "codeowners": ["@fabaff"] } From 896df9267a77712e432b453fd55cc5058b2c78d4 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Tue, 3 Mar 2020 13:37:17 -0500 Subject: [PATCH 201/416] Fix ZHA device healthcheck pings (#32425) --- homeassistant/components/zha/core/device.py | 10 +- tests/components/zha/test_device.py | 146 ++++++++++++++++++++ 2 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 tests/components/zha/test_device.py diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 54c1bbe49a8..94eb16fc417 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -87,7 +87,7 @@ class ZHADevice(LogMixin): self._available_signal = "{}_{}_{}".format( self.name, self.ieee, SIGNAL_AVAILABLE ) - self._checkins_missed_count = 2 + self._checkins_missed_count = 0 self._unsub = async_dispatcher_connect( self.hass, self._available_signal, self.async_initialize ) @@ -284,8 +284,12 @@ class ZHADevice(LogMixin): ) if not self._channels.pools: return - pool = self._channels.pools[0] - basic_ch = pool.all_channels[f"{pool.id}:0"] + try: + pool = self._channels.pools[0] + basic_ch = pool.all_channels[f"{pool.id}:0x0000"] + except KeyError: + self.debug("%s %s does not have a mandatory basic cluster") + return self.hass.async_create_task( basic_ch.get_attribute_value( ATTR_MANUFACTURER, from_cache=False diff --git a/tests/components/zha/test_device.py b/tests/components/zha/test_device.py new file mode 100644 index 00000000000..3ac22b136fb --- /dev/null +++ b/tests/components/zha/test_device.py @@ -0,0 +1,146 @@ +"""Test zha device switch.""" +from datetime import timedelta +import time +from unittest import mock + +import asynctest +import pytest +import zigpy.zcl.clusters.general as general + +import homeassistant.components.zha.core.device as zha_core_device +import homeassistant.core as ha +import homeassistant.util.dt as dt_util + +from .common import async_enable_traffic + + +@pytest.fixture +def zigpy_device(zigpy_device_mock): + """Device tracker zigpy device.""" + + def _dev(with_basic_channel: bool = True): + in_clusters = [general.OnOff.cluster_id] + if with_basic_channel: + in_clusters.append(general.Basic.cluster_id) + + endpoints = { + 3: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0} + } + return zigpy_device_mock(endpoints) + + return _dev + + +@pytest.fixture +def device_with_basic_channel(zigpy_device): + """Return a zha device with a basic channel present.""" + return zigpy_device(with_basic_channel=True) + + +@pytest.fixture +def device_without_basic_channel(zigpy_device): + """Return a zha device with a basic channel present.""" + return zigpy_device(with_basic_channel=False) + + +def _send_time_changed(hass, seconds): + """Send a time changed event.""" + now = dt_util.utcnow() + timedelta(seconds) + hass.bus.async_fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now}) + + +@asynctest.patch( + "homeassistant.components.zha.core.channels.general.BasicChannel.async_initialize", + new=mock.MagicMock(), +) +async def test_check_available_success( + hass, device_with_basic_channel, zha_device_restored +): + """Check device availability success on 1st try.""" + + # pylint: disable=protected-access + zha_device = await zha_device_restored(device_with_basic_channel) + await async_enable_traffic(hass, [zha_device]) + basic_ch = device_with_basic_channel.endpoints[3].basic + + basic_ch.read_attributes.reset_mock() + device_with_basic_channel.last_seen = None + assert zha_device.available is True + _send_time_changed(hass, 61) + await hass.async_block_till_done() + assert zha_device.available is False + assert basic_ch.read_attributes.await_count == 0 + + device_with_basic_channel.last_seen = ( + time.time() - zha_core_device._KEEP_ALIVE_INTERVAL - 2 + ) + _seens = [time.time(), device_with_basic_channel.last_seen] + + def _update_last_seen(*args, **kwargs): + device_with_basic_channel.last_seen = _seens.pop() + + basic_ch.read_attributes.side_effect = _update_last_seen + + # successfully ping zigpy device, but zha_device is not yet available + _send_time_changed(hass, 61) + await hass.async_block_till_done() + assert basic_ch.read_attributes.await_count == 1 + assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] + assert zha_device.available is False + + # There was traffic from the device: pings, but not yet available + _send_time_changed(hass, 61) + await hass.async_block_till_done() + assert basic_ch.read_attributes.await_count == 2 + assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] + assert zha_device.available is False + + # There was traffic from the device: don't try to ping, marked as available + _send_time_changed(hass, 61) + await hass.async_block_till_done() + assert basic_ch.read_attributes.await_count == 2 + assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] + assert zha_device.available is True + + +@asynctest.patch( + "homeassistant.components.zha.core.channels.general.BasicChannel.async_initialize", + new=mock.MagicMock(), +) +async def test_check_available_unsuccessful( + hass, device_with_basic_channel, zha_device_restored +): + """Check device availability all tries fail.""" + + # pylint: disable=protected-access + zha_device = await zha_device_restored(device_with_basic_channel) + await async_enable_traffic(hass, [zha_device]) + basic_ch = device_with_basic_channel.endpoints[3].basic + + assert zha_device.available is True + assert basic_ch.read_attributes.await_count == 0 + + device_with_basic_channel.last_seen = ( + time.time() - zha_core_device._KEEP_ALIVE_INTERVAL - 2 + ) + + # unsuccessfuly ping zigpy device, but zha_device is still available + _send_time_changed(hass, 61) + await hass.async_block_till_done() + assert basic_ch.read_attributes.await_count == 1 + assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] + assert zha_device.available is True + + # still no traffic, but zha_device is still available + _send_time_changed(hass, 61) + await hass.async_block_till_done() + assert basic_ch.read_attributes.await_count == 2 + assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] + assert zha_device.available is True + + # not even trying to update, device is unavailble + _send_time_changed(hass, 61) + await hass.async_block_till_done() + assert basic_ch.read_attributes.await_count == 2 + assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] + assert zha_device.available is False From 8f6651af3dfde712a5a776281b75675527edeaf3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 3 Mar 2020 13:55:15 -0800 Subject: [PATCH 202/416] Update system log grouping (#32367) --- .../components/system_log/__init__.py | 51 ++++++++-------- tests/components/system_log/test_init.py | 59 ++++++++++++------- 2 files changed, 65 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 0c4270eaeef..2ddf02f76ed 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -1,5 +1,5 @@ """Support for system log.""" -from collections import OrderedDict +from collections import OrderedDict, deque import logging import re import traceback @@ -55,28 +55,21 @@ SERVICE_WRITE_SCHEMA = vol.Schema( def _figure_out_source(record, call_stack, hass): paths = [HOMEASSISTANT_PATH[0], hass.config.config_dir] - try: - # If netdisco is installed check its path too. - # pylint: disable=import-outside-toplevel - from netdisco import __path__ as netdisco_path - paths.append(netdisco_path[0]) - except ImportError: - pass # If a stack trace exists, extract file names from the entire call stack. # The other case is when a regular "log" is made (without an attached # exception). In that case, just use the file where the log was made from. if record.exc_info: - stack = [x[0] for x in traceback.extract_tb(record.exc_info[2])] + stack = [(x[0], x[1]) for x in traceback.extract_tb(record.exc_info[2])] else: index = -1 for i, frame in enumerate(call_stack): - if frame == record.pathname: + if frame[0] == record.pathname: index = i break if index == -1: # For some reason we couldn't find pathname in the stack. - stack = [record.pathname] + stack = [(record.pathname, record.lineno)] else: stack = call_stack[0 : index + 1] @@ -86,11 +79,11 @@ def _figure_out_source(record, call_stack, hass): for pathname in reversed(stack): # Try to match with a file within Home Assistant - match = re.match(paths_re, pathname) + match = re.match(paths_re, pathname[0]) if match: - return match.group(1) + return [match.group(1), pathname[1]] # Ok, we don't know what this is - return record.pathname + return (record.pathname, record.lineno) class LogEntry: @@ -101,7 +94,7 @@ class LogEntry: self.first_occured = self.timestamp = record.created self.name = record.name self.level = record.levelname - self.message = record.getMessage() + self.message = deque([record.getMessage()], maxlen=5) self.exception = "" self.root_cause = None if record.exc_info: @@ -112,14 +105,20 @@ class LogEntry: self.root_cause = str(traceback.extract_tb(tb)[-1]) self.source = source self.count = 1 - - def hash(self): - """Calculate a key for DedupStore.""" - return frozenset([self.name, self.message, self.root_cause]) + self.hash = str([self.name, *self.source, self.root_cause]) def to_dict(self): """Convert object into dict to maintain backward compatibility.""" - return vars(self) + return { + "name": self.name, + "message": list(self.message), + "level": self.level, + "source": self.source, + "timestamp": self.timestamp, + "exception": self.exception, + "count": self.count, + "first_occured": self.first_occured, + } class DedupStore(OrderedDict): @@ -132,12 +131,16 @@ class DedupStore(OrderedDict): def add_entry(self, entry): """Add a new entry.""" - key = str(entry.hash()) + key = entry.hash if key in self: # Update stored entry - self[key].count += 1 - self[key].timestamp = entry.timestamp + existing = self[key] + existing.count += 1 + existing.timestamp = entry.timestamp + + if entry.message[0] not in existing.message: + existing.message.append(entry.message[0]) self.move_to_end(key) else: @@ -172,7 +175,7 @@ class LogErrorHandler(logging.Handler): if record.levelno >= logging.WARN: stack = [] if not record.exc_info: - stack = [f for f, _, _, _ in traceback.extract_stack()] + stack = [(f[0], f[1]) for f in traceback.extract_stack()] entry = LogEntry( record, stack, _figure_out_source(record, stack, self.hass) diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py index 0ad87b59a81..9862260c5f8 100644 --- a/tests/components/system_log/test_init.py +++ b/tests/components/system_log/test_init.py @@ -30,6 +30,9 @@ def _generate_and_log_exception(exception, log): def assert_log(log, exception, message, level): """Assert that specified values are in a specific log entry.""" + if not isinstance(message, list): + message = [message] + assert log["name"] == "test_logger" assert exception in log["exception"] assert message == log["message"] @@ -39,7 +42,7 @@ def assert_log(log, exception, message, level): def get_frame(name): """Get log stack frame.""" - return (name, None, None, None) + return (name, 5, None, None) async def test_normal_logs(hass, hass_client): @@ -134,23 +137,46 @@ async def test_remove_older_logs(hass, hass_client): assert_log(log[1], "", "error message 2", "ERROR") +def log_msg(nr=2): + """Log an error at same line.""" + _LOGGER.error(f"error message %s", nr) + + async def test_dedup_logs(hass, hass_client): """Test that duplicate log entries are dedup.""" - await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) + await async_setup_component(hass, system_log.DOMAIN, {}) _LOGGER.error("error message 1") - _LOGGER.error("error message 2") - _LOGGER.error("error message 2") + log_msg() + log_msg("2-2") _LOGGER.error("error message 3") - log = await get_error_log(hass, hass_client, 2) + log = await get_error_log(hass, hass_client, 3) assert_log(log[0], "", "error message 3", "ERROR") assert log[1]["count"] == 2 - assert_log(log[1], "", "error message 2", "ERROR") + assert_log(log[1], "", ["error message 2", "error message 2-2"], "ERROR") - _LOGGER.error("error message 2") - log = await get_error_log(hass, hass_client, 2) - assert_log(log[0], "", "error message 2", "ERROR") + log_msg() + log = await get_error_log(hass, hass_client, 3) + assert_log(log[0], "", ["error message 2", "error message 2-2"], "ERROR") assert log[0]["timestamp"] > log[0]["first_occured"] + log_msg("2-3") + log_msg("2-4") + log_msg("2-5") + log_msg("2-6") + log = await get_error_log(hass, hass_client, 3) + assert_log( + log[0], + "", + [ + "error message 2-2", + "error message 2-3", + "error message 2-4", + "error message 2-5", + "error message 2-6", + ], + "ERROR", + ) + async def test_clear_logs(hass, hass_client): """Test that the log can be cleared via a service call.""" @@ -218,7 +244,7 @@ async def test_unknown_path(hass, hass_client): _LOGGER.findCaller = MagicMock(return_value=("unknown_path", 0, None, None)) _LOGGER.error("error message") log = (await get_error_log(hass, hass_client, 1))[0] - assert log["source"] == "unknown_path" + assert log["source"] == ["unknown_path", 0] def log_error_from_test_path(path): @@ -250,7 +276,7 @@ async def test_homeassistant_path(hass, hass_client): ): log_error_from_test_path("venv_path/homeassistant/component/component.py") log = (await get_error_log(hass, hass_client, 1))[0] - assert log["source"] == "component/component.py" + assert log["source"] == ["component/component.py", 5] async def test_config_path(hass, hass_client): @@ -259,13 +285,4 @@ async def test_config_path(hass, hass_client): with patch.object(hass.config, "config_dir", new="config"): log_error_from_test_path("config/custom_component/test.py") log = (await get_error_log(hass, hass_client, 1))[0] - assert log["source"] == "custom_component/test.py" - - -async def test_netdisco_path(hass, hass_client): - """Test error logged from netdisco path.""" - await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG) - with patch.dict("sys.modules", netdisco=MagicMock(__path__=["venv_path/netdisco"])): - log_error_from_test_path("venv_path/netdisco/disco_component.py") - log = (await get_error_log(hass, hass_client, 1))[0] - assert log["source"] == "disco_component.py" + assert log["source"] == ["custom_component/test.py", 5] From fed23030d6dd528aeb90d7fcee9aa7732de7b15a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 3 Mar 2020 13:56:32 -0800 Subject: [PATCH 203/416] Filter out duplicate logbook states (#32427) --- homeassistant/components/logbook/__init__.py | 11 ++++++- tests/components/logbook/test_init.py | 33 ++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 266ff3601eb..9921abdb59d 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -199,6 +199,9 @@ def humanify(hass, events): """ domain_prefixes = tuple(f"{dom}." for dom in CONTINUOUS_DOMAINS) + # Track last states to filter out duplicates + last_state = {} + # Group events in batches of GROUP_BY_MINUTES for _, g_events in groupby( events, lambda event: event.time_fired.minute // GROUP_BY_MINUTES @@ -236,9 +239,15 @@ def humanify(hass, events): # Yield entries for event in events_batch: if event.event_type == EVENT_STATE_CHANGED: - to_state = State.from_dict(event.data.get("new_state")) + # Filter out states that become same state again (force_update=True) + # or light becoming different color + if last_state.get(to_state.entity_id) == to_state.state: + continue + + last_state[to_state.entity_id] = to_state.state + domain = to_state.domain # Skip all but the last sensor state diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 70e769a54f2..750ad17b523 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -1484,3 +1484,36 @@ async def test_humanify_script_started_event(hass): assert event2["domain"] == "script" assert event2["message"] == "started" assert event2["entity_id"] == "script.bye" + + +async def test_humanify_same_state(hass): + """Test humanifying Script Run event.""" + state_50 = ha.State("light.kitchen", "on", {"brightness": 50}).as_dict() + state_100 = ha.State("light.kitchen", "on", {"brightness": 100}).as_dict() + state_200 = ha.State("light.kitchen", "on", {"brightness": 200}).as_dict() + + events = list( + logbook.humanify( + hass, + [ + ha.Event( + EVENT_STATE_CHANGED, + { + "entity_id": "light.kitchen", + "old_state": state_50, + "new_state": state_100, + }, + ), + ha.Event( + EVENT_STATE_CHANGED, + { + "entity_id": "light.kitchen", + "old_state": state_100, + "new_state": state_200, + }, + ), + ], + ) + ) + + assert len(events) == 1 From db7d0eb9b90de547de54a4a56d900dc5c1483fd1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 3 Mar 2020 13:57:09 -0800 Subject: [PATCH 204/416] Remove hassfest blacklisted rest (#32441) * Remove blacklisted deps from hassfest deps * Whitelist all internal integrations --- .../dwd_weather_warnings/manifest.json | 1 + .../components/emulated_hue/manifest.json | 1 + script/hassfest/dependencies.py | 62 ++++++++++++------- 3 files changed, 41 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index 19dcf2860d7..0a9f972c84e 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings", "requirements": [], "dependencies": [], + "after_dependencies": ["rest"], "codeowners": [] } diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json index c3c0302dbc3..37848e6f306 100644 --- a/homeassistant/components/emulated_hue/manifest.json +++ b/homeassistant/components/emulated_hue/manifest.json @@ -4,6 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/emulated_hue", "requirements": ["aiohttp_cors==0.7.0"], "dependencies": [], + "after_dependencies": ["http"], "codeowners": [], "quality_scale": "internal" } diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index c909b6216a9..934400533e1 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -65,7 +65,7 @@ class ImportCollector(ast.NodeVisitor): # self.hass.components.hue.async_create() # Name(id=self) - # .Attribute(attr=hass) + # .Attribute(attr=hass) or .Attribute(attr=_hass) # .Attribute(attr=hue) # .Attribute(attr=async_create) if ( @@ -78,7 +78,7 @@ class ImportCollector(ast.NodeVisitor): ) or ( isinstance(node.value.value, ast.Attribute) - and node.value.value.attr == "hass" + and node.value.value.attr in ("hass", "_hass") ) ) ): @@ -89,20 +89,47 @@ class ImportCollector(ast.NodeVisitor): ALLOWED_USED_COMPONENTS = { - # This component will always be set up - "persistent_notification", - # These allow to register things without being set up - "conversation", - "frontend", - "hassio", - "system_health", - "websocket_api", + # Internal integrations + "alert", "automation", + "conversation", "device_automation", - "zone", + "frontend", + "group", + "hassio", "homeassistant", - "system_log", + "input_boolean", + "input_datetime", + "input_number", + "input_select", + "input_text", + "persistent_notification", "person", + "script", + "shopping_list", + "sun", + "system_health", + "system_log", + "timer", + "webhook", + "websocket_api", + "zone", + # Entity integrations with platforms + "alarm_control_panel", + "binary_sensor", + "climate", + "cover", + "device_tracker", + "fan", + "image_processing", + "light", + "lock", + "media_player", + "scene", + "sensor", + "switch", + "vacuum", + "water_heater", # Other "mjpeg", # base class, has no reqs or component to load. "stream", # Stream cannot install on all systems, can be imported without reqs. @@ -121,18 +148,7 @@ IGNORE_VIOLATIONS = { # This should become a helper method that integrations can submit data to ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), - # Expose HA to external systems - "homekit", - "alexa", - "google_assistant", - "emulated_hue", - "prometheus", - "conversation", "logbook", - "mobile_app", - # These should be extracted to external package - "pvoutput", - "dwd_weather_warnings", } From 4cf86262af580ee5afed44df8843c3ef31b388ca Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 3 Mar 2020 17:26:44 -0800 Subject: [PATCH 205/416] Numeric state trigger: validate that above is not above below (#32421) * Numeric state trigger: validate that above is not above below * Lint --- .../components/automation/numeric_state.py | 18 ++++++++++++++++++ .../automation/test_numeric_state.py | 10 ++++++++++ 2 files changed, 28 insertions(+) diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py index e944b66751b..d8f71f5bdf3 100644 --- a/homeassistant/components/automation/numeric_state.py +++ b/homeassistant/components/automation/numeric_state.py @@ -19,6 +19,23 @@ from homeassistant.helpers.event import async_track_same_state, async_track_stat # mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs # mypy: no-check-untyped-defs + +def validate_above_below(value): + """Validate that above and below can co-exist.""" + above = value.get(CONF_ABOVE) + below = value.get(CONF_BELOW) + + if above is None or below is None: + return value + + if above > below: + raise vol.Invalid( + f"A value can never be above {above} and below {below} at the same time. You probably want two different triggers.", + ) + + return value + + TRIGGER_SCHEMA = vol.All( vol.Schema( { @@ -35,6 +52,7 @@ TRIGGER_SCHEMA = vol.All( } ), cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE), + validate_above_below, ) _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 17cb8e38136..f779f022e65 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -3,8 +3,10 @@ from datetime import timedelta from unittest.mock import patch import pytest +import voluptuous as vol import homeassistant.components.automation as automation +from homeassistant.components.automation import numeric_state from homeassistant.core import Context from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -1229,3 +1231,11 @@ async def test_if_fires_on_entities_change_overlap_for_template(hass, calls): await hass.async_block_till_done() assert 2 == len(calls) assert "test.entity_2 - 0:00:10" == calls[1].data["some"] + + +def test_below_above(): + """Test above cannot be above below.""" + with pytest.raises(vol.Invalid): + numeric_state.TRIGGER_SCHEMA( + {"platform": "numeric_state", "above": 1200, "below": 1000} + ) From 02c8cd07f37724605f4e77a15078f7516d7e4c88 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 4 Mar 2020 02:32:13 +0100 Subject: [PATCH 206/416] UniFi - Fix websocket bug (#32449) --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index a42b136e665..85633ebf131 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifi", "requirements": [ - "aiounifi==13" + "aiounifi==14" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index cba7ea06545..8561bc29457 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -203,7 +203,7 @@ aiopylgtv==0.3.3 aioswitcher==2019.4.26 # homeassistant.components.unifi -aiounifi==13 +aiounifi==14 # homeassistant.components.wwlln aiowwlln==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8177ffa5039..290a058c9b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ aiopylgtv==0.3.3 aioswitcher==2019.4.26 # homeassistant.components.unifi -aiounifi==13 +aiounifi==14 # homeassistant.components.wwlln aiowwlln==2.0.2 From 7e3e4c166840057246f454fcc1d06c382f6f1c90 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Wed, 4 Mar 2020 02:36:28 +0100 Subject: [PATCH 207/416] Fix pushover's ATTR_RETRY env variable typo (#32440) --- homeassistant/components/pushover/notify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/pushover/notify.py b/homeassistant/components/pushover/notify.py index bc44cbeddb7..01d4d8fddde 100644 --- a/homeassistant/components/pushover/notify.py +++ b/homeassistant/components/pushover/notify.py @@ -61,7 +61,7 @@ class PushoverNotificationService(BaseNotificationService): url = data.get(ATTR_URL, None) url_title = data.get(ATTR_URL_TITLE, None) priority = data.get(ATTR_PRIORITY, None) - retry = data.get(ATTR_PRIORITY, None) + retry = data.get(ATTR_RETRY, None) expire = data.get(ATTR_EXPIRE, None) callback_url = data.get(ATTR_CALLBACK_URL, None) timestamp = data.get(ATTR_TIMESTAMP, None) From f0c7a7c1bf2375cb0b5705239fe48e3f023caeba Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Tue, 3 Mar 2020 18:25:32 -0800 Subject: [PATCH 208/416] Fix too many device tracker updates in log for Tesla (#32426) * Fix Tesla too many device tracker updates in log * Empty commit to re-trigger build Co-authored-by: Franck Nijhof --- homeassistant/components/tesla/device_tracker.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/tesla/device_tracker.py b/homeassistant/components/tesla/device_tracker.py index 08e5d58ba6e..f39d8055b12 100644 --- a/homeassistant/components/tesla/device_tracker.py +++ b/homeassistant/components/tesla/device_tracker.py @@ -68,3 +68,8 @@ class TeslaDeviceEntity(TeslaDevice, TrackerEntity): def source_type(self): """Return the source type, eg gps or router, of the device.""" return SOURCE_TYPE_GPS + + @property + def force_update(self): + """All updates do not need to be written to the state machine.""" + return False From d666b156892bbfdcea5b9ee1144c683abe8335ed Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Tue, 3 Mar 2020 23:08:31 -0800 Subject: [PATCH 209/416] Bump total-connect-client to 0.53 (#32460) --- homeassistant/components/totalconnect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 967115e721a..3ebb319ad07 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -2,7 +2,7 @@ "domain": "totalconnect", "name": "Honeywell Total Connect Alarm", "documentation": "https://www.home-assistant.io/integrations/totalconnect", - "requirements": ["total_connect_client==0.50"], + "requirements": ["total_connect_client==0.53"], "dependencies": [], "codeowners": ["@austinmroczek"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8561bc29457..8ea452abe42 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2005,7 +2005,7 @@ todoist-python==8.0.0 toonapilib==3.2.4 # homeassistant.components.totalconnect -total_connect_client==0.50 +total_connect_client==0.53 # homeassistant.components.tplink_lte tp-connected==0.0.4 From af76a336af5f40c230407265f2f8a57897fde5a6 Mon Sep 17 00:00:00 2001 From: Philipp Schmitt Date: Wed, 4 Mar 2020 09:41:34 +0100 Subject: [PATCH 210/416] Update roombapy to 1.4.3 (#32462) --- homeassistant/components/roomba/manifest.json | 8 ++++++-- requirements_all.txt | 2 +- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 0b674588dc6..bf048cadc8f 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -2,7 +2,11 @@ "domain": "roomba", "name": "iRobot Roomba", "documentation": "https://www.home-assistant.io/integrations/roomba", - "requirements": ["roombapy==1.4.2"], + "requirements": [ + "roombapy==1.4.3" + ], "dependencies": [], - "codeowners": ["@pschmitt"] + "codeowners": [ + "@pschmitt" + ] } diff --git a/requirements_all.txt b/requirements_all.txt index 8ea452abe42..c0d5cb62ad7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1786,7 +1786,7 @@ rocketchat-API==0.6.1 roku==4.0.0 # homeassistant.components.roomba -roombapy==1.4.2 +roombapy==1.4.3 # homeassistant.components.rova rova==0.1.0 From 7678d66464c9825f99f53bdd2997289ca93bf935 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 00:43:52 -0800 Subject: [PATCH 211/416] Fix device tracker TrackerEntity defaults (#32459) --- .../components/device_tracker/config_entry.py | 9 +++++++-- .../components/geofency/device_tracker.py | 5 ----- .../components/gpslogger/device_tracker.py | 5 ----- .../components/icloud/device_tracker.py | 5 ----- .../components/locative/device_tracker.py | 5 ----- .../components/mobile_app/device_tracker.py | 5 ----- .../components/owntracks/device_tracker.py | 5 ----- .../components/tesla/device_tracker.py | 5 ----- .../components/traccar/device_tracker.py | 5 ----- .../device_tracker/test_config_entry.py | 19 +++++++++++++++++++ 10 files changed, 26 insertions(+), 42 deletions(-) create mode 100644 tests/components/device_tracker/test_config_entry.py diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 059c51989fe..1be47b9b981 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -61,10 +61,15 @@ class BaseTrackerEntity(Entity): class TrackerEntity(BaseTrackerEntity): """Represent a tracked device.""" + @property + def should_poll(self): + """No polling for entities that have location pushed.""" + return False + @property def force_update(self): - """All updates need to be written to the state machine.""" - return True + """All updates need to be written to the state machine if we're not polling.""" + return not self.should_poll @property def location_accuracy(self): diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py index 49bd70192ef..e730f108f8f 100644 --- a/homeassistant/components/geofency/device_tracker.py +++ b/homeassistant/components/geofency/device_tracker.py @@ -84,11 +84,6 @@ class GeofencyEntity(TrackerEntity, RestoreEntity): """Return the name of the device.""" return self._name - @property - def should_poll(self): - """No polling needed.""" - return False - @property def unique_id(self): """Return the unique ID.""" diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py index d8afc377d40..d294b07ebc7 100644 --- a/homeassistant/components/gpslogger/device_tracker.py +++ b/homeassistant/components/gpslogger/device_tracker.py @@ -107,11 +107,6 @@ class GPSLoggerEntity(TrackerEntity, RestoreEntity): """Return the name of the device.""" return self._name - @property - def should_poll(self): - """No polling needed.""" - return False - @property def unique_id(self): """Return the unique ID.""" diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 00f35fbee85..4248485e11b 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -107,11 +107,6 @@ class IcloudTrackerEntity(TrackerEntity): "model": self._device.device_model, } - @property - def should_poll(self) -> bool: - """No polling needed.""" - return False - async def async_added_to_hass(self): """Register state update callback.""" self._unsub_dispatcher = async_dispatcher_connect( diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py index ef247954171..49983d44eb9 100644 --- a/homeassistant/components/locative/device_tracker.py +++ b/homeassistant/components/locative/device_tracker.py @@ -61,11 +61,6 @@ class LocativeEntity(TrackerEntity): """Return the name of the device.""" return self._name - @property - def should_poll(self): - """No polling needed.""" - return False - @property def source_type(self): """Return the source type, eg gps or router, of the device.""" diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py index 480bfee512f..850d17212fd 100644 --- a/homeassistant/components/mobile_app/device_tracker.py +++ b/homeassistant/components/mobile_app/device_tracker.py @@ -100,11 +100,6 @@ class MobileAppEntity(TrackerEntity, RestoreEntity): """Return the name of the device.""" return self._entry.data[ATTR_DEVICE_NAME] - @property - def should_poll(self): - """No polling needed.""" - return False - @property def source_type(self): """Return the source type, eg gps or router, of the device.""" diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index ed94ef0fa14..89312f96c68 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -118,11 +118,6 @@ class OwnTracksEntity(TrackerEntity, RestoreEntity): """Return the name of the device.""" return self._data.get("host_name") - @property - def should_poll(self): - """No polling needed.""" - return False - @property def source_type(self): """Return the source type, eg gps or router, of the device.""" diff --git a/homeassistant/components/tesla/device_tracker.py b/homeassistant/components/tesla/device_tracker.py index f39d8055b12..08e5d58ba6e 100644 --- a/homeassistant/components/tesla/device_tracker.py +++ b/homeassistant/components/tesla/device_tracker.py @@ -68,8 +68,3 @@ class TeslaDeviceEntity(TeslaDevice, TrackerEntity): def source_type(self): """Return the source type, eg gps or router, of the device.""" return SOURCE_TYPE_GPS - - @property - def force_update(self): - """All updates do not need to be written to the state machine.""" - return False diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py index 7f23d6cf31e..b6e829750e9 100644 --- a/homeassistant/components/traccar/device_tracker.py +++ b/homeassistant/components/traccar/device_tracker.py @@ -371,11 +371,6 @@ class TraccarEntity(TrackerEntity, RestoreEntity): """Return the name of the device.""" return self._name - @property - def should_poll(self): - """No polling needed.""" - return False - @property def unique_id(self): """Return the unique ID.""" diff --git a/tests/components/device_tracker/test_config_entry.py b/tests/components/device_tracker/test_config_entry.py new file mode 100644 index 00000000000..9b6a85cf8a0 --- /dev/null +++ b/tests/components/device_tracker/test_config_entry.py @@ -0,0 +1,19 @@ +"""Test Device Tracker config entry things.""" +from homeassistant.components.device_tracker import config_entry + + +def test_tracker_entity(): + """Test tracker entity.""" + + class TestEntry(config_entry.TrackerEntity): + """Mock tracker class.""" + + should_poll = False + + instance = TestEntry() + + assert instance.force_update + + instance.should_poll = True + + assert not instance.force_update From 6a6bf517fe45dc6de17bdc521e8fe88bf4bf7f6e Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Wed, 4 Mar 2020 01:00:34 -0800 Subject: [PATCH 212/416] Add energy added attribute to Tesla charging rate sensor (#32368) * Add charge_energy_added attribute * Bump teslajsonpy --- homeassistant/components/tesla/manifest.json | 11 ++++++++--- homeassistant/components/tesla/sensor.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 11 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tesla/manifest.json b/homeassistant/components/tesla/manifest.json index f536cdf96b4..21605d16579 100644 --- a/homeassistant/components/tesla/manifest.json +++ b/homeassistant/components/tesla/manifest.json @@ -3,7 +3,12 @@ "name": "Tesla", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tesla", - "requirements": ["teslajsonpy==0.3.0"], + "requirements": [ + "teslajsonpy==0.4.0" + ], "dependencies": [], - "codeowners": ["@zabuldon", "@alandtse"] -} + "codeowners": [ + "@zabuldon", + "@alandtse" + ] +} \ No newline at end of file diff --git a/homeassistant/components/tesla/sensor.py b/homeassistant/components/tesla/sensor.py index 9b06828693f..62bdebbb1f3 100644 --- a/homeassistant/components/tesla/sensor.py +++ b/homeassistant/components/tesla/sensor.py @@ -95,6 +95,7 @@ class TeslaSensor(TeslaDevice, Entity): 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, diff --git a/requirements_all.txt b/requirements_all.txt index c0d5cb62ad7..ce075e9927c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1984,7 +1984,7 @@ temperusb==1.5.3 # tensorflow==1.13.2 # homeassistant.components.tesla -teslajsonpy==0.3.0 +teslajsonpy==0.4.0 # homeassistant.components.thermoworks_smoke thermoworks_smoke==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 290a058c9b3..1156d63968e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -678,7 +678,7 @@ sunwatcher==0.2.1 tellduslive==0.10.10 # homeassistant.components.tesla -teslajsonpy==0.3.0 +teslajsonpy==0.4.0 # homeassistant.components.toon toonapilib==3.2.4 From 4f619691dfce25fc054dea1c8e4cf64532c07e1a Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Wed, 4 Mar 2020 04:04:52 -0500 Subject: [PATCH 213/416] Add is_volume_muted property to vizio integration (#32332) * add is_muted property and update tests * black * manually set is_muted on async_mute_volume calls to set state early * combine two lines into one * set is_muted to None when device is not on --- homeassistant/components/vizio/config_flow.py | 2 +- .../components/vizio/media_player.py | 20 +++++++++++++++---- tests/components/vizio/conftest.py | 7 +++++-- tests/components/vizio/test_media_player.py | 4 ++-- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 71fd606322e..993bb09b14f 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -230,7 +230,7 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): new_options.update(updated_options) self.hass.config_entries.async_update_entry( - entry=entry, data=new_data, options=new_options, + entry=entry, data=new_data, options=new_options ) return self.async_abort(reason="updated_entry") diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 6b62d6bafd0..edbe4171f0a 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -54,7 +54,7 @@ async def async_setup_entry( # If config entry options not set up, set them up, otherwise assign values managed in options volume_step = config_entry.options.get( - CONF_VOLUME_STEP, config_entry.data.get(CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP), + CONF_VOLUME_STEP, config_entry.data.get(CONF_VOLUME_STEP, DEFAULT_VOLUME_STEP) ) params = {} @@ -107,6 +107,7 @@ class VizioDevice(MediaPlayerDevice): self._state = None self._volume_level = None self._volume_step = volume_step + self._is_muted = None self._current_input = None self._available_inputs = None self._device_class = device_class @@ -145,15 +146,19 @@ class VizioDevice(MediaPlayerDevice): if not is_on: self._state = STATE_OFF self._volume_level = None + self._is_muted = None self._current_input = None self._available_inputs = None return self._state = STATE_ON - volume = await self._device.get_current_volume(log_api_exception=False) - if volume is not None: - self._volume_level = float(volume) / self._max_volume + audio_settings = await self._device.get_all_audio_settings( + log_api_exception=False + ) + if audio_settings is not None: + self._volume_level = float(audio_settings["volume"]) / self._max_volume + self._is_muted = audio_settings["mute"].lower() == "on" input_ = await self._device.get_current_input(log_api_exception=False) if input_ is not None: @@ -224,6 +229,11 @@ class VizioDevice(MediaPlayerDevice): """Return the volume level of the device.""" return self._volume_level + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._is_muted + @property def source(self) -> str: """Return current input of the device.""" @@ -272,8 +282,10 @@ class VizioDevice(MediaPlayerDevice): """Mute the volume.""" if mute: await self._device.mute_on() + self._is_muted = True else: await self._device.mute_off() + self._is_muted = False async def async_media_previous_track(self) -> None: """Send previous channel command.""" diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index e0b1b727f3d..5c0500fe1a6 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -132,8 +132,11 @@ def vizio_update_fixture(): "homeassistant.components.vizio.media_player.VizioAsync.can_connect_with_auth_check", return_value=True, ), patch( - "homeassistant.components.vizio.media_player.VizioAsync.get_current_volume", - return_value=int(MAX_VOLUME[DEVICE_CLASS_SPEAKER] / 2), + "homeassistant.components.vizio.media_player.VizioAsync.get_all_audio_settings", + return_value={ + "volume": int(MAX_VOLUME[DEVICE_CLASS_SPEAKER] / 2), + "mute": "Off", + }, ), patch( "homeassistant.components.vizio.media_player.VizioAsync.get_current_input", return_value=CURRENT_INPUT, diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index a94effa7433..d13fe8ecf53 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -69,8 +69,8 @@ async def _test_setup( ) with patch( - "homeassistant.components.vizio.media_player.VizioAsync.get_current_volume", - return_value=int(MAX_VOLUME[vizio_device_class] / 2), + "homeassistant.components.vizio.media_player.VizioAsync.get_all_audio_settings", + return_value={"volume": int(MAX_VOLUME[vizio_device_class] / 2), "mute": "Off"}, ), patch( "homeassistant.components.vizio.media_player.VizioAsync.get_power_state", return_value=vizio_power_state, From f62322cfb440653ca32dabfceb1e4d7469ec8289 Mon Sep 17 00:00:00 2001 From: z0p Date: Wed, 4 Mar 2020 11:09:33 +0200 Subject: [PATCH 214/416] Add set_speed to smarty fan (#32255) --- homeassistant/components/smarty/fan.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index bb6b7623779..e46198b051b 100644 --- a/homeassistant/components/smarty/fan.py +++ b/homeassistant/components/smarty/fan.py @@ -76,6 +76,16 @@ class SmartyFan(FanEntity): """Return speed of the fan.""" return self._speed + def set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + _LOGGER.debug("Set the fan speed to %s", speed) + if speed == SPEED_OFF: + self.turn_off() + else: + self._smarty.set_fan_speed(SPEED_TO_MODE.get(speed)) + self._speed = speed + self._state = True + def turn_on(self, speed=None, **kwargs): """Turn on the fan.""" _LOGGER.debug("Turning on fan. Speed is %s", speed) From b27c46750c330d7e55f1213df00ea96c28c64ab8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 08:05:46 -0800 Subject: [PATCH 215/416] Update error handling in update coordinator (#32452) --- .../components/coronavirus/__init__.py | 18 ++++------ homeassistant/components/hue/light.py | 12 ++----- homeassistant/components/hue/sensor_base.py | 8 ++--- .../components/tankerkoenig/sensor.py | 2 +- homeassistant/components/updater/__init__.py | 36 +++++++++---------- homeassistant/helpers/update_coordinator.py | 12 +++++++ tests/components/updater/test_init.py | 12 ------- tests/helpers/test_update_coordinator.py | 25 ++++++++----- 8 files changed, 58 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/coronavirus/__init__.py b/homeassistant/components/coronavirus/__init__.py index 04976a1e4c5..fa8efebe154 100644 --- a/homeassistant/components/coronavirus/__init__.py +++ b/homeassistant/components/coronavirus/__init__.py @@ -3,7 +3,6 @@ import asyncio from datetime import timedelta import logging -import aiohttp import async_timeout import coronavirus @@ -73,16 +72,13 @@ async def get_coordinator(hass): return hass.data[DOMAIN] async def async_get_cases(): - try: - with async_timeout.timeout(10): - return { - case.country: case - for case in await coronavirus.get_cases( - aiohttp_client.async_get_clientsession(hass) - ) - } - except (asyncio.TimeoutError, aiohttp.ClientError): - raise update_coordinator.UpdateFailed + with async_timeout.timeout(10): + return { + case.country: case + for case in await coronavirus.get_cases( + aiohttp_client.async_get_clientsession(hass) + ) + } hass.data[DOMAIN] = update_coordinator.DataUpdateCoordinator( hass, diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py index 253c0a2069c..e468c516676 100644 --- a/homeassistant/components/hue/light.py +++ b/homeassistant/components/hue/light.py @@ -1,11 +1,9 @@ """Support for the Philips Hue lights.""" -import asyncio from datetime import timedelta from functools import partial import logging import random -from aiohttp import client_exceptions import aiohue import async_timeout @@ -172,13 +170,9 @@ async def async_safe_fetch(bridge, fetch_method): return await bridge.async_request_call(fetch_method) except aiohue.Unauthorized: await bridge.handle_unauthorized_error() - raise UpdateFailed - except ( - asyncio.TimeoutError, - aiohue.AiohueException, - client_exceptions.ClientError, - ): - raise UpdateFailed + raise UpdateFailed("Unauthorized") + except (aiohue.AiohueException,) as err: + raise UpdateFailed(f"Hue error: {err}") @callback diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py index ed27cff8eab..507415963a5 100644 --- a/homeassistant/components/hue/sensor_base.py +++ b/homeassistant/components/hue/sensor_base.py @@ -1,9 +1,7 @@ """Support for the Philips Hue sensors as a platform.""" -import asyncio from datetime import timedelta import logging -from aiohttp import client_exceptions from aiohue import AiohueException, Unauthorized from aiohue.sensors import TYPE_ZLL_PRESENCE import async_timeout @@ -60,9 +58,9 @@ class SensorManager: ) except Unauthorized: await self.bridge.handle_unauthorized_error() - raise UpdateFailed - except (asyncio.TimeoutError, AiohueException, client_exceptions.ClientError): - raise UpdateFailed + raise UpdateFailed("Unauthorized") + except AiohueException as err: + raise UpdateFailed(f"Hue error: {err}") async def async_register_component(self, binary, async_add_entities): """Register async_add_entities methods for components.""" diff --git a/homeassistant/components/tankerkoenig/sensor.py b/homeassistant/components/tankerkoenig/sensor.py index c9e25d94a4b..2fb184848ea 100755 --- a/homeassistant/components/tankerkoenig/sensor.py +++ b/homeassistant/components/tankerkoenig/sensor.py @@ -36,7 +36,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= try: return await tankerkoenig.fetch_data() except LookupError: - raise UpdateFailed + raise UpdateFailed("Failed to fetch data") coordinator = DataUpdateCoordinator( hass, diff --git a/homeassistant/components/updater/__init__.py b/homeassistant/components/updater/__init__.py index 0a2c6697f69..5771a4f0cfe 100644 --- a/homeassistant/components/updater/__init__.py +++ b/homeassistant/components/updater/__init__.py @@ -1,12 +1,10 @@ """Support to check for available updates.""" -import asyncio from datetime import timedelta from distutils.version import StrictVersion import json import logging import uuid -import aiohttp import async_timeout from distro import linux_distribution # pylint: disable=import-error import voluptuous as vol @@ -156,29 +154,27 @@ async def get_newest_version(hass, huuid, include_components): info_object = {} session = async_get_clientsession(hass) - try: - with async_timeout.timeout(5): - req = await session.post(UPDATER_URL, json=info_object) - _LOGGER.info( - ( - "Submitted analytics to Home Assistant servers. " - "Information submitted includes %s" - ), - info_object, - ) - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Could not contact Home Assistant Update to check for updates") - raise update_coordinator.UpdateFailed + + with async_timeout.timeout(15): + req = await session.post(UPDATER_URL, json=info_object) + + _LOGGER.info( + ( + "Submitted analytics to Home Assistant servers. " + "Information submitted includes %s" + ), + info_object, + ) try: res = await req.json() except ValueError: - _LOGGER.error("Received invalid JSON from Home Assistant Update") - raise update_coordinator.UpdateFailed + raise update_coordinator.UpdateFailed( + "Received invalid JSON from Home Assistant Update" + ) try: res = RESPONSE_SCHEMA(res) return res["version"], res["release-notes"] - except vol.Invalid: - _LOGGER.error("Got unexpected response: %s", res) - raise update_coordinator.UpdateFailed + except vol.Invalid as err: + raise update_coordinator.UpdateFailed(f"Got unexpected response: {err}") diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index fe877fe9bb8..b2fe87148b1 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -5,6 +5,8 @@ import logging from time import monotonic from typing import Any, Awaitable, Callable, List, Optional +import aiohttp + from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow @@ -114,6 +116,16 @@ class DataUpdateCoordinator: start = monotonic() self.data = await self.update_method() + except asyncio.TimeoutError: + if self.last_update_success: + self.logger.error("Timeout fetching %s data", self.name) + self.last_update_success = False + + except aiohttp.ClientError as err: + if self.last_update_success: + self.logger.error("Error requesting %s data: %s", self.name, err) + self.last_update_success = False + except UpdateFailed as err: if self.last_update_success: self.logger.error("Error fetching %s data: %s", self.name, err) diff --git a/tests/components/updater/test_init.py b/tests/components/updater/test_init.py index 10fa026db29..e583f4e0114 100644 --- a/tests/components/updater/test_init.py +++ b/tests/components/updater/test_init.py @@ -1,5 +1,4 @@ """The tests for the Updater component.""" -import asyncio from unittest.mock import Mock from asynctest import patch @@ -130,17 +129,6 @@ async def test_get_newest_version_analytics_when_huuid(hass, aioclient_mock): assert res == (MOCK_RESPONSE["version"], MOCK_RESPONSE["release-notes"]) -async def test_error_fetching_new_version_timeout(hass): - """Test we handle timeout error while fetching new version.""" - with patch( - "homeassistant.helpers.system_info.async_get_system_info", - Mock(return_value=mock_coro({"fake": "bla"})), - ), patch("async_timeout.timeout", side_effect=asyncio.TimeoutError), pytest.raises( - UpdateFailed - ): - await updater.get_newest_version(hass, MOCK_HUUID, False) - - async def test_error_fetching_new_version_bad_json(hass, aioclient_mock): """Test we handle json error while fetching new version.""" aioclient_mock.post(updater.UPDATER_URL, text="not json") diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 04fd180b60d..115e00168fc 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -1,7 +1,9 @@ """Tests for the update coordinator.""" +import asyncio from datetime import timedelta import logging +import aiohttp from asynctest import CoroutineMock, Mock import pytest @@ -70,25 +72,30 @@ async def test_request_refresh(crd): assert crd.last_update_success is True -async def test_refresh_fail(crd, caplog): - """Test a failing update function.""" - crd.update_method = CoroutineMock(side_effect=update_coordinator.UpdateFailed) +@pytest.mark.parametrize( + "err_msg", + [ + (asyncio.TimeoutError, "Timeout fetching test data"), + (aiohttp.ClientError, "Error requesting test data"), + (update_coordinator.UpdateFailed, "Error fetching test data"), + ], +) +async def test_refresh_known_errors(err_msg, crd, caplog): + """Test raising known errors.""" + crd.update_method = CoroutineMock(side_effect=err_msg[0]) await crd.async_refresh() assert crd.data is None assert crd.last_update_success is False - assert "Error fetching test data" in caplog.text + assert err_msg[1] in caplog.text - crd.update_method = CoroutineMock(return_value=1) +async def test_refresh_fail_unknown(crd, caplog): + """Test raising unknown error.""" await crd.async_refresh() - assert crd.data == 1 - assert crd.last_update_success is True - crd.update_method = CoroutineMock(side_effect=ValueError) - caplog.clear() await crd.async_refresh() From 7f91501a3637912ef4a239fd198a78729bcb01cd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 4 Mar 2020 10:32:38 -0600 Subject: [PATCH 216/416] Use a requests Session for rest sensors (#32463) This avoids the ssl setup overhead for each request --- homeassistant/components/rest/sensor.py | 8 +++++++- tests/components/rest/test_sensor.py | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index dbe1d75f6af..0ed62abd001 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -5,6 +5,7 @@ from xml.parsers.expat import ExpatError from jsonpath import jsonpath import requests +from requests import Session from requests.auth import HTTPBasicAuth, HTTPDigestAuth import voluptuous as vol import xmltodict @@ -271,9 +272,14 @@ class RestData: self._request_data = data self._verify_ssl = verify_ssl self._timeout = timeout + self._http_session = Session() self.data = None self.headers = None + def __del__(self): + """Destroy the http session on destroy.""" + self._http_session.close() + def set_url(self, url): """Set url.""" self._resource = url @@ -282,7 +288,7 @@ class RestData: """Get the latest data from REST service with provided method.""" _LOGGER.debug("Updating from %s", self._resource) try: - response = requests.request( + response = self._http_session.request( self._method, self._resource, headers=self._headers, diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 74f7faae4b3..cd2a911292c 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -672,7 +672,7 @@ class TestRestData(unittest.TestCase): self.rest.update() assert "test data" == self.rest.data - @patch("requests.request", side_effect=RequestException) + @patch("requests.Session.request", side_effect=RequestException) def test_update_request_exception(self, mock_req): """Test update when a request exception occurs.""" self.rest.update() From 20333703c56e924507aa2cfba4cdc633c14f2dfd Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 4 Mar 2020 13:11:53 -0500 Subject: [PATCH 217/416] Remove ZHA attribute listening channel (#32468) * remove AttributeListeningChannel * pull sleeps * update signature to fix pylint --- homeassistant/components/zha/binary_sensor.py | 4 +- .../components/zha/core/channels/__init__.py | 4 +- .../components/zha/core/channels/base.py | 50 ++++++------------- .../components/zha/core/channels/closures.py | 20 ++++++-- .../components/zha/core/channels/general.py | 35 +++++++------ .../zha/core/channels/homeautomation.py | 10 ++-- .../components/zha/core/channels/hvac.py | 9 ++-- .../zha/core/channels/manufacturerspecific.py | 13 +++-- .../zha/core/channels/measurement.py | 16 +++--- .../components/zha/core/channels/security.py | 11 +++- .../zha/core/channels/smartenergy.py | 6 +-- .../components/zha/core/discovery.py | 2 +- homeassistant/components/zha/cover.py | 14 +++--- .../components/zha/device_tracker.py | 4 +- homeassistant/components/zha/entity.py | 2 +- homeassistant/components/zha/fan.py | 6 +-- homeassistant/components/zha/light.py | 6 +-- homeassistant/components/zha/lock.py | 4 +- homeassistant/components/zha/sensor.py | 8 +-- homeassistant/components/zha/switch.py | 4 +- tests/components/zha/test_channels.py | 4 +- 21 files changed, 124 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 93baf8e111b..def1588a127 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -103,9 +103,9 @@ class BinarySensor(ZhaEntity, BinarySensorDevice): return self._device_class @callback - def async_set_state(self, state): + def async_set_state(self, attr_id, attr_name, value): """Set the state.""" - self._state = bool(state) + self._state = bool(value) self.async_schedule_update_ha_state() async def async_update(self): diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index d884f359d47..715bc3e3e75 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -235,7 +235,7 @@ class ChannelPool: """Create and add channels for all input clusters.""" for cluster_id, cluster in self.endpoint.in_clusters.items(): channel_class = zha_regs.ZIGBEE_CHANNEL_REGISTRY.get( - cluster_id, base.AttributeListeningChannel + cluster_id, base.ZigbeeChannel ) # really ugly hack to deal with xiaomi using the door lock cluster # incorrectly. @@ -243,7 +243,7 @@ class ChannelPool: hasattr(cluster, "ep_attribute") and cluster.ep_attribute == "multistate_input" ): - channel_class = base.AttributeListeningChannel + channel_class = base.ZigbeeChannel # end of ugly hack channel = channel_class(cluster, self) if channel.name == const.CHANNEL_POWER_CONFIGURATION: diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index 7bb2ad7b57e..d94c01fe4cd 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -4,7 +4,6 @@ import asyncio from enum import Enum from functools import wraps import logging -from random import uniform from typing import Any, Union import zigpy.exceptions @@ -22,7 +21,6 @@ from ..const import ( ATTR_VALUE, CHANNEL_EVENT_RELAY, CHANNEL_ZDO, - REPORT_CONFIG_DEFAULT, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, REPORT_CONFIG_RPT_CHANGE, @@ -84,7 +82,7 @@ class ZigbeeChannel(LogMixin): REPORT_CONFIG = () def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType ) -> None: """Initialize ZigbeeChannel.""" self._channel_name = cluster.ep_attribute @@ -97,6 +95,12 @@ class ZigbeeChannel(LogMixin): unique_id = ch_pool.unique_id.replace("-", ":") self._unique_id = f"{unique_id}:0x{cluster.cluster_id:04x}" self._report_config = self.REPORT_CONFIG + if not hasattr(self, "_value_attribute") and len(self._report_config) > 0: + attr = self._report_config[0].get("attr") + if isinstance(attr, str): + self.value_attribute = get_attr_id_by_name(self.cluster, attr) + else: + self.value_attribute = attr self._status = ChannelStatus.CREATED self._cluster.add_listener(self) @@ -200,7 +204,6 @@ class ZigbeeChannel(LogMixin): await self.configure_reporting( report_config["attr"], report_config["config"] ) - await asyncio.sleep(uniform(0.1, 0.5)) self.debug("finished channel configuration") else: self.debug("skipping channel configuration") @@ -209,6 +212,8 @@ class ZigbeeChannel(LogMixin): async def async_initialize(self, from_cache): """Initialize channel.""" self.debug("initializing channel: from_cache: %s", from_cache) + for report_config in self._report_config: + await self.get_attribute_value(report_config["attr"], from_cache=from_cache) self._status = ChannelStatus.INITIALIZED @callback @@ -219,7 +224,12 @@ class ZigbeeChannel(LogMixin): @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" - pass + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + attrid, + self.cluster.attributes.get(attrid, [attrid])[0], + value, + ) @callback def zdo_command(self, *args, **kwargs): @@ -272,36 +282,6 @@ class ZigbeeChannel(LogMixin): return self.__getattribute__(name) -class AttributeListeningChannel(ZigbeeChannel): - """Channel for attribute reports from the cluster.""" - - REPORT_CONFIG = [{"attr": 0, "config": REPORT_CONFIG_DEFAULT}] - - def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, - ) -> None: - """Initialize AttributeListeningChannel.""" - super().__init__(cluster, ch_pool) - attr = self._report_config[0].get("attr") - if isinstance(attr, str): - self.value_attribute = get_attr_id_by_name(self.cluster, attr) - else: - self.value_attribute = attr - - @callback - def attribute_updated(self, attrid, value): - """Handle attribute updates on this cluster.""" - if attrid == self.value_attribute: - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) - - async def async_initialize(self, from_cache): - """Initialize listener.""" - await self.get_attribute_value( - self._report_config[0].get("attr"), from_cache=from_cache - ) - await super().async_initialize(from_cache) - - class ZDOChannel(LogMixin): """Channel for ZDO events.""" diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/channels/closures.py index e25c2253bb3..af6306c45e3 100644 --- a/homeassistant/components/zha/core/channels/closures.py +++ b/homeassistant/components/zha/core/channels/closures.py @@ -23,7 +23,9 @@ class DoorLockChannel(ZigbeeChannel): """Retrieve latest state.""" result = await self.get_attribute_value("lock_state", from_cache=True) - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "lock_state", result + ) @callback def attribute_updated(self, attrid, value): @@ -33,7 +35,9 @@ class DoorLockChannel(ZigbeeChannel): "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) if attrid == self._value_attribute: - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value + ) async def async_initialize(self, from_cache): """Initialize channel.""" @@ -63,8 +67,12 @@ class WindowCovering(ZigbeeChannel): "current_position_lift_percentage", from_cache=False ) self.debug("read current position: %s", result) - - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + 8, + "current_position_lift_percentage", + result, + ) @callback def attribute_updated(self, attrid, value): @@ -74,7 +82,9 @@ class WindowCovering(ZigbeeChannel): "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) if attrid == self._value_attribute: - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value + ) async def async_initialize(self, from_cache): """Initialize channel.""" diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 28bc9c7d763..aa2ddd44bf3 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -18,7 +18,7 @@ from ..const import ( SIGNAL_STATE_ATTR, ) from ..helpers import get_attr_id_by_name -from .base import AttributeListeningChannel, ZigbeeChannel, parse_and_log_command +from .base import ZigbeeChannel, parse_and_log_command _LOGGER = logging.getLogger(__name__) @@ -31,21 +31,21 @@ class Alarms(ZigbeeChannel): @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogInput.cluster_id) -class AnalogInput(AttributeListeningChannel): +class AnalogInput(ZigbeeChannel): """Analog Input channel.""" REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogOutput.cluster_id) -class AnalogOutput(AttributeListeningChannel): +class AnalogOutput(ZigbeeChannel): """Analog Output channel.""" REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.AnalogValue.cluster_id) -class AnalogValue(AttributeListeningChannel): +class AnalogValue(ZigbeeChannel): """Analog Value channel.""" REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] @@ -77,7 +77,7 @@ class BasicChannel(ZigbeeChannel): } def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType ) -> None: """Initialize BasicChannel.""" super().__init__(cluster, ch_pool) @@ -101,21 +101,21 @@ class BasicChannel(ZigbeeChannel): @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryInput.cluster_id) -class BinaryInput(AttributeListeningChannel): +class BinaryInput(ZigbeeChannel): """Binary Input channel.""" REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryOutput.cluster_id) -class BinaryOutput(AttributeListeningChannel): +class BinaryOutput(ZigbeeChannel): """Binary Output channel.""" REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.BinaryValue.cluster_id) -class BinaryValue(AttributeListeningChannel): +class BinaryValue(ZigbeeChannel): """Binary Value channel.""" REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] @@ -209,21 +209,21 @@ class LevelControlChannel(ZigbeeChannel): @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateInput.cluster_id) -class MultistateInput(AttributeListeningChannel): +class MultistateInput(ZigbeeChannel): """Multistate Input channel.""" REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateOutput.cluster_id) -class MultistateOutput(AttributeListeningChannel): +class MultistateOutput(ZigbeeChannel): """Multistate Output channel.""" REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.MultistateValue.cluster_id) -class MultistateValue(AttributeListeningChannel): +class MultistateValue(ZigbeeChannel): """Multistate Value channel.""" REPORT_CONFIG = [{"attr": "present_value", "config": REPORT_CONFIG_DEFAULT}] @@ -242,7 +242,7 @@ class OnOffChannel(ZigbeeChannel): REPORT_CONFIG = ({"attr": "on_off", "config": REPORT_CONFIG_IMMEDIATE},) def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType ) -> None: """Initialize OnOffChannel.""" super().__init__(cluster, ch_pool) @@ -286,7 +286,9 @@ class OnOffChannel(ZigbeeChannel): def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" if attrid == self.ON_OFF: - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, "on_off", value + ) self._state = bool(value) async def async_initialize(self, from_cache): @@ -354,7 +356,12 @@ class PowerConfigurationChannel(ZigbeeChannel): else: attr_id = attr if attrid == attr_id: - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + attrid, + self.cluster.attributes.get(attrid, [attrid])[0], + value, + ) return attr_name = self.cluster.attributes.get(attrid, [attrid])[0] self.async_send_signal( diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py index e47aca5eafd..d4c1a1b7422 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -10,7 +10,7 @@ from ..const import ( REPORT_CONFIG_DEFAULT, SIGNAL_ATTR_UPDATED, ) -from .base import AttributeListeningChannel, ZigbeeChannel +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -52,7 +52,7 @@ class Diagnostic(ZigbeeChannel): @registries.ZIGBEE_CHANNEL_REGISTRY.register( homeautomation.ElectricalMeasurement.cluster_id ) -class ElectricalMeasurementChannel(AttributeListeningChannel): +class ElectricalMeasurementChannel(ZigbeeChannel): """Channel that polls active power level.""" CHANNEL_NAME = CHANNEL_ELECTRICAL_MEASUREMENT @@ -60,7 +60,7 @@ class ElectricalMeasurementChannel(AttributeListeningChannel): REPORT_CONFIG = ({"attr": "active_power", "config": REPORT_CONFIG_DEFAULT},) def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType ) -> None: """Initialize Metering.""" super().__init__(cluster, ch_pool) @@ -73,7 +73,9 @@ class ElectricalMeasurementChannel(AttributeListeningChannel): # This is a polling channel. Don't allow cache. result = await self.get_attribute_value("active_power", from_cache=False) - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0x050B, "active_power", result + ) async def async_initialize(self, from_cache): """Initialize channel.""" diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py index e4519d5cb2c..6d5ce4beb29 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -40,8 +40,9 @@ class FanChannel(ZigbeeChannel): async def async_update(self): """Retrieve latest state.""" result = await self.get_attribute_value("fan_mode", from_cache=True) - - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", result) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "fan_mode", result + ) @callback def attribute_updated(self, attrid, value): @@ -51,7 +52,9 @@ class FanChannel(ZigbeeChannel): "Attribute report '%s'[%s] = %s", self.cluster.name, attr_name, value ) if attrid == self._value_attribute: - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", attrid, attr_name, value + ) async def async_initialize(self, from_cache): """Initialize channel.""" diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 2f30421822c..208bc8f8836 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -14,13 +14,13 @@ from ..const import ( SIGNAL_ATTR_UPDATED, UNKNOWN, ) -from .base import AttributeListeningChannel, ZigbeeChannel +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) @registries.ZIGBEE_CHANNEL_REGISTRY.register(registries.SMARTTHINGS_HUMIDITY_CLUSTER) -class SmartThingsHumidity(AttributeListeningChannel): +class SmartThingsHumidity(ZigbeeChannel): """Smart Things Humidity channel.""" REPORT_CONFIG = [ @@ -50,7 +50,7 @@ class OppleRemote(ZigbeeChannel): @registries.ZIGBEE_CHANNEL_REGISTRY.register( registries.SMARTTHINGS_ACCELERATION_CLUSTER ) -class SmartThingsAcceleration(AttributeListeningChannel): +class SmartThingsAcceleration(ZigbeeChannel): """Smart Things Acceleration channel.""" REPORT_CONFIG = [ @@ -64,7 +64,12 @@ class SmartThingsAcceleration(AttributeListeningChannel): def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" if attrid == self.value_attribute: - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + attrid, + self._cluster.attributes.get(attrid, [UNKNOWN])[0], + value, + ) return self.zha_send_event( diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/channels/measurement.py index 68952c64e8d..f05177de600 100644 --- a/homeassistant/components/zha/core/channels/measurement.py +++ b/homeassistant/components/zha/core/channels/measurement.py @@ -10,13 +10,13 @@ from ..const import ( REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, ) -from .base import AttributeListeningChannel +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.FlowMeasurement.cluster_id) -class FlowMeasurement(AttributeListeningChannel): +class FlowMeasurement(ZigbeeChannel): """Flow Measurement channel.""" REPORT_CONFIG = [{"attr": "measured_value", "config": REPORT_CONFIG_DEFAULT}] @@ -25,7 +25,7 @@ class FlowMeasurement(AttributeListeningChannel): @registries.ZIGBEE_CHANNEL_REGISTRY.register( measurement.IlluminanceLevelSensing.cluster_id ) -class IlluminanceLevelSensing(AttributeListeningChannel): +class IlluminanceLevelSensing(ZigbeeChannel): """Illuminance Level Sensing channel.""" REPORT_CONFIG = [{"attr": "level_status", "config": REPORT_CONFIG_DEFAULT}] @@ -34,7 +34,7 @@ class IlluminanceLevelSensing(AttributeListeningChannel): @registries.ZIGBEE_CHANNEL_REGISTRY.register( measurement.IlluminanceMeasurement.cluster_id ) -class IlluminanceMeasurement(AttributeListeningChannel): +class IlluminanceMeasurement(ZigbeeChannel): """Illuminance Measurement channel.""" REPORT_CONFIG = [{"attr": "measured_value", "config": REPORT_CONFIG_DEFAULT}] @@ -42,21 +42,21 @@ class IlluminanceMeasurement(AttributeListeningChannel): @registries.BINARY_SENSOR_CLUSTERS.register(measurement.OccupancySensing.cluster_id) @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.OccupancySensing.cluster_id) -class OccupancySensing(AttributeListeningChannel): +class OccupancySensing(ZigbeeChannel): """Occupancy Sensing channel.""" REPORT_CONFIG = [{"attr": "occupancy", "config": REPORT_CONFIG_IMMEDIATE}] @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.PressureMeasurement.cluster_id) -class PressureMeasurement(AttributeListeningChannel): +class PressureMeasurement(ZigbeeChannel): """Pressure measurement channel.""" REPORT_CONFIG = [{"attr": "measured_value", "config": REPORT_CONFIG_DEFAULT}] @registries.ZIGBEE_CHANNEL_REGISTRY.register(measurement.RelativeHumidity.cluster_id) -class RelativeHumidity(AttributeListeningChannel): +class RelativeHumidity(ZigbeeChannel): """Relative Humidity measurement channel.""" REPORT_CONFIG = [ @@ -70,7 +70,7 @@ class RelativeHumidity(AttributeListeningChannel): @registries.ZIGBEE_CHANNEL_REGISTRY.register( measurement.TemperatureMeasurement.cluster_id ) -class TemperatureMeasurement(AttributeListeningChannel): +class TemperatureMeasurement(ZigbeeChannel): """Temperature measurement channel.""" REPORT_CONFIG = [ diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index 20390c018d8..cd826792790 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -124,7 +124,9 @@ class IASZoneChannel(ZigbeeChannel): """Handle commands received to this cluster.""" if command_id == 0: state = args[0] & 3 - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", state) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 2, "zone_status", state + ) self.debug("Updated alarm state: %s", state) elif command_id == 1: self.debug("Enroll requested") @@ -165,7 +167,12 @@ class IASZoneChannel(ZigbeeChannel): """Handle attribute updates on this cluster.""" if attrid == 2: value = value & 3 - self.async_send_signal(f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value) + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + attrid, + self.cluster.attributes.get(attrid, [attrid])[0], + value, + ) async def async_initialize(self, from_cache): """Initialize channel.""" diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py index c7cad5e455d..86533662838 100644 --- a/homeassistant/components/zha/core/channels/smartenergy.py +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -8,7 +8,7 @@ from homeassistant.core import callback from .. import registries, typing as zha_typing from ..const import REPORT_CONFIG_DEFAULT -from .base import AttributeListeningChannel, ZigbeeChannel +from .base import ZigbeeChannel _LOGGER = logging.getLogger(__name__) @@ -70,7 +70,7 @@ class Messaging(ZigbeeChannel): @registries.ZIGBEE_CHANNEL_REGISTRY.register(smartenergy.Metering.cluster_id) -class Metering(AttributeListeningChannel): +class Metering(ZigbeeChannel): """Metering channel.""" REPORT_CONFIG = [{"attr": "instantaneous_demand", "config": REPORT_CONFIG_DEFAULT}] @@ -92,7 +92,7 @@ class Metering(AttributeListeningChannel): } def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType ) -> None: """Initialize Metering.""" super().__init__(cluster, ch_pool) diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index e6b844b9c43..b60357cf9f3 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -134,7 +134,7 @@ class ProbeEndpoint: continue channel_class = zha_regs.ZIGBEE_CHANNEL_REGISTRY.get( - cluster_id, base.AttributeListeningChannel + cluster_id, base.ZigbeeChannel ) channel = channel_class(cluster, ep_channels) self.probe_single_cluster(component, channel, ep_channels) diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 13de445cf37..46c97bc6b2b 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -91,10 +91,10 @@ class ZhaCover(ZhaEntity, CoverDevice): return self._current_position @callback - def async_set_position(self, pos): + def async_set_position(self, attr_id, attr_name, value): """Handle position update from channel.""" - _LOGGER.debug("setting position: %s", pos) - self._current_position = 100 - pos + _LOGGER.debug("setting position: %s", value) + self._current_position = 100 - value if self._current_position == 0: self._state = STATE_CLOSED elif self._current_position == 100: @@ -102,7 +102,7 @@ class ZhaCover(ZhaEntity, CoverDevice): self.async_schedule_update_ha_state() @callback - def async_set_state(self, state): + def async_update_state(self, state): """Handle state update from channel.""" _LOGGER.debug("state=%s", state) self._state = state @@ -112,20 +112,20 @@ class ZhaCover(ZhaEntity, CoverDevice): """Open the window cover.""" res = await self._cover_channel.up_open() if isinstance(res, list) and res[1] is Status.SUCCESS: - self.async_set_state(STATE_OPENING) + self.async_update_state(STATE_OPENING) async def async_close_cover(self, **kwargs): """Close the window cover.""" res = await self._cover_channel.down_close() if isinstance(res, list) and res[1] is Status.SUCCESS: - self.async_set_state(STATE_CLOSING) + self.async_update_state(STATE_CLOSING) async def async_set_cover_position(self, **kwargs): """Move the roller shutter to a specific position.""" new_pos = kwargs[ATTR_POSITION] res = await self._cover_channel.go_to_lift_percentage(100 - new_pos) if isinstance(res, list) and res[1] is Status.SUCCESS: - self.async_set_state( + self.async_update_state( STATE_CLOSING if new_pos < self._current_position else STATE_OPENING ) diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index 5481ec70f52..2643642c47d 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -83,8 +83,10 @@ class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): return SOURCE_TYPE_ROUTER @callback - def async_battery_percentage_remaining_updated(self, value): + def async_battery_percentage_remaining_updated(self, attr_id, attr_name, value): """Handle tracking.""" + if not attr_name == "battery_percentage_remaining": + return self.debug("battery_percentage_remaining updated: %s", value) self._connected = True self._battery_level = Battery.formatter(value) diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 76d0908000b..799e1239f61 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -111,7 +111,7 @@ class ZhaEntity(RestoreEntity, LogMixin, entity.Entity): self.async_schedule_update_ha_state() @callback - def async_set_state(self, state): + def async_set_state(self, attr_id, attr_name, value): """Set the entity state.""" pass diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 59a6bfb9c47..79b3bc62960 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -114,9 +114,9 @@ class ZhaFan(ZhaEntity, FanEntity): return self.state_attributes @callback - def async_set_state(self, state): + def async_set_state(self, attr_id, attr_name, value): """Handle state update from channel.""" - self._state = VALUE_TO_SPEED.get(state, self._state) + self._state = VALUE_TO_SPEED.get(value, self._state) self.async_schedule_update_ha_state() async def async_turn_on(self, speed: str = None, **kwargs) -> None: @@ -133,7 +133,7 @@ class ZhaFan(ZhaEntity, FanEntity): async def async_set_speed(self, speed: str) -> None: """Set the speed of the fan.""" await self._fan_channel.async_set_speed(SPEED_TO_VALUE[speed]) - self.async_set_state(speed) + self.async_set_state(0, "fan_mode", speed) async def async_update(self): """Attempt to retrieve on off state from the fan.""" diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 4264fded26b..68032001816 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -155,10 +155,10 @@ class Light(ZhaEntity, light.Light): return self._supported_features @callback - def async_set_state(self, state): + def async_set_state(self, attr_id, attr_name, value): """Set the state.""" - self._state = bool(state) - if state: + self._state = bool(value) + if value: self._off_brightness = None self.async_schedule_update_ha_state() diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 7ba31158fc3..a7f360b3424 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -103,9 +103,9 @@ class ZhaDoorLock(ZhaEntity, LockDevice): await self.async_get_state() @callback - def async_set_state(self, state): + def async_set_state(self, attr_id, attr_name, value): """Handle state update from channel.""" - self._state = VALUE_TO_STATE.get(state, self._state) + self._state = VALUE_TO_STATE.get(value, self._state) self.async_schedule_update_ha_state() async def async_get_state(self, from_cache=True): diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index e4788acfc53..01298a40fca 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -124,11 +124,11 @@ class Sensor(ZhaEntity): return self._state @callback - def async_set_state(self, state): + def async_set_state(self, attr_id, attr_name, value): """Handle state update from channel.""" - if state is not None: - state = self.formatter(state) - self._state = state + if value is not None: + value = self.formatter(value) + self._state = value self.async_schedule_update_ha_state() @callback diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index e6a82fe0270..15e10b50393 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -71,9 +71,9 @@ class Switch(ZhaEntity, SwitchDevice): self.async_schedule_update_ha_state() @callback - def async_set_state(self, state): + def async_set_state(self, attr_id, attr_name, value): """Handle state update from channel.""" - self._state = bool(state) + self._state = bool(value) self.async_schedule_update_ha_state() @property diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py index 3f38108cf89..9eac267273b 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_channels.py @@ -98,7 +98,7 @@ async def test_in_channel_config( cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id] channel_class = registries.ZIGBEE_CHANNEL_REGISTRY.get( - cluster_id, base_channels.AttributeListeningChannel + cluster_id, base_channels.ZigbeeChannel ) channel = channel_class(cluster, channel_pool) @@ -156,7 +156,7 @@ async def test_out_channel_config( cluster = zigpy_dev.endpoints[1].out_clusters[cluster_id] cluster.bind_only = True channel_class = registries.ZIGBEE_CHANNEL_REGISTRY.get( - cluster_id, base_channels.AttributeListeningChannel + cluster_id, base_channels.ZigbeeChannel ) channel = channel_class(cluster, channel_pool) From 104350265d7108f54b7ca295c5bd4c1d7419a385 Mon Sep 17 00:00:00 2001 From: HomeAssistant Azure Date: Wed, 4 Mar 2020 18:13:24 +0000 Subject: [PATCH 218/416] [ci skip] Translation update --- .../airvisual/.translations/es.json | 23 ++++++++ .../airvisual/.translations/lb.json | 21 +++++++ .../airvisual/.translations/no.json | 23 ++++++++ .../ambient_station/.translations/es.json | 3 + .../components/august/.translations/es.json | 32 +++++++++++ .../binary_sensor/.translations/zh-Hans.json | 55 +++++++++++++++++++ .../cert_expiry/.translations/en.json | 5 +- .../cert_expiry/.translations/es.json | 5 +- .../cert_expiry/.translations/no.json | 5 +- .../cert_expiry/.translations/ru.json | 5 +- .../cert_expiry/.translations/zh-Hant.json | 5 +- .../coronavirus/.translations/en.json | 3 + .../coronavirus/.translations/es.json | 16 ++++++ .../coronavirus/.translations/lb.json | 16 ++++++ .../coronavirus/.translations/no.json | 16 ++++++ .../coronavirus/.translations/ru.json | 16 ++++++ .../coronavirus/.translations/zh-Hans.json | 16 ++++++ .../coronavirus/.translations/zh-Hant.json | 16 ++++++ .../components/cover/.translations/es.json | 8 +++ .../components/deconz/.translations/es.json | 12 ++-- .../icloud/.translations/zh-Hans.json | 36 ++++++++++++ .../konnected/.translations/es.json | 4 ++ .../konnected/.translations/no.json | 2 +- .../components/light/.translations/es.json | 2 + .../components/notion/.translations/es.json | 3 + .../rainmachine/.translations/es.json | 3 + .../components/sense/.translations/es.json | 22 ++++++++ .../simplisafe/.translations/es.json | 3 + .../components/vizio/.translations/es.json | 17 ++++++ .../components/zha/.translations/en.json | 8 +++ .../components/zha/.translations/es.json | 20 +++++-- .../components/zha/.translations/lb.json | 8 +++ .../components/zha/.translations/no.json | 8 +++ .../components/zha/.translations/ru.json | 8 +++ .../components/zha/.translations/zh-Hant.json | 8 +++ 35 files changed, 435 insertions(+), 18 deletions(-) create mode 100644 homeassistant/components/airvisual/.translations/es.json create mode 100644 homeassistant/components/airvisual/.translations/lb.json create mode 100644 homeassistant/components/airvisual/.translations/no.json create mode 100644 homeassistant/components/august/.translations/es.json create mode 100644 homeassistant/components/binary_sensor/.translations/zh-Hans.json create mode 100644 homeassistant/components/coronavirus/.translations/es.json create mode 100644 homeassistant/components/coronavirus/.translations/lb.json create mode 100644 homeassistant/components/coronavirus/.translations/no.json create mode 100644 homeassistant/components/coronavirus/.translations/ru.json create mode 100644 homeassistant/components/coronavirus/.translations/zh-Hans.json create mode 100644 homeassistant/components/coronavirus/.translations/zh-Hant.json create mode 100644 homeassistant/components/icloud/.translations/zh-Hans.json create mode 100644 homeassistant/components/sense/.translations/es.json diff --git a/homeassistant/components/airvisual/.translations/es.json b/homeassistant/components/airvisual/.translations/es.json new file mode 100644 index 00000000000..3ec5c12f1e9 --- /dev/null +++ b/homeassistant/components/airvisual/.translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Esta clave API ya est\u00e1 en uso." + }, + "error": { + "invalid_api_key": "Clave API inv\u00e1lida" + }, + "step": { + "user": { + "data": { + "api_key": "Clave API", + "latitude": "Latitud", + "longitude": "Longitud", + "show_on_map": "Mostrar geograf\u00eda monitorizada en el mapa" + }, + "description": "Monitorizar la calidad del aire en una ubicaci\u00f3n geogr\u00e1fica.", + "title": "Configurar AirVisual" + } + }, + "title": "AirVisual" + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/.translations/lb.json b/homeassistant/components/airvisual/.translations/lb.json new file mode 100644 index 00000000000..0ae807dde52 --- /dev/null +++ b/homeassistant/components/airvisual/.translations/lb.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebsen App Schl\u00ebssel g\u00ebtt scho benotzt" + }, + "error": { + "invalid_api_key": "Ong\u00ebltegen API Schl\u00ebssel" + }, + "step": { + "user": { + "data": { + "api_key": "API Schl\u00ebssel", + "latitude": "Breedegrad", + "longitude": "L\u00e4ngegrad" + }, + "title": "AirVisual konfigur\u00e9ieren" + } + }, + "title": "AirVisual" + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/.translations/no.json b/homeassistant/components/airvisual/.translations/no.json new file mode 100644 index 00000000000..bf089c485d6 --- /dev/null +++ b/homeassistant/components/airvisual/.translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Denne API-n\u00f8kkelen er allerede i bruk." + }, + "error": { + "invalid_api_key": "Ugyldig API-n\u00f8kkel" + }, + "step": { + "user": { + "data": { + "api_key": "API-n\u00f8kkel", + "latitude": "Breddegrad", + "longitude": "Lengdegrad", + "show_on_map": "Vis overv\u00e5ket geografi p\u00e5 kartet" + }, + "description": "Overv\u00e5k luftkvaliteten p\u00e5 et geografisk sted.", + "title": "Konfigurer AirVisual" + } + }, + "title": "AirVisual" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/es.json b/homeassistant/components/ambient_station/.translations/es.json index d4222f1d2eb..d575db2ba71 100644 --- a/homeassistant/components/ambient_station/.translations/es.json +++ b/homeassistant/components/ambient_station/.translations/es.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Esta clave API ya est\u00e1 en uso." + }, "error": { "identifier_exists": "La clave API y/o la clave de aplicaci\u00f3n ya est\u00e1 registrada", "invalid_key": "Clave API y/o clave de aplicaci\u00f3n no v\u00e1lida", diff --git a/homeassistant/components/august/.translations/es.json b/homeassistant/components/august/.translations/es.json new file mode 100644 index 00000000000..58d94bb0cbf --- /dev/null +++ b/homeassistant/components/august/.translations/es.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "La cuenta ya est\u00e1 configurada" + }, + "error": { + "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo.", + "invalid_auth": "Autenticaci\u00f3n no v\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "login_method": "M\u00e9todo de inicio de sesi\u00f3n", + "password": "Contrase\u00f1a", + "timeout": "Tiempo de espera (segundos)", + "username": "Usuario" + }, + "description": "Si el M\u00e9todo de Inicio de Sesi\u00f3n es 'correo electr\u00f3nico', Usuario es la direcci\u00f3n de correo electr\u00f3nico. Si el M\u00e9todo de Inicio de Sesi\u00f3n es 'tel\u00e9fono', Usuario es el n\u00famero de tel\u00e9fono en formato '+NNNNNNNNN'.", + "title": "Configurar una cuenta de August" + }, + "validation": { + "data": { + "code": "C\u00f3digo de verificaci\u00f3n" + }, + "description": "Por favor, compruebe tu {login_method} ({username}) e introduce el c\u00f3digo de verificaci\u00f3n a continuaci\u00f3n", + "title": "Autenticaci\u00f3n de dos factores" + } + }, + "title": "August" + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/zh-Hans.json b/homeassistant/components/binary_sensor/.translations/zh-Hans.json new file mode 100644 index 00000000000..aeb24e5056a --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/zh-Hans.json @@ -0,0 +1,55 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} \u7535\u6c60\u7535\u91cf\u4f4e", + "is_cold": "{entity_name} \u8fc7\u51b7", + "is_connected": "{entity_name} \u5df2\u8fde\u63a5", + "is_gas": "{entity_name} \u68c0\u6d4b\u5230\u71c3\u6c14\u6cc4\u6f0f", + "is_hot": "{entity_name} \u8fc7\u70ed", + "is_light": "{entity_name} \u68c0\u6d4b\u5230\u5149\u7ebf", + "is_locked": "{entity_name} \u5df2\u9501\u5b9a", + "is_moist": "{entity_name} \u6f6e\u6e7f", + "is_motion": "{entity_name} \u68c0\u6d4b\u5230\u6709\u4eba", + "is_moving": "{entity_name} \u6b63\u5728\u79fb\u52a8", + "is_no_gas": "{entity_name} \u672a\u68c0\u6d4b\u5230\u71c3\u6c14\u6cc4\u6f0f", + "is_no_light": "{entity_name} \u672a\u68c0\u6d4b\u5230\u5149\u7ebf", + "is_no_motion": "{entity_name} \u672a\u68c0\u6d4b\u5230\u6709\u4eba", + "is_no_problem": "{entity_name} \u672a\u53d1\u73b0\u95ee\u9898", + "is_no_smoke": "{entity_name} \u672a\u68c0\u6d4b\u5230\u70df\u96fe", + "is_no_sound": "{entity_name} \u672a\u68c0\u6d4b\u5230\u58f0\u97f3", + "is_no_vibration": "{entity_name} \u672a\u68c0\u6d4b\u5230\u632f\u52a8", + "is_not_bat_low": "{entity_name} \u7535\u6c60\u7535\u91cf\u6b63\u5e38", + "is_not_cold": "{entity_name} \u4e0d\u51b7", + "is_not_connected": "{entity_name} \u5df2\u65ad\u5f00", + "is_not_hot": "{entity_name} \u4e0d\u70ed", + "is_not_locked": "{entity_name} \u5df2\u89e3\u9501", + "is_not_moist": "{entity_name} \u5e72\u71e5", + "is_not_moving": "{entity_name} \u9759\u6b62", + "is_not_open": "{entity_name} \u5df2\u5173\u95ed", + "is_not_plugged_in": "{entity_name} \u672a\u63d2\u5165", + "is_not_powered": "{entity_name} \u672a\u901a\u7535", + "is_not_present": "{entity_name} \u4e0d\u5728\u5bb6", + "is_not_unsafe": "{entity_name} \u5b89\u5168", + "is_off": "{entity_name} \u5df2\u5173\u95ed", + "is_on": "{entity_name} \u5df2\u5f00\u542f", + "is_open": "{entity_name} \u5df2\u6253\u5f00", + "is_plugged_in": "{entity_name} \u5df2\u63d2\u5165", + "is_powered": "{entity_name} \u5df2\u901a\u7535", + "is_present": "{entity_name} \u5728\u5bb6", + "is_problem": "{entity_name} \u53d1\u73b0\u95ee\u9898", + "is_smoke": "{entity_name} \u68c0\u6d4b\u5230\u70df\u96fe", + "is_sound": "{entity_name} \u68c0\u6d4b\u5230\u58f0\u97f3", + "is_unsafe": "{entity_name} \u4e0d\u5b89\u5168", + "is_vibration": "{entity_name} \u68c0\u6d4b\u5230\u632f\u52a8" + }, + "trigger_type": { + "bat_low": "{entity_name} \u7535\u6c60\u7535\u91cf\u4f4e", + "closed": "{entity_name} \u5df2\u5173\u95ed", + "cold": "{entity_name} \u53d8\u51b7", + "connected": "{entity_name} \u5df2\u8fde\u63a5", + "gas": "{entity_name} \u5f00\u59cb\u68c0\u6d4b\u5230\u71c3\u6c14\u6cc4\u6f0f", + "hot": "{entity_name} \u53d8\u70ed", + "light": "{entity_name} \u5f00\u59cb\u68c0\u6d4b\u5230\u5149\u7ebf" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/en.json b/homeassistant/components/cert_expiry/.translations/en.json index 19e237a6d05..1c1b9a882e3 100644 --- a/homeassistant/components/cert_expiry/.translations/en.json +++ b/homeassistant/components/cert_expiry/.translations/en.json @@ -1,11 +1,14 @@ { "config": { "abort": { - "host_port_exists": "This host and port combination is already configured" + "already_configured": "This host and port combination is already configured", + "host_port_exists": "This host and port combination is already configured", + "import_failed": "Import from config failed" }, "error": { "certificate_error": "Certificate could not be validated", "certificate_fetch_failed": "Can not fetch certificate from this host and port combination", + "connection_refused": "Connection refused when connecting to host", "connection_timeout": "Timeout when connecting to this host", "host_port_exists": "This host and port combination is already configured", "resolve_failed": "This host can not be resolved", diff --git a/homeassistant/components/cert_expiry/.translations/es.json b/homeassistant/components/cert_expiry/.translations/es.json index 4432edac563..628f2b22e21 100644 --- a/homeassistant/components/cert_expiry/.translations/es.json +++ b/homeassistant/components/cert_expiry/.translations/es.json @@ -1,11 +1,14 @@ { "config": { "abort": { - "host_port_exists": "Esta combinaci\u00f3n de host y puerto ya est\u00e1 configurada" + "already_configured": "Esta combinaci\u00f3n de host y puerto ya est\u00e1 configurada", + "host_port_exists": "Esta combinaci\u00f3n de host y puerto ya est\u00e1 configurada", + "import_failed": "No se pudo importar desde la configuraci\u00f3n" }, "error": { "certificate_error": "El certificado no pudo ser validado", "certificate_fetch_failed": "No se puede obtener el certificado de esta combinaci\u00f3n de host y puerto", + "connection_refused": "Conexi\u00f3n rechazada al conectarse al host", "connection_timeout": "Tiempo de espera agotado al conectar a este host", "host_port_exists": "Esta combinaci\u00f3n de host y puerto ya est\u00e1 configurada", "resolve_failed": "Este host no se puede resolver", diff --git a/homeassistant/components/cert_expiry/.translations/no.json b/homeassistant/components/cert_expiry/.translations/no.json index fc2e98b725d..e5faab74995 100644 --- a/homeassistant/components/cert_expiry/.translations/no.json +++ b/homeassistant/components/cert_expiry/.translations/no.json @@ -1,11 +1,14 @@ { "config": { "abort": { - "host_port_exists": "Denne verts- og portkombinasjonen er allerede konfigurert" + "already_configured": "Denne verts- og portkombinasjonen er allerede konfigurert", + "host_port_exists": "Denne verts- og portkombinasjonen er allerede konfigurert", + "import_failed": "Import fra config mislyktes" }, "error": { "certificate_error": "Sertifikatet kunne ikke valideres", "certificate_fetch_failed": "Kan ikke hente sertifikat fra denne verts- og portkombinasjonen", + "connection_refused": "Tilkoblingen ble nektet da den koblet til verten", "connection_timeout": "Tidsavbrudd n\u00e5r du kobler til denne verten", "host_port_exists": "Denne verts- og portkombinasjonen er allerede konfigurert", "resolve_failed": "Denne verten kan ikke l\u00f8ses", diff --git a/homeassistant/components/cert_expiry/.translations/ru.json b/homeassistant/components/cert_expiry/.translations/ru.json index 8c0f230382a..04a41704500 100644 --- a/homeassistant/components/cert_expiry/.translations/ru.json +++ b/homeassistant/components/cert_expiry/.translations/ru.json @@ -1,11 +1,14 @@ { "config": { "abort": { - "host_port_exists": "\u042d\u0442\u0430 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u0445\u043e\u0441\u0442\u0430 \u0438 \u043f\u043e\u0440\u0442\u0430 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430." + "already_configured": "\u042d\u0442\u0430 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u0445\u043e\u0441\u0442\u0430 \u0438 \u043f\u043e\u0440\u0442\u0430 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430.", + "host_port_exists": "\u042d\u0442\u0430 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u0445\u043e\u0441\u0442\u0430 \u0438 \u043f\u043e\u0440\u0442\u0430 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430.", + "import_failed": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0438\u043c\u043f\u043e\u0440\u0442\u0430 \u0438\u0437 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438." }, "error": { "certificate_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442.", "certificate_fetch_failed": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 \u0441 \u044d\u0442\u043e\u0439 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u0438 \u0445\u043e\u0441\u0442\u0430 \u0438 \u043f\u043e\u0440\u0442\u0430.", + "connection_refused": "\u041f\u0440\u0438 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043a \u0445\u043e\u0441\u0442\u0443 \u0431\u044b\u043b\u043e \u043e\u0442\u043a\u0430\u0437\u0430\u043d\u043e \u0432 \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0438.", "connection_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0445\u043e\u0441\u0442\u0443.", "host_port_exists": "\u042d\u0442\u0430 \u043a\u043e\u043c\u0431\u0438\u043d\u0430\u0446\u0438\u044f \u0445\u043e\u0441\u0442\u0430 \u0438 \u043f\u043e\u0440\u0442\u0430 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430.", "resolve_failed": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u0442\u044c \u0445\u043e\u0441\u0442.", diff --git a/homeassistant/components/cert_expiry/.translations/zh-Hant.json b/homeassistant/components/cert_expiry/.translations/zh-Hant.json index a14361376df..833e2370dde 100644 --- a/homeassistant/components/cert_expiry/.translations/zh-Hant.json +++ b/homeassistant/components/cert_expiry/.translations/zh-Hant.json @@ -1,11 +1,14 @@ { "config": { "abort": { - "host_port_exists": "\u6b64\u4e3b\u6a5f\u7aef\u8207\u901a\u8a0a\u57e0\u7d44\u5408\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + "already_configured": "\u6b64\u4e3b\u6a5f\u7aef\u8207\u901a\u8a0a\u57e0\u7d44\u5408\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "host_port_exists": "\u6b64\u4e3b\u6a5f\u7aef\u8207\u901a\u8a0a\u57e0\u7d44\u5408\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "import_failed": "\u532f\u5165\u8a2d\u5b9a\u5931\u6557" }, "error": { "certificate_error": "\u8a8d\u8b49\u7121\u6cd5\u78ba\u8a8d", "certificate_fetch_failed": "\u7121\u6cd5\u81ea\u6b64\u4e3b\u6a5f\u7aef\u8207\u901a\u8a0a\u57e0\u7d44\u5408\u7372\u5f97\u8a8d\u8b49", + "connection_refused": "\u9023\u7dda\u81f3\u4e3b\u6a5f\u6642\u906d\u62d2\u7d55", "connection_timeout": "\u9023\u7dda\u81f3\u4e3b\u6a5f\u7aef\u903e\u6642", "host_port_exists": "\u6b64\u4e3b\u6a5f\u7aef\u8207\u901a\u8a0a\u57e0\u7d44\u5408\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "resolve_failed": "\u4e3b\u6a5f\u7aef\u7121\u6cd5\u89e3\u6790", diff --git a/homeassistant/components/coronavirus/.translations/en.json b/homeassistant/components/coronavirus/.translations/en.json index ad7a3cf2cdf..b19e42cdf27 100644 --- a/homeassistant/components/coronavirus/.translations/en.json +++ b/homeassistant/components/coronavirus/.translations/en.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "This country is already configured." + }, "step": { "user": { "data": { diff --git a/homeassistant/components/coronavirus/.translations/es.json b/homeassistant/components/coronavirus/.translations/es.json new file mode 100644 index 00000000000..edc31f48761 --- /dev/null +++ b/homeassistant/components/coronavirus/.translations/es.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Este pa\u00eds ya est\u00e1 configurado." + }, + "step": { + "user": { + "data": { + "country": "Pa\u00eds" + }, + "title": "Elige un pa\u00eds para monitorizar" + } + }, + "title": "Coronavirus" + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/.translations/lb.json b/homeassistant/components/coronavirus/.translations/lb.json new file mode 100644 index 00000000000..dbd56e461bb --- /dev/null +++ b/homeassistant/components/coronavirus/.translations/lb.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "D\u00ebst Land ass scho konfigur\u00e9iert" + }, + "step": { + "user": { + "data": { + "country": "Land" + }, + "title": "Wiel ee Land aus fir z'iwwerwaachen" + } + }, + "title": "Coronavirus" + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/.translations/no.json b/homeassistant/components/coronavirus/.translations/no.json new file mode 100644 index 00000000000..ef5d75ac2a9 --- /dev/null +++ b/homeassistant/components/coronavirus/.translations/no.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "Dette landet er allerede konfigurert." + }, + "step": { + "user": { + "data": { + "country": "Land" + }, + "title": "Velg et land du vil overv\u00e5ke" + } + }, + "title": "Coronavirus" + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/.translations/ru.json b/homeassistant/components/coronavirus/.translations/ru.json new file mode 100644 index 00000000000..b8e5a069e4a --- /dev/null +++ b/homeassistant/components/coronavirus/.translations/ru.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "step": { + "user": { + "data": { + "country": "\u0421\u0442\u0440\u0430\u043d\u0430" + }, + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u0442\u0440\u0430\u043d\u0443 \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430" + } + }, + "title": "\u041a\u043e\u0440\u043e\u043d\u0430\u0432\u0438\u0440\u0443\u0441" + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/.translations/zh-Hans.json b/homeassistant/components/coronavirus/.translations/zh-Hans.json new file mode 100644 index 00000000000..f122e794424 --- /dev/null +++ b/homeassistant/components/coronavirus/.translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u56fd\u5bb6/\u5730\u533a\u5df2\u914d\u7f6e\u5b8c\u6210\u3002" + }, + "step": { + "user": { + "data": { + "country": "\u56fd\u5bb6/\u5730\u533a" + }, + "title": "\u8bf7\u9009\u62e9\u8981\u76d1\u63a7\u7684\u56fd\u5bb6/\u5730\u533a" + } + }, + "title": "\u65b0\u578b\u51a0\u72b6\u75c5\u6bd2" + } +} \ No newline at end of file diff --git a/homeassistant/components/coronavirus/.translations/zh-Hant.json b/homeassistant/components/coronavirus/.translations/zh-Hant.json new file mode 100644 index 00000000000..7286694fd9b --- /dev/null +++ b/homeassistant/components/coronavirus/.translations/zh-Hant.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "already_configured": "\u6b64\u570b\u5bb6\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002" + }, + "step": { + "user": { + "data": { + "country": "\u570b\u5bb6" + }, + "title": "\u9078\u64c7\u6240\u8981\u76e3\u8996\u7684\u570b\u5bb6" + } + }, + "title": "\u65b0\u51a0\u72c0\u75c5\u6bd2" + } +} \ No newline at end of file diff --git a/homeassistant/components/cover/.translations/es.json b/homeassistant/components/cover/.translations/es.json index 490583b54c4..04efe4964e8 100644 --- a/homeassistant/components/cover/.translations/es.json +++ b/homeassistant/components/cover/.translations/es.json @@ -1,5 +1,13 @@ { "device_automation": { + "action_type": { + "close": "Cerrar {entity_name}", + "close_tilt": "Cerrar inclinaci\u00f3n de {entity_name}", + "open": "Abrir {entity_name}", + "open_tilt": "Abrir inclinaci\u00f3n de {entity_name}", + "set_position": "Ajustar la posici\u00f3n de {entity_name}", + "set_tilt_position": "Ajustar la posici\u00f3n de inclinaci\u00f3n de {entity_name}" + }, "condition_type": { "is_closed": "{entity_name} est\u00e1 cerrado", "is_closing": "{entity_name} se est\u00e1 cerrando", diff --git a/homeassistant/components/deconz/.translations/es.json b/homeassistant/components/deconz/.translations/es.json index cfff05b1e02..047be1c7933 100644 --- a/homeassistant/components/deconz/.translations/es.json +++ b/homeassistant/components/deconz/.translations/es.json @@ -66,16 +66,16 @@ }, "trigger_type": { "remote_awakened": "Dispositivo despertado", - "remote_button_double_press": "Bot\u00f3n \"{subtype}\" pulsado dos veces consecutivas", + "remote_button_double_press": "Bot\u00f3n \"{subtype}\" doble pulsaci\u00f3n", "remote_button_long_press": "Bot\u00f3n \"{subtype}\" pulsado continuamente", - "remote_button_long_release": "Bot\u00f3n \"{subtype}\" liberado despu\u00e9s de un rato pulsado", - "remote_button_quadruple_press": "Bot\u00f3n \"{subtype}\" pulsado cuatro veces consecutivas", - "remote_button_quintuple_press": "Bot\u00f3n \"{subtype}\" pulsado cinco veces consecutivas", + "remote_button_long_release": "Bot\u00f3n \"{subtype}\" soltado despu\u00e9s de una pulsaci\u00f3n larga", + "remote_button_quadruple_press": "Bot\u00f3n \"{subtype}\" cu\u00e1druple pulsaci\u00f3n", + "remote_button_quintuple_press": "Bot\u00f3n \"{subtype}\" qu\u00edntuple pulsaci\u00f3n", "remote_button_rotated": "Bot\u00f3n \"{subtype}\" girado", "remote_button_rotation_stopped": "Bot\u00f3n rotativo \"{subtype}\" detenido", "remote_button_short_press": "Bot\u00f3n \"{subtype}\" pulsado", - "remote_button_short_release": "Bot\u00f3n \"{subtype}\" liberado", - "remote_button_triple_press": "Bot\u00f3n \"{subtype}\" pulsado cuatro veces consecutivas", + "remote_button_short_release": "Bot\u00f3n \"{subtype}\" soltado", + "remote_button_triple_press": "Bot\u00f3n \"{subtype}\" triple pulsaci\u00f3n", "remote_double_tap": "Dispositivo \" {subtype} \" doble pulsaci\u00f3n", "remote_double_tap_any_side": "Dispositivo con doble toque en cualquier lado", "remote_falling": "Dispositivo en ca\u00edda libre", diff --git a/homeassistant/components/icloud/.translations/zh-Hans.json b/homeassistant/components/icloud/.translations/zh-Hans.json new file mode 100644 index 00000000000..dd5592884be --- /dev/null +++ b/homeassistant/components/icloud/.translations/zh-Hans.json @@ -0,0 +1,36 @@ +{ + "config": { + "abort": { + "already_configured": "\u8d26\u6237\u5df2\u914d\u7f6e\u5b8c\u6210" + }, + "error": { + "login": "\u767b\u5f55\u51fa\u9519\uff1a\u8bf7\u68c0\u67e5\u60a8\u7684\u7535\u5b50\u90ae\u7bb1\u548c\u5bc6\u7801", + "send_verification_code": "\u65e0\u6cd5\u53d1\u9001\u9a8c\u8bc1\u7801", + "validate_verification_code": "\u65e0\u6cd5\u9a8c\u8bc1\u9a8c\u8bc1\u7801\uff0c\u8bf7\u9009\u62e9\u53d7\u4fe1\u4efb\u7684\u8bbe\u5907\u5e76\u91cd\u65b0\u5f00\u59cb\u9a8c\u8bc1" + }, + "step": { + "trusted_device": { + "data": { + "trusted_device": "\u53d7\u4fe1\u4efb\u7684\u8bbe\u5907" + }, + "description": "\u9009\u62e9\u53d7\u4fe1\u4efb\u7684\u8bbe\u5907", + "title": "iCloud \u53d7\u4fe1\u4efb\u7684\u8bbe\u5907" + }, + "user": { + "data": { + "password": "\u5bc6\u7801" + }, + "description": "\u8bf7\u8f93\u5165\u51ed\u636e", + "title": "iCloud \u51ed\u636e" + }, + "verification_code": { + "data": { + "verification_code": "\u9a8c\u8bc1\u7801" + }, + "description": "\u8bf7\u8f93\u5165\u60a8\u521a\u521a\u4ece iCloud \u6536\u5230\u7684\u9a8c\u8bc1\u7801", + "title": "iCloud \u9a8c\u8bc1\u7801" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/konnected/.translations/es.json b/homeassistant/components/konnected/.translations/es.json index f72a58cf649..ed65b29a3b9 100644 --- a/homeassistant/components/konnected/.translations/es.json +++ b/homeassistant/components/konnected/.translations/es.json @@ -14,6 +14,10 @@ "description": "Modelo: {model}\nHost: {host}\nPuerto: {port}\n\nPuede configurar las E/S y el comportamiento del panel en los ajustes del panel de alarmas Konnected.", "title": "Dispositivo Konnected Listo" }, + "import_confirm": { + "description": "Se ha descubierto un Panel de Alarma Konnected con ID {id} en configuration.yaml. Este flujo te permitir\u00e1 importarlo a una entrada de configuraci\u00f3n.", + "title": "Importar Dispositivo Konnected" + }, "user": { "data": { "host": "Direcci\u00f3n IP del dispositivo Konnected", diff --git a/homeassistant/components/konnected/.translations/no.json b/homeassistant/components/konnected/.translations/no.json index 9c663537c1a..72cd2911bbc 100644 --- a/homeassistant/components/konnected/.translations/no.json +++ b/homeassistant/components/konnected/.translations/no.json @@ -11,7 +11,7 @@ }, "step": { "confirm": { - "description": "Modell: {model}\nVert: {host}\nPort: {port}\n\nDu kan konfigurere IO og panel atferd i Konnected Alarm Panel innstillinger.", + "description": "Modell: {model}\nID: {id}\nVert: {host}\nPort: {port}\n\nDu kan konfigurere IO- og panelvirkem\u00e5ten i innstillingene for Konnected Alarm Panel.", "title": "Konnected Enhet klar" }, "import_confirm": { diff --git a/homeassistant/components/light/.translations/es.json b/homeassistant/components/light/.translations/es.json index 6bf91651d2e..f0996f9a523 100644 --- a/homeassistant/components/light/.translations/es.json +++ b/homeassistant/components/light/.translations/es.json @@ -1,6 +1,8 @@ { "device_automation": { "action_type": { + "brightness_decrease": "Disminuir brillo de {entity_name}", + "brightness_increase": "Aumentar brillo de {entity_name}", "toggle": "Alternar {entity_name}", "turn_off": "Apagar {entity_name}", "turn_on": "Encender {entity_name}" diff --git a/homeassistant/components/notion/.translations/es.json b/homeassistant/components/notion/.translations/es.json index ed17f83974c..08d02bd7493 100644 --- a/homeassistant/components/notion/.translations/es.json +++ b/homeassistant/components/notion/.translations/es.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Esta nombre de usuario ya est\u00e1 en uso." + }, "error": { "identifier_exists": "Nombre de usuario ya registrado", "invalid_credentials": "Usuario o contrase\u00f1a no v\u00e1lido", diff --git a/homeassistant/components/rainmachine/.translations/es.json b/homeassistant/components/rainmachine/.translations/es.json index 2cb49dc0ac1..518ff39f8bf 100644 --- a/homeassistant/components/rainmachine/.translations/es.json +++ b/homeassistant/components/rainmachine/.translations/es.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Este controlador RainMachine ya est\u00e1 configurado." + }, "error": { "identifier_exists": "Cuenta ya registrada", "invalid_credentials": "Credenciales no v\u00e1lidas" diff --git a/homeassistant/components/sense/.translations/es.json b/homeassistant/components/sense/.translations/es.json new file mode 100644 index 00000000000..07078670ace --- /dev/null +++ b/homeassistant/components/sense/.translations/es.json @@ -0,0 +1,22 @@ +{ + "config": { + "abort": { + "already_configured": "El dispositivo ya est\u00e1 configurado" + }, + "error": { + "cannot_connect": "No se ha podido conectar, por favor, int\u00e9ntalo de nuevo.", + "invalid_auth": "Autentificaci\u00f3n inv\u00e1lida", + "unknown": "Error inesperado" + }, + "step": { + "user": { + "data": { + "email": "Direcci\u00f3n de correo electr\u00f3nico", + "password": "Contrase\u00f1a" + }, + "title": "Conectar a tu Sense Energy Monitor" + } + }, + "title": "Sense" + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/es.json b/homeassistant/components/simplisafe/.translations/es.json index 802a2e6b842..815aa6be742 100644 --- a/homeassistant/components/simplisafe/.translations/es.json +++ b/homeassistant/components/simplisafe/.translations/es.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "already_configured": "Esta cuenta SimpliSafe ya est\u00e1 en uso." + }, "error": { "identifier_exists": "Cuenta ya registrada", "invalid_credentials": "Credenciales no v\u00e1lidas" diff --git a/homeassistant/components/vizio/.translations/es.json b/homeassistant/components/vizio/.translations/es.json index 408d94825f1..af3cc1750ab 100644 --- a/homeassistant/components/vizio/.translations/es.json +++ b/homeassistant/components/vizio/.translations/es.json @@ -12,11 +12,27 @@ }, "error": { "cant_connect": "No se pudo conectar al dispositivo. [Revise los documentos] (https://www.home-assistant.io/integrations/vizio/) y vuelva a verificar que:\n- El dispositivo est\u00e1 encendido\n- El dispositivo est\u00e1 conectado a la red\n- Los valores que ha rellenado son precisos\nantes de intentar volver a enviar.", + "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.", "host_exists": "El host ya est\u00e1 configurado.", "name_exists": "Nombre ya configurado.", "tv_needs_token": "Cuando el tipo de dispositivo es `tv`, se necesita un token de acceso v\u00e1lido." }, "step": { + "pair_tv": { + "data": { + "pin": "PIN" + }, + "description": "Tu TV debe estar mostrando un c\u00f3digo. Escribe ese c\u00f3digo en el formulario y contin\u00faa con el paso siguiente para completar el emparejamiento.", + "title": "Completar Proceso de Emparejamiento" + }, + "pairing_complete": { + "description": "Tu dispositivo Vizio SmartCast est\u00e1 ahora conectado a Home Assistant.", + "title": "Emparejamiento Completado" + }, + "pairing_complete_import": { + "description": "Su dispositivo Vizio SmartCast ahora est\u00e1 conectado a Home Assistant.\n\nTu Token de Acceso es '**{access_token}**'.", + "title": "Emparejamiento Completado" + }, "user": { "data": { "access_token": "Token de acceso", @@ -24,6 +40,7 @@ "host": "< Host / IP > : ", "name": "Nombre" }, + "description": "Todos los campos son obligatorios excepto el Token de Acceso. Si decides no proporcionar un Token de Acceso y tu Tipo de Dispositivo es \"tv\", se te llevar\u00e1 por un proceso de emparejamiento con tu dispositivo para que se pueda recuperar un Token de Acceso.\n\nPara pasar por el proceso de emparejamiento, antes de pulsar en Enviar, aseg\u00farese de que tu TV est\u00e9 encendida y conectada a la red. Tambi\u00e9n es necesario poder ver la pantalla.", "title": "Configurar el cliente de Vizio SmartCast" } }, diff --git a/homeassistant/components/zha/.translations/en.json b/homeassistant/components/zha/.translations/en.json index d8e8955a935..500083a7e4e 100644 --- a/homeassistant/components/zha/.translations/en.json +++ b/homeassistant/components/zha/.translations/en.json @@ -54,6 +54,14 @@ "device_shaken": "Device shaken", "device_slid": "Device slid \"{subtype}\"", "device_tilted": "Device tilted", + "remote_button_alt_double_press": "\"{subtype}\" button double clicked (Alternate mode)", + "remote_button_alt_long_press": "\"{subtype}\" button continuously pressed (Alternate mode)", + "remote_button_alt_long_release": "\"{subtype}\" button released after long press (Alternate mode)", + "remote_button_alt_quadruple_press": "\"{subtype}\" button quadruple clicked (Alternate mode)", + "remote_button_alt_quintuple_press": "\"{subtype}\" button quintuple clicked (Alternate mode)", + "remote_button_alt_short_press": "\"{subtype}\" button pressed (Alternate mode)", + "remote_button_alt_short_release": "\"{subtype}\" button released (Alternate mode)", + "remote_button_alt_triple_press": "\"{subtype}\" button triple clicked (Alternate mode)", "remote_button_double_press": "\"{subtype}\" button double clicked", "remote_button_long_press": "\"{subtype}\" button continuously pressed", "remote_button_long_release": "\"{subtype}\" button released after long press", diff --git a/homeassistant/components/zha/.translations/es.json b/homeassistant/components/zha/.translations/es.json index fb2271b260a..2bf817daf63 100644 --- a/homeassistant/components/zha/.translations/es.json +++ b/homeassistant/components/zha/.translations/es.json @@ -54,14 +54,22 @@ "device_shaken": "Dispositivo agitado", "device_slid": "Dispositivo deslizado \" {subtype} \"", "device_tilted": "Dispositivo inclinado", - "remote_button_double_press": "\"{subtype}\" bot\u00f3n de doble clic", + "remote_button_alt_double_press": "Bot\u00f3n \"{subtype}\" doble pulsaci\u00f3n (modo Alternativo)", + "remote_button_alt_long_press": "Bot\u00f3n \"{subtype}\" pulsado continuamente (modo Alternativo)", + "remote_button_alt_long_release": "Bot\u00f3n \"{subtype}\" soltado despu\u00e9s de una pulsaci\u00f3n larga (modo Alternativo)", + "remote_button_alt_quadruple_press": "Bot\u00f3n \"{subtype}\" cu\u00e1druple pulsaci\u00f3n (modo Alternativo)", + "remote_button_alt_quintuple_press": "Bot\u00f3n \"{subtype}\" qu\u00edntuple pulsaci\u00f3n (modo Alternativo)", + "remote_button_alt_short_press": "Bot\u00f3n \"{subtype}\" pulsado (modo Alternativo)", + "remote_button_alt_short_release": "Bot\u00f3n \"{subtype}\" soltado (modo Alternativo)", + "remote_button_alt_triple_press": "Bot\u00f3n \"{subtype}\" triple pulsaci\u00f3n (modo Alternativo)", + "remote_button_double_press": "Bot\u00f3n \"{subtype}\" doble pulsaci\u00f3n", "remote_button_long_press": "Bot\u00f3n \"{subtype}\" pulsado continuamente", - "remote_button_long_release": "Bot\u00f3n \"{subtype}\" liberado despu\u00e9s de una pulsaci\u00f3n prolongada", - "remote_button_quadruple_press": "\"{subtype}\" bot\u00f3n cu\u00e1druple pulsado", - "remote_button_quintuple_press": "\"{subtype}\" bot\u00f3n qu\u00edntuple pulsado", + "remote_button_long_release": "Bot\u00f3n \"{subtype}\" soltado despu\u00e9s de una pulsaci\u00f3n larga", + "remote_button_quadruple_press": "Bot\u00f3n \"{subtype}\" cu\u00e1druple pulsaci\u00f3n", + "remote_button_quintuple_press": "Bot\u00f3n \"{subtype}\" qu\u00edntuple pulsaci\u00f3n", "remote_button_short_press": "Bot\u00f3n \"{subtype}\" pulsado", - "remote_button_short_release": "Bot\u00f3n \"{subtype}\" liberado", - "remote_button_triple_press": "\"{subtype}\" bot\u00f3n de triple clic" + "remote_button_short_release": "Bot\u00f3n \"{subtype}\" soltado", + "remote_button_triple_press": "Bot\u00f3n \"{subtype}\" triple pulsaci\u00f3n" } } } \ No newline at end of file diff --git a/homeassistant/components/zha/.translations/lb.json b/homeassistant/components/zha/.translations/lb.json index a289e05e667..c4c65bf2037 100644 --- a/homeassistant/components/zha/.translations/lb.json +++ b/homeassistant/components/zha/.translations/lb.json @@ -54,6 +54,14 @@ "device_shaken": "Apparat ger\u00ebselt", "device_slid": "Apparat gerutscht \"{subtype}\"", "device_tilted": "Apparat ass gekippt", + "remote_button_alt_double_press": "\"{subtype}\" Kn\u00e4ppche zwee mol gedr\u00e9ckt (Alternative Modus)", + "remote_button_alt_long_press": "\"{subtype}\" Kn\u00e4ppche permanent gedr\u00e9ckt (Alternative Modus)", + "remote_button_alt_long_release": "\"{subtype}\" Kn\u00e4ppche no laangem unhalen lassgelooss (Alternative Modus)", + "remote_button_alt_quadruple_press": "\"{subtype}\" Kn\u00e4ppche v\u00e9ier mol gedr\u00e9ckt (Alternative Modus)", + "remote_button_alt_quintuple_press": "\"{subtype}\" Kn\u00e4ppche f\u00ebnnef mol gedr\u00e9ckt (Alternative Modus)", + "remote_button_alt_short_press": "\"{subtype}\" Kn\u00e4ppche gedr\u00e9ckt (Alternative Modus)", + "remote_button_alt_short_release": "\"{subtype}\" Kn\u00e4ppche lassgelooss (Alternative Modus)", + "remote_button_alt_triple_press": "\"{subtype}\" Kn\u00e4ppche dr\u00e4imol gedr\u00e9ckt (Alternative Modus)", "remote_button_double_press": "\"{subtype}\" Kn\u00e4ppche zwee mol gedr\u00e9ckt", "remote_button_long_press": "\"{subtype}\" Kn\u00e4ppche permanent gedr\u00e9ckt", "remote_button_long_release": "\"{subtype}\" Kn\u00e4ppche no laangem unhalen lassgelooss", diff --git a/homeassistant/components/zha/.translations/no.json b/homeassistant/components/zha/.translations/no.json index a70f5ad1c33..a08761ac4b6 100644 --- a/homeassistant/components/zha/.translations/no.json +++ b/homeassistant/components/zha/.translations/no.json @@ -54,6 +54,14 @@ "device_shaken": "Enhet er ristet", "device_slid": "Enheten skled \"{subtype}\"", "device_tilted": "Enheten skr\u00e5stilt", + "remote_button_alt_double_press": "\" {subtype} \" -knapp dobbeltklikket (alternativ modus)", + "remote_button_alt_long_press": "\" {subtype} \" -knappen trykkes kontinuerlig (alternativ modus)", + "remote_button_alt_long_release": "\" {subtype} \" -knapp sluppet etter langt trykk (Alternativ modus)", + "remote_button_alt_quadruple_press": "\"{subtype}\" knapp firedoblet klikket (alternativ modus)", + "remote_button_alt_quintuple_press": "\"{subtype}\" knapp femdobblet klikket (alternativ modus)", + "remote_button_alt_short_press": "\" {subtype} \" -knappen trykket p\u00e5 (alternativ modus)", + "remote_button_alt_short_release": "\" {subtype} \" -knapp utgitt (alternativ modus)", + "remote_button_alt_triple_press": "\" {subtype} \" -knapp tredobbeltklikket (alternativ modus)", "remote_button_double_press": "\"{subtype}\"-knappen ble dobbeltklikket", "remote_button_long_press": "\"{subtype}\"-knappen ble holdt inne", "remote_button_long_release": "\"{subtype}\"-knappen sluppet etter langt trykk", diff --git a/homeassistant/components/zha/.translations/ru.json b/homeassistant/components/zha/.translations/ru.json index 38b0aa8359c..c5f38d00d69 100644 --- a/homeassistant/components/zha/.translations/ru.json +++ b/homeassistant/components/zha/.translations/ru.json @@ -54,6 +54,14 @@ "device_shaken": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0432\u0441\u0442\u0440\u044f\u0445\u043d\u0443\u043b\u0438", "device_slid": "\u0421\u0434\u0432\u0438\u0433 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \"{subtype}\"", "device_tilted": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0430\u043a\u043b\u043e\u043d\u0438\u043b\u0438", + "remote_button_alt_double_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0434\u0432\u0430 \u0440\u0430\u0437\u0430 (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)", + "remote_button_alt_long_press": "\"{subtype}\" \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e \u043d\u0430\u0436\u0430\u0442\u0430 (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)", + "remote_button_alt_long_release": "\"{subtype}\" \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430 \u043f\u043e\u0441\u043b\u0435 \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)", + "remote_button_alt_quadruple_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0447\u0435\u0442\u044b\u0440\u0435 \u0440\u0430\u0437\u0430 (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)", + "remote_button_alt_quintuple_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u043f\u044f\u0442\u044c \u0440\u0430\u0437 (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)", + "remote_button_alt_short_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)", + "remote_button_alt_short_release": "\"{subtype}\" \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430 \u043f\u043e\u0441\u043b\u0435 \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)", + "remote_button_alt_triple_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0442\u0440\u0438 \u0440\u0430\u0437\u0430 (\u0430\u043b\u044c\u0442\u0435\u0440\u043d\u0430\u0442\u0438\u0432\u043d\u044b\u0439 \u0440\u0435\u0436\u0438\u043c)", "remote_button_double_press": "\"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0434\u0432\u0430 \u0440\u0430\u0437\u0430", "remote_button_long_press": "\"{subtype}\" \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e \u043d\u0430\u0436\u0430\u0442\u0430", "remote_button_long_release": "\"{subtype}\" \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430 \u043f\u043e\u0441\u043b\u0435 \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f", diff --git a/homeassistant/components/zha/.translations/zh-Hant.json b/homeassistant/components/zha/.translations/zh-Hant.json index d7f421c7e84..9547e7b5b7d 100644 --- a/homeassistant/components/zha/.translations/zh-Hant.json +++ b/homeassistant/components/zha/.translations/zh-Hant.json @@ -54,6 +54,14 @@ "device_shaken": "\u8a2d\u5099\u6416\u6643", "device_slid": "\u63a8\u52d5 \"{subtype}\" \u8a2d\u5099", "device_tilted": "\u8a2d\u5099\u540d\u7a31", + "remote_button_alt_double_press": "\"{subtype}\" \u6309\u9215\u96d9\u64ca\u9375\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09", + "remote_button_alt_long_press": "\"{subtype}\" \u6309\u9215\u6301\u7e8c\u6309\u4e0b\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09", + "remote_button_alt_long_release": "\"{subtype}\" \u6309\u9215\u9577\u6309\u5f8c\u91cb\u653e\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09", + "remote_button_alt_quadruple_press": "\"{subtype}\" \u6309\u9215\u56db\u9023\u64ca\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09", + "remote_button_alt_quintuple_press": "\"{subtype}\" \u6309\u9215\u4e94\u9023\u64ca\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09", + "remote_button_alt_short_press": "\"{subtype}\" \u6309\u9215\u5df2\u6309\u4e0b\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09", + "remote_button_alt_short_release": "\"{subtype}\" \u6309\u9215\u5df2\u91cb\u653e\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09", + "remote_button_alt_triple_press": "\"{subtype}\" \u6309\u9215\u4e09\u9023\u64ca\uff08\u66ff\u4ee3\u6a21\u5f0f\uff09", "remote_button_double_press": "\"{subtype}\" \u6309\u9215\u96d9\u64ca", "remote_button_long_press": "\"{subtype}\" \u6309\u9215\u6301\u7e8c\u6309\u4e0b", "remote_button_long_release": "\"{subtype}\" \u6309\u9215\u9577\u6309\u5f8c\u91cb\u653e", From e9978e77bd1ed7039bab06039351d9dae29ee225 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 4 Mar 2020 20:51:56 +0100 Subject: [PATCH 219/416] Upgrade huawei-lte-api to 1.4.10 (#32472) https://github.com/Salamek/huawei-lte-api/releases/tag/1.4.8 https://github.com/Salamek/huawei-lte-api/releases/tag/1.4.9 https://github.com/Salamek/huawei-lte-api/releases/tag/1.4.10 --- homeassistant/components/huawei_lte/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json index 8525b9eeaad..795b33485b6 100644 --- a/homeassistant/components/huawei_lte/manifest.json +++ b/homeassistant/components/huawei_lte/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/huawei_lte", "requirements": [ "getmac==0.8.1", - "huawei-lte-api==1.4.7", + "huawei-lte-api==1.4.10", "stringcase==1.2.0", "url-normalize==1.4.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index ce075e9927c..7929d30f036 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -709,7 +709,7 @@ horimote==0.4.1 httplib2==0.10.3 # homeassistant.components.huawei_lte -huawei-lte-api==1.4.7 +huawei-lte-api==1.4.10 # homeassistant.components.hydrawise hydrawiser==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1156d63968e..1b954093028 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -273,7 +273,7 @@ homematicip==0.10.17 httplib2==0.10.3 # homeassistant.components.huawei_lte -huawei-lte-api==1.4.7 +huawei-lte-api==1.4.10 # homeassistant.components.iaqualink iaqualink==0.3.1 From 0763dc60895c4e09d57567e30e6f6c9f5082adf9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 12:47:53 -0800 Subject: [PATCH 220/416] Fix filter sensor processing states that aren't numbers (#32453) * lint * only_numbers flag --- homeassistant/components/filter/sensor.py | 13 +++++++++++- tests/components/filter/test_sensor.py | 25 +++++++++++++++++++---- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 77622f62b1d..4d508ce2d81 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -364,6 +364,7 @@ class Filter: self._skip_processing = False self._window_size = window_size self._store_raw = False + self._only_numbers = True @property def window_size(self): @@ -386,7 +387,11 @@ class Filter: def filter_state(self, new_state): """Implement a common interface for filters.""" - filtered = self._filter_state(FilterState(new_state)) + fstate = FilterState(new_state) + if self._only_numbers and not isinstance(fstate.state, Number): + raise ValueError + + filtered = self._filter_state(fstate) filtered.set_precision(self.precision) if self._store_raw: self.states.append(copy(FilterState(new_state))) @@ -423,6 +428,7 @@ class RangeFilter(Filter): def _filter_state(self, new_state): """Implement the range filter.""" + if self._upper_bound is not None and new_state.state > self._upper_bound: self._stats_internal["erasures_up"] += 1 @@ -469,6 +475,7 @@ class OutlierFilter(Filter): def _filter_state(self, new_state): """Implement the outlier filter.""" + median = statistics.median([s.state for s in self.states]) if self.states else 0 if ( len(self.states) == self.states.maxlen @@ -498,6 +505,7 @@ class LowPassFilter(Filter): def _filter_state(self, new_state): """Implement the low pass filter.""" + if not self.states: return new_state @@ -539,6 +547,7 @@ class TimeSMAFilter(Filter): def _filter_state(self, new_state): """Implement the Simple Moving Average filter.""" + self._leak(new_state.timestamp) self.queue.append(copy(new_state)) @@ -565,6 +574,7 @@ class ThrottleFilter(Filter): def __init__(self, window_size, precision, entity): """Initialize Filter.""" super().__init__(FILTER_NAME_THROTTLE, window_size, precision, entity) + self._only_numbers = False def _filter_state(self, new_state): """Implement the throttle filter.""" @@ -589,6 +599,7 @@ class TimeThrottleFilter(Filter): super().__init__(FILTER_NAME_TIME_THROTTLE, window_size, precision, entity) self._time_window = window_size self._last_emitted_at = None + self._only_numbers = False def _filter_state(self, new_state): """Implement the filter.""" diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py index d46fa4eab68..06bf7cfaf12 100644 --- a/tests/components/filter/test_sensor.py +++ b/tests/components/filter/test_sensor.py @@ -104,6 +104,7 @@ class TestFilterSensor(unittest.TestCase): t_0 = dt_util.utcnow() - timedelta(minutes=1) t_1 = dt_util.utcnow() - timedelta(minutes=2) t_2 = dt_util.utcnow() - timedelta(minutes=3) + t_3 = dt_util.utcnow() - timedelta(minutes=4) if missing: fake_states = {} @@ -111,8 +112,9 @@ class TestFilterSensor(unittest.TestCase): fake_states = { "sensor.test_monitored": [ ha.State("sensor.test_monitored", 18.0, last_changed=t_0), - ha.State("sensor.test_monitored", 19.0, last_changed=t_1), - ha.State("sensor.test_monitored", 18.2, last_changed=t_2), + ha.State("sensor.test_monitored", "unknown", last_changed=t_1), + ha.State("sensor.test_monitored", 19.0, last_changed=t_2), + ha.State("sensor.test_monitored", 18.2, last_changed=t_3), ] } @@ -208,6 +210,17 @@ class TestFilterSensor(unittest.TestCase): filtered = filt.filter_state(state) assert 21 == filtered.state + def test_unknown_state_outlier(self): + """Test issue #32395.""" + filt = OutlierFilter(window_size=3, precision=2, entity=None, radius=4.0) + out = ha.State("sensor.test_monitored", "unknown") + for state in [out] + self.values + [out]: + try: + filtered = filt.filter_state(state) + except ValueError: + assert state.state == "unknown" + assert 21 == filtered.state + def test_precision_zero(self): """Test if precision of zero returns an integer.""" filt = LowPassFilter(window_size=10, precision=0, entity=None, time_constant=10) @@ -218,8 +231,12 @@ class TestFilterSensor(unittest.TestCase): def test_lowpass(self): """Test if lowpass filter works.""" filt = LowPassFilter(window_size=10, precision=2, entity=None, time_constant=10) - for state in self.values: - filtered = filt.filter_state(state) + out = ha.State("sensor.test_monitored", "unknown") + for state in [out] + self.values + [out]: + try: + filtered = filt.filter_state(state) + except ValueError: + assert state.state == "unknown" assert 18.05 == filtered.state def test_range(self): From 9aaab4198504dfd74ea529f2342db8b045058c6d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 4 Mar 2020 21:58:07 +0100 Subject: [PATCH 221/416] Deprecate camera WS API (#32473) --- homeassistant/components/camera/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 647e54556c4..45cfe96e11b 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -535,6 +535,7 @@ async def websocket_camera_thumbnail(hass, connection, msg): Async friendly. """ + _LOGGER.warning("The websocket command 'camera_thumbnail' has been deprecated.") try: image = await async_get_image(hass, msg["entity_id"]) await connection.send_big_result( From 0c0d4c460f66917d9bf77913f09d98120b9978df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Wed, 4 Mar 2020 22:48:00 +0100 Subject: [PATCH 222/416] Upgrade Tibber library to 0.13.2 (#32478) --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 2f325f7fe9b..4186e0781fe 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -2,7 +2,7 @@ "domain": "tibber", "name": "Tibber", "documentation": "https://www.home-assistant.io/integrations/tibber", - "requirements": ["pyTibber==0.13.0"], + "requirements": ["pyTibber==0.13.2"], "dependencies": [], "codeowners": ["@danielhiversen"], "quality_scale": "silver" diff --git a/requirements_all.txt b/requirements_all.txt index 7929d30f036..0a107eb34b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1116,7 +1116,7 @@ pyRFXtrx==0.25 # pySwitchmate==0.4.6 # homeassistant.components.tibber -pyTibber==0.13.0 +pyTibber==0.13.2 # homeassistant.components.dlink pyW215==0.6.0 From c7d983fd44a22a4f30b822866883811c0305c46b Mon Sep 17 00:00:00 2001 From: springstan <46536646+springstan@users.noreply.github.com> Date: Wed, 4 Mar 2020 23:09:54 +0100 Subject: [PATCH 223/416] Catch an extra error for Ring (#32477) --- homeassistant/components/ring/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py index 0d54db5993f..7f097d48a5f 100644 --- a/homeassistant/components/ring/__init__.py +++ b/homeassistant/components/ring/__init__.py @@ -205,6 +205,11 @@ class GlobalDataUpdater: "Time out fetching Ring %s data", self.data_type, ) return + except requests.RequestException as err: + _LOGGER.warning( + "Error fetching Ring %s data: %s", self.data_type, err, + ) + return for update_callback in self.listeners: update_callback() @@ -290,6 +295,14 @@ class DeviceDataUpdater: device_id, ) continue + except requests.RequestException as err: + _LOGGER.warning( + "Error fetching Ring %s data for device %s: %s", + self.data_type, + device_id, + err, + ) + continue for update_callback in info["update_callbacks"]: self.hass.loop.call_soon_threadsafe(update_callback, data) From 0e436ac80e2fc13e1f1d049b5bbb20b3cd16c540 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 14:24:59 -0800 Subject: [PATCH 224/416] add const file for roku (#32470) --- homeassistant/components/roku/const.py | 2 ++ homeassistant/components/roku/media_player.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/roku/const.py diff --git a/homeassistant/components/roku/const.py b/homeassistant/components/roku/const.py new file mode 100644 index 00000000000..54c52de2622 --- /dev/null +++ b/homeassistant/components/roku/const.py @@ -0,0 +1,2 @@ +"""Constants for the Roku integration.""" +DEFAULT_PORT = 8060 diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index ce275aff92c..cc6a6056665 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -24,7 +24,7 @@ from homeassistant.const import ( STATE_STANDBY, ) -DEFAULT_PORT = 8060 +from .const import DEFAULT_PORT _LOGGER = logging.getLogger(__name__) From dd7d8d4792f81b5c26d72bebd85babd825a4a72d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 14:42:50 -0800 Subject: [PATCH 225/416] Remove 'show all controls' option for Plex (#32391) --- homeassistant/components/plex/__init__.py | 15 +++--- homeassistant/components/plex/config_flow.py | 8 ---- homeassistant/components/plex/media_player.py | 47 ------------------- homeassistant/components/plex/server.py | 6 --- homeassistant/components/plex/strings.json | 1 - tests/components/plex/test_config_flow.py | 7 --- 6 files changed, 9 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index c9b120f75f6..101704013c1 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -48,12 +48,15 @@ from .const import ( ) from .server import PlexServer -MEDIA_PLAYER_SCHEMA = vol.Schema( - { - vol.Optional(CONF_USE_EPISODE_ART, default=False): cv.boolean, - vol.Optional(CONF_SHOW_ALL_CONTROLS, default=False): cv.boolean, - vol.Optional(CONF_IGNORE_NEW_SHARED_USERS, default=False): cv.boolean, - } +MEDIA_PLAYER_SCHEMA = vol.All( + cv.deprecated(CONF_SHOW_ALL_CONTROLS, invalidation_version="0.110"), + vol.Schema( + { + vol.Optional(CONF_USE_EPISODE_ART, default=False): cv.boolean, + vol.Optional(CONF_SHOW_ALL_CONTROLS): cv.boolean, + vol.Optional(CONF_IGNORE_NEW_SHARED_USERS, default=False): cv.boolean, + } + ), ) SERVER_CONFIG_SCHEMA = vol.Schema( diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 19cec6dfb8b..6e4dce3e914 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -25,7 +25,6 @@ from .const import ( # pylint: disable=unused-import CONF_MONITORED_USERS, CONF_SERVER, CONF_SERVER_IDENTIFIER, - CONF_SHOW_ALL_CONTROLS, CONF_USE_EPISODE_ART, DEFAULT_VERIFY_SSL, DOMAIN, @@ -272,9 +271,6 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow): self.options[MP_DOMAIN][CONF_USE_EPISODE_ART] = user_input[ CONF_USE_EPISODE_ART ] - self.options[MP_DOMAIN][CONF_SHOW_ALL_CONTROLS] = user_input[ - CONF_SHOW_ALL_CONTROLS - ] self.options[MP_DOMAIN][CONF_IGNORE_NEW_SHARED_USERS] = user_input[ CONF_IGNORE_NEW_SHARED_USERS ] @@ -315,10 +311,6 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow): CONF_USE_EPISODE_ART, default=plex_server.option_use_episode_art, ): bool, - vol.Required( - CONF_SHOW_ALL_CONTROLS, - default=plex_server.option_show_all_controls, - ): bool, vol.Optional( CONF_MONITORED_USERS, default=default_accounts ): cv.multi_select(available_accounts), diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 47e5ba6104f..0599837aa80 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -17,7 +17,6 @@ from homeassistant.components.media_player.const import ( SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, - SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, ) @@ -481,46 +480,6 @@ class PlexMediaPlayer(MediaPlayerDevice): @property def supported_features(self): """Flag media player features that are supported.""" - # force show all controls - if self.plex_server.option_show_all_controls: - return ( - SUPPORT_PAUSE - | SUPPORT_PREVIOUS_TRACK - | SUPPORT_NEXT_TRACK - | SUPPORT_STOP - | SUPPORT_VOLUME_SET - | SUPPORT_PLAY - | SUPPORT_PLAY_MEDIA - | SUPPORT_TURN_OFF - | SUPPORT_VOLUME_MUTE - ) - - # no mute support - if self.make.lower() == "shield android tv": - _LOGGER.debug( - "Shield Android TV client detected, disabling mute controls: %s", - self.name, - ) - return ( - SUPPORT_PAUSE - | SUPPORT_PREVIOUS_TRACK - | SUPPORT_NEXT_TRACK - | SUPPORT_STOP - | SUPPORT_VOLUME_SET - | SUPPORT_PLAY - | SUPPORT_PLAY_MEDIA - | SUPPORT_TURN_OFF - ) - - # Only supports play,pause,stop (and off which really is stop) - if self.make.lower().startswith("tivo"): - _LOGGER.debug( - "Tivo client detected, only enabling pause, play, " - "stop, and off controls: %s", - self.name, - ) - return SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_STOP | SUPPORT_TURN_OFF - if self.device and "playback" in self._device_protocol_capabilities: return ( SUPPORT_PAUSE @@ -530,7 +489,6 @@ class PlexMediaPlayer(MediaPlayerDevice): | SUPPORT_VOLUME_SET | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA - | SUPPORT_TURN_OFF | SUPPORT_VOLUME_MUTE ) @@ -594,11 +552,6 @@ class PlexMediaPlayer(MediaPlayerDevice): self.device.stop(self._active_media_plexapi_type) self.plex_server.update_platforms() - def turn_off(self): - """Turn the client off.""" - # Fake it since we can't turn the client off - self.media_stop() - def media_next_track(self): """Send next track command.""" if self.device and "playback" in self._device_protocol_capabilities: diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py index 5532362b87a..54a248309b6 100644 --- a/homeassistant/components/plex/server.py +++ b/homeassistant/components/plex/server.py @@ -16,7 +16,6 @@ from .const import ( CONF_IGNORE_NEW_SHARED_USERS, CONF_MONITORED_USERS, CONF_SERVER, - CONF_SHOW_ALL_CONTROLS, CONF_USE_EPISODE_ART, DEFAULT_VERIFY_SSL, PLEX_NEW_MP_SIGNAL, @@ -264,11 +263,6 @@ class PlexServer: """Return use_episode_art option.""" return self.options[MP_DOMAIN][CONF_USE_EPISODE_ART] - @property - def option_show_all_controls(self): - """Return show_all_controls option.""" - return self.options[MP_DOMAIN][CONF_SHOW_ALL_CONTROLS] - @property def option_monitored_users(self): """Return dict of monitored users option.""" diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index 1f99e28df8b..cf91b8b6fb7 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -36,7 +36,6 @@ "description": "Options for Plex Media Players", "data": { "use_episode_art": "Use episode art", - "show_all_controls": "Show all controls", "ignore_new_shared_users": "Ignore new managed/shared users", "monitored_users": "Monitored users" } diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index b331444123a..4e7af44c5a4 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -26,7 +26,6 @@ MOCK_FILE_CONTENTS = { DEFAULT_OPTIONS = { config_flow.MP_DOMAIN: { config_flow.CONF_USE_EPISODE_ART: False, - config_flow.CONF_SHOW_ALL_CONTROLS: False, config_flow.CONF_IGNORE_NEW_SHARED_USERS: False, } } @@ -485,7 +484,6 @@ async def test_option_flow(hass): result["flow_id"], user_input={ config_flow.CONF_USE_EPISODE_ART: True, - config_flow.CONF_SHOW_ALL_CONTROLS: True, config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts), }, @@ -494,7 +492,6 @@ async def test_option_flow(hass): assert result["data"] == { config_flow.MP_DOMAIN: { config_flow.CONF_USE_EPISODE_ART: True, - config_flow.CONF_SHOW_ALL_CONTROLS: True, config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, config_flow.CONF_MONITORED_USERS: { user: {"enabled": True} for user in mock_plex_server.accounts @@ -530,7 +527,6 @@ async def test_option_flow_loading_saved_users(hass): result["flow_id"], user_input={ config_flow.CONF_USE_EPISODE_ART: True, - config_flow.CONF_SHOW_ALL_CONTROLS: True, config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts), }, @@ -539,7 +535,6 @@ async def test_option_flow_loading_saved_users(hass): assert result["data"] == { config_flow.MP_DOMAIN: { config_flow.CONF_USE_EPISODE_ART: True, - config_flow.CONF_SHOW_ALL_CONTROLS: True, config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, config_flow.CONF_MONITORED_USERS: { user: {"enabled": True} for user in mock_plex_server.accounts @@ -580,7 +575,6 @@ async def test_option_flow_new_users_available(hass): result["flow_id"], user_input={ config_flow.CONF_USE_EPISODE_ART: True, - config_flow.CONF_SHOW_ALL_CONTROLS: True, config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts), }, @@ -589,7 +583,6 @@ async def test_option_flow_new_users_available(hass): assert result["data"] == { config_flow.MP_DOMAIN: { config_flow.CONF_USE_EPISODE_ART: True, - config_flow.CONF_SHOW_ALL_CONTROLS: True, config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, config_flow.CONF_MONITORED_USERS: { user: {"enabled": True} for user in mock_plex_server.accounts From 2abdfc9da676b7def6fde004347dc3bd0465181c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 14:52:16 -0800 Subject: [PATCH 226/416] GeoNet NZ Quakes code improvements (#32338) * code quality improvements * code quality improvements and fixed tests * explicitly set unique ids * improve unique id creation * remove entities from entity registry * added test for removing entities from entity registry * revert entity registry handling from sensor and test code * check for entity registry removal in geolocation test case * make import absolute; isort * change quality scale --- .../components/geonetnz_quakes/__init__.py | 29 +++---- .../components/geonetnz_quakes/config_flow.py | 46 ++++------- .../geonetnz_quakes/geo_location.py | 26 +++++-- .../components/geonetnz_quakes/manifest.json | 5 +- .../components/geonetnz_quakes/sensor.py | 22 ++++-- .../components/geonetnz_quakes/strings.json | 4 +- tests/components/geonetnz_quakes/conftest.py | 36 +++++++++ .../geonetnz_quakes/test_config_flow.py | 78 ++++--------------- .../geonetnz_quakes/test_geo_location.py | 28 ++++--- tests/components/geonetnz_quakes/test_init.py | 21 +++++ .../components/geonetnz_quakes/test_sensor.py | 4 +- 11 files changed, 152 insertions(+), 147 deletions(-) create mode 100644 tests/components/geonetnz_quakes/conftest.py create mode 100644 tests/components/geonetnz_quakes/test_init.py diff --git a/homeassistant/components/geonetnz_quakes/__init__.py b/homeassistant/components/geonetnz_quakes/__init__.py index fae8841bee3..9395c9dbe66 100644 --- a/homeassistant/components/geonetnz_quakes/__init__.py +++ b/homeassistant/components/geonetnz_quakes/__init__.py @@ -12,7 +12,6 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_RADIUS, CONF_SCAN_INTERVAL, - CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL, LENGTH_MILES, ) @@ -22,7 +21,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.unit_system import METRIC_SYSTEM -from .config_flow import configured_instances from .const import ( CONF_MINIMUM_MAGNITUDE, CONF_MMI, @@ -42,8 +40,8 @@ CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, vol.Optional(CONF_MMI, default=DEFAULT_MMI): vol.All( vol.Coerce(int), vol.Range(min=-1, max=8) ), @@ -67,16 +65,11 @@ async def async_setup(hass, config): return True conf = config[DOMAIN] - latitude = conf.get(CONF_LATITUDE, hass.config.latitude) longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) mmi = conf[CONF_MMI] scan_interval = conf[CONF_SCAN_INTERVAL] - identifier = f"{latitude}, {longitude}" - if identifier in configured_instances(hass): - return True - hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, @@ -97,18 +90,15 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry): """Set up the GeoNet NZ Quakes component as config entry.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} - if FEED not in hass.data[DOMAIN]: - hass.data[DOMAIN][FEED] = {} + hass.data.setdefault(DOMAIN, {}) + feeds = hass.data[DOMAIN].setdefault(FEED, {}) radius = config_entry.data[CONF_RADIUS] - unit_system = config_entry.data[CONF_UNIT_SYSTEM] - if unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: radius = METRIC_SYSTEM.length(radius, LENGTH_MILES) # Create feed entity manager for all platforms. - manager = GeonetnzQuakesFeedEntityManager(hass, config_entry, radius, unit_system) - hass.data[DOMAIN][FEED][config_entry.entry_id] = manager + manager = GeonetnzQuakesFeedEntityManager(hass, config_entry, radius) + feeds[config_entry.entry_id] = manager _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) await manager.async_init() return True @@ -130,7 +120,7 @@ async def async_unload_entry(hass, config_entry): class GeonetnzQuakesFeedEntityManager: """Feed Entity Manager for GeoNet NZ Quakes feed.""" - def __init__(self, hass, config_entry, radius_in_km, unit_system): + def __init__(self, hass, config_entry, radius_in_km): """Initialize the Feed Entity Manager.""" self._hass = hass self._config_entry = config_entry @@ -153,7 +143,6 @@ class GeonetnzQuakesFeedEntityManager: ) self._config_entry_id = config_entry.entry_id self._scan_interval = timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL]) - self._unit_system = unit_system self._track_time_remove_callback = None self._status_info = None self.listeners = [] @@ -212,8 +201,8 @@ class GeonetnzQuakesFeedEntityManager: self._hass, self.async_event_new_entity(), self, + self._config_entry.unique_id, external_id, - self._unit_system, ) async def _update_entity(self, external_id): diff --git a/homeassistant/components/geonetnz_quakes/config_flow.py b/homeassistant/components/geonetnz_quakes/config_flow.py index cc40f31f1fb..f3f5829f465 100644 --- a/homeassistant/components/geonetnz_quakes/config_flow.py +++ b/homeassistant/components/geonetnz_quakes/config_flow.py @@ -9,14 +9,10 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_RADIUS, CONF_SCAN_INTERVAL, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, ) -from homeassistant.core import callback from homeassistant.helpers import config_validation as cv -from .const import ( +from .const import ( # pylint: disable=unused-import CONF_MINIMUM_MAGNITUDE, CONF_MMI, DEFAULT_MINIMUM_MAGNITUDE, @@ -26,37 +22,27 @@ from .const import ( DOMAIN, ) +DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_MMI, default=DEFAULT_MMI): vol.All( + vol.Coerce(int), vol.Range(min=-1, max=8) + ), + vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): cv.positive_int, + } +) + _LOGGER = logging.getLogger(__name__) -@callback -def configured_instances(hass): - """Return a set of configured GeoNet NZ Quakes instances.""" - return set( - f"{entry.data[CONF_LATITUDE]}, {entry.data[CONF_LONGITUDE]}" - for entry in hass.config_entries.async_entries(DOMAIN) - ) - - -@config_entries.HANDLERS.register(DOMAIN) -class GeonetnzQuakesFlowHandler(config_entries.ConfigFlow): +class GeonetnzQuakesFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a GeoNet NZ Quakes config flow.""" CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL async def _show_form(self, errors=None): """Show the form to the user.""" - data_schema = vol.Schema( - { - vol.Optional(CONF_MMI, default=DEFAULT_MMI): vol.All( - vol.Coerce(int), vol.Range(min=-1, max=8) - ), - vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): cv.positive_int, - } - ) - return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors or {} + step_id="user", data_schema=DATA_SCHEMA, errors=errors or {} ) async def async_step_import(self, import_config): @@ -75,13 +61,9 @@ class GeonetnzQuakesFlowHandler(config_entries.ConfigFlow): user_input[CONF_LONGITUDE] = longitude identifier = f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}" - if identifier in configured_instances(self.hass): - return await self._show_form({"base": "identifier_exists"}) - if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: - user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_IMPERIAL - else: - user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_METRIC + await self.async_set_unique_id(identifier) + self._abort_if_unique_id_configured() scan_interval = user_input.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds diff --git a/homeassistant/components/geonetnz_quakes/geo_location.py b/homeassistant/components/geonetnz_quakes/geo_location.py index d7fd91d3d5b..7d29f5ed3ec 100644 --- a/homeassistant/components/geonetnz_quakes/geo_location.py +++ b/homeassistant/components/geonetnz_quakes/geo_location.py @@ -12,6 +12,7 @@ from homeassistant.const import ( ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.util.unit_system import IMPERIAL_SYSTEM from .const import DOMAIN, FEED @@ -26,6 +27,9 @@ ATTR_MMI = "mmi" ATTR_PUBLICATION_DATE = "publication_date" ATTR_QUALITY = "quality" +# An update of this entity is not making a web request, but uses internal data only. +PARALLEL_UPDATES = 0 + SOURCE = "geonetnz_quakes" @@ -34,9 +38,9 @@ async def async_setup_entry(hass, entry, async_add_entities): manager = hass.data[DOMAIN][FEED][entry.entry_id] @callback - def async_add_geolocation(feed_manager, external_id, unit_system): + def async_add_geolocation(feed_manager, integration_id, external_id): """Add gelocation entity from feed.""" - new_entity = GeonetnzQuakesEvent(feed_manager, external_id, unit_system) + new_entity = GeonetnzQuakesEvent(feed_manager, integration_id, external_id) _LOGGER.debug("Adding geolocation %s", new_entity) async_add_entities([new_entity], True) @@ -45,6 +49,8 @@ async def async_setup_entry(hass, entry, async_add_entities): hass, manager.async_event_new_entity(), async_add_geolocation ) ) + # Do not wait for update here so that the setup can be completed and because an + # update will fetch data from the feed via HTTP and then process that data. hass.async_create_task(manager.async_update()) _LOGGER.debug("Geolocation setup done") @@ -52,11 +58,11 @@ async def async_setup_entry(hass, entry, async_add_entities): class GeonetnzQuakesEvent(GeolocationEvent): """This represents an external event with GeoNet NZ Quakes feed data.""" - def __init__(self, feed_manager, external_id, unit_system): + def __init__(self, feed_manager, integration_id, external_id): """Initialize entity with data from feed entry.""" self._feed_manager = feed_manager + self._integration_id = integration_id self._external_id = external_id - self._unit_system = unit_system self._title = None self._distance = None self._latitude = None @@ -88,6 +94,9 @@ class GeonetnzQuakesEvent(GeolocationEvent): """Call when entity will be removed from hass.""" self._remove_signal_delete() self._remove_signal_update() + # Remove from entity registry. + entity_registry = await async_get_registry(self.hass) + entity_registry.async_remove(self.entity_id) @callback def _delete_callback(self): @@ -115,7 +124,7 @@ class GeonetnzQuakesEvent(GeolocationEvent): """Update the internal state from the provided feed entry.""" self._title = feed_entry.title # Convert distance if not metric system. - if self._unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: self._distance = IMPERIAL_SYSTEM.length( feed_entry.distance_to_home, LENGTH_KILOMETERS ) @@ -131,6 +140,11 @@ class GeonetnzQuakesEvent(GeolocationEvent): self._quality = feed_entry.quality self._time = feed_entry.time + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID containing latitude/longitude and external id.""" + return f"{self._integration_id}_{self._external_id}" + @property def icon(self): """Return the icon to use in the frontend, if any.""" @@ -164,7 +178,7 @@ class GeonetnzQuakesEvent(GeolocationEvent): @property def unit_of_measurement(self): """Return the unit of measurement.""" - if self._unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: return LENGTH_MILES return LENGTH_KILOMETERS diff --git a/homeassistant/components/geonetnz_quakes/manifest.json b/homeassistant/components/geonetnz_quakes/manifest.json index 50813b062f0..613af313393 100644 --- a/homeassistant/components/geonetnz_quakes/manifest.json +++ b/homeassistant/components/geonetnz_quakes/manifest.json @@ -5,5 +5,6 @@ "documentation": "https://www.home-assistant.io/integrations/geonetnz_quakes", "requirements": ["aio_geojson_geonetnz_quakes==0.12"], "dependencies": [], - "codeowners": ["@exxamalte"] -} + "codeowners": ["@exxamalte"], + "quality_scale": "platinum" +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index f5360c76c45..1cb2d0dc091 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -22,11 +22,14 @@ ATTR_REMOVED = "removed" DEFAULT_ICON = "mdi:pulse" DEFAULT_UNIT_OF_MEASUREMENT = "quakes" +# An update of this entity is not making a web request, but uses internal data only. +PARALLEL_UPDATES = 0 + async def async_setup_entry(hass, entry, async_add_entities): """Set up the GeoNet NZ Quakes Feed platform.""" manager = hass.data[DOMAIN][FEED][entry.entry_id] - sensor = GeonetnzQuakesSensor(entry.entry_id, entry.title, manager) + sensor = GeonetnzQuakesSensor(entry.entry_id, entry.unique_id, entry.title, manager) async_add_entities([sensor]) _LOGGER.debug("Sensor setup done") @@ -34,9 +37,10 @@ async def async_setup_entry(hass, entry, async_add_entities): class GeonetnzQuakesSensor(Entity): """This is a status sensor for the GeoNet NZ Quakes integration.""" - def __init__(self, config_entry_id, config_title, manager): + def __init__(self, config_entry_id, config_unique_id, config_title, manager): """Initialize entity.""" self._config_entry_id = config_entry_id + self._config_unique_id = config_unique_id self._config_title = config_title self._manager = manager self._status = None @@ -90,11 +94,10 @@ class GeonetnzQuakesSensor(Entity): self._last_update = ( dt.as_utc(status_info.last_update) if status_info.last_update else None ) - self._last_update_successful = ( - dt.as_utc(status_info.last_update_successful) - if status_info.last_update_successful - else None - ) + if status_info.last_update_successful: + self._last_update_successful = dt.as_utc(status_info.last_update_successful) + else: + self._last_update_successful = None self._last_timestamp = status_info.last_timestamp self._total = status_info.total self._created = status_info.created @@ -106,6 +109,11 @@ class GeonetnzQuakesSensor(Entity): """Return the state of the sensor.""" return self._total + @property + def unique_id(self) -> str: + """Return a unique ID containing latitude/longitude.""" + return self._config_unique_id + @property def name(self) -> Optional[str]: """Return the name of the entity.""" diff --git a/homeassistant/components/geonetnz_quakes/strings.json b/homeassistant/components/geonetnz_quakes/strings.json index 6ec915eb68d..9c5ea291897 100644 --- a/homeassistant/components/geonetnz_quakes/strings.json +++ b/homeassistant/components/geonetnz_quakes/strings.json @@ -10,8 +10,8 @@ } } }, - "error": { - "identifier_exists": "Location already registered" + "abort": { + "already_configured": "Location is already configured." } } } diff --git a/tests/components/geonetnz_quakes/conftest.py b/tests/components/geonetnz_quakes/conftest.py new file mode 100644 index 00000000000..7715b17796b --- /dev/null +++ b/tests/components/geonetnz_quakes/conftest.py @@ -0,0 +1,36 @@ +"""Configuration for GeoNet NZ Quakes tests.""" +import pytest + +from homeassistant.components.geonetnz_quakes import ( + CONF_MINIMUM_MAGNITUDE, + CONF_MMI, + DOMAIN, +) +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, + CONF_UNIT_SYSTEM, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def config_entry(): + """Create a mock GeoNet NZ Quakes config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25, + CONF_UNIT_SYSTEM: "metric", + CONF_SCAN_INTERVAL: 300.0, + CONF_MMI: 4, + CONF_MINIMUM_MAGNITUDE: 0.0, + }, + title="-41.2, 174.7", + unique_id="-41.2, 174.7", + ) diff --git a/tests/components/geonetnz_quakes/test_config_flow.py b/tests/components/geonetnz_quakes/test_config_flow.py index 494ceaa542d..051873f5360 100644 --- a/tests/components/geonetnz_quakes/test_config_flow.py +++ b/tests/components/geonetnz_quakes/test_config_flow.py @@ -1,18 +1,11 @@ """Define tests for the GeoNet NZ Quakes config flow.""" from datetime import timedelta -from asynctest import CoroutineMock, patch -import pytest - from homeassistant import data_entry_flow from homeassistant.components.geonetnz_quakes import ( CONF_MINIMUM_MAGNITUDE, CONF_MMI, DOMAIN, - FEED, - async_setup_entry, - async_unload_entry, - config_flow, ) from homeassistant.const import ( CONF_LATITUDE, @@ -22,46 +15,24 @@ from homeassistant.const import ( CONF_UNIT_SYSTEM, ) -from tests.common import MockConfigEntry - - -@pytest.fixture -def config_entry(): - """Create a mock GeoNet NZ Quakes config entry.""" - return MockConfigEntry( - domain=DOMAIN, - data={ - CONF_LATITUDE: -41.2, - CONF_LONGITUDE: 174.7, - CONF_RADIUS: 25, - CONF_UNIT_SYSTEM: "metric", - CONF_SCAN_INTERVAL: 300.0, - CONF_MMI: 4, - CONF_MINIMUM_MAGNITUDE: 0.0, - }, - title="-41.2, 174.7", - ) - async def test_duplicate_error(hass, config_entry): """Test that errors are shown when duplicates are added.""" conf = {CONF_LATITUDE: -41.2, CONF_LONGITUDE: 174.7, CONF_RADIUS: 25} - config_entry.add_to_hass(hass) - flow = config_flow.GeonetnzQuakesFlowHandler() - flow.hass = hass - result = await flow.async_step_user(user_input=conf) - assert result["errors"] == {"base": "identifier_exists"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=conf + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" async def test_show_form(hass): """Test that the form is served with no input.""" - flow = config_flow.GeonetnzQuakesFlowHandler() - flow.hass = hass - - result = await flow.async_step_user(user_input=None) - + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" @@ -78,10 +49,9 @@ async def test_step_import(hass): CONF_MINIMUM_MAGNITUDE: 2.5, } - flow = config_flow.GeonetnzQuakesFlowHandler() - flow.hass = hass - - result = await flow.async_step_import(import_config=conf) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "import"}, data=conf + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "-41.2, 174.7" assert result["data"] == { @@ -101,10 +71,9 @@ async def test_step_user(hass): hass.config.longitude = 174.7 conf = {CONF_RADIUS: 25, CONF_MMI: 4} - flow = config_flow.GeonetnzQuakesFlowHandler() - flow.hass = hass - - result = await flow.async_step_user(user_input=conf) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=conf + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "-41.2, 174.7" assert result["data"] == { @@ -112,25 +81,6 @@ async def test_step_user(hass): CONF_LONGITUDE: 174.7, CONF_RADIUS: 25, CONF_MMI: 4, - CONF_UNIT_SYSTEM: "metric", CONF_SCAN_INTERVAL: 300.0, CONF_MINIMUM_MAGNITUDE: 0.0, } - - -async def test_component_unload_config_entry(hass, config_entry): - """Test that loading and unloading of a config entry works.""" - config_entry.add_to_hass(hass) - with patch( - "aio_geojson_geonetnz_quakes.GeonetnzQuakesFeedManager.update", - new_callable=CoroutineMock, - ) as mock_feed_manager_update: - # Load config entry. - assert await async_setup_entry(hass, config_entry) - await hass.async_block_till_done() - assert mock_feed_manager_update.call_count == 1 - assert hass.data[DOMAIN][FEED][config_entry.entry_id] is not None - # Unload config entry. - assert await async_unload_entry(hass, config_entry) - await hass.async_block_till_done() - assert hass.data[DOMAIN][FEED].get(config_entry.entry_id) is None diff --git a/tests/components/geonetnz_quakes/test_geo_location.py b/tests/components/geonetnz_quakes/test_geo_location.py index 0132a07c745..06244227726 100644 --- a/tests/components/geonetnz_quakes/test_geo_location.py +++ b/tests/components/geonetnz_quakes/test_geo_location.py @@ -1,11 +1,11 @@ """The tests for the GeoNet NZ Quakes Feed integration.""" import datetime -from asynctest import CoroutineMock, patch +from asynctest import patch from homeassistant.components import geonetnz_quakes from homeassistant.components.geo_location import ATTR_SOURCE -from homeassistant.components.geonetnz_quakes import DEFAULT_SCAN_INTERVAL +from homeassistant.components.geonetnz_quakes import DEFAULT_SCAN_INTERVAL, DOMAIN, FEED from homeassistant.components.geonetnz_quakes.geo_location import ( ATTR_DEPTH, ATTR_EXTERNAL_ID, @@ -25,6 +25,7 @@ from homeassistant.const import ( CONF_RADIUS, EVENT_HOMEASSISTANT_START, ) +from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from homeassistant.util.unit_system import IMPERIAL_SYSTEM @@ -62,7 +63,7 @@ async def test_setup(hass): # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "aio_geojson_client.feed.GeoJsonFeed.update", new_callable=CoroutineMock + "aio_geojson_client.feed.GeoJsonFeed.update" ) as mock_feed_update: mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_2, mock_entry_3] assert await async_setup_component(hass, geonetnz_quakes.DOMAIN, CONFIG) @@ -73,6 +74,8 @@ async def test_setup(hass): all_states = hass.states.async_all() # 3 geolocation and 1 sensor entities assert len(all_states) == 4 + entity_registry = await async_get_registry(hass) + assert len(entity_registry.entities) == 4 state = hass.states.get("geo_location.title_1") assert state is not None @@ -151,6 +154,7 @@ async def test_setup(hass): all_states = hass.states.async_all() assert len(all_states) == 1 + assert len(entity_registry.entities) == 1 async def test_setup_imperial(hass): @@ -162,15 +166,9 @@ async def test_setup_imperial(hass): # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "aio_geojson_client.feed.GeoJsonFeed.update", new_callable=CoroutineMock + "aio_geojson_client.feed.GeoJsonFeed.update" ) as mock_feed_update, patch( - "aio_geojson_client.feed.GeoJsonFeed.__init__", - new_callable=CoroutineMock, - create=True, - ) as mock_feed_init, patch( - "aio_geojson_client.feed.GeoJsonFeed.last_timestamp", - new_callable=CoroutineMock, - create=True, + "aio_geojson_client.feed.GeoJsonFeed.last_timestamp", create=True ): mock_feed_update.return_value = "OK", [mock_entry_1] assert await async_setup_component(hass, geonetnz_quakes.DOMAIN, CONFIG) @@ -182,7 +180,12 @@ async def test_setup_imperial(hass): assert len(all_states) == 2 # Test conversion of 200 miles to kilometers. - assert mock_feed_init.call_args[1].get("filter_radius") == 321.8688 + feeds = hass.data[DOMAIN][FEED] + assert feeds is not None + assert len(feeds) == 1 + manager = list(feeds.values())[0] + # Ensure that the filter value in km is correctly set. + assert manager._feed_manager._feed._filter_radius == 321.8688 state = hass.states.get("geo_location.title_1") assert state is not None @@ -196,4 +199,5 @@ async def test_setup_imperial(hass): ATTR_SOURCE: "geonetnz_quakes", ATTR_ICON: "mdi:pulse", } + # 15.5km (as defined in mock entry) has been converted to 9.6mi. assert float(state.state) == 9.6 diff --git a/tests/components/geonetnz_quakes/test_init.py b/tests/components/geonetnz_quakes/test_init.py new file mode 100644 index 00000000000..85724879f7b --- /dev/null +++ b/tests/components/geonetnz_quakes/test_init.py @@ -0,0 +1,21 @@ +"""Define tests for the GeoNet NZ Quakes general setup.""" +from asynctest import patch + +from homeassistant.components.geonetnz_quakes import DOMAIN, FEED + + +async def test_component_unload_config_entry(hass, config_entry): + """Test that loading and unloading of a config entry works.""" + config_entry.add_to_hass(hass) + with patch( + "aio_geojson_geonetnz_quakes.GeonetnzQuakesFeedManager.update" + ) as mock_feed_manager_update: + # Load config entry. + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert mock_feed_manager_update.call_count == 1 + assert hass.data[DOMAIN][FEED][config_entry.entry_id] is not None + # Unload config entry. + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert hass.data[DOMAIN][FEED].get(config_entry.entry_id) is None diff --git a/tests/components/geonetnz_quakes/test_sensor.py b/tests/components/geonetnz_quakes/test_sensor.py index aecd012ba1c..7d7f8333bc0 100644 --- a/tests/components/geonetnz_quakes/test_sensor.py +++ b/tests/components/geonetnz_quakes/test_sensor.py @@ -1,7 +1,7 @@ """The tests for the GeoNet NZ Quakes Feed integration.""" import datetime -from asynctest import CoroutineMock, patch +from asynctest import patch from homeassistant.components import geonetnz_quakes from homeassistant.components.geonetnz_quakes import DEFAULT_SCAN_INTERVAL @@ -55,7 +55,7 @@ async def test_setup(hass): # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() with patch("homeassistant.util.dt.utcnow", return_value=utcnow), patch( - "aio_geojson_client.feed.GeoJsonFeed.update", new_callable=CoroutineMock + "aio_geojson_client.feed.GeoJsonFeed.update" ) as mock_feed_update: mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_2, mock_entry_3] assert await async_setup_component(hass, geonetnz_quakes.DOMAIN, CONFIG) From d1beb92c5dad24ae31366b7a2755c5b4206b70e2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 14:54:28 -0800 Subject: [PATCH 227/416] Add abode support for CUE automations (#32296) * Add support for CUE automations * Update requirements * Minor update to string name --- homeassistant/components/abode/__init__.py | 37 ++++++++---------- .../components/abode/alarm_control_panel.py | 9 +++-- .../components/abode/binary_sensor.py | 34 +--------------- homeassistant/components/abode/camera.py | 7 +--- homeassistant/components/abode/config_flow.py | 11 ++---- homeassistant/components/abode/const.py | 4 ++ homeassistant/components/abode/cover.py | 4 -- homeassistant/components/abode/light.py | 3 -- homeassistant/components/abode/lock.py | 4 -- homeassistant/components/abode/manifest.json | 2 +- homeassistant/components/abode/sensor.py | 4 -- homeassistant/components/abode/services.yaml | 6 +-- homeassistant/components/abode/switch.py | 39 +++++++++++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 15 files changed, 66 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py index 4ce9a4faca0..666c8481bfb 100644 --- a/homeassistant/components/abode/__init__.py +++ b/homeassistant/components/abode/__init__.py @@ -2,7 +2,6 @@ from asyncio import gather from copy import deepcopy from functools import partial -import logging from abodepy import Abode from abodepy.exceptions import AbodeException @@ -24,15 +23,13 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.entity import Entity -from .const import ATTRIBUTION, DEFAULT_CACHEDB, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import ATTRIBUTION, DEFAULT_CACHEDB, DOMAIN, LOGGER CONF_POLLING = "polling" SERVICE_SETTINGS = "change_setting" SERVICE_CAPTURE_IMAGE = "capture_image" -SERVICE_TRIGGER = "trigger_quick_action" +SERVICE_TRIGGER_AUTOMATION = "trigger_automation" ATTR_DEVICE_ID = "device_id" ATTR_DEVICE_NAME = "device_name" @@ -47,8 +44,6 @@ ATTR_APP_TYPE = "app_type" ATTR_EVENT_BY = "event_by" ATTR_VALUE = "value" -ABODE_DEVICE_ID_LIST_SCHEMA = vol.Schema([str]) - CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -68,7 +63,7 @@ CHANGE_SETTING_SCHEMA = vol.Schema( CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}) -TRIGGER_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}) +AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids}) ABODE_PLATFORMS = [ "alarm_control_panel", @@ -87,7 +82,6 @@ class AbodeSystem: def __init__(self, abode, polling): """Initialize the system.""" - self.abode = abode self.polling = polling self.entity_ids = set() @@ -124,7 +118,7 @@ async def async_setup_entry(hass, config_entry): hass.data[DOMAIN] = AbodeSystem(abode, polling) except (AbodeException, ConnectTimeout, HTTPError) as ex: - _LOGGER.error("Unable to connect to Abode: %s", str(ex)) + LOGGER.error("Unable to connect to Abode: %s", str(ex)) return False for platform in ABODE_PLATFORMS: @@ -143,7 +137,7 @@ async def async_unload_entry(hass, config_entry): """Unload a config entry.""" hass.services.async_remove(DOMAIN, SERVICE_SETTINGS) hass.services.async_remove(DOMAIN, SERVICE_CAPTURE_IMAGE) - hass.services.async_remove(DOMAIN, SERVICE_TRIGGER) + hass.services.async_remove(DOMAIN, SERVICE_TRIGGER_AUTOMATION) tasks = [] @@ -174,7 +168,7 @@ def setup_hass_services(hass): try: hass.data[DOMAIN].abode.set_setting(setting, value) except AbodeException as ex: - _LOGGER.warning(ex) + LOGGER.warning(ex) def capture_image(call): """Capture a new image.""" @@ -190,8 +184,8 @@ def setup_hass_services(hass): signal = f"abode_camera_capture_{entity_id}" dispatcher_send(hass, signal) - def trigger_quick_action(call): - """Trigger a quick action.""" + def trigger_automation(call): + """Trigger an Abode automation.""" entity_ids = call.data.get(ATTR_ENTITY_ID, None) target_entities = [ @@ -201,7 +195,7 @@ def setup_hass_services(hass): ] for entity_id in target_entities: - signal = f"abode_trigger_quick_action_{entity_id}" + signal = f"abode_trigger_automation_{entity_id}" dispatcher_send(hass, signal) hass.services.register( @@ -213,7 +207,7 @@ def setup_hass_services(hass): ) hass.services.register( - DOMAIN, SERVICE_TRIGGER, trigger_quick_action, schema=TRIGGER_SCHEMA + DOMAIN, SERVICE_TRIGGER_AUTOMATION, trigger_automation, schema=AUTOMATION_SCHEMA ) @@ -226,7 +220,7 @@ async def setup_hass_events(hass): hass.data[DOMAIN].abode.events.stop() hass.data[DOMAIN].abode.logout() - _LOGGER.info("Logged out of Abode") + LOGGER.info("Logged out of Abode") if not hass.data[DOMAIN].polling: await hass.async_add_executor_job(hass.data[DOMAIN].abode.events.start) @@ -384,11 +378,14 @@ class AbodeAutomation(Entity): """Return the state attributes.""" return { ATTR_ATTRIBUTION: ATTRIBUTION, - "automation_id": self._automation.automation_id, - "type": self._automation.type, - "sub_type": self._automation.sub_type, + "type": "CUE automation", } + @property + def unique_id(self): + """Return a unique ID to use for this automation.""" + return self._automation.automation_id + def _update_callback(self, device): """Update the automation state.""" self._automation.refresh() diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py index b9a0a8ce192..40040d90d0d 100644 --- a/homeassistant/components/abode/alarm_control_panel.py +++ b/homeassistant/components/abode/alarm_control_panel.py @@ -1,6 +1,4 @@ """Support for Abode Security System alarm control panels.""" -import logging - import homeassistant.components.alarm_control_panel as alarm from homeassistant.components.alarm_control_panel.const import ( SUPPORT_ALARM_ARM_AWAY, @@ -16,8 +14,6 @@ from homeassistant.const import ( from . import AbodeDevice from .const import ATTRIBUTION, DOMAIN -_LOGGER = logging.getLogger(__name__) - ICON = "mdi:security" @@ -50,6 +46,11 @@ class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanel): state = None return state + @property + def code_arm_required(self): + """Whether the code is required for arm actions.""" + return False + @property def supported_features(self) -> int: """Return the list of supported features.""" diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py index 7d4474437e9..c4cdadf9bd9 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -1,17 +1,11 @@ """Support for Abode Security System binary sensors.""" -import logging - import abodepy.helpers.constants as CONST -import abodepy.helpers.timeline as TIMELINE from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import AbodeAutomation, AbodeDevice +from . import AbodeDevice from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Abode binary sensor devices.""" @@ -30,13 +24,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for device in data.abode.get_devices(generic_type=device_types): entities.append(AbodeBinarySensor(data, device)) - for automation in data.abode.get_automations(generic_type=CONST.TYPE_QUICK_ACTION): - entities.append( - AbodeQuickActionBinarySensor( - data, automation, TIMELINE.AUTOMATION_EDIT_GROUP - ) - ) - async_add_entities(entities) @@ -52,22 +39,3 @@ class AbodeBinarySensor(AbodeDevice, BinarySensorDevice): def device_class(self): """Return the class of the binary sensor.""" return self._device.generic_type - - -class AbodeQuickActionBinarySensor(AbodeAutomation, BinarySensorDevice): - """A binary sensor implementation for Abode quick action automations.""" - - async def async_added_to_hass(self): - """Subscribe Abode events.""" - await super().async_added_to_hass() - signal = f"abode_trigger_quick_action_{self.entity_id}" - async_dispatcher_connect(self.hass, signal, self.trigger) - - def trigger(self): - """Trigger a quick automation.""" - self._automation.trigger() - - @property - def is_on(self): - """Return True if the binary sensor is on.""" - return self._automation.is_active diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py index edf29c4a198..bee73644890 100644 --- a/homeassistant/components/abode/camera.py +++ b/homeassistant/components/abode/camera.py @@ -1,6 +1,5 @@ """Support for Abode Security System cameras.""" from datetime import timedelta -import logging import abodepy.helpers.constants as CONST import abodepy.helpers.timeline as TIMELINE @@ -11,12 +10,10 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.util import Throttle from . import AbodeDevice -from .const import DOMAIN +from .const import DOMAIN, LOGGER MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90) -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Abode camera devices.""" @@ -71,7 +68,7 @@ class AbodeCamera(AbodeDevice, Camera): self._response.raise_for_status() except requests.HTTPError as err: - _LOGGER.warning("Failed to get camera image: %s", err) + LOGGER.warning("Failed to get camera image: %s", err) self._response = None else: self._response = None diff --git a/homeassistant/components/abode/config_flow.py b/homeassistant/components/abode/config_flow.py index 89b389798f6..5c2c5e7b843 100644 --- a/homeassistant/components/abode/config_flow.py +++ b/homeassistant/components/abode/config_flow.py @@ -1,6 +1,4 @@ """Config flow for the Abode Security System component.""" -import logging - from abodepy import Abode from abodepy.exceptions import AbodeException from requests.exceptions import ConnectTimeout, HTTPError @@ -10,12 +8,10 @@ from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback -from .const import DEFAULT_CACHEDB, DOMAIN # pylint: disable=unused-import +from .const import DEFAULT_CACHEDB, DOMAIN, LOGGER # pylint: disable=unused-import CONF_POLLING = "polling" -_LOGGER = logging.getLogger(__name__) - class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for Abode.""" @@ -32,7 +28,6 @@ class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" - if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") @@ -50,7 +45,7 @@ class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) except (AbodeException, ConnectTimeout, HTTPError) as ex: - _LOGGER.error("Unable to connect to Abode: %s", str(ex)) + LOGGER.error("Unable to connect to Abode: %s", str(ex)) if ex.errcode == 400: return self._show_form({"base": "invalid_credentials"}) return self._show_form({"base": "connection_error"}) @@ -76,7 +71,7 @@ class AbodeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" if self._async_current_entries(): - _LOGGER.warning("Only one configuration of abode is allowed.") + LOGGER.warning("Only one configuration of abode is allowed.") return self.async_abort(reason="single_instance_allowed") return await self.async_step_user(import_config) diff --git a/homeassistant/components/abode/const.py b/homeassistant/components/abode/const.py index 092843ba212..b509984876b 100644 --- a/homeassistant/components/abode/const.py +++ b/homeassistant/components/abode/const.py @@ -1,4 +1,8 @@ """Constants for the Abode Security System component.""" +import logging + +LOGGER = logging.getLogger(__package__) + DOMAIN = "abode" ATTRIBUTION = "Data provided by goabode.com" diff --git a/homeassistant/components/abode/cover.py b/homeassistant/components/abode/cover.py index ec4f54a985c..6e38c11cfcc 100644 --- a/homeassistant/components/abode/cover.py +++ b/homeassistant/components/abode/cover.py @@ -1,6 +1,4 @@ """Support for Abode Security System covers.""" -import logging - import abodepy.helpers.constants as CONST from homeassistant.components.cover import CoverDevice @@ -8,8 +6,6 @@ from homeassistant.components.cover import CoverDevice from . import AbodeDevice from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Abode cover devices.""" diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py index ad2df23ef9c..7602768a6e3 100644 --- a/homeassistant/components/abode/light.py +++ b/homeassistant/components/abode/light.py @@ -1,5 +1,4 @@ """Support for Abode Security System lights.""" -import logging from math import ceil import abodepy.helpers.constants as CONST @@ -21,8 +20,6 @@ from homeassistant.util.color import ( from . import AbodeDevice from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Abode light devices.""" diff --git a/homeassistant/components/abode/lock.py b/homeassistant/components/abode/lock.py index b05a3e7f297..33431433ef9 100644 --- a/homeassistant/components/abode/lock.py +++ b/homeassistant/components/abode/lock.py @@ -1,6 +1,4 @@ """Support for the Abode Security System locks.""" -import logging - import abodepy.helpers.constants as CONST from homeassistant.components.lock import LockDevice @@ -8,8 +6,6 @@ from homeassistant.components.lock import LockDevice from . import AbodeDevice from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Abode lock devices.""" diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json index 383320141e5..eabd4a7f74f 100644 --- a/homeassistant/components/abode/manifest.json +++ b/homeassistant/components/abode/manifest.json @@ -3,7 +3,7 @@ "name": "Abode", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/abode", - "requirements": ["abodepy==0.17.0"], + "requirements": ["abodepy==0.18.1"], "dependencies": [], "codeowners": ["@shred86"] } diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py index afa5e372222..6ecc5c871cd 100644 --- a/homeassistant/components/abode/sensor.py +++ b/homeassistant/components/abode/sensor.py @@ -1,6 +1,4 @@ """Support for Abode Security System sensors.""" -import logging - import abodepy.helpers.constants as CONST from homeassistant.const import ( @@ -12,8 +10,6 @@ from homeassistant.const import ( from . import AbodeDevice from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - # Sensor types: Name, icon SENSOR_TYPES = { CONST.TEMP_STATUS_KEY: ["Temperature", DEVICE_CLASS_TEMPERATURE], diff --git a/homeassistant/components/abode/services.yaml b/homeassistant/components/abode/services.yaml index ad0bb076d90..5818cdc0048 100644 --- a/homeassistant/components/abode/services.yaml +++ b/homeassistant/components/abode/services.yaml @@ -7,7 +7,7 @@ change_setting: fields: setting: {description: Setting to change., example: beeper_mute} value: {description: Value of the setting., example: '1'} -trigger_quick_action: - description: Trigger an Abode quick action. +trigger_automation: + description: Trigger an Abode automation. fields: - entity_id: {description: Entity id of the quick action to trigger., example: binary_sensor.home_quick_action} + entity_id: {description: Entity id of the automation to trigger., example: switch.my_automation} \ No newline at end of file diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py index d6773e10ca1..e29deb72f82 100644 --- a/homeassistant/components/abode/switch.py +++ b/homeassistant/components/abode/switch.py @@ -1,18 +1,17 @@ """Support for Abode Security System switches.""" -import logging - import abodepy.helpers.constants as CONST import abodepy.helpers.timeline as TIMELINE from homeassistant.components.switch import SwitchDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import AbodeAutomation, AbodeDevice from .const import DOMAIN -_LOGGER = logging.getLogger(__name__) - DEVICE_TYPES = [CONST.TYPE_SWITCH, CONST.TYPE_VALVE] +ICON = "mdi:robot" + async def async_setup_entry(hass, config_entry, async_add_entities): """Set up Abode switch devices.""" @@ -24,7 +23,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for device in data.abode.get_devices(generic_type=device_type): entities.append(AbodeSwitch(data, device)) - for automation in data.abode.get_automations(generic_type=CONST.TYPE_AUTOMATION): + for automation in data.abode.get_automations(): entities.append( AbodeAutomationSwitch(data, automation, TIMELINE.AUTOMATION_EDIT_GROUP) ) @@ -52,15 +51,33 @@ class AbodeSwitch(AbodeDevice, SwitchDevice): class AbodeAutomationSwitch(AbodeAutomation, SwitchDevice): """A switch implementation for Abode automations.""" + async def async_added_to_hass(self): + """Subscribe Abode events.""" + await super().async_added_to_hass() + + signal = f"abode_trigger_automation_{self.entity_id}" + async_dispatcher_connect(self.hass, signal, self.trigger) + def turn_on(self, **kwargs): - """Turn on the device.""" - self._automation.set_active(True) + """Enable the automation.""" + if self._automation.enable(True): + self.schedule_update_ha_state() def turn_off(self, **kwargs): - """Turn off the device.""" - self._automation.set_active(False) + """Disable the automation.""" + if self._automation.enable(False): + self.schedule_update_ha_state() + + def trigger(self): + """Trigger the automation.""" + self._automation.trigger() @property def is_on(self): - """Return True if the binary sensor is on.""" - return self._automation.is_active + """Return True if the automation is enabled.""" + return self._automation.is_enabled + + @property + def icon(self): + """Return the robot icon to match Home Assistant automations.""" + return ICON diff --git a/requirements_all.txt b/requirements_all.txt index 0a107eb34b2..3adff8a4743 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -106,7 +106,7 @@ WazeRouteCalculator==0.12 YesssSMS==0.4.1 # homeassistant.components.abode -abodepy==0.17.0 +abodepy==0.18.1 # homeassistant.components.mcp23017 adafruit-blinka==3.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1b954093028..c7f59369b26 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -26,7 +26,7 @@ RtmAPI==0.7.2 YesssSMS==0.4.1 # homeassistant.components.abode -abodepy==0.17.0 +abodepy==0.18.1 # homeassistant.components.androidtv adb-shell==0.1.1 From 9a8017aaddb0c5153ad608ed509e4461c26c0628 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 14:56:37 -0800 Subject: [PATCH 228/416] Add more Huawei LTE sensors (#32123) * Add Huawei LTE WiFi client count and DNS server sensors * Add Huawei LTE current month statistics sensors --- .../components/huawei_lte/__init__.py | 4 ++ homeassistant/components/huawei_lte/const.py | 3 ++ homeassistant/components/huawei_lte/sensor.py | 38 ++++++++++++++++--- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 1bcdd7129c7..261250e9c02 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -63,6 +63,7 @@ from .const import ( KEY_DEVICE_INFORMATION, KEY_DEVICE_SIGNAL, KEY_DIALUP_MOBILE_DATASWITCH, + KEY_MONITORING_MONTH_STATISTICS, KEY_MONITORING_STATUS, KEY_MONITORING_TRAFFIC_STATISTICS, KEY_WLAN_HOST_LIST, @@ -230,6 +231,9 @@ class Router: self._get_data( KEY_DIALUP_MOBILE_DATASWITCH, self.client.dial_up.mobile_dataswitch ) + self._get_data( + KEY_MONITORING_MONTH_STATISTICS, self.client.monitoring.month_statistics + ) self._get_data(KEY_MONITORING_STATUS, self.client.monitoring.status) self._get_data( KEY_MONITORING_TRAFFIC_STATISTICS, self.client.monitoring.traffic_statistics diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index 41814d5ae10..5a4aeb5f0b7 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -27,6 +27,7 @@ KEY_DEVICE_BASIC_INFORMATION = "device_basic_information" KEY_DEVICE_INFORMATION = "device_information" KEY_DEVICE_SIGNAL = "device_signal" KEY_DIALUP_MOBILE_DATASWITCH = "dialup_mobile_dataswitch" +KEY_MONITORING_MONTH_STATISTICS = "monitoring_month_statistics" KEY_MONITORING_STATUS = "monitoring_status" KEY_MONITORING_TRAFFIC_STATISTICS = "monitoring_traffic_statistics" KEY_WLAN_HOST_LIST = "wlan_host_list" @@ -38,6 +39,8 @@ DEVICE_TRACKER_KEYS = {KEY_WLAN_HOST_LIST} SENSOR_KEYS = { KEY_DEVICE_INFORMATION, KEY_DEVICE_SIGNAL, + KEY_MONITORING_MONTH_STATISTICS, + KEY_MONITORING_STATUS, KEY_MONITORING_TRAFFIC_STATISTICS, } diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 8ca5e02dcdd..e49de1c05a3 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -17,7 +17,10 @@ from .const import ( DOMAIN, KEY_DEVICE_INFORMATION, KEY_DEVICE_SIGNAL, + KEY_MONITORING_MONTH_STATISTICS, + KEY_MONITORING_STATUS, KEY_MONITORING_TRAFFIC_STATISTICS, + SENSOR_KEYS, ) _LOGGER = logging.getLogger(__name__) @@ -117,6 +120,35 @@ SENSOR_META = { and "mdi:signal-cellular-2" or "mdi:signal-cellular-3", ), + KEY_MONITORING_MONTH_STATISTICS: dict( + exclude=re.compile(r"^month(duration|lastcleartime)$", re.IGNORECASE) + ), + (KEY_MONITORING_MONTH_STATISTICS, "CurrentMonthDownload"): dict( + name="Current month download", unit=DATA_BYTES, icon="mdi:download" + ), + (KEY_MONITORING_MONTH_STATISTICS, "CurrentMonthUpload"): dict( + name="Current month upload", unit=DATA_BYTES, icon="mdi:upload" + ), + KEY_MONITORING_STATUS: dict( + include=re.compile( + r"^(currentwifiuser|(primary|secondary).*dns)$", re.IGNORECASE + ) + ), + (KEY_MONITORING_STATUS, "CurrentWifiUser"): dict( + name="WiFi clients connected", icon="mdi:wifi" + ), + (KEY_MONITORING_STATUS, "PrimaryDns"): dict( + name="Primary DNS server", icon="mdi:ip" + ), + (KEY_MONITORING_STATUS, "SecondaryDns"): dict( + name="Secondary DNS server", icon="mdi:ip" + ), + (KEY_MONITORING_STATUS, "PrimaryIPv6Dns"): dict( + name="Primary IPv6 DNS server", icon="mdi:ip" + ), + (KEY_MONITORING_STATUS, "SecondaryIPv6Dns"): dict( + name="Secondary IPv6 DNS server", icon="mdi:ip" + ), KEY_MONITORING_TRAFFIC_STATISTICS: dict( exclude=re.compile(r"^showtraffic$", re.IGNORECASE) ), @@ -145,11 +177,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up from config entry.""" router = hass.data[DOMAIN].routers[config_entry.data[CONF_URL]] sensors = [] - for key in ( - KEY_DEVICE_INFORMATION, - KEY_DEVICE_SIGNAL, - KEY_MONITORING_TRAFFIC_STATISTICS, - ): + for key in SENSOR_KEYS: items = router.data.get(key) if not items: continue From 15a5cebd5f3533ed671107efc5f11d40b892aae8 Mon Sep 17 00:00:00 2001 From: Quentame Date: Thu, 5 Mar 2020 00:02:22 +0100 Subject: [PATCH 229/416] Add icons to Coronavirus (#32480) * Add icons to Coronavirus * Update homeassistant/components/coronavirus/sensor.py Co-authored-by: Paulus Schoutsen --- homeassistant/components/coronavirus/sensor.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/coronavirus/sensor.py b/homeassistant/components/coronavirus/sensor.py index 20f18896431..3885dbebf24 100644 --- a/homeassistant/components/coronavirus/sensor.py +++ b/homeassistant/components/coronavirus/sensor.py @@ -5,6 +5,13 @@ from homeassistant.helpers.entity import Entity from . import get_coordinator from .const import ATTRIBUTION, OPTION_WORLDWIDE +SENSORS = { + "confirmed": "mdi:emoticon-neutral-outline", + "current": "mdi:emoticon-frown-outline", + "recovered": "mdi:emoticon-happy-outline", + "deaths": "mdi:emoticon-dead-outline", +} + async def async_setup_entry(hass, config_entry, async_add_entities): """Defer sensor setup to the shared sensor module.""" @@ -12,7 +19,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_add_entities( CoronavirusSensor(coordinator, config_entry.data["country"], info_type) - for info_type in ("confirmed", "recovered", "deaths", "current") + for info_type in SENSORS ) @@ -50,6 +57,11 @@ class CoronavirusSensor(Entity): return getattr(self.coordinator.data[self.country], self.info_type) + @property + def icon(self): + """Return the icon.""" + return SENSORS[self.info_type] + @property def unit_of_measurement(self): """Return unit of measurement.""" From 76fec90fecc5d72bffb1536c8095104ec452bb46 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 15:32:44 -0800 Subject: [PATCH 230/416] Reduce log level for initial error and bump library version (#32381) * Reduce log level for initial error and bump library version * Use new buienradar library version which reduces the log level for some types of warnings. * Initially logs at a lower level and only logs at WARN for repeated errors. This should serve to reduce confusion with users such as in issue #32301. * Fix linter error with new linter settings. * Fix linter warning * Update homeassistant/components/buienradar/util.py Co-authored-by: Paulus Schoutsen --- .../components/buienradar/manifest.json | 2 +- homeassistant/components/buienradar/util.py | 31 +++++++++++++++++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/buienradar/manifest.json b/homeassistant/components/buienradar/manifest.json index 23e282c34bb..5f604322b16 100644 --- a/homeassistant/components/buienradar/manifest.json +++ b/homeassistant/components/buienradar/manifest.json @@ -2,7 +2,7 @@ "domain": "buienradar", "name": "Buienradar", "documentation": "https://www.home-assistant.io/integrations/buienradar", - "requirements": ["buienradar==1.0.1"], + "requirements": ["buienradar==1.0.4"], "dependencies": [], "codeowners": ["@mjj4791", "@ties"] } diff --git a/homeassistant/components/buienradar/util.py b/homeassistant/components/buienradar/util.py index 37c518cef7a..7d16f072b98 100644 --- a/homeassistant/components/buienradar/util.py +++ b/homeassistant/components/buienradar/util.py @@ -32,12 +32,31 @@ from homeassistant.util import dt as dt_util from .const import SCHEDULE_NOK, SCHEDULE_OK +__all__ = ["BrData"] _LOGGER = logging.getLogger(__name__) +""" +Log at WARN level after WARN_THRESHOLD failures, otherwise log at +DEBUG level. +""" +WARN_THRESHOLD = 4 + + +def threshold_log(count: int, *args, **kwargs) -> None: + """Log at warn level after WARN_THRESHOLD failures, debug otherwise.""" + if count >= WARN_THRESHOLD: + _LOGGER.warning(*args, **kwargs) + else: + _LOGGER.debug(*args, **kwargs) + class BrData: """Get the latest data and updates the states.""" + # Initialize to warn immediately if the first call fails. + load_error_count: int = WARN_THRESHOLD + rain_error_count: int = WARN_THRESHOLD + def __init__(self, hass, coordinates, timeframe, devices): """Initialize the data object.""" self.devices = devices @@ -96,7 +115,9 @@ class BrData: if content.get(SUCCESS) is not True: # unable to get the data - _LOGGER.warning( + self.load_error_count += 1 + threshold_log( + self.load_error_count, "Unable to retrieve json data from Buienradar." "(Msg: %s, status: %s,)", content.get(MESSAGE), @@ -105,6 +126,7 @@ class BrData: # schedule new call await self.schedule_update(SCHEDULE_NOK) return + self.load_error_count = 0 # rounding coordinates prevents unnecessary redirects/calls lat = self.coordinates[CONF_LATITUDE] @@ -113,15 +135,18 @@ class BrData: raincontent = await self.get_data(rainurl) if raincontent.get(SUCCESS) is not True: + self.rain_error_count += 1 # unable to get the data - _LOGGER.warning( - "Unable to retrieve raindata from Buienradar. (Msg: %s, status: %s)", + threshold_log( + self.rain_error_count, + "Unable to retrieve rain data from Buienradar." "(Msg: %s, status: %s)", raincontent.get(MESSAGE), raincontent.get(STATUS_CODE), ) # schedule new call await self.schedule_update(SCHEDULE_NOK) return + self.rain_error_count = 0 result = parse_data( content.get(CONTENT), diff --git a/requirements_all.txt b/requirements_all.txt index 3adff8a4743..3133c23bde8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -368,7 +368,7 @@ bthomehub5-devicelist==0.1.1 btsmarthub_devicelist==0.1.3 # homeassistant.components.buienradar -buienradar==1.0.1 +buienradar==1.0.4 # homeassistant.components.caldav caldav==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c7f59369b26..866c7eaa9c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -130,7 +130,7 @@ broadlink==0.12.0 brother==0.1.8 # homeassistant.components.buienradar -buienradar==1.0.1 +buienradar==1.0.4 # homeassistant.components.caldav caldav==0.6.1 From e416f17e4d31269086d3a839e399c005030b65c8 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 4 Mar 2020 19:27:37 -0500 Subject: [PATCH 231/416] ZHA: Successful pairing feedback (#32456) --- homeassistant/components/zha/core/const.py | 6 ++++ homeassistant/components/zha/core/device.py | 7 +++++ homeassistant/components/zha/light.py | 7 +++-- tests/components/zha/common.py | 1 + tests/components/zha/conftest.py | 4 ++- tests/components/zha/test_discover.py | 34 +++++++++++++++++++++ 6 files changed, 56 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 3204fa76e2a..4b5a5a0c6a1 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -251,3 +251,9 @@ ZHA_GW_MSG_LOG_OUTPUT = "log_output" ZHA_GW_MSG_RAW_INIT = "raw_device_initialized" ZHA_GW_RADIO = "radio" ZHA_GW_RADIO_DESCRIPTION = "radio_description" + +EFFECT_BLINK = 0x00 +EFFECT_BREATHE = 0x01 +EFFECT_OKAY = 0x02 + +EFFECT_DEFAULT_VARIANT = 0x00 diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 94eb16fc417..76685180ea2 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -48,6 +48,8 @@ from .const import ( CLUSTER_COMMANDS_SERVER, CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, + EFFECT_DEFAULT_VARIANT, + EFFECT_OKAY, POWER_BATTERY_OR_UNKNOWN, POWER_MAINS_POWERED, SIGNAL_AVAILABLE, @@ -342,6 +344,11 @@ class ZHADevice(LogMixin): entry = self.gateway.zha_storage.async_create_or_update(self) self.debug("stored in registry: %s", entry) + if self._channels.identify_ch is not None: + await self._channels.identify_ch.trigger_effect( + EFFECT_OKAY, EFFECT_DEFAULT_VARIANT + ) + async def async_initialize(self, from_cache=False): """Initialize channels.""" self.debug("started initialization") diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 68032001816..634bf50001e 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -19,6 +19,9 @@ from .core.const import ( CHANNEL_ON_OFF, DATA_ZHA, DATA_ZHA_DISPATCHERS, + EFFECT_BLINK, + EFFECT_BREATHE, + EFFECT_DEFAULT_VARIANT, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL, @@ -38,7 +41,7 @@ UPDATE_COLORLOOP_DIRECTION = 0x2 UPDATE_COLORLOOP_TIME = 0x4 UPDATE_COLORLOOP_HUE = 0x8 -FLASH_EFFECTS = {light.FLASH_SHORT: 0x00, light.FLASH_LONG: 0x01} +FLASH_EFFECTS = {light.FLASH_SHORT: EFFECT_BLINK, light.FLASH_LONG: EFFECT_BREATHE} UNSUPPORTED_ATTRIBUTE = 0x86 SCAN_INTERVAL = timedelta(minutes=60) @@ -287,7 +290,7 @@ class Light(ZhaEntity, light.Light): if flash is not None and self._supported_features & light.SUPPORT_FLASH: result = await self._identify_channel.trigger_effect( - FLASH_EFFECTS[flash], 0 # effect identifier, effect variant + FLASH_EFFECTS[flash], EFFECT_DEFAULT_VARIANT ) t_log["trigger_effect"] = result diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index dfa0c455649..8e99a51f1f9 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -29,6 +29,7 @@ class FakeEndpoint: self.model = model self.profile_id = zigpy.profiles.zha.PROFILE_ID self.device_type = None + self.request = CoroutineMock() def add_input_cluster(self, cluster_id): """Add an input cluster.""" diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index e3a8f6bf4dc..53ffb121291 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -166,7 +166,9 @@ def zha_device_restored(hass, zigpy_app_controller, setup_zha): @pytest.fixture(params=["zha_device_joined", "zha_device_restored"]) def zha_device_joined_restored(request): """Join or restore ZHA device.""" - return request.getfixturevalue(request.param) + named_method = request.getfixturevalue(request.param) + named_method.name = request.param + return named_method @pytest.fixture diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index c8f2eb0dd7c..9515de32fcd 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -3,11 +3,14 @@ import re from unittest import mock +import asynctest import pytest import zigpy.quirks +import zigpy.types import zigpy.zcl.clusters.closures import zigpy.zcl.clusters.general import zigpy.zcl.clusters.security +import zigpy.zcl.foundation as zcl_f import homeassistant.components.zha.binary_sensor import homeassistant.components.zha.core.channels as zha_channels @@ -48,6 +51,12 @@ def channels_mock(zha_device_mock): return _mock +@asynctest.patch( + "zigpy.zcl.clusters.general.Identify.request", + new=asynctest.CoroutineMock( + return_value=[mock.sentinel.data, zcl_f.Status.SUCCESS] + ), +) @pytest.mark.parametrize("device", DEVICES) async def test_devices( device, hass, zigpy_device_mock, monkeypatch, zha_device_joined_restored @@ -66,6 +75,10 @@ async def test_devices( node_descriptor=device["node_descriptor"], ) + cluster_identify = _get_first_identify_cluster(zigpy_device) + if cluster_identify: + cluster_identify.request.reset_mock() + orig_new_entity = zha_channels.ChannelPool.async_new_entity _dispatch = mock.MagicMock(wraps=orig_new_entity) try: @@ -81,6 +94,21 @@ async def test_devices( ent for ent in entity_ids if ent.split(".")[0] in zha_const.COMPONENTS } + if cluster_identify: + called = int(zha_device_joined_restored.name == "zha_device_joined") + assert cluster_identify.request.call_count == called + assert cluster_identify.request.await_count == called + if called: + assert cluster_identify.request.call_args == mock.call( + False, + 64, + (zigpy.types.uint8_t, zigpy.types.uint8_t), + 2, + 0, + expect_reply=True, + manufacturer=None, + ) + event_channels = { ch.id for pool in zha_dev.channels.pools for ch in pool.relay_channels.values() } @@ -108,6 +136,12 @@ async def test_devices( assert entity_cls.__name__ == entity_map[key]["entity_class"] +def _get_first_identify_cluster(zigpy_device): + for endpoint in list(zigpy_device.endpoints.values())[1:]: + if hasattr(endpoint, "identify"): + return endpoint.identify + + @mock.patch( "homeassistant.components.zha.core.discovery.ProbeEndpoint.discover_by_device_type" ) From 3ca97a05175665db8a05bad7c553c1829d266e70 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 17:31:54 -0800 Subject: [PATCH 232/416] Add sighthound timestamped file (#32202) * Update image_processing.py Adds save timestamp file and adds last_detection attribute * Update test_image_processing.py Adds test * Adds assert pil_img.save.call_args * Test timestamp filename * Add test bad data * Update test_image_processing.py * Fix bad image data test * Update homeassistant/components/sighthound/image_processing.py Co-Authored-By: Martin Hjelmare Co-authored-by: Martin Hjelmare --- .../components/sighthound/image_processing.py | 40 +++++++++- .../sighthound/test_image_processing.py | 80 +++++++++++++++++-- 2 files changed, 111 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/sighthound/image_processing.py b/homeassistant/components/sighthound/image_processing.py index ff67749b192..7e9e789423e 100644 --- a/homeassistant/components/sighthound/image_processing.py +++ b/homeassistant/components/sighthound/image_processing.py @@ -3,7 +3,7 @@ import io import logging from pathlib import Path -from PIL import Image, ImageDraw +from PIL import Image, ImageDraw, UnidentifiedImageError import simplehound.core as hound import voluptuous as vol @@ -17,6 +17,7 @@ from homeassistant.components.image_processing import ( from homeassistant.const import ATTR_ENTITY_ID, CONF_API_KEY from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util from homeassistant.util.pil import draw_box _LOGGER = logging.getLogger(__name__) @@ -27,6 +28,8 @@ ATTR_BOUNDING_BOX = "bounding_box" ATTR_PEOPLE = "people" CONF_ACCOUNT_TYPE = "account_type" CONF_SAVE_FILE_FOLDER = "save_file_folder" +CONF_SAVE_TIMESTAMPTED_FILE = "save_timestamped_file" +DATETIME_FORMAT = "%Y-%m-%d_%H:%M:%S" DEV = "dev" PROD = "prod" @@ -35,6 +38,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_ACCOUNT_TYPE, default=DEV): vol.In([DEV, PROD]), vol.Optional(CONF_SAVE_FILE_FOLDER): cv.isdir, + vol.Optional(CONF_SAVE_TIMESTAMPTED_FILE, default=False): cv.boolean, } ) @@ -58,7 +62,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): entities = [] for camera in config[CONF_SOURCE]: sighthound = SighthoundEntity( - api, camera[CONF_ENTITY_ID], camera.get(CONF_NAME), save_file_folder + api, + camera[CONF_ENTITY_ID], + camera.get(CONF_NAME), + save_file_folder, + config[CONF_SAVE_TIMESTAMPTED_FILE], ) entities.append(sighthound) add_entities(entities) @@ -67,7 +75,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class SighthoundEntity(ImageProcessingEntity): """Create a sighthound entity.""" - def __init__(self, api, camera_entity, name, save_file_folder): + def __init__( + self, api, camera_entity, name, save_file_folder, save_timestamped_file + ): """Init.""" self._api = api self._camera = camera_entity @@ -77,15 +87,19 @@ class SighthoundEntity(ImageProcessingEntity): camera_name = split_entity_id(camera_entity)[1] self._name = f"sighthound_{camera_name}" self._state = None + self._last_detection = None self._image_width = None self._image_height = None self._save_file_folder = save_file_folder + self._save_timestamped_file = save_timestamped_file def process_image(self, image): """Process an image.""" detections = self._api.detect(image) people = hound.get_people(detections) self._state = len(people) + if self._state > 0: + self._last_detection = dt_util.now().strftime(DATETIME_FORMAT) metadata = hound.get_metadata(detections) self._image_width = metadata["image_width"] @@ -109,7 +123,11 @@ class SighthoundEntity(ImageProcessingEntity): def save_image(self, image, people, directory): """Save a timestamped image with bounding boxes around targets.""" - img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") + try: + img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") + except UnidentifiedImageError: + _LOGGER.warning("Sighthound unable to process image, bad data") + return draw = ImageDraw.Draw(img) for person in people: @@ -117,9 +135,15 @@ class SighthoundEntity(ImageProcessingEntity): person["boundingBox"], self._image_width, self._image_height ) draw_box(draw, box, self._image_width, self._image_height) + latest_save_path = directory / f"{self._name}_latest.jpg" img.save(latest_save_path) + if self._save_timestamped_file: + timestamp_save_path = directory / f"{self._name}_{self._last_detection}.jpg" + img.save(timestamp_save_path) + _LOGGER.info("Sighthound saved file %s", timestamp_save_path) + @property def camera_entity(self): """Return camera entity id from process pictures.""" @@ -144,3 +168,11 @@ class SighthoundEntity(ImageProcessingEntity): def unit_of_measurement(self): """Return the unit of measurement.""" return ATTR_PEOPLE + + @property + def device_state_attributes(self): + """Return the attributes.""" + attr = {} + if self._last_detection: + attr["last_person"] = self._last_detection + return attr diff --git a/tests/components/sighthound/test_image_processing.py b/tests/components/sighthound/test_image_processing.py index 3c0d10bd5b3..1d73ace184e 100644 --- a/tests/components/sighthound/test_image_processing.py +++ b/tests/components/sighthound/test_image_processing.py @@ -1,8 +1,11 @@ """Tests for the Sighthound integration.""" from copy import deepcopy +import datetime import os -from unittest.mock import patch +from pathlib import Path +from unittest import mock +from PIL import UnidentifiedImageError import pytest import simplehound.core as hound @@ -40,11 +43,13 @@ MOCK_DETECTIONS = { "requestId": "545cec700eac4d389743e2266264e84b", } +MOCK_NOW = datetime.datetime(2020, 2, 20, 10, 5, 3) + @pytest.fixture def mock_detections(): """Return a mock detection.""" - with patch( + with mock.patch( "simplehound.core.cloud.detect", return_value=MOCK_DETECTIONS ) as detection: yield detection @@ -53,16 +58,35 @@ def mock_detections(): @pytest.fixture def mock_image(): """Return a mock camera image.""" - with patch( + with mock.patch( "homeassistant.components.demo.camera.DemoCamera.camera_image", return_value=b"Test", ) as image: yield image +@pytest.fixture +def mock_bad_image_data(): + """Mock bad image data.""" + with mock.patch( + "homeassistant.components.sighthound.image_processing.Image.open", + side_effect=UnidentifiedImageError, + ) as bad_data: + yield bad_data + + +@pytest.fixture +def mock_now(): + """Return a mock now datetime.""" + with mock.patch("homeassistant.util.dt.now", return_value=MOCK_NOW) as now_dt: + yield now_dt + + async def test_bad_api_key(hass, caplog): """Catch bad api key.""" - with patch("simplehound.core.cloud.detect", side_effect=hound.SimplehoundException): + with mock.patch( + "simplehound.core.cloud.detect", side_effect=hound.SimplehoundException + ): await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) assert "Sighthound error" in caplog.text assert not hass.states.get(VALID_ENTITY_ID) @@ -97,6 +121,21 @@ async def test_process_image(hass, mock_image, mock_detections): assert len(person_events) == 2 +async def test_catch_bad_image( + hass, caplog, mock_image, mock_detections, mock_bad_image_data +): + """Process an image.""" + valid_config_save_file = deepcopy(VALID_CONFIG) + valid_config_save_file[ip.DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR}) + await async_setup_component(hass, ip.DOMAIN, valid_config_save_file) + assert hass.states.get(VALID_ENTITY_ID) + + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} + await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) + await hass.async_block_till_done() + assert "Sighthound unable to process image" in caplog.text + + async def test_save_image(hass, mock_image, mock_detections): """Save a processed image.""" valid_config_save_file = deepcopy(VALID_CONFIG) @@ -104,7 +143,7 @@ async def test_save_image(hass, mock_image, mock_detections): await async_setup_component(hass, ip.DOMAIN, valid_config_save_file) assert hass.states.get(VALID_ENTITY_ID) - with patch( + with mock.patch( "homeassistant.components.sighthound.image_processing.Image.open" ) as pil_img_open: pil_img = pil_img_open.return_value @@ -115,3 +154,34 @@ async def test_save_image(hass, mock_image, mock_detections): state = hass.states.get(VALID_ENTITY_ID) assert state.state == "2" assert pil_img.save.call_count == 1 + + directory = Path(TEST_DIR) + latest_save_path = directory / "sighthound_demo_camera_latest.jpg" + assert pil_img.save.call_args_list[0] == mock.call(latest_save_path) + + +async def test_save_timestamped_image(hass, mock_image, mock_detections, mock_now): + """Save a processed image.""" + valid_config_save_ts_file = deepcopy(VALID_CONFIG) + valid_config_save_ts_file[ip.DOMAIN].update({sh.CONF_SAVE_FILE_FOLDER: TEST_DIR}) + valid_config_save_ts_file[ip.DOMAIN].update({sh.CONF_SAVE_TIMESTAMPTED_FILE: True}) + await async_setup_component(hass, ip.DOMAIN, valid_config_save_ts_file) + assert hass.states.get(VALID_ENTITY_ID) + + with mock.patch( + "homeassistant.components.sighthound.image_processing.Image.open" + ) as pil_img_open: + pil_img = pil_img_open.return_value + pil_img = pil_img.convert.return_value + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} + await hass.services.async_call(ip.DOMAIN, ip.SERVICE_SCAN, service_data=data) + await hass.async_block_till_done() + state = hass.states.get(VALID_ENTITY_ID) + assert state.state == "2" + assert pil_img.save.call_count == 2 + + directory = Path(TEST_DIR) + timestamp_save_path = ( + directory / "sighthound_demo_camera_2020-02-20_10:05:03.jpg" + ) + assert pil_img.save.call_args_list[1] == mock.call(timestamp_save_path) From 7b5b909f0abf6d2d9fd04f11897dc745dbf54f9d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 17:35:07 -0800 Subject: [PATCH 233/416] ZHA Dependencies bump (#32483) * ZHA Dependencies bump. * Bump up ZHA dependencies. --- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 01a204a282c..235a3872ec0 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -5,10 +5,10 @@ "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ "bellows-homeassistant==0.13.2", - "zha-quirks==0.0.34", + "zha-quirks==0.0.35", "zigpy-cc==0.1.0", "zigpy-deconz==0.7.0", - "zigpy-homeassistant==0.14.0", + "zigpy-homeassistant==0.15.0", "zigpy-xbee-homeassistant==0.9.0", "zigpy-zigate==0.5.1" ], diff --git a/requirements_all.txt b/requirements_all.txt index 3133c23bde8..121646cae11 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2146,7 +2146,7 @@ zengge==0.2 zeroconf==0.24.4 # homeassistant.components.zha -zha-quirks==0.0.34 +zha-quirks==0.0.35 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2161,7 +2161,7 @@ zigpy-cc==0.1.0 zigpy-deconz==0.7.0 # homeassistant.components.zha -zigpy-homeassistant==0.14.0 +zigpy-homeassistant==0.15.0 # homeassistant.components.zha zigpy-xbee-homeassistant==0.9.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 866c7eaa9c2..ff37f3a9ba3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -741,7 +741,7 @@ yahooweather==0.10 zeroconf==0.24.4 # homeassistant.components.zha -zha-quirks==0.0.34 +zha-quirks==0.0.35 # homeassistant.components.zha zigpy-cc==0.1.0 @@ -750,7 +750,7 @@ zigpy-cc==0.1.0 zigpy-deconz==0.7.0 # homeassistant.components.zha -zigpy-homeassistant==0.14.0 +zigpy-homeassistant==0.15.0 # homeassistant.components.zha zigpy-xbee-homeassistant==0.9.0 From 2d3b117cb87e53d46cebf27aee96bb497dd087b6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 17:39:28 -0800 Subject: [PATCH 234/416] Use SCAN_INTERVAL instead of Throttle for google travel time (#31420) The documentation for google_travel_time was at odds with the implementation. The documentation stated a default scan time of 5 minutes, but the implementation was using Throttle which resulted in the sensor updating at a maximum rate of one API call every 5 minutes. This was especially at odds with a given example at the end of the documentation, which showed updating the sensor every 2 minutes during commute times. This change brings the implementation in line with the docs by adopting the `SCAN_INTERVAL` constant set to the stated default of 5 minutes and removing the Throttle. --- homeassistant/components/google_travel_time/sensor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py index 213f773fb60..dd7d9bf8585 100644 --- a/homeassistant/components/google_travel_time/sensor.py +++ b/homeassistant/components/google_travel_time/sensor.py @@ -19,7 +19,6 @@ from homeassistant.const import ( from homeassistant.helpers import location import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -33,7 +32,7 @@ CONF_TRAVEL_MODE = "travel_mode" DEFAULT_NAME = "Google Travel Time" -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) +SCAN_INTERVAL = timedelta(minutes=5) ALL_LANGUAGES = [ "ar", @@ -256,7 +255,6 @@ class GoogleTravelTimeSensor(Entity): """Return the unit this state is expressed in.""" return self._unit_of_measurement - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from Google.""" options_copy = self._options.copy() From 1d3647e6a16a1826007b32af7fdc6a44cb3938c0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 17:39:59 -0800 Subject: [PATCH 235/416] =?UTF-8?q?Make=20gen=5Frequirements=5Fall.py=20ca?= =?UTF-8?q?se=20insensitive=20for=20ignored=20pack=E2=80=A6=20(#30885)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- script/gen_requirements_all.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 2b7fe8226b2..243490499c3 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -135,7 +135,7 @@ def gather_recursive_requirements(domain, seen=None): def comment_requirement(req): """Comment out requirement. Some don't install on all systems.""" - return any(ign in req for ign in COMMENT_REQUIREMENTS) + return any(ign.lower() in req.lower() for ign in COMMENT_REQUIREMENTS) def gather_modules(): From eac1f029e584fa46e09b8e61cfed8fed3380396f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 17:40:58 -0800 Subject: [PATCH 236/416] Allow string values on zwave.set_node_value (#31061) * Allow string values on zwave.set_node_value This allows for: * Accessing longer value_ids. In some cases, value ids in z-wave nodes are very large (17 digits in my case). Passing them as int does not seem to work well (python probably truncates the number), but passing them as string works fine * Changing color values, which are represented as hex string reformat test * update services.yaml with string set_node_value --- homeassistant/components/zwave/__init__.py | 4 ++-- homeassistant/components/zwave/services.yaml | 4 ++-- tests/components/zwave/test_init.py | 25 ++++++++++++++++++++ 3 files changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index ba7e26ee58c..1491c10777f 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -126,8 +126,8 @@ SET_CONFIG_PARAMETER_SCHEMA = vol.Schema( SET_NODE_VALUE_SCHEMA = vol.Schema( { vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), - vol.Required(const.ATTR_VALUE_ID): vol.Coerce(int), - vol.Required(const.ATTR_CONFIG_VALUE): vol.Coerce(int), + vol.Required(const.ATTR_VALUE_ID): vol.Any(vol.Coerce(int), cv.string), + vol.Required(const.ATTR_CONFIG_VALUE): vol.Any(vol.Coerce(int), cv.string), } ) diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml index 9d3d2b0cadf..d908941fb92 100644 --- a/homeassistant/components/zwave/services.yaml +++ b/homeassistant/components/zwave/services.yaml @@ -75,9 +75,9 @@ set_node_value: node_id: description: Node id of the device to set the value on (integer). value_id: - description: Value id of the value to set (integer). + description: Value id of the value to set (integer or string). value: - description: Value to set (integer). + description: Value to set (integer or string). refresh_node_value: description: Refresh the value for a given value_id on a Z-Wave device. diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index a8f72d2105c..4d358bde770 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -1797,6 +1797,31 @@ class TestZWaveServices(unittest.TestCase): assert self.zwave_network.nodes[14].values[12].data == 2 + def test_set_node_value_with_long_id_and_text_value(self): + """Test zwave set_node_value service.""" + value = MockValue( + index=87512398541236578, + command_class=const.COMMAND_CLASS_SWITCH_COLOR, + data="#ff0000", + ) + node = MockNode(node_id=14, command_classes=[const.COMMAND_CLASS_SWITCH_COLOR]) + node.values = {87512398541236578: value} + node.get_values.return_value = node.values + self.zwave_network.nodes = {14: node} + + self.hass.services.call( + "zwave", + "set_node_value", + { + const.ATTR_NODE_ID: 14, + const.ATTR_VALUE_ID: "87512398541236578", + const.ATTR_CONFIG_VALUE: "#00ff00", + }, + ) + self.hass.block_till_done() + + assert self.zwave_network.nodes[14].values[87512398541236578].data == "#00ff00" + def test_refresh_node_value(self): """Test zwave refresh_node_value service.""" node = MockNode( From c4ed2ecb6169621aeb69bc6710ea61ac56ca2b69 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 17:42:18 -0800 Subject: [PATCH 237/416] Add node_def_id for ISY994i wrapped X10 modules (#31815) --- homeassistant/components/isy994/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 6615fc6c569..ebd1b0dbbb2 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -142,6 +142,7 @@ NODE_FILTERS = { "AlertModuleArmed", "Siren", "Siren_ADV", + "X10", ], "insteon_type": ["2.", "9.10.", "9.11.", "113."], }, From 2316f7ace4f1c081211cf8a32c9b82329292e223 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 17:43:12 -0800 Subject: [PATCH 238/416] Add edl21 component for SML-based smart meters (#27962) * Add edl21 component for SML-based smart meters * edl21: Remove unused variable * [edl21] Add 1 minute throttle to the sensor * Update homeassistant/components/edl21/manifest.json Fix documentation URL Co-Authored-By: Paulus Schoutsen * edl21: Move imports to top * edl21: Remove special case for STATE_UNKNOWN, which replicated default behavior * edl21: Implement blacklist for and warn about unhandled OBIS values * edl21: Make blacklist global * edl21: Add filter to issues URL Co-Authored-By: Paulus Schoutsen * edl21: Rename device to entity * edl21: Don't schedule async_add_entities * edl21: Use dispatcher, implement own throttling mechanism * edl21: Simplify keeping track of known obis * edl21: Use whitelist for state attributes * edl21: Remove dispatcher on shutdown * edl21: Convert state attributes to snakecase * edl21: Annotate handle_telegram with @callback * edl21: Call async_write_ha_state instead of schedule_update_ha_state Co-authored-by: David Straub Co-authored-by: Paulus Schoutsen --- .coveragerc | 1 + CODEOWNERS | 1 + homeassistant/components/edl21/__init__.py | 1 + homeassistant/components/edl21/manifest.json | 12 ++ homeassistant/components/edl21/sensor.py | 196 +++++++++++++++++++ requirements_all.txt | 3 + 6 files changed, 214 insertions(+) create mode 100644 homeassistant/components/edl21/__init__.py create mode 100644 homeassistant/components/edl21/manifest.json create mode 100644 homeassistant/components/edl21/sensor.py diff --git a/.coveragerc b/.coveragerc index 221b43998c4..eef39ded2bf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -180,6 +180,7 @@ omit = homeassistant/components/ecobee/weather.py homeassistant/components/econet/* homeassistant/components/ecovacs/* + homeassistant/components/edl21/* homeassistant/components/eddystone_temperature/sensor.py homeassistant/components/edimax/switch.py homeassistant/components/egardia/* diff --git a/CODEOWNERS b/CODEOWNERS index 8f821f43fec..8af5a8216a6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -90,6 +90,7 @@ homeassistant/components/dynalite/* @ziv1234 homeassistant/components/dyson/* @etheralm homeassistant/components/ecobee/* @marthoc homeassistant/components/ecovacs/* @OverloadUT +homeassistant/components/edl21/* @mtdcr homeassistant/components/egardia/* @jeroenterheerdt homeassistant/components/eight_sleep/* @mezz64 homeassistant/components/elgato/* @frenck diff --git a/homeassistant/components/edl21/__init__.py b/homeassistant/components/edl21/__init__.py new file mode 100644 index 00000000000..f1cd5984744 --- /dev/null +++ b/homeassistant/components/edl21/__init__.py @@ -0,0 +1 @@ +"""The edl21 component.""" diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json new file mode 100644 index 00000000000..313ac2c262e --- /dev/null +++ b/homeassistant/components/edl21/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "edl21", + "name": "EDL21", + "documentation": "https://www.home-assistant.io/integrations/edl21", + "requirements": [ + "pysml==0.0.2" + ], + "dependencies": [], + "codeowners": [ + "@mtdcr" + ] +} diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py new file mode 100644 index 00000000000..a4e0ca734fa --- /dev/null +++ b/homeassistant/components/edl21/sensor.py @@ -0,0 +1,196 @@ +"""Support for EDL21 Smart Meters.""" + +from datetime import timedelta +import logging + +from sml import SmlGetListResponse +from sml.asyncio import SmlProtocol +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import Optional +from homeassistant.util.dt import utcnow + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "edl21" +CONF_SERIAL_PORT = "serial_port" +ICON_POWER = "mdi:flash" +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) +SIGNAL_EDL21_TELEGRAM = "edl21_telegram" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_SERIAL_PORT): cv.string}) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the EDL21 sensor.""" + hass.data[DOMAIN] = EDL21(hass, config, async_add_entities) + await hass.data[DOMAIN].connect() + + +class EDL21: + """EDL21 handles telegrams sent by a compatible smart meter.""" + + # OBIS format: A-B:C.D.E*F + _OBIS_NAMES = { + # A=1: Electricity + # C=0: General purpose objects + "1-0:0.0.9*255": "Electricity ID", + # C=1: Active power + + # D=8: Time integral 1 + # E=0: Total + "1-0:1.8.0*255": "Positive active energy total", + # E=1: Rate 1 + "1-0:1.8.1*255": "Positive active energy in tariff T1", + # E=2: Rate 2 + "1-0:1.8.2*255": "Positive active energy in tariff T2", + # D=17: Time integral 7 + # E=0: Total + "1-0:1.17.0*255": "Last signed positive active energy total", + # C=15: Active power absolute + # D=7: Instantaneous value + # E=0: Total + "1-0:15.7.0*255": "Absolute active instantaneous power", + # C=16: Active power sum + # D=7: Instantaneous value + # E=0: Total + "1-0:16.7.0*255": "Sum active instantaneous power", + } + _OBIS_BLACKLIST = { + # A=129: Manufacturer specific + "129-129:199.130.3*255", # Iskraemeco: Manufacturer + "129-129:199.130.5*255", # Iskraemeco: Public Key + } + + def __init__(self, hass, config, async_add_entities) -> None: + """Initialize an EDL21 object.""" + self._registered_obis = set() + self._hass = hass + self._async_add_entities = async_add_entities + self._proto = SmlProtocol(config[CONF_SERIAL_PORT]) + self._proto.add_listener(self.event, ["SmlGetListResponse"]) + + async def connect(self): + """Connect to an EDL21 reader.""" + await self._proto.connect(self._hass.loop) + + def event(self, message_body) -> None: + """Handle events from pysml.""" + assert isinstance(message_body, SmlGetListResponse) + + new_entities = [] + for telegram in message_body.get("valList", []): + obis = telegram.get("objName") + if not obis: + continue + + if obis in self._registered_obis: + async_dispatcher_send(self._hass, SIGNAL_EDL21_TELEGRAM, telegram) + else: + name = self._OBIS_NAMES.get(obis) + if name: + new_entities.append(EDL21Entity(obis, name, telegram)) + self._registered_obis.add(obis) + elif obis not in self._OBIS_BLACKLIST: + _LOGGER.warning( + "Unhandled sensor %s detected. Please report at " + 'https://github.com/home-assistant/home-assistant/issues?q=is%%3Aissue+label%%3A"integration%%3A+edl21"+', + obis, + ) + self._OBIS_BLACKLIST.add(obis) + + if new_entities: + self._async_add_entities(new_entities, update_before_add=True) + + +class EDL21Entity(Entity): + """Entity reading values from EDL21 telegram.""" + + def __init__(self, obis, name, telegram): + """Initialize an EDL21Entity.""" + self._obis = obis + self._name = name + self._telegram = telegram + self._min_time = MIN_TIME_BETWEEN_UPDATES + self._last_update = utcnow() + self._state_attrs = { + "status": "status", + "valTime": "val_time", + "scaler": "scaler", + "valueSignature": "value_signature", + } + self._async_remove_dispatcher = None + + async def async_added_to_hass(self): + """Run when entity about to be added to hass.""" + + @callback + def handle_telegram(telegram): + """Update attributes from last received telegram for this object.""" + if self._obis != telegram.get("objName"): + return + if self._telegram == telegram: + return + + now = utcnow() + if now - self._last_update < self._min_time: + return + + self._telegram = telegram + self._last_update = now + self.async_write_ha_state() + + self._async_remove_dispatcher = async_dispatcher_connect( + self.hass, SIGNAL_EDL21_TELEGRAM, handle_telegram + ) + + async def async_will_remove_from_hass(self): + """Run when entity will be removed from hass.""" + if self._async_remove_dispatcher: + self._async_remove_dispatcher() + + @property + def should_poll(self) -> bool: + """Do not poll.""" + return False + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return self._obis + + @property + def name(self) -> Optional[str]: + """Return a name.""" + return self._name + + @property + def state(self) -> str: + """Return the value of the last received telegram.""" + return self._telegram.get("value") + + @property + def device_state_attributes(self): + """Enumerate supported attributes.""" + return { + self._state_attrs[k]: v + for k, v in self._telegram.items() + if k in self._state_attrs + } + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._telegram.get("unit") + + @property + def icon(self): + """Return an icon.""" + return ICON_POWER diff --git a/requirements_all.txt b/requirements_all.txt index 121646cae11..fad600e918d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1529,6 +1529,9 @@ pysmartthings==0.7.0 # homeassistant.components.smarty pysmarty==0.8 +# homeassistant.components.edl21 +pysml==0.0.2 + # homeassistant.components.snmp pysnmp==4.4.12 From b848c97211dd2d187950fbfd430cc79fb0f31db9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 17:53:15 -0800 Subject: [PATCH 239/416] Add soundtouch attributes exposing multiroom zone info (#28298) * [soundtouch] workaround for API bug when removing multiple slaves from a zone at once * [soundtouch] added additional attributes exposing multiroom zone info * Fix update with slave entities * Add zone attributes test * Fix and clean up tests * Fix typo Co-authored-by: Martin Hjelmare --- .../components/soundtouch/media_player.py | 93 +++++++++- .../soundtouch/test_media_player.py | 168 +++++++++++------- 2 files changed, 194 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 71592e92c17..1d82c38d088 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -22,11 +22,13 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PORT, + EVENT_HOMEASSISTANT_START, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_UNAVAILABLE, ) +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from .const import ( @@ -47,6 +49,8 @@ MAP_STATUS = { } DATA_SOUNDTOUCH = "soundtouch" +ATTR_SOUNDTOUCH_GROUP = "soundtouch_group" +ATTR_SOUNDTOUCH_ZONE = "soundtouch_zone" SOUNDTOUCH_PLAY_EVERYWHERE = vol.Schema({vol.Required("master"): cv.entity_id}) @@ -103,7 +107,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): remote_config = {"id": "ha.component.soundtouch", "host": host, "port": port} bose_soundtouch_entity = SoundTouchDevice(None, remote_config) hass.data[DATA_SOUNDTOUCH].append(bose_soundtouch_entity) - add_entities([bose_soundtouch_entity]) + add_entities([bose_soundtouch_entity], True) else: name = config.get(CONF_NAME) remote_config = { @@ -113,7 +117,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): } bose_soundtouch_entity = SoundTouchDevice(name, remote_config) hass.data[DATA_SOUNDTOUCH].append(bose_soundtouch_entity) - add_entities([bose_soundtouch_entity]) + add_entities([bose_soundtouch_entity], True) def service_handle(service): """Handle the applying of a service.""" @@ -191,9 +195,10 @@ class SoundTouchDevice(MediaPlayerDevice): self._name = self._device.config.name else: self._name = name - self._status = self._device.status() - self._volume = self._device.volume() + self._status = None + self._volume = None self._config = config + self._zone = None @property def config(self): @@ -209,6 +214,7 @@ class SoundTouchDevice(MediaPlayerDevice): """Retrieve the latest data.""" self._status = self._device.status() self._volume = self._device.volume() + self._zone = self.get_zone_info() @property def volume_level(self): @@ -317,6 +323,18 @@ class SoundTouchDevice(MediaPlayerDevice): """Album name of current playing media.""" return self._status.album + async def async_added_to_hass(self): + """Populate zone info which requires entity_id.""" + + @callback + def async_update_on_start(event): + """Schedule an update when all platform entities have been added.""" + self.async_schedule_update_ha_state(True) + + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, async_update_on_start + ) + def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" _LOGGER.debug("Starting media with media_id: %s", media_id) @@ -369,7 +387,13 @@ class SoundTouchDevice(MediaPlayerDevice): _LOGGER.info( "Removing slaves from zone with master %s", self._device.config.name ) - self._device.remove_zone_slave([slave.device for slave in slaves]) + # SoundTouch API seems to have a bug and won't remove slaves if there are + # more than one in the payload. Therefore we have to loop over all slaves + # and remove them individually + for slave in slaves: + # make sure to not try to remove the master (aka current device) + if slave.entity_id != self.entity_id: + self._device.remove_zone_slave([slave.device]) def add_zone_slave(self, slaves): """ @@ -387,3 +411,62 @@ class SoundTouchDevice(MediaPlayerDevice): "Adding slaves to zone with master %s", self._device.config.name ) self._device.add_zone_slave([slave.device for slave in slaves]) + + @property + def device_state_attributes(self): + """Return entity specific state attributes.""" + attributes = {} + + if self._zone and "master" in self._zone: + attributes[ATTR_SOUNDTOUCH_ZONE] = self._zone + # Compatibility with how other components expose their groups (like SONOS). + # First entry is the master, others are slaves + group_members = [self._zone["master"]] + self._zone["slaves"] + attributes[ATTR_SOUNDTOUCH_GROUP] = group_members + + return attributes + + def get_zone_info(self): + """Return the current zone info.""" + zone_status = self._device.zone_status() + if not zone_status: + return None + + # Due to a bug in the SoundTouch API itself client devices do NOT return their + # siblings as part of the "slaves" list. Only the master has the full list of + # slaves for some reason. To compensate for this shortcoming we have to fetch + # the zone info from the master when the current device is a slave until this is + # fixed in the SoundTouch API or libsoundtouch, or of course until somebody has a + # better idea on how to fix this + if zone_status.is_master: + return self._build_zone_info(self.entity_id, zone_status.slaves) + + master_instance = self._get_instance_by_ip(zone_status.master_ip) + master_zone_status = master_instance.device.zone_status() + return self._build_zone_info( + master_instance.entity_id, master_zone_status.slaves + ) + + def _get_instance_by_ip(self, ip_address): + """Search and return a SoundTouchDevice instance by it's IP address.""" + for instance in self.hass.data[DATA_SOUNDTOUCH]: + if instance and instance.config["host"] == ip_address: + return instance + return None + + def _build_zone_info(self, master, zone_slaves): + """Build the exposed zone attributes.""" + slaves = [] + + for slave in zone_slaves: + slave_instance = self._get_instance_by_ip(slave.device_ip) + if slave_instance: + slaves.append(slave_instance.entity_id) + + attributes = { + "master": master, + "is_master": master == self.entity_id, + "slaves": slaves, + } + + return attributes diff --git a/tests/components/soundtouch/test_media_player.py b/tests/components/soundtouch/test_media_player.py index b18f9efda97..e69cec12ba3 100644 --- a/tests/components/soundtouch/test_media_player.py +++ b/tests/components/soundtouch/test_media_player.py @@ -19,7 +19,11 @@ from homeassistant.components.media_player.const import ( ) from homeassistant.components.soundtouch import media_player as soundtouch from homeassistant.components.soundtouch.const import DOMAIN -from homeassistant.components.soundtouch.media_player import DATA_SOUNDTOUCH +from homeassistant.components.soundtouch.media_player import ( + ATTR_SOUNDTOUCH_GROUP, + ATTR_SOUNDTOUCH_ZONE, + DATA_SOUNDTOUCH, +) from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING from homeassistant.helpers.discovery import async_load_platform from homeassistant.setup import async_setup_component @@ -154,9 +158,9 @@ def _mocked_presets(*args, **kwargs): class MockPreset(Preset): """Mock preset.""" - def __init__(self, id): + def __init__(self, id_): """Init the class.""" - self._id = id + self._id = id_ self._name = "preset" @@ -318,8 +322,8 @@ async def test_playing_media(mocked_status, mocked_volume, hass, one_device): await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.state == STATE_PLAYING @@ -336,8 +340,8 @@ async def test_playing_unknown_media(mocked_status, mocked_volume, hass, one_dev await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.state == STATE_PLAYING @@ -349,8 +353,8 @@ async def test_playing_radio(mocked_status, mocked_volume, hass, one_device): await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.state == STATE_PLAYING @@ -363,8 +367,8 @@ async def test_get_volume_level(mocked_status, mocked_volume, hass, one_device): await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.attributes["volume_level"] == 0.12 @@ -376,8 +380,8 @@ async def test_get_state_off(mocked_status, mocked_volume, hass, one_device): await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.state == STATE_OFF @@ -389,8 +393,8 @@ async def test_get_state_pause(mocked_status, mocked_volume, hass, one_device): await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.state == STATE_PAUSED @@ -402,8 +406,8 @@ async def test_is_muted(mocked_status, mocked_volume, hass, one_device): await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.attributes["is_volume_muted"] @@ -414,8 +418,8 @@ async def test_media_commands(mocked_status, mocked_volume, hass, one_device): await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 entity_1_state = hass.states.get("media_player.soundtouch_1") assert entity_1_state.attributes["supported_features"] == 18365 @@ -429,13 +433,13 @@ async def test_should_turn_off( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", "turn_off", {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_status.call_count == 2 + assert mocked_status.call_count == 3 assert mocked_power_off.call_count == 1 @@ -448,13 +452,13 @@ async def test_should_turn_on( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", "turn_on", {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_status.call_count == 2 + assert mocked_status.call_count == 3 assert mocked_power_on.call_count == 1 @@ -466,13 +470,13 @@ async def test_volume_up( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", "volume_up", {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_volume.call_count == 2 + assert mocked_volume.call_count == 3 assert mocked_volume_up.call_count == 1 @@ -484,13 +488,13 @@ async def test_volume_down( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", "volume_down", {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_volume.call_count == 2 + assert mocked_volume.call_count == 3 assert mocked_volume_down.call_count == 1 @@ -502,8 +506,8 @@ async def test_set_volume_level( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", @@ -511,7 +515,7 @@ async def test_set_volume_level( {"entity_id": "media_player.soundtouch_1", "volume_level": 0.17}, True, ) - assert mocked_volume.call_count == 2 + assert mocked_volume.call_count == 3 mocked_set_volume.assert_called_with(17) @@ -521,8 +525,8 @@ async def test_mute(mocked_mute, mocked_status, mocked_volume, hass, one_device) await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", @@ -530,7 +534,7 @@ async def test_mute(mocked_mute, mocked_status, mocked_volume, hass, one_device) {"entity_id": "media_player.soundtouch_1", "is_volume_muted": True}, True, ) - assert mocked_volume.call_count == 2 + assert mocked_volume.call_count == 3 assert mocked_mute.call_count == 1 @@ -540,13 +544,13 @@ async def test_play(mocked_play, mocked_status, mocked_volume, hass, one_device) await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", "media_play", {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_status.call_count == 2 + assert mocked_status.call_count == 3 assert mocked_play.call_count == 1 @@ -556,13 +560,13 @@ async def test_pause(mocked_pause, mocked_status, mocked_volume, hass, one_devic await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", "media_pause", {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_status.call_count == 2 + assert mocked_status.call_count == 3 assert mocked_pause.call_count == 1 @@ -574,8 +578,8 @@ async def test_play_pause( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", @@ -583,7 +587,7 @@ async def test_play_pause( {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_status.call_count == 2 + assert mocked_status.call_count == 3 assert mocked_play_pause.call_count == 1 @@ -601,8 +605,8 @@ async def test_next_previous_track( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", @@ -610,7 +614,7 @@ async def test_next_previous_track( {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_status.call_count == 2 + assert mocked_status.call_count == 3 assert mocked_next_track.call_count == 1 await hass.services.async_call( @@ -619,7 +623,7 @@ async def test_next_previous_track( {"entity_id": "media_player.soundtouch_1"}, True, ) - assert mocked_status.call_count == 3 + assert mocked_status.call_count == 4 assert mocked_previous_track.call_count == 1 @@ -632,8 +636,8 @@ async def test_play_media( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", @@ -670,8 +674,8 @@ async def test_play_media_url( await setup_soundtouch(hass, DEVICE_1_CONFIG) assert one_device.call_count == 1 - assert mocked_status.call_count == 1 - assert mocked_volume.call_count == 1 + assert mocked_status.call_count == 2 + assert mocked_volume.call_count == 2 await hass.services.async_call( "media_player", @@ -695,8 +699,8 @@ async def test_play_everywhere( await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) assert mocked_device.call_count == 2 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + assert mocked_status.call_count == 4 + assert mocked_volume.call_count == 4 # one master, one slave => create zone await hass.services.async_call( @@ -740,8 +744,8 @@ async def test_create_zone( await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) assert mocked_device.call_count == 2 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + assert mocked_status.call_count == 4 + assert mocked_volume.call_count == 4 # one master, one slave => create zone await hass.services.async_call( @@ -783,8 +787,8 @@ async def test_remove_zone_slave( await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) assert mocked_device.call_count == 2 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + assert mocked_status.call_count == 4 + assert mocked_volume.call_count == 4 # remove one slave await hass.services.async_call( @@ -826,8 +830,8 @@ async def test_add_zone_slave( await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) assert mocked_device.call_count == 2 - assert mocked_status.call_count == 2 - assert mocked_volume.call_count == 2 + assert mocked_status.call_count == 4 + assert mocked_volume.call_count == 4 # add one slave await hass.services.async_call( @@ -858,3 +862,43 @@ async def test_add_zone_slave( True, ) assert mocked_add_zone_slave.call_count == 1 + + +@patch("libsoundtouch.device.SoundTouchDevice.create_zone") +async def test_zone_attributes( + mocked_create_zone, mocked_status, mocked_volume, hass, two_zones, +): + """Test play everywhere.""" + mocked_device = two_zones + await setup_soundtouch(hass, [DEVICE_1_CONFIG, DEVICE_2_CONFIG]) + + assert mocked_device.call_count == 2 + assert mocked_status.call_count == 4 + assert mocked_volume.call_count == 4 + + entity_1_state = hass.states.get("media_player.soundtouch_1") + assert entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["is_master"] + assert ( + entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["master"] + == "media_player.soundtouch_1" + ) + assert entity_1_state.attributes[ATTR_SOUNDTOUCH_ZONE]["slaves"] == [ + "media_player.soundtouch_2" + ] + assert entity_1_state.attributes[ATTR_SOUNDTOUCH_GROUP] == [ + "media_player.soundtouch_1", + "media_player.soundtouch_2", + ] + entity_2_state = hass.states.get("media_player.soundtouch_2") + assert not entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["is_master"] + assert ( + entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["master"] + == "media_player.soundtouch_1" + ) + assert entity_2_state.attributes[ATTR_SOUNDTOUCH_ZONE]["slaves"] == [ + "media_player.soundtouch_2" + ] + assert entity_2_state.attributes[ATTR_SOUNDTOUCH_GROUP] == [ + "media_player.soundtouch_1", + "media_player.soundtouch_2", + ] From bfe1e8fef3c06a11e6978f1b4bd0b6c90b68d70b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 17:54:00 -0800 Subject: [PATCH 240/416] fix double tab and match events. (#32108) this propose makes some logic to the device triggers, matching the events for the aqara cube. this also fixes the double tap function on side 2 --- .../components/deconz/device_trigger.py | 72 +++++++++---------- 1 file changed, 36 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/deconz/device_trigger.py b/homeassistant/components/deconz/device_trigger.py index e8322c18e9a..8ae0394f935 100644 --- a/homeassistant/components/deconz/device_trigger.py +++ b/homeassistant/components/deconz/device_trigger.py @@ -153,48 +153,48 @@ TRADFRI_WIRELESS_DIMMER = { AQARA_CUBE_MODEL = "lumi.sensor_cube" AQARA_CUBE_MODEL_ALT1 = "lumi.sensor_cube.aqgl01" AQARA_CUBE = { - (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_2): {CONF_EVENT: 6002}, - (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_3): {CONF_EVENT: 3002}, - (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_4): {CONF_EVENT: 4002}, - (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_5): {CONF_EVENT: 1002}, - (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_6): {CONF_EVENT: 5002}, - (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_1): {CONF_EVENT: 2006}, - (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_3): {CONF_EVENT: 3006}, - (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_4): {CONF_EVENT: 4006}, - (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_5): {CONF_EVENT: 1006}, - (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_6): {CONF_EVENT: 5006}, - (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_1): {CONF_EVENT: 2003}, - (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_2): {CONF_EVENT: 6003}, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_2): {CONF_EVENT: 2001}, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_3): {CONF_EVENT: 3001}, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_4): {CONF_EVENT: 4001}, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_5): {CONF_EVENT: 5001}, + (CONF_ROTATE_FROM_SIDE_1, CONF_SIDE_6): {CONF_EVENT: 6001}, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_1): {CONF_EVENT: 1002}, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_3): {CONF_EVENT: 3002}, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_4): {CONF_EVENT: 4002}, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_5): {CONF_EVENT: 5002}, + (CONF_ROTATE_FROM_SIDE_2, CONF_SIDE_6): {CONF_EVENT: 6002}, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_1): {CONF_EVENT: 1003}, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_2): {CONF_EVENT: 2003}, (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_4): {CONF_EVENT: 4003}, - (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_5): {CONF_EVENT: 1003}, - (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_6): {CONF_EVENT: 5003}, - (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_1): {CONF_EVENT: 2004}, - (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_2): {CONF_EVENT: 6004}, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_5): {CONF_EVENT: 5003}, + (CONF_ROTATE_FROM_SIDE_3, CONF_SIDE_6): {CONF_EVENT: 6003}, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_1): {CONF_EVENT: 1004}, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_2): {CONF_EVENT: 2004}, (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_3): {CONF_EVENT: 3004}, - (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_5): {CONF_EVENT: 1004}, - (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_6): {CONF_EVENT: 5004}, - (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_1): {CONF_EVENT: 2001}, - (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_2): {CONF_EVENT: 6001}, - (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_3): {CONF_EVENT: 3001}, - (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_4): {CONF_EVENT: 4001}, - (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_6): {CONF_EVENT: 5001}, - (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_1): {CONF_EVENT: 2005}, - (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_2): {CONF_EVENT: 6005}, - (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_3): {CONF_EVENT: 3005}, - (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_4): {CONF_EVENT: 4005}, - (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_5): {CONF_EVENT: 1005}, - (CONF_MOVE, CONF_SIDE_1): {CONF_EVENT: 2000}, - (CONF_MOVE, CONF_SIDE_2): {CONF_EVENT: 6000}, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_5): {CONF_EVENT: 5004}, + (CONF_ROTATE_FROM_SIDE_4, CONF_SIDE_6): {CONF_EVENT: 6004}, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_1): {CONF_EVENT: 1005}, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_2): {CONF_EVENT: 2005}, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_3): {CONF_EVENT: 3005}, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_4): {CONF_EVENT: 4005}, + (CONF_ROTATE_FROM_SIDE_5, CONF_SIDE_6): {CONF_EVENT: 6005}, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_1): {CONF_EVENT: 1006}, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_2): {CONF_EVENT: 2006}, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_3): {CONF_EVENT: 3006}, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_4): {CONF_EVENT: 4006}, + (CONF_ROTATE_FROM_SIDE_6, CONF_SIDE_5): {CONF_EVENT: 5006}, + (CONF_MOVE, CONF_SIDE_1): {CONF_EVENT: 1000}, + (CONF_MOVE, CONF_SIDE_2): {CONF_EVENT: 2000}, (CONF_MOVE, CONF_SIDE_3): {CONF_EVENT: 3000}, (CONF_MOVE, CONF_SIDE_4): {CONF_EVENT: 4000}, - (CONF_MOVE, CONF_SIDE_5): {CONF_EVENT: 1000}, - (CONF_MOVE, CONF_SIDE_6): {CONF_EVENT: 5000}, - (CONF_DOUBLE_TAP, CONF_SIDE_1): {CONF_EVENT: 2002}, - (CONF_DOUBLE_TAP, CONF_SIDE_2): {CONF_EVENT: 6002}, + (CONF_MOVE, CONF_SIDE_5): {CONF_EVENT: 5000}, + (CONF_MOVE, CONF_SIDE_6): {CONF_EVENT: 6000}, + (CONF_DOUBLE_TAP, CONF_SIDE_1): {CONF_EVENT: 1001}, + (CONF_DOUBLE_TAP, CONF_SIDE_2): {CONF_EVENT: 2002}, (CONF_DOUBLE_TAP, CONF_SIDE_3): {CONF_EVENT: 3003}, (CONF_DOUBLE_TAP, CONF_SIDE_4): {CONF_EVENT: 4004}, - (CONF_DOUBLE_TAP, CONF_SIDE_5): {CONF_EVENT: 1001}, - (CONF_DOUBLE_TAP, CONF_SIDE_6): {CONF_EVENT: 5005}, + (CONF_DOUBLE_TAP, CONF_SIDE_5): {CONF_EVENT: 5005}, + (CONF_DOUBLE_TAP, CONF_SIDE_6): {CONF_EVENT: 6006}, (CONF_AWAKE, ""): {CONF_GESTURE: 0}, (CONF_SHAKE, ""): {CONF_GESTURE: 1}, (CONF_FREE_FALL, ""): {CONF_GESTURE: 2}, From daff87fe5d41881df2b6fc5d96dea7bbe0870fa1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 18:03:51 -0800 Subject: [PATCH 241/416] Emoncms API now provides a Unit of Measurement (#32042) * Emoncms API provides a Unit The EmonCMS API has been amended to include a 'unit' as part of it's payload. By using this information, all the sensors can be created without the need for individual sensors to be setup by type. The change is backward compatible so if a unit type has been specified in the configuration, this will be used by default. If no unit is pecified either by the Home Assistant configuration, or the Emoncms API, then the default of W will be used as before. * Update sensor.py Check the 'unit' key is in the API call. Older systems may not have that key in the payload. * Modified approach with new configuration item * Removed new config item Removed the configuration item. The integration attempts to get the unit from the API. If this fails *or* the unit key of the API is blank, either the specified unit, or the default will be used. If approved, documentation will be updated. * Update homeassistant/components/emoncms/sensor.py Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> * Update homeassistant/components/emoncms/sensor.py Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> * Apply suggestions from code review * Apply suggestions from code review v2 * Update homeassistant/components/emoncms/sensor.py Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> * Update sensor.py Update `config_unit` Co-authored-by: springstan <46536646+springstan@users.noreply.github.com> --- CODEOWNERS | 1 + homeassistant/components/emoncms/manifest.json | 2 +- homeassistant/components/emoncms/sensor.py | 9 +++++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 8af5a8216a6..d76b0abc8a8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -96,6 +96,7 @@ homeassistant/components/eight_sleep/* @mezz64 homeassistant/components/elgato/* @frenck homeassistant/components/elv/* @majuss homeassistant/components/emby/* @mezz64 +homeassistant/components/emoncms/* @borpin homeassistant/components/enigma2/* @fbradyirl homeassistant/components/enocean/* @bdurrer homeassistant/components/entur_public_transport/* @hfurubotten diff --git a/homeassistant/components/emoncms/manifest.json b/homeassistant/components/emoncms/manifest.json index 83833d4f79b..b9c012d6e73 100644 --- a/homeassistant/components/emoncms/manifest.json +++ b/homeassistant/components/emoncms/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/integrations/emoncms", "requirements": [], "dependencies": [], - "codeowners": [] + "codeowners": ["@borpin"] } diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py index 4f214d697f3..c0754405840 100644 --- a/homeassistant/components/emoncms/sensor.py +++ b/homeassistant/components/emoncms/sensor.py @@ -37,7 +37,6 @@ CONF_SENSOR_NAMES = "sensor_names" DECIMALS = 2 DEFAULT_UNIT = POWER_WATT - MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) ONLY_INCL_EXCL_NONE = "only_include_exclude_or_none" @@ -73,7 +72,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): url = config.get(CONF_URL) sensorid = config.get(CONF_ID) value_template = config.get(CONF_VALUE_TEMPLATE) - unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + config_unit = config.get(CONF_UNIT_OF_MEASUREMENT) exclude_feeds = config.get(CONF_EXCLUDE_FEEDID) include_only_feeds = config.get(CONF_ONLY_INCLUDE_FEEDID) sensor_names = config.get(CONF_SENSOR_NAMES) @@ -105,6 +104,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if sensor_names is not None: name = sensor_names.get(int(elem["id"]), None) + unit = elem.get("unit") + if unit: + unit_of_measurement = unit + else: + unit_of_measurement = config_unit + sensors.append( EmonCmsSensor( hass, From 9a4aad1777f90efcfdd6fbca2bddecd71a73d531 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 18:09:17 -0800 Subject: [PATCH 242/416] Add async_setup_entry/async_unload_entry for remote platform (#31974) * add async_setup_entry for remote platform * add async_unload_entry for remote platform * Update __init__.py * Update __init__.py * Update __init__.py * Update __init__.py * Update __init__.py * Update __init__.py * Update __init__.py * Type Co-authored-by: Paulus Schoutsen --- homeassistant/components/remote/__init__.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index 3f8bded6a85..3a71ebb94d1 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -2,10 +2,11 @@ from datetime import timedelta import functools as ft import logging -from typing import Any, Iterable +from typing import Any, Iterable, cast import voluptuous as vol +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_TOGGLE, SERVICE_TURN_OFF, @@ -66,7 +67,9 @@ def is_on(hass: HomeAssistantType, entity_id: str) -> bool: async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Track states and offer events for remotes.""" - component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) + component = hass.data[DOMAIN] = EntityComponent( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) await component.async_setup(config) component.async_register_entity_service( @@ -109,6 +112,18 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: return True +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + return cast( + bool, await cast(EntityComponent, hass.data[DOMAIN]).async_setup_entry(entry) + ) + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await cast(EntityComponent, hass.data[DOMAIN]).async_unload_entry(entry) + + class RemoteDevice(ToggleEntity): """Representation of a remote.""" From 56cf4e54a996ab770a380d054c6cede3a4a13ef4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 18:14:45 -0800 Subject: [PATCH 243/416] Add github sensor latest tag attribute (#32360) * Add attribute latest release tag * add attrs = and only create attr Tag if exists made requested changes * change condition for _latest_release_tag to use self._github_data.latest_release_url * Correct changes * Update sensor.py * blackify --- homeassistant/components/github/sensor.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py index 96dfd4de58c..26199763036 100644 --- a/homeassistant/components/github/sensor.py +++ b/homeassistant/components/github/sensor.py @@ -22,6 +22,7 @@ CONF_REPOS = "repositories" ATTR_LATEST_COMMIT_MESSAGE = "latest_commit_message" ATTR_LATEST_COMMIT_SHA = "latest_commit_sha" +ATTR_LATEST_RELEASE_TAG = "latest_release_tag" ATTR_LATEST_RELEASE_URL = "latest_release_url" ATTR_LATEST_OPEN_ISSUE_URL = "latest_open_issue_url" ATTR_OPEN_ISSUES = "open_issues" @@ -78,6 +79,7 @@ class GitHubSensor(Entity): self._repository_path = None self._latest_commit_message = None self._latest_commit_sha = None + self._latest_release_tag = None self._latest_release_url = None self._open_issue_count = None self._latest_open_issue_url = None @@ -109,7 +111,7 @@ class GitHubSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" - return { + attrs = { ATTR_PATH: self._repository_path, ATTR_NAME: self._name, ATTR_LATEST_COMMIT_MESSAGE: self._latest_commit_message, @@ -121,6 +123,9 @@ class GitHubSensor(Entity): ATTR_OPEN_PULL_REQUESTS: self._pull_request_count, ATTR_STARGAZERS: self._stargazers, } + if self._latest_release_tag is not None: + attrs[ATTR_LATEST_RELEASE_TAG] = self._latest_release_tag + return attrs @property def icon(self): @@ -136,6 +141,12 @@ class GitHubSensor(Entity): self._available = self._github_data.available self._latest_commit_message = self._github_data.latest_commit_message self._latest_commit_sha = self._github_data.latest_commit_sha + if self._github_data.latest_release_url is not None: + self._latest_release_tag = self._github_data.latest_release_url.split( + "tag/" + )[1] + else: + self._latest_release_tag = None self._latest_release_url = self._github_data.latest_release_url self._state = self._github_data.latest_commit_sha[0:7] self._open_issue_count = self._github_data.open_issue_count From 81810dd920ee9c7d1bb95cdd5f98afc7c8c1a19b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 18:23:00 -0800 Subject: [PATCH 244/416] Modernize WWLLN config flow (#32194) * Modernize WWLLN config flow * Code review * Update tests --- .../components/wwlln/.translations/en.json | 5 +- homeassistant/components/wwlln/__init__.py | 69 +++++------- homeassistant/components/wwlln/config_flow.py | 92 ++++++++-------- homeassistant/components/wwlln/const.py | 3 + .../components/wwlln/geo_location.py | 21 ++-- homeassistant/components/wwlln/strings.json | 5 +- tests/components/wwlln/test_config_flow.py | 102 +++++++++++------- 7 files changed, 152 insertions(+), 145 deletions(-) diff --git a/homeassistant/components/wwlln/.translations/en.json b/homeassistant/components/wwlln/.translations/en.json index 4200c4b4378..62d0dea656d 100644 --- a/homeassistant/components/wwlln/.translations/en.json +++ b/homeassistant/components/wwlln/.translations/en.json @@ -1,7 +1,8 @@ { "config": { - "error": { - "identifier_exists": "Location already registered" + "abort": { + "already_configured": "This location is already registered.", + "window_too_small": "A too-small window will cause Home Assistant to miss events." }, "step": { "user": { diff --git a/homeassistant/components/wwlln/__init__.py b/homeassistant/components/wwlln/__init__.py index 412efc904db..d896e16319c 100644 --- a/homeassistant/components/wwlln/__init__.py +++ b/homeassistant/components/wwlln/__init__.py @@ -1,24 +1,19 @@ """Support for World Wide Lightning Location Network.""" -import logging - from aiowwlln import Client import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT -from homeassistant.const import ( - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_RADIUS, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, -) +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.helpers import aiohttp_client, config_validation as cv -from .config_flow import configured_instances -from .const import CONF_WINDOW, DATA_CLIENT, DEFAULT_RADIUS, DEFAULT_WINDOW, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import ( + CONF_WINDOW, + DATA_CLIENT, + DEFAULT_RADIUS, + DEFAULT_WINDOW, + DOMAIN, + LOGGER, +) CONFIG_SCHEMA = vol.Schema( { @@ -28,7 +23,9 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_LONGITUDE): cv.longitude, vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): cv.positive_int, vol.Optional(CONF_WINDOW, default=DEFAULT_WINDOW): vol.All( - cv.time_period, cv.positive_timedelta + cv.time_period, + cv.positive_timedelta, + lambda value: value.total_seconds(), ), } ) @@ -44,36 +41,9 @@ async def async_setup(hass, config): conf = config[DOMAIN] - latitude = conf.get(CONF_LATITUDE, hass.config.latitude) - longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) - - identifier = f"{latitude}, {longitude}" - if identifier in configured_instances(hass): - return True - - if conf[CONF_WINDOW] < DEFAULT_WINDOW: - _LOGGER.warning( - "Setting a window smaller than %s seconds may cause Home Assistant \ - to miss events", - DEFAULT_WINDOW.total_seconds(), - ) - - if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: - unit_system = CONF_UNIT_SYSTEM_IMPERIAL - else: - unit_system = CONF_UNIT_SYSTEM_METRIC - hass.async_create_task( hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_LATITUDE: latitude, - CONF_LONGITUDE: longitude, - CONF_RADIUS: conf[CONF_RADIUS], - CONF_WINDOW: conf[CONF_WINDOW], - CONF_UNIT_SYSTEM: unit_system, - }, + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf ) ) @@ -82,6 +52,15 @@ async def async_setup(hass, config): async def async_setup_entry(hass, config_entry): """Set up the WWLLN as config entry.""" + if not config_entry.unique_id: + hass.config_entries.async_update_entry( + config_entry, + unique_id=( + f"{config_entry.data[CONF_LATITUDE]}, " + f"{config_entry.data[CONF_LONGITUDE]}" + ), + ) + hass.data[DOMAIN] = {} hass.data[DOMAIN][DATA_CLIENT] = {} @@ -112,7 +91,7 @@ async def async_migrate_entry(hass, config_entry): default_total_seconds = DEFAULT_WINDOW.total_seconds() - _LOGGER.debug("Migrating from version %s", version) + LOGGER.debug("Migrating from version %s", version) # 1 -> 2: Expanding the default window to 1 hour (if needed): if version == 1: @@ -120,6 +99,6 @@ async def async_migrate_entry(hass, config_entry): data[CONF_WINDOW] = default_total_seconds version = config_entry.version = 2 hass.config_entries.async_update_entry(config_entry, data=data) - _LOGGER.info("Migration to version %s successful", version) + LOGGER.info("Migration to version %s successful", version) return True diff --git a/homeassistant/components/wwlln/config_flow.py b/homeassistant/components/wwlln/config_flow.py index f9cd022f255..51b705b04ee 100644 --- a/homeassistant/components/wwlln/config_flow.py +++ b/homeassistant/components/wwlln/config_flow.py @@ -2,39 +2,28 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import ( - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_RADIUS, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_IMPERIAL, - CONF_UNIT_SYSTEM_METRIC, -) -from homeassistant.core import callback +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.helpers import config_validation as cv -from .const import CONF_WINDOW, DEFAULT_RADIUS, DEFAULT_WINDOW, DOMAIN +from .const import ( # pylint: disable=unused-import + CONF_WINDOW, + DEFAULT_RADIUS, + DEFAULT_WINDOW, + DOMAIN, + LOGGER, +) -@callback -def configured_instances(hass): - """Return a set of configured WWLLN instances.""" - return set( - "{0}, {1}".format(entry.data[CONF_LATITUDE], entry.data[CONF_LONGITUDE]) - for entry in hass.config_entries.async_entries(DOMAIN) - ) - - -@config_entries.HANDLERS.register(DOMAIN) -class WWLLNFlowHandler(config_entries.ConfigFlow): +class WWLLNFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a WWLLN config flow.""" VERSION = 2 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL - async def _show_form(self, errors=None): - """Show the form to the user.""" - data_schema = vol.Schema( + @property + def data_schema(self): + """Return the data schema for the user form.""" + return vol.Schema( { vol.Optional( CONF_LATITUDE, default=self.hass.config.latitude @@ -46,12 +35,26 @@ class WWLLNFlowHandler(config_entries.ConfigFlow): } ) + async def _show_form(self, errors=None): + """Show the form to the user.""" return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors or {} + step_id="user", data_schema=self.data_schema, errors=errors or {} ) async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" + default_window_seconds = DEFAULT_WINDOW.total_seconds() + if ( + CONF_WINDOW in import_config + and import_config[CONF_WINDOW] < default_window_seconds + ): + LOGGER.error( + "Refusing to use too-small window (%s < %s)", + import_config[CONF_WINDOW], + default_window_seconds, + ) + return self.async_abort(reason="window_too_small") + return await self.async_step_user(import_config) async def async_step_user(self, user_input=None): @@ -59,25 +62,22 @@ class WWLLNFlowHandler(config_entries.ConfigFlow): if not user_input: return await self._show_form() - identifier = "{0}, {1}".format( - user_input[CONF_LATITUDE], user_input[CONF_LONGITUDE] + latitude = user_input.get(CONF_LATITUDE, self.hass.config.latitude) + longitude = user_input.get(CONF_LONGITUDE, self.hass.config.longitude) + + identifier = f"{latitude}, {longitude}" + + await self.async_set_unique_id(identifier) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=identifier, + data={ + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_RADIUS: user_input.get(CONF_RADIUS, DEFAULT_RADIUS), + CONF_WINDOW: user_input.get( + CONF_WINDOW, DEFAULT_WINDOW.total_seconds() + ), + }, ) - if identifier in configured_instances(self.hass): - return await self._show_form({"base": "identifier_exists"}) - - if self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: - user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_IMPERIAL - else: - user_input[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_METRIC - - # When importing from `configuration.yaml`, we give the user - # flexibility by allowing the `window` parameter to be any type - # of time period. This will always return a timedelta; unfortunately, - # timedeltas aren't JSON-serializable, so we can't store them in a - # config entry as-is; instead, we save the total seconds as an int: - if CONF_WINDOW in user_input: - user_input[CONF_WINDOW] = user_input[CONF_WINDOW].total_seconds() - else: - user_input[CONF_WINDOW] = DEFAULT_WINDOW.total_seconds() - - return self.async_create_entry(title=identifier, data=user_input) diff --git a/homeassistant/components/wwlln/const.py b/homeassistant/components/wwlln/const.py index 141baf38cda..c21f30fdd59 100644 --- a/homeassistant/components/wwlln/const.py +++ b/homeassistant/components/wwlln/const.py @@ -1,5 +1,8 @@ """Define constants for the WWLLN integration.""" from datetime import timedelta +import logging + +LOGGER = logging.getLogger(__package__) DOMAIN = "wwlln" diff --git a/homeassistant/components/wwlln/geo_location.py b/homeassistant/components/wwlln/geo_location.py index e1ca47664d5..3e42f0245b2 100644 --- a/homeassistant/components/wwlln/geo_location.py +++ b/homeassistant/components/wwlln/geo_location.py @@ -1,6 +1,5 @@ """Support for WWLLN geo location events.""" from datetime import timedelta -import logging from aiowwlln.errors import WWLLNError @@ -10,7 +9,6 @@ from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, - CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL, LENGTH_KILOMETERS, LENGTH_MILES, @@ -23,9 +21,7 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.dt import utc_from_timestamp -from .const import CONF_WINDOW, DATA_CLIENT, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_WINDOW, DATA_CLIENT, DOMAIN, LOGGER ATTR_EXTERNAL_ID = "external_id" ATTR_PUBLICATION_DATE = "publication_date" @@ -49,7 +45,6 @@ async def async_setup_entry(hass, entry, async_add_entities): entry.data[CONF_LONGITUDE], entry.data[CONF_RADIUS], entry.data[CONF_WINDOW], - entry.data[CONF_UNIT_SYSTEM], ) await manager.async_init() @@ -66,7 +61,6 @@ class WWLLNEventManager: longitude, radius, window_seconds, - unit_system, ): """Initialize.""" self._async_add_entities = async_add_entities @@ -79,8 +73,7 @@ class WWLLNEventManager: self._strikes = {} self._window = timedelta(seconds=window_seconds) - self._unit_system = unit_system - if unit_system == CONF_UNIT_SYSTEM_IMPERIAL: + if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL: self._unit = LENGTH_MILES else: self._unit = LENGTH_KILOMETERS @@ -88,7 +81,7 @@ class WWLLNEventManager: @callback def _create_events(self, ids_to_create): """Create new geo location events.""" - _LOGGER.debug("Going to create %s", ids_to_create) + LOGGER.debug("Going to create %s", ids_to_create) events = [] for strike_id in ids_to_create: strike = self._strikes[strike_id] @@ -107,7 +100,7 @@ class WWLLNEventManager: @callback def _remove_events(self, ids_to_remove): """Remove old geo location events.""" - _LOGGER.debug("Going to remove %s", ids_to_remove) + LOGGER.debug("Going to remove %s", ids_to_remove) for strike_id in ids_to_remove: async_dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(strike_id)) @@ -123,18 +116,18 @@ class WWLLNEventManager: async def async_update(self): """Refresh data.""" - _LOGGER.debug("Refreshing WWLLN data") + LOGGER.debug("Refreshing WWLLN data") try: self._strikes = await self._client.within_radius( self._latitude, self._longitude, self._radius, - unit=self._unit_system, + unit=self._hass.config.units.name, window=self._window, ) except WWLLNError as err: - _LOGGER.error("Error while updating WWLLN data: %s", err) + LOGGER.error("Error while updating WWLLN data: %s", err) return new_strike_ids = set(self._strikes) diff --git a/homeassistant/components/wwlln/strings.json b/homeassistant/components/wwlln/strings.json index c0d768a010c..4385e70cd7b 100644 --- a/homeassistant/components/wwlln/strings.json +++ b/homeassistant/components/wwlln/strings.json @@ -11,8 +11,9 @@ } } }, - "error": { - "identifier_exists": "Location already registered" + "abort": { + "already_configured": "This location is already registered.", + "window_too_small": "A too-small window will cause Home Assistant to miss events." } } } diff --git a/tests/components/wwlln/test_config_flow.py b/tests/components/wwlln/test_config_flow.py index 8ddc25f8680..2894ae76571 100644 --- a/tests/components/wwlln/test_config_flow.py +++ b/tests/components/wwlln/test_config_flow.py @@ -1,6 +1,4 @@ """Define tests for the WWLLN config flow.""" -from datetime import timedelta - from asynctest import patch from homeassistant import data_entry_flow @@ -9,34 +7,34 @@ from homeassistant.components.wwlln import ( DATA_CLIENT, DOMAIN, async_setup_entry, - config_flow, -) -from homeassistant.const import ( - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_RADIUS, - CONF_UNIT_SYSTEM, ) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS + +from tests.common import MockConfigEntry async def test_duplicate_error(hass, config_entry): """Test that errors are shown when duplicates are added.""" conf = {CONF_LATITUDE: 39.128712, CONF_LONGITUDE: -104.9812612, CONF_RADIUS: 25} - config_entry.add_to_hass(hass) - flow = config_flow.WWLLNFlowHandler() - flow.hass = hass + MockConfigEntry( + domain=DOMAIN, unique_id="39.128712, -104.9812612", data=conf + ).add_to_hass(hass) - result = await flow.async_step_user(user_input=conf) - assert result["errors"] == {"base": "identifier_exists"} + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" async def test_show_form(hass): """Test that the form is served with no input.""" - flow = config_flow.WWLLNFlowHandler() - flow.hass = hass - - result = await flow.async_step_user(user_input=None) + 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"] == "user" @@ -44,46 +42,79 @@ async def test_show_form(hass): async def test_step_import(hass): """Test that the import step works.""" - # `configuration.yaml` will always return a timedelta for the `window` - # parameter, FYI: conf = { CONF_LATITUDE: 39.128712, CONF_LONGITUDE: -104.9812612, CONF_RADIUS: 25, - CONF_UNIT_SYSTEM: "metric", - CONF_WINDOW: timedelta(minutes=10), } - flow = config_flow.WWLLNFlowHandler() - flow.hass = hass + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) - result = await flow.async_step_import(import_config=conf) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "39.128712, -104.9812612" assert result["data"] == { CONF_LATITUDE: 39.128712, CONF_LONGITUDE: -104.9812612, CONF_RADIUS: 25, - CONF_UNIT_SYSTEM: "metric", - CONF_WINDOW: 600.0, + CONF_WINDOW: 3600.0, } +async def test_step_import_too_small_window(hass): + """Test that the import step with a too-small window is aborted.""" + conf = { + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + CONF_RADIUS: 25, + CONF_WINDOW: 60, + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "window_too_small" + + async def test_step_user(hass): """Test that the user step works.""" conf = {CONF_LATITUDE: 39.128712, CONF_LONGITUDE: -104.9812612, CONF_RADIUS: 25} - flow = config_flow.WWLLNFlowHandler() - flow.hass = hass + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "39.128712, -104.9812612" + assert result["data"] == { + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + CONF_RADIUS: 25, + CONF_WINDOW: 3600.0, + } + + +async def test_different_unit_system(hass): + """Test that the config flow picks up the HASS unit system.""" + conf = { + CONF_LATITUDE: 39.128712, + CONF_LONGITUDE: -104.9812612, + CONF_RADIUS: 25, + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) - result = await flow.async_step_user(user_input=conf) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "39.128712, -104.9812612" assert result["data"] == { CONF_LATITUDE: 39.128712, CONF_LONGITUDE: -104.9812612, CONF_RADIUS: 25, - CONF_UNIT_SYSTEM: "metric", CONF_WINDOW: 3600.0, } @@ -94,20 +125,19 @@ async def test_custom_window(hass): CONF_LATITUDE: 39.128712, CONF_LONGITUDE: -104.9812612, CONF_RADIUS: 25, - CONF_WINDOW: timedelta(hours=2), + CONF_WINDOW: 7200, } - flow = config_flow.WWLLNFlowHandler() - flow.hass = hass + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) - result = await flow.async_step_user(user_input=conf) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "39.128712, -104.9812612" assert result["data"] == { CONF_LATITUDE: 39.128712, CONF_LONGITUDE: -104.9812612, CONF_RADIUS: 25, - CONF_UNIT_SYSTEM: "metric", CONF_WINDOW: 7200, } From 81f99efda17d876c62462ff95cd95815888c8d73 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 21:42:07 -0800 Subject: [PATCH 245/416] Mock all invocations of coronavirus.get_cases (#32487) --- tests/components/coronavirus/conftest.py | 17 +++++++++++++++++ .../components/coronavirus/test_config_flow.py | 16 ++++------------ tests/components/coronavirus/test_init.py | 13 ++----------- 3 files changed, 23 insertions(+), 23 deletions(-) create mode 100644 tests/components/coronavirus/conftest.py diff --git a/tests/components/coronavirus/conftest.py b/tests/components/coronavirus/conftest.py new file mode 100644 index 00000000000..45d2a00e69d --- /dev/null +++ b/tests/components/coronavirus/conftest.py @@ -0,0 +1,17 @@ +"""Test helpers.""" + +from asynctest import Mock, patch +import pytest + + +@pytest.fixture(autouse=True) +def mock_cases(): + """Mock coronavirus cases.""" + with patch( + "coronavirus.get_cases", + return_value=[ + Mock(country="Netherlands", confirmed=10, recovered=8, deaths=1, current=1), + Mock(country="Germany", confirmed=1, recovered=0, deaths=0, current=0), + ], + ) as mock_get_cases: + yield mock_get_cases diff --git a/tests/components/coronavirus/test_config_flow.py b/tests/components/coronavirus/test_config_flow.py index ef04d0df07a..b7af2e343b2 100644 --- a/tests/components/coronavirus/test_config_flow.py +++ b/tests/components/coronavirus/test_config_flow.py @@ -1,6 +1,4 @@ """Test the Coronavirus config flow.""" -from asynctest import patch - from homeassistant import config_entries, setup from homeassistant.components.coronavirus.const import DOMAIN, OPTION_WORLDWIDE @@ -14,14 +12,9 @@ async def test_form(hass): assert result["type"] == "form" assert result["errors"] == {} - with patch("coronavirus.get_cases", return_value=[],), patch( - "homeassistant.components.coronavirus.async_setup", return_value=True - ) as mock_setup, patch( - "homeassistant.components.coronavirus.async_setup_entry", return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {"country": OPTION_WORLDWIDE}, - ) + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"country": OPTION_WORLDWIDE}, + ) assert result2["type"] == "create_entry" assert result2["title"] == "Worldwide" assert result2["result"].unique_id == OPTION_WORLDWIDE @@ -29,5 +22,4 @@ async def test_form(hass): "country": OPTION_WORLDWIDE, } await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + assert len(hass.states.async_all()) == 4 diff --git a/tests/components/coronavirus/test_init.py b/tests/components/coronavirus/test_init.py index 57293635570..483fabab9f9 100644 --- a/tests/components/coronavirus/test_init.py +++ b/tests/components/coronavirus/test_init.py @@ -1,6 +1,4 @@ """Test init of Coronavirus integration.""" -from asynctest import Mock, patch - from homeassistant.components.coronavirus.const import DOMAIN, OPTION_WORLDWIDE from homeassistant.helpers import entity_registry from homeassistant.setup import async_setup_component @@ -33,15 +31,8 @@ async def test_migration(hass): ), }, ) - with patch( - "coronavirus.get_cases", - return_value=[ - Mock(country="Netherlands", confirmed=10, recovered=8, deaths=1, current=1), - Mock(country="Germany", confirmed=1, recovered=0, deaths=0, current=0), - ], - ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() ent_reg = await entity_registry.async_get_registry(hass) From 1615a5ee81fde04e0722da2842059b81126a0250 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 21:42:48 -0800 Subject: [PATCH 246/416] Use unique_id in Plex config entries (#32489) --- homeassistant/components/plex/__init__.py | 5 +++++ homeassistant/components/plex/config_flow.py | 8 ++------ tests/components/plex/test_config_flow.py | 11 ++++++----- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 101704013c1..9d74ed8cb75 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -116,6 +116,11 @@ async def async_setup_entry(hass, entry): """Set up Plex from a config entry.""" server_config = entry.data[PLEX_SERVER_CONFIG] + if entry.unique_id is None: + hass.config_entries.async_update_entry( + entry, unique_id=entry.data[CONF_SERVER_IDENTIFIER] + ) + if MP_DOMAIN not in entry.options: options = dict(entry.options) options.setdefault( diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 6e4dce3e914..d61da8609a9 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -125,12 +125,8 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): server_id = plex_server.machine_identifier - for entry in self._async_current_entries(): - if entry.data[CONF_SERVER_IDENTIFIER] == server_id: - _LOGGER.debug( - "Plex server already configured: %s", entry.data[CONF_SERVER] - ) - return self.async_abort(reason="already_configured") + await self.async_set_unique_id(server_id) + self._abort_if_unique_id_configured() url = plex_server.url_in_use token = server_config.get(CONF_TOKEN) diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index 4e7af44c5a4..da4c95c145f 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -383,8 +383,6 @@ async def test_already_configured(hass): mock_plex_server = MockPlexServer() - flow = init_config_flow(hass) - flow.context = {"source": "import"} MockConfigEntry( domain=config_flow.DOMAIN, data={ @@ -393,6 +391,7 @@ async def test_already_configured(hass): config_flow.CONF_SERVER_IDENTIFIER ], }, + unique_id=MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER], ).add_to_hass(hass) with patch( @@ -400,11 +399,13 @@ async def test_already_configured(hass): ), asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch( "plexauth.PlexAuth.token", return_value=MOCK_TOKEN ): - result = await flow.async_step_import( - { + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "import"}, + data={ CONF_TOKEN: MOCK_TOKEN, CONF_URL: f"http://{MOCK_SERVERS[0][CONF_HOST]}:{MOCK_SERVERS[0][CONF_PORT]}", - } + }, ) assert result["type"] == "abort" assert result["reason"] == "already_configured" From d216c1f2acaa91d1dfd00717a0c6a4473c6f6915 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 21:55:56 -0800 Subject: [PATCH 247/416] UniFi - Add block network access control to config option (#32004) * Add block network access control to config option * Clean up --- .../components/unifi/.translations/en.json | 18 ++- homeassistant/components/unifi/config_flow.py | 82 +++++++++-- homeassistant/components/unifi/const.py | 2 - homeassistant/components/unifi/controller.py | 6 +- homeassistant/components/unifi/strings.json | 18 ++- homeassistant/components/unifi/switch.py | 54 +++++++- tests/components/unifi/test_config_flow.py | 104 ++++++++++---- tests/components/unifi/test_controller.py | 6 +- tests/components/unifi/test_switch.py | 128 +++++++++++------- 9 files changed, 314 insertions(+), 104 deletions(-) diff --git a/homeassistant/components/unifi/.translations/en.json b/homeassistant/components/unifi/.translations/en.json index f1f96b3c363..9ac01e514bf 100644 --- a/homeassistant/components/unifi/.translations/en.json +++ b/homeassistant/components/unifi/.translations/en.json @@ -6,7 +6,8 @@ }, "error": { "faulty_credentials": "Bad user credentials", - "service_unavailable": "No service available" + "service_unavailable": "No service available", + "unknown_client_mac": "No client available on that MAC address" }, "step": { "user": { @@ -34,15 +35,26 @@ "track_wired_clients": "Include wired network clients" }, "description": "Configure device tracking", - "title": "UniFi options" + "title": "UniFi options 1/3" + }, + "client_control": { + "data": { + "block_client": "Network access controlled clients", + "new_client": "Add new client (MAC) for network access control" + }, + "description": "Configure client controls\n\nCreate switches for serial numbers you want to control network access for.", + "title": "UniFi options 2/3" }, "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Bandwidth usage sensors for network clients" }, "description": "Configure statistics sensors", - "title": "UniFi options" + "title": "UniFi options 3/3" } + }, + "error": { + "unknown_client_mac": "No client available in UniFi on that MAC address" } } } \ No newline at end of file diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 36fa7489e81..e0bb1c3bb9f 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -16,6 +16,7 @@ import homeassistant.helpers.config_validation as cv from .const import ( CONF_ALLOW_BANDWIDTH_SENSORS, + CONF_BLOCK_CLIENT, CONF_CONTROLLER, CONF_DETECTION_TIME, CONF_SITE_ID, @@ -30,6 +31,7 @@ from .const import ( from .controller import get_controller from .errors import AlreadyConfigured, AuthenticationRequired, CannotConnect +CONF_NEW_CLIENT = "new_client" DEFAULT_PORT = 8443 DEFAULT_SITE_ID = "default" DEFAULT_VERIFY_SSL = False @@ -171,61 +173,117 @@ class UnifiOptionsFlowHandler(config_entries.OptionsFlow): """Initialize UniFi options flow.""" self.config_entry = config_entry self.options = dict(config_entry.options) + self.controller = None async def async_step_init(self, user_input=None): """Manage the UniFi options.""" + self.controller = get_controller_from_config_entry(self.hass, self.config_entry) + self.options[CONF_BLOCK_CLIENT] = self.controller.option_block_clients return await self.async_step_device_tracker() async def async_step_device_tracker(self, user_input=None): """Manage the device tracker options.""" if user_input is not None: self.options.update(user_input) - return await self.async_step_statistics_sensors() + return await self.async_step_client_control() - controller = get_controller_from_config_entry(self.hass, self.config_entry) - - ssid_filter = {wlan: wlan for wlan in controller.api.wlans} + ssid_filter = {wlan: wlan for wlan in self.controller.api.wlans} return self.async_show_form( step_id="device_tracker", data_schema=vol.Schema( { vol.Optional( - CONF_TRACK_CLIENTS, default=controller.option_track_clients, + CONF_TRACK_CLIENTS, + default=self.controller.option_track_clients, ): bool, vol.Optional( CONF_TRACK_WIRED_CLIENTS, - default=controller.option_track_wired_clients, + default=self.controller.option_track_wired_clients, ): bool, vol.Optional( - CONF_TRACK_DEVICES, default=controller.option_track_devices, + CONF_TRACK_DEVICES, + default=self.controller.option_track_devices, ): bool, vol.Optional( - CONF_SSID_FILTER, default=controller.option_ssid_filter + CONF_SSID_FILTER, default=self.controller.option_ssid_filter ): cv.multi_select(ssid_filter), vol.Optional( CONF_DETECTION_TIME, - default=int(controller.option_detection_time.total_seconds()), + default=int( + self.controller.option_detection_time.total_seconds() + ), ): int, } ), ) + async def async_step_client_control(self, user_input=None): + """Manage configuration of network access controlled clients.""" + errors = {} + + if user_input is not None: + new_client = user_input.pop(CONF_NEW_CLIENT, None) + self.options.update(user_input) + + if new_client: + if ( + new_client in self.controller.api.clients + or new_client in self.controller.api.clients_all + ): + self.options[CONF_BLOCK_CLIENT].append(new_client) + + else: + errors["base"] = "unknown_client_mac" + + else: + return await self.async_step_statistics_sensors() + + clients_to_block = {} + + for mac in self.options[CONF_BLOCK_CLIENT]: + + name = None + + for clients in [ + self.controller.api.clients, + self.controller.api.clients_all, + ]: + if mac in clients: + name = f"{clients[mac].name or clients[mac].hostname} ({mac})" + break + + if not name: + name = mac + + clients_to_block[mac] = name + + return self.async_show_form( + step_id="client_control", + data_schema=vol.Schema( + { + vol.Optional( + CONF_BLOCK_CLIENT, default=self.options[CONF_BLOCK_CLIENT] + ): cv.multi_select(clients_to_block), + vol.Optional(CONF_NEW_CLIENT): str, + } + ), + errors=errors, + ) + async def async_step_statistics_sensors(self, user_input=None): """Manage the statistics sensors options.""" if user_input is not None: self.options.update(user_input) return await self._update_options() - controller = get_controller_from_config_entry(self.hass, self.config_entry) - return self.async_show_form( step_id="statistics_sensors", data_schema=vol.Schema( { vol.Optional( CONF_ALLOW_BANDWIDTH_SENSORS, - default=controller.option_allow_bandwidth_sensors, + default=self.controller.option_allow_bandwidth_sensors, ): bool } ), diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py index d82b7b49d45..341364063f2 100644 --- a/homeassistant/components/unifi/const.py +++ b/homeassistant/components/unifi/const.py @@ -25,11 +25,9 @@ CONF_DONT_TRACK_DEVICES = "dont_track_devices" CONF_DONT_TRACK_WIRED_CLIENTS = "dont_track_wired_clients" DEFAULT_ALLOW_BANDWIDTH_SENSORS = False -DEFAULT_BLOCK_CLIENTS = [] DEFAULT_TRACK_CLIENTS = True DEFAULT_TRACK_DEVICES = True DEFAULT_TRACK_WIRED_CLIENTS = True DEFAULT_DETECTION_TIME = 300 -DEFAULT_SSID_FILTER = [] ATTR_MANUFACTURER = "Ubiquiti Networks" diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index b7cd8e8b6a1..7da36131058 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -31,9 +31,7 @@ from .const import ( CONF_TRACK_WIRED_CLIENTS, CONTROLLER_ID, DEFAULT_ALLOW_BANDWIDTH_SENSORS, - DEFAULT_BLOCK_CLIENTS, DEFAULT_DETECTION_TIME, - DEFAULT_SSID_FILTER, DEFAULT_TRACK_CLIENTS, DEFAULT_TRACK_DEVICES, DEFAULT_TRACK_WIRED_CLIENTS, @@ -99,7 +97,7 @@ class UniFiController: @property def option_block_clients(self): """Config entry option with list of clients to control network access.""" - return self.config_entry.options.get(CONF_BLOCK_CLIENT, DEFAULT_BLOCK_CLIENTS) + return self.config_entry.options.get(CONF_BLOCK_CLIENT, []) @property def option_track_clients(self): @@ -130,7 +128,7 @@ class UniFiController: @property def option_ssid_filter(self): """Config entry option listing what SSIDs are being used to track clients.""" - return self.config_entry.options.get(CONF_SSID_FILTER, DEFAULT_SSID_FILTER) + return self.config_entry.options.get(CONF_SSID_FILTER, []) @property def mac(self): diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json index e652b60ee32..58728225de7 100644 --- a/homeassistant/components/unifi/strings.json +++ b/homeassistant/components/unifi/strings.json @@ -16,7 +16,8 @@ }, "error": { "faulty_credentials": "Bad user credentials", - "service_unavailable": "No service available" + "service_unavailable": "No service available", + "unknown_client_mac": "No client available on that MAC address" }, "abort": { "already_configured": "Controller site is already configured", @@ -37,15 +38,26 @@ "track_wired_clients": "Include wired network clients" }, "description": "Configure device tracking", - "title": "UniFi options" + "title": "UniFi options 1/3" + }, + "client_control": { + "data": { + "block_client": "Network access controlled clients", + "new_client": "Add new client for network access control" + }, + "description": "Configure client controls\n\nCreate switches for serial numbers you want to control network access for.", + "title": "UniFi options 2/3" }, "statistics_sensors": { "data": { "allow_bandwidth_sensors": "Bandwidth usage sensors for network clients" }, "description": "Configure statistics sensors", - "title": "UniFi options" + "title": "UniFi options 3/3" } } + }, + "error": { + "unknown_client_mac": "No client available in UniFi on that MAC address" } } \ No newline at end of file diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 941f4f8ab84..84e85188ede 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -4,7 +4,6 @@ import logging from homeassistant.components.switch import SwitchDevice from homeassistant.components.unifi.config_flow import get_controller_from_config_entry from homeassistant.core import callback -from homeassistant.helpers import entity_registry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.restore_state import RestoreEntity @@ -30,10 +29,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): switches = {} switches_off = [] - registry = await entity_registry.async_get_registry(hass) + option_block_clients = controller.option_block_clients + + entity_registry = await hass.helpers.entity_registry.async_get_registry() # Restore clients that is not a part of active clients list. - for entity in registry.entities.values(): + for entity in entity_registry.entities.values(): if ( entity.config_entry_id == config_entry.entry_id @@ -61,6 +62,43 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_dispatcher_connect(hass, controller.signal_update, update_controller) ) + @callback + def options_updated(): + """Manage entities affected by config entry options.""" + nonlocal option_block_clients + + update = set() + remove = set() + + if option_block_clients != controller.option_block_clients: + option_block_clients = controller.option_block_clients + + for block_client_id, entity in switches.items(): + if not isinstance(entity, UniFiBlockClientSwitch): + continue + + if entity.client.mac in option_block_clients: + update.add(block_client_id) + else: + remove.add(block_client_id) + + for block_client_id in remove: + entity = switches.pop(block_client_id) + + if entity_registry.async_is_registered(entity.entity_id): + entity_registry.async_remove(entity.entity_id) + + hass.async_create_task(entity.async_remove()) + + if len(update) != len(option_block_clients): + update_controller() + + controller.listeners.append( + async_dispatcher_connect( + hass, controller.signal_options_update, options_updated + ) + ) + update_controller() switches_off.clear() @@ -74,15 +112,21 @@ def add_entities(controller, async_add_entities, switches, switches_off): # block client for client_id in controller.option_block_clients: + client = None block_client_id = f"block-{client_id}" if block_client_id in switches: continue - if client_id not in controller.api.clients_all: + if client_id in controller.api.clients: + client = controller.api.clients[client_id] + + elif client_id in controller.api.clients_all: + client = controller.api.clients_all[client_id] + + if not client: continue - client = controller.api.clients_all[client_id] switches[block_client_id] = UniFiBlockClientSwitch(client, controller) new_switches.append(switches[block_client_id]) diff --git a/tests/components/unifi/test_config_flow.py b/tests/components/unifi/test_config_flow.py index 64d1ab9775e..9a280ffe9e6 100644 --- a/tests/components/unifi/test_config_flow.py +++ b/tests/components/unifi/test_config_flow.py @@ -5,7 +5,18 @@ from asynctest import patch from homeassistant import data_entry_flow from homeassistant.components import unifi from homeassistant.components.unifi import config_flow -from homeassistant.components.unifi.const import CONF_CONTROLLER, CONF_SITE_ID +from homeassistant.components.unifi.config_flow import CONF_NEW_CLIENT +from homeassistant.components.unifi.const import ( + CONF_ALLOW_BANDWIDTH_SENSORS, + CONF_BLOCK_CLIENT, + CONF_CONTROLLER, + CONF_DETECTION_TIME, + CONF_SITE_ID, + CONF_SSID_FILTER, + CONF_TRACK_CLIENTS, + CONF_TRACK_DEVICES, + CONF_TRACK_WIRED_CLIENTS, +) from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -18,6 +29,8 @@ from .test_controller import setup_unifi_integration from tests.common import MockConfigEntry +CLIENTS = [{"mac": "00:00:00:00:00:01"}] + WLANS = [{"name": "SSID 1"}, {"name": "SSID 2"}] @@ -28,7 +41,7 @@ async def test_flow_works(hass, aioclient_mock, mock_discovery): config_flow.DOMAIN, context={"source": "user"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" assert result["data_schema"]({CONF_USERNAME: "", CONF_PASSWORD: ""}) == { CONF_HOST: "unifi", @@ -64,7 +77,7 @@ async def test_flow_works(hass, aioclient_mock, mock_discovery): }, ) - assert result["type"] == "create_entry" + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == "Site name" assert result["data"] == { CONF_CONTROLLER: { @@ -84,7 +97,7 @@ async def test_flow_works_multiple_sites(hass, aioclient_mock): config_flow.DOMAIN, context={"source": "user"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" aioclient_mock.post( @@ -116,7 +129,7 @@ async def test_flow_works_multiple_sites(hass, aioclient_mock): }, ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "site" assert result["data_schema"]({"site": "site name"}) assert result["data_schema"]({"site": "site2 name"}) @@ -133,7 +146,7 @@ async def test_flow_fails_site_already_configured(hass, aioclient_mock): config_flow.DOMAIN, context={"source": "user"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" aioclient_mock.post( @@ -162,7 +175,7 @@ async def test_flow_fails_site_already_configured(hass, aioclient_mock): }, ) - assert result["type"] == "abort" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT async def test_flow_fails_user_credentials_faulty(hass, aioclient_mock): @@ -171,7 +184,7 @@ async def test_flow_fails_user_credentials_faulty(hass, aioclient_mock): config_flow.DOMAIN, context={"source": "user"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" with patch("aiounifi.Controller.login", side_effect=aiounifi.errors.Unauthorized): @@ -186,7 +199,7 @@ async def test_flow_fails_user_credentials_faulty(hass, aioclient_mock): }, ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {"base": "faulty_credentials"} @@ -196,7 +209,7 @@ async def test_flow_fails_controller_unavailable(hass, aioclient_mock): config_flow.DOMAIN, context={"source": "user"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" with patch("aiounifi.Controller.login", side_effect=aiounifi.errors.RequestError): @@ -211,7 +224,7 @@ async def test_flow_fails_controller_unavailable(hass, aioclient_mock): }, ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["errors"] == {"base": "service_unavailable"} @@ -221,7 +234,7 @@ async def test_flow_fails_unknown_problem(hass, aioclient_mock): config_flow.DOMAIN, context={"source": "user"} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" with patch("aiounifi.Controller.login", side_effect=Exception): @@ -236,12 +249,14 @@ async def test_flow_fails_unknown_problem(hass, aioclient_mock): }, ) - assert result["type"] == "abort" + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT async def test_option_flow(hass): """Test config flow options.""" - controller = await setup_unifi_integration(hass, wlans_response=WLANS) + controller = await setup_unifi_integration( + hass, clients_response=CLIENTS, wlans_response=WLANS + ) result = await hass.config_entries.options.async_init( controller.config_entry.entry_id @@ -253,27 +268,64 @@ async def test_option_flow(hass): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - config_flow.CONF_TRACK_CLIENTS: False, - config_flow.CONF_TRACK_WIRED_CLIENTS: False, - config_flow.CONF_TRACK_DEVICES: False, - config_flow.CONF_SSID_FILTER: ["SSID 1"], - config_flow.CONF_DETECTION_TIME: 100, + CONF_TRACK_CLIENTS: False, + CONF_TRACK_WIRED_CLIENTS: False, + CONF_TRACK_DEVICES: False, + CONF_SSID_FILTER: ["SSID 1"], + CONF_DETECTION_TIME: 100, }, ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "client_control" + + clients_to_block = hass.config_entries.options._progress[result["flow_id"]].options[ + CONF_BLOCK_CLIENT + ] + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_BLOCK_CLIENT: clients_to_block, + CONF_NEW_CLIENT: "00:00:00:00:00:01", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "client_control" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + CONF_BLOCK_CLIENT: clients_to_block, + CONF_NEW_CLIENT: "00:00:00:00:00:02", + }, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "client_control" + assert result["errors"] == {"base": "unknown_client_mac"} + + clients_to_block = hass.config_entries.options._progress[result["flow_id"]].options[ + CONF_BLOCK_CLIENT + ] + result = await hass.config_entries.options.async_configure( + result["flow_id"], user_input={CONF_BLOCK_CLIENT: clients_to_block}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "statistics_sensors" result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={config_flow.CONF_ALLOW_BANDWIDTH_SENSORS: True} + result["flow_id"], user_input={CONF_ALLOW_BANDWIDTH_SENSORS: True} ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["data"] == { - config_flow.CONF_TRACK_CLIENTS: False, - config_flow.CONF_TRACK_WIRED_CLIENTS: False, - config_flow.CONF_TRACK_DEVICES: False, - config_flow.CONF_DETECTION_TIME: 100, - config_flow.CONF_SSID_FILTER: ["SSID 1"], - config_flow.CONF_ALLOW_BANDWIDTH_SENSORS: True, + CONF_TRACK_CLIENTS: False, + CONF_TRACK_WIRED_CLIENTS: False, + CONF_TRACK_DEVICES: False, + CONF_DETECTION_TIME: 100, + CONF_SSID_FILTER: ["SSID 1"], + CONF_BLOCK_CLIENT: ["00:00:00:00:00:01"], + CONF_ALLOW_BANDWIDTH_SENSORS: True, } diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py index daec8cddf5d..8bf2225d1f1 100644 --- a/tests/components/unifi/test_controller.py +++ b/tests/components/unifi/test_controller.py @@ -166,7 +166,7 @@ async def test_controller_setup(hass): controller.option_allow_bandwidth_sensors == unifi.const.DEFAULT_ALLOW_BANDWIDTH_SENSORS ) - assert controller.option_block_clients == unifi.const.DEFAULT_BLOCK_CLIENTS + assert isinstance(controller.option_block_clients, list) assert controller.option_track_clients == unifi.const.DEFAULT_TRACK_CLIENTS assert controller.option_track_devices == unifi.const.DEFAULT_TRACK_DEVICES assert ( @@ -175,7 +175,7 @@ async def test_controller_setup(hass): assert controller.option_detection_time == timedelta( seconds=unifi.const.DEFAULT_DETECTION_TIME ) - assert controller.option_ssid_filter == unifi.const.DEFAULT_SSID_FILTER + assert isinstance(controller.option_ssid_filter, list) assert controller.mac is None @@ -235,7 +235,7 @@ async def test_reset_after_successful_setup(hass): """Calling reset when the entry has been setup.""" controller = await setup_unifi_integration(hass) - assert len(controller.listeners) == 5 + assert len(controller.listeners) == 6 result = await controller.async_reset() await hass.async_block_till_done() diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index a2b609078de..bc30161b77f 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -4,6 +4,11 @@ from copy import deepcopy from homeassistant import config_entries from homeassistant.components import unifi import homeassistant.components.switch as switch +from homeassistant.components.unifi.const import ( + CONF_BLOCK_CLIENT, + CONF_TRACK_CLIENTS, + CONF_TRACK_DEVICES, +) from homeassistant.helpers import entity_registry from homeassistant.setup import async_setup_component @@ -200,11 +205,7 @@ 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={ - unifi.const.CONF_TRACK_CLIENTS: False, - unifi.const.CONF_TRACK_DEVICES: False, - }, + hass, options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False}, ) assert len(controller.mock_requests) == 4 @@ -215,10 +216,7 @@ async def test_controller_not_client(hass): """Test that the controller doesn't become a switch.""" controller = await setup_unifi_integration( hass, - options={ - unifi.const.CONF_TRACK_CLIENTS: False, - unifi.const.CONF_TRACK_DEVICES: False, - }, + options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False}, clients_response=[CONTROLLER_HOST], devices_response=[DEVICE_1], ) @@ -235,10 +233,7 @@ async def test_not_admin(hass): sites["Site name"]["role"] = "not admin" controller = await setup_unifi_integration( hass, - options={ - unifi.const.CONF_TRACK_CLIENTS: False, - unifi.const.CONF_TRACK_DEVICES: False, - }, + options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False}, sites=sites, clients_response=[CLIENT_1], devices_response=[DEVICE_1], @@ -253,9 +248,9 @@ async def test_switches(hass): controller = await setup_unifi_integration( hass, options={ - unifi.CONF_BLOCK_CLIENT: [BLOCKED["mac"], UNBLOCKED["mac"]], - unifi.const.CONF_TRACK_CLIENTS: False, - unifi.const.CONF_TRACK_DEVICES: False, + CONF_BLOCK_CLIENT: [BLOCKED["mac"], UNBLOCKED["mac"]], + CONF_TRACK_CLIENTS: False, + CONF_TRACK_DEVICES: False, }, clients_response=[CLIENT_1, CLIENT_4], devices_response=[DEVICE_1], @@ -284,34 +279,10 @@ async def test_switches(hass): assert unblocked is not None assert unblocked.state == "on" - -async def test_new_client_discovered_on_block_control(hass): - """Test if 2nd update has a new client.""" - controller = await setup_unifi_integration( - hass, - options={ - unifi.CONF_BLOCK_CLIENT: [BLOCKED["mac"]], - unifi.const.CONF_TRACK_CLIENTS: False, - unifi.const.CONF_TRACK_DEVICES: False, - }, - clients_all_response=[BLOCKED], - ) - - assert len(controller.mock_requests) == 4 - assert len(hass.states.async_all()) == 2 - - controller.api.websocket._data = { - "meta": {"message": "sta:sync"}, - "data": [BLOCKED], - } - controller.api.session_handler("data") - - # Calling a service will trigger the updates to run await hass.services.async_call( "switch", "turn_off", {"entity_id": "switch.block_client_1"}, blocking=True ) assert len(controller.mock_requests) == 5 - assert len(hass.states.async_all()) == 2 assert controller.mock_requests[4] == { "json": {"mac": "00:00:00:00:01:01", "cmd": "block-sta"}, "method": "post", @@ -329,14 +300,79 @@ async def test_new_client_discovered_on_block_control(hass): } -async def test_new_client_discovered_on_poe_control(hass): +async def test_new_client_discovered_on_block_control(hass): """Test if 2nd update has a new client.""" controller = await setup_unifi_integration( hass, options={ - unifi.const.CONF_TRACK_CLIENTS: False, - unifi.const.CONF_TRACK_DEVICES: False, + CONF_BLOCK_CLIENT: [BLOCKED["mac"]], + CONF_TRACK_CLIENTS: False, + CONF_TRACK_DEVICES: False, }, + ) + + assert len(controller.mock_requests) == 4 + assert len(hass.states.async_all()) == 1 + + blocked = hass.states.get("switch.block_client_1") + assert blocked is None + + controller.api.websocket._data = { + "meta": {"message": "sta:sync"}, + "data": [BLOCKED], + } + controller.api.session_handler("data") + await hass.async_block_till_done() + + assert len(hass.states.async_all()) == 2 + blocked = hass.states.get("switch.block_client_1") + assert blocked is not None + + +async def test_option_block_clients(hass): + """Test the changes to option reflects accordingly.""" + controller = await setup_unifi_integration( + hass, + options={CONF_BLOCK_CLIENT: [BLOCKED["mac"]]}, + clients_all_response=[BLOCKED, UNBLOCKED], + ) + assert len(hass.states.async_all()) == 2 + + # Add a second switch + hass.config_entries.async_update_entry( + controller.config_entry, + options={CONF_BLOCK_CLIENT: [BLOCKED["mac"], UNBLOCKED["mac"]]}, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 3 + + # Remove the second switch again + hass.config_entries.async_update_entry( + controller.config_entry, options={CONF_BLOCK_CLIENT: [BLOCKED["mac"]]}, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 2 + + # Enable one and remove another one + hass.config_entries.async_update_entry( + controller.config_entry, options={CONF_BLOCK_CLIENT: [UNBLOCKED["mac"]]}, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 2 + + # Remove one + hass.config_entries.async_update_entry( + controller.config_entry, options={CONF_BLOCK_CLIENT: []}, + ) + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 1 + + +async def test_new_client_discovered_on_poe_control(hass): + """Test if 2nd update has a new client.""" + controller = await setup_unifi_integration( + hass, + options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False}, clients_response=[CLIENT_1], devices_response=[DEVICE_1], ) @@ -435,9 +471,9 @@ async def test_restoring_client(hass): controller = await setup_unifi_integration( hass, options={ - unifi.CONF_BLOCK_CLIENT: ["random mac"], - unifi.const.CONF_TRACK_CLIENTS: False, - unifi.const.CONF_TRACK_DEVICES: False, + CONF_BLOCK_CLIENT: ["random mac"], + CONF_TRACK_CLIENTS: False, + CONF_TRACK_DEVICES: False, }, clients_response=[CLIENT_2], devices_response=[DEVICE_1], From 521cc7247dadf37cfc96498863b356219e3fad10 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 4 Mar 2020 22:05:39 -0800 Subject: [PATCH 248/416] Add Dynalite switch platform (#32389) * added presets for switch devices * added channel type to __init and const * ran pylint on library so needed a few changes in names * removed callback * bool -> cv.boolean --- homeassistant/components/dynalite/__init__.py | 52 ++++++++++++++++--- homeassistant/components/dynalite/bridge.py | 6 +-- .../components/dynalite/config_flow.py | 4 +- homeassistant/components/dynalite/const.py | 11 +++- homeassistant/components/dynalite/light.py | 7 +-- .../components/dynalite/manifest.json | 2 +- homeassistant/components/dynalite/switch.py | 29 +++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/dynalite/common.py | 2 +- tests/components/dynalite/test_bridge.py | 6 +-- tests/components/dynalite/test_init.py | 8 ++- tests/components/dynalite/test_switch.py | 34 ++++++++++++ 13 files changed, 134 insertions(+), 31 deletions(-) create mode 100755 homeassistant/components/dynalite/switch.py create mode 100755 tests/components/dynalite/test_switch.py diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index 1c4ba99d1a4..e27bdfbb142 100755 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -1,4 +1,7 @@ """Support for the Dynalite networks.""" + +import asyncio + import voluptuous as vol from homeassistant import config_entries @@ -10,18 +13,26 @@ from homeassistant.helpers import config_validation as cv from .bridge import DynaliteBridge from .const import ( CONF_ACTIVE, + CONF_ACTIVE_INIT, + CONF_ACTIVE_OFF, + CONF_ACTIVE_ON, CONF_AREA, CONF_AUTO_DISCOVER, CONF_BRIDGES, CONF_CHANNEL, + CONF_CHANNEL_TYPE, CONF_DEFAULT, CONF_FADE, CONF_NAME, + CONF_NO_DEFAULT, CONF_POLLTIMER, CONF_PORT, + CONF_PRESET, + DEFAULT_CHANNEL_TYPE, DEFAULT_NAME, DEFAULT_PORT, DOMAIN, + ENTITY_PLATFORMS, LOGGER, ) @@ -35,16 +46,31 @@ def num_string(value): CHANNEL_DATA_SCHEMA = vol.Schema( - {vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_FADE): vol.Coerce(float)} + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_CHANNEL_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any( + "light", "switch" + ), + } ) CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA}) +PRESET_DATA_SCHEMA = vol.Schema( + {vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_FADE): vol.Coerce(float)} +) + +PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)}) + + AREA_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_NAME): cv.string, vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_NO_DEFAULT): vol.Coerce(bool), vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, + vol.Optional(CONF_PRESET): PRESET_SCHEMA, }, ) @@ -62,7 +88,10 @@ BRIDGE_SCHEMA = vol.Schema( vol.Optional(CONF_POLLTIMER, default=1.0): vol.Coerce(float), vol.Optional(CONF_AREA): AREA_SCHEMA, vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA, - vol.Optional(CONF_ACTIVE, default=False): vol.Coerce(bool), + vol.Optional(CONF_ACTIVE, default=False): vol.Any( + CONF_ACTIVE_ON, CONF_ACTIVE_OFF, CONF_ACTIVE_INIT, cv.boolean + ), + vol.Optional(CONF_PRESET): PRESET_SCHEMA, } ) @@ -120,14 +149,17 @@ async def async_setup_entry(hass, entry): """Set up a bridge from a config entry.""" LOGGER.debug("Setting up entry %s", entry.data) bridge = DynaliteBridge(hass, entry.data) + # need to do it before the listener + hass.data[DOMAIN][entry.entry_id] = bridge entry.add_update_listener(async_entry_changed) if not await bridge.async_setup(): LOGGER.error("Could not set up bridge for entry %s", entry.data) + hass.data[DOMAIN][entry.entry_id] = None raise ConfigEntryNotReady - hass.data[DOMAIN][entry.entry_id] = bridge - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "light") - ) + for platform in ENTITY_PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) return True @@ -135,5 +167,9 @@ async def async_unload_entry(hass, entry): """Unload a config entry.""" LOGGER.debug("Unloading entry %s", entry.data) hass.data[DOMAIN].pop(entry.entry_id) - result = await hass.config_entries.async_forward_entry_unload(entry, "light") - return result + tasks = [ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in ENTITY_PLATFORMS + ] + results = await asyncio.gather(*tasks) + return False not in results diff --git a/homeassistant/components/dynalite/bridge.py b/homeassistant/components/dynalite/bridge.py index 85a187249df..fa0a91bfab1 100755 --- a/homeassistant/components/dynalite/bridge.py +++ b/homeassistant/components/dynalite/bridge.py @@ -20,8 +20,8 @@ class DynaliteBridge: self.host = config[CONF_HOST] # Configure the dynalite devices self.dynalite_devices = DynaliteDevices( - newDeviceFunc=self.add_devices_when_registered, - updateDeviceFunc=self.update_device, + new_device_func=self.add_devices_when_registered, + update_device_func=self.update_device, ) self.dynalite_devices.configure(config) @@ -31,7 +31,7 @@ class DynaliteBridge: LOGGER.debug("Setting up bridge - host %s", self.host) return await self.dynalite_devices.async_setup() - async def reload_config(self, config): + def reload_config(self, config): """Reconfigure a bridge when config changes.""" LOGGER.debug("Reloading bridge - host %s, config %s", self.host, config) self.dynalite_devices.configure(config) diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index 10d66c82d52..ca95c0754a6 100755 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -3,7 +3,7 @@ from homeassistant import config_entries from homeassistant.const import CONF_HOST from .bridge import DynaliteBridge -from .const import DOMAIN, LOGGER # pylint: disable=unused-import +from .const import DOMAIN, LOGGER class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -12,8 +12,6 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL - # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 - def __init__(self): """Initialize the Dynalite flow.""" self.host = None diff --git a/homeassistant/components/dynalite/const.py b/homeassistant/components/dynalite/const.py index 2e86d49c825..267b5727b83 100755 --- a/homeassistant/components/dynalite/const.py +++ b/homeassistant/components/dynalite/const.py @@ -4,21 +4,28 @@ import logging LOGGER = logging.getLogger(__package__) DOMAIN = "dynalite" -ENTITY_PLATFORMS = ["light"] +ENTITY_PLATFORMS = ["light", "switch"] + CONF_ACTIVE = "active" +CONF_ACTIVE_INIT = "init" +CONF_ACTIVE_OFF = "off" +CONF_ACTIVE_ON = "on" CONF_ALL = "ALL" CONF_AREA = "area" CONF_AUTO_DISCOVER = "autodiscover" CONF_BRIDGES = "bridges" CONF_CHANNEL = "channel" +CONF_CHANNEL_TYPE = "type" CONF_DEFAULT = "default" CONF_FADE = "fade" CONF_HOST = "host" CONF_NAME = "name" +CONF_NO_DEFAULT = "nodefault" CONF_POLLTIMER = "polltimer" CONF_PORT = "port" +CONF_PRESET = "preset" - +DEFAULT_CHANNEL_TYPE = "light" DEFAULT_NAME = "dynalite" DEFAULT_PORT = 12345 diff --git a/homeassistant/components/dynalite/light.py b/homeassistant/components/dynalite/light.py index caa39ad573a..a5b7139803c 100755 --- a/homeassistant/components/dynalite/light.py +++ b/homeassistant/components/dynalite/light.py @@ -1,6 +1,5 @@ """Support for Dynalite channels as lights.""" from homeassistant.components.light import SUPPORT_BRIGHTNESS, Light -from homeassistant.core import callback from .dynalitebase import DynaliteBase, async_setup_entry_base @@ -8,12 +7,8 @@ from .dynalitebase import DynaliteBase, async_setup_entry_base async def async_setup_entry(hass, config_entry, async_add_entities): """Record the async_add_entities function to add them later when received from Dynalite.""" - @callback - def light_from_device(device, bridge): - return DynaliteLight(device, bridge) - async_setup_entry_base( - hass, config_entry, async_add_entities, "light", light_from_device + hass, config_entry, async_add_entities, "light", DynaliteLight ) diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json index 18f1ebed919..d6351db17b2 100755 --- a/homeassistant/components/dynalite/manifest.json +++ b/homeassistant/components/dynalite/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/dynalite", "dependencies": [], "codeowners": ["@ziv1234"], - "requirements": ["dynalite_devices==0.1.30"] + "requirements": ["dynalite_devices==0.1.32"] } diff --git a/homeassistant/components/dynalite/switch.py b/homeassistant/components/dynalite/switch.py new file mode 100755 index 00000000000..84be74cee36 --- /dev/null +++ b/homeassistant/components/dynalite/switch.py @@ -0,0 +1,29 @@ +"""Support for the Dynalite channels and presets as switches.""" +from homeassistant.components.switch import SwitchDevice + +from .dynalitebase import DynaliteBase, async_setup_entry_base + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Record the async_add_entities function to add them later when received from Dynalite.""" + + async_setup_entry_base( + hass, config_entry, async_add_entities, "switch", DynaliteSwitch + ) + + +class DynaliteSwitch(DynaliteBase, SwitchDevice): + """Representation of a Dynalite Channel as a Home Assistant Switch.""" + + @property + def is_on(self): + """Return true if switch is on.""" + return self._device.is_on + + async def async_turn_on(self, **kwargs): + """Turn the switch on.""" + await self._device.async_turn_on() + + async def async_turn_off(self, **kwargs): + """Turn the switch off.""" + await self._device.async_turn_off() diff --git a/requirements_all.txt b/requirements_all.txt index fad600e918d..5f134d6d9c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -466,7 +466,7 @@ dsmr_parser==0.18 dweepy==0.3.0 # homeassistant.components.dynalite -dynalite_devices==0.1.30 +dynalite_devices==0.1.32 # homeassistant.components.rainforest_eagle eagle200_reader==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ff37f3a9ba3..ec2608fe644 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -174,7 +174,7 @@ distro==1.4.0 dsmr_parser==0.18 # homeassistant.components.dynalite -dynalite_devices==0.1.30 +dynalite_devices==0.1.32 # homeassistant.components.ee_brightbox eebrightbox==0.0.4 diff --git a/tests/components/dynalite/common.py b/tests/components/dynalite/common.py index 97750140811..56554efaa07 100755 --- a/tests/components/dynalite/common.py +++ b/tests/components/dynalite/common.py @@ -41,7 +41,7 @@ async def create_entity_from_device(hass, device): mock_dyn_dev().async_setup = CoroutineMock(return_value=True) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - new_device_func = mock_dyn_dev.mock_calls[1][2]["newDeviceFunc"] + new_device_func = mock_dyn_dev.mock_calls[1][2]["new_device_func"] new_device_func([device]) await hass.async_block_till_done() diff --git a/tests/components/dynalite/test_bridge.py b/tests/components/dynalite/test_bridge.py index 0c9ea517992..ee6baaa7561 100755 --- a/tests/components/dynalite/test_bridge.py +++ b/tests/components/dynalite/test_bridge.py @@ -20,7 +20,7 @@ async def test_update_device(hass): mock_dyn_dev().async_setup = CoroutineMock(return_value=True) assert await hass.config_entries.async_setup(entry.entry_id) # Not waiting so it add the devices before registration - update_device_func = mock_dyn_dev.mock_calls[1][2]["updateDeviceFunc"] + update_device_func = mock_dyn_dev.mock_calls[1][2]["update_device_func"] device = Mock() device.unique_id = "abcdef" wide_func = Mock() @@ -50,7 +50,7 @@ async def test_add_devices_then_register(hass): mock_dyn_dev().async_setup = CoroutineMock(return_value=True) assert await hass.config_entries.async_setup(entry.entry_id) # Not waiting so it add the devices before registration - new_device_func = mock_dyn_dev.mock_calls[1][2]["newDeviceFunc"] + new_device_func = mock_dyn_dev.mock_calls[1][2]["new_device_func"] # Now with devices device1 = Mock() device1.category = "light" @@ -73,7 +73,7 @@ async def test_register_then_add_devices(hass): mock_dyn_dev().async_setup = CoroutineMock(return_value=True) assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - new_device_func = mock_dyn_dev.mock_calls[1][2]["newDeviceFunc"] + new_device_func = mock_dyn_dev.mock_calls[1][2]["new_device_func"] # Now with devices device1 = Mock() device1.category = "light" diff --git a/tests/components/dynalite/test_init.py b/tests/components/dynalite/test_init.py index 6c9309cb4e5..b74fcd64da0 100755 --- a/tests/components/dynalite/test_init.py +++ b/tests/components/dynalite/test_init.py @@ -83,5 +83,9 @@ async def test_unload_entry(hass): ) as mock_unload: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() - mock_unload.assert_called_once() - assert mock_unload.mock_calls == [call(entry, "light")] + assert mock_unload.call_count == len(dynalite.ENTITY_PLATFORMS) + expected_calls = [ + call(entry, platform) for platform in dynalite.ENTITY_PLATFORMS + ] + for cur_call in mock_unload.mock_calls: + assert cur_call in expected_calls diff --git a/tests/components/dynalite/test_switch.py b/tests/components/dynalite/test_switch.py new file mode 100755 index 00000000000..7c0c5d632d3 --- /dev/null +++ b/tests/components/dynalite/test_switch.py @@ -0,0 +1,34 @@ +"""Test Dynalite switch.""" + +from dynalite_devices_lib.switch import DynalitePresetSwitchDevice +import pytest + +from .common import ( + ATTR_METHOD, + ATTR_SERVICE, + create_entity_from_device, + create_mock_device, + run_service_tests, +) + + +@pytest.fixture +def mock_device(): + """Mock a Dynalite device.""" + return create_mock_device("switch", DynalitePresetSwitchDevice) + + +async def test_switch_setup(hass, mock_device): + """Test a successful setup.""" + await create_entity_from_device(hass, mock_device) + entity_state = hass.states.get("switch.name") + assert entity_state.attributes["friendly_name"] == mock_device.name + await run_service_tests( + hass, + mock_device, + "switch", + [ + {ATTR_SERVICE: "turn_on", ATTR_METHOD: "async_turn_on"}, + {ATTR_SERVICE: "turn_off", ATTR_METHOD: "async_turn_off"}, + ], + ) From 85ba4692a97af7785686699718842d3a302eb55a Mon Sep 17 00:00:00 2001 From: Quentame Date: Thu, 5 Mar 2020 08:50:39 +0100 Subject: [PATCH 249/416] Get pending iCloud devices when available + request again when needs an update (#32400) * Fetch iCloud devices again if the status is pending * Remove "No iCloud device found" double check * fix default api_devices value * Remove useless unitialisation declarations --- homeassistant/components/icloud/__init__.py | 4 +- homeassistant/components/icloud/account.py | 63 +++++++++++++++---- homeassistant/components/icloud/const.py | 1 - .../components/icloud/device_tracker.py | 45 +++++++++---- homeassistant/components/icloud/sensor.py | 48 ++++++++++---- 5 files changed, 119 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 687c6bf93de..1131a4eecc9 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -123,10 +123,8 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool hass, username, password, icloud_dir, max_interval, gps_accuracy_threshold, ) await hass.async_add_executor_job(account.setup) - if not account.devices: - return False - hass.data[DOMAIN][username] = account + hass.data[DOMAIN][entry.unique_id] = account for platform in PLATFORMS: hass.async_create_task( diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index 5d681539668..789ae563482 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -10,6 +10,7 @@ from pyicloud.services.findmyiphone import AppleDevice from homeassistant.components.zone import async_active_zone from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.helpers.storage import Store @@ -37,7 +38,7 @@ from .const import ( DEVICE_STATUS, DEVICE_STATUS_CODES, DEVICE_STATUS_SET, - SERVICE_UPDATE, + DOMAIN, ) ATTRIBUTION = "Data provided by Apple iCloud" @@ -91,7 +92,7 @@ class IcloudAccount: self._family_members_fullname = {} self._devices = {} - self.unsub_device_tracker = None + self.listeners = [] def setup(self) -> None: """Set up an iCloud account.""" @@ -104,13 +105,17 @@ class IcloudAccount: _LOGGER.error("Error logging into iCloud Service: %s", error) return - user_info = None try: + api_devices = self.api.devices # Gets device owners infos - user_info = self.api.devices.response["userInfo"] - except PyiCloudNoDevicesException: + user_info = api_devices.response["userInfo"] + except (KeyError, PyiCloudNoDevicesException): _LOGGER.error("No iCloud device found") - return + raise ConfigEntryNotReady + + if DEVICE_STATUS_CODES.get(list(api_devices)[0][DEVICE_STATUS]) == "pending": + _LOGGER.warning("Pending devices, trying again ...") + raise ConfigEntryNotReady self._owner_fullname = f"{user_info['firstName']} {user_info['lastName']}" @@ -132,13 +137,21 @@ class IcloudAccount: api_devices = {} try: api_devices = self.api.devices - except PyiCloudNoDevicesException: - _LOGGER.error("No iCloud device found") - return except Exception as err: # pylint: disable=broad-except _LOGGER.error("Unknown iCloud error: %s", err) - self._fetch_interval = 5 - dispatcher_send(self.hass, SERVICE_UPDATE) + self._fetch_interval = 2 + dispatcher_send(self.hass, self.signal_device_update) + track_point_in_utc_time( + self.hass, + self.keep_alive, + utcnow() + timedelta(minutes=self._fetch_interval), + ) + return + + if DEVICE_STATUS_CODES.get(list(api_devices)[0][DEVICE_STATUS]) == "pending": + _LOGGER.warning("Pending devices, trying again in 15s") + self._fetch_interval = 0.25 + dispatcher_send(self.hass, self.signal_device_update) track_point_in_utc_time( self.hass, self.keep_alive, @@ -147,10 +160,19 @@ class IcloudAccount: return # Gets devices infos + new_device = False for device in api_devices: status = device.status(DEVICE_STATUS_SET) device_id = status[DEVICE_ID] device_name = status[DEVICE_NAME] + device_status = DEVICE_STATUS_CODES.get(status[DEVICE_STATUS], "error") + + if ( + device_status == "pending" + or status[DEVICE_BATTERY_STATUS] == "Unknown" + or status.get(DEVICE_BATTERY_LEVEL) is None + ): + continue if self._devices.get(device_id, None) is not None: # Seen device -> updating @@ -165,9 +187,14 @@ class IcloudAccount: ) self._devices[device_id] = IcloudDevice(self, device, status) self._devices[device_id].update(status) + new_device = True self._fetch_interval = self._determine_interval() - dispatcher_send(self.hass, SERVICE_UPDATE) + + dispatcher_send(self.hass, self.signal_device_update) + if new_device: + dispatcher_send(self.hass, self.signal_device_new) + track_point_in_utc_time( self.hass, self.keep_alive, @@ -291,6 +318,16 @@ class IcloudAccount: """Return the account devices.""" return self._devices + @property + def signal_device_new(self) -> str: + """Event specific per Freebox entry to signal new device.""" + return f"{DOMAIN}-{self._username}-device-new" + + @property + def signal_device_update(self) -> str: + """Event specific per Freebox entry to signal updates in devices.""" + return f"{DOMAIN}-{self._username}-device-update" + class IcloudDevice: """Representation of a iCloud device.""" @@ -348,6 +385,8 @@ class IcloudDevice: and self._status[DEVICE_LOCATION][DEVICE_LOCATION_LATITUDE] ): location = self._status[DEVICE_LOCATION] + if self._location is None: + dispatcher_send(self._account.hass, self._account.signal_device_new) self._location = location def play_sound(self) -> None: diff --git a/homeassistant/components/icloud/const.py b/homeassistant/components/icloud/const.py index 3349615ed57..14bd4e498bd 100644 --- a/homeassistant/components/icloud/const.py +++ b/homeassistant/components/icloud/const.py @@ -1,7 +1,6 @@ """iCloud component constants.""" DOMAIN = "icloud" -SERVICE_UPDATE = f"{DOMAIN}_update" CONF_MAX_INTERVAL = "max_interval" CONF_GPS_ACCURACY_THRESHOLD = "gps_accuracy_threshold" diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 4248485e11b..47a302e2f26 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -5,17 +5,16 @@ from typing import Dict from homeassistant.components.device_tracker import SOURCE_TYPE_GPS from homeassistant.components.device_tracker.config_entry import TrackerEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import HomeAssistantType -from .account import IcloudDevice +from .account import IcloudAccount, IcloudDevice from .const import ( DEVICE_LOCATION_HORIZONTAL_ACCURACY, DEVICE_LOCATION_LATITUDE, DEVICE_LOCATION_LONGITUDE, DOMAIN, - SERVICE_UPDATE, ) _LOGGER = logging.getLogger(__name__) @@ -30,25 +29,45 @@ async def async_setup_scanner( async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities -): - """Configure a dispatcher connection based on a config entry.""" - username = entry.data[CONF_USERNAME] +) -> None: + """Set up device tracker for iCloud component.""" + account = hass.data[DOMAIN][entry.unique_id] + tracked = set() - for device in hass.data[DOMAIN][username].devices.values(): - if device.location is None: - _LOGGER.debug("No position found for %s", device.name) + @callback + def update_account(): + """Update the values of the account.""" + add_entities(account, async_add_entities, tracked) + + account.listeners.append( + async_dispatcher_connect(hass, account.signal_device_new, update_account) + ) + + update_account() + + +@callback +def add_entities(account, async_add_entities, tracked): + """Add new tracker entities from the account.""" + new_tracked = [] + + for dev_id, device in account.devices.items(): + if dev_id in tracked or device.location is None: continue - _LOGGER.debug("Adding device_tracker for %s", device.name) + new_tracked.append(IcloudTrackerEntity(account, device)) + tracked.add(dev_id) - async_add_entities([IcloudTrackerEntity(device)]) + if new_tracked: + async_add_entities(new_tracked, True) class IcloudTrackerEntity(TrackerEntity): """Represent a tracked device.""" - def __init__(self, device: IcloudDevice): + def __init__(self, account: IcloudAccount, device: IcloudDevice): """Set up the iCloud tracker entity.""" + self._account = account self._device = device self._unsub_dispatcher = None @@ -110,7 +129,7 @@ class IcloudTrackerEntity(TrackerEntity): async def async_added_to_hass(self): """Register state update callback.""" self._unsub_dispatcher = async_dispatcher_connect( - self.hass, SERVICE_UPDATE, self.async_write_ha_state + self.hass, self._account.signal_device_update, self.async_write_ha_state ) async def async_will_remove_from_hass(self): diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index 5438b9ce810..b2e8b4ead1e 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -3,14 +3,15 @@ import logging from typing import Dict from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_USERNAME, DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE +from homeassistant.const import DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.helpers.typing import HomeAssistantType -from .account import IcloudDevice -from .const import DOMAIN, SERVICE_UPDATE +from .account import IcloudAccount, IcloudDevice +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) @@ -18,23 +19,44 @@ _LOGGER = logging.getLogger(__name__) async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities ) -> None: - """Set up iCloud devices sensors based on a config entry.""" - username = entry.data[CONF_USERNAME] + """Set up device tracker for iCloud component.""" + account = hass.data[DOMAIN][entry.unique_id] + tracked = set() - entities = [] - for device in hass.data[DOMAIN][username].devices.values(): - if device.battery_level is not None: - _LOGGER.debug("Adding battery sensor for %s", device.name) - entities.append(IcloudDeviceBatterySensor(device)) + @callback + def update_account(): + """Update the values of the account.""" + add_entities(account, async_add_entities, tracked) - async_add_entities(entities, True) + account.listeners.append( + async_dispatcher_connect(hass, account.signal_device_new, update_account) + ) + + update_account() + + +@callback +def add_entities(account, async_add_entities, tracked): + """Add new tracker entities from the account.""" + new_tracked = [] + + for dev_id, device in account.devices.items(): + if dev_id in tracked or device.battery_level is None: + continue + + new_tracked.append(IcloudDeviceBatterySensor(account, device)) + tracked.add(dev_id) + + if new_tracked: + async_add_entities(new_tracked, True) class IcloudDeviceBatterySensor(Entity): """Representation of a iCloud device battery sensor.""" - def __init__(self, device: IcloudDevice): + def __init__(self, account: IcloudAccount, device: IcloudDevice): """Initialize the battery sensor.""" + self._account = account self._device = device self._unsub_dispatcher = None @@ -94,7 +116,7 @@ class IcloudDeviceBatterySensor(Entity): async def async_added_to_hass(self): """Register state update callback.""" self._unsub_dispatcher = async_dispatcher_connect( - self.hass, SERVICE_UPDATE, self.async_write_ha_state + self.hass, self._account.signal_device_update, self.async_write_ha_state ) async def async_will_remove_from_hass(self): From 007d934214f5a92db69ac3fbfe5470a54d19a459 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Thu, 5 Mar 2020 13:49:56 +0000 Subject: [PATCH 250/416] Initial support for HomeKit enabled televisions (#32404) * Initial support for HomeKit enabled televisions * Fix nit from review --- .../components/homekit_controller/const.py | 1 + .../homekit_controller/manifest.json | 2 +- .../homekit_controller/media_player.py | 169 +++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../specific_devices/test_lg_tv.py | 42 + .../homekit_controller/test_media_player.py | 204 ++++ tests/fixtures/homekit_controller/lg_tv.json | 1059 +++++++++++++++++ 8 files changed, 1478 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/homekit_controller/media_player.py create mode 100644 tests/components/homekit_controller/specific_devices/test_lg_tv.py create mode 100644 tests/components/homekit_controller/test_media_player.py create mode 100644 tests/fixtures/homekit_controller/lg_tv.json diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py index 9c750b17e8f..f5ae6cbd644 100644 --- a/homeassistant/components/homekit_controller/const.py +++ b/homeassistant/components/homekit_controller/const.py @@ -31,4 +31,5 @@ HOMEKIT_ACCESSORY_DISPATCH = { "fanv2": "fan", "air-quality": "air_quality", "occupancy": "binary_sensor", + "television": "media_player", } diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index e821efb3a60..5351dfb69cb 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit[IP]==0.2.15"], + "requirements": ["aiohomekit[IP]==0.2.17"], "dependencies": [], "zeroconf": ["_hap._tcp.local."], "codeowners": ["@Jc2k"] diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py new file mode 100644 index 00000000000..7f38dc3ce2a --- /dev/null +++ b/homeassistant/components/homekit_controller/media_player.py @@ -0,0 +1,169 @@ +"""Support for HomeKit Controller Televisions.""" +import logging + +from aiohomekit.model.characteristics import ( + CharacteristicsTypes, + CurrentMediaStateValues, + RemoteKeyValues, + TargetMediaStateValues, +) +from aiohomekit.utils import clamp_enum_to_char + +from homeassistant.components.media_player import DEVICE_CLASS_TV, MediaPlayerDevice +from homeassistant.components.media_player.const import ( + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_STOP, +) +from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING +from homeassistant.core import callback + +from . import KNOWN_DEVICES, HomeKitEntity + +_LOGGER = logging.getLogger(__name__) + + +HK_TO_HA_STATE = { + CurrentMediaStateValues.PLAYING: STATE_PLAYING, + CurrentMediaStateValues.PAUSED: STATE_PAUSED, + CurrentMediaStateValues.STOPPED: STATE_IDLE, +} + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Homekit television.""" + hkid = config_entry.data["AccessoryPairingID"] + conn = hass.data[KNOWN_DEVICES][hkid] + + @callback + def async_add_service(aid, service): + if service["stype"] != "television": + return False + info = {"aid": aid, "iid": service["iid"]} + async_add_entities([HomeKitTelevision(conn, info)], True) + return True + + conn.add_listener(async_add_service) + + +class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice): + """Representation of a HomeKit Controller Television.""" + + def __init__(self, accessory, discovery_info): + """Initialise the TV.""" + self._state = None + self._features = 0 + self._supported_target_media_state = set() + self._supported_remote_key = set() + super().__init__(accessory, discovery_info) + + def get_characteristic_types(self): + """Define the homekit characteristics the entity cares about.""" + return [ + CharacteristicsTypes.CURRENT_MEDIA_STATE, + CharacteristicsTypes.TARGET_MEDIA_STATE, + CharacteristicsTypes.REMOTE_KEY, + ] + + def _setup_target_media_state(self, char): + self._supported_target_media_state = clamp_enum_to_char( + TargetMediaStateValues, char + ) + + if TargetMediaStateValues.PAUSE in self._supported_target_media_state: + self._features |= SUPPORT_PAUSE + + if TargetMediaStateValues.PLAY in self._supported_target_media_state: + self._features |= SUPPORT_PLAY + + if TargetMediaStateValues.STOP in self._supported_target_media_state: + self._features |= SUPPORT_STOP + + def _setup_remote_key(self, char): + self._supported_remote_key = clamp_enum_to_char(RemoteKeyValues, char) + if RemoteKeyValues.PLAY_PAUSE in self._supported_remote_key: + self._features |= SUPPORT_PAUSE | SUPPORT_PLAY + + @property + def device_class(self): + """Define the device class for a HomeKit enabled TV.""" + return DEVICE_CLASS_TV + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return self._features + + @property + def state(self): + """State of the tv.""" + homekit_state = self.get_hk_char_value(CharacteristicsTypes.CURRENT_MEDIA_STATE) + if homekit_state is None: + return None + return HK_TO_HA_STATE[homekit_state] + + async def async_media_play(self): + """Send play command.""" + if self.state == STATE_PLAYING: + _LOGGER.debug("Cannot play while already playing") + return + + if TargetMediaStateValues.PLAY in self._supported_target_media_state: + characteristics = [ + { + "aid": self._aid, + "iid": self._chars["target-media-state"], + "value": TargetMediaStateValues.PLAY, + } + ] + await self._accessory.put_characteristics(characteristics) + elif RemoteKeyValues.PLAY_PAUSE in self._supported_remote_key: + characteristics = [ + { + "aid": self._aid, + "iid": self._chars["remote-key"], + "value": RemoteKeyValues.PLAY_PAUSE, + } + ] + await self._accessory.put_characteristics(characteristics) + + async def async_media_pause(self): + """Send pause command.""" + if self.state == STATE_PAUSED: + _LOGGER.debug("Cannot pause while already paused") + return + + if TargetMediaStateValues.PAUSE in self._supported_target_media_state: + characteristics = [ + { + "aid": self._aid, + "iid": self._chars["target-media-state"], + "value": TargetMediaStateValues.PAUSE, + } + ] + await self._accessory.put_characteristics(characteristics) + elif RemoteKeyValues.PLAY_PAUSE in self._supported_remote_key: + characteristics = [ + { + "aid": self._aid, + "iid": self._chars["remote-key"], + "value": RemoteKeyValues.PLAY_PAUSE, + } + ] + await self._accessory.put_characteristics(characteristics) + + async def async_media_stop(self): + """Send stop command.""" + if self.state == STATE_IDLE: + _LOGGER.debug("Cannot stop when already idle") + return + + if TargetMediaStateValues.STOP in self._supported_target_media_state: + characteristics = [ + { + "aid": self._aid, + "iid": self._chars["target-media-state"], + "value": TargetMediaStateValues.STOP, + } + ] + await self._accessory.put_characteristics(characteristics) diff --git a/requirements_all.txt b/requirements_all.txt index 5f134d6d9c2..ce04d4c5fb4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -163,7 +163,7 @@ aioftp==0.12.0 aioharmony==0.1.13 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.15 +aiohomekit[IP]==0.2.17 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec2608fe644..66cf4d0798e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -62,7 +62,7 @@ aiobotocore==0.11.1 aioesphomeapi==2.6.1 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.15 +aiohomekit[IP]==0.2.17 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py new file mode 100644 index 00000000000..69f17ba6431 --- /dev/null +++ b/tests/components/homekit_controller/specific_devices/test_lg_tv.py @@ -0,0 +1,42 @@ +"""Make sure that handling real world LG HomeKit characteristics isn't broken.""" + + +from homeassistant.components.media_player.const import SUPPORT_PAUSE, SUPPORT_PLAY + +from tests.components.homekit_controller.common import ( + Helper, + setup_accessories_from_file, + setup_test_accessories, +) + + +async def test_lg_tv(hass): + """Test that a Koogeek LS1 can be correctly setup in HA.""" + accessories = await setup_accessories_from_file(hass, "lg_tv.json") + config_entry, pairing = await setup_test_accessories(hass, accessories) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + # Assert that the entity is correctly added to the entity registry + entry = entity_registry.async_get("media_player.lg_webos_tv_af80") + assert entry.unique_id == "homekit-999AAAAAA999-48" + + helper = Helper( + hass, "media_player.lg_webos_tv_af80", pairing, accessories[0], config_entry + ) + state = await helper.poll_and_get_state() + + # Assert that the friendly name is detected correctly + assert state.attributes["friendly_name"] == "LG webOS TV AF80" + + # Assert that all optional features the LS1 supports are detected + assert state.attributes["supported_features"] == (SUPPORT_PAUSE | SUPPORT_PLAY) + + device_registry = await hass.helpers.device_registry.async_get_registry() + + device = device_registry.async_get(entry.device_id) + assert device.manufacturer == "LG Electronics" + assert device.name == "LG webOS TV AF80" + assert device.model == "OLED55B9PUA" + assert device.sw_version == "04.71.04" + assert device.via_device_id is None diff --git a/tests/components/homekit_controller/test_media_player.py b/tests/components/homekit_controller/test_media_player.py new file mode 100644 index 00000000000..3389201f61d --- /dev/null +++ b/tests/components/homekit_controller/test_media_player.py @@ -0,0 +1,204 @@ +"""Basic checks for HomeKit motion sensors and contact sensors.""" +from aiohomekit.model.characteristics import ( + CharacteristicPermissions, + CharacteristicsTypes, +) +from aiohomekit.model.services import ServicesTypes + +from tests.components.homekit_controller.common import setup_test_component + +CURRENT_MEDIA_STATE = ("television", "current-media-state") +TARGET_MEDIA_STATE = ("television", "target-media-state") +REMOTE_KEY = ("television", "remote-key") + + +def create_tv_service(accessory): + """ + Define tv characteristics. + + The TV is not currently documented publicly - this is based on observing really TV's that have HomeKit support. + """ + service = accessory.add_service(ServicesTypes.TELEVISION) + + cur_state = service.add_char(CharacteristicsTypes.CURRENT_MEDIA_STATE) + cur_state.value = 0 + + remote = service.add_char(CharacteristicsTypes.REMOTE_KEY) + remote.value = None + remote.perms.append(CharacteristicPermissions.paired_write) + + return service + + +def create_tv_service_with_target_media_state(accessory): + """Define a TV service that can play/pause/stop without generate remote events.""" + service = create_tv_service(accessory) + + tms = service.add_char(CharacteristicsTypes.TARGET_MEDIA_STATE) + tms.value = None + tms.perms.append(CharacteristicPermissions.paired_write) + + return service + + +async def test_tv_read_state(hass, utcnow): + """Test that we can read the state of a HomeKit fan accessory.""" + helper = await setup_test_component(hass, create_tv_service) + + helper.characteristics[CURRENT_MEDIA_STATE].value = 0 + state = await helper.poll_and_get_state() + assert state.state == "playing" + + helper.characteristics[CURRENT_MEDIA_STATE].value = 1 + state = await helper.poll_and_get_state() + assert state.state == "paused" + + helper.characteristics[CURRENT_MEDIA_STATE].value = 2 + state = await helper.poll_and_get_state() + assert state.state == "idle" + + +async def test_play_remote_key(hass, utcnow): + """Test that we can play media on a media player.""" + helper = await setup_test_component(hass, create_tv_service) + + helper.characteristics[CURRENT_MEDIA_STATE].value = 1 + await helper.poll_and_get_state() + + await hass.services.async_call( + "media_player", + "media_play", + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + assert helper.characteristics[REMOTE_KEY].value == 11 + + # Second time should be a no-op + helper.characteristics[CURRENT_MEDIA_STATE].value = 0 + await helper.poll_and_get_state() + + helper.characteristics[REMOTE_KEY].value = None + await hass.services.async_call( + "media_player", + "media_play", + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + assert helper.characteristics[REMOTE_KEY].value is None + + +async def test_pause_remote_key(hass, utcnow): + """Test that we can pause a media player.""" + helper = await setup_test_component(hass, create_tv_service) + + helper.characteristics[CURRENT_MEDIA_STATE].value = 0 + await helper.poll_and_get_state() + + await hass.services.async_call( + "media_player", + "media_pause", + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + assert helper.characteristics[REMOTE_KEY].value == 11 + + # Second time should be a no-op + helper.characteristics[CURRENT_MEDIA_STATE].value = 1 + await helper.poll_and_get_state() + + helper.characteristics[REMOTE_KEY].value = None + await hass.services.async_call( + "media_player", + "media_pause", + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + assert helper.characteristics[REMOTE_KEY].value is None + + +async def test_play(hass, utcnow): + """Test that we can play media on a media player.""" + helper = await setup_test_component(hass, create_tv_service_with_target_media_state) + + helper.characteristics[CURRENT_MEDIA_STATE].value = 1 + await helper.poll_and_get_state() + + await hass.services.async_call( + "media_player", + "media_play", + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + assert helper.characteristics[REMOTE_KEY].value is None + assert helper.characteristics[TARGET_MEDIA_STATE].value == 0 + + # Second time should be a no-op + helper.characteristics[CURRENT_MEDIA_STATE].value = 0 + await helper.poll_and_get_state() + + helper.characteristics[TARGET_MEDIA_STATE].value = None + await hass.services.async_call( + "media_player", + "media_play", + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + assert helper.characteristics[REMOTE_KEY].value is None + assert helper.characteristics[TARGET_MEDIA_STATE].value is None + + +async def test_pause(hass, utcnow): + """Test that we can turn pause a media player.""" + helper = await setup_test_component(hass, create_tv_service_with_target_media_state) + + helper.characteristics[CURRENT_MEDIA_STATE].value = 0 + await helper.poll_and_get_state() + + await hass.services.async_call( + "media_player", + "media_pause", + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + assert helper.characteristics[REMOTE_KEY].value is None + assert helper.characteristics[TARGET_MEDIA_STATE].value == 1 + + # Second time should be a no-op + helper.characteristics[CURRENT_MEDIA_STATE].value = 1 + await helper.poll_and_get_state() + + helper.characteristics[REMOTE_KEY].value = None + await hass.services.async_call( + "media_player", + "media_pause", + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + assert helper.characteristics[REMOTE_KEY].value is None + + +async def test_stop(hass, utcnow): + """Test that we can stop a media player.""" + helper = await setup_test_component(hass, create_tv_service_with_target_media_state) + + await hass.services.async_call( + "media_player", + "media_stop", + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + assert helper.characteristics[TARGET_MEDIA_STATE].value == 2 + + # Second time should be a no-op + helper.characteristics[CURRENT_MEDIA_STATE].value = 2 + await helper.poll_and_get_state() + + helper.characteristics[TARGET_MEDIA_STATE].value = None + await hass.services.async_call( + "media_player", + "media_stop", + {"entity_id": "media_player.testdevice"}, + blocking=True, + ) + assert helper.characteristics[REMOTE_KEY].value is None + assert helper.characteristics[TARGET_MEDIA_STATE].value is None diff --git a/tests/fixtures/homekit_controller/lg_tv.json b/tests/fixtures/homekit_controller/lg_tv.json new file mode 100644 index 00000000000..26b3557c2e6 --- /dev/null +++ b/tests/fixtures/homekit_controller/lg_tv.json @@ -0,0 +1,1059 @@ +[ + { + "aid": 1, + "services": [ + { + "characteristics": [ + { + "format": "bool", + "iid": 2, + "perms": [ + "pw" + ], + "type": "00000014-0000-1000-8000-0026BB765291" + }, + { + "ev": false, + "format": "string", + "iid": 3, + "perms": [ + "pr" + ], + "type": "00000020-0000-1000-8000-0026BB765291", + "value": "LG Electronics" + }, + { + "ev": false, + "format": "string", + "iid": 4, + "perms": [ + "pr" + ], + "type": "00000021-0000-1000-8000-0026BB765291", + "value": "OLED55B9PUA" + }, + { + "ev": false, + "format": "string", + "iid": 5, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "LG webOS TV AF80" + }, + { + "ev": false, + "format": "string", + "iid": 6, + "perms": [ + "pr" + ], + "type": "00000030-0000-1000-8000-0026BB765291", + "value": "999AAAAAA999" + }, + { + "ev": false, + "format": "string", + "iid": 7, + "perms": [ + "pr" + ], + "type": "00000052-0000-1000-8000-0026BB765291", + "value": "04.71.04" + }, + { + "ev": false, + "format": "string", + "iid": 8, + "perms": [ + "pr" + ], + "type": "00000053-0000-1000-8000-0026BB765291", + "value": "1" + }, + { + "ev": false, + "format": "string", + "iid": 9, + "perms": [ + "pr", + "hd" + ], + "type": "34AB8811-AC7F-4340-BAC3-FD6A85F9943B", + "value": "2.1;16B62a" + } + ], + "hidden": false, + "iid": 1, + "linked": [], + "primary": false, + "stype": "accessory-information", + "type": "0000003E-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "ev": false, + "format": "string", + "iid": 18, + "perms": [ + "pr" + ], + "type": "00000037-0000-1000-8000-0026BB765291", + "value": "1.1.0" + } + ], + "hidden": false, + "iid": 16, + "linked": [], + "primary": false, + "stype": "service", + "type": "000000A2-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "ev": false, + "format": "string", + "iid": 50, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "LG webOS TV" + }, + { + "ev": false, + "format": "string", + "iid": 51, + "maxLen": 25, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "E3", + "value": "LG webOS TV OLED55B9PUA" + }, + { + "ev": false, + "format": "uint8", + "iid": 52, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "000000B0-0000-1000-8000-0026BB765291", + "value": 1 + }, + { + "ev": false, + "format": "uint32", + "iid": 53, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "E7", + "value": 6 + }, + { + "ev": false, + "format": "uint8", + "iid": 54, + "maxValue": 2, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "E8", + "value": 1 + }, + { + "format": "uint8", + "iid": 57, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pw" + ], + "type": "DF" + }, + { + "format": "uint8", + "iid": 59, + "maxValue": 16, + "minStep": 1, + "minValue": 0, + "perms": [ + "pw" + ], + "type": "E1" + } + ], + "hidden": false, + "iid": 48, + "linked": [ + 64, + 80, + 384, + 256, + 272, + 288, + 304, + 320, + 336, + 352 + ], + "primary": true, + "stype": "Unknown Service: D8", + "type": "D8" + }, + { + "characteristics": [ + { + "ev": false, + "format": "uint16", + "iid": 66, + "maxValue": 2, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "E5", + "value": 0 + }, + { + "ev": false, + "format": "tlv8", + "iid": 67, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "E4", + "value": "AQACAQA=" + } + ], + "hidden": false, + "iid": 64, + "linked": [], + "primary": false, + "stype": "Unknown Service: DA", + "type": "DA" + }, + { + "characteristics": [ + { + "ev": false, + "format": "uint8", + "iid": 84, + "maxValue": 100, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "00000119-0000-1000-8000-0026BB765291", + "unit": "percentage", + "value": 0 + }, + { + "ev": false, + "format": "bool", + "iid": 82, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "0000011A-0000-1000-8000-0026BB765291", + "value": 0 + }, + { + "ev": false, + "format": "string", + "iid": 83, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Speaker" + }, + { + "ev": false, + "format": "uint8", + "iid": 85, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "000000B0-0000-1000-8000-0026BB765291", + "value": 1 + }, + { + "ev": false, + "format": "uint8", + "iid": 86, + "maxValue": 3, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "E9", + "value": 2 + }, + { + "format": "uint8", + "iid": 87, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pw" + ], + "type": "EA" + } + ], + "hidden": false, + "iid": 80, + "linked": [], + "primary": false, + "stype": "speaker", + "type": "00000113-0000-1000-8000-0026BB765291" + }, + { + "characteristics": [ + { + "ev": false, + "format": "tlv8", + "iid": 385, + "perms": [ + "pr" + ], + "type": "222", + "value": "AQgBBnRAvoQmJQIaAQYgF0KJBUICBiAXQokFQgAAAgZ0QL6EJiQ=" + } + ], + "hidden": false, + "iid": 384, + "linked": [], + "primary": false, + "stype": "Unknown Service: 221", + "type": "221" + }, + { + "characteristics": [ + { + "ev": false, + "format": "uint8", + "iid": 258, + "maxValue": 10, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "DB", + "value": 8 + }, + { + "ev": false, + "format": "uint8", + "iid": 259, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "D6", + "value": 1 + }, + { + "ev": false, + "format": "uint32", + "iid": 260, + "perms": [ + "pr" + ], + "type": "E6", + "value": 1 + }, + { + "ev": false, + "format": "string", + "iid": 261, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "AirPlay" + }, + { + "ev": false, + "format": "string", + "iid": 262, + "maxLen": 25, + "perms": [ + "pr", + "ev" + ], + "type": "E3", + "value": "AirPlay" + }, + { + "ev": false, + "format": "uint8", + "iid": 264, + "maxValue": 6, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "DC", + "value": 0 + }, + { + "ev": false, + "format": "uint8", + "iid": 263, + "maxValue": 3, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "135", + "value": 3 + } + ], + "hidden": false, + "iid": 256, + "linked": [], + "primary": false, + "stype": "Unknown Service: D9", + "type": "D9" + }, + { + "characteristics": [ + { + "ev": false, + "format": "uint8", + "iid": 274, + "maxValue": 10, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "DB", + "value": 2 + }, + { + "ev": false, + "format": "uint8", + "iid": 275, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "D6", + "value": 1 + }, + { + "ev": false, + "format": "uint32", + "iid": 276, + "perms": [ + "pr" + ], + "type": "E6", + "value": 2 + }, + { + "ev": false, + "format": "string", + "iid": 277, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "Live TV" + }, + { + "ev": false, + "format": "string", + "iid": 278, + "maxLen": 25, + "perms": [ + "pr", + "ev" + ], + "type": "E3", + "value": "Live TV" + }, + { + "ev": false, + "format": "uint8", + "iid": 280, + "maxValue": 6, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "DC", + "value": 3 + }, + { + "ev": false, + "format": "uint8", + "iid": 279, + "maxValue": 3, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "135", + "value": 3 + } + ], + "hidden": false, + "iid": 272, + "linked": [], + "primary": false, + "stype": "Unknown Service: D9", + "type": "D9" + }, + { + "characteristics": [ + { + "ev": false, + "format": "uint8", + "iid": 290, + "maxValue": 10, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "DB", + "value": 3 + }, + { + "ev": false, + "format": "uint8", + "iid": 291, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "D6", + "value": 1 + }, + { + "ev": false, + "format": "uint32", + "iid": 292, + "perms": [ + "pr" + ], + "type": "E6", + "value": 3 + }, + { + "ev": false, + "format": "string", + "iid": 293, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "HDMI 1" + }, + { + "ev": false, + "format": "string", + "iid": 294, + "maxLen": 25, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "E3", + "value": "HDMI 1" + }, + { + "ev": false, + "format": "uint8", + "iid": 296, + "maxValue": 6, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "DC", + "value": 4 + }, + { + "ev": false, + "format": "uint8", + "iid": 295, + "maxValue": 3, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "135", + "value": 1 + } + ], + "hidden": false, + "iid": 288, + "linked": [], + "primary": false, + "stype": "Unknown Service: D9", + "type": "D9" + }, + { + "characteristics": [ + { + "ev": false, + "format": "uint8", + "iid": 306, + "maxValue": 10, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "DB", + "value": 3 + }, + { + "ev": false, + "format": "uint8", + "iid": 307, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "D6", + "value": 1 + }, + { + "ev": false, + "format": "uint32", + "iid": 308, + "perms": [ + "pr" + ], + "type": "E6", + "value": 4 + }, + { + "ev": false, + "format": "string", + "iid": 309, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "HDMI 2" + }, + { + "ev": false, + "format": "string", + "iid": 310, + "maxLen": 25, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "E3", + "value": "Sony" + }, + { + "ev": false, + "format": "uint8", + "iid": 312, + "maxValue": 6, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "DC", + "value": 4 + }, + { + "ev": false, + "format": "uint8", + "iid": 311, + "maxValue": 3, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "135", + "value": 2 + } + ], + "hidden": false, + "iid": 304, + "linked": [], + "primary": false, + "stype": "Unknown Service: D9", + "type": "D9" + }, + { + "characteristics": [ + { + "ev": false, + "format": "uint8", + "iid": 322, + "maxValue": 10, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "DB", + "value": 3 + }, + { + "ev": false, + "format": "uint8", + "iid": 323, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "D6", + "value": 1 + }, + { + "ev": false, + "format": "uint32", + "iid": 324, + "perms": [ + "pr" + ], + "type": "E6", + "value": 5 + }, + { + "ev": false, + "format": "string", + "iid": 325, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "HDMI 3" + }, + { + "ev": false, + "format": "string", + "iid": 326, + "maxLen": 25, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "E3", + "value": "Apple" + }, + { + "ev": false, + "format": "uint8", + "iid": 328, + "maxValue": 6, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "DC", + "value": 4 + }, + { + "ev": false, + "format": "uint8", + "iid": 327, + "maxValue": 3, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "135", + "value": 2 + } + ], + "hidden": false, + "iid": 320, + "linked": [], + "primary": false, + "stype": "Unknown Service: D9", + "type": "D9" + }, + { + "characteristics": [ + { + "ev": false, + "format": "uint8", + "iid": 338, + "maxValue": 10, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "DB", + "value": 4 + }, + { + "ev": false, + "format": "uint8", + "iid": 339, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "D6", + "value": 1 + }, + { + "ev": false, + "format": "uint32", + "iid": 340, + "perms": [ + "pr" + ], + "type": "E6", + "value": 7 + }, + { + "ev": false, + "format": "string", + "iid": 341, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "AV" + }, + { + "ev": false, + "format": "string", + "iid": 342, + "maxLen": 25, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "E3", + "value": "AV" + }, + { + "ev": false, + "format": "uint8", + "iid": 344, + "maxValue": 6, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "DC", + "value": 2 + }, + { + "ev": false, + "format": "uint8", + "iid": 343, + "maxValue": 3, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "135", + "value": 1 + } + ], + "hidden": false, + "iid": 336, + "linked": [], + "primary": false, + "stype": "Unknown Service: D9", + "type": "D9" + }, + { + "characteristics": [ + { + "ev": false, + "format": "uint8", + "iid": 354, + "maxValue": 10, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "DB", + "value": 3 + }, + { + "ev": false, + "format": "uint8", + "iid": 355, + "maxValue": 1, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "D6", + "value": 1 + }, + { + "ev": false, + "format": "uint32", + "iid": 356, + "perms": [ + "pr" + ], + "type": "E6", + "value": 6 + }, + { + "ev": false, + "format": "string", + "iid": 357, + "perms": [ + "pr" + ], + "type": "00000023-0000-1000-8000-0026BB765291", + "value": "HDMI 4" + }, + { + "ev": false, + "format": "string", + "iid": 358, + "maxLen": 25, + "perms": [ + "pr", + "pw", + "ev" + ], + "type": "E3", + "value": "HDMI 4" + }, + { + "ev": false, + "format": "uint8", + "iid": 360, + "maxValue": 6, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "DC", + "value": 4 + }, + { + "ev": false, + "format": "uint8", + "iid": 359, + "maxValue": 3, + "minStep": 1, + "minValue": 0, + "perms": [ + "pr", + "ev" + ], + "type": "135", + "value": 2 + } + ], + "hidden": false, + "iid": 352, + "linked": [], + "primary": false, + "stype": "Unknown Service: D9", + "type": "D9" + } + ] + } +] From da7c5518f310e2e5a23c47c9504606e8d3333120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Thu, 5 Mar 2020 21:29:53 +0200 Subject: [PATCH 251/416] Add Huawei LTE operator and network related sensors (#32485) * Add Huawei LTE operator and network related sensors Adds "Operator search mode", "Operator name", "Operator code", and "Preferred mode" sensors * Blackify * Blackify "Add Huawei LTE operator and network related sensors" --- .../components/huawei_lte/__init__.py | 4 +++ homeassistant/components/huawei_lte/const.py | 4 +++ homeassistant/components/huawei_lte/sensor.py | 25 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 261250e9c02..e4291ae7e67 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -66,6 +66,8 @@ from .const import ( KEY_MONITORING_MONTH_STATISTICS, KEY_MONITORING_STATUS, KEY_MONITORING_TRAFFIC_STATISTICS, + KEY_NET_CURRENT_PLMN, + KEY_NET_NET_MODE, KEY_WLAN_HOST_LIST, NOTIFY_SUPPRESS_TIMEOUT, SERVICE_CLEAR_TRAFFIC_STATISTICS, @@ -238,6 +240,8 @@ class Router: self._get_data( KEY_MONITORING_TRAFFIC_STATISTICS, self.client.monitoring.traffic_statistics ) + self._get_data(KEY_NET_CURRENT_PLMN, self.client.net.current_plmn) + self._get_data(KEY_NET_NET_MODE, self.client.net.net_mode) self._get_data(KEY_WLAN_HOST_LIST, self.client.wlan.host_list) self.signal_update() diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index 5a4aeb5f0b7..5279dd65b92 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -30,6 +30,8 @@ KEY_DIALUP_MOBILE_DATASWITCH = "dialup_mobile_dataswitch" KEY_MONITORING_MONTH_STATISTICS = "monitoring_month_statistics" KEY_MONITORING_STATUS = "monitoring_status" KEY_MONITORING_TRAFFIC_STATISTICS = "monitoring_traffic_statistics" +KEY_NET_CURRENT_PLMN = "net_current_plmn" +KEY_NET_NET_MODE = "net_net_mode" KEY_WLAN_HOST_LIST = "wlan_host_list" BINARY_SENSOR_KEYS = {KEY_MONITORING_STATUS} @@ -42,6 +44,8 @@ SENSOR_KEYS = { KEY_MONITORING_MONTH_STATISTICS, KEY_MONITORING_STATUS, KEY_MONITORING_TRAFFIC_STATISTICS, + KEY_NET_CURRENT_PLMN, + KEY_NET_NET_MODE, } SWITCH_KEYS = {KEY_DIALUP_MOBILE_DATASWITCH} diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index e49de1c05a3..84d8e72c2ff 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -20,6 +20,8 @@ from .const import ( KEY_MONITORING_MONTH_STATISTICS, KEY_MONITORING_STATUS, KEY_MONITORING_TRAFFIC_STATISTICS, + KEY_NET_CURRENT_PLMN, + KEY_NET_NET_MODE, SENSOR_KEYS, ) @@ -170,6 +172,29 @@ SENSOR_META = { (KEY_MONITORING_TRAFFIC_STATISTICS, "TotalUpload"): dict( name="Total upload", unit=DATA_BYTES, icon="mdi:upload" ), + KEY_NET_CURRENT_PLMN: dict(exclude=re.compile(r"^(Rat|ShortName)$", re.IGNORECASE)), + (KEY_NET_CURRENT_PLMN, "State"): dict( + name="Operator search mode", + formatter=lambda x: ({"0": "Auto", "1": "Manual"}.get(x, "Unknown"), None), + ), + (KEY_NET_CURRENT_PLMN, "FullName"): dict(name="Operator name",), + (KEY_NET_CURRENT_PLMN, "Numeric"): dict(name="Operator code",), + KEY_NET_NET_MODE: dict(include=re.compile(r"^NetworkMode$", re.IGNORECASE)), + (KEY_NET_NET_MODE, "NetworkMode"): dict( + name="Preferred mode", + formatter=lambda x: ( + { + "00": "4G/3G/2G", + "01": "2G", + "02": "3G", + "03": "4G", + "0301": "4G/2G", + "0302": "4G/3G", + "0201": "3G/2G", + }.get(x, "Unknown"), + None, + ), + ), } From 6a21afa2a8a6fa5ccaa59e2be518b9d7df8312cc Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Mar 2020 11:44:42 -0800 Subject: [PATCH 252/416] Improve script validation (#32461) --- homeassistant/const.py | 21 +++-- homeassistant/helpers/config_validation.py | 97 ++++++++++++++++------ homeassistant/helpers/script.py | 81 ++++++------------ homeassistant/helpers/service.py | 4 +- tests/helpers/test_config_validation.py | 27 +++++- 5 files changed, 141 insertions(+), 89 deletions(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4e4be408b40..66db936669b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -35,9 +35,9 @@ CONF_ALIAS = "alias" CONF_API_KEY = "api_key" CONF_API_VERSION = "api_version" CONF_AT = "at" -CONF_AUTHENTICATION = "authentication" CONF_AUTH_MFA_MODULES = "auth_mfa_modules" CONF_AUTH_PROVIDERS = "auth_providers" +CONF_AUTHENTICATION = "authentication" CONF_BASE = "base" CONF_BEFORE = "before" CONF_BELOW = "below" @@ -57,11 +57,13 @@ CONF_COMMAND_OPEN = "command_open" CONF_COMMAND_STATE = "command_state" CONF_COMMAND_STOP = "command_stop" CONF_CONDITION = "condition" +CONF_CONTINUE_ON_TIMEOUT = "continue_on_timeout" CONF_COVERS = "covers" CONF_CURRENCY = "currency" CONF_CUSTOMIZE = "customize" CONF_CUSTOMIZE_DOMAIN = "customize_domain" CONF_CUSTOMIZE_GLOB = "customize_glob" +CONF_DELAY = "delay" CONF_DELAY_TIME = "delay_time" CONF_DEVICE = "device" CONF_DEVICE_CLASS = "device_class" @@ -82,6 +84,8 @@ CONF_ENTITY_ID = "entity_id" CONF_ENTITY_NAMESPACE = "entity_namespace" CONF_ENTITY_PICTURE_TEMPLATE = "entity_picture_template" CONF_EVENT = "event" +CONF_EVENT_DATA = "event_data" +CONF_EVENT_DATA_TEMPLATE = "event_data_template" CONF_EXCLUDE = "exclude" CONF_FILE_PATH = "file_path" CONF_FILENAME = "filename" @@ -95,15 +99,15 @@ CONF_HOSTS = "hosts" CONF_HS = "hs" CONF_ICON = "icon" CONF_ICON_TEMPLATE = "icon_template" -CONF_INCLUDE = "include" CONF_ID = "id" +CONF_INCLUDE = "include" CONF_IP_ADDRESS = "ip_address" CONF_LATITUDE = "latitude" -CONF_LONGITUDE = "longitude" CONF_LIGHTS = "lights" +CONF_LONGITUDE = "longitude" CONF_MAC = "mac" -CONF_METHOD = "method" CONF_MAXIMUM = "maximum" +CONF_METHOD = "method" CONF_MINIMUM = "minimum" CONF_MODE = "mode" CONF_MONITORED_CONDITIONS = "monitored_conditions" @@ -130,14 +134,18 @@ CONF_RADIUS = "radius" CONF_RECIPIENT = "recipient" CONF_REGION = "region" CONF_RESOURCE = "resource" -CONF_RESOURCES = "resources" CONF_RESOURCE_TEMPLATE = "resource_template" +CONF_RESOURCES = "resources" CONF_RGB = "rgb" CONF_ROOM = "room" CONF_SCAN_INTERVAL = "scan_interval" +CONF_SCENE = "scene" CONF_SENDER = "sender" CONF_SENSOR_TYPE = "sensor_type" CONF_SENSORS = "sensors" +CONF_SERVICE = "service" +CONF_SERVICE_DATA = "data" +CONF_SERVICE_TEMPLATE = "service_template" CONF_SHOW_ON_MAP = "show_on_map" CONF_SLAVE = "slave" CONF_SOURCE = "source" @@ -159,11 +167,12 @@ CONF_URL = "url" CONF_USERNAME = "username" CONF_VALUE_TEMPLATE = "value_template" CONF_VERIFY_SSL = "verify_ssl" +CONF_WAIT_TEMPLATE = "wait_template" CONF_WEBHOOK_ID = "webhook_id" CONF_WEEKDAY = "weekday" +CONF_WHITE_VALUE = "white_value" CONF_WHITELIST = "whitelist" CONF_WHITELIST_EXTERNAL_DIRS = "whitelist_external_dirs" -CONF_WHITE_VALUE = "white_value" CONF_XY = "xy" CONF_ZONE = "zone" diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 565cac4058c..db966d93412 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -39,18 +39,27 @@ from homeassistant.const import ( CONF_ALIAS, CONF_BELOW, CONF_CONDITION, + CONF_CONTINUE_ON_TIMEOUT, + CONF_DELAY, CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, CONF_ENTITY_NAMESPACE, + CONF_EVENT, + CONF_EVENT_DATA, + CONF_EVENT_DATA_TEMPLATE, CONF_FOR, CONF_PLATFORM, CONF_SCAN_INTERVAL, + CONF_SCENE, + CONF_SERVICE, + CONF_SERVICE_TEMPLATE, CONF_STATE, CONF_TIMEOUT, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, CONF_VALUE_TEMPLATE, + CONF_WAIT_TEMPLATE, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE, SUN_EVENT_SUNRISE, @@ -722,7 +731,7 @@ def key_value_schemas( if key_value not in value_schemas: raise vol.Invalid( - f"Unexpected key {key_value}. Expected {', '.join(value_schemas)}" + f"Unexpected value for {key}: '{key_value}'. Expected {', '.join(value_schemas)}" ) return cast(Dict[str, Any], value_schemas[key_value](value)) @@ -800,9 +809,9 @@ def make_entity_service_schema( EVENT_SCHEMA = vol.Schema( { vol.Optional(CONF_ALIAS): string, - vol.Required("event"): string, - vol.Optional("event_data"): dict, - vol.Optional("event_data_template"): {match_all: template_complex}, + vol.Required(CONF_EVENT): string, + vol.Optional(CONF_EVENT_DATA): dict, + vol.Optional(CONF_EVENT_DATA_TEMPLATE): {match_all: template_complex}, } ) @@ -810,14 +819,14 @@ SERVICE_SCHEMA = vol.All( vol.Schema( { vol.Optional(CONF_ALIAS): string, - vol.Exclusive("service", "service name"): service, - vol.Exclusive("service_template", "service name"): template, + 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.Optional(CONF_ENTITY_ID): comp_entity_ids, } ), - has_at_least_one_key("service", "service_template"), + has_at_least_one_key(CONF_SERVICE, CONF_SERVICE_TEMPLATE), ) NUMERIC_STATE_CONDITION_SCHEMA = vol.All( @@ -943,7 +952,7 @@ CONDITION_SCHEMA: vol.Schema = key_value_schemas( _SCRIPT_DELAY_SCHEMA = vol.Schema( { vol.Optional(CONF_ALIAS): string, - vol.Required("delay"): vol.Any( + vol.Required(CONF_DELAY): vol.Any( vol.All(time_period, positive_timedelta), template, template_complex ), } @@ -952,9 +961,9 @@ _SCRIPT_DELAY_SCHEMA = vol.Schema( _SCRIPT_WAIT_TEMPLATE_SCHEMA = vol.Schema( { vol.Optional(CONF_ALIAS): string, - vol.Required("wait_template"): template, + vol.Required(CONF_WAIT_TEMPLATE): template, vol.Optional(CONF_TIMEOUT): vol.All(time_period, positive_timedelta), - vol.Optional("continue_on_timeout"): boolean, + vol.Optional(CONF_CONTINUE_ON_TIMEOUT): boolean, } ) @@ -964,19 +973,57 @@ DEVICE_ACTION_BASE_SCHEMA = vol.Schema( DEVICE_ACTION_SCHEMA = DEVICE_ACTION_BASE_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) -_SCRIPT_SCENE_SCHEMA = vol.Schema({vol.Required("scene"): entity_domain("scene")}) +_SCRIPT_SCENE_SCHEMA = vol.Schema({vol.Required(CONF_SCENE): entity_domain("scene")}) -SCRIPT_SCHEMA = vol.All( - ensure_list, - [ - vol.Any( - SERVICE_SCHEMA, - _SCRIPT_DELAY_SCHEMA, - _SCRIPT_WAIT_TEMPLATE_SCHEMA, - EVENT_SCHEMA, - CONDITION_SCHEMA, - DEVICE_ACTION_SCHEMA, - _SCRIPT_SCENE_SCHEMA, - ) - ], -) +SCRIPT_ACTION_DELAY = "delay" +SCRIPT_ACTION_WAIT_TEMPLATE = "wait_template" +SCRIPT_ACTION_CHECK_CONDITION = "condition" +SCRIPT_ACTION_FIRE_EVENT = "event" +SCRIPT_ACTION_CALL_SERVICE = "call_service" +SCRIPT_ACTION_DEVICE_AUTOMATION = "device" +SCRIPT_ACTION_ACTIVATE_SCENE = "scene" + + +def determine_script_action(action: dict) -> str: + """Determine action type.""" + if CONF_DELAY in action: + return SCRIPT_ACTION_DELAY + + if CONF_WAIT_TEMPLATE in action: + return SCRIPT_ACTION_WAIT_TEMPLATE + + if CONF_CONDITION in action: + return SCRIPT_ACTION_CHECK_CONDITION + + if CONF_EVENT in action: + return SCRIPT_ACTION_FIRE_EVENT + + if CONF_DEVICE_ID in action: + return SCRIPT_ACTION_DEVICE_AUTOMATION + + if CONF_SCENE in action: + return SCRIPT_ACTION_ACTIVATE_SCENE + + return SCRIPT_ACTION_CALL_SERVICE + + +ACTION_TYPE_SCHEMAS: Dict[str, Callable[[Any], dict]] = { + SCRIPT_ACTION_CALL_SERVICE: SERVICE_SCHEMA, + SCRIPT_ACTION_DELAY: _SCRIPT_DELAY_SCHEMA, + SCRIPT_ACTION_WAIT_TEMPLATE: _SCRIPT_WAIT_TEMPLATE_SCHEMA, + SCRIPT_ACTION_FIRE_EVENT: EVENT_SCHEMA, + SCRIPT_ACTION_CHECK_CONDITION: CONDITION_SCHEMA, + SCRIPT_ACTION_DEVICE_AUTOMATION: DEVICE_ACTION_SCHEMA, + SCRIPT_ACTION_ACTIVATE_SCENE: _SCRIPT_SCENE_SCHEMA, +} + + +def script_action(value: Any) -> dict: + """Validate a script action.""" + if not isinstance(value, dict): + raise vol.Invalid("expected dictionary") + + return ACTION_TYPE_SCHEMAS[determine_script_action(value)](value) + + +SCRIPT_SCHEMA = vol.All(ensure_list, [script_action]) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 1ce9d2b87bb..937a675aada 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -15,9 +15,16 @@ import homeassistant.components.scene as scene from homeassistant.const import ( ATTR_ENTITY_ID, CONF_CONDITION, + CONF_CONTINUE_ON_TIMEOUT, + CONF_DELAY, CONF_DEVICE_ID, CONF_DOMAIN, + CONF_EVENT, + CONF_EVENT_DATA, + CONF_EVENT_DATA_TEMPLATE, + CONF_SCENE, CONF_TIMEOUT, + CONF_WAIT_TEMPLATE, SERVICE_TURN_ON, ) from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback @@ -37,24 +44,6 @@ from homeassistant.util.dt import utcnow # mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs CONF_ALIAS = "alias" -CONF_SERVICE = "service" -CONF_SERVICE_DATA = "data" -CONF_SEQUENCE = "sequence" -CONF_EVENT = "event" -CONF_EVENT_DATA = "event_data" -CONF_EVENT_DATA_TEMPLATE = "event_data_template" -CONF_DELAY = "delay" -CONF_WAIT_TEMPLATE = "wait_template" -CONF_CONTINUE = "continue_on_timeout" -CONF_SCENE = "scene" - -ACTION_DELAY = "delay" -ACTION_WAIT_TEMPLATE = "wait_template" -ACTION_CHECK_CONDITION = "condition" -ACTION_FIRE_EVENT = "event" -ACTION_CALL_SERVICE = "call_service" -ACTION_DEVICE_AUTOMATION = "device" -ACTION_ACTIVATE_SCENE = "scene" IF_RUNNING_ERROR = "error" IF_RUNNING_IGNORE = "ignore" @@ -82,41 +71,21 @@ _LOG_EXCEPTION = logging.ERROR + 1 _TIMEOUT_MSG = "Timeout reached, abort script." -def _determine_action(action): - """Determine action type.""" - if CONF_DELAY in action: - return ACTION_DELAY - - if CONF_WAIT_TEMPLATE in action: - return ACTION_WAIT_TEMPLATE - - if CONF_CONDITION in action: - return ACTION_CHECK_CONDITION - - if CONF_EVENT in action: - return ACTION_FIRE_EVENT - - if CONF_DEVICE_ID in action: - return ACTION_DEVICE_AUTOMATION - - if CONF_SCENE in action: - return ACTION_ACTIVATE_SCENE - - return ACTION_CALL_SERVICE - - async def async_validate_action_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" - action_type = _determine_action(config) + action_type = cv.determine_script_action(config) - if action_type == ACTION_DEVICE_AUTOMATION: + if action_type == cv.SCRIPT_ACTION_DEVICE_AUTOMATION: platform = await device_automation.async_get_device_automation_platform( hass, config[CONF_DOMAIN], "action" ) config = platform.ACTION_SCHEMA(config) # type: ignore - if action_type == ACTION_CHECK_CONDITION and config[CONF_CONDITION] == "device": + if ( + action_type == cv.SCRIPT_ACTION_CHECK_CONDITION + and config[CONF_CONDITION] == "device" + ): platform = await device_automation.async_get_device_automation_platform( hass, config[CONF_DOMAIN], "condition" ) @@ -165,7 +134,9 @@ class _ScriptRunBase(ABC): async def _async_step(self, log_exceptions): try: - await getattr(self, f"_async_{_determine_action(self._action)}_step")() + await getattr( + self, f"_async_{cv.determine_script_action(self._action)}_step" + )() except Exception as err: if not isinstance(err, (_SuspendScript, _StopScript)) and ( self._log_exceptions or log_exceptions @@ -178,7 +149,7 @@ class _ScriptRunBase(ABC): """Stop script run.""" def _log_exception(self, exception): - action_type = _determine_action(self._action) + action_type = cv.determine_script_action(self._action) error = str(exception) level = logging.ERROR @@ -406,7 +377,7 @@ class _ScriptRun(_ScriptRunBase): timeout, ) except asyncio.TimeoutError: - if not self._action.get(CONF_CONTINUE, True): + if not self._action.get(CONF_CONTINUE_ON_TIMEOUT, True): self._log(_TIMEOUT_MSG) raise _StopScript finally: @@ -547,7 +518,7 @@ class _LegacyScriptRun(_ScriptRunBase): # Check if we want to continue to execute # the script after the timeout - if self._action.get(CONF_CONTINUE, True): + if self._action.get(CONF_CONTINUE_ON_TIMEOUT, True): self._hass.async_create_task(self._async_run(False)) else: self._log(_TIMEOUT_MSG) @@ -632,12 +603,12 @@ class Script: referenced = set() for step in self.sequence: - action = _determine_action(step) + action = cv.determine_script_action(step) - if action == ACTION_CHECK_CONDITION: + if action == cv.SCRIPT_ACTION_CHECK_CONDITION: referenced |= condition.async_extract_devices(step) - elif action == ACTION_DEVICE_AUTOMATION: + elif action == cv.SCRIPT_ACTION_DEVICE_AUTOMATION: referenced.add(step[CONF_DEVICE_ID]) self._referenced_devices = referenced @@ -652,9 +623,9 @@ class Script: referenced = set() for step in self.sequence: - action = _determine_action(step) + action = cv.determine_script_action(step) - if action == ACTION_CALL_SERVICE: + if action == cv.SCRIPT_ACTION_CALL_SERVICE: data = step.get(service.CONF_SERVICE_DATA) if not data: continue @@ -670,10 +641,10 @@ class Script: for entity_id in entity_ids: referenced.add(entity_id) - elif action == ACTION_CHECK_CONDITION: + elif action == cv.SCRIPT_ACTION_CHECK_CONDITION: referenced |= condition.async_extract_entities(step) - elif action == ACTION_ACTIVATE_SCENE: + elif action == cv.SCRIPT_ACTION_ACTIVATE_SCENE: referenced.add(step[CONF_SCENE]) self._referenced_entities = referenced diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 9085c929651..578d5368314 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -10,6 +10,8 @@ from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL from homeassistant.const import ( ATTR_AREA_ID, ATTR_ENTITY_ID, + CONF_SERVICE, + CONF_SERVICE_TEMPLATE, ENTITY_MATCH_ALL, ENTITY_MATCH_NONE, ) @@ -29,8 +31,6 @@ from homeassistant.util.yaml.loader import JSON_TYPE # mypy: allow-untyped-defs, no-check-untyped-defs -CONF_SERVICE = "service" -CONF_SERVICE_TEMPLATE = "service_template" CONF_SERVICE_ENTITY_ID = "entity_id" CONF_SERVICE_DATA = "data" CONF_SERVICE_DATA_TEMPLATE = "data_template" diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index 71d845ac637..ff269d2b8c6 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1008,7 +1008,10 @@ def test_key_value_schemas(): for mode in None, "invalid": with pytest.raises(vol.Invalid) as excinfo: schema({"mode": mode}) - assert str(excinfo.value) == f"Unexpected key {mode}. Expected number, string" + assert ( + str(excinfo.value) + == f"Unexpected value for mode: '{mode}'. Expected number, string" + ) with pytest.raises(vol.Invalid) as excinfo: schema({"mode": "number", "data": "string-value"}) @@ -1020,3 +1023,25 @@ def test_key_value_schemas(): for mode, data in (("number", 1), ("string", "hello")): schema({"mode": mode, "data": data}) + + +def test_script(caplog): + """Test script validation is user friendly.""" + for data, msg in ( + ({"delay": "{{ invalid"}, "should be format 'HH:MM'"), + ({"wait_template": "{{ invalid"}, "invalid template"), + ({"condition": "invalid"}, "Unexpected value for condition: 'invalid'"), + ({"event": None}, "string value is None for dictionary value @ data['event']"), + ( + {"device_id": None}, + "string value is None for dictionary value @ data['device_id']", + ), + ( + {"scene": "light.kitchen"}, + "Entity ID 'light.kitchen' does not belong to domain 'scene'", + ), + ): + with pytest.raises(vol.Invalid) as excinfo: + cv.script_action(data) + + assert msg in str(excinfo.value) From d885853b355d9457c56ce7ac4485510048de39e9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Mar 2020 11:45:36 -0800 Subject: [PATCH 253/416] Make it possible to fetch proxy media player album art (#32471) --- .../components/media_player/__init__.py | 29 ++++++----- tests/components/media_player/test_init.py | 49 ++++++++++++------- 2 files changed, 48 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 8a31dbe6bdb..a62b6bd7c2b 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -757,6 +757,11 @@ class MediaPlayerDevice(Entity): if self.media_image_remotely_accessible: return self.media_image_url + return self.media_image_local + + @property + def media_image_local(self): + """Return local url to media image.""" image_hash = self.media_image_hash if image_hash is None: @@ -788,11 +793,15 @@ class MediaPlayerDevice(Entity): if self.state == STATE_OFF: return None - state_attr = { - attr: getattr(self, attr) - for attr in ATTR_TO_PROPERTY - if getattr(self, attr) is not None - } + state_attr = {} + + for attr in ATTR_TO_PROPERTY: + value = getattr(self, attr) + if value is not None: + state_attr[attr] = value + + if self.media_image_remotely_accessible: + state_attr["entity_picture_local"] = self.media_image_local return state_attr @@ -863,12 +872,6 @@ class MediaPlayerImageView(HomeAssistantView): if not authenticated: return web.Response(status=401) - if player.media_image_remotely_accessible: - url = player.media_image_url - if url is not None: - return web.Response(status=302, headers={"location": url}) - return web.Response(status=500) - data, content_type = await player.async_get_media_image() if data is None: @@ -895,6 +898,10 @@ async def websocket_handle_thumbnail(hass, connection, msg): ) return + _LOGGER.warning( + "The websocket command media_player_thumbnail is deprecated. Use /api/media_player_proxy instead." + ) + data, content_type = await player.async_get_media_image() if data is None: diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 3db92cda42d..f3d8ec3298a 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -1,6 +1,7 @@ """Test the base functions of the media player.""" import base64 -from unittest.mock import patch + +from asynctest import patch from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.setup import async_setup_component @@ -8,7 +9,7 @@ from homeassistant.setup import async_setup_component from tests.common import mock_coro -async def test_get_image(hass, hass_ws_client): +async def test_get_image(hass, hass_ws_client, caplog): """Test get image via WS command.""" await async_setup_component( hass, "media_player", {"media_player": {"platform": "demo"}} @@ -37,43 +38,53 @@ async def test_get_image(hass, hass_ws_client): assert msg["result"]["content_type"] == "image/jpeg" assert msg["result"]["content"] == base64.b64encode(b"image").decode("utf-8") + assert "media_player_thumbnail is deprecated" in caplog.text -async def test_get_image_http(hass, hass_client): + +async def test_get_image_http(hass, aiohttp_client): """Test get image via http command.""" await async_setup_component( hass, "media_player", {"media_player": {"platform": "demo"}} ) - client = await hass_client() + state = hass.states.get("media_player.bedroom") + assert "entity_picture_local" not in state.attributes + + client = await aiohttp_client(hass.http.app) with patch( "homeassistant.components.media_player.MediaPlayerDevice." "async_get_media_image", - return_value=mock_coro((b"image", "image/jpeg")), + return_value=(b"image", "image/jpeg"), ): - resp = await client.get("/api/media_player_proxy/media_player.bedroom") + resp = await client.get(state.attributes["entity_picture"]) content = await resp.read() assert content == b"image" -async def test_get_image_http_url(hass, hass_client): +async def test_get_image_http_remote(hass, aiohttp_client): """Test get image url via http command.""" - await async_setup_component( - hass, "media_player", {"media_player": {"platform": "demo"}} - ) - - client = await hass_client() - with patch( "homeassistant.components.media_player.MediaPlayerDevice." "media_image_remotely_accessible", return_value=True, ): - resp = await client.get( - "/api/media_player_proxy/media_player.bedroom", allow_redirects=False - ) - assert ( - resp.headers["Location"] - == "https://img.youtube.com/vi/kxopViU98Xo/hqdefault.jpg" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} ) + + state = hass.states.get("media_player.bedroom") + assert "entity_picture_local" in state.attributes + + client = await aiohttp_client(hass.http.app) + + with patch( + "homeassistant.components.media_player.MediaPlayerDevice." + "async_get_media_image", + return_value=(b"image", "image/jpeg"), + ): + resp = await client.get(state.attributes["entity_picture_local"]) + content = await resp.read() + + assert content == b"image" From 7c51318861b0357f4819f6eae0373e2aa086ada5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Mar 2020 11:52:12 -0800 Subject: [PATCH 254/416] Require title, separate show sidebar option (#32479) * Require title, separate show sidebar option * Fix list command not updating * Some more test checks --- homeassistant/components/lovelace/__init__.py | 10 ++- homeassistant/components/lovelace/const.py | 21 +++--- .../components/lovelace/dashboard.py | 6 +- tests/components/lovelace/test_dashboard.py | 73 ++++++++++++++----- 4 files changed, 73 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 65c3b11b369..23e8a14e511 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -16,10 +16,11 @@ from .const import ( CONF_MODE, CONF_REQUIRE_ADMIN, CONF_RESOURCES, - CONF_SIDEBAR, + CONF_SHOW_IN_SIDEBAR, CONF_TITLE, CONF_URL_PATH, DASHBOARD_BASE_CREATE_FIELDS, + DEFAULT_ICON, DOMAIN, MODE_STORAGE, MODE_YAML, @@ -171,6 +172,7 @@ async def async_setup(hass, config): update = False else: + hass.data[DOMAIN]["dashboards"][url_path].config = item update = True try: @@ -207,8 +209,8 @@ def _register_panel(hass, url_path, mode, config, update): "update": update, } - if CONF_SIDEBAR in config: - kwargs["sidebar_title"] = config[CONF_SIDEBAR][CONF_TITLE] - kwargs["sidebar_icon"] = config[CONF_SIDEBAR][CONF_ICON] + if config[CONF_SHOW_IN_SIDEBAR]: + kwargs["sidebar_title"] = config[CONF_TITLE] + kwargs["sidebar_icon"] = config.get(CONF_ICON, DEFAULT_ICON) frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs) diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index de6aa99894a..7205ae21cbe 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -11,6 +11,8 @@ from homeassistant.util import slugify DOMAIN = "lovelace" EVENT_LOVELACE_UPDATED = "lovelace_updated" +DEFAULT_ICON = "hass:view-dashboard" + CONF_MODE = "mode" MODE_YAML = "yaml" MODE_STORAGE = "storage" @@ -39,24 +41,23 @@ RESOURCE_UPDATE_FIELDS = { vol.Optional(CONF_URL): cv.string, } -CONF_SIDEBAR = "sidebar" CONF_TITLE = "title" CONF_REQUIRE_ADMIN = "require_admin" - -SIDEBAR_FIELDS = { - vol.Required(CONF_ICON): cv.icon, - vol.Required(CONF_TITLE): cv.string, -} +CONF_SHOW_IN_SIDEBAR = "show_in_sidebar" DASHBOARD_BASE_CREATE_FIELDS = { vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean, - vol.Optional(CONF_SIDEBAR): SIDEBAR_FIELDS, + vol.Optional(CONF_ICON): cv.icon, + vol.Required(CONF_TITLE): cv.string, + vol.Optional(CONF_SHOW_IN_SIDEBAR, default=True): cv.boolean, } DASHBOARD_BASE_UPDATE_FIELDS = { vol.Optional(CONF_REQUIRE_ADMIN): cv.boolean, - vol.Optional(CONF_SIDEBAR): vol.Any(None, SIDEBAR_FIELDS), + vol.Optional(CONF_ICON): vol.Any(cv.icon, None), + vol.Optional(CONF_TITLE): cv.string, + vol.Optional(CONF_SHOW_IN_SIDEBAR): cv.boolean, } @@ -68,9 +69,7 @@ STORAGE_DASHBOARD_CREATE_FIELDS = { vol.Optional(CONF_MODE, default=MODE_STORAGE): MODE_STORAGE, } -STORAGE_DASHBOARD_UPDATE_FIELDS = { - **DASHBOARD_BASE_UPDATE_FIELDS, -} +STORAGE_DASHBOARD_UPDATE_FIELDS = DASHBOARD_BASE_UPDATE_FIELDS def url_slug(value: Any) -> str: diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index cd0d4a6fea8..514f1eb87b6 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -13,7 +13,7 @@ from homeassistant.helpers import collection, storage from homeassistant.util.yaml import load_yaml from .const import ( - CONF_SIDEBAR, + CONF_ICON, CONF_URL_PATH, DOMAIN, EVENT_LOVELACE_UPDATED, @@ -246,7 +246,7 @@ class DashboardsCollection(collection.StorageCollection): update_data = self.UPDATE_SCHEMA(update_data) updated = {**data, **update_data} - if CONF_SIDEBAR in updated and updated[CONF_SIDEBAR] is None: - updated.pop(CONF_SIDEBAR) + if CONF_ICON in updated and updated[CONF_ICON] is None: + updated.pop(CONF_ICON) return updated diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 0b6d6806cb0..21a44bc771d 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -209,10 +209,16 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path): "test-panel": { "mode": "yaml", "filename": "bla.yaml", - "sidebar": {"title": "Test Panel", "icon": "mdi:test-icon"}, + "title": "Test Panel", + "icon": "mdi:test-icon", + "show_in_sidebar": False, "require_admin": True, }, - "test-panel-no-sidebar": {"mode": "yaml", "filename": "bla2.yaml"}, + "test-panel-no-sidebar": { + "title": "Title No Sidebar", + "mode": "yaml", + "filename": "bla2.yaml", + }, } } }, @@ -233,13 +239,15 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path): assert with_sb["mode"] == "yaml" assert with_sb["filename"] == "bla.yaml" - assert with_sb["sidebar"] == {"title": "Test Panel", "icon": "mdi:test-icon"} + assert with_sb["title"] == "Test Panel" + assert with_sb["icon"] == "mdi:test-icon" + assert with_sb["show_in_sidebar"] is False assert with_sb["require_admin"] is True assert with_sb["url_path"] == "test-panel" assert without_sb["mode"] == "yaml" assert without_sb["filename"] == "bla2.yaml" - assert "sidebar" not in without_sb + assert without_sb["show_in_sidebar"] is True assert without_sb["require_admin"] is False assert without_sb["url_path"] == "test-panel-no-sidebar" @@ -315,16 +323,15 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): "type": "lovelace/dashboards/create", "url_path": "created_url_path", "require_admin": True, - "sidebar": {"title": "Updated Title", "icon": "mdi:map"}, + "title": "New Title", + "icon": "mdi:map", } ) response = await client.receive_json() assert response["success"] assert response["result"]["require_admin"] is True - assert response["result"]["sidebar"] == { - "title": "Updated Title", - "icon": "mdi:map", - } + assert response["result"]["title"] == "New Title" + assert response["result"]["icon"] == "mdi:map" dashboard_id = response["result"]["id"] @@ -335,10 +342,9 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): assert response["success"] assert len(response["result"]) == 1 assert response["result"][0]["mode"] == "storage" - assert response["result"][0]["sidebar"] == { - "title": "Updated Title", - "icon": "mdi:map", - } + assert response["result"][0]["title"] == "New Title" + assert response["result"][0]["icon"] == "mdi:map" + assert response["result"][0]["show_in_sidebar"] is True assert response["result"][0]["require_admin"] is True # Fetch config @@ -382,24 +388,42 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): "type": "lovelace/dashboards/update", "dashboard_id": dashboard_id, "require_admin": False, - "sidebar": None, + "icon": "mdi:updated", + "show_in_sidebar": False, + "title": "Updated Title", } ) response = await client.receive_json() assert response["success"] + assert response["result"]["mode"] == "storage" + assert response["result"]["url_path"] == "created_url_path" + assert response["result"]["title"] == "Updated Title" + assert response["result"]["icon"] == "mdi:updated" + assert response["result"]["show_in_sidebar"] is False assert response["result"]["require_admin"] is False - assert "sidebar" not in response["result"] + + # List dashboards again and make sure we see latest config + await client.send_json({"id": 12, "type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + assert len(response["result"]) == 1 + assert response["result"][0]["mode"] == "storage" + assert response["result"][0]["url_path"] == "created_url_path" + assert response["result"][0]["title"] == "Updated Title" + assert response["result"][0]["icon"] == "mdi:updated" + assert response["result"][0]["show_in_sidebar"] is False + assert response["result"][0]["require_admin"] is False # Add dashboard with existing url path await client.send_json( - {"id": 12, "type": "lovelace/dashboards/create", "url_path": "created_url_path"} + {"id": 13, "type": "lovelace/dashboards/create", "url_path": "created_url_path"} ) response = await client.receive_json() assert not response["success"] # Delete dashboards await client.send_json( - {"id": 13, "type": "lovelace/dashboards/delete", "dashboard_id": dashboard_id} + {"id": 14, "type": "lovelace/dashboards/delete", "dashboard_id": dashboard_id} ) response = await client.receive_json() assert response["success"] @@ -416,7 +440,11 @@ async def test_websocket_list_dashboards(hass, hass_ws_client): { "lovelace": { "dashboards": { - "test-panel-no-sidebar": {"mode": "yaml", "filename": "bla.yaml"}, + "test-panel-no-sidebar": { + "title": "Test YAML", + "mode": "yaml", + "filename": "bla.yaml", + }, } } }, @@ -426,7 +454,12 @@ async def test_websocket_list_dashboards(hass, hass_ws_client): # Create a storage dashboard await client.send_json( - {"id": 6, "type": "lovelace/dashboards/create", "url_path": "created_url_path"} + { + "id": 6, + "type": "lovelace/dashboards/create", + "url_path": "created_url_path", + "title": "Test Storage", + } ) response = await client.receive_json() assert response["success"] @@ -439,8 +472,10 @@ async def test_websocket_list_dashboards(hass, hass_ws_client): with_sb, without_sb = response["result"] assert with_sb["mode"] == "yaml" + assert with_sb["title"] == "Test YAML" assert with_sb["filename"] == "bla.yaml" assert with_sb["url_path"] == "test-panel-no-sidebar" assert without_sb["mode"] == "storage" + assert without_sb["title"] == "Test Storage" assert without_sb["url_path"] == "created_url_path" From 8aea5386626f8b41b83458e36e5fa36068e8674f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Mar 2020 11:55:50 -0800 Subject: [PATCH 255/416] Allow teaching logbook about events (#32444) * Allow teaching logbook about events * Use async_add_executor_job * Fix tests --- homeassistant/components/alexa/__init__.py | 34 +++++- homeassistant/components/alexa/const.py | 1 + homeassistant/components/alexa/manifest.json | 1 + homeassistant/components/alexa/smart_home.py | 4 +- homeassistant/components/cloud/manifest.json | 4 +- homeassistant/components/logbook/__init__.py | 51 ++++---- homeassistant/scripts/benchmark/__init__.py | 2 +- tests/components/alexa/test_init.py | 63 ++++++++++ tests/components/alexa/test_intent.py | 3 +- tests/components/logbook/test_init.py | 117 ++++++++----------- 10 files changed, 175 insertions(+), 105 deletions(-) create mode 100644 tests/components/alexa/test_init.py diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py index 5861c4cc985..1355b0123b8 100644 --- a/homeassistant/components/alexa/__init__.py +++ b/homeassistant/components/alexa/__init__.py @@ -4,6 +4,7 @@ import logging import voluptuous as vol from homeassistant.const import CONF_NAME +from homeassistant.core import callback from homeassistant.helpers import config_validation as cv, entityfilter from . import flash_briefings, intent, smart_home_http @@ -23,6 +24,7 @@ from .const import ( CONF_TITLE, CONF_UID, DOMAIN, + EVENT_ALEXA_SMART_HOME, ) _LOGGER = logging.getLogger(__name__) @@ -80,7 +82,37 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass, config): """Activate the Alexa component.""" - config = config.get(DOMAIN, {}) + + @callback + def async_describe_logbook_event(event): + """Describe a logbook event.""" + data = event.data + entity_id = data["request"].get("entity_id") + + if entity_id: + state = hass.states.get(entity_id) + name = state.name if state else entity_id + message = f"send command {data['request']['namespace']}/{data['request']['name']} for {name}" + else: + message = ( + f"send command {data['request']['namespace']}/{data['request']['name']}" + ) + + return { + "name": "Amazon Alexa", + "message": message, + "entity_id": entity_id, + } + + hass.components.logbook.async_describe_event( + DOMAIN, EVENT_ALEXA_SMART_HOME, async_describe_logbook_event + ) + + if DOMAIN not in config: + return True + + config = config[DOMAIN] + flash_briefings_config = config.get(CONF_FLASH_BRIEFINGS) intent.async_setup(hass) diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py index e45bcf824bc..ca1c6236fe6 100644 --- a/homeassistant/components/alexa/const.py +++ b/homeassistant/components/alexa/const.py @@ -6,6 +6,7 @@ from homeassistant.components.climate import const as climate from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT DOMAIN = "alexa" +EVENT_ALEXA_SMART_HOME = "alexa_smart_home" # Flash briefing constants CONF_UID = "uid" diff --git a/homeassistant/components/alexa/manifest.json b/homeassistant/components/alexa/manifest.json index 5334cf765b8..bf8d4b08ba4 100644 --- a/homeassistant/components/alexa/manifest.json +++ b/homeassistant/components/alexa/manifest.json @@ -4,5 +4,6 @@ "documentation": "https://www.home-assistant.io/integrations/alexa", "requirements": [], "dependencies": ["http"], + "after_dependencies": ["logbook"], "codeowners": ["@home-assistant/cloud", "@ochlocracy"] } diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index 9b0955f8fca..0f166ab3a27 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -3,15 +3,13 @@ import logging import homeassistant.core as ha -from .const import API_DIRECTIVE, API_HEADER +from .const import API_DIRECTIVE, API_HEADER, EVENT_ALEXA_SMART_HOME from .errors import AlexaBridgeUnreachableError, AlexaError from .handlers import HANDLERS from .messages import AlexaDirective _LOGGER = logging.getLogger(__name__) -EVENT_ALEXA_SMART_HOME = "alexa_smart_home" - async def async_handle_message(hass, config, request, context=None, enabled=True): """Handle incoming API messages. diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 34ef7a6dfa5..f3ea66971ac 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Cloud", "documentation": "https://www.home-assistant.io/integrations/cloud", "requirements": ["hass-nabucasa==0.31"], - "dependencies": ["http", "webhook"], - "after_dependencies": ["alexa", "google_assistant"], + "dependencies": ["http", "webhook", "alexa"], + "after_dependencies": ["google_assistant"], "codeowners": ["@home-assistant/cloud"] } diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 9921abdb59d..959b2be68d9 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -8,7 +8,6 @@ from sqlalchemy.exc import SQLAlchemyError import voluptuous as vol from homeassistant.components import sun -from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME from homeassistant.components.homekit.const import ( ATTR_DISPLAY_NAME, ATTR_VALUE, @@ -90,7 +89,6 @@ ALL_EVENT_TYPES = [ EVENT_LOGBOOK_ENTRY, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - EVENT_ALEXA_SMART_HOME, EVENT_HOMEKIT_CHANGED, EVENT_AUTOMATION_TRIGGERED, EVENT_SCRIPT_STARTED, @@ -124,6 +122,12 @@ def async_log_entry(hass, name, message, domain=None, entity_id=None): hass.bus.async_fire(EVENT_LOGBOOK_ENTRY, data) +@bind_hass +def async_describe_event(hass, domain, event_name, describe_callback): + """Teach logbook how to describe a new event.""" + hass.data.setdefault(DOMAIN, {})[event_name] = (domain, describe_callback) + + async def async_setup(hass, config): """Listen for download events to download files.""" @@ -237,7 +241,17 @@ def humanify(hass, events): start_stop_events[event.time_fired.minute] = 2 # Yield entries + external_events = hass.data.get(DOMAIN, {}) for event in events_batch: + if event.event_type in external_events: + domain, describe_event = external_events[event.event_type] + data = describe_event(event) + data["when"] = event.time_fired + data["domain"] = domain + data["context_id"] = event.context.id + data["context_user_id"] = event.context.user_id + yield data + if event.event_type == EVENT_STATE_CHANGED: to_state = State.from_dict(event.data.get("new_state")) @@ -320,27 +334,6 @@ def humanify(hass, events): "context_user_id": event.context.user_id, } - elif event.event_type == EVENT_ALEXA_SMART_HOME: - data = event.data - entity_id = data["request"].get("entity_id") - - if entity_id: - state = hass.states.get(entity_id) - name = state.name if state else entity_id - message = f"send command {data['request']['namespace']}/{data['request']['name']} for {name}" - else: - message = f"send command {data['request']['namespace']}/{data['request']['name']}" - - yield { - "when": event.time_fired, - "name": "Amazon Alexa", - "message": message, - "domain": "alexa", - "entity_id": entity_id, - "context_id": event.context.id, - "context_user_id": event.context.user_id, - } - elif event.event_type == EVENT_HOMEKIT_CHANGED: data = event.data entity_id = data.get(ATTR_ENTITY_ID) @@ -436,7 +429,7 @@ def _get_events(hass, config, start_day, end_day, entity_id=None): """Yield Events that are not filtered away.""" for row in query.yield_per(500): event = row.to_native() - if _keep_event(event, entities_filter): + if _keep_event(hass, event, entities_filter): yield event with session_scope(hass=hass) as session: @@ -449,7 +442,9 @@ def _get_events(hass, config, start_day, end_day, entity_id=None): session.query(Events) .order_by(Events.time_fired) .outerjoin(States, (Events.event_id == States.event_id)) - .filter(Events.event_type.in_(ALL_EVENT_TYPES)) + .filter( + Events.event_type.in_(ALL_EVENT_TYPES + list(hass.data.get(DOMAIN, {}))) + ) .filter((Events.time_fired > start_day) & (Events.time_fired < end_day)) .filter( ( @@ -463,7 +458,7 @@ def _get_events(hass, config, start_day, end_day, entity_id=None): return list(humanify(hass, yield_events(query))) -def _keep_event(event, entities_filter): +def _keep_event(hass, event, entities_filter): domain, entity_id = None, None if event.event_type == EVENT_STATE_CHANGED: @@ -514,8 +509,8 @@ def _keep_event(event, entities_filter): domain = "script" entity_id = event.data.get(ATTR_ENTITY_ID) - elif event.event_type == EVENT_ALEXA_SMART_HOME: - domain = "alexa" + elif event.event_type in hass.data.get(DOMAIN, {}): + domain = hass.data[DOMAIN][event.event_type][0] elif event.event_type == EVENT_HOMEKIT_CHANGED: domain = DOMAIN_HOMEKIT diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index 4d7df6d7248..d28b8ab08f7 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -177,7 +177,7 @@ def _logbook_filtering(hass, last_changed, last_updated): # pylint: disable=protected-access entities_filter = logbook._generate_filter_from_config({}) for _ in range(10 ** 5): - if logbook._keep_event(event, entities_filter): + if logbook._keep_event(hass, event, entities_filter): yield event start = timer() diff --git a/tests/components/alexa/test_init.py b/tests/components/alexa/test_init.py new file mode 100644 index 00000000000..212b48cb436 --- /dev/null +++ b/tests/components/alexa/test_init.py @@ -0,0 +1,63 @@ +"""Tests for alexa.""" +from homeassistant.components import logbook +from homeassistant.components.alexa.const import EVENT_ALEXA_SMART_HOME +import homeassistant.core as ha +from homeassistant.setup import async_setup_component + + +async def test_humanify_alexa_event(hass): + """Test humanifying Alexa event.""" + await async_setup_component(hass, "alexa", {}) + hass.states.async_set("light.kitchen", "on", {"friendly_name": "Kitchen Light"}) + + results = list( + logbook.humanify( + hass, + [ + ha.Event( + EVENT_ALEXA_SMART_HOME, + {"request": {"namespace": "Alexa.Discovery", "name": "Discover"}}, + ), + ha.Event( + EVENT_ALEXA_SMART_HOME, + { + "request": { + "namespace": "Alexa.PowerController", + "name": "TurnOn", + "entity_id": "light.kitchen", + } + }, + ), + ha.Event( + EVENT_ALEXA_SMART_HOME, + { + "request": { + "namespace": "Alexa.PowerController", + "name": "TurnOn", + "entity_id": "light.non_existing", + } + }, + ), + ], + ) + ) + + event1, event2, event3 = results + + assert event1["name"] == "Amazon Alexa" + assert event1["message"] == "send command Alexa.Discovery/Discover" + assert event1["entity_id"] is None + + assert event2["name"] == "Amazon Alexa" + assert ( + event2["message"] + == "send command Alexa.PowerController/TurnOn for Kitchen Light" + ) + assert event2["entity_id"] == "light.kitchen" + + assert event3["name"] == "Amazon Alexa" + assert ( + event3["message"] + == "send command Alexa.PowerController/TurnOn for light.non_existing" + ) + assert event3["entity_id"] == "light.non_existing" diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index 962ba677403..8937a7938ac 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -37,7 +37,8 @@ def alexa_client(loop, hass, hass_client): alexa.DOMAIN, { # Key is here to verify we allow other keys in config too - "homeassistant": {} + "homeassistant": {}, + "alexa": {}, }, ) ) diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index 750ad17b523..e64abe8dbb7 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta import logging import unittest +from asynctest import patch import pytest import voluptuous as vol @@ -169,7 +170,7 @@ class TestComponentLogbook(unittest.TestCase): events = [ e for e in (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB) - if logbook._keep_event(e, entities_filter) + if logbook._keep_event(self.hass, e, entities_filter) ] entries = list(logbook.humanify(self.hass, events)) @@ -196,7 +197,7 @@ class TestComponentLogbook(unittest.TestCase): events = [ e for e in (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB) - if logbook._keep_event(e, entities_filter) + if logbook._keep_event(self.hass, e, entities_filter) ] entries = list(logbook.humanify(self.hass, events)) @@ -224,7 +225,7 @@ class TestComponentLogbook(unittest.TestCase): events = [ e for e in (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB) - if logbook._keep_event(e, entities_filter) + if logbook._keep_event(self.hass, e, entities_filter) ] entries = list(logbook.humanify(self.hass, events)) @@ -258,7 +259,7 @@ class TestComponentLogbook(unittest.TestCase): events = [ e for e in (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB) - if logbook._keep_event(e, entities_filter) + if logbook._keep_event(self.hass, e, entities_filter) ] entries = list(logbook.humanify(self.hass, events)) @@ -300,7 +301,7 @@ class TestComponentLogbook(unittest.TestCase): eventA, eventB, ) - if logbook._keep_event(e, entities_filter) + if logbook._keep_event(self.hass, e, entities_filter) ] entries = list(logbook.humanify(self.hass, events)) @@ -341,7 +342,7 @@ class TestComponentLogbook(unittest.TestCase): events = [ e for e in (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB) - if logbook._keep_event(e, entities_filter) + if logbook._keep_event(self.hass, e, entities_filter) ] entries = list(logbook.humanify(self.hass, events)) @@ -380,7 +381,7 @@ class TestComponentLogbook(unittest.TestCase): events = [ e for e in (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB) - if logbook._keep_event(e, entities_filter) + if logbook._keep_event(self.hass, e, entities_filter) ] entries = list(logbook.humanify(self.hass, events)) @@ -412,7 +413,7 @@ class TestComponentLogbook(unittest.TestCase): events = [ e for e in (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB) - if logbook._keep_event(e, entities_filter) + if logbook._keep_event(self.hass, e, entities_filter) ] entries = list(logbook.humanify(self.hass, events)) @@ -426,6 +427,7 @@ class TestComponentLogbook(unittest.TestCase): def test_include_events_domain(self): """Test if events are filtered if domain is included in config.""" + assert setup_component(self.hass, "alexa", {}) entity_id = "switch.bla" entity_id2 = "sensor.blu" pointA = dt_util.utcnow() @@ -467,7 +469,7 @@ class TestComponentLogbook(unittest.TestCase): eventA, eventB, ) - if logbook._keep_event(e, entities_filter) + if logbook._keep_event(self.hass, e, entities_filter) ] entries = list(logbook.humanify(self.hass, events)) @@ -521,7 +523,7 @@ class TestComponentLogbook(unittest.TestCase): eventB1, eventB2, ) - if logbook._keep_event(e, entities_filter) + if logbook._keep_event(self.hass, e, entities_filter) ] entries = list(logbook.humanify(self.hass, events)) @@ -553,7 +555,9 @@ class TestComponentLogbook(unittest.TestCase): entities_filter = logbook._generate_filter_from_config({}) events = [ - e for e in (eventA, eventB) if logbook._keep_event(e, entities_filter) + e + for e in (eventA, eventB) + if logbook._keep_event(self.hass, e, entities_filter) ] entries = list(logbook.humanify(self.hass, events)) @@ -576,7 +580,9 @@ class TestComponentLogbook(unittest.TestCase): entities_filter = logbook._generate_filter_from_config({}) events = [ - e for e in (eventA, eventB) if logbook._keep_event(e, entities_filter) + e + for e in (eventA, eventB) + if logbook._keep_event(self.hass, e, entities_filter) ] entries = list(logbook.humanify(self.hass, events)) @@ -1333,63 +1339,6 @@ async def test_logbook_view_period_entity(hass, hass_client): assert json[0]["entity_id"] == entity_id_test -async def test_humanify_alexa_event(hass): - """Test humanifying Alexa event.""" - hass.states.async_set("light.kitchen", "on", {"friendly_name": "Kitchen Light"}) - - results = list( - logbook.humanify( - hass, - [ - ha.Event( - EVENT_ALEXA_SMART_HOME, - {"request": {"namespace": "Alexa.Discovery", "name": "Discover"}}, - ), - ha.Event( - EVENT_ALEXA_SMART_HOME, - { - "request": { - "namespace": "Alexa.PowerController", - "name": "TurnOn", - "entity_id": "light.kitchen", - } - }, - ), - ha.Event( - EVENT_ALEXA_SMART_HOME, - { - "request": { - "namespace": "Alexa.PowerController", - "name": "TurnOn", - "entity_id": "light.non_existing", - } - }, - ), - ], - ) - ) - - event1, event2, event3 = results - - assert event1["name"] == "Amazon Alexa" - assert event1["message"] == "send command Alexa.Discovery/Discover" - assert event1["entity_id"] is None - - assert event2["name"] == "Amazon Alexa" - assert ( - event2["message"] - == "send command Alexa.PowerController/TurnOn for Kitchen Light" - ) - assert event2["entity_id"] == "light.kitchen" - - assert event3["name"] == "Amazon Alexa" - assert ( - event3["message"] - == "send command Alexa.PowerController/TurnOn for light.non_existing" - ) - assert event3["entity_id"] == "light.non_existing" - - async def test_humanify_homekit_changed_event(hass): """Test humanifying HomeKit changed event.""" event1, event2 = list( @@ -1517,3 +1466,33 @@ async def test_humanify_same_state(hass): ) assert len(events) == 1 + + +async def test_logbook_describe_event(hass, hass_client): + """Test teaching logbook about a new event.""" + await hass.async_add_executor_job(init_recorder_component, hass) + assert await async_setup_component(hass, "logbook", {}) + with patch( + "homeassistant.util.dt.utcnow", + return_value=dt_util.utcnow() - timedelta(seconds=5), + ): + hass.bus.async_fire("some_event") + await hass.async_block_till_done() + await hass.async_add_executor_job( + hass.data[recorder.DATA_INSTANCE].block_till_done + ) + + def _describe(event): + """Describe an event.""" + return {"name": "Test Name", "message": "tested a message"} + + hass.components.logbook.async_describe_event("test_domain", "some_event", _describe) + + client = await hass_client() + response = await client.get("/api/logbook") + results = await response.json() + assert len(results) == 1 + event = results[0] + assert event["name"] == "Test Name" + assert event["message"] == "tested a message" + assert event["domain"] == "test_domain" From 4717d072c90bd95c29909269eabc4da3f42a4ff2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Mar 2020 12:36:18 -0800 Subject: [PATCH 256/416] Remove discovery and legacy config file loading for Plex (#32510) --- .../components/discovery/__init__.py | 2 - homeassistant/components/plex/config_flow.py | 27 +------ homeassistant/components/plex/const.py | 1 - homeassistant/components/plex/strings.json | 1 - tests/components/plex/test_config_flow.py | 73 +------------------ 5 files changed, 2 insertions(+), 102 deletions(-) diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 1e29d066f2d..d12e9d2c54b 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -37,7 +37,6 @@ SERVICE_KONNECTED = "konnected" SERVICE_MOBILE_APP = "hass_mobile_app" SERVICE_NETGEAR = "netgear_router" SERVICE_OCTOPRINT = "octoprint" -SERVICE_PLEX = "plex_mediaserver" SERVICE_ROKU = "roku" SERVICE_SABNZBD = "sabnzbd" SERVICE_SAMSUNG_PRINTER = "samsung_printer" @@ -51,7 +50,6 @@ CONFIG_ENTRY_HANDLERS = { SERVICE_DAIKIN: "daikin", SERVICE_TELLDUSLIVE: "tellduslive", SERVICE_IGD: "upnp", - SERVICE_PLEX: "plex", } SERVICE_HANDLERS = { diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index d61da8609a9..84b16817ca8 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -11,11 +11,10 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.http.view import HomeAssistantView from homeassistant.components.media_player import DOMAIN as MP_DOMAIN -from homeassistant.const import CONF_SSL, CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL from homeassistant.core import callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -from homeassistant.util.json import load_json from .const import ( # pylint: disable=unused-import AUTH_CALLBACK_NAME, @@ -28,7 +27,6 @@ from .const import ( # pylint: disable=unused-import CONF_USE_EPISODE_ART, DEFAULT_VERIFY_SSL, DOMAIN, - PLEX_CONFIG_FILE, PLEX_SERVER_CONFIG, SERVERS, X_PLEX_DEVICE_NAME, @@ -180,29 +178,6 @@ class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors={}, ) - async def async_step_discovery(self, discovery_info): - """Set default host and port from discovery.""" - if self._async_current_entries() or self._async_in_progress(): - # Skip discovery if a config already exists or is in progress. - return self.async_abort(reason="already_configured") - - json_file = self.hass.config.path(PLEX_CONFIG_FILE) - file_config = await self.hass.async_add_executor_job(load_json, json_file) - - if file_config: - host_and_port, host_config = file_config.popitem() - prefix = "https" if host_config[CONF_SSL] else "http" - - server_config = { - CONF_URL: f"{prefix}://{host_and_port}", - CONF_TOKEN: host_config[CONF_TOKEN], - CONF_VERIFY_SSL: host_config["verify"], - } - _LOGGER.info("Imported legacy config, file can be removed: %s", json_file) - return await self.async_step_server_validate(server_config) - - return self.async_abort(reason="discovery_no_file") - async def async_step_import(self, import_config): """Import from Plex configuration.""" _LOGGER.debug("Imported Plex configuration") diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py index 7d6812674ca..d5cb3db3aba 100644 --- a/homeassistant/components/plex/const.py +++ b/homeassistant/components/plex/const.py @@ -15,7 +15,6 @@ PLATFORMS_COMPLETED = "platforms_completed" SERVERS = "servers" WEBSOCKETS = "websockets" -PLEX_CONFIG_FILE = "plex.conf" PLEX_MEDIA_PLAYER_OPTIONS = "plex_mp_options" PLEX_SERVER_CONFIG = "server_config" diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json index cf91b8b6fb7..43dc47bec10 100644 --- a/homeassistant/components/plex/strings.json +++ b/homeassistant/components/plex/strings.json @@ -23,7 +23,6 @@ "all_configured": "All linked servers already configured", "already_configured": "This Plex server is already configured", "already_in_progress": "Plex is being configured", - "discovery_no_file": "No legacy configuration file found", "invalid_import": "Imported configuration is invalid", "non-interactive": "Non-interactive import", "token_request_timeout": "Timed out obtaining token", diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index da4c95c145f..c131a123dc9 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -7,7 +7,7 @@ import plexapi.exceptions import requests.exceptions from homeassistant.components.plex import config_flow -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_TOKEN, CONF_URL +from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, CONF_URL from homeassistant.setup import async_setup_component from .mock_classes import MOCK_SERVERS, MockPlexAccount, MockPlexServer @@ -65,77 +65,6 @@ async def test_bad_credentials(hass): assert result["errors"]["base"] == "faulty_credentials" -async def test_import_file_from_discovery(hass): - """Test importing a legacy file during discovery.""" - - file_host_and_port, file_config = list(MOCK_FILE_CONTENTS.items())[0] - file_use_ssl = file_config[CONF_SSL] - file_prefix = "https" if file_use_ssl else "http" - used_url = f"{file_prefix}://{file_host_and_port}" - - mock_plex_server = MockPlexServer(ssl=file_use_ssl) - - with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( - "homeassistant.components.plex.config_flow.load_json", - return_value=MOCK_FILE_CONTENTS, - ): - - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": "discovery"}, - data={ - CONF_HOST: MOCK_SERVERS[0][CONF_HOST], - CONF_PORT: MOCK_SERVERS[0][CONF_PORT], - }, - ) - assert result["type"] == "create_entry" - assert result["title"] == mock_plex_server.friendlyName - assert result["data"][config_flow.CONF_SERVER] == mock_plex_server.friendlyName - assert ( - result["data"][config_flow.CONF_SERVER_IDENTIFIER] - == mock_plex_server.machineIdentifier - ) - assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL] == used_url - assert ( - result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN] - == file_config[CONF_TOKEN] - ) - - -async def test_discovery(hass): - """Test starting a flow from discovery.""" - with patch("homeassistant.components.plex.config_flow.load_json", return_value={}): - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": "discovery"}, - data={ - CONF_HOST: MOCK_SERVERS[0][CONF_HOST], - CONF_PORT: MOCK_SERVERS[0][CONF_PORT], - }, - ) - assert result["type"] == "abort" - assert result["reason"] == "discovery_no_file" - - -async def test_discovery_while_in_progress(hass): - """Test starting a flow from discovery.""" - - await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} - ) - - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": "discovery"}, - data={ - CONF_HOST: MOCK_SERVERS[0][CONF_HOST], - CONF_PORT: MOCK_SERVERS[0][CONF_PORT], - }, - ) - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - async def test_import_success(hass): """Test a successful configuration import.""" From ae0ea0f088c438cefb47f73a1e9a73c1dfe07ff2 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Thu, 5 Mar 2020 20:42:52 +0000 Subject: [PATCH 257/416] Bugfix evohome converting non-UTC timezones (#32120) * bugfix: correctly handle non-UTC TZs * bugfix: system mode is always permanent * bugfix: handle where until is none * tweak: improve logging to support above fixes --- homeassistant/components/evohome/__init__.py | 41 ++++++++++++------- homeassistant/components/evohome/climate.py | 33 ++++++++------- homeassistant/components/evohome/const.py | 4 +- .../components/evohome/water_heater.py | 9 ++-- 4 files changed, 54 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py index 1a408c0a660..f56c92d6572 100644 --- a/homeassistant/components/evohome/__init__.py +++ b/homeassistant/components/evohome/__init__.py @@ -34,7 +34,7 @@ from homeassistant.helpers.service import verify_domain_control from homeassistant.helpers.typing import ConfigType, HomeAssistantType import homeassistant.util.dt as dt_util -from .const import DOMAIN, EVO_FOLLOW, GWS, STORAGE_KEY, STORAGE_VERSION, TCS +from .const import DOMAIN, EVO_FOLLOW, GWS, STORAGE_KEY, STORAGE_VER, TCS, UTC_OFFSET _LOGGER = logging.getLogger(__name__) @@ -93,22 +93,22 @@ SET_ZONE_OVERRIDE_SCHEMA = vol.Schema( # system mode schemas are built dynamically, below -def _local_dt_to_aware(dt_naive: dt) -> dt: +def _dt_local_to_aware(dt_naive: dt) -> dt: dt_aware = dt_util.now() + (dt_naive - dt.now()) if dt_aware.microsecond >= 500000: dt_aware += timedelta(seconds=1) return dt_aware.replace(microsecond=0) -def _dt_to_local_naive(dt_aware: dt) -> dt: +def _dt_aware_to_naive(dt_aware: dt) -> dt: dt_naive = dt.now() + (dt_aware - dt_util.now()) if dt_naive.microsecond >= 500000: dt_naive += timedelta(seconds=1) return dt_naive.replace(microsecond=0) -def convert_until(status_dict, until_key) -> str: - """Convert datetime string from "%Y-%m-%dT%H:%M:%SZ" to local/aware/isoformat.""" +def convert_until(status_dict: dict, until_key: str) -> str: + """Reformat a dt str from "%Y-%m-%dT%H:%M:%SZ" as local/aware/isoformat.""" if until_key in status_dict: # only present for certain modes dt_utc_naive = dt_util.parse_datetime(status_dict[until_key]) status_dict[until_key] = dt_util.as_local(dt_utc_naive).isoformat() @@ -190,14 +190,14 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: # evohomeasync2 requires naive/local datetimes as strings if tokens.get(ACCESS_TOKEN_EXPIRES) is not None: - tokens[ACCESS_TOKEN_EXPIRES] = _dt_to_local_naive( + tokens[ACCESS_TOKEN_EXPIRES] = _dt_aware_to_naive( dt_util.parse_datetime(tokens[ACCESS_TOKEN_EXPIRES]) ) user_data = tokens.pop(USER_DATA, None) return (tokens, user_data) - store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + store = hass.helpers.storage.Store(STORAGE_VER, STORAGE_KEY) tokens, user_data = await load_auth_tokens(store) client_v2 = evohomeasync2.EvohomeClient( @@ -217,7 +217,7 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: loc_idx = config[DOMAIN][CONF_LOCATION_IDX] try: - loc_config = client_v2.installation_info[loc_idx][GWS][0][TCS][0] + loc_config = client_v2.installation_info[loc_idx] except IndexError: _LOGGER.error( "Config error: '%s' = %s, but the valid range is 0-%s. " @@ -228,7 +228,11 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: ) return False - _LOGGER.debug("Config = %s", loc_config) + if _LOGGER.isEnabledFor(logging.DEBUG): + _config = {"locationInfo": {"timeZone": None}, GWS: [{TCS: None}]} + _config["locationInfo"]["timeZone"] = loc_config["locationInfo"]["timeZone"] + _config[GWS][0][TCS] = loc_config[GWS][0][TCS] + _LOGGER.debug("Config = %s", _config) client_v1 = evohomeasync.EvohomeClient( client_v2.username, @@ -393,12 +397,15 @@ class EvoBroker: loc_idx = params[CONF_LOCATION_IDX] self.config = client.installation_info[loc_idx][GWS][0][TCS][0] self.tcs = client.locations[loc_idx]._gateways[0]._control_systems[0] + self.tcs_utc_offset = timedelta( + minutes=client.locations[loc_idx].timeZone[UTC_OFFSET] + ) self.temps = {} async def save_auth_tokens(self) -> None: """Save access tokens and session IDs to the store for later use.""" # evohomeasync2 uses naive/local datetimes - access_token_expires = _local_dt_to_aware(self.client.access_token_expires) + access_token_expires = _dt_local_to_aware(self.client.access_token_expires) app_storage = {CONF_USERNAME: self.client.username} app_storage[REFRESH_TOKEN] = self.client.refresh_token @@ -481,7 +488,7 @@ class EvoBroker: else: async_dispatcher_send(self.hass, DOMAIN) - _LOGGER.debug("Status = %s", status[GWS][0][TCS][0]) + _LOGGER.debug("Status = %s", status) if access_token != self.client.access_token: await self.save_auth_tokens() @@ -621,6 +628,11 @@ class EvoChild(EvoDevice): Only Zones & DHW controllers (but not the TCS) can have schedules. """ + + def _dt_evo_to_aware(dt_naive: dt, utc_offset: timedelta) -> dt: + dt_aware = dt_naive.replace(tzinfo=dt_util.UTC) - utc_offset + return dt_util.as_local(dt_aware) + if not self._schedule["DailySchedules"]: return {} # no schedule {'DailySchedules': []}, so no scheduled setpoints @@ -650,11 +662,12 @@ class EvoChild(EvoDevice): day = self._schedule["DailySchedules"][(day_of_week + offset) % 7] switchpoint = day["Switchpoints"][idx] - dt_local_aware = _local_dt_to_aware( - dt_util.parse_datetime(f"{sp_date}T{switchpoint['TimeOfDay']}") + dt_aware = _dt_evo_to_aware( + dt_util.parse_datetime(f"{sp_date}T{switchpoint['TimeOfDay']}"), + self._evo_broker.tcs_utc_offset, ) - self._setpoints[f"{key}_sp_from"] = dt_local_aware.isoformat() + self._setpoints[f"{key}_sp_from"] = dt_aware.isoformat() try: self._setpoints[f"{key}_sp_temp"] = switchpoint["heatSetpoint"] except KeyError: diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index aece0f0ec0d..8b65d837171 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -20,7 +20,7 @@ from homeassistant.components.climate.const import ( ) from homeassistant.const import PRECISION_TENTHS from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from homeassistant.util.dt import parse_datetime +import homeassistant.util.dt as dt_util from . import ( ATTR_DURATION_DAYS, @@ -170,21 +170,21 @@ class EvoZone(EvoChild, EvoClimateDevice): return # otherwise it is SVC_SET_ZONE_OVERRIDE - temp = round(data[ATTR_ZONE_TEMP] * self.precision) / self.precision - temp = max(min(temp, self.max_temp), self.min_temp) + temperature = max(min(data[ATTR_ZONE_TEMP], self.max_temp), self.min_temp) if ATTR_DURATION_UNTIL in data: duration = data[ATTR_DURATION_UNTIL] if duration.total_seconds() == 0: await self._update_schedule() - until = parse_datetime(str(self.setpoints.get("next_sp_from"))) + until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) else: - until = dt.now() + data[ATTR_DURATION_UNTIL] + until = dt_util.now() + data[ATTR_DURATION_UNTIL] else: until = None # indefinitely + until = dt_util.as_utc(until) if until else None await self._evo_broker.call_client_api( - self._evo_device.set_temperature(temperature=temp, until=until) + self._evo_device.set_temperature(temperature, until=until) ) @property @@ -244,12 +244,13 @@ class EvoZone(EvoChild, EvoClimateDevice): if until is None: if self._evo_device.setpointStatus["setpointMode"] == EVO_FOLLOW: await self._update_schedule() - until = parse_datetime(str(self.setpoints.get("next_sp_from"))) + until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) elif self._evo_device.setpointStatus["setpointMode"] == EVO_TEMPOVER: - until = parse_datetime(self._evo_device.setpointStatus["until"]) + until = dt_util.parse_datetime(self._evo_device.setpointStatus["until"]) + until = dt_util.as_utc(until) if until else None await self._evo_broker.call_client_api( - self._evo_device.set_temperature(temperature, until) + self._evo_device.set_temperature(temperature, until=until) ) async def async_set_hvac_mode(self, hvac_mode: str) -> None: @@ -292,12 +293,13 @@ class EvoZone(EvoChild, EvoClimateDevice): if evo_preset_mode == EVO_TEMPOVER: await self._update_schedule() - until = parse_datetime(str(self.setpoints.get("next_sp_from"))) + until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) else: # EVO_PERMOVER until = None + until = dt_util.as_utc(until) if until else None await self._evo_broker.call_client_api( - self._evo_device.set_temperature(temperature, until) + self._evo_device.set_temperature(temperature, until=until) ) async def async_update(self) -> None: @@ -345,11 +347,11 @@ class EvoController(EvoClimateDevice): mode = EVO_RESET if ATTR_DURATION_DAYS in data: - until = dt.combine(dt.now().date(), dt.min.time()) + until = dt_util.start_of_local_day() until += data[ATTR_DURATION_DAYS] elif ATTR_DURATION_HOURS in data: - until = dt.now() + data[ATTR_DURATION_HOURS] + until = dt_util.now() + data[ATTR_DURATION_HOURS] else: until = None @@ -358,7 +360,10 @@ class EvoController(EvoClimateDevice): async def _set_tcs_mode(self, mode: str, until: Optional[dt] = None) -> None: """Set a Controller to any of its native EVO_* operating modes.""" - await self._evo_broker.call_client_api(self._evo_tcs.set_status(mode)) + until = dt_util.as_utc(until) if until else None + await self._evo_broker.call_client_api( + self._evo_tcs.set_status(mode, until=until) + ) @property def hvac_mode(self) -> str: diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py index eaa7048e53b..6bd3a59c225 100644 --- a/homeassistant/components/evohome/const.py +++ b/homeassistant/components/evohome/const.py @@ -1,7 +1,7 @@ """Support for (EMEA/EU-based) Honeywell TCC climate systems.""" DOMAIN = "evohome" -STORAGE_VERSION = 1 +STORAGE_VER = 1 STORAGE_KEY = DOMAIN # The Parent's (i.e. TCS, Controller's) operating mode is one of: @@ -21,3 +21,5 @@ EVO_PERMOVER = "PermanentOverride" # These are used only to help prevent E501 (line too long) violations GWS = "gateways" TCS = "temperatureControlSystems" + +UTC_OFFSET = "currentOffsetMinutes" diff --git a/homeassistant/components/evohome/water_heater.py b/homeassistant/components/evohome/water_heater.py index cc282534f1b..20aa0710d0d 100644 --- a/homeassistant/components/evohome/water_heater.py +++ b/homeassistant/components/evohome/water_heater.py @@ -9,7 +9,7 @@ from homeassistant.components.water_heater import ( ) from homeassistant.const import PRECISION_TENTHS, PRECISION_WHOLE, STATE_OFF, STATE_ON from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from homeassistant.util.dt import parse_datetime +import homeassistant.util.dt as dt_util from . import EvoChild from .const import DOMAIN, EVO_FOLLOW, EVO_PERMOVER @@ -90,15 +90,16 @@ class EvoDHW(EvoChild, WaterHeaterDevice): await self._evo_broker.call_client_api(self._evo_device.set_dhw_auto()) else: await self._update_schedule() - until = parse_datetime(str(self.setpoints.get("next_sp_from"))) + until = dt_util.parse_datetime(self.setpoints.get("next_sp_from", "")) + until = dt_util.as_utc(until) if until else None if operation_mode == STATE_ON: await self._evo_broker.call_client_api( - self._evo_device.set_dhw_on(until) + self._evo_device.set_dhw_on(until=until) ) else: # STATE_OFF await self._evo_broker.call_client_api( - self._evo_device.set_dhw_off(until) + self._evo_device.set_dhw_off(until=until) ) async def async_turn_away_mode_on(self): From b5022f5bcb41436d2c9c9797c700f3d9849c964d Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 5 Mar 2020 15:52:09 -0500 Subject: [PATCH 258/416] guard against invalid trigger and action scenarios (#32512) --- homeassistant/components/zha/device_action.py | 10 ++++++++-- homeassistant/components/zha/device_trigger.py | 11 ++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zha/device_action.py b/homeassistant/components/zha/device_action.py index 5a2e0c40881..46363939190 100644 --- a/homeassistant/components/zha/device_action.py +++ b/homeassistant/components/zha/device_action.py @@ -56,7 +56,10 @@ async def async_call_action_from_config( async def async_get_actions(hass: HomeAssistant, device_id: str) -> List[dict]: """List device actions.""" - zha_device = await async_get_zha_device(hass, device_id) + try: + zha_device = await async_get_zha_device(hass, device_id) + except (KeyError, AttributeError): + return [] cluster_channels = [ ch.name for pool in zha_device.channels.pools @@ -81,7 +84,10 @@ async def _execute_service_based_action( ) -> None: action_type = config[CONF_TYPE] service_name = SERVICE_NAMES[action_type] - zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID]) + try: + zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID]) + except (KeyError, AttributeError): + return service_data = {ATTR_IEEE: str(zha_device.ieee)} diff --git a/homeassistant/components/zha/device_trigger.py b/homeassistant/components/zha/device_trigger.py index b7c46e5a40a..222fdfc7a2c 100644 --- a/homeassistant/components/zha/device_trigger.py +++ b/homeassistant/components/zha/device_trigger.py @@ -27,7 +27,10 @@ async def async_validate_trigger_config(hass, config): if "zha" in hass.config.components: trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) - zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID]) + try: + zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID]) + except (KeyError, AttributeError): + raise InvalidDeviceAutomationConfig if ( zha_device.device_automation_triggers is None or trigger not in zha_device.device_automation_triggers @@ -40,8 +43,10 @@ async def async_validate_trigger_config(hass, config): async def async_attach_trigger(hass, config, action, automation_info): """Listen for state changes based on configuration.""" trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) - zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID]) - + try: + zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID]) + except (KeyError, AttributeError): + return None trigger = zha_device.device_automation_triggers[trigger] event_config = { From 873bf887a5b9c64ee96871ece531d02cce3a0daf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Mar 2020 12:55:48 -0800 Subject: [PATCH 259/416] Add OwnTracks Friends via person integration (#27303) * Returns an unencrypted location of all persons with device trackers * Handle encrypted messages and exclude the poster's location * Friends is by default False. Reformats with Black * Updates the context init to account for the Friends option * Fix Linter error * Remove as a config option * No longer imports encyrption-related functions in encrypt_message * Fix initialization in test * Test the friends functionality * Bugfix for persons not having a location * Better way to return the timestamp * Update homeassistant/components/owntracks/__init__.py Co-Authored-By: Paulus Schoutsen * Linting and tid generation * Fix test Co-authored-by: Paulus Schoutsen --- .../components/owntracks/__init__.py | 34 ++++++++- .../components/owntracks/messages.py | 31 +++++++++ .../owntracks/test_device_tracker.py | 69 +++++++++++++++++++ 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index 71494e9e805..cf034950154 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -16,7 +16,7 @@ from homeassistant.setup import async_when_setup from .config_flow import CONF_SECRET from .const import DOMAIN -from .messages import async_handle_message +from .messages import async_handle_message, encrypt_message _LOGGER = logging.getLogger(__name__) @@ -154,6 +154,7 @@ async def handle_webhook(hass, webhook_id, request): Android does not set a topic but adds headers to the request. """ context = hass.data[DOMAIN]["context"] + topic_base = re.sub("/#$", "", context.mqtt_topic) try: message = await request.json() @@ -168,7 +169,6 @@ async def handle_webhook(hass, webhook_id, request): device = headers.get("X-Limit-D", user) if user: - topic_base = re.sub("/#$", "", context.mqtt_topic) message["topic"] = f"{topic_base}/{user}/{device}" elif message["_type"] != "encrypted": @@ -180,7 +180,35 @@ async def handle_webhook(hass, webhook_id, request): return json_response([]) hass.helpers.dispatcher.async_dispatcher_send(DOMAIN, hass, context, message) - return json_response([]) + + response = [] + + for person in hass.states.async_all(): + if person.domain != "person": + continue + + if "latitude" in person.attributes and "longitude" in person.attributes: + response.append( + { + "_type": "location", + "lat": person.attributes["latitude"], + "lon": person.attributes["longitude"], + "tid": "".join(p[0] for p in person.name.split(" ")[:2]), + "tst": int(person.last_updated.timestamp()), + } + ) + + if message["_type"] == "encrypted" and context.secret: + return json_response( + { + "_type": "encrypted", + "data": encrypt_message( + context.secret, message["topic"], json.dumps(response) + ), + } + ) + + return json_response(response) class OwnTracksContext: diff --git a/homeassistant/components/owntracks/messages.py b/homeassistant/components/owntracks/messages.py index 7fab391efc1..42f1f62d10a 100644 --- a/homeassistant/components/owntracks/messages.py +++ b/homeassistant/components/owntracks/messages.py @@ -144,6 +144,37 @@ def _decrypt_payload(secret, topic, ciphertext): return None +def encrypt_message(secret, topic, message): + """Encrypt message.""" + + keylen = SecretBox.KEY_SIZE + + if isinstance(secret, dict): + key = secret.get(topic) + else: + key = secret + + if key is None: + _LOGGER.warning( + "Unable to encrypt payload because no decryption key known " "for topic %s", + topic, + ) + return None + + key = key.encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b"\0") + + try: + message = message.encode("utf-8") + payload = SecretBox(key).encrypt(message, encoder=Base64Encoder) + _LOGGER.debug("Encrypted message: %s to %s", message, payload) + return payload.decode("utf-8") + except ValueError: + _LOGGER.warning("Unable to encrypt message for topic %s", topic) + return None + + @HANDLERS.register("location") async def async_handle_location_message(hass, context, message): """Handle a location message.""" diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index 730da4bc7b2..ae9fb65c615 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -1565,3 +1565,72 @@ async def test_restore_state(hass, hass_client): assert state_1.attributes["longitude"] == state_2.attributes["longitude"] assert state_1.attributes["battery_level"] == state_2.attributes["battery_level"] assert state_1.attributes["source_type"] == state_2.attributes["source_type"] + + +async def test_returns_empty_friends(hass, hass_client): + """Test that an empty list of persons' locations is returned.""" + entry = MockConfigEntry( + domain="owntracks", data={"webhook_id": "owntracks_test", "secret": "abcd"} + ) + entry.add_to_hass(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + client = await hass_client() + resp = await client.post( + "/api/webhook/owntracks_test", + json=LOCATION_MESSAGE, + headers={"X-Limit-u": "Paulus", "X-Limit-d": "Pixel"}, + ) + + assert resp.status == 200 + assert await resp.text() == "[]" + + +async def test_returns_array_friends(hass, hass_client): + """Test that a list of persons' current locations is returned.""" + otracks = MockConfigEntry( + domain="owntracks", data={"webhook_id": "owntracks_test", "secret": "abcd"} + ) + otracks.add_to_hass(hass) + + await hass.config_entries.async_setup(otracks.entry_id) + await hass.async_block_till_done() + + # Setup device_trackers + assert await async_setup_component( + hass, + "person", + { + "person": [ + { + "name": "person 1", + "id": "person1", + "device_trackers": ["device_tracker.person_1_tracker_1"], + }, + { + "name": "person2", + "id": "person2", + "device_trackers": ["device_tracker.person_2_tracker_1"], + }, + ] + }, + ) + hass.states.async_set( + "device_tracker.person_1_tracker_1", "home", {"latitude": 10, "longitude": 20} + ) + + client = await hass_client() + resp = await client.post( + "/api/webhook/owntracks_test", + json=LOCATION_MESSAGE, + headers={"X-Limit-u": "Paulus", "X-Limit-d": "Pixel"}, + ) + + assert resp.status == 200 + response_json = json.loads(await resp.text()) + + assert response_json[0]["lat"] == 10 + assert response_json[0]["lon"] == 20 + assert response_json[0]["tid"] == "p1" From a579fcf2482da094e3261f07c40328f948b06c03 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Mar 2020 13:34:12 -0800 Subject: [PATCH 260/416] Add app support for TVs to Vizio integration (#32432) * add app support * code cleanup, add additional test, add CONF_APPS storage logic for import * simplify schema defaults logic * remove unnecessary lower() and fix docstring * remove default return for popping CONF_APPS during import update because we know entry data has CONF_APPS due to if statement * further simplification * even more simplification * fix type hints * move app configuration to separate step, fix tests, and only make app updates if device_type == tv * remove errors variable from tv_apps and move tv_apps schema out of ConfigFlow for consistency * slight refactor * remove unused error from strings.json * set unique id as early as possible * correct which dictionary to use to set unique id in pair_tv step --- homeassistant/components/vizio/__init__.py | 19 +- homeassistant/components/vizio/config_flow.py | 149 +++++++++---- homeassistant/components/vizio/const.py | 37 ++++ homeassistant/components/vizio/manifest.json | 2 +- .../components/vizio/media_player.py | 120 ++++++++++- homeassistant/components/vizio/strings.json | 18 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vizio/conftest.py | 22 ++ tests/components/vizio/const.py | 96 ++++++++- tests/components/vizio/test_config_flow.py | 137 +++++++++++- tests/components/vizio/test_media_player.py | 198 +++++++++++++++++- 12 files changed, 729 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index 88d600abce6..a52b395c5c9 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -3,14 +3,29 @@ import asyncio import voluptuous as vol +from homeassistant.components.media_player import DEVICE_CLASS_TV from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from .const import DOMAIN, VIZIO_SCHEMA +from .const import CONF_APPS, CONF_DEVICE_CLASS, DOMAIN, VIZIO_SCHEMA + + +def validate_apps(config: ConfigType) -> ConfigType: + """Validate CONF_APPS is only used when CONF_DEVICE_CLASS == DEVICE_CLASS_TV.""" + if ( + config.get(CONF_APPS) is not None + and config[CONF_DEVICE_CLASS] != DEVICE_CLASS_TV + ): + raise vol.Invalid( + f"'{CONF_APPS}' can only be used if {CONF_DEVICE_CLASS}' is '{DEVICE_CLASS_TV}'" + ) + + return config + CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.All(cv.ensure_list, [vol.Schema(VIZIO_SCHEMA)])}, + {DOMAIN: vol.All(cv.ensure_list, [vol.All(VIZIO_SCHEMA, validate_apps)])}, extra=vol.ALLOW_EXTRA, ) diff --git a/homeassistant/components/vizio/config_flow.py b/homeassistant/components/vizio/config_flow.py index 993bb09b14f..1eb58c96670 100644 --- a/homeassistant/components/vizio/config_flow.py +++ b/homeassistant/components/vizio/config_flow.py @@ -12,16 +12,22 @@ from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_ZEROCONF, ConfigE from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS, + CONF_EXCLUDE, CONF_HOST, + CONF_INCLUDE, CONF_NAME, CONF_PIN, CONF_PORT, CONF_TYPE, ) from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( + CONF_APPS, + CONF_APPS_TO_INCLUDE_OR_EXCLUDE, + CONF_INCLUDE_OR_EXCLUDE, CONF_VOLUME_STEP, DEFAULT_DEVICE_CLASS, DEFAULT_NAME, @@ -34,7 +40,11 @@ _LOGGER = logging.getLogger(__name__) def _get_config_schema(input_dict: Dict[str, Any] = None) -> vol.Schema: - """Return schema defaults for config data based on user input/config dict. Retain info already provided for future form views by setting them as defaults in schema.""" + """ + Return schema defaults for init step based on user input/config dict. + + Retain info already provided for future form views by setting them as defaults in schema. + """ if input_dict is None: input_dict = {} @@ -57,13 +67,16 @@ def _get_config_schema(input_dict: Dict[str, Any] = None) -> vol.Schema: def _get_pairing_schema(input_dict: Dict[str, Any] = None) -> vol.Schema: - """Return schema defaults for pairing data based on user input. Retain info already provided for future form views by setting them as defaults in schema.""" + """ + Return schema defaults for pairing data based on user input. + + Retain info already provided for future form views by setting them as defaults in schema. + """ if input_dict is None: input_dict = {} return vol.Schema( - {vol.Required(CONF_PIN, default=input_dict.get(CONF_PIN, "")): str}, - extra=vol.ALLOW_EXTRA, + {vol.Required(CONF_PIN, default=input_dict.get(CONF_PIN, "")): str} ) @@ -73,7 +86,7 @@ def _host_is_same(host1: str, host2: str) -> bool: class VizioOptionsConfigFlow(config_entries.OptionsFlow): - """Handle Transmission client options.""" + """Handle Vizio options.""" def __init__(self, config_entry: ConfigEntry) -> None: """Initialize vizio options flow.""" @@ -117,22 +130,18 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._ch_type = None self._pairing_token = None self._data = None + self._apps = {} async def _create_entry_if_unique( self, input_dict: Dict[str, Any] ) -> Dict[str, Any]: """Check if unique_id doesn't already exist. If it does, abort. If it doesn't, create entry.""" - unique_id = await VizioAsync.get_unique_id( - input_dict[CONF_HOST], - input_dict.get(CONF_ACCESS_TOKEN), - input_dict[CONF_DEVICE_CLASS], - session=async_get_clientsession(self.hass, False), - ) + # 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) - # 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() + if self._apps: + input_dict[CONF_APPS] = self._apps return self.async_create_entry(title=input_dict[CONF_NAME], data=input_dict) @@ -172,6 +181,27 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "cant_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() + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + if ( + user_input[CONF_DEVICE_CLASS] == DEVICE_CLASS_TV + and self.context["source"] != SOURCE_IMPORT + ): + self._data = copy.deepcopy(user_input) + return await self.async_step_tv_apps() return await self._create_entry_if_unique(user_input) # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 elif self._must_show_form and self.context["source"] == SOURCE_IMPORT: @@ -180,11 +210,9 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # their configuration.yaml or to proceed with config flow pairing. We # will also provide contextual message to user explaining why _LOGGER.warning( - "Couldn't complete configuration.yaml import: '%s' key is missing. To " - "complete setup, '%s' can be obtained by going through pairing process " - "via frontend Integrations menu; to avoid re-pairing your device in the " - "future, once you have finished pairing, it is recommended to add " - "obtained value to your config ", + "Couldn't complete configuration.yaml import: '%s' key is " + "missing. Either provide '%s' key in configuration.yaml or " + "finish setup by completing configuration via frontend.", CONF_ACCESS_TOKEN, CONF_ACCESS_TOKEN, ) @@ -210,20 +238,32 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): for entry in self.hass.config_entries.async_entries(DOMAIN): if _host_is_same(entry.data[CONF_HOST], import_config[CONF_HOST]): updated_options = {} - updated_name = {} + updated_data = {} + remove_apps = False if entry.data[CONF_NAME] != import_config[CONF_NAME]: - updated_name[CONF_NAME] = import_config[CONF_NAME] + updated_data[CONF_NAME] = import_config[CONF_NAME] + + # Update entry.data[CONF_APPS] if import_config[CONF_APPS] differs, and + # pop entry.data[CONF_APPS] if import_config[CONF_APPS] is not specified + if entry.data.get(CONF_APPS) != import_config.get(CONF_APPS): + if not import_config.get(CONF_APPS): + remove_apps = True + else: + updated_data[CONF_APPS] = import_config[CONF_APPS] if entry.data.get(CONF_VOLUME_STEP) != import_config[CONF_VOLUME_STEP]: updated_options[CONF_VOLUME_STEP] = import_config[CONF_VOLUME_STEP] - if updated_options or updated_name: + if updated_options or updated_data or remove_apps: new_data = entry.data.copy() new_options = entry.options.copy() - if updated_name: - new_data.update(updated_name) + if remove_apps: + new_data.pop(CONF_APPS) + + if updated_data: + new_data.update(updated_data) if updated_options: new_data.update(updated_options) @@ -237,6 +277,10 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_setup") self._must_show_form = True + # Store config key/value pairs that are not configurable in user step so they + # don't get lost on user step + if import_config.get(CONF_APPS): + self._apps = copy.deepcopy(import_config[CONF_APPS]) return await self.async_step_user(user_input=import_config) async def async_step_zeroconf( @@ -319,12 +363,26 @@ 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 return await self.async_step_pairing_complete_import() - return await self.async_step_pairing_complete() + return await self.async_step_tv_apps() # If no data was retrieved, it's assumed that the pairing attempt was not # successful @@ -336,26 +394,43 @@ class VizioConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def _pairing_complete(self, step_id: str) -> Dict[str, Any]: - """Handle config flow completion.""" + async def async_step_pairing_complete_import( + self, user_input: Dict[str, Any] = None + ) -> Dict[str, Any]: + """Complete import config flow by displaying final message to show user access token and give further instructions.""" if not self._must_show_form: return await self._create_entry_if_unique(self._data) self._must_show_form = False return self.async_show_form( - step_id=step_id, + step_id="pairing_complete_import", data_schema=vol.Schema({}), description_placeholders={"access_token": self._data[CONF_ACCESS_TOKEN]}, ) - async def async_step_pairing_complete( + async def async_step_tv_apps( self, user_input: Dict[str, Any] = None ) -> Dict[str, Any]: - """Complete non-import config flow by displaying final message to confirm pairing.""" - return await self._pairing_complete("pairing_complete") + """Handle app configuration to complete TV configuration.""" + if user_input is not None: + if user_input.get(CONF_APPS_TO_INCLUDE_OR_EXCLUDE): + # Update stored apps with user entry config keys + self._apps[user_input[CONF_INCLUDE_OR_EXCLUDE].lower()] = user_input[ + CONF_APPS_TO_INCLUDE_OR_EXCLUDE + ].copy() - async def async_step_pairing_complete_import( - self, user_input: Dict[str, Any] = None - ) -> Dict[str, Any]: - """Complete import config flow by displaying final message to show user access token and give further instructions.""" - return await self._pairing_complete("pairing_complete_import") + return await self._create_entry_if_unique(self._data) + + return self.async_show_form( + step_id="tv_apps", + data_schema=vol.Schema( + { + vol.Optional( + CONF_INCLUDE_OR_EXCLUDE, default=CONF_INCLUDE.title(), + ): vol.In([CONF_INCLUDE.title(), CONF_EXCLUDE.title()]), + vol.Optional(CONF_APPS_TO_INCLUDE_OR_EXCLUDE): cv.multi_select( + VizioAsync.get_apps_list() + ), + } + ), + ) diff --git a/homeassistant/components/vizio/const.py b/homeassistant/components/vizio/const.py index e3ac66e05c3..795f12266fb 100644 --- a/homeassistant/components/vizio/const.py +++ b/homeassistant/components/vizio/const.py @@ -1,4 +1,5 @@ """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, @@ -19,11 +20,21 @@ from homeassistant.components.media_player.const import ( from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS, + CONF_EXCLUDE, CONF_HOST, + CONF_INCLUDE, CONF_NAME, ) import homeassistant.helpers.config_validation as cv +CONF_ADDITIONAL_CONFIGS = "additional_configs" +CONF_APP_ID = "APP_ID" +CONF_APPS = "apps" +CONF_APPS_TO_INCLUDE_OR_EXCLUDE = "apps_to_include_or_exclude" +CONF_CONFIG = "config" +CONF_INCLUDE_OR_EXCLUDE = "include_or_exclude" +CONF_NAME_SPACE = "NAME_SPACE" +CONF_MESSAGE = "MESSAGE" CONF_VOLUME_STEP = "volume_step" DEFAULT_DEVICE_CLASS = DEVICE_CLASS_TV @@ -69,4 +80,30 @@ VIZIO_SCHEMA = { vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): vol.All( vol.Coerce(int), vol.Range(min=1, max=10) ), + 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()))] + ), + vol.Exclusive(CONF_EXCLUDE, "apps_filter"): vol.All( + cv.ensure_list, [vol.All(cv.string, vol.In(VizioAsync.get_apps_list()))] + ), + vol.Optional(CONF_ADDITIONAL_CONFIGS): vol.All( + cv.ensure_list, + [ + { + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_CONFIG): { + vol.Required(CONF_APP_ID): cv.string, + vol.Required(CONF_NAME_SPACE): vol.Coerce(int), + vol.Optional(CONF_MESSAGE, default=None): vol.Or( + cv.string, None + ), + }, + }, + ], + ), + }, + cv.has_at_least_one_key(CONF_INCLUDE, CONF_EXCLUDE, CONF_ADDITIONAL_CONFIGS), + ), } diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json index 08d442b803e..f1931f6fdb1 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.26"], + "requirements": ["pyvizio==0.1.35"], "dependencies": [], "codeowners": ["@raman325"], "config_flow": true, diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index edbe4171f0a..63918737411 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -1,16 +1,23 @@ """Vizio SmartCast Device support.""" from datetime import timedelta import logging -from typing import Callable, List +from typing import Any, Callable, Dict, List, Optional from pyvizio import VizioAsync +from pyvizio.const import INPUT_APPS, NO_APP_RUNNING, UNKNOWN_APP +from pyvizio.helpers import find_app_name -from homeassistant.components.media_player import MediaPlayerDevice +from homeassistant.components.media_player import ( + DEVICE_CLASS_SPEAKER, + MediaPlayerDevice, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS, + CONF_EXCLUDE, CONF_HOST, + CONF_INCLUDE, CONF_NAME, STATE_OFF, STATE_ON, @@ -25,6 +32,8 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import HomeAssistantType from .const import ( + CONF_ADDITIONAL_CONFIGS, + CONF_APPS, CONF_VOLUME_STEP, DEFAULT_TIMEOUT, DEFAULT_VOLUME_STEP, @@ -51,6 +60,7 @@ async def async_setup_entry( token = config_entry.data.get(CONF_ACCESS_TOKEN) name = config_entry.data[CONF_NAME] device_class = config_entry.data[CONF_DEVICE_CLASS] + conf_apps = config_entry.data.get(CONF_APPS, {}) # If config entry options not set up, set them up, otherwise assign values managed in options volume_step = config_entry.options.get( @@ -83,7 +93,9 @@ async def async_setup_entry( _LOGGER.warning("Failed to connect to %s", host) raise PlatformNotReady - entity = VizioDevice(config_entry, device, name, volume_step, device_class) + entity = VizioDevice( + config_entry, device, name, volume_step, device_class, conf_apps, + ) async_add_entities([entity], update_before_add=True) @@ -98,6 +110,7 @@ class VizioDevice(MediaPlayerDevice): name: str, volume_step: int, device_class: str, + conf_apps: Dict[str, List[Any]], ) -> None: """Initialize Vizio device.""" self._config_entry = config_entry @@ -109,7 +122,11 @@ class VizioDevice(MediaPlayerDevice): self._volume_step = volume_step self._is_muted = None self._current_input = None - self._available_inputs = None + self._current_app = None + self._available_inputs = [] + self._available_apps = [] + self._conf_apps = conf_apps + self._additional_app_configs = self._conf_apps.get(CONF_ADDITIONAL_CONFIGS, []) self._device_class = device_class self._supported_commands = SUPPORTED_COMMANDS[device_class] self._device = device @@ -119,6 +136,30 @@ class VizioDevice(MediaPlayerDevice): self._model = None self._sw_version = None + def _apps_list(self, apps: List[str]) -> List[str]: + """Return process apps list based on configured filters.""" + if self._conf_apps.get(CONF_INCLUDE): + return [app for app in apps if app in self._conf_apps[CONF_INCLUDE]] + + if self._conf_apps.get(CONF_EXCLUDE): + return [app for app in apps if app not in self._conf_apps[CONF_EXCLUDE]] + + return apps + + async def _current_app_name(self) -> Optional[str]: + """Return name of the currently running app by parsing pyvizio output.""" + app = await self._device.get_current_app(log_api_exception=False) + if app in [None, NO_APP_RUNNING]: + return None + + if app == UNKNOWN_APP and self._additional_app_configs: + return find_app_name( + await self._device.get_current_app_config(log_api_exception=False), + self._additional_app_configs, + ) + + return app + async def async_update(self) -> None: """Retrieve latest state of the device.""" if not self._model: @@ -149,6 +190,7 @@ class VizioDevice(MediaPlayerDevice): self._is_muted = None self._current_input = None self._available_inputs = None + self._available_apps = None return self._state = STATE_ON @@ -165,8 +207,33 @@ class VizioDevice(MediaPlayerDevice): self._current_input = input_ inputs = await self._device.get_inputs_list(log_api_exception=False) - if inputs is not None: - self._available_inputs = [input_.name for input_ in inputs] + + # If no inputs returned, end update + if not inputs: + return + + self._available_inputs = [input_.name for input_ in inputs] + + # Return before setting app variables if INPUT_APPS isn't in available inputs + if self._device_class == DEVICE_CLASS_SPEAKER or not any( + app for app in INPUT_APPS if app in self._available_inputs + ): + return + + # Create list of available known apps from known app list after + # filtering by CONF_INCLUDE/CONF_EXCLUDE + if not self._available_apps: + self._available_apps = self._apps_list(self._device.get_apps_list()) + + # Attempt to get current app name. If app name is unknown, check list + # of additional apps specified in configuration + self._current_app = await self._current_app_name() + + def _get_additional_app_names(self) -> List[Dict[str, Any]]: + """Return list of additional apps that were included in configuration.yaml.""" + return [ + additional_app["name"] for additional_app in self._additional_app_configs + ] @staticmethod async def _async_send_update_options_signal( @@ -237,13 +304,39 @@ class VizioDevice(MediaPlayerDevice): @property def source(self) -> str: """Return current input of the device.""" + if self._current_input in INPUT_APPS: + return self._current_app + return self._current_input @property - def source_list(self) -> List: + def source_list(self) -> List[str]: """Return list of available inputs of the device.""" + # If Smartcast app is in input list, and the app list has been retrieved, + # show the combination with , otherwise just return inputs + if self._available_apps: + return [ + *[ + _input + for _input in self._available_inputs + if _input not in INPUT_APPS + ], + *self._available_apps, + *self._get_additional_app_names(), + ] + return self._available_inputs + @property + def app_id(self) -> Optional[str]: + """Return the current app.""" + return self._current_app + + @property + def app_name(self) -> Optional[str]: + """Return the friendly name of the current app.""" + return self._current_app + @property def supported_features(self) -> int: """Flag device features that are supported.""" @@ -297,7 +390,18 @@ class VizioDevice(MediaPlayerDevice): async def async_select_source(self, source: str) -> None: """Select input source.""" - await self._device.set_input(source) + if source in self._available_inputs: + await self._device.set_input(source) + elif source in self._get_additional_app_names(): + await self._device.launch_app_config( + **next( + app["config"] + for app in self._additional_app_configs + if app["name"] == source + ) + ) + elif source in self._available_apps: + await self._device.launch_app(source) async def async_volume_up(self) -> None: """Increase volume of the device.""" diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 08414b7fe53..4142f809fba 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -4,7 +4,7 @@ "step": { "user": { "title": "Setup Vizio SmartCast Device", - "description": "All fields are required except Access Token. If you choose not to provide an Access Token, and your Device Type is 'tv', you will go through a pairing process with your device so an Access Token can be retrieved.\n\nTo go through the pairing process, before clicking Submit, ensure your TV is powered on and connected to the network. You also need to be able to see the screen.", + "description": "An Access Token is only needed for TVs. If you are configuring a TV and do not have an Access Token yet, leave it blank to go through a pairing process.", "data": { "name": "Name", "host": ":", @@ -19,13 +19,17 @@ "pin": "PIN" } }, - "pairing_complete": { - "title": "Pairing Complete", - "description": "Your Vizio SmartCast device is now connected to Home Assistant." - }, "pairing_complete_import": { "title": "Pairing Complete", - "description": "Your Vizio SmartCast device is now connected to Home Assistant.\n\nYour Access Token is '**{access_token}**'." + "description": "Your Vizio SmartCast TV is now connected to Home Assistant.\n\nYour Access Token is '**{access_token}**'." + }, + "user_tv": { + "title": "Configure Apps for Smart TV", + "description": "If you have a Smart TV, you can optionally filter your source list by choosing which apps to include or exclude in your source list. You can skip this step for TVs that don't support apps.", + "data": { + "include_or_exclude": "Include or Exclude Apps?", + "apps_to_include_or_exclude": "Apps to Include or Exclude" + } } }, "error": { @@ -36,7 +40,7 @@ }, "abort": { "already_setup": "This entry has already been setup.", - "updated_entry": "This entry has already been setup but the name and/or options defined in the configuration do not match the previously imported configuration, so the configuration entry has been updated accordingly." + "updated_entry": "This entry has already been setup but the name, apps, and/or options defined in the configuration do not match the previously imported configuration, so the configuration entry has been updated accordingly." } }, "options": { diff --git a/requirements_all.txt b/requirements_all.txt index ce04d4c5fb4..1220ed30961 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1717,7 +1717,7 @@ pyversasense==0.0.6 pyvesync==1.1.0 # homeassistant.components.vizio -pyvizio==0.1.26 +pyvizio==0.1.35 # homeassistant.components.velux pyvlx==0.2.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66cf4d0798e..420eefafb8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -602,7 +602,7 @@ pyvera==0.3.7 pyvesync==1.1.0 # homeassistant.components.vizio -pyvizio==0.1.26 +pyvizio==0.1.35 # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/tests/components/vizio/conftest.py b/tests/components/vizio/conftest.py index 5c0500fe1a6..ab581bdf3c6 100644 --- a/tests/components/vizio/conftest.py +++ b/tests/components/vizio/conftest.py @@ -5,9 +5,12 @@ from pyvizio.const import DEVICE_CLASS_SPEAKER, MAX_VOLUME from .const import ( ACCESS_TOKEN, + APP_LIST, CH_TYPE, + CURRENT_APP, CURRENT_INPUT, INPUT_LIST, + INPUT_LIST_WITH_APPS, MODEL, RESPONSE_TOKEN, UNIQUE_ID, @@ -154,3 +157,22 @@ def vizio_update_fixture(): return_value=VERSION, ): yield + + +@pytest.fixture(name="vizio_update_with_apps") +def vizio_update_with_apps_fixture(vizio_update: pytest.fixture): + """Mock valid updates to vizio device that supports apps.""" + 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", + return_value=CURRENT_APP, + ): + yield diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index d3b6089a023..2cb9103c4d9 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -6,11 +6,23 @@ from homeassistant.components.media_player import ( DEVICE_CLASS_TV, DOMAIN as MP_DOMAIN, ) -from homeassistant.components.vizio.const import CONF_VOLUME_STEP +from homeassistant.components.vizio.const import ( + CONF_ADDITIONAL_CONFIGS, + CONF_APP_ID, + CONF_APPS, + CONF_APPS_TO_INCLUDE_OR_EXCLUDE, + CONF_CONFIG, + CONF_INCLUDE_OR_EXCLUDE, + CONF_MESSAGE, + CONF_NAME_SPACE, + CONF_VOLUME_STEP, +) from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_DEVICE_CLASS, + CONF_EXCLUDE, CONF_HOST, + CONF_INCLUDE, CONF_NAME, CONF_PIN, CONF_PORT, @@ -52,6 +64,22 @@ class MockCompletePairingResponse(object): self.auth_token = auth_token +CURRENT_INPUT = "HDMI" +INPUT_LIST = ["HDMI", "USB", "Bluetooth", "AUX"] + +CURRENT_APP = "Hulu" +APP_LIST = ["Hulu", "Netflix"] +INPUT_LIST_WITH_APPS = INPUT_LIST + ["CAST"] +CUSTOM_APP_NAME = "APP3" +CUSTOM_CONFIG = {CONF_APP_ID: "test", CONF_MESSAGE: None, CONF_NAME_SPACE: 10} +ADDITIONAL_APP_CONFIG = { + "name": CUSTOM_APP_NAME, + CONF_CONFIG: CUSTOM_CONFIG, +} + +ENTITY_ID = f"{MP_DOMAIN}.{slugify(NAME)}" + + MOCK_PIN_CONFIG = {CONF_PIN: PIN} MOCK_USER_VALID_TV_CONFIG = { @@ -73,6 +101,58 @@ MOCK_IMPORT_VALID_TV_CONFIG = { CONF_VOLUME_STEP: VOLUME_STEP, } +MOCK_TV_WITH_INCLUDE_CONFIG = { + CONF_NAME: NAME, + CONF_HOST: HOST, + CONF_DEVICE_CLASS: DEVICE_CLASS_TV, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_VOLUME_STEP: VOLUME_STEP, + CONF_APPS: {CONF_INCLUDE: [CURRENT_APP]}, +} + +MOCK_TV_WITH_EXCLUDE_CONFIG = { + CONF_NAME: NAME, + CONF_HOST: HOST, + CONF_DEVICE_CLASS: DEVICE_CLASS_TV, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_VOLUME_STEP: VOLUME_STEP, + CONF_APPS: {CONF_EXCLUDE: ["Netflix"]}, +} + +MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG = { + CONF_NAME: NAME, + CONF_HOST: HOST, + CONF_DEVICE_CLASS: DEVICE_CLASS_TV, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_VOLUME_STEP: VOLUME_STEP, + CONF_APPS: {CONF_ADDITIONAL_CONFIGS: [ADDITIONAL_APP_CONFIG]}, +} + +MOCK_SPEAKER_APPS_FAILURE = { + CONF_NAME: NAME, + CONF_HOST: HOST, + CONF_DEVICE_CLASS: DEVICE_CLASS_SPEAKER, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_VOLUME_STEP: VOLUME_STEP, + CONF_APPS: {CONF_ADDITIONAL_CONFIGS: [ADDITIONAL_APP_CONFIG]}, +} + +MOCK_TV_APPS_FAILURE = { + CONF_NAME: NAME, + CONF_HOST: HOST, + CONF_DEVICE_CLASS: DEVICE_CLASS_TV, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_VOLUME_STEP: VOLUME_STEP, + CONF_APPS: None, +} + +MOCK_TV_APPS_WITH_VALID_APPS_CONFIG = { + CONF_HOST: HOST, + CONF_DEVICE_CLASS: DEVICE_CLASS_TV, + CONF_ACCESS_TOKEN: ACCESS_TOKEN, + CONF_APPS: {CONF_INCLUDE: [CURRENT_APP]}, +} + MOCK_TV_CONFIG_NO_TOKEN = { CONF_NAME: NAME, CONF_HOST: HOST, @@ -85,6 +165,15 @@ MOCK_SPEAKER_CONFIG = { CONF_DEVICE_CLASS: DEVICE_CLASS_SPEAKER, } +MOCK_INCLUDE_APPS = { + CONF_INCLUDE_OR_EXCLUDE: CONF_INCLUDE.title(), + CONF_APPS_TO_INCLUDE_OR_EXCLUDE: [CURRENT_APP], +} +MOCK_INCLUDE_NO_APPS = { + CONF_INCLUDE_OR_EXCLUDE: CONF_INCLUDE.title(), + CONF_APPS_TO_INCLUDE_OR_EXCLUDE: [], +} + VIZIO_ZEROCONF_SERVICE_TYPE = "_viziocast._tcp.local." ZEROCONF_NAME = f"{NAME}.{VIZIO_ZEROCONF_SERVICE_TYPE}" ZEROCONF_HOST = HOST.split(":")[0] @@ -97,8 +186,3 @@ MOCK_ZEROCONF_SERVICE_INFO = { CONF_PORT: ZEROCONF_PORT, "properties": {"name": "SB4031-D5"}, } - -CURRENT_INPUT = "HDMI" -INPUT_LIST = ["HDMI", "USB", "Bluetooth", "AUX"] - -ENTITY_ID = f"{MP_DOMAIN}.{slugify(NAME)}" diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index c65c0eb46c2..e773035447a 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -6,7 +6,12 @@ import voluptuous as vol from homeassistant import data_entry_flow from homeassistant.components.media_player import DEVICE_CLASS_SPEAKER, DEVICE_CLASS_TV +from homeassistant.components.vizio.config_flow import _get_config_schema from homeassistant.components.vizio.const import ( + CONF_APPS, + CONF_APPS_TO_INCLUDE_OR_EXCLUDE, + CONF_INCLUDE, + CONF_INCLUDE_OR_EXCLUDE, CONF_VOLUME_STEP, DEFAULT_NAME, DEFAULT_VOLUME_STEP, @@ -25,12 +30,16 @@ from homeassistant.helpers.typing import HomeAssistantType from .const import ( ACCESS_TOKEN, + CURRENT_APP, HOST, HOST2, MOCK_IMPORT_VALID_TV_CONFIG, + MOCK_INCLUDE_APPS, + MOCK_INCLUDE_NO_APPS, MOCK_PIN_CONFIG, MOCK_SPEAKER_CONFIG, MOCK_TV_CONFIG_NO_TOKEN, + MOCK_TV_WITH_EXCLUDE_CONFIG, MOCK_USER_VALID_TV_CONFIG, MOCK_ZEROCONF_SERVICE_INFO, NAME, @@ -86,12 +95,48 @@ async def test_user_flow_all_fields( result["flow_id"], user_input=MOCK_USER_VALID_TV_CONFIG ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "tv_apps" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_INCLUDE_APPS + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == NAME assert result["data"][CONF_NAME] == NAME assert result["data"][CONF_HOST] == HOST assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV assert result["data"][CONF_ACCESS_TOKEN] == ACCESS_TOKEN + assert result["data"][CONF_APPS][CONF_INCLUDE] == [CURRENT_APP] + + +async def test_user_apps_with_tv( + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_bypass_setup: pytest.fixture, +) -> None: + """Test TV can have selected apps during user setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=MOCK_IMPORT_VALID_TV_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "tv_apps" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_INCLUDE_APPS + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == NAME + assert result["data"][CONF_NAME] == NAME + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV + assert result["data"][CONF_ACCESS_TOKEN] == ACCESS_TOKEN + assert result["data"][CONF_APPS][CONF_INCLUDE] == [CURRENT_APP] + assert CONF_APPS_TO_INCLUDE_OR_EXCLUDE not in result["data"] + assert CONF_INCLUDE_OR_EXCLUDE not in result["data"] async def test_options_flow(hass: HomeAssistantType) -> None: @@ -218,13 +263,13 @@ async def test_user_error_on_could_not_connect( assert result["errors"] == {"base": "cant_connect"} -async def test_user_tv_pairing( +async def test_user_tv_pairing_no_apps( hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_bypass_setup: pytest.fixture, vizio_complete_pairing: pytest.fixture, ) -> None: - """Test pairing config flow when access token not provided for tv during user entry.""" + """Test pairing config flow when access token not provided for tv during user entry and no apps configured.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=MOCK_TV_CONFIG_NO_TOKEN ) @@ -237,15 +282,18 @@ async def test_user_tv_pairing( ) assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "pairing_complete" + assert result["step_id"] == "tv_apps" - result = await hass.config_entries.flow.async_configure(result["flow_id"]) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_INCLUDE_NO_APPS + ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["title"] == NAME assert result["data"][CONF_NAME] == NAME assert result["data"][CONF_HOST] == HOST assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV + assert CONF_APPS not in result["data"] async def test_user_start_pairing_failure( @@ -385,12 +433,12 @@ async def test_import_flow_update_options( ) -async def test_import_flow_update_name( +async def test_import_flow_update_name_and_apps( hass: HomeAssistantType, vizio_connect: pytest.fixture, vizio_bypass_update: pytest.fixture, ) -> None: - """Test import config flow with updated name.""" + """Test import config flow with updated name and apps.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, @@ -404,6 +452,7 @@ async def test_import_flow_update_name( updated_config = MOCK_IMPORT_VALID_TV_CONFIG.copy() updated_config[CONF_NAME] = NAME2 + updated_config[CONF_APPS] = {CONF_INCLUDE: [CURRENT_APP]} result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, @@ -413,6 +462,39 @@ async def test_import_flow_update_name( assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "updated_entry" assert hass.config_entries.async_get_entry(entry_id).data[CONF_NAME] == NAME2 + assert hass.config_entries.async_get_entry(entry_id).data[CONF_APPS] == { + CONF_INCLUDE: [CURRENT_APP] + } + + +async def test_import_flow_update_remove_apps( + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_bypass_update: pytest.fixture, +) -> None: + """Test import config flow with removed apps.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=vol.Schema(VIZIO_SCHEMA)(MOCK_TV_WITH_EXCLUDE_CONFIG), + ) + await hass.async_block_till_done() + + assert result["result"].data[CONF_NAME] == NAME + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + entry_id = result["result"].entry_id + + updated_config = MOCK_TV_WITH_EXCLUDE_CONFIG.copy() + updated_config.pop(CONF_APPS) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=vol.Schema(VIZIO_SCHEMA)(updated_config), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "updated_entry" + assert hass.config_entries.async_get_entry(entry_id).data.get(CONF_APPS) is None async def test_import_needs_pairing( @@ -452,6 +534,49 @@ async def test_import_needs_pairing( assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV +async def test_import_with_apps_needs_pairing( + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_bypass_setup: pytest.fixture, + vizio_complete_pairing: pytest.fixture, +) -> None: + """Test pairing config flow when access token not provided for tv but apps are included during import.""" + import_config = MOCK_TV_CONFIG_NO_TOKEN.copy() + import_config[CONF_APPS] = {CONF_INCLUDE: [CURRENT_APP]} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=import_config + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + # Mock inputting info without apps to make sure apps get stored + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=_get_config_schema(MOCK_TV_CONFIG_NO_TOKEN)(MOCK_TV_CONFIG_NO_TOKEN), + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pair_tv" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_PIN_CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "pairing_complete_import" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == NAME + assert result["data"][CONF_NAME] == NAME + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_DEVICE_CLASS] == DEVICE_CLASS_TV + assert result["data"][CONF_APPS][CONF_INCLUDE] == [CURRENT_APP] + + async def test_import_error( hass: HomeAssistantType, vizio_connect: pytest.fixture, diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index d13fe8ecf53..19696af73a2 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -1,14 +1,21 @@ """Tests for Vizio config flow.""" from datetime import timedelta +import logging +from typing import Any, Dict from unittest.mock import call from asynctest import patch import pytest +from pytest import raises +from pyvizio._api.apps import AppConfig from pyvizio.const import ( DEVICE_CLASS_SPEAKER as VIZIO_DEVICE_CLASS_SPEAKER, DEVICE_CLASS_TV as VIZIO_DEVICE_CLASS_TV, + INPUT_APPS, MAX_VOLUME, + UNKNOWN_APP, ) +import voluptuous as vol from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, @@ -27,16 +34,41 @@ from homeassistant.components.media_player import ( SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, ) -from homeassistant.components.vizio.const import CONF_VOLUME_STEP, DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.components.vizio import validate_apps +from homeassistant.components.vizio.const import ( + CONF_ADDITIONAL_CONFIGS, + CONF_APPS, + CONF_VOLUME_STEP, + DOMAIN, + VIZIO_SCHEMA, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_EXCLUDE, + CONF_INCLUDE, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import dt as dt_util from .const import ( + ADDITIONAL_APP_CONFIG, + APP_LIST, + CURRENT_APP, CURRENT_INPUT, + CUSTOM_APP_NAME, + CUSTOM_CONFIG, ENTITY_ID, INPUT_LIST, + INPUT_LIST_WITH_APPS, + MOCK_SPEAKER_APPS_FAILURE, MOCK_SPEAKER_CONFIG, + MOCK_TV_APPS_FAILURE, + MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG, + MOCK_TV_WITH_EXCLUDE_CONFIG, + MOCK_TV_WITH_INCLUDE_CONFIG, MOCK_USER_VALID_TV_CONFIG, NAME, UNIQUE_ID, @@ -45,6 +77,8 @@ from .const import ( from tests.common import MockConfigEntry, async_fire_time_changed +_LOGGER = logging.getLogger(__name__) + async def _test_setup( hass: HomeAssistantType, ha_device_class: str, vizio_power_state: bool @@ -60,12 +94,16 @@ async def _test_setup( if ha_device_class == DEVICE_CLASS_SPEAKER: vizio_device_class = VIZIO_DEVICE_CLASS_SPEAKER config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_SPEAKER_CONFIG, unique_id=UNIQUE_ID + domain=DOMAIN, + data=vol.Schema(VIZIO_SCHEMA)(MOCK_SPEAKER_CONFIG), + unique_id=UNIQUE_ID, ) else: vizio_device_class = VIZIO_DEVICE_CLASS_TV config_entry = MockConfigEntry( - domain=DOMAIN, data=MOCK_USER_VALID_TV_CONFIG, unique_id=UNIQUE_ID + domain=DOMAIN, + data=vol.Schema(VIZIO_SCHEMA)(MOCK_USER_VALID_TV_CONFIG), + unique_id=UNIQUE_ID, ) with patch( @@ -94,6 +132,72 @@ async def _test_setup( ) +async def _test_setup_with_apps( + hass: HomeAssistantType, device_config: Dict[str, Any], app: str +) -> None: + """Test Vizio Device with apps entity setup.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=vol.Schema(VIZIO_SCHEMA)(device_config), unique_id=UNIQUE_ID + ) + + with patch( + "homeassistant.components.vizio.media_player.VizioAsync.get_all_audio_settings", + return_value={ + "volume": int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2), + "mute": "Off", + }, + ), patch( + "homeassistant.components.vizio.media_player.VizioAsync.get_power_state", + return_value=True, + ), patch( + "homeassistant.components.vizio.media_player.VizioAsync.get_current_app", + return_value=app, + ), patch( + "homeassistant.components.vizio.media_player.VizioAsync.get_current_app_config", + return_value=AppConfig(**ADDITIONAL_APP_CONFIG["config"]), + ): + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + attr = hass.states.get(ENTITY_ID).attributes + assert attr["friendly_name"] == NAME + assert attr["device_class"] == DEVICE_CLASS_TV + assert hass.states.get(ENTITY_ID).state == STATE_ON + + if device_config.get(CONF_APPS, {}).get(CONF_INCLUDE) or device_config.get( + CONF_APPS, {} + ).get(CONF_EXCLUDE): + list_to_test = list(INPUT_LIST_WITH_APPS + [CURRENT_APP]) + elif device_config.get(CONF_APPS, {}).get(CONF_ADDITIONAL_CONFIGS): + list_to_test = list( + INPUT_LIST_WITH_APPS + + APP_LIST + + [ + app["name"] + for app in device_config[CONF_APPS][CONF_ADDITIONAL_CONFIGS] + ] + ) + else: + list_to_test = list(INPUT_LIST_WITH_APPS + APP_LIST) + + for app_to_remove in INPUT_APPS: + if app_to_remove in list_to_test: + list_to_test.remove(app_to_remove) + + assert attr["source_list"] == list_to_test + assert app in attr["source_list"] or app == UNKNOWN_APP + if app == UNKNOWN_APP: + assert attr["source"] == ADDITIONAL_APP_CONFIG["name"] + else: + assert attr["source"] == app + assert ( + attr["volume_level"] + == float(int(MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] / 2)) + / MAX_VOLUME[VIZIO_DEVICE_CLASS_TV] + ) + + async def _test_setup_failure(hass: HomeAssistantType, config: str) -> None: """Test generic Vizio entity setup failure.""" with patch( @@ -311,3 +415,89 @@ async def test_update_available_to_unavailable( ) -> None: """Test device becomes unavailable after being available.""" await _test_update_availability_switch(hass, True, None, caplog) + + +async def test_setup_with_apps( + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_update_with_apps: pytest.fixture, + caplog: pytest.fixture, +) -> None: + """Test device setup with apps.""" + await _test_setup_with_apps(hass, MOCK_USER_VALID_TV_CONFIG, CURRENT_APP) + await _test_service( + hass, + "launch_app", + SERVICE_SELECT_SOURCE, + {ATTR_INPUT_SOURCE: CURRENT_APP}, + CURRENT_APP, + ) + + +async def test_setup_with_apps_include( + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_update_with_apps: pytest.fixture, + caplog: pytest.fixture, +) -> None: + """Test device setup with apps and apps["include"] in config.""" + await _test_setup_with_apps(hass, MOCK_TV_WITH_INCLUDE_CONFIG, CURRENT_APP) + + +async def test_setup_with_apps_exclude( + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_update_with_apps: pytest.fixture, + caplog: pytest.fixture, +) -> None: + """Test device setup with apps and apps["exclude"] in config.""" + await _test_setup_with_apps(hass, MOCK_TV_WITH_EXCLUDE_CONFIG, CURRENT_APP) + + +async def test_setup_with_apps_additional_apps_config( + hass: HomeAssistantType, + vizio_connect: pytest.fixture, + vizio_update_with_apps: pytest.fixture, + caplog: pytest.fixture, +) -> None: + """Test device setup with apps and apps["additional_configs"] in config.""" + await _test_setup_with_apps(hass, MOCK_TV_WITH_ADDITIONAL_APPS_CONFIG, UNKNOWN_APP) + + await _test_service( + hass, + "launch_app", + SERVICE_SELECT_SOURCE, + {ATTR_INPUT_SOURCE: CURRENT_APP}, + CURRENT_APP, + ) + await _test_service( + hass, + "launch_app_config", + SERVICE_SELECT_SOURCE, + {ATTR_INPUT_SOURCE: CUSTOM_APP_NAME}, + **CUSTOM_CONFIG, + ) + + # Test that invalid app does nothing + with patch( + "homeassistant.components.vizio.media_player.VizioAsync.launch_app" + ) as service_call1, patch( + "homeassistant.components.vizio.media_player.VizioAsync.launch_app_config" + ) as service_call2: + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + service_data={ATTR_ENTITY_ID: ENTITY_ID, ATTR_INPUT_SOURCE: "_"}, + blocking=True, + ) + assert not service_call1.called + assert not service_call2.called + + +def test_invalid_apps_config(hass: HomeAssistantType): + """Test that schema validation fails on certain conditions.""" + with raises(vol.Invalid): + vol.Schema(vol.All(VIZIO_SCHEMA, validate_apps))(MOCK_TV_APPS_FAILURE) + + with raises(vol.Invalid): + vol.Schema(vol.All(VIZIO_SCHEMA, validate_apps))(MOCK_SPEAKER_APPS_FAILURE) From 19254eee3037651ffb095d6643394fcdf7805a3e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Mar 2020 13:34:24 -0800 Subject: [PATCH 261/416] Upgrade hass-nabucasa to 0.32 (#32508) --- homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index f3ea66971ac..b91af34a96a 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "cloud", "name": "Home Assistant Cloud", "documentation": "https://www.home-assistant.io/integrations/cloud", - "requirements": ["hass-nabucasa==0.31"], + "requirements": ["hass-nabucasa==0.32"], "dependencies": ["http", "webhook", "alexa"], "after_dependencies": ["google_assistant"], "codeowners": ["@home-assistant/cloud"] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 73696282dfd..f1de3f27d4a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ ciso8601==2.1.3 cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 -hass-nabucasa==0.31 +hass-nabucasa==0.32 home-assistant-frontend==20200228.0 importlib-metadata==1.5.0 jinja2>=2.10.3 diff --git a/requirements_all.txt b/requirements_all.txt index 1220ed30961..45c2a26b6b5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -663,7 +663,7 @@ habitipy==0.2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.31 +hass-nabucasa==0.32 # homeassistant.components.mqtt hbmqtt==0.9.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 420eefafb8b..c4b400192de 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -242,7 +242,7 @@ ha-ffmpeg==2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.31 +hass-nabucasa==0.32 # homeassistant.components.mqtt hbmqtt==0.9.5 From e02042b33eac4603e32a6082af74da29360dcd2f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 5 Mar 2020 17:19:30 -0800 Subject: [PATCH 262/416] Send messages conforming new facebook policy (#32516) --- homeassistant/components/facebook/notify.py | 7 ++++++- tests/components/facebook/test_notify.py | 11 ++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py index b75f2628033..dbd9be61516 100644 --- a/homeassistant/components/facebook/notify.py +++ b/homeassistant/components/facebook/notify.py @@ -97,7 +97,12 @@ class FacebookNotificationService(BaseNotificationService): else: recipient = {"id": target} - body = {"recipient": recipient, "message": body_message} + body = { + "recipient": recipient, + "message": body_message, + "messaging_type": "MESSAGE_TAG", + "tag": "ACCOUNT_UPDATE", + } resp = requests.post( BASE_URL, data=json.dumps(body), diff --git a/tests/components/facebook/test_notify.py b/tests/components/facebook/test_notify.py index c4c85d1cee0..c4675a4311a 100644 --- a/tests/components/facebook/test_notify.py +++ b/tests/components/facebook/test_notify.py @@ -30,6 +30,8 @@ class TestFacebook(unittest.TestCase): expected_body = { "recipient": {"phone_number": target[0]}, "message": {"text": message}, + "messaging_type": "MESSAGE_TAG", + "tag": "ACCOUNT_UPDATE", } assert mock.last_request.json() == expected_body @@ -53,6 +55,8 @@ class TestFacebook(unittest.TestCase): expected_body = { "recipient": {"phone_number": target}, "message": {"text": message}, + "messaging_type": "MESSAGE_TAG", + "tag": "ACCOUNT_UPDATE", } assert request.json() == expected_body @@ -77,7 +81,12 @@ class TestFacebook(unittest.TestCase): assert mock.called assert mock.call_count == 1 - expected_body = {"recipient": {"phone_number": target[0]}, "message": data} + expected_body = { + "recipient": {"phone_number": target[0]}, + "message": data, + "messaging_type": "MESSAGE_TAG", + "tag": "ACCOUNT_UPDATE", + } assert mock.last_request.json() == expected_body expected_params = {"access_token": ["page-access-token"]} From 5f6158f656d5b0212a640a6d7bb61bc113e56ddf Mon Sep 17 00:00:00 2001 From: Josef Schlehofer Date: Fri, 6 Mar 2020 07:30:04 +0100 Subject: [PATCH 263/416] Upgrade youtube_dl to version 2020.03.06 (#32521) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 7adf2fff505..53b9e93575e 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -2,7 +2,7 @@ "domain": "media_extractor", "name": "Media Extractor", "documentation": "https://www.home-assistant.io/integrations/media_extractor", - "requirements": ["youtube_dl==2020.03.01"], + "requirements": ["youtube_dl==2020.03.06"], "dependencies": ["media_player"], "codeowners": [], "quality_scale": "internal" diff --git a/requirements_all.txt b/requirements_all.txt index 45c2a26b6b5..aa2573253cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2140,7 +2140,7 @@ yeelight==0.5.0 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2020.03.01 +youtube_dl==2020.03.06 # homeassistant.components.zengge zengge==0.2 From 3b75fdccfd6bfd7bd8a50a7ea8bb3ce3895c2b53 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Fri, 6 Mar 2020 06:55:32 -0600 Subject: [PATCH 264/416] Add availability to roku media player entities (#32484) * track if roku is available based on update errors. * Update media_player.py * Update media_player.py --- homeassistant/components/roku/media_player.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index cc6a6056665..21a2f562293 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -59,6 +59,7 @@ class RokuDevice(MediaPlayerDevice): self.ip_address = host self.channels = [] self.current_app = None + self._available = False self._device_info = {} self._power_state = "Unknown" @@ -74,7 +75,10 @@ class RokuDevice(MediaPlayerDevice): self.current_app = self.roku.current_app else: self.current_app = None + + self._available = True except (requests.exceptions.ConnectionError, requests.exceptions.ReadTimeout): + self._available = False pass def get_source_list(self): @@ -116,6 +120,11 @@ class RokuDevice(MediaPlayerDevice): """Flag media player features that are supported.""" return SUPPORT_ROKU + @property + def available(self): + """Return if able to retrieve information from device or not.""" + return self._available + @property def unique_id(self): """Return a unique, Home Assistant friendly identifier for this entity.""" From 0d667c1bd9842125ddcd7817f0867c78b7868f40 Mon Sep 17 00:00:00 2001 From: olijouve <17448560+olijouve@users.noreply.github.com> Date: Fri, 6 Mar 2020 15:14:01 +0100 Subject: [PATCH 265/416] Add more onvif PTZ move modes (#30152) * Adding support for PTZ move modes Adding support for other PTZ move modes. Onvif intergration used to only support RelativeMove where it should also supports AbsoluteMove, ContinuousMove and Stop. For exemple Goke GK7102 based IP camera only support ContinuousMove mode. This commit add those new modes with avaibility to select mode and params in service call. * Adding support for PTZ move modes Adding support for other PTZ move modes. Onvif intergration used to only support RelativeMove where it should also supports AbsoluteMove, ContinuousMove and Stop. For exemple Goke GK7102 based IP camera only support ContinuousMove mode. Update service helper for new avaibility to select mode and params in service call. * RelativeMode as default move_mode to avoid breakchange RelativeMode as default move_mode to avoid breakchange * add missing attribute add missing continuous_duration attribute * change service attribute label for continuous_duration * update description fix wrong assertion for move_mode attr description * Update services.yaml * Update services.yaml fix wrong wording for move_mode * Update camera.py Using defined constants instead of raw strings in conditions * Update camera.py Replace integer to floating point in logger debug PTZ values * Update services.yaml * Update services.yaml * Update camera.py * Update camera.py * use dict[key] for required schema keys and keys with default schema values * remove async for setup_ptz method * lint error * remove unecessary PTZ_NONE = "NONE" changed request by @MartinHjelmare * addressing @ MartinHjelmare comments - Remove None in defaluts and dicts - Replace long if blocks * remove NONE * lint issue * Update camera.py * Fix lint error - typo * rename onvif_ptz service to just ptz * rename onvif_ptz service to just ptz * use dict[key] when default values are set use service.data[key] instead of service.data.get[key] when default value is set in service schema * adresse comment: use dict[key] for pan tilt zoom * Apply suggestions from code review Co-authored-by: Martin Hjelmare --- homeassistant/components/camera/services.yaml | 15 -- homeassistant/components/onvif/camera.py | 137 ++++++++++++++---- homeassistant/components/onvif/services.yaml | 35 +++-- 3 files changed, 133 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index c50e2926a3f..6196322e234 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -68,18 +68,3 @@ record: description: (Optional) Target lookback period (in seconds) to include in addition to duration. Only available if there is currently an active HLS stream. example: 4 -onvif_ptz: - description: Pan/Tilt/Zoom service for ONVIF camera. - fields: - entity_id: - description: Name(s) of entities to pan, tilt or zoom. - example: 'camera.living_room_camera' - pan: - description: "Direction of pan. Allowed values: LEFT, RIGHT." - example: 'LEFT' - tilt: - description: "Direction of tilt. Allowed values: DOWN, UP." - example: 'DOWN' - zoom: - description: "Zoom. Allowed values: ZOOM_IN, ZOOM_OUT" - example: "ZOOM_IN" diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index f87da72936d..02c9d2e9544 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -15,7 +15,6 @@ from zeep.asyncio import AsyncTransport from zeep.exceptions import Fault from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera -from homeassistant.components.camera.const import DOMAIN from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS, DATA_FFMPEG from homeassistant.const import ( ATTR_ENTITY_ID, @@ -48,6 +47,10 @@ CONF_PROFILE = "profile" ATTR_PAN = "pan" ATTR_TILT = "tilt" ATTR_ZOOM = "zoom" +ATTR_DISTANCE = "distance" +ATTR_SPEED = "speed" +ATTR_MOVE_MODE = "move_mode" +ATTR_CONTINUOUS_DURATION = "continuous_duration" DIR_UP = "UP" DIR_DOWN = "DOWN" @@ -55,13 +58,20 @@ DIR_LEFT = "LEFT" DIR_RIGHT = "RIGHT" ZOOM_OUT = "ZOOM_OUT" ZOOM_IN = "ZOOM_IN" -PTZ_NONE = "NONE" +PAN_FACTOR = {DIR_RIGHT: 1, DIR_LEFT: -1} +TILT_FACTOR = {DIR_UP: 1, DIR_DOWN: -1} +ZOOM_FACTOR = {ZOOM_IN: 1, ZOOM_OUT: -1} +CONTINUOUS_MOVE = "ContinuousMove" +RELATIVE_MOVE = "RelativeMove" +ABSOLUTE_MOVE = "AbsoluteMove" -SERVICE_PTZ = "onvif_ptz" +SERVICE_PTZ = "ptz" +DOMAIN = "onvif" ONVIF_DATA = "onvif" ENTITIES = "entities" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -79,9 +89,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( SERVICE_PTZ_SCHEMA = vol.Schema( { ATTR_ENTITY_ID: cv.entity_ids, - ATTR_PAN: vol.In([DIR_LEFT, DIR_RIGHT, PTZ_NONE]), - ATTR_TILT: vol.In([DIR_UP, DIR_DOWN, PTZ_NONE]), - ATTR_ZOOM: vol.In([ZOOM_OUT, ZOOM_IN, PTZ_NONE]), + vol.Optional(ATTR_PAN): vol.In([DIR_LEFT, DIR_RIGHT]), + vol.Optional(ATTR_TILT): vol.In([DIR_UP, DIR_DOWN]), + vol.Optional(ATTR_ZOOM): vol.In([ZOOM_OUT, ZOOM_IN]), + ATTR_MOVE_MODE: vol.In([CONTINUOUS_MOVE, RELATIVE_MOVE, ABSOLUTE_MOVE]), + vol.Optional(ATTR_CONTINUOUS_DURATION, default=0.5): cv.small_float, + vol.Optional(ATTR_DISTANCE, default=0.1): cv.small_float, + vol.Optional(ATTR_SPEED, default=0.5): cv.small_float, } ) @@ -92,9 +106,13 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async def async_handle_ptz(service): """Handle PTZ service call.""" - pan = service.data.get(ATTR_PAN, None) - tilt = service.data.get(ATTR_TILT, None) - zoom = service.data.get(ATTR_ZOOM, None) + pan = service.data.get(ATTR_PAN) + tilt = service.data.get(ATTR_TILT) + zoom = service.data.get(ATTR_ZOOM) + distance = service.data[ATTR_DISTANCE] + speed = service.data[ATTR_SPEED] + move_mode = service.data.get(ATTR_MOVE_MODE) + continuous_duration = service.data[ATTR_CONTINUOUS_DURATION] all_cameras = hass.data[ONVIF_DATA][ENTITIES] entity_ids = await async_extract_entity_ids(hass, service) target_cameras = [] @@ -105,7 +123,9 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= camera for camera in all_cameras if camera.entity_id in entity_ids ] for camera in target_cameras: - await camera.async_perform_ptz(pan, tilt, zoom) + await camera.async_perform_ptz( + pan, tilt, zoom, distance, speed, move_mode, continuous_duration + ) hass.services.async_register( DOMAIN, SERVICE_PTZ, async_handle_ptz, schema=SERVICE_PTZ_SCHEMA @@ -263,6 +283,35 @@ class ONVIFHassCamera(Camera): "Couldn't get camera '%s' date/time. Error: %s", self._name, err ) + async def async_obtain_profile_token(self): + """Obtain profile token to use with requests.""" + try: + media_service = self._camera.get_service("media") + + profiles = await media_service.GetProfiles() + + _LOGGER.debug("Retrieved '%d' profiles", len(profiles)) + + if self._profile_index >= len(profiles): + _LOGGER.warning( + "ONVIF Camera '%s' doesn't provide profile %d." + " Using the last profile.", + self._name, + self._profile_index, + ) + self._profile_index = -1 + + _LOGGER.debug("Using profile index '%d'", self._profile_index) + + return profiles[self._profile_index].token + except exceptions.ONVIFError as err: + _LOGGER.error( + "Couldn't retrieve profile token of camera '%s'. Error: %s", + self._name, + err, + ) + return None + async def async_obtain_input_uri(self): """Set the input uri for the camera.""" _LOGGER.debug( @@ -320,37 +369,67 @@ class ONVIFHassCamera(Camera): def setup_ptz(self): """Set up PTZ if available.""" _LOGGER.debug("Setting up the ONVIF PTZ service") - if self._camera.get_service("ptz", create=False) is None: + if self._camera.get_service("ptz") is None: _LOGGER.debug("PTZ is not available") else: self._ptz_service = self._camera.create_ptz_service() - _LOGGER.debug("Completed set up of the ONVIF camera component") + _LOGGER.debug("Completed set up of the ONVIF camera component") - async def async_perform_ptz(self, pan, tilt, zoom): + async def async_perform_ptz( + self, pan, tilt, zoom, distance, speed, move_mode, continuous_duration + ): """Perform a PTZ action on the camera.""" if self._ptz_service is None: _LOGGER.warning("PTZ actions are not supported on camera '%s'", self._name) return if self._ptz_service: - pan_val = 1 if pan == DIR_RIGHT else -1 if pan == DIR_LEFT else 0 - tilt_val = 1 if tilt == DIR_UP else -1 if tilt == DIR_DOWN else 0 - zoom_val = 1 if zoom == ZOOM_IN else -1 if zoom == ZOOM_OUT else 0 - req = { - "Velocity": { - "PanTilt": {"_x": pan_val, "_y": tilt_val}, - "Zoom": {"_x": zoom_val}, - } - } + pan_val = distance * PAN_FACTOR.get(pan, 0) + tilt_val = distance * TILT_FACTOR.get(tilt, 0) + zoom_val = distance * ZOOM_FACTOR.get(zoom, 0) + speed_val = speed + _LOGGER.debug( + "Calling %s PTZ | Pan = %4.2f | Tilt = %4.2f | Zoom = %4.2f | Speed = %4.2f", + move_mode, + pan_val, + tilt_val, + zoom_val, + speed_val, + ) try: - _LOGGER.debug( - "Calling PTZ | Pan = %d | Tilt = %d | Zoom = %d", - pan_val, - tilt_val, - zoom_val, - ) + req = self._ptz_service.create_type(move_mode) + req.ProfileToken = await self.async_obtain_profile_token() + if move_mode == CONTINUOUS_MOVE: + req.Velocity = { + "PanTilt": {"x": pan_val, "y": tilt_val}, + "Zoom": {"x": zoom_val}, + } - await self._ptz_service.ContinuousMove(req) + await self._ptz_service.ContinuousMove(req) + await asyncio.sleep(continuous_duration) + req = self._ptz_service.create_type("Stop") + req.ProfileToken = await self.async_obtain_profile_token() + await self._ptz_service.Stop({"ProfileToken": req.ProfileToken}) + elif move_mode == RELATIVE_MOVE: + req.Translation = { + "PanTilt": {"x": pan_val, "y": tilt_val}, + "Zoom": {"x": zoom_val}, + } + req.Speed = { + "PanTilt": {"x": speed_val, "y": speed_val}, + "Zoom": {"x": speed_val}, + } + await self._ptz_service.RelativeMove(req) + elif move_mode == ABSOLUTE_MOVE: + req.Position = { + "PanTilt": {"x": pan_val, "y": tilt_val}, + "Zoom": {"x": zoom_val}, + } + req.Speed = { + "PanTilt": {"x": speed_val, "y": speed_val}, + "Zoom": {"x": speed_val}, + } + await self._ptz_service.AbsoluteMove(req) except exceptions.ONVIFError as err: if "Bad Request" in err.reason: self._ptz_service = None diff --git a/homeassistant/components/onvif/services.yaml b/homeassistant/components/onvif/services.yaml index 667538f056a..8d14633cc9c 100644 --- a/homeassistant/components/onvif/services.yaml +++ b/homeassistant/components/onvif/services.yaml @@ -1,16 +1,31 @@ -onvif_ptz: +ptz: description: If your ONVIF camera supports PTZ, you will be able to pan, tilt or zoom your camera. fields: entity_id: - description: 'String or list of strings that point at entity_ids of cameras. Else targets all.' - example: 'camera.backyard' + description: "String or list of strings that point at entity_ids of cameras. Else targets all." + example: "camera.living_room_camera" tilt: - description: 'Tilt direction. Allowed values: UP, DOWN, NONE' - example: 'UP' + description: "Tilt direction. Allowed values: UP, DOWN" + example: "UP" pan: - description: 'Pan direction. Allowed values: RIGHT, LEFT, NONE' - example: 'RIGHT' + description: "Pan direction. Allowed values: RIGHT, LEFT" + example: "RIGHT" zoom: - description: 'Zoom. Allowed values: ZOOM_IN, ZOOM_OUT, NONE' - example: 'NONE' - + description: "Zoom. Allowed values: ZOOM_IN, ZOOM_OUT" + example: "ZOOM_IN" + distance: + description: "Distance coefficient. Sets how much PTZ should be executed in one request. Allowed values: floating point numbers, 0 to 1" + default: 0.1 + example: 0.1 + speed: + description: "Speed coefficient. Sets how fast PTZ will be executed. Allowed values: floating point numbers, 0 to 1" + default: 0.5 + example: 0.5 + continuous_duration: + description: "Set ContinuousMove delay in seconds before stopping the move" + default: 0.5 + example: 0.5 + move_mode: + description: "PTZ moving mode. One of ContinuousMove, RelativeMove or AbsoluteMove" + default: "RelativeMove" + example: "ContinuousMove" From 2879081772b248b707a22afa6f5a80ac0ff0d166 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Fri, 6 Mar 2020 15:47:40 +0000 Subject: [PATCH 266/416] Implement source switching for homekit_controller televisions (#32526) --- .../components/homekit_controller/__init__.py | 10 +++ .../homekit_controller/connection.py | 13 +++- .../homekit_controller/manifest.json | 2 +- .../homekit_controller/media_player.py | 71 +++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homekit_controller/common.py | 6 +- .../specific_devices/test_lg_tv.py | 22 +++++- .../homekit_controller/test_config_flow.py | 14 ++-- .../homekit_controller/test_media_player.py | 51 +++++++++++-- 10 files changed, 177 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index d94405f23e3..b5dd848aa77 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -95,6 +95,16 @@ class HomeKitEntity(Entity): continue self._setup_characteristic(char) + accessory = self._accessory.entity_map.aid(self._aid) + this_service = accessory.services.iid(self._iid) + for child_service in accessory.services.filter( + parent_service=this_service + ): + for char in child_service.characteristics: + if char.type not in characteristic_types: + continue + self._setup_characteristic(char.to_accessory_and_service_list()) + def _setup_characteristic(self, char): """Configure an entity based on a HomeKit characteristics metadata.""" # Build up a list of (aid, iid) tuples to poll on update() diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index a84c318b6b3..06f2830d5f8 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -8,6 +8,7 @@ from aiohomekit.exceptions import ( AccessoryNotFoundError, EncryptionError, ) +from aiohomekit.model import Accessories from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.services import ServicesTypes @@ -69,9 +70,11 @@ class HKDevice: self.pairing_data["AccessoryPairingID"], self.pairing_data ) - self.accessories = {} + self.accessories = None self.config_num = 0 + self.entity_map = Accessories() + # A list of callbacks that turn HK service metadata into entities self.listeners = [] @@ -153,6 +156,8 @@ class HKDevice: self.accessories = cache["accessories"] self.config_num = cache["config_num"] + self.entity_map = Accessories.from_list(self.accessories) + self._polling_interval_remover = async_track_time_interval( self.hass, self.async_update, DEFAULT_SCAN_INTERVAL ) @@ -213,6 +218,8 @@ class HKDevice: # later when Bonjour spots c# is still not up to date. return False + self.entity_map = Accessories.from_list(self.accessories) + self.hass.data[ENTITY_MAP].async_create_or_update_map( self.unique_id, config_num, self.accessories ) @@ -318,6 +325,10 @@ class HKDevice: accessory = self.current_state.setdefault(aid, {}) accessory[cid] = value + # self.current_state will be replaced by entity_map in a future PR + # For now we update both + self.entity_map.process_changes(new_values_dict) + self.hass.helpers.dispatcher.async_dispatcher_send(self.signal_state_updated) async def get_characteristics(self, *args, **kwargs): diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 5351dfb69cb..4cfc642bf8c 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit[IP]==0.2.17"], + "requirements": ["aiohomekit[IP]==0.2.21"], "dependencies": [], "zeroconf": ["_hap._tcp.local."], "codeowners": ["@Jc2k"] diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py index 7f38dc3ce2a..38817712def 100644 --- a/homeassistant/components/homekit_controller/media_player.py +++ b/homeassistant/components/homekit_controller/media_player.py @@ -7,12 +7,14 @@ from aiohomekit.model.characteristics import ( RemoteKeyValues, TargetMediaStateValues, ) +from aiohomekit.model.services import ServicesTypes from aiohomekit.utils import clamp_enum_to_char from homeassistant.components.media_player import DEVICE_CLASS_TV, MediaPlayerDevice from homeassistant.components.media_player.const import ( SUPPORT_PAUSE, SUPPORT_PLAY, + SUPPORT_SELECT_SOURCE, SUPPORT_STOP, ) from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING @@ -63,8 +65,15 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice): CharacteristicsTypes.CURRENT_MEDIA_STATE, CharacteristicsTypes.TARGET_MEDIA_STATE, CharacteristicsTypes.REMOTE_KEY, + CharacteristicsTypes.ACTIVE_IDENTIFIER, + # Characterics that are on the linked INPUT_SOURCE services + CharacteristicsTypes.CONFIGURED_NAME, + CharacteristicsTypes.IDENTIFIER, ] + def _setup_active_identifier(self, char): + self._features |= SUPPORT_SELECT_SOURCE + def _setup_target_media_state(self, char): self._supported_target_media_state = clamp_enum_to_char( TargetMediaStateValues, char @@ -94,6 +103,43 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice): """Flag media player features that are supported.""" return self._features + @property + def source_list(self): + """List of all input sources for this television.""" + sources = [] + + this_accessory = self._accessory.entity_map.aid(self._aid) + this_tv = this_accessory.services.iid(self._iid) + + input_sources = this_accessory.services.filter( + service_type=ServicesTypes.INPUT_SOURCE, parent_service=this_tv, + ) + + for input_source in input_sources: + char = input_source[CharacteristicsTypes.CONFIGURED_NAME] + sources.append(char.value) + return sources + + @property + def source(self): + """Name of the current input source.""" + active_identifier = self.get_hk_char_value( + CharacteristicsTypes.ACTIVE_IDENTIFIER + ) + if not active_identifier: + return None + + this_accessory = self._accessory.entity_map.aid(self._aid) + this_tv = this_accessory.services.iid(self._iid) + + input_source = this_accessory.services.first( + service_type=ServicesTypes.INPUT_SOURCE, + characteristics={CharacteristicsTypes.IDENTIFIER: active_identifier}, + parent_service=this_tv, + ) + char = input_source[CharacteristicsTypes.CONFIGURED_NAME] + return char.value + @property def state(self): """State of the tv.""" @@ -167,3 +213,28 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice): } ] await self._accessory.put_characteristics(characteristics) + + async def async_select_source(self, source): + """Switch to a different media source.""" + this_accessory = self._accessory.entity_map.aid(self._aid) + this_tv = this_accessory.services.iid(self._iid) + + input_source = this_accessory.services.first( + service_type=ServicesTypes.INPUT_SOURCE, + characteristics={CharacteristicsTypes.CONFIGURED_NAME: source}, + parent_service=this_tv, + ) + + if not input_source: + raise ValueError(f"Could not find source {source}") + + identifier = input_source[CharacteristicsTypes.IDENTIFIER] + + characteristics = [ + { + "aid": self._aid, + "iid": self._chars["active-identifier"], + "value": identifier.value, + } + ] + await self._accessory.put_characteristics(characteristics) diff --git a/requirements_all.txt b/requirements_all.txt index aa2573253cd..6b0d1a42787 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -163,7 +163,7 @@ aioftp==0.12.0 aioharmony==0.1.13 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.17 +aiohomekit[IP]==0.2.21 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c4b400192de..685f2e6ad41 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -62,7 +62,7 @@ aiobotocore==0.11.1 aioesphomeapi==2.6.1 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.17 +aiohomekit[IP]==0.2.21 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 7e27d1a970b..a1b5f37324d 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -67,7 +67,7 @@ async def setup_accessories_from_file(hass, path): load_fixture, os.path.join("homekit_controller", path) ) accessories_json = json.loads(accessories_fixture) - accessories = Accessory.setup_accessories_from_list(accessories_json) + accessories = Accessories.from_list(accessories_json) return accessories @@ -153,7 +153,9 @@ async def setup_test_component(hass, setup_accessory, capitalize=False, suffix=N If suffix is set, entityId will include the suffix """ - accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1") + accessory = Accessory.create_with_info( + "TestDevice", "example.com", "Test", "0001", "0.1" + ) setup_accessory(accessory) domain = None diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py index 69f17ba6431..3ffd906213b 100644 --- a/tests/components/homekit_controller/specific_devices/test_lg_tv.py +++ b/tests/components/homekit_controller/specific_devices/test_lg_tv.py @@ -1,7 +1,11 @@ """Make sure that handling real world LG HomeKit characteristics isn't broken.""" -from homeassistant.components.media_player.const import SUPPORT_PAUSE, SUPPORT_PLAY +from homeassistant.components.media_player.const import ( + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_SELECT_SOURCE, +) from tests.components.homekit_controller.common import ( Helper, @@ -29,8 +33,22 @@ async def test_lg_tv(hass): # Assert that the friendly name is detected correctly assert state.attributes["friendly_name"] == "LG webOS TV AF80" + # Assert that all channels were found and that we know which is active. + assert state.attributes["source_list"] == [ + "AirPlay", + "Live TV", + "HDMI 1", + "Sony", + "Apple", + "AV", + "HDMI 4", + ] + assert state.attributes["source"] == "HDMI 4" + # Assert that all optional features the LS1 supports are detected - assert state.attributes["supported_features"] == (SUPPORT_PAUSE | SUPPORT_PLAY) + assert state.attributes["supported_features"] == ( + SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE + ) device_registry = await hass.helpers.device_registry.async_get_registry() diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 2f2554caf85..760c5f30436 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -147,7 +147,7 @@ def setup_mock_accessory(controller): """Add a bridge accessory to a test controller.""" bridge = Accessories() - accessory = Accessory( + accessory = Accessory.create_with_info( name="Koogeek-LS1-20833F", manufacturer="Koogeek", model="LS1", @@ -500,7 +500,9 @@ async def test_user_no_unpaired_devices(hass, controller): async def test_parse_new_homekit_json(hass): """Test migrating recent .homekit/pairings.json files.""" - accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1") + accessory = Accessory.create_with_info( + "TestDevice", "example.com", "Test", "0001", "0.1" + ) service = accessory.add_service(ServicesTypes.LIGHTBULB) on_char = service.add_char(CharacteristicsTypes.ON) on_char.value = 0 @@ -549,7 +551,9 @@ async def test_parse_new_homekit_json(hass): async def test_parse_old_homekit_json(hass): """Test migrating original .homekit/hk-00:00:00:00:00:00 files.""" - accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1") + accessory = Accessory.create_with_info( + "TestDevice", "example.com", "Test", "0001", "0.1" + ) service = accessory.add_service(ServicesTypes.LIGHTBULB) on_char = service.add_char(CharacteristicsTypes.ON) on_char.value = 0 @@ -602,7 +606,9 @@ async def test_parse_old_homekit_json(hass): async def test_parse_overlapping_homekit_json(hass): """Test migrating .homekit/pairings.json files when hk- exists too.""" - accessory = Accessory("TestDevice", "example.com", "Test", "0001", "0.1") + accessory = Accessory.create_with_info( + "TestDevice", "example.com", "Test", "0001", "0.1" + ) service = accessory.add_service(ServicesTypes.LIGHTBULB) on_char = service.add_char(CharacteristicsTypes.ON) on_char.value = 0 diff --git a/tests/components/homekit_controller/test_media_player.py b/tests/components/homekit_controller/test_media_player.py index 3389201f61d..09798c218a8 100644 --- a/tests/components/homekit_controller/test_media_player.py +++ b/tests/components/homekit_controller/test_media_player.py @@ -10,6 +10,7 @@ from tests.components.homekit_controller.common import setup_test_component CURRENT_MEDIA_STATE = ("television", "current-media-state") TARGET_MEDIA_STATE = ("television", "target-media-state") REMOTE_KEY = ("television", "remote-key") +ACTIVE_IDENTIFIER = ("television", "active-identifier") def create_tv_service(accessory): @@ -18,16 +19,33 @@ def create_tv_service(accessory): The TV is not currently documented publicly - this is based on observing really TV's that have HomeKit support. """ - service = accessory.add_service(ServicesTypes.TELEVISION) + tv_service = accessory.add_service(ServicesTypes.TELEVISION) - cur_state = service.add_char(CharacteristicsTypes.CURRENT_MEDIA_STATE) + cur_state = tv_service.add_char(CharacteristicsTypes.CURRENT_MEDIA_STATE) cur_state.value = 0 - remote = service.add_char(CharacteristicsTypes.REMOTE_KEY) + remote = tv_service.add_char(CharacteristicsTypes.REMOTE_KEY) remote.value = None remote.perms.append(CharacteristicPermissions.paired_write) - return service + # Add a HDMI 1 channel + input_source_1 = accessory.add_service(ServicesTypes.INPUT_SOURCE) + input_source_1.add_char(CharacteristicsTypes.IDENTIFIER, value=1) + input_source_1.add_char(CharacteristicsTypes.CONFIGURED_NAME, value="HDMI 1") + tv_service.add_linked_service(input_source_1) + + # Add a HDMI 2 channel + input_source_2 = accessory.add_service(ServicesTypes.INPUT_SOURCE) + input_source_2.add_char(CharacteristicsTypes.IDENTIFIER, value=2) + input_source_2.add_char(CharacteristicsTypes.CONFIGURED_NAME, value="HDMI 2") + tv_service.add_linked_service(input_source_2) + + # Support switching channels + active_identifier = tv_service.add_char(CharacteristicsTypes.ACTIVE_IDENTIFIER) + active_identifier.value = 1 + active_identifier.perms.append(CharacteristicPermissions.paired_write) + + return tv_service def create_tv_service_with_target_media_state(accessory): @@ -58,6 +76,15 @@ async def test_tv_read_state(hass, utcnow): assert state.state == "idle" +async def test_tv_read_sources(hass, utcnow): + """Test that we can read the input source of a HomeKit TV.""" + helper = await setup_test_component(hass, create_tv_service) + + state = await helper.poll_and_get_state() + assert state.attributes["source"] == "HDMI 1" + assert state.attributes["source_list"] == ["HDMI 1", "HDMI 2"] + + async def test_play_remote_key(hass, utcnow): """Test that we can play media on a media player.""" helper = await setup_test_component(hass, create_tv_service) @@ -202,3 +229,19 @@ async def test_stop(hass, utcnow): ) assert helper.characteristics[REMOTE_KEY].value is None assert helper.characteristics[TARGET_MEDIA_STATE].value is None + + +async def test_tv_set_source(hass, utcnow): + """Test that we can set the input source of a HomeKit TV.""" + helper = await setup_test_component(hass, create_tv_service) + + await hass.services.async_call( + "media_player", + "select_source", + {"entity_id": "media_player.testdevice", "source": "HDMI 2"}, + blocking=True, + ) + assert helper.characteristics[ACTIVE_IDENTIFIER].value == 2 + + state = await helper.poll_and_get_state() + assert state.attributes["source"] == "HDMI 2" From ac945242dc2312984602446fc576a56f3b637f18 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 6 Mar 2020 17:42:19 +0100 Subject: [PATCH 267/416] Updated frontend to 20200306.0 (#32532) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 8fc45b3320a..9925d26df98 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200228.0" + "home-assistant-frontend==20200306.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f1de3f27d4a..ae621c1bcef 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.32 -home-assistant-frontend==20200228.0 +home-assistant-frontend==20200306.0 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 6b0d1a42787..cf6a929dcd3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -693,7 +693,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200228.0 +home-assistant-frontend==20200306.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 685f2e6ad41..e6a00a6955e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -260,7 +260,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200228.0 +home-assistant-frontend==20200306.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 From e1cc2acdf917df97c1ecc7e4799b43b7c531d279 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Fri, 6 Mar 2020 19:59:57 +0200 Subject: [PATCH 268/416] Specify rtsp_transport for Onvif Camera (#31918) * specify rtsp_transport for onvif camera * remove used variable * Update homeassistant/components/stream/__init__.py Co-Authored-By: Paulus Schoutsen * change options to stream_options Co-authored-by: Paulus Schoutsen --- homeassistant/components/camera/__init__.py | 25 +++++++++++++++++---- homeassistant/components/onvif/camera.py | 6 +++++ homeassistant/components/stream/__init__.py | 13 +++++------ 3 files changed, 33 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 45cfe96e11b..5d9bc99f945 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -136,7 +136,13 @@ async def async_request_stream(hass, entity_id, fmt): f"{camera.entity_id} does not support play stream service" ) - return request_stream(hass, source, fmt=fmt, keepalive=camera_prefs.preload_stream) + return request_stream( + hass, + source, + fmt=fmt, + keepalive=camera_prefs.preload_stream, + options=camera.options, + ) @bind_hass @@ -256,7 +262,7 @@ async def async_setup(hass, config): if not source: continue - request_stream(hass, source, keepalive=True) + request_stream(hass, source, keepalive=True, options=camera.stream_options) async_when_setup(hass, DOMAIN_STREAM, preload_stream) @@ -312,6 +318,7 @@ class Camera(Entity): def __init__(self): """Initialize a camera.""" self.is_streaming = False + self.stream_options = {} self.content_type = DEFAULT_CONTENT_TYPE self.access_tokens: collections.deque = collections.deque([], 2) self.async_update_token() @@ -581,7 +588,11 @@ async def ws_camera_stream(hass, connection, msg): fmt = msg["format"] url = request_stream( - hass, source, fmt=fmt, keepalive=camera_prefs.preload_stream + hass, + source, + fmt=fmt, + keepalive=camera_prefs.preload_stream, + options=camera.stream_options, ) connection.send_result(msg["id"], {"url": url}) except HomeAssistantError as ex: @@ -666,7 +677,13 @@ async def async_handle_play_stream_service(camera, service_call): fmt = service_call.data[ATTR_FORMAT] entity_ids = service_call.data[ATTR_MEDIA_PLAYER] - url = request_stream(hass, source, fmt=fmt, keepalive=camera_prefs.preload_stream) + url = request_stream( + hass, + source, + fmt=fmt, + keepalive=camera_prefs.preload_stream, + options=camera.stream_options, + ) data = { ATTR_ENTITY_ID: entity_ids, ATTR_MEDIA_CONTENT_ID: f"{hass.config.api.base_url}{url}", diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 02c9d2e9544..614eb4e6556 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -43,6 +43,7 @@ DEFAULT_ARGUMENTS = "-pred 1" DEFAULT_PROFILE = 0 CONF_PROFILE = "profile" +CONF_RTSP_TRANSPORT = "rtsp_transport" ATTR_PAN = "pan" ATTR_TILT = "tilt" @@ -71,6 +72,7 @@ DOMAIN = "onvif" ONVIF_DATA = "onvif" ENTITIES = "entities" +RTSP_TRANS_PROTOCOLS = ["tcp", "udp", "udp_multicast", "http"] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -80,6 +82,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_EXTRA_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string, + vol.Optional(CONF_RTSP_TRANSPORT, default=RTSP_TRANS_PROTOCOLS[0]): vol.In( + RTSP_TRANS_PROTOCOLS + ), vol.Optional(CONF_PROFILE, default=DEFAULT_PROFILE): vol.All( vol.Coerce(int), vol.Range(min=0) ), @@ -161,6 +166,7 @@ class ONVIFHassCamera(Camera): self._profile_index = config.get(CONF_PROFILE) self._ptz_service = None self._input = None + self.stream_options[CONF_RTSP_TRANSPORT] = config.get(CONF_RTSP_TRANSPORT) self._mac = None _LOGGER.debug( diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index d88f90a83f8..ab80630cb33 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -50,13 +50,12 @@ def request_stream(hass, stream_source, *, fmt="hls", keepalive=False, options=N options = {} # For RTSP streams, prefer TCP - if ( - isinstance(stream_source, str) - and stream_source[:7] == "rtsp://" - and not options - ): - options["rtsp_flags"] = "prefer_tcp" - options["stimeout"] = "5000000" + if isinstance(stream_source, str) and stream_source[:7] == "rtsp://": + options = { + "rtsp_flags": "prefer_tcp", + "stimeout": "5000000", + **options, + } try: streams = hass.data[DOMAIN][ATTR_STREAMS] From 8f2567f30d79168ee9aa0e33efa8cfc9d28427d2 Mon Sep 17 00:00:00 2001 From: Tom Schneider Date: Fri, 6 Mar 2020 19:17:30 +0100 Subject: [PATCH 269/416] Add config_flow to shopping_list (#32388) * Add config_flow to shopping_list * Fix pylint unused import error * Use _abort_if_unique_id_configured * Remove SHOPPING_LIST const * Use const.py for DOMAIN and CONF_TYPE * Fix tests * Remove unchanged variable _errors * Revert CONF_TYPE (wrong usage) * Use consts in test * Remove import check * Remove data={} * Remove parameters and default values * Re-add data={}, because it's needed * Unique ID checks and reverts for default parameters * remove pylint comment * Remove block till done * Address change requests * Update homeassistant/components/shopping_list/strings.json Co-Authored-By: Quentame * Update homeassistant/components/shopping_list/strings.json Co-Authored-By: Quentame * Update tests/components/shopping_list/test_config_flow.py Co-Authored-By: Quentame * Update tests/components/shopping_list/test_config_flow.py Co-Authored-By: Quentame * Update tests/components/shopping_list/test_config_flow.py Co-Authored-By: Quentame * Update tests/components/shopping_list/test_config_flow.py Co-Authored-By: Quentame * Only test config_flow * Generate translations * Move data to end * @asyncio.coroutine --> async def, yield from --> await * @asyncio.coroutine --> async def, yield from --> await (tests) * Remove init in config flow * remove if not hass.config_entries.async_entries(DOMAIN) * Add DOMAIN not in config * Fix tests * Update homeassistant/components/shopping_list/config_flow.py Co-Authored-By: Paulus Schoutsen * Fix tests * Update homeassistant/components/shopping_list/__init__.py Co-Authored-By: Martin Hjelmare Co-authored-by: Quentame Co-authored-by: Paulus Schoutsen Co-authored-by: Martin Hjelmare --- .../shopping_list/.translations/en.json | 14 +++ .../components/shopping_list/__init__.py | 34 ++++--- .../components/shopping_list/config_flow.py | 24 +++++ .../components/shopping_list/const.py | 2 + .../components/shopping_list/manifest.json | 1 + .../components/shopping_list/strings.json | 14 +++ homeassistant/generated/config_flows.py | 1 + tests/components/shopping_list/conftest.py | 13 ++- .../shopping_list/test_config_flow.py | 36 ++++++++ tests/components/shopping_list/test_init.py | 89 ++++++++----------- 10 files changed, 163 insertions(+), 65 deletions(-) create mode 100644 homeassistant/components/shopping_list/.translations/en.json create mode 100644 homeassistant/components/shopping_list/config_flow.py create mode 100644 homeassistant/components/shopping_list/const.py create mode 100644 homeassistant/components/shopping_list/strings.json create mode 100644 tests/components/shopping_list/test_config_flow.py diff --git a/homeassistant/components/shopping_list/.translations/en.json b/homeassistant/components/shopping_list/.translations/en.json new file mode 100644 index 00000000000..6a22409e8c6 --- /dev/null +++ b/homeassistant/components/shopping_list/.translations/en.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "The shopping list is already configured." + }, + "step": { + "user": { + "description": "Do you want to configure the shopping list?", + "title": "Shopping List" + } + }, + "title": "Shopping List" + } +} \ No newline at end of file diff --git a/homeassistant/components/shopping_list/__init__.py b/homeassistant/components/shopping_list/__init__.py index 3f61f70f858..11f61d6d626 100644 --- a/homeassistant/components/shopping_list/__init__.py +++ b/homeassistant/components/shopping_list/__init__.py @@ -1,10 +1,10 @@ """Support to manage a shopping list.""" -import asyncio import logging import uuid import voluptuous as vol +from homeassistant import config_entries from homeassistant.components import http, websocket_api from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import HTTP_BAD_REQUEST, HTTP_NOT_FOUND @@ -12,9 +12,10 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json +from .const import DOMAIN + ATTR_NAME = "name" -DOMAIN = "shopping_list" _LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema({DOMAIN: {}}, extra=vol.ALLOW_EXTRA) EVENT = "shopping_list_updated" @@ -53,20 +54,32 @@ SCHEMA_WEBSOCKET_CLEAR_ITEMS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend( ) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Initialize the shopping list.""" - @asyncio.coroutine - def add_item_service(call): + if DOMAIN not in config: + return True + + 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, config_entry): + """Set up shopping list from config flow.""" + + async def add_item_service(call): """Add an item with `name`.""" data = hass.data[DOMAIN] name = call.data.get(ATTR_NAME) if name is not None: data.async_add(name) - @asyncio.coroutine - def complete_item_service(call): + async def complete_item_service(call): """Mark the item provided via `name` as completed.""" data = hass.data[DOMAIN] name = call.data.get(ATTR_NAME) @@ -80,7 +93,7 @@ def async_setup(hass, config): data.async_update(item["id"], {"name": name, "complete": True}) data = hass.data[DOMAIN] = ShoppingData(hass) - yield from data.async_load() + await data.async_load() hass.services.async_register( DOMAIN, SERVICE_ADD_ITEM, add_item_service, schema=SERVICE_ITEM_SCHEMA @@ -206,8 +219,7 @@ class CreateShoppingListItemView(http.HomeAssistantView): name = "api:shopping_list:item" @RequestDataValidator(vol.Schema({vol.Required("name"): str})) - @asyncio.coroutine - def post(self, request, data): + async def post(self, request, data): """Create a new shopping list item.""" item = request.app["hass"].data[DOMAIN].async_add(data["name"]) request.app["hass"].bus.async_fire(EVENT) diff --git a/homeassistant/components/shopping_list/config_flow.py b/homeassistant/components/shopping_list/config_flow.py new file mode 100644 index 00000000000..974174640be --- /dev/null +++ b/homeassistant/components/shopping_list/config_flow.py @@ -0,0 +1,24 @@ +"""Config flow to configure ShoppingList component.""" +from homeassistant import config_entries + +from .const import DOMAIN + + +class ShoppingListFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for ShoppingList component.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + # Check if already configured + await self.async_set_unique_id(DOMAIN) + self._abort_if_unique_id_configured() + + if user_input is not None: + return self.async_create_entry(title="Shopping List", data=user_input) + + return self.async_show_form(step_id="user") + + async_step_import = async_step_user diff --git a/homeassistant/components/shopping_list/const.py b/homeassistant/components/shopping_list/const.py new file mode 100644 index 00000000000..4878d317780 --- /dev/null +++ b/homeassistant/components/shopping_list/const.py @@ -0,0 +1,2 @@ +"""All constants related to the shopping list component.""" +DOMAIN = "shopping_list" diff --git a/homeassistant/components/shopping_list/manifest.json b/homeassistant/components/shopping_list/manifest.json index 0c8b66b9a03..ad060f16756 100644 --- a/homeassistant/components/shopping_list/manifest.json +++ b/homeassistant/components/shopping_list/manifest.json @@ -5,5 +5,6 @@ "requirements": [], "dependencies": ["http"], "codeowners": [], + "config_flow": true, "quality_scale": "internal" } diff --git a/homeassistant/components/shopping_list/strings.json b/homeassistant/components/shopping_list/strings.json new file mode 100644 index 00000000000..9e56dd7eaa4 --- /dev/null +++ b/homeassistant/components/shopping_list/strings.json @@ -0,0 +1,14 @@ +{ + "config": { + "title": "Shopping List", + "step": { + "user": { + "title": "Shopping List", + "description": "Do you want to configure the shopping list?" + } + }, + "abort": { + "already_configured": "The shopping list is already configured." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cb5d7105131..3d752a955c5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -82,6 +82,7 @@ FLOWS = [ "samsungtv", "sense", "sentry", + "shopping_list", "simplisafe", "smartthings", "smhi", diff --git a/tests/components/shopping_list/conftest.py b/tests/components/shopping_list/conftest.py index 44c8000efa2..f63363e2f63 100644 --- a/tests/components/shopping_list/conftest.py +++ b/tests/components/shopping_list/conftest.py @@ -1,10 +1,10 @@ """Shopping list test helpers.""" -from unittest.mock import patch - +from asynctest import patch import pytest from homeassistant.components.shopping_list import intent as sl_intent -from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry @pytest.fixture(autouse=True) @@ -19,5 +19,10 @@ def mock_shopping_list_io(): @pytest.fixture async def sl_setup(hass): """Set up the shopping list.""" - assert await async_setup_component(hass, "shopping_list", {}) + + entry = MockConfigEntry(domain="shopping_list") + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await sl_intent.async_setup_intents(hass) diff --git a/tests/components/shopping_list/test_config_flow.py b/tests/components/shopping_list/test_config_flow.py new file mode 100644 index 00000000000..dfc23e18504 --- /dev/null +++ b/tests/components/shopping_list/test_config_flow.py @@ -0,0 +1,36 @@ +"""Test config flow.""" + +from homeassistant import data_entry_flow +from homeassistant.components.shopping_list.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER + + +async def test_import(hass): + """Test entry will be imported.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + +async def test_user(hass): + """Test we can start a config flow.""" + + 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"] == "user" + + +async def test_user_confirm(hass): + """Test we can finish a config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["result"].data == {} diff --git a/tests/components/shopping_list/test_init.py b/tests/components/shopping_list/test_init.py index 74c354848a3..bb65dcf631f 100644 --- a/tests/components/shopping_list/test_init.py +++ b/tests/components/shopping_list/test_init.py @@ -1,36 +1,33 @@ """Test shopping list component.""" -import asyncio from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.helpers import intent -@asyncio.coroutine -def test_add_item(hass, sl_setup): +async def test_add_item(hass, sl_setup): """Test adding an item intent.""" - response = yield from intent.async_handle( + response = await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} ) assert response.speech["plain"]["speech"] == "I've added beer to your shopping list" -@asyncio.coroutine -def test_recent_items_intent(hass, sl_setup): +async def test_recent_items_intent(hass, sl_setup): """Test recent items.""" - yield from intent.async_handle( + await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} ) - yield from intent.async_handle( + await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}} ) - yield from intent.async_handle( + await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "soda"}} ) - response = yield from intent.async_handle(hass, "test", "HassShoppingListLastItems") + response = await intent.async_handle(hass, "test", "HassShoppingListLastItems") assert ( response.speech["plain"]["speech"] @@ -38,22 +35,21 @@ def test_recent_items_intent(hass, sl_setup): ) -@asyncio.coroutine -def test_deprecated_api_get_all(hass, hass_client, sl_setup): +async def test_deprecated_api_get_all(hass, hass_client, sl_setup): """Test the API.""" - yield from intent.async_handle( + await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} ) - yield from intent.async_handle( + await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}} ) - client = yield from hass_client() - resp = yield from client.get("/api/shopping_list") + client = await hass_client() + resp = await client.get("/api/shopping_list") assert resp.status == 200 - data = yield from resp.json() + data = await resp.json() assert len(data) == 2 assert data[0]["name"] == "beer" assert not data[0]["complete"] @@ -88,35 +84,34 @@ async def test_ws_get_items(hass, hass_ws_client, sl_setup): assert not data[1]["complete"] -@asyncio.coroutine -def test_deprecated_api_update(hass, hass_client, sl_setup): +async def test_deprecated_api_update(hass, hass_client, sl_setup): """Test the API.""" - yield from intent.async_handle( + await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} ) - yield from intent.async_handle( + await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}} ) beer_id = hass.data["shopping_list"].items[0]["id"] wine_id = hass.data["shopping_list"].items[1]["id"] - client = yield from hass_client() - resp = yield from client.post( + client = await hass_client() + resp = await client.post( "/api/shopping_list/item/{}".format(beer_id), json={"name": "soda"} ) assert resp.status == 200 - data = yield from resp.json() + data = await resp.json() assert data == {"id": beer_id, "name": "soda", "complete": False} - resp = yield from client.post( + resp = await client.post( "/api/shopping_list/item/{}".format(wine_id), json={"complete": True} ) assert resp.status == 200 - data = yield from resp.json() + data = await resp.json() assert data == {"id": wine_id, "name": "wine", "complete": True} beer, wine = hass.data["shopping_list"].items @@ -166,23 +161,20 @@ async def test_ws_update_item(hass, hass_ws_client, sl_setup): assert wine == {"id": wine_id, "name": "wine", "complete": True} -@asyncio.coroutine -def test_api_update_fails(hass, hass_client, sl_setup): +async def test_api_update_fails(hass, hass_client, sl_setup): """Test the API.""" - yield from intent.async_handle( + await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} ) - client = yield from hass_client() - resp = yield from client.post( - "/api/shopping_list/non_existing", json={"name": "soda"} - ) + client = await hass_client() + resp = await client.post("/api/shopping_list/non_existing", json={"name": "soda"}) assert resp.status == 404 beer_id = hass.data["shopping_list"].items[0]["id"] - resp = yield from client.post( + resp = await client.post( "/api/shopping_list/item/{}".format(beer_id), json={"name": 123} ) @@ -212,29 +204,28 @@ async def test_ws_update_item_fail(hass, hass_ws_client, sl_setup): assert msg["success"] is False -@asyncio.coroutine -def test_deprecated_api_clear_completed(hass, hass_client, sl_setup): +async def test_deprecated_api_clear_completed(hass, hass_client, sl_setup): """Test the API.""" - yield from intent.async_handle( + await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}} ) - yield from intent.async_handle( + await intent.async_handle( hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}} ) beer_id = hass.data["shopping_list"].items[0]["id"] wine_id = hass.data["shopping_list"].items[1]["id"] - client = yield from hass_client() + client = await hass_client() # Mark beer as completed - resp = yield from client.post( + resp = await client.post( "/api/shopping_list/item/{}".format(beer_id), json={"complete": True} ) assert resp.status == 200 - resp = yield from client.post("/api/shopping_list/clear_completed") + resp = await client.post("/api/shopping_list/clear_completed") assert resp.status == 200 items = hass.data["shopping_list"].items @@ -272,15 +263,14 @@ async def test_ws_clear_items(hass, hass_ws_client, sl_setup): assert items[0] == {"id": wine_id, "name": "wine", "complete": False} -@asyncio.coroutine -def test_deprecated_api_create(hass, hass_client, sl_setup): +async def test_deprecated_api_create(hass, hass_client, sl_setup): """Test the API.""" - client = yield from hass_client() - resp = yield from client.post("/api/shopping_list/item", json={"name": "soda"}) + client = await hass_client() + resp = await client.post("/api/shopping_list/item", json={"name": "soda"}) assert resp.status == 200 - data = yield from resp.json() + data = await resp.json() assert data["name"] == "soda" assert data["complete"] is False @@ -290,12 +280,11 @@ def test_deprecated_api_create(hass, hass_client, sl_setup): assert items[0]["complete"] is False -@asyncio.coroutine -def test_deprecated_api_create_fail(hass, hass_client, sl_setup): +async def test_deprecated_api_create_fail(hass, hass_client, sl_setup): """Test the API.""" - client = yield from hass_client() - resp = yield from client.post("/api/shopping_list/item", json={"name": 1234}) + client = await hass_client() + resp = await client.post("/api/shopping_list/item", json={"name": 1234}) assert resp.status == 400 assert len(hass.data["shopping_list"].items) == 0 From 28a5fca7f4990135cf7cebd758f2ae78a6e29b66 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 6 Mar 2020 13:56:15 -0500 Subject: [PATCH 270/416] fix step name in strings.json for vizio (#32536) --- homeassistant/components/vizio/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/vizio/strings.json b/homeassistant/components/vizio/strings.json index 4142f809fba..61db7b49665 100644 --- a/homeassistant/components/vizio/strings.json +++ b/homeassistant/components/vizio/strings.json @@ -23,7 +23,7 @@ "title": "Pairing Complete", "description": "Your Vizio SmartCast TV is now connected to Home Assistant.\n\nYour Access Token is '**{access_token}**'." }, - "user_tv": { + "tv_apps": { "title": "Configure Apps for Smart TV", "description": "If you have a Smart TV, you can optionally filter your source list by choosing which apps to include or exclude in your source list. You can skip this step for TVs that don't support apps.", "data": { From dd91b51435463ca06847d6290cdd8db091bbce78 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Fri, 6 Mar 2020 17:32:26 -0500 Subject: [PATCH 271/416] Make ZHA Scene channel an eventable channel. (#32546) --- .../components/zha/core/channels/general.py | 2 +- tests/components/zha/zha_devices_list.py | 40 +++++++++++-------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index aa2ddd44bf3..3af8192dee1 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -401,7 +401,7 @@ class RSSILocation(ZigbeeChannel): pass -@registries.OUTPUT_CHANNEL_ONLY_CLUSTERS.register(general.Scenes.cluster_id) +@registries.EVENT_RELAY_CLUSTERS.register(general.Scenes.cluster_id) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.Scenes.cluster_id) class Scenes(ZigbeeChannel): """Scenes channel.""" diff --git a/tests/components/zha/zha_devices_list.py b/tests/components/zha/zha_devices_list.py index a3dc4f1d780..b92fc64dee2 100644 --- a/tests/components/zha/zha_devices_list.py +++ b/tests/components/zha/zha_devices_list.py @@ -627,7 +627,7 @@ DEVICES = [ "entity_id": "light.ikea_of_sweden_tradfri_bulb_e12_ws_opal_600lm_77665544_level_light_color_on_off", } }, - "event_channels": [], + "event_channels": ["1:0x0005"], "manufacturer": "IKEA of Sweden", "model": "TRADFRI bulb E12 WS opal 600lm", "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", @@ -653,7 +653,7 @@ DEVICES = [ "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_cws_opal_600lm_77665544_level_light_color_on_off", } }, - "event_channels": [], + "event_channels": ["1:0x0005"], "manufacturer": "IKEA of Sweden", "model": "TRADFRI bulb E26 CWS opal 600lm", "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", @@ -679,7 +679,7 @@ DEVICES = [ "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_w_opal_1000lm_77665544_level_on_off", } }, - "event_channels": [], + "event_channels": ["1:0x0005"], "manufacturer": "IKEA of Sweden", "model": "TRADFRI bulb E26 W opal 1000lm", "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", @@ -705,7 +705,7 @@ DEVICES = [ "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_ws_opal_980lm_77665544_level_light_color_on_off", } }, - "event_channels": [], + "event_channels": ["1:0x0005"], "manufacturer": "IKEA of Sweden", "model": "TRADFRI bulb E26 WS opal 980lm", "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", @@ -731,7 +731,7 @@ DEVICES = [ "entity_id": "light.ikea_of_sweden_tradfri_bulb_e26_opal_1000lm_77665544_level_on_off", } }, - "event_channels": [], + "event_channels": ["1:0x0005"], "manufacturer": "IKEA of Sweden", "model": "TRADFRI bulb E26 opal 1000lm", "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00\x00R\x00\x00", @@ -755,7 +755,7 @@ DEVICES = [ "entity_id": "switch.ikea_of_sweden_tradfri_control_outlet_77665544_on_off", } }, - "event_channels": [], + "event_channels": ["1:0x0005"], "manufacturer": "IKEA of Sweden", "model": "TRADFRI control outlet", "node_descriptor": b"\x01@\x8e|\x11RR\x00\x00,R\x00\x00", @@ -838,7 +838,7 @@ DEVICES = [ "entity_id": "sensor.ikea_of_sweden_tradfri_remote_control_77665544_power", } }, - "event_channels": ["1:0x0006", "1:0x0008"], + "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008"], "manufacturer": "IKEA of Sweden", "model": "TRADFRI remote control", "node_descriptor": b"\x02@\x80|\x11RR\x00\x00\x00R\x00\x00", @@ -1510,7 +1510,7 @@ DEVICES = [ "entity_id": "sensor.lumi_lumi_remote_b186acn01_77665544_multistate_input", }, }, - "event_channels": [], + "event_channels": ["1:0x0005", "2:0x0005", "3:0x0005"], "manufacturer": "LUMI", "model": "lumi.remote.b186acn01", "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", @@ -1569,7 +1569,7 @@ DEVICES = [ "entity_id": "sensor.lumi_lumi_remote_b286acn01_77665544_multistate_input", }, }, - "event_channels": [], + "event_channels": ["1:0x0005", "2:0x0005", "3:0x0005"], "manufacturer": "LUMI", "model": "lumi.remote.b286acn01", "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", @@ -1925,7 +1925,7 @@ DEVICES = [ "entity_id": "sensor.lumi_lumi_sensor_86sw1_77665544_multistate_input", }, }, - "event_channels": [], + "event_channels": ["1:0x0005", "2:0x0005", "3:0x0005"], "manufacturer": "LUMI", "model": "lumi.sensor_86sw1", "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", @@ -1978,7 +1978,7 @@ DEVICES = [ "entity_id": "sensor.lumi_lumi_sensor_cube_aqgl01_77665544_analog_input", }, }, - "event_channels": [], + "event_channels": ["1:0x0005", "2:0x0005", "3:0x0005"], "manufacturer": "LUMI", "model": "lumi.sensor_cube.aqgl01", "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", @@ -2031,7 +2031,7 @@ DEVICES = [ "entity_id": "sensor.lumi_lumi_sensor_ht_77665544_humidity", }, }, - "event_channels": [], + "event_channels": ["1:0x0005", "2:0x0005", "3:0x0005"], "manufacturer": "LUMI", "model": "lumi.sensor_ht", "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", @@ -2064,7 +2064,7 @@ DEVICES = [ "entity_id": "binary_sensor.lumi_lumi_sensor_magnet_77665544_on_off", }, }, - "event_channels": ["1:0x0006", "1:0x0008"], + "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008"], "manufacturer": "LUMI", "model": "lumi.sensor_magnet", "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", @@ -2212,7 +2212,7 @@ DEVICES = [ "entity_id": "sensor.lumi_lumi_sensor_switch_77665544_power", } }, - "event_channels": ["1:0x0006", "1:0x0008"], + "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008"], "manufacturer": "LUMI", "model": "lumi.sensor_switch", "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", @@ -2349,7 +2349,7 @@ DEVICES = [ "entity_id": "binary_sensor.lumi_lumi_vibration_aq1_77665544_ias_zone", }, }, - "event_channels": [], + "event_channels": ["1:0x0005", "2:0x0005"], "manufacturer": "LUMI", "model": "lumi.vibration.aq1", "node_descriptor": b"\x02@\x807\x10\x7fd\x00\x00\x00d\x00\x00", @@ -2704,21 +2704,27 @@ DEVICES = [ } }, "event_channels": [ + "1:0x0005", "1:0x0006", "1:0x0008", "1:0x0300", + "2:0x0005", "2:0x0006", "2:0x0008", "2:0x0300", + "3:0x0005", "3:0x0006", "3:0x0008", "3:0x0300", + "4:0x0005", "4:0x0006", "4:0x0008", "4:0x0300", + "5:0x0005", "5:0x0006", "5:0x0008", "5:0x0300", + "6:0x0005", "6:0x0006", "6:0x0008", "6:0x0300", @@ -2754,7 +2760,7 @@ DEVICES = [ "entity_id": "sensor.philips_rwl020_77665544_power", } }, - "event_channels": ["1:0x0006", "1:0x0008"], + "event_channels": ["1:0x0005", "1:0x0006", "1:0x0008"], "manufacturer": "Philips", "model": "RWL020", "node_descriptor": b"\x02@\x80\x0b\x10G-\x00\x00\x00-\x00\x00", @@ -2910,7 +2916,7 @@ DEVICES = [ "entity_id": "sensor.securifi_ltd_unk_model_77665544_electrical_measurement", }, }, - "event_channels": ["1:0x0006"], + "event_channels": ["1:0x0005", "1:0x0006"], "manufacturer": "Securifi Ltd.", "model": None, "node_descriptor": b"\x01@\x8e\x02\x10RR\x00\x00\x00R\x00\x00", From e52542c4d7a33ad051130e5ebddb4178fd570e54 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sat, 7 Mar 2020 07:33:59 -0500 Subject: [PATCH 272/416] Allow multiple attribute reads in ZHA (#32498) * multi attribute reads for lights * catch specific exceptions * get attributes * fix mains powered update * add guards and use get_attributes * use debug for read failures * cleanup * update return value for read_attributes * fix on with timed off --- homeassistant/components/zha/binary_sensor.py | 4 +- .../components/zha/core/channels/__init__.py | 10 ++++ .../components/zha/core/channels/base.py | 33 ++++++++++-- .../components/zha/core/channels/closures.py | 21 ++++---- .../components/zha/core/channels/general.py | 37 +++++++------- .../zha/core/channels/homeautomation.py | 14 +++-- .../components/zha/core/channels/hvac.py | 7 +-- .../components/zha/core/channels/lighting.py | 9 ++-- .../components/zha/core/channels/security.py | 4 +- homeassistant/components/zha/core/helpers.py | 12 ----- homeassistant/components/zha/light.py | 51 ++++++++++++------- homeassistant/components/zha/sensor.py | 6 ++- homeassistant/components/zha/switch.py | 4 +- tests/components/zha/common.py | 2 +- 14 files changed, 136 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index def1588a127..5bcb0878a1a 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -112,7 +112,9 @@ class BinarySensor(ZhaEntity, BinarySensorDevice): """Attempt to retrieve on off state from the binary sensor.""" await super().async_update() attribute = getattr(self._channel, "value_attribute", "on_off") - self._state = await self._channel.get_attribute_value(attribute) + attr_value = await self._channel.get_attribute_value(attribute) + if attr_value is not None: + self._state = attr_value @STRICT_MATCH(channel_names=CHANNEL_ACCELEROMETER) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 715bc3e3e75..a4848fbaa63 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -191,6 +191,11 @@ class ChannelPool: """Device NWK for logging.""" return self._channels.zha_device.nwk + @property + def is_mains_powered(self) -> bool: + """Device is_mains_powered.""" + return self._channels.zha_device.is_mains_powered + @property def manufacturer(self) -> Optional[str]: """Return device manufacturer.""" @@ -201,6 +206,11 @@ class ChannelPool: """Return device manufacturer.""" return self._channels.zha_device.manufacturer_code + @property + def hass(self): + """Return hass.""" + return self._channels.zha_device.hass + @property def model(self) -> Optional[str]: """Return device model.""" diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index d94c01fe4cd..dca0bbe09f3 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -26,7 +26,7 @@ from ..const import ( REPORT_CONFIG_RPT_CHANGE, SIGNAL_ATTR_UPDATED, ) -from ..helpers import LogMixin, get_attr_id_by_name, safe_read +from ..helpers import LogMixin, safe_read _LOGGER = logging.getLogger(__name__) @@ -98,7 +98,7 @@ class ZigbeeChannel(LogMixin): if not hasattr(self, "_value_attribute") and len(self._report_config) > 0: attr = self._report_config[0].get("attr") if isinstance(attr, str): - self.value_attribute = get_attr_id_by_name(self.cluster, attr) + self.value_attribute = self.cluster.attridx.get(attr) else: self.value_attribute = attr self._status = ChannelStatus.CREATED @@ -212,8 +212,11 @@ class ZigbeeChannel(LogMixin): async def async_initialize(self, from_cache): """Initialize channel.""" self.debug("initializing channel: from_cache: %s", from_cache) + attributes = [] for report_config in self._report_config: - await self.get_attribute_value(report_config["attr"], from_cache=from_cache) + attributes.append(report_config["attr"]) + if len(attributes) > 0: + await self.get_attributes(attributes, from_cache=from_cache) self._status = ChannelStatus.INITIALIZED @callback @@ -267,6 +270,30 @@ class ZigbeeChannel(LogMixin): ) return result.get(attribute) + async def get_attributes(self, attributes, from_cache=True): + """Get the values for a list of attributes.""" + manufacturer = None + manufacturer_code = self._ch_pool.manufacturer_code + if self.cluster.cluster_id >= 0xFC00 and manufacturer_code: + manufacturer = manufacturer_code + try: + result, _ = await self.cluster.read_attributes( + attributes, + allow_cache=from_cache, + only_cache=from_cache, + manufacturer=manufacturer, + ) + results = {attribute: result.get(attribute) for attribute in attributes} + except (asyncio.TimeoutError, zigpy.exceptions.DeliveryError) as ex: + self.debug( + "failed to get attributes '%s' on '%s' cluster: %s", + attributes, + self.cluster.ep_attribute, + str(ex), + ) + results = {} + return results + def log(self, level, msg, *args): """Log a message.""" msg = f"[%s:%s]: {msg}" diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/channels/closures.py index af6306c45e3..2b6c06ba12a 100644 --- a/homeassistant/components/zha/core/channels/closures.py +++ b/homeassistant/components/zha/core/channels/closures.py @@ -22,10 +22,10 @@ class DoorLockChannel(ZigbeeChannel): async def async_update(self): """Retrieve latest state.""" result = await self.get_attribute_value("lock_state", from_cache=True) - - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "lock_state", result - ) + if result is not None: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "lock_state", result + ) @callback def attribute_updated(self, attrid, value): @@ -67,12 +67,13 @@ class WindowCovering(ZigbeeChannel): "current_position_lift_percentage", from_cache=False ) self.debug("read current position: %s", result) - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - 8, - "current_position_lift_percentage", - result, - ) + if result is not None: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + 8, + "current_position_lift_percentage", + result, + ) @callback def attribute_updated(self, attrid, value): diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 3af8192dee1..d51c03b33c9 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -17,7 +17,6 @@ from ..const import ( SIGNAL_SET_LEVEL, SIGNAL_STATE_ATTR, ) -from ..helpers import get_attr_id_by_name from .base import ZigbeeChannel, parse_and_log_command _LOGGER = logging.getLogger(__name__) @@ -90,9 +89,11 @@ class BasicChannel(ZigbeeChannel): async def async_initialize(self, from_cache): """Initialize channel.""" - self._power_source = await self.get_attribute_value( + power_source = await self.get_attribute_value( "power_source", from_cache=from_cache ) + if power_source is not None: + self._power_source = power_source await super().async_initialize(from_cache) def get_power_source(self): @@ -269,7 +270,7 @@ class OnOffChannel(ZigbeeChannel): self.attribute_updated(self.ON_OFF, True) if on_time > 0: self._off_listener = async_call_later( - self.device.hass, + self._ch_pool.hass, (on_time / 10), # value is in 10ths of a second self.set_to_off, ) @@ -293,19 +294,20 @@ class OnOffChannel(ZigbeeChannel): async def async_initialize(self, from_cache): """Initialize channel.""" - self._state = bool( - await self.get_attribute_value(self.ON_OFF, from_cache=from_cache) - ) + state = await self.get_attribute_value(self.ON_OFF, from_cache=from_cache) + if state is not None: + self._state = bool(state) await super().async_initialize(from_cache) async def async_update(self): """Initialize channel.""" if self.cluster.is_client: return - self.debug("attempting to update onoff state - from cache: False") - self._state = bool( - await self.get_attribute_value(self.ON_OFF, from_cache=False) - ) + from_cache = not self._ch_pool.is_mains_powered + self.debug("attempting to update onoff state - from cache: %s", from_cache) + state = await self.get_attribute_value(self.ON_OFF, from_cache=from_cache) + if state is not None: + self._state = bool(state) await super().async_update() @@ -352,7 +354,7 @@ class PowerConfigurationChannel(ZigbeeChannel): """Handle attribute updates on this cluster.""" attr = self._report_config[1].get("attr") if isinstance(attr, str): - attr_id = get_attr_id_by_name(self.cluster, attr) + attr_id = self.cluster.attridx.get(attr) else: attr_id = attr if attrid == attr_id: @@ -379,12 +381,13 @@ class PowerConfigurationChannel(ZigbeeChannel): async def async_read_state(self, from_cache): """Read data from the cluster.""" - await self.get_attribute_value("battery_size", from_cache=from_cache) - await self.get_attribute_value( - "battery_percentage_remaining", from_cache=from_cache - ) - await self.get_attribute_value("battery_voltage", from_cache=from_cache) - await self.get_attribute_value("battery_quantity", from_cache=from_cache) + attributes = [ + "battery_size", + "battery_percentage_remaining", + "battery_voltage", + "battery_quantity", + ] + await self.get_attributes(attributes, from_cache=from_cache) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.PowerProfile.cluster_id) diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py index d4c1a1b7422..1df7cf117e2 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -73,9 +73,13 @@ class ElectricalMeasurementChannel(ZigbeeChannel): # This is a polling channel. Don't allow cache. result = await self.get_attribute_value("active_power", from_cache=False) - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0x050B, "active_power", result - ) + if result is not None: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + 0x050B, + "active_power", + result, + ) async def async_initialize(self, from_cache): """Initialize channel.""" @@ -92,6 +96,8 @@ class ElectricalMeasurementChannel(ZigbeeChannel): divisor = await self.get_attribute_value( "power_divisor", from_cache=from_cache ) + if divisor is None: + divisor = 1 self._divisor = divisor mult = await self.get_attribute_value( @@ -101,6 +107,8 @@ class ElectricalMeasurementChannel(ZigbeeChannel): mult = await self.get_attribute_value( "power_multiplier", from_cache=from_cache ) + if mult is None: + mult = 1 self._multiplier = mult @property diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py index 6d5ce4beb29..3c00e186ebb 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -40,9 +40,10 @@ class FanChannel(ZigbeeChannel): async def async_update(self): """Retrieve latest state.""" result = await self.get_attribute_value("fan_mode", from_cache=True) - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "fan_mode", result - ) + if result is not None: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "fan_mode", result + ) @callback def attribute_updated(self, attrid, value): diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index c87235d9ec0..7dc98d04515 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -34,7 +34,7 @@ class ColorChannel(ZigbeeChannel): ) def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType ) -> None: """Initialize ColorChannel.""" super().__init__(cluster, ch_pool) @@ -52,9 +52,8 @@ class ColorChannel(ZigbeeChannel): async def async_initialize(self, from_cache): """Initialize channel.""" await self.fetch_color_capabilities(True) - await self.get_attribute_value("color_temperature", from_cache=from_cache) - await self.get_attribute_value("current_x", from_cache=from_cache) - await self.get_attribute_value("current_y", from_cache=from_cache) + attributes = ["color_temperature", "current_x", "current_y"] + await self.get_attributes(attributes, from_cache=from_cache) async def fetch_color_capabilities(self, from_cache): """Get the color configuration.""" @@ -72,7 +71,7 @@ class ColorChannel(ZigbeeChannel): "color_temperature", from_cache=from_cache ) - if result is not self.UNSUPPORTED_ATTRIBUTE: + if result is not None and result is not self.UNSUPPORTED_ATTRIBUTE: capabilities |= self.CAPABILITIES_COLOR_TEMP self._color_capabilities = capabilities await super().async_initialize(from_cache) diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index cd826792790..2616161de03 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -176,6 +176,6 @@ class IASZoneChannel(ZigbeeChannel): async def async_initialize(self, from_cache): """Initialize channel.""" - await self.get_attribute_value("zone_status", from_cache=from_cache) - await self.get_attribute_value("zone_state", from_cache=from_cache) + attributes = ["zone_status", "zone_state"] + await self.get_attributes(attributes, from_cache=from_cache) await super().async_initialize(from_cache) diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index c0008b055db..ab4c7ae540c 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -35,18 +35,6 @@ async def safe_read( return {} -def get_attr_id_by_name(cluster, attr_name): - """Get the attribute id for a cluster attribute by its name.""" - return next( - ( - attrid - for attrid, (attrname, datatype) in cluster.attributes.items() - if attr_name == attrname - ), - None, - ) - - async def get_matched_clusters(source_zha_device, target_zha_device): """Get matched input/output cluster pairs for 2 devices.""" source_clusters = source_zha_device.async_get_std_clusters() diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 634bf50001e..18df380780d 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -329,44 +329,59 @@ class Light(ZhaEntity, light.Light): """Attempt to retrieve on off state from the light.""" self.debug("polling current state") if self._on_off_channel: - self._state = await self._on_off_channel.get_attribute_value( + state = await self._on_off_channel.get_attribute_value( "on_off", from_cache=from_cache ) + if state is not None: + self._state = state if self._level_channel: - self._brightness = await self._level_channel.get_attribute_value( + level = await self._level_channel.get_attribute_value( "current_level", from_cache=from_cache ) + if level is not None: + self._brightness = level if self._color_channel: + attributes = [] color_capabilities = self._color_channel.get_color_capabilities() if ( color_capabilities is not None and color_capabilities & CAPABILITIES_COLOR_TEMP ): - self._color_temp = await self._color_channel.get_attribute_value( - "color_temperature", from_cache=from_cache - ) + attributes.append("color_temperature") if ( color_capabilities is not None and color_capabilities & CAPABILITIES_COLOR_XY ): - color_x = await self._color_channel.get_attribute_value( - "current_x", from_cache=from_cache - ) - color_y = await self._color_channel.get_attribute_value( - "current_y", from_cache=from_cache - ) - if color_x is not None and color_y is not None: - self._hs_color = color_util.color_xy_to_hs( - float(color_x / 65535), float(color_y / 65535) - ) + attributes.append("current_x") + attributes.append("current_y") if ( color_capabilities is not None and color_capabilities & CAPABILITIES_COLOR_LOOP ): - color_loop_active = await self._color_channel.get_attribute_value( - "color_loop_active", from_cache=from_cache + attributes.append("color_loop_active") + + results = await self._color_channel.get_attributes( + attributes, from_cache=from_cache + ) + + if ( + "color_temperature" in results + and results["color_temperature"] is not None + ): + self._color_temp = results["color_temperature"] + + color_x = results.get("color_x", None) + color_y = results.get("color_y", None) + if color_x is not None and color_y is not None: + self._hs_color = color_util.color_xy_to_hs( + float(color_x / 65535), float(color_y / 65535) ) - if color_loop_active is not None and color_loop_active == 1: + if ( + "color_loop_active" in results + and results["color_loop_active"] is not None + ): + color_loop_active = results["color_loop_active"] + if color_loop_active == 1: self._effect = light.EFFECT_COLORLOOP async def refresh(self, time): diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 01298a40fca..9f18913c45a 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -176,10 +176,12 @@ class Battery(Sensor): async def async_state_attr_provider(self): """Return device state attrs for battery sensors.""" state_attrs = {} - battery_size = await self._channel.get_attribute_value("battery_size") + attributes = ["battery_size", "battery_quantity"] + results = await self._channel.get_attributes(attributes) + battery_size = results.get("battery_size", None) if battery_size is not None: state_attrs["battery_size"] = BATTERY_SIZES.get(battery_size, "Unknown") - battery_quantity = await self._channel.get_attribute_value("battery_quantity") + battery_quantity = results.get("battery_quantity", None) if battery_quantity is not None: state_attrs["battery_quantity"] = battery_quantity return state_attrs diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 15e10b50393..298bcb9db77 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -97,4 +97,6 @@ class Switch(ZhaEntity, SwitchDevice): """Attempt to retrieve on off state from the switch.""" await super().async_update() if self._on_off_channel: - self._state = await self._on_off_channel.get_attribute_value("on_off") + state = await self._on_off_channel.get_attribute_value("on_off") + if state is not None: + self._state = state diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 8e99a51f1f9..c21d05aa364 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -52,7 +52,7 @@ def patch_cluster(cluster): cluster.configure_reporting = CoroutineMock(return_value=[0]) cluster.deserialize = Mock() cluster.handle_cluster_request = Mock() - cluster.read_attributes = CoroutineMock() + cluster.read_attributes = CoroutineMock(return_value=[{}, {}]) cluster.read_attributes_raw = Mock() cluster.unbind = CoroutineMock(return_value=[0]) cluster.write_attributes = CoroutineMock(return_value=[0]) From ae2e6f9d2a30508b729ba595c7f08d911bbf3715 Mon Sep 17 00:00:00 2001 From: shred86 <32663154+shred86@users.noreply.github.com> Date: Sat, 7 Mar 2020 07:13:39 -0800 Subject: [PATCH 273/416] Fix unnecessary method call (#32549) --- homeassistant/components/abode/light.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py index 7602768a6e3..f15c10fc410 100644 --- a/homeassistant/components/abode/light.py +++ b/homeassistant/components/abode/light.py @@ -42,16 +42,19 @@ class AbodeLight(AbodeDevice, Light): self._device.set_color_temp( int(color_temperature_mired_to_kelvin(kwargs[ATTR_COLOR_TEMP])) ) + return if ATTR_HS_COLOR in kwargs and self._device.is_color_capable: self._device.set_color(kwargs[ATTR_HS_COLOR]) + return if ATTR_BRIGHTNESS in kwargs and self._device.is_dimmable: # Convert Home Assistant brightness (0-255) to Abode brightness (0-99) # If 100 is sent to Abode, response is 99 causing an error self._device.set_level(ceil(kwargs[ATTR_BRIGHTNESS] * 99 / 255.0)) - else: - self._device.switch_on() + return + + self._device.switch_on() def turn_off(self, **kwargs): """Turn off the light.""" From 1f510389b83d56d1d7d850b1af6e01b63c443a75 Mon Sep 17 00:00:00 2001 From: Ivan Belokobylskiy Date: Sat, 7 Mar 2020 20:26:35 +0300 Subject: [PATCH 274/416] Fix Yandex transport after API change (#32500) * Update Yandex transport after API change (home-assistant#32431) * Update mocked response for test (home-assistant#32431) * Codestyle fixes (home-assistant#32431) --- .../components/yandex_transport/sensor.py | 6 +- .../test_yandex_transport_sensor.py | 8 +- tests/fixtures/yandex_transport_reply.json | 3110 +++++++++-------- 3 files changed, 1720 insertions(+), 1404 deletions(-) diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py index 54db4882e74..be029715cce 100644 --- a/homeassistant/components/yandex_transport/sensor.py +++ b/homeassistant/components/yandex_transport/sensor.py @@ -65,7 +65,6 @@ class DiscoverMoscowYandexTransport(Entity): try: yandex_reply = self.requester.get_stop_info(self._stop_id) data = yandex_reply["data"] - stop_metadata = data["properties"]["StopMetaData"] except KeyError as key_error: _LOGGER.warning( "Exception KeyError was captured, missing key is %s. Yandex returned: %s", @@ -74,9 +73,8 @@ class DiscoverMoscowYandexTransport(Entity): ) self.requester.set_new_session() data = self.requester.get_stop_info(self._stop_id)["data"] - stop_metadata = data["properties"]["StopMetaData"] - stop_name = data["properties"]["name"] - transport_list = stop_metadata["Transport"] + stop_name = data["name"] + transport_list = data["transports"] for transport in transport_list: route = transport["name"] for thread in transport["threads"]: diff --git a/tests/components/yandex_transport/test_yandex_transport_sensor.py b/tests/components/yandex_transport/test_yandex_transport_sensor.py index a67108dc93b..f0664e4f045 100644 --- a/tests/components/yandex_transport/test_yandex_transport_sensor.py +++ b/tests/components/yandex_transport/test_yandex_transport_sensor.py @@ -40,14 +40,14 @@ TEST_CONFIG = { } FILTERED_ATTRS = { - "т36": ["16:10", "16:17", "16:26"], - "т47": ["16:09", "16:10"], - "м10": ["16:12", "16:20"], + "т36": ["18:25", "18:42", "18:46"], + "т47": ["18:35", "18:37", "18:40", "18:42"], + "м10": ["18:20", "18:27", "18:29", "18:41", "18:43"], "stop_name": "7-й автобусный парк", "attribution": "Data provided by maps.yandex.ru", } -RESULT_STATE = dt_util.utc_from_timestamp(1570972183).isoformat(timespec="seconds") +RESULT_STATE = dt_util.utc_from_timestamp(1583421540).isoformat(timespec="seconds") async def assert_setup_sensor(hass, config, count=1): diff --git a/tests/fixtures/yandex_transport_reply.json b/tests/fixtures/yandex_transport_reply.json index 3189d7a9d9b..af167553b6e 100644 --- a/tests/fixtures/yandex_transport_reply.json +++ b/tests/fixtures/yandex_transport_reply.json @@ -1,1377 +1,1620 @@ { "data": { - "geometries": [ - { - "type": "Point", - "coordinates": [ - 37.565280044, - 55.851959656 - ] - } + "id": "stop__9639579", + "name": "7-й автобусный парк", + "coordinates": [ + 37.565280044, + 55.851959656 ], - "geometry": { - "type": "Point", - "coordinates": [ - 37.565280044, - 55.851959656 - ] - }, - "properties": { - "name": "7-й автобусный парк", - "description": "7-й автобусный парк", - "currentTime": 1570971868567, - "tzOffset": 10800, - "StopMetaData": { - "id": "stop__9639579", - "name": "7-й автобусный парк", - "type": "urban", - "region": { - "id": 213, - "type": 6, - "parent_id": 1, - "capital_id": 0, + "currentTime": 1583421546364, + "tzOffset": 10800, + "type": "urban", + "region": { + "id": 213, + "type": 6, + "parent_id": 1, + "capital_id": 0, + "geo_parent_id": 0, + "city_id": 213, + "name": "Москва", + "native_name": "", + "iso_name": "RU MOW", + "is_main": true, + "en_name": "Moscow", + "short_en_name": "MSK", + "phone_code": "495 499", + "phone_code_old": "095", + "zip_code": "", + "population": 12506468, + "synonyms": "Moskau, Moskva", + "latitude": 55.753215, + "longitude": 37.622504, + "latitude_size": 0.878654, + "longitude_size": 1.164423, + "zoom": 10, + "tzname": "Europe/Moscow", + "official_languages": "ru", + "widespread_languages": "ru", + "suggest_list": [], + "is_eu": false, + "services_names": [ + "bs", + "yaca", + "weather", + "afisha", + "maps", + "tv", + "ad", + "etrain", + "subway", + "delivery", + "route" + ], + "seoname": "moscow", + "bounds": [ + [ + 37.0402925, + 55.31141404514547 + ], + [ + 38.2047155, + 56.190068045145466 + ] + ], + "names": { + "ablative": "", + "accusative": "Москву", + "dative": "Москве", + "directional": "", + "genitive": "Москвы", + "instrumental": "Москвой", + "locative": "", + "nominative": "Москва", + "preposition": "в", + "prepositional": "Москве" + }, + "parent": { + "id": 1, + "type": 5, + "parent_id": 3, + "capital_id": 213, + "geo_parent_id": 0, + "city_id": 213, + "name": "Москва и Московская область", + "native_name": "", + "iso_name": "RU-MOS", + "is_main": true, + "en_name": "Moscow and Moscow Oblast", + "short_en_name": "RU-MOS", + "phone_code": "495 496 498 499", + "phone_code_old": "", + "zip_code": "", + "population": 7503385, + "synonyms": "Московская область, Подмосковье, Podmoskovye", + "latitude": 55.815792, + "longitude": 37.380031, + "latitude_size": 2.705659, + "longitude_size": 5.060749, + "zoom": 8, + "tzname": "Europe/Moscow", + "official_languages": "ru", + "widespread_languages": "ru", + "suggest_list": [ + 213, + 10716, + 10747, + 10758, + 20728, + 10740, + 10738, + 20523, + 10735, + 10734, + 10743, + 21622 + ], + "is_eu": false, + "services_names": [ + "bs", + "yaca", + "ad" + ], + "seoname": "moscow-and-moscow-oblast", + "bounds": [ + [ + 34.8496565, + 54.439456064325434 + ], + [ + 39.9104055, + 57.14511506432543 + ] + ], + "names": { + "ablative": "", + "accusative": "Москву и Московскую область", + "dative": "Москве и Московской области", + "directional": "", + "genitive": "Москвы и Московской области", + "instrumental": "Москвой и Московской областью", + "locative": "", + "nominative": "Москва и Московская область", + "preposition": "в", + "prepositional": "Москве и Московской области" + }, + "parent": { + "id": 225, + "type": 3, + "parent_id": 10001, + "capital_id": 213, "geo_parent_id": 0, "city_id": 213, - "name": "moscow", + "name": "Россия", "native_name": "", - "iso_name": "RU MOW", - "is_main": true, - "en_name": "Moscow", - "short_en_name": "MSK", - "phone_code": "495 499", - "phone_code_old": "095", + "iso_name": "RU", + "is_main": false, + "en_name": "Russia", + "short_en_name": "RU", + "phone_code": "7", + "phone_code_old": "", "zip_code": "", - "population": 12506468, - "synonyms": "Moskau, Moskva", - "latitude": 55.753215, - "longitude": 37.622504, - "latitude_size": 0.878654, - "longitude_size": 1.164423, - "zoom": 10, - "tzname": "Europe/Moscow", + "population": 146880432, + "synonyms": "Russian Federation,Российская Федерация", + "latitude": 61.698653, + "longitude": 99.505405, + "latitude_size": 40.700127, + "longitude_size": 171.643239, + "zoom": 3, + "tzname": "", "official_languages": "ru", "widespread_languages": "ru", - "suggest_list": [], + "suggest_list": [ + 213, + 2, + 65, + 54, + 47, + 43, + 66, + 51, + 56, + 172, + 39, + 62 + ], "is_eu": false, "services_names": [ "bs", "yaca", - "weather", - "afisha", - "maps", - "tv", - "ad", - "etrain", - "subway", - "delivery", - "route" + "ad" ], - "ename": "moscow", + "seoname": "russia", "bounds": [ [ - 37.0402925, - 55.31141404514547 + 13.683785499999999, + 35.290400699917846 ], [ - 38.2047155, - 56.190068045145466 + -174.6729755, + 75.99052769991785 ] ], "names": { "ablative": "", - "accusative": "Москву", - "dative": "Москве", + "accusative": "Россию", + "dative": "России", "directional": "", - "genitive": "Москвы", - "instrumental": "Москвой", + "genitive": "России", + "instrumental": "Россией", "locative": "", - "nominative": "Москва", + "nominative": "Россия", "preposition": "в", - "prepositional": "Москве" - }, - "parent": { - "id": 1, - "type": 5, - "parent_id": 3, - "capital_id": 213, - "geo_parent_id": 0, - "city_id": 213, - "name": "moscow-and-moscow-oblast", - "native_name": "", - "iso_name": "RU-MOS", - "is_main": true, - "en_name": "Moscow and Moscow Oblast", - "short_en_name": "RU-MOS", - "phone_code": "495 496 498 499", - "phone_code_old": "", - "zip_code": "", - "population": 7503385, - "synonyms": "Московская область, Подмосковье, Podmoskovye", - "latitude": 55.815792, - "longitude": 37.380031, - "latitude_size": 2.705659, - "longitude_size": 5.060749, - "zoom": 8, - "tzname": "Europe/Moscow", - "official_languages": "ru", - "widespread_languages": "ru", - "suggest_list": [ - 213, - 10716, - 10747, - 10758, - 20728, - 10740, - 10738, - 20523, - 10735, - 10734, - 10743, - 21622 + "prepositional": "России" + } + } + } + }, + "transports": [ + { + "lineId": "2036924633", + "name": "215", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213B_215_bus_mosgortrans", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9711780", + "name": "Метро Петровско-Разумовская" + }, + { + "id": "stop__9711744", + "name": "Станция Ховрино" + } ], - "is_eu": false, - "services_names": [ - "bs", - "yaca", - "ad" - ], - "ename": "moscow-and-moscow-oblast", - "bounds": [ - [ - 34.8496565, - 54.439456064325434 - ], - [ - 39.9104055, - 57.14511506432543 - ] - ], - "names": { - "ablative": "", - "accusative": "Москву и Московскую область", - "dative": "Москве и Московской области", - "directional": "", - "genitive": "Москвы и Московской области", - "instrumental": "Москвой и Московской областью", - "locative": "", - "nominative": "Москва и Московская область", - "preposition": "в", - "prepositional": "Москве и Московской области" - }, - "parent": { - "id": 225, - "type": 3, - "parent_id": 10001, - "capital_id": 213, - "geo_parent_id": 0, - "city_id": 213, - "name": "russia", - "native_name": "", - "iso_name": "RU", - "is_main": false, - "en_name": "Russia", - "short_en_name": "RU", - "phone_code": "7", - "phone_code_old": "", - "zip_code": "", - "population": 146880432, - "synonyms": "Russian Federation,Российская Федерация", - "latitude": 61.698653, - "longitude": 99.505405, - "latitude_size": 40.700127, - "longitude_size": 171.643239, - "zoom": 3, - "tzname": "", - "official_languages": "ru", - "widespread_languages": "ru", - "suggest_list": [ - 213, - 2, - 65, - 54, - 47, - 43, - 66, - 51, - 56, - 172, - 39, - 62 - ], - "is_eu": false, - "services_names": [ - "bs", - "yaca", - "ad" - ], - "ename": "russia", - "bounds": [ - [ - 13.683785499999999, - 35.290400699917846 - ], - [ - -174.6729755, - 75.99052769991785 - ] - ], - "names": { - "ablative": "", - "accusative": "Россию", - "dative": "России", - "directional": "", - "genitive": "России", - "instrumental": "Россией", - "locative": "", - "nominative": "Россия", - "preposition": "в", - "prepositional": "России" + "BriefSchedule": { + "Events": [], + "Frequency": { + "text": "27 мин", + "value": 1620, + "begin": { + "value": "1583375676", + "tzOffset": 10800, + "text": "5:34" + }, + "end": { + "value": "1583445876", + "tzOffset": 10800, + "text": "1:04" + } } } } - }, - "Transport": [ + ], + "uri": "ymapsbm1://transit/line?id=2036924633&ll=37.543816%2C55.854638&name=215&r=2766&type=bus", + "seoname": "215" + }, + { + "lineId": "2036924720", + "name": "692", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ { - "lineId": "2036924720", - "name": "692", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ + "threadId": "2036928706", + "noBoarding": false, + "EssentialStops": [ { - "threadId": "2036928706", - "EssentialStops": [ - { - "id": "3163417967", - "name": "Платформа Дегунино" - }, - { - "id": "3163417967", - "name": "Платформа Дегунино" - } - ], - "BriefSchedule": { - "Events": [ - { - "Estimated": { - "value": "1570973441", - "tzOffset": 10800, - "text": "16:30" - }, - "vehicleId": "codd%5Fnew|144020%5F31402" - } - ], - "Frequency": { - "text": "1 ч", - "value": 3600, - "begin": { - "value": "1570938428", - "tzOffset": 10800, - "text": "6:47" - }, - "end": { - "value": "1570990628", - "tzOffset": 10800, - "text": "21:17" - } - } - } + "id": "3163417967", + "name": "Станция Дегунино" + }, + { + "id": "3163417967", + "name": "Станция Дегунино" } ], - "uri": "ymapsbm1://transit/line?id=2036924720&ll=37.577436%2C55.828981&name=692&r=4037&type=bus", - "seoname": "692" - }, - { - "lineId": "2036924968", - "name": "82", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "2036925244", - "EssentialStops": [ - { - "id": "2310890052", - "name": "Метро Верхние Лихоборы" + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1583421600", + "tzOffset": 10800, + "text": "18:20" }, - { - "id": "2310890052", - "name": "Метро Верхние Лихоборы" - } - ], - "BriefSchedule": { - "Events": [], - "Frequency": { - "text": "34 мин", - "value": 2040, - "begin": { - "value": "1570944072", - "tzOffset": 10800, - "text": "8:21" - }, - "end": { - "value": "1570997592", - "tzOffset": 10800, - "text": "23:13" - } + "Estimated": { + "value": "1583422070", + "tzOffset": 10800, + "text": "18:27" + }, + "vehicleId": "codd%5Fnew|144020%5F31402" + }, + { + "Scheduled": { + "value": "1583424060", + "tzOffset": 10800, + "text": "19:01" + }, + "Estimated": { + "value": "1583423194", + "tzOffset": 10800, + "text": "18:46" + }, + "vehicleId": "codd%5Fnew|1115930%5F31497" + }, + { + "Scheduled": { + "value": "1583425380", + "tzOffset": 10800, + "text": "19:23" } } - } - ], - "uri": "ymapsbm1://transit/line?id=2036924968&ll=37.571504%2C55.816622&name=82&r=4164&type=bus", - "seoname": "82" - }, - { - "lineId": "2036925416", - "name": "194", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "2036927196", - "EssentialStops": [ - { - "id": "stop__9711780", - "name": "Метро Петровско-Разумовская" - }, - { - "id": "stop__9648742", - "name": "Коровино" - } - ], - "BriefSchedule": { - "Events": [], - "Frequency": { - "text": "12 мин", - "value": 720, - "begin": { - "value": "1570933976", - "tzOffset": 10800, - "text": "5:32" - }, - "end": { - "value": "1571004356", - "tzOffset": 10800, - "text": "1:05" - } - } - } - } - ], - "uri": "ymapsbm1://transit/line?id=2036925416&ll=37.544800%2C55.865286&name=194&r=3667&type=bus", - "seoname": "194" - }, - { - "lineId": "2036925728", - "name": "282", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "213A_282_bus_mosgortrans", - "EssentialStops": [ - { - "id": "stop__9641102", - "name": "Улица Корнейчука" - }, - { - "id": "2532226085", - "name": "Метро Войковская" - } - ], - "BriefSchedule": { - "Events": [ - { - "Estimated": { - "value": "1570971861", - "tzOffset": 10800, - "text": "16:04" - }, - "vehicleId": "codd%5Fnew|34854%5F9345401" - }, - { - "Estimated": { - "value": "1570973231", - "tzOffset": 10800, - "text": "16:27" - }, - "vehicleId": "codd%5Fnew|37913%5F9225419" - } - ], - "Frequency": { - "text": "22 мин", - "value": 1320, - "begin": { - "value": "1570934963", - "tzOffset": 10800, - "text": "5:49" - }, - "end": { - "value": "1571005163", - "tzOffset": 10800, - "text": "1:19" - } - } - } - } - ], - "uri": "ymapsbm1://transit/line?id=2036925728&ll=37.553526%2C55.860385&name=282&r=5779&type=bus", - "seoname": "282" - }, - { - "lineId": "2036926781", - "name": "154", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "213B_154_bus_mosgortrans", - "EssentialStops": [ - { - "id": "stop__9642548", - "name": "ВДНХ (южная)" - }, - { - "id": "stop__9711744", - "name": "Станция Ховрино" - } - ], - "BriefSchedule": { - "Events": [ - { - "Estimated": { - "value": "1570972424", - "tzOffset": 10800, - "text": "16:13" - }, - "vehicleId": "codd%5Fnew|1161539%5F191543" - }, - { - "Estimated": { - "value": "1570973620", - "tzOffset": 10800, - "text": "16:33" - }, - "vehicleId": "codd%5Fnew|58773%5F190599" - } - ], - "Frequency": { - "text": "20 мин", - "value": 1200, - "begin": { - "value": "1570938166", - "tzOffset": 10800, - "text": "6:42" - }, - "end": { - "value": "1571006446", - "tzOffset": 10800, - "text": "1:40" - } - } - } - } - ], - "uri": "ymapsbm1://transit/line?id=2036926781&ll=37.576158%2C55.846301&name=154&r=4917&type=bus", - "seoname": "154" - }, - { - "lineId": "2036926818", - "name": "994", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "213A_294m_minibus_default", - "EssentialStops": [ - { - "id": "stop__9640756", - "name": "Метро Петровско-Разумовская" - }, - { - "id": "stop__9649459", - "name": "Метро Алтуфьево" - } - ], - "BriefSchedule": { - "Events": [], - "Frequency": { - "text": "30 мин", - "value": 1800, - "begin": { - "value": "1570934327", - "tzOffset": 10800, - "text": "5:38" - }, - "end": { - "value": "1571004527", - "tzOffset": 10800, - "text": "1:08" - } - } - } - } - ], - "uri": "ymapsbm1://transit/line?id=2036926818&ll=37.560060%2C55.868431&name=994&r=3637&type=bus", - "seoname": "994" - }, - { - "lineId": "2036926890", - "name": "466", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "466B_bus_default", - "EssentialStops": [ - { - "id": "stop__9640546", - "name": "Станция Бескудниково" - }, - { - "id": "stop__9640545", - "name": "Станция Бескудниково" - } - ], - "BriefSchedule": { - "Events": [], - "Frequency": { - "text": "22 мин", - "value": 1320, - "begin": { - "value": "1570937447", - "tzOffset": 10800, - "text": "6:30" - }, - "end": { - "value": "1571008247", - "tzOffset": 10800, - "text": "2:10" - } - } - } - } - ], - "uri": "ymapsbm1://transit/line?id=2036926890&ll=37.564238%2C55.845050&name=466&r=4163&type=bus", - "seoname": "466" - }, - { - "lineId": "213_114_bus_mosgortrans", - "name": "114", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "213B_114_bus_mosgortrans", - "EssentialStops": [ - { - "id": "stop__9647199", - "name": "Метро Войковская" - }, - { - "id": "stop__9639588", - "name": "Коровинское шоссе" - } - ], - "BriefSchedule": { - "Events": [ - { - "Estimated": { - "value": "1570972913", - "tzOffset": 10800, - "text": "16:21" - }, - "vehicleId": "codd%5Fnew|1092230%5F191422" - } - ], - "Frequency": { - "text": "15 мин", - "value": 900, - "begin": { - "value": "1570936205", - "tzOffset": 10800, - "text": "6:10" - }, - "end": { - "value": "1571004965", - "tzOffset": 10800, - "text": "1:16" - } - } - } - } - ], - "uri": "ymapsbm1://transit/line?id=213_114_bus_mosgortrans&ll=37.508487%2C55.852137&name=114&r=3544&type=bus", - "seoname": "114" - }, - { - "lineId": "213_179_bus_mosgortrans", - "name": "179", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "213B_179_bus_mosgortrans", - "EssentialStops": [ - { - "id": "stop__9647199", - "name": "Метро Войковская" - }, - { - "id": "stop__9639480", - "name": "Платформа Лианозово" - } - ], - "BriefSchedule": { - "Events": [ - { - "Estimated": { - "value": "1570971963", - "tzOffset": 10800, - "text": "16:06" - }, - "vehicleId": "codd%5Fnew|194519%5F31367" - }, - { - "Estimated": { - "value": "1570973105", - "tzOffset": 10800, - "text": "16:25" - }, - "vehicleId": "codd%5Fnew|56358%5F31365" - } - ], - "Frequency": { - "text": "15 мин", - "value": 900, - "begin": { - "value": "1570936823", - "tzOffset": 10800, - "text": "6:20" - }, - "end": { - "value": "1571005583", - "tzOffset": 10800, - "text": "1:26" - } - } - } - } - ], - "uri": "ymapsbm1://transit/line?id=213_179_bus_mosgortrans&ll=37.526151%2C55.858031&name=179&r=4634&type=bus", - "seoname": "179" - }, - { - "lineId": "213_191m_minibus_default", - "name": "591", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "213A_191m_minibus_default", - "EssentialStops": [ - { - "id": "stop__9647199", - "name": "Метро Войковская" - }, - { - "id": "stop__9711744", - "name": "Станция Ховрино" - } - ], - "BriefSchedule": { - "Events": [ - { - "Estimated": { - "value": "1570972150", - "tzOffset": 10800, - "text": "16:09" - }, - "vehicleId": "codd%5Fnew|35595%5F9345307" - } - ], - "Frequency": { - "text": "22 мин", - "value": 1320, - "begin": { - "value": "1570934833", - "tzOffset": 10800, - "text": "5:47" - }, - "end": { - "value": "1571005033", - "tzOffset": 10800, - "text": "1:17" - } - } - } - } - ], - "uri": "ymapsbm1://transit/line?id=213_191m_minibus_default&ll=37.510906%2C55.848214&name=591&r=3384&type=bus", - "seoname": "591" - }, - { - "lineId": "213_206m_minibus_default", - "name": "206к", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "213A_206m_minibus_default", - "EssentialStops": [ - { - "id": "stop__9640756", - "name": "Метро Петровско-Разумовская" - }, - { - "id": "stop__9640553", - "name": "Лобненская улица" - } - ], - "BriefSchedule": { - "Events": [], - "Frequency": { - "text": "22 мин", - "value": 1320, - "begin": { - "value": "1570934039", - "tzOffset": 10800, - "text": "5:33" - }, - "end": { - "value": "1571004239", - "tzOffset": 10800, - "text": "1:03" - } - } - } - } - ], - "uri": "ymapsbm1://transit/line?id=213_206m_minibus_default&ll=37.548997%2C55.864997&name=206%D0%BA&r=3515&type=bus", - "seoname": "206k" - }, - { - "lineId": "213_215_bus_mosgortrans", - "name": "215", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "213B_215_bus_mosgortrans", - "EssentialStops": [ - { - "id": "stop__9711780", - "name": "Метро Петровско-Разумовская" - }, - { - "id": "stop__9711744", - "name": "Станция Ховрино" - } - ], - "BriefSchedule": { - "Events": [], - "Frequency": { - "text": "27 мин", - "value": 1620, - "begin": { - "value": "1570934076", - "tzOffset": 10800, - "text": "5:34" - }, - "end": { - "value": "1571004276", - "tzOffset": 10800, - "text": "1:04" - } - } - } - } - ], - "uri": "ymapsbm1://transit/line?id=213_215_bus_mosgortrans&ll=37.543701%2C55.854527&name=215&r=2763&type=bus", - "seoname": "215" - }, - { - "lineId": "213_36_trolleybus_mosgortrans", - "name": "т36", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "213A_36_trolleybus_mosgortrans", - "EssentialStops": [ - { - "id": "stop__9642550", - "name": "ВДНХ (южная)" - }, - { - "id": "stop__9640641", - "name": "Дмитровское шоссе, 155" - } - ], - "BriefSchedule": { - "Events": [ - { - "Estimated": { - "value": "1570972236", - "tzOffset": 10800, - "text": "16:10" - }, - "vehicleId": "codd%5Fnew|1084830%5F430261" - }, - { - "Estimated": { - "value": "1570972641", - "tzOffset": 10800, - "text": "16:17" - }, - "vehicleId": "codd%5Fnew|1084829%5F430260" - }, - { - "Estimated": { - "value": "1570973178", - "tzOffset": 10800, - "text": "16:26" - }, - "vehicleId": "codd%5Fnew|1084827%5F430255" - } - ], - "Frequency": { - "text": "12 мин", - "value": 720, - "begin": { - "value": "1570932741", - "tzOffset": 10800, - "text": "5:12" - }, - "end": { - "value": "1571003121", - "tzOffset": 10800, - "text": "0:45" - } - } - } - } - ], - "uri": "ymapsbm1://transit/line?id=213_36_trolleybus_mosgortrans&ll=37.588604%2C55.859705&name=%D1%8236&r=5104&type=bus", - "seoname": "t36" - }, - { - "lineId": "213_47_trolleybus_mosgortrans", - "name": "т47", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "213B_47_trolleybus_mosgortrans", - "EssentialStops": [ - { - "id": "stop__9639568", - "name": "Бескудниковский переулок" - }, - { - "id": "stop__9641903", - "name": "Бескудниковский переулок" - } - ], - "BriefSchedule": { - "Events": [ - { - "Scheduled": { - "value": "1570972080", - "tzOffset": 10800, - "text": "16:08" - }, - "Estimated": { - "value": "1570972183", - "tzOffset": 10800, - "text": "16:09" - }, - "vehicleId": "codd%5Fnew|1132404%5F430361" - }, - { - "Scheduled": { - "value": "1570972980", - "tzOffset": 10800, - "text": "16:23" - }, - "Estimated": { - "value": "1570972219", - "tzOffset": 10800, - "text": "16:10" - }, - "vehicleId": "codd%5Fnew|1136132%5F430358" - }, - { - "Scheduled": { - "value": "1570973940", - "tzOffset": 10800, - "text": "16:39" - } - } - ], - "departureTime": "16:08" - } - } - ], - "uri": "ymapsbm1://transit/line?id=213_47_trolleybus_mosgortrans&ll=37.588308%2C55.818685&name=%D1%8247&r=5359&type=bus", - "seoname": "t47" - }, - { - "lineId": "213_56_trolleybus_mosgortrans", - "name": "т56", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "213A_56_trolleybus_mosgortrans", - "EssentialStops": [ - { - "id": "stop__9639561", - "name": "Коровинское шоссе" - }, - { - "id": "stop__9639588", - "name": "Коровинское шоссе" - } - ], - "BriefSchedule": { - "Events": [ - { - "Scheduled": { - "value": "1570971900", - "tzOffset": 10800, - "text": "16:05" - }, - "Estimated": { - "value": "1570972560", - "tzOffset": 10800, - "text": "16:16" - }, - "vehicleId": "codd%5Fnew|1117148%5F430351" - }, - { - "Scheduled": { - "value": "1570972680", - "tzOffset": 10800, - "text": "16:18" - }, - "Estimated": { - "value": "1570973442", - "tzOffset": 10800, - "text": "16:30" - }, - "vehicleId": "codd%5Fnew|1080552%5F430302" - }, - { - "Scheduled": { - "value": "1570973400", - "tzOffset": 10800, - "text": "16:30" - } - } - ], - "departureTime": "16:05" - } - } - ], - "uri": "ymapsbm1://transit/line?id=213_56_trolleybus_mosgortrans&ll=37.551454%2C55.830147&name=%D1%8256&r=6304&type=bus", - "seoname": "t56" - }, - { - "lineId": "213_63_bus_mosgortrans", - "name": "63", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "213A_63_bus_mosgortrans", - "EssentialStops": [ - { - "id": "stop__9640554", - "name": "Лобненская улица" - }, - { - "id": "stop__9640553", - "name": "Лобненская улица" - } - ], - "BriefSchedule": { - "Events": [ - { - "Estimated": { - "value": "1570972434", - "tzOffset": 10800, - "text": "16:13" - }, - "vehicleId": "codd%5Fnew|38700%5F9215301" - } - ], - "Frequency": { - "text": "17 мин", - "value": 1020, - "begin": { - "value": "1570934207", - "tzOffset": 10800, - "text": "5:36" - }, - "end": { - "value": "1571003507", - "tzOffset": 10800, - "text": "0:51" - } - } - } - } - ], - "uri": "ymapsbm1://transit/line?id=213_63_bus_mosgortrans&ll=37.550792%2C55.872690&name=63&r=3057&type=bus", - "seoname": "63" - }, - { - "lineId": "213_677_bus_mosgortrans", - "name": "677", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "213B_677_bus_mosgortrans", - "EssentialStops": [ - { - "id": "stop__9639495", - "name": "Метро Петровско-Разумовская" - }, - { - "id": "stop__9639480", - "name": "Платформа Лианозово" - } - ], - "BriefSchedule": { - "Events": [ - { - "Scheduled": { - "value": "1570972200", - "tzOffset": 10800, - "text": "16:10" - }, - "Estimated": { - "value": "1570971838", - "tzOffset": 10800, - "text": "16:03" - }, - "vehicleId": "codd%5Fnew|58581%5F31321" - }, - { - "Scheduled": { - "value": "1570972560", - "tzOffset": 10800, - "text": "16:16" - } - }, - { - "Scheduled": { - "value": "1570972920", - "tzOffset": 10800, - "text": "16:22" - } - } - ], - "departureTime": "16:10" - } - } - ], - "uri": "ymapsbm1://transit/line?id=213_677_bus_mosgortrans&ll=37.564191%2C55.866620&name=677&r=3386&type=bus", - "seoname": "677" - }, - { - "lineId": "213_78_trolleybus_mosgortrans", - "name": "т78", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "213A_78_trolleybus_mosgortrans", - "EssentialStops": [ - { - "id": "stop__9887464", - "name": "9-я Северная линия" - }, - { - "id": "stop__9887464", - "name": "9-я Северная линия" - } - ], - "BriefSchedule": { - "Events": [ - { - "Estimated": { - "value": "1570971984", - "tzOffset": 10800, - "text": "16:06" - }, - "vehicleId": "codd%5Fnew|59694%5F31155" - }, - { - "Estimated": { - "value": "1570972003", - "tzOffset": 10800, - "text": "16:06" - }, - "vehicleId": "codd%5Fnew|55041%5F31116" - }, - { - "Estimated": { - "value": "1570972550", - "tzOffset": 10800, - "text": "16:15" - }, - "vehicleId": "codd%5Fnew|62710%5F31142" - }, - { - "Estimated": { - "value": "1570973307", - "tzOffset": 10800, - "text": "16:28" - }, - "vehicleId": "codd%5Fnew|1037437%5F31144" - }, - { - "Estimated": { - "value": "1570973456", - "tzOffset": 10800, - "text": "16:30" - }, - "vehicleId": "codd%5Fnew|318517%5F31136" - } - ], - "Frequency": { - "text": "11 мин", - "value": 660, - "begin": { - "value": "1570937045", - "tzOffset": 10800, - "text": "6:24" - }, - "end": { - "value": "1571002385", - "tzOffset": 10800, - "text": "0:33" - } - } - } - } - ], - "uri": "ymapsbm1://transit/line?id=213_78_trolleybus_mosgortrans&ll=37.569453%2C55.855402&name=%D1%8278&r=8810&type=bus", - "seoname": "t78" - }, - { - "lineId": "2465131598", - "name": "179к", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "2465131758", - "EssentialStops": [ - { - "id": "stop__9640244", - "name": "Платформа Лианозово" - }, - { - "id": "stop__9639480", - "name": "Платформа Лианозово" - } - ], - "BriefSchedule": { - "Events": [], - "Frequency": { - "text": "15 мин", - "value": 900, - "begin": { - "value": "1570935230", - "tzOffset": 10800, - "text": "5:53" - }, - "end": { - "value": "1571003030", - "tzOffset": 10800, - "text": "0:43" - } - } - } - } - ], - "uri": "ymapsbm1://transit/line?id=2465131598&ll=37.561423%2C55.871807&name=179%D0%BA&r=2787&type=bus", - "seoname": "179k" - }, - { - "lineId": "677k_bus_default", - "name": "677к", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "677kA_bus_default", - "EssentialStops": [ - { - "id": "stop__9640244", - "name": "Платформа Лианозово" - }, - { - "id": "stop__9639480", - "name": "Платформа Лианозово" - } - ], - "BriefSchedule": { - "Events": [ - { - "Scheduled": { - "value": "1570972560", - "tzOffset": 10800, - "text": "16:16" - }, - "Estimated": { - "value": "1570971986", - "tzOffset": 10800, - "text": "16:06" - }, - "vehicleId": "codd%5Fnew|1038096%5F31398" - }, - { - "Scheduled": { - "value": "1570973280", - "tzOffset": 10800, - "text": "16:28" - }, - "Estimated": { - "value": "1570972342", - "tzOffset": 10800, - "text": "16:12" - }, - "vehicleId": "codd%5Fnew|58590%5F31348" - }, - { - "Scheduled": { - "value": "1570974000", - "tzOffset": 10800, - "text": "16:40" - }, - "Estimated": { - "value": "1570973387", - "tzOffset": 10800, - "text": "16:29" - }, - "vehicleId": "codd%5Fnew|58902%5F31316" - } - ], - "departureTime": "16:16" - } - } - ], - "uri": "ymapsbm1://transit/line?id=677k_bus_default&ll=37.565257%2C55.870397&name=677%D0%BA&r=2987&type=bus", - "seoname": "677k" - }, - { - "lineId": "m10_bus_default", - "name": "м10", - "Types": [ - "bus" - ], - "type": "bus", - "threads": [ - { - "threadId": "2036926048", - "EssentialStops": [ - { - "id": "stop__9640554", - "name": "Лобненская улица" - }, - { - "id": "stop__9640553", - "name": "Лобненская улица" - } - ], - "BriefSchedule": { - "Events": [ - { - "Estimated": { - "value": "1570972343", - "tzOffset": 10800, - "text": "16:12" - }, - "vehicleId": "codd%5Fnew|62922%5F31434" - }, - { - "Estimated": { - "value": "1570972813", - "tzOffset": 10800, - "text": "16:20" - }, - "vehicleId": "codd%5Fnew|57281%5F31242" - } - ], - "Frequency": { - "text": "15 мин", - "value": 900, - "begin": { - "value": "1570939772", - "tzOffset": 10800, - "text": "7:09" - }, - "end": { - "value": "1571008052", - "tzOffset": 10800, - "text": "2:07" - } - } - } - } - ], - "uri": "ymapsbm1://transit/line?id=m10_bus_default&ll=37.579221%2C55.823763&name=%D0%BC10&r=8474&type=bus", - "seoname": "m10" + ], + "departureTime": "18:20" + } } - ] + ], + "uri": "ymapsbm1://transit/line?id=2036924720&ll=37.577436%2C55.828981&name=692&r=4037&type=bus", + "seoname": "692" + }, + { + "lineId": "2036924957", + "name": "т29", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "2036925191", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9642124", + "name": "Метро Тимирязевская" + }, + { + "id": "stop__10007273", + "name": "Метро Тимирязевская" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1583422260", + "tzOffset": 10800, + "text": "18:31" + }, + "Estimated": { + "value": "1583421899", + "tzOffset": 10800, + "text": "18:24" + }, + "vehicleId": "codd%5Fnew|57882%5F31270" + }, + { + "Scheduled": { + "value": "1583423160", + "tzOffset": 10800, + "text": "18:46" + }, + "Estimated": { + "value": "1583422201", + "tzOffset": 10800, + "text": "18:30" + }, + "vehicleId": "codd%5Fnew|58538%5F31266" + }, + { + "Scheduled": { + "value": "1583424060", + "tzOffset": 10800, + "text": "19:01" + }, + "Estimated": { + "value": "1583422779", + "tzOffset": 10800, + "text": "18:39" + }, + "vehicleId": "codd%5Fnew|58626%5F31209" + }, + { + "Estimated": { + "value": "1583423238", + "tzOffset": 10800, + "text": "18:47" + }, + "vehicleId": "codd%5Fnew|58577%5F31224" + } + ], + "departureTime": "18:31" + } + } + ], + "uri": "ymapsbm1://transit/line?id=2036924957&ll=37.566536%2C55.821320&name=%D1%8229&r=3614&type=bus", + "seoname": "t29" + }, + { + "lineId": "2036924959", + "name": "т3", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "2036928125", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9644805", + "name": "7-й автобусный парк" + }, + { + "id": "2310890052", + "name": "Метро Верхние Лихоборы" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1583421780", + "tzOffset": 10800, + "text": "18:23" + }, + "Estimated": { + "value": "1583422190", + "tzOffset": 10800, + "text": "18:29" + }, + "vehicleId": "codd%5Fnew|58308%5F31268" + }, + { + "Scheduled": { + "value": "1583422380", + "tzOffset": 10800, + "text": "18:33" + }, + "Estimated": { + "value": "1583422522", + "tzOffset": 10800, + "text": "18:35" + }, + "vehicleId": "codd%5Fnew|60817%5F31226" + }, + { + "Scheduled": { + "value": "1583423040", + "tzOffset": 10800, + "text": "18:44" + }, + "Estimated": { + "value": "1583423156", + "tzOffset": 10800, + "text": "18:45" + }, + "vehicleId": "codd%5Fnew|146304%5F31207" + } + ], + "departureTime": "18:23" + } + } + ], + "uri": "ymapsbm1://transit/line?id=2036924959&ll=37.582485%2C55.812619&name=%D1%823&r=4734&type=bus", + "seoname": "t3" + }, + { + "lineId": "2036924968", + "name": "82", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "2036925244", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9712356", + "name": "Станция Дегунино" + }, + { + "id": "3163417967", + "name": "Станция Дегунино" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1583423340", + "tzOffset": 10800, + "text": "18:49" + }, + "Estimated": { + "value": "1583421564", + "tzOffset": 10800, + "text": "18:19" + }, + "vehicleId": "codd%5Fnew|58855%5F31459" + }, + { + "Scheduled": { + "value": "1583425380", + "tzOffset": 10800, + "text": "19:23" + } + }, + { + "Scheduled": { + "value": "1583427060", + "tzOffset": 10800, + "text": "19:51" + } + } + ], + "departureTime": "18:49" + } + } + ], + "uri": "ymapsbm1://transit/line?id=2036924968&ll=37.576010%2C55.822319&name=82&r=4771&type=bus", + "seoname": "82" + }, + { + "lineId": "2036925396", + "name": "м10", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "2036926048", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9640554", + "name": "Лобненская улица" + }, + { + "id": "stop__9640553", + "name": "Лобненская улица" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1583421720", + "tzOffset": 10800, + "text": "18:22" + }, + "Estimated": { + "value": "1583421638", + "tzOffset": 10800, + "text": "18:20" + }, + "vehicleId": "codd%5Fnew|58857%5F31208" + }, + { + "Scheduled": { + "value": "1583422320", + "tzOffset": 10800, + "text": "18:32" + }, + "Estimated": { + "value": "1583422060", + "tzOffset": 10800, + "text": "18:27" + }, + "vehicleId": "codd%5Fnew|146255%5F31230" + }, + { + "Scheduled": { + "value": "1583422800", + "tzOffset": 10800, + "text": "18:40" + }, + "Estimated": { + "value": "1583422172", + "tzOffset": 10800, + "text": "18:29" + }, + "vehicleId": "codd%5Fnew|62727%5F31251" + }, + { + "Estimated": { + "value": "1583422871", + "tzOffset": 10800, + "text": "18:41" + }, + "vehicleId": "codd%5Fnew|58861%5F31225" + }, + { + "Estimated": { + "value": "1583423033", + "tzOffset": 10800, + "text": "18:43" + }, + "vehicleId": "codd%5Fnew|58892%5F31202" + } + ], + "departureTime": "18:22" + } + } + ], + "uri": "ymapsbm1://transit/line?id=2036925396&ll=37.579221%2C55.823763&name=%D0%BC10&r=8474&type=bus", + "seoname": "m10" + }, + { + "lineId": "2036925416", + "name": "194", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "2036927196", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9711780", + "name": "Метро Петровско-Разумовская" + }, + { + "id": "stop__9648742", + "name": "Коровино" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1583422500", + "tzOffset": 10800, + "text": "18:35" + }, + "Estimated": { + "value": "1583421540", + "tzOffset": 10800, + "text": "18:19" + }, + "vehicleId": "codd%5Fnew|1101580%5F191636" + }, + { + "Scheduled": { + "value": "1583423460", + "tzOffset": 10800, + "text": "18:51" + } + }, + { + "Scheduled": { + "value": "1583424360", + "tzOffset": 10800, + "text": "19:06" + } + } + ], + "departureTime": "18:35" + } + } + ], + "uri": "ymapsbm1://transit/line?id=2036925416&ll=37.545611%2C55.865605&name=194&r=3662&type=bus", + "seoname": "194" + }, + { + "lineId": "2036925728", + "name": "282", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213A_282_bus_mosgortrans", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9641102", + "name": "Улица Корнейчука" + }, + { + "id": "2532226085", + "name": "Метро Войковская" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1583421540", + "tzOffset": 10800, + "text": "18:19" + }, + "vehicleId": "codd%5Fnew|37916%5F9225416" + }, + { + "Estimated": { + "value": "1583422093", + "tzOffset": 10800, + "text": "18:28" + }, + "vehicleId": "codd%5Fnew|34861%5F9345407" + }, + { + "Estimated": { + "value": "1583422486", + "tzOffset": 10800, + "text": "18:34" + }, + "vehicleId": "codd%5Fnew|34847%5F9545405" + }, + { + "Estimated": { + "value": "1583423061", + "tzOffset": 10800, + "text": "18:44" + }, + "vehicleId": "codd%5Fnew|34857%5F9345402" + } + ], + "Frequency": { + "text": "15 мин", + "value": 900, + "begin": { + "value": "1583376863", + "tzOffset": 10800, + "text": "5:54" + }, + "end": { + "value": "1583448143", + "tzOffset": 10800, + "text": "1:42" + } + } + } + } + ], + "uri": "ymapsbm1://transit/line?id=2036925728&ll=37.553526%2C55.860385&name=282&r=5779&type=bus", + "seoname": "282" + }, + { + "lineId": "2036926781", + "name": "154", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213B_154_bus_mosgortrans", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9642548", + "name": "ВДНХ (южная)" + }, + { + "id": "stop__9711744", + "name": "Станция Ховрино" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1583422260", + "tzOffset": 10800, + "text": "18:31" + }, + "Estimated": { + "value": "1583422184", + "tzOffset": 10800, + "text": "18:29" + }, + "vehicleId": "codd%5Fnew|1092234%5F191522" + }, + { + "Scheduled": { + "value": "1583423520", + "tzOffset": 10800, + "text": "18:52" + }, + "Estimated": { + "value": "1583422778", + "tzOffset": 10800, + "text": "18:39" + }, + "vehicleId": "codd%5Fnew|62451%5F190582" + }, + { + "Scheduled": { + "value": "1583424660", + "tzOffset": 10800, + "text": "19:11" + } + } + ], + "departureTime": "18:31" + } + } + ], + "uri": "ymapsbm1://transit/line?id=2036926781&ll=37.576273%2C55.846412&name=154&r=4918&type=bus", + "seoname": "154" + }, + { + "lineId": "2036926818", + "name": "994", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "2036925175", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9640756", + "name": "Метро Петровско-Разумовская" + }, + { + "id": "stop__9640351", + "name": "Метро Петровско-Разумовская" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1583421928", + "tzOffset": 10800, + "text": "18:25" + }, + "vehicleId": "codd%5Fnew|45432%5F9745639" + } + ], + "Frequency": { + "text": "30 мин", + "value": 1800, + "begin": { + "value": "1583375749", + "tzOffset": 10800, + "text": "5:35" + }, + "end": { + "value": "1583445949", + "tzOffset": 10800, + "text": "1:05" + } + } + } + } + ], + "uri": "ymapsbm1://transit/line?id=2036926818&ll=37.563627%2C55.871273&name=994&r=3902&type=bus", + "seoname": "994" + }, + { + "lineId": "2036926890", + "name": "466", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "466B_bus_default", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9640546", + "name": "Станция Бескудниково" + }, + { + "id": "stop__9640545", + "name": "Станция Бескудниково" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1583421843", + "tzOffset": 10800, + "text": "18:24" + }, + "vehicleId": "codd%5Fnew|41011%5F1018030" + }, + { + "Estimated": { + "value": "1583422338", + "tzOffset": 10800, + "text": "18:32" + }, + "vehicleId": "codd%5Fnew|41008%5F9715027" + }, + { + "Estimated": { + "value": "1583423283", + "tzOffset": 10800, + "text": "18:48" + }, + "vehicleId": "codd%5Fnew|41857%5F9705058" + } + ], + "Frequency": { + "text": "22 мин", + "value": 1320, + "begin": { + "value": "1583379047", + "tzOffset": 10800, + "text": "6:30" + }, + "end": { + "value": "1583449847", + "tzOffset": 10800, + "text": "2:10" + } + } + } + } + ], + "uri": "ymapsbm1://transit/line?id=2036926890&ll=37.564238%2C55.845050&name=466&r=4163&type=bus", + "seoname": "466" + }, + { + "lineId": "213_114_bus_mosgortrans", + "name": "114", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213B_114_bus_mosgortrans", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9647199", + "name": "Метро Войковская" + }, + { + "id": "stop__9639588", + "name": "Коровинское шоссе" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1583422200", + "tzOffset": 10800, + "text": "18:30" + }, + "Estimated": { + "value": "1583422179", + "tzOffset": 10800, + "text": "18:29" + }, + "vehicleId": "codd%5Fnew|1092236%5F191423" + }, + { + "Scheduled": { + "value": "1583423340", + "tzOffset": 10800, + "text": "18:49" + }, + "Estimated": { + "value": "1583423191", + "tzOffset": 10800, + "text": "18:46" + }, + "vehicleId": "codd%5Fnew|1054181%5F191402" + }, + { + "Scheduled": { + "value": "1583424420", + "tzOffset": 10800, + "text": "19:07" + } + } + ], + "departureTime": "18:30" + } + } + ], + "uri": "ymapsbm1://transit/line?id=213_114_bus_mosgortrans&ll=37.508504%2C55.852139&name=114&r=3544&type=bus", + "seoname": "114" + }, + { + "lineId": "213_179_bus_mosgortrans", + "name": "179", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213B_179_bus_mosgortrans", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9647199", + "name": "Метро Войковская" + }, + { + "id": "stop__9639480", + "name": "Станция Лианозово" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1583422020", + "tzOffset": 10800, + "text": "18:27" + }, + "Estimated": { + "value": "1583421975", + "tzOffset": 10800, + "text": "18:26" + }, + "vehicleId": "codd%5Fnew|58590%5F31348" + }, + { + "Scheduled": { + "value": "1583422680", + "tzOffset": 10800, + "text": "18:38" + }, + "Estimated": { + "value": "1583422480", + "tzOffset": 10800, + "text": "18:34" + }, + "vehicleId": "codd%5Fnew|60470%5F31355" + }, + { + "Scheduled": { + "value": "1583423340", + "tzOffset": 10800, + "text": "18:49" + }, + "Estimated": { + "value": "1583423264", + "tzOffset": 10800, + "text": "18:47" + }, + "vehicleId": "codd%5Fnew|1115928%5F31333" + } + ], + "departureTime": "18:27" + } + } + ], + "uri": "ymapsbm1://transit/line?id=213_179_bus_mosgortrans&ll=37.526151%2C55.858031&name=179&r=4634&type=bus", + "seoname": "179" + }, + { + "lineId": "213_191m_minibus_default", + "name": "591", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213A_191m_minibus_default", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9647199", + "name": "Метро Войковская" + }, + { + "id": "stop__9711744", + "name": "Станция Ховрино" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1583421660", + "tzOffset": 10800, + "text": "18:21" + }, + "vehicleId": "codd%5Fnew|35615%5F9345304" + }, + { + "Estimated": { + "value": "1583422323", + "tzOffset": 10800, + "text": "18:32" + }, + "vehicleId": "codd%5Fnew|35641%5F9345301" + }, + { + "Estimated": { + "value": "1583422926", + "tzOffset": 10800, + "text": "18:42" + }, + "vehicleId": "codd%5Fnew|38273%5F9345310" + } + ], + "Frequency": { + "text": "22 мин", + "value": 1320, + "begin": { + "value": "1583376433", + "tzOffset": 10800, + "text": "5:47" + }, + "end": { + "value": "1583446633", + "tzOffset": 10800, + "text": "1:17" + } + } + } + } + ], + "uri": "ymapsbm1://transit/line?id=213_191m_minibus_default&ll=37.510906%2C55.848214&name=591&r=3384&type=bus", + "seoname": "591" + }, + { + "lineId": "213_36_trolleybus_mosgortrans", + "name": "т36", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213A_36_trolleybus_mosgortrans", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9642550", + "name": "ВДНХ (южная)" + }, + { + "id": "stop__9640641", + "name": "Дмитровское шоссе, 155" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1583421600", + "tzOffset": 10800, + "text": "18:20" + }, + "Estimated": { + "value": "1583421929", + "tzOffset": 10800, + "text": "18:25" + }, + "vehicleId": "codd%5Fnew|1105873%5F430273" + }, + { + "Scheduled": { + "value": "1583422080", + "tzOffset": 10800, + "text": "18:28" + }, + "Estimated": { + "value": "1583422942", + "tzOffset": 10800, + "text": "18:42" + }, + "vehicleId": "codd%5Fnew|1084831%5F430257" + }, + { + "Scheduled": { + "value": "1583422560", + "tzOffset": 10800, + "text": "18:36" + }, + "Estimated": { + "value": "1583423178", + "tzOffset": 10800, + "text": "18:46" + }, + "vehicleId": "codd%5Fnew|1042449%5F430223" + } + ], + "departureTime": "18:20" + } + } + ], + "uri": "ymapsbm1://transit/line?id=213_36_trolleybus_mosgortrans&ll=37.588569%2C55.859718&name=%D1%8236&r=5106&type=bus", + "seoname": "t36" + }, + { + "lineId": "213_47_trolleybus_mosgortrans", + "name": "т47", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213B_47_trolleybus_mosgortrans", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9639568", + "name": "Бескудниковский переулок" + }, + { + "id": "stop__9641903", + "name": "Бескудниковский переулок" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1583421840", + "tzOffset": 10800, + "text": "18:24" + }, + "Estimated": { + "value": "1583422502", + "tzOffset": 10800, + "text": "18:35" + }, + "vehicleId": "codd%5Fnew|1119832%5F430355" + }, + { + "Scheduled": { + "value": "1583422200", + "tzOffset": 10800, + "text": "18:30" + }, + "Estimated": { + "value": "1583422636", + "tzOffset": 10800, + "text": "18:37" + }, + "vehicleId": "codd%5Fnew|1080551%5F430301" + }, + { + "Scheduled": { + "value": "1583422560", + "tzOffset": 10800, + "text": "18:36" + }, + "Estimated": { + "value": "1583422845", + "tzOffset": 10800, + "text": "18:40" + }, + "vehicleId": "codd%5Fnew|1139254%5F430378" + }, + { + "Estimated": { + "value": "1583422950", + "tzOffset": 10800, + "text": "18:42" + }, + "vehicleId": "codd%5Fnew|1091992%5F430316" + } + ], + "departureTime": "18:24" + } + } + ], + "uri": "ymapsbm1://transit/line?id=213_47_trolleybus_mosgortrans&ll=37.588308%2C55.818685&name=%D1%8247&r=5359&type=bus", + "seoname": "t47" + }, + { + "lineId": "213_56_trolleybus_mosgortrans", + "name": "т56", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213A_56_trolleybus_mosgortrans", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9639561", + "name": "Коровинское шоссе" + }, + { + "id": "stop__9639588", + "name": "Коровинское шоссе" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1583421900", + "tzOffset": 10800, + "text": "18:25" + }, + "Estimated": { + "value": "1583421746", + "tzOffset": 10800, + "text": "18:22" + }, + "vehicleId": "codd%5Fnew|1117148%5F430351" + }, + { + "Scheduled": { + "value": "1583422320", + "tzOffset": 10800, + "text": "18:32" + }, + "Estimated": { + "value": "1583422183", + "tzOffset": 10800, + "text": "18:29" + }, + "vehicleId": "codd%5Fnew|1139619%5F430381" + }, + { + "Scheduled": { + "value": "1583422680", + "tzOffset": 10800, + "text": "18:38" + }, + "Estimated": { + "value": "1583422496", + "tzOffset": 10800, + "text": "18:34" + }, + "vehicleId": "codd%5Fnew|1119829%5F430352" + }, + { + "Estimated": { + "value": "1583423315", + "tzOffset": 10800, + "text": "18:48" + }, + "vehicleId": "codd%5Fnew|1139256%5F430373" + } + ], + "departureTime": "18:25" + } + } + ], + "uri": "ymapsbm1://transit/line?id=213_56_trolleybus_mosgortrans&ll=37.551454%2C55.830147&name=%D1%8256&r=6304&type=bus", + "seoname": "t56" + }, + { + "lineId": "213_677_bus_mosgortrans", + "name": "677", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213B_677_bus_mosgortrans", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9639495", + "name": "Метро Петровско-Разумовская" + }, + { + "id": "stop__9639480", + "name": "Станция Лианозово" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1583421660", + "tzOffset": 10800, + "text": "18:21" + }, + "Estimated": { + "value": "1583421557", + "tzOffset": 10800, + "text": "18:19" + }, + "vehicleId": "codd%5Fnew|1082793%5F31390" + }, + { + "Scheduled": { + "value": "1583421840", + "tzOffset": 10800, + "text": "18:24" + }, + "Estimated": { + "value": "1583421675", + "tzOffset": 10800, + "text": "18:21" + }, + "vehicleId": "codd%5Fnew|58601%5F31323" + }, + { + "Scheduled": { + "value": "1583422020", + "tzOffset": 10800, + "text": "18:27" + } + } + ], + "departureTime": "18:21" + } + } + ], + "uri": "ymapsbm1://transit/line?id=213_677_bus_mosgortrans&ll=37.564191%2C55.866620&name=677&r=3386&type=bus", + "seoname": "677" + }, + { + "lineId": "213_78_trolleybus_mosgortrans", + "name": "т78", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213A_78_trolleybus_mosgortrans", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9887464", + "name": "9-я Северная линия" + }, + { + "id": "stop__9887464", + "name": "9-я Северная линия" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1583421780", + "tzOffset": 10800, + "text": "18:23" + }, + "Estimated": { + "value": "1583421764", + "tzOffset": 10800, + "text": "18:22" + }, + "vehicleId": "codd%5Fnew|58868%5F31131" + }, + { + "Scheduled": { + "value": "1583422200", + "tzOffset": 10800, + "text": "18:30" + }, + "Estimated": { + "value": "1583422361", + "tzOffset": 10800, + "text": "18:32" + }, + "vehicleId": "codd%5Fnew|58373%5F31158" + }, + { + "Scheduled": { + "value": "1583422620", + "tzOffset": 10800, + "text": "18:37" + }, + "Estimated": { + "value": "1583422440", + "tzOffset": 10800, + "text": "18:34" + }, + "vehicleId": "codd%5Fnew|55687%5F31120" + }, + { + "Estimated": { + "value": "1583423316", + "tzOffset": 10800, + "text": "18:48" + }, + "vehicleId": "codd%5Fnew|58107%5F31147" + } + ], + "departureTime": "18:23" + } + } + ], + "uri": "ymapsbm1://transit/line?id=213_78_trolleybus_mosgortrans&ll=37.569472%2C55.856380&name=%D1%8278&r=8918&type=bus", + "seoname": "t78" + }, + { + "lineId": "677k_bus_default", + "name": "677к", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "677kA_bus_default", + "noBoarding": false, + "EssentialStops": [ + { + "id": "stop__9640244", + "name": "Станция Лианозово" + }, + { + "id": "stop__9639480", + "name": "Станция Лианозово" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1583422500", + "tzOffset": 10800, + "text": "18:35" + }, + "Estimated": { + "value": "1583421857", + "tzOffset": 10800, + "text": "18:24" + }, + "vehicleId": "codd%5Fnew|59576%5F31317" + }, + { + "Scheduled": { + "value": "1583422860", + "tzOffset": 10800, + "text": "18:41" + }, + "Estimated": { + "value": "1583422383", + "tzOffset": 10800, + "text": "18:33" + }, + "vehicleId": "codd%5Fnew|58524%5F31321" + }, + { + "Scheduled": { + "value": "1583423220", + "tzOffset": 10800, + "text": "18:47" + }, + "Estimated": { + "value": "1583422574", + "tzOffset": 10800, + "text": "18:36" + }, + "vehicleId": "codd%5Fnew|125096%5F31369" + }, + { + "Estimated": { + "value": "1583423150", + "tzOffset": 10800, + "text": "18:45" + }, + "vehicleId": "codd%5Fnew|1038096%5F31398" + } + ], + "departureTime": "18:35" + } + } + ], + "uri": "ymapsbm1://transit/line?id=677k_bus_default&ll=37.565257%2C55.870397&name=677%D0%BA&r=2987&type=bus", + "seoname": "677k" } - }, + ], + "breadcrumbs": [ + { + "name": "Карты", + "type": "root", + "url": "https://yandex.ru/maps/" + }, + { + "name": "Москва", + "type": "region", + "url": "https://yandex.ru/maps/213/moscow/", + "region": { + "center": [ + 37.622504, + 55.753215 + ], + "zoom": 10 + } + }, + { + "name": "Общественный транспорт", + "type": "masstransit-home", + "url": "https://yandex.ru/maps/213/moscow/transport/" + }, + { + "name": "7-й автобусный парк", + "type": "search", + "url": "https://yandex.ru/maps/213/moscow/stops/stop__9639579/", + "currentPage": true + } + ], "searchResult": { - "requestId": "1570971868582853-530182592-man1-6817", + "type": "business", + "requestId": "1583421546337462-875775042-sas1-6586-sas-addrs-nmeta-new-8031", + "analyticsId": "1", "title": "7-й автобусный парк", "description": "Россия, Москва, Дмитровское шоссе", "address": "Россия, Москва, Дмитровское шоссе", @@ -1379,6 +1622,10 @@ 37.56528, 55.85196 ], + "displayCoordinates": [ + 37.56528, + 55.85196 + ], "bounds": [ [ 37.543123, @@ -1389,16 +1636,15 @@ 55.92488366 ] ], - "displayCoordinates": [ - 37.56528, - 55.85196 - ], + "logId": "dHlwZT1iaXpmaW5kZXI7aWQ9MjM5MzY2OTUwNjU4", + "uri": "ymapsbm1://transit/stop?id=stop__9639579", + "id": "239366950658", "metro": [ { "id": "2244536395", "name": "Верхние Лихоборы", "distance": "510 м", - "distanceValue": 509.265, + "distanceValue": 509.184, "coordinates": [ 37.56121218, 55.854501501 @@ -1447,7 +1693,7 @@ "id": "2310890052", "name": "Метро Верхние Лихоборы", "distance": "420 м", - "distanceValue": 424.274, + "distanceValue": 424.302, "coordinates": [ 37.563047501, 55.853727589 @@ -1478,7 +1724,7 @@ }, { "id": "stop__9639906", - "name": "Платформа Окружная", + "name": "Станция Окружная", "distance": "930 м", "distanceValue": 926.144, "coordinates": [ @@ -1488,59 +1734,26 @@ "type": "common" } ], - "logId": "dHlwZT1iaXpmaW5kZXI7aWQ9MjM5MzY2OTUwNjU4", - "type": "business", - "id": "239366950658", - "shortTitle": "7-й автобусный парк", - "additionalAddress": "", - "fullAddress": "Россия, Москва, Дмитровское шоссе", - "postalCode": "", - "addressDetails": { - "locality": "Москва", - "street": "Дмитровское шоссе" + "panorama": { + "id": "1297826777_672185801_23_1574243990", + "direction": [ + 32, + 10 + ], + "point": { + "type": "Point", + "coordinates": [ + 37.565167718, + 55.8518591059 + ] + }, + "preview": "https://avatars.mds.yandex.net/get-altay/1908863/2a00000169e52b1c1475fd4b51bae1f09966/%s", + "staticPreview": "https://static-pano.maps.yandex.ru/v1/?panoid=1297826777_672185801_23_1574243990&size=500,240&azimuth=32&tilt=10&signature=fiN27DzM02pZ3x3eVIodfiQQ8tFX2_KGNZocjzsdfnA=" }, - "categories": [ - { - "name": "Остановка общественного транспорта", - "class": "bus stop", - "seoname": "public_transport_stop", - "pluralName": "Остановки общественного транспорта", - "id": "223677355200" - } - ], + "shortTitle": "7-й автобусный парк", + "fullAddress": "Россия, Москва, Дмитровское шоссе", "status": "open", "businessLinks": [], - "businessProperties": { - "geoproduct_poi_color": "#ABAEB3", - "snippet_show_title": "short_title", - "snippet_show_rating": "five_star_rating", - "snippet_show_photo": "single_photo", - "snippet_show_eta": "show_eta", - "snippet_show_category": "single_category", - "snippet_show_subline": [ - "no_subline" - ], - "snippet_show_geoproduct_offer": "show_geoproduct_offer", - "snippet_show_bookmark": "show_bookmark", - "detailview_show_claim_organization": "not_show_claim_organization", - "detailview_show_reviews": "show_reviews", - "detailview_show_add_photo_button": "show_add_photo_button", - "detailview_show_taxi_button": "show_taxi_button", - "sensitive": "1" - }, - "seoname": "7_y_avtobusny_park", - "geoId": 117015, - "uri": "ymapsbm1://org?oid=239366950658", - "uriList": [ - "ymapsbm1://org?oid=239366950658", - "ymapsbm1://transit/stop?id=stop__9639579" - ], - "references": [ - { - "id": "2036929560", - "scope": "nyak" - } - ], "ratingData": { "ratingCount": 0, "ratingValue": 0, @@ -1553,8 +1766,113 @@ "href": "https://www.yandex.ru" } ], - "analyticsId": "1" - }, - "toponymSeoname": "dmitrovskoye_shosse" + "categories": [ + { + "name": "Остановка общественного транспорта", + "class": "bus stop", + "seoname": "public_transport_stop", + "pluralName": "Остановки общественного транспорта" + } + ], + "businessProperties": { + "has_verified_owner": false, + "geoproduct_poi_color": "#ABAEB3", + "snippet_show_photo": "single_photo", + "snippet_show_subline": [ + "no_subline" + ], + "unusual_hours": [ + "2020-03-08" + ] + }, + "seoname": "7_y_avtobusny_park", + "geoId": 117015, + "references": [ + { + "id": "2036929560", + "scope": "nyak" + } + ], + "region": { + "id": 213, + "hierarchy": [ + 225, + 1, + 213 + ], + "seoname": "moscow", + "bounds": [ + [ + 37.0402925, + 55.31141404514547 + ], + [ + 38.2047155, + 56.190068045145466 + ] + ], + "names": { + "ablative": "", + "accusative": "Москву", + "dative": "Москве", + "directional": "", + "genitive": "Москвы", + "instrumental": "Москвой", + "locative": "", + "nominative": "Москва", + "preposition": "в", + "prepositional": "Москве" + }, + "longitude": 37.622504, + "latitude": 55.753215, + "zoom": 10 + }, + "breadcrumbs": [ + { + "name": "Карты", + "type": "root", + "url": "https://yandex.ru/maps/" + }, + { + "name": "Москва", + "type": "region", + "url": "https://yandex.ru/maps/213/moscow/", + "region": { + "center": [ + 37.622504, + 55.753215 + ], + "zoom": 10 + } + }, + { + "category": { + "name": "Остановка общественного транспорта", + "class": "bus stop", + "seoname": "public_transport_stop", + "pluralName": "Остановки общественного транспорта" + }, + "name": "Остановки общественного транспорта", + "type": "category", + "url": "https://yandex.ru/maps/213/moscow/category/public_transport_stop/" + }, + { + "name": "7-й автобусный парк", + "type": "search", + "url": "https://yandex.ru/maps/org/7_y_avtobusny_park/239366950658/", + "currentPage": true + } + ], + "geoWhere": { + "id": "10049405", + "seoname": "dmitrovskoye_shosse", + "kind": "street", + "coordinates": [ + 37.547719, + 55.87593 + ], + "encodedCoordinates": "Z04YcwNnTkQOQFtvfXR2dHVgZA==" + } + } } -} \ No newline at end of file +} From 732457745fdf0218c35ce36f40521d979732b1e5 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sat, 7 Mar 2020 12:59:18 -0500 Subject: [PATCH 275/416] Add guard to ZHA device triggers (#32559) --- homeassistant/components/zha/device_trigger.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/zha/device_trigger.py b/homeassistant/components/zha/device_trigger.py index 222fdfc7a2c..5f842d7f380 100644 --- a/homeassistant/components/zha/device_trigger.py +++ b/homeassistant/components/zha/device_trigger.py @@ -47,6 +47,10 @@ async def async_attach_trigger(hass, config, action, automation_info): zha_device = await async_get_zha_device(hass, config[CONF_DEVICE_ID]) except (KeyError, AttributeError): return None + + if trigger not in zha_device.device_automation_triggers: + return None + trigger = zha_device.device_automation_triggers[trigger] event_config = { From 7e781946fa30f41b08e4d1ca5440b517fbe7323c Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Sat, 7 Mar 2020 13:52:45 -0500 Subject: [PATCH 276/416] Refactor ZHA device keep alive checker (#32561) * Refactor zha core device _check_available(). Make it async, so we don't run it in a sync worker. * Use random keep alive interval for zha device pings. * Update tests. --- homeassistant/components/zha/core/device.py | 67 +++++++++++---------- tests/components/zha/test_device.py | 38 ++++++++++-- 2 files changed, 68 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 76685180ea2..7297440624a 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -3,6 +3,7 @@ import asyncio from datetime import timedelta from enum import Enum import logging +import random import time from zigpy import types @@ -61,7 +62,7 @@ from .helpers import LogMixin _LOGGER = logging.getLogger(__name__) _KEEP_ALIVE_INTERVAL = 7200 -_UPDATE_ALIVE_INTERVAL = timedelta(seconds=60) +_UPDATE_ALIVE_INTERVAL = (60, 90) _CHECKIN_GRACE_PERIODS = 2 @@ -98,8 +99,9 @@ class ZHADevice(LogMixin): self._zigpy_device.__class__.__module__, self._zigpy_device.__class__.__name__, ) + keep_alive_interval = random.randint(*_UPDATE_ALIVE_INTERVAL) self._available_check = async_track_time_interval( - self.hass, self._check_available, _UPDATE_ALIVE_INTERVAL + self.hass, self._check_available, timedelta(seconds=keep_alive_interval) ) self._ha_device_id = None self.status = DeviceStatus.CREATED @@ -271,37 +273,40 @@ class ZHADevice(LogMixin): zha_dev.channels = channels.Channels.new(zha_dev) return zha_dev - def _check_available(self, *_): + async def _check_available(self, *_): if self.last_seen is None: self.update_available(False) - else: - difference = time.time() - self.last_seen - if difference > _KEEP_ALIVE_INTERVAL: - if self._checkins_missed_count < _CHECKIN_GRACE_PERIODS: - self._checkins_missed_count += 1 - if self.manufacturer != "LUMI": - self.debug( - "Attempting to checkin with device - missed checkins: %s", - self._checkins_missed_count, - ) - if not self._channels.pools: - return - try: - pool = self._channels.pools[0] - basic_ch = pool.all_channels[f"{pool.id}:0x0000"] - except KeyError: - self.debug("%s %s does not have a mandatory basic cluster") - return - self.hass.async_create_task( - basic_ch.get_attribute_value( - ATTR_MANUFACTURER, from_cache=False - ) - ) - else: - self.update_available(False) - else: - self.update_available(True) - self._checkins_missed_count = 0 + return + + difference = time.time() - self.last_seen + if difference < _KEEP_ALIVE_INTERVAL: + self.update_available(True) + self._checkins_missed_count = 0 + return + + if ( + self._checkins_missed_count >= _CHECKIN_GRACE_PERIODS + or self.manufacturer == "LUMI" + or not self._channels.pools + ): + self.update_available(False) + return + + self._checkins_missed_count += 1 + self.debug( + "Attempting to checkin with device - missed checkins: %s", + self._checkins_missed_count, + ) + try: + pool = self._channels.pools[0] + basic_ch = pool.all_channels[f"{pool.id}:0x0000"] + except KeyError: + self.debug("does not have a mandatory basic cluster") + self.update_available(False) + return + res = await basic_ch.get_attribute_value(ATTR_MANUFACTURER, from_cache=False) + if res is not None: + self._checkins_missed_count = 0 def update_available(self, available): """Set sensor availability.""" diff --git a/tests/components/zha/test_device.py b/tests/components/zha/test_device.py index 3ac22b136fb..0df975b732f 100644 --- a/tests/components/zha/test_device.py +++ b/tests/components/zha/test_device.py @@ -82,21 +82,21 @@ async def test_check_available_success( basic_ch.read_attributes.side_effect = _update_last_seen # successfully ping zigpy device, but zha_device is not yet available - _send_time_changed(hass, 61) + _send_time_changed(hass, 91) await hass.async_block_till_done() assert basic_ch.read_attributes.await_count == 1 assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] assert zha_device.available is False # There was traffic from the device: pings, but not yet available - _send_time_changed(hass, 61) + _send_time_changed(hass, 91) await hass.async_block_till_done() assert basic_ch.read_attributes.await_count == 2 assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] assert zha_device.available is False # There was traffic from the device: don't try to ping, marked as available - _send_time_changed(hass, 61) + _send_time_changed(hass, 91) await hass.async_block_till_done() assert basic_ch.read_attributes.await_count == 2 assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] @@ -125,22 +125,48 @@ async def test_check_available_unsuccessful( ) # unsuccessfuly ping zigpy device, but zha_device is still available - _send_time_changed(hass, 61) + _send_time_changed(hass, 91) await hass.async_block_till_done() assert basic_ch.read_attributes.await_count == 1 assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] assert zha_device.available is True # still no traffic, but zha_device is still available - _send_time_changed(hass, 61) + _send_time_changed(hass, 91) await hass.async_block_till_done() assert basic_ch.read_attributes.await_count == 2 assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] assert zha_device.available is True # not even trying to update, device is unavailble - _send_time_changed(hass, 61) + _send_time_changed(hass, 91) await hass.async_block_till_done() assert basic_ch.read_attributes.await_count == 2 assert basic_ch.read_attributes.await_args[0][0] == ["manufacturer"] assert zha_device.available is False + + +@asynctest.patch( + "homeassistant.components.zha.core.channels.general.BasicChannel.async_initialize", + new=mock.MagicMock(), +) +async def test_check_available_no_basic_channel( + hass, device_without_basic_channel, zha_device_restored, caplog +): + """Check device availability for a device without basic cluster.""" + + # pylint: disable=protected-access + zha_device = await zha_device_restored(device_without_basic_channel) + await async_enable_traffic(hass, [zha_device]) + + assert zha_device.available is True + + device_without_basic_channel.last_seen = ( + time.time() - zha_core_device._KEEP_ALIVE_INTERVAL - 2 + ) + + assert "does not have a mandatory basic cluster" not in caplog.text + _send_time_changed(hass, 91) + await hass.async_block_till_done() + assert zha_device.available is False + assert "does not have a mandatory basic cluster" in caplog.text From a0787cd9bede0640dfad1bca4062bda7821cd1ba Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 7 Mar 2020 21:22:27 -0600 Subject: [PATCH 277/416] Bump zeroconf to 0.24.5 (#32573) * Bump zeroconf to 0.24.5 * Empty commit to force ci re-run --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index e038b9c0da1..b0808d83d68 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.24.4"], + "requirements": ["zeroconf==0.24.5"], "dependencies": ["api"], "codeowners": ["@robbiet480", "@Kane610"], "quality_scale": "internal" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ae621c1bcef..c782bbbd4ea 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -25,7 +25,7 @@ ruamel.yaml==0.15.100 sqlalchemy==1.3.13 voluptuous-serialize==2.3.0 voluptuous==0.11.7 -zeroconf==0.24.4 +zeroconf==0.24.5 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index cf6a929dcd3..e111164cdcc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2146,7 +2146,7 @@ youtube_dl==2020.03.06 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.24.4 +zeroconf==0.24.5 # homeassistant.components.zha zha-quirks==0.0.35 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e6a00a6955e..4c0cbd796a2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -738,7 +738,7 @@ ya_ma==0.3.8 yahooweather==0.10 # homeassistant.components.zeroconf -zeroconf==0.24.4 +zeroconf==0.24.5 # homeassistant.components.zha zha-quirks==0.0.35 From 17215709e16486c27900f55f028f19df32a5737a Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 8 Mar 2020 05:27:51 +0100 Subject: [PATCH 278/416] Simplify logbook duplicate handling (#32572) --- homeassistant/components/logbook/__init__.py | 28 ++------ tests/components/logbook/test_init.py | 75 +++++++++----------- 2 files changed, 39 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index 959b2be68d9..9fad7e9752f 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -203,9 +203,6 @@ def humanify(hass, events): """ domain_prefixes = tuple(f"{dom}." for dom in CONTINUOUS_DOMAINS) - # Track last states to filter out duplicates - last_state = {} - # Group events in batches of GROUP_BY_MINUTES for _, g_events in groupby( events, lambda event: event.time_fired.minute // GROUP_BY_MINUTES @@ -255,13 +252,6 @@ def humanify(hass, events): if event.event_type == EVENT_STATE_CHANGED: to_state = State.from_dict(event.data.get("new_state")) - # Filter out states that become same state again (force_update=True) - # or light becoming different color - if last_state.get(to_state.entity_id) == to_state.state: - continue - - last_state[to_state.entity_id] = to_state.state - domain = to_state.domain # Skip all but the last sensor state @@ -468,25 +458,21 @@ def _keep_event(hass, event, entities_filter): return False # Do not report on new entities - if event.data.get("old_state") is None: + old_state = event.data.get("old_state") + if old_state is None: return False - new_state = event.data.get("new_state") - # Do not report on entity removal - if not new_state: + new_state = event.data.get("new_state") + if new_state is None: return False - attributes = new_state.get("attributes", {}) - - # If last_changed != last_updated only attributes have changed - # we do not report on that yet. - last_changed = new_state.get("last_changed") - last_updated = new_state.get("last_updated") - if last_changed != last_updated: + # Do not report on only attribute changes + if new_state.get("state") == old_state.get("state"): return False domain = split_entity_id(entity_id)[0] + attributes = new_state.get("attributes", {}) # Also filter auto groups. if domain == "group" and attributes.get("auto", False): diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index e64abe8dbb7..cc9a459d2c1 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -568,14 +568,35 @@ class TestComponentLogbook(unittest.TestCase): def test_exclude_attribute_changes(self): """Test if events of attribute changes are filtered.""" - entity_id = "switch.bla" - entity_id2 = "switch.blu" pointA = dt_util.utcnow() pointB = pointA + timedelta(minutes=1) + pointC = pointB + timedelta(minutes=1) - eventA = self.create_state_changed_event(pointA, entity_id, 10) - eventB = self.create_state_changed_event( - pointA, entity_id2, 20, last_changed=pointA, last_updated=pointB + state_off = ha.State("light.kitchen", "off", {}, pointA, pointA).as_dict() + state_100 = ha.State( + "light.kitchen", "on", {"brightness": 100}, pointB, pointB + ).as_dict() + state_200 = ha.State( + "light.kitchen", "on", {"brightness": 200}, pointB, pointC + ).as_dict() + + eventA = ha.Event( + EVENT_STATE_CHANGED, + { + "entity_id": "light.kitchen", + "old_state": state_off, + "new_state": state_100, + }, + time_fired=pointB, + ) + eventB = ha.Event( + EVENT_STATE_CHANGED, + { + "entity_id": "light.kitchen", + "old_state": state_100, + "new_state": state_200, + }, + time_fired=pointC, ) entities_filter = logbook._generate_filter_from_config({}) @@ -588,7 +609,7 @@ class TestComponentLogbook(unittest.TestCase): assert 1 == len(entries) self.assert_entry( - entries[0], pointA, "bla", domain="switch", entity_id=entity_id + entries[0], pointB, "kitchen", domain="light", entity_id="light.kitchen" ) def test_home_assistant_start_stop_grouped(self): @@ -1231,15 +1252,16 @@ class TestComponentLogbook(unittest.TestCase): last_updated=None, ): """Create state changed event.""" - # Logbook only cares about state change events that - # contain an old state but will not actually act on it. - state = ha.State( + old_state = ha.State( + entity_id, "old", attributes, last_changed, last_updated + ).as_dict() + new_state = ha.State( entity_id, state, attributes, last_changed, last_updated ).as_dict() return ha.Event( EVENT_STATE_CHANGED, - {"entity_id": entity_id, "old_state": state, "new_state": state}, + {"entity_id": entity_id, "old_state": old_state, "new_state": new_state}, time_fired=event_time_fired, ) @@ -1435,39 +1457,6 @@ async def test_humanify_script_started_event(hass): assert event2["entity_id"] == "script.bye" -async def test_humanify_same_state(hass): - """Test humanifying Script Run event.""" - state_50 = ha.State("light.kitchen", "on", {"brightness": 50}).as_dict() - state_100 = ha.State("light.kitchen", "on", {"brightness": 100}).as_dict() - state_200 = ha.State("light.kitchen", "on", {"brightness": 200}).as_dict() - - events = list( - logbook.humanify( - hass, - [ - ha.Event( - EVENT_STATE_CHANGED, - { - "entity_id": "light.kitchen", - "old_state": state_50, - "new_state": state_100, - }, - ), - ha.Event( - EVENT_STATE_CHANGED, - { - "entity_id": "light.kitchen", - "old_state": state_100, - "new_state": state_200, - }, - ), - ], - ) - ) - - assert len(events) == 1 - - async def test_logbook_describe_event(hass, hass_client): """Test teaching logbook about a new event.""" await hass.async_add_executor_job(init_recorder_component, hass) From b5118c41a6b82d62116becb02d928d673470f465 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sun, 8 Mar 2020 12:20:33 +0100 Subject: [PATCH 279/416] Add Tibber retry (#32554) * Add retry setup to Tibber * tibber lib * update comment * update comment * increase delay for every try * Update homeassistant/components/tibber/__init__.py Co-Authored-By: Martin Hjelmare Co-authored-by: Martin Hjelmare --- homeassistant/components/tibber/__init__.py | 18 ++++++++++++++---- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py index df56989714f..53c02a1461a 100644 --- a/homeassistant/components/tibber/__init__.py +++ b/homeassistant/components/tibber/__init__.py @@ -10,10 +10,13 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME, EVENT_HOMEASSISTAN from homeassistant.helpers import discovery from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_call_later from homeassistant.util import dt as dt_util DOMAIN = "tibber" +FIRST_RETRY_TIME = 60 + CONFIG_SCHEMA = vol.Schema( {DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})}, extra=vol.ALLOW_EXTRA, @@ -22,7 +25,7 @@ CONFIG_SCHEMA = vol.Schema( _LOGGER = logging.getLogger(__name__) -async def async_setup(hass, config): +async def async_setup(hass, config, retry_delay=FIRST_RETRY_TIME): """Set up the Tibber component.""" conf = config.get(DOMAIN) @@ -40,9 +43,16 @@ async def async_setup(hass, config): try: await tibber_connection.update_info() - except asyncio.TimeoutError as err: - _LOGGER.error("Timeout connecting to Tibber: %s ", err) - return False + except asyncio.TimeoutError: + _LOGGER.warning("Timeout connecting to Tibber. Will retry in %ss", retry_delay) + + async def retry_setup(now): + """Retry setup if a timeout happens on Tibber API.""" + await async_setup(hass, config, retry_delay=min(2 * retry_delay, 900)) + + async_call_later(hass, retry_delay, retry_setup) + + return True except aiohttp.ClientError as err: _LOGGER.error("Error connecting to Tibber: %s ", err) return False diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index 4186e0781fe..78b358d70ec 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -2,7 +2,7 @@ "domain": "tibber", "name": "Tibber", "documentation": "https://www.home-assistant.io/integrations/tibber", - "requirements": ["pyTibber==0.13.2"], + "requirements": ["pyTibber==0.13.3"], "dependencies": [], "codeowners": ["@danielhiversen"], "quality_scale": "silver" diff --git a/requirements_all.txt b/requirements_all.txt index e111164cdcc..2ace08d081c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1116,7 +1116,7 @@ pyRFXtrx==0.25 # pySwitchmate==0.4.6 # homeassistant.components.tibber -pyTibber==0.13.2 +pyTibber==0.13.3 # homeassistant.components.dlink pyW215==0.6.0 From 4fbc404c4749c0bc33ccfff2df34482e95e694ab Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Sun, 8 Mar 2020 19:10:05 +0100 Subject: [PATCH 280/416] Update python-velbus to fix a missing data file (#32580) --- homeassistant/components/velbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 3063c4445bd..4179c3e89ba 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.41"], + "requirements": ["python-velbus==2.0.42"], "config_flow": true, "dependencies": [], "codeowners": ["@Cereal2nd", "@brefra"] diff --git a/requirements_all.txt b/requirements_all.txt index 2ace08d081c..603e1d63657 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1659,7 +1659,7 @@ python-telnet-vlc==1.0.4 python-twitch-client==0.6.0 # homeassistant.components.velbus -python-velbus==2.0.41 +python-velbus==2.0.42 # homeassistant.components.vlc python-vlc==1.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c0cbd796a2..c63215ed395 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -584,7 +584,7 @@ python-nest==4.1.0 python-twitch-client==0.6.0 # homeassistant.components.velbus -python-velbus==2.0.41 +python-velbus==2.0.42 # homeassistant.components.awair python_awair==0.0.4 From 8a878bbe72d0e0cd32592518ac90f9d8aaf1b984 Mon Sep 17 00:00:00 2001 From: Quentame Date: Sun, 8 Mar 2020 23:32:14 +0100 Subject: [PATCH 281/416] Bump pyicloud to 0.9.3 (#32582) --- homeassistant/components/icloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json index a4a51f9e1a2..5b232cf1e62 100644 --- a/homeassistant/components/icloud/manifest.json +++ b/homeassistant/components/icloud/manifest.json @@ -3,7 +3,7 @@ "name": "Apple iCloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/icloud", - "requirements": ["pyicloud==0.9.2"], + "requirements": ["pyicloud==0.9.3"], "dependencies": [], "codeowners": ["@Quentame"] } diff --git a/requirements_all.txt b/requirements_all.txt index 603e1d63657..ad97b200f4a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1309,7 +1309,7 @@ pyhomeworks==0.0.6 pyialarm==0.3 # homeassistant.components.icloud -pyicloud==0.9.2 +pyicloud==0.9.3 # homeassistant.components.intesishome pyintesishome==1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c63215ed395..bc9b048d860 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -480,7 +480,7 @@ pyheos==0.6.0 pyhomematic==0.1.65 # homeassistant.components.icloud -pyicloud==0.9.2 +pyicloud==0.9.3 # homeassistant.components.ipma pyipma==2.0.5 From f54c50fae3dba163bafbfe5603a5f7c0ba436785 Mon Sep 17 00:00:00 2001 From: Josef Schlehofer Date: Sun, 8 Mar 2020 23:37:58 +0100 Subject: [PATCH 282/416] Upgrade youtube_dl to version 2020.03.08 (#32581) --- homeassistant/components/media_extractor/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 53b9e93575e..631dc7675ca 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -2,7 +2,7 @@ "domain": "media_extractor", "name": "Media Extractor", "documentation": "https://www.home-assistant.io/integrations/media_extractor", - "requirements": ["youtube_dl==2020.03.06"], + "requirements": ["youtube_dl==2020.03.08"], "dependencies": ["media_player"], "codeowners": [], "quality_scale": "internal" diff --git a/requirements_all.txt b/requirements_all.txt index ad97b200f4a..11ac085b196 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2140,7 +2140,7 @@ yeelight==0.5.0 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2020.03.06 +youtube_dl==2020.03.08 # homeassistant.components.zengge zengge==0.2 From 963b5db763842d36b107b9ed8509366ddd0925cc Mon Sep 17 00:00:00 2001 From: Oliver Date: Sun, 8 Mar 2020 23:43:12 +0100 Subject: [PATCH 283/416] Bump denonavr to 0.8.0 (#32578) --- homeassistant/components/denonavr/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json index 1387875c02d..d13ae1d7701 100644 --- a/homeassistant/components/denonavr/manifest.json +++ b/homeassistant/components/denonavr/manifest.json @@ -2,7 +2,7 @@ "domain": "denonavr", "name": "Denon AVR Network Receivers", "documentation": "https://www.home-assistant.io/integrations/denonavr", - "requirements": ["denonavr==0.7.12"], + "requirements": ["denonavr==0.8.0"], "dependencies": [], "codeowners": [] } diff --git a/requirements_all.txt b/requirements_all.txt index 11ac085b196..2901d6477ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -436,7 +436,7 @@ defusedxml==0.6.0 deluge-client==1.7.1 # homeassistant.components.denonavr -denonavr==0.7.12 +denonavr==0.8.0 # homeassistant.components.directv directpy==0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bc9b048d860..c43a9f33a07 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -162,7 +162,7 @@ datadog==0.15.0 defusedxml==0.6.0 # homeassistant.components.denonavr -denonavr==0.7.12 +denonavr==0.8.0 # homeassistant.components.directv directpy==0.6 From 0dd0b2fa03d233335bb217eb2d7f2797c5871922 Mon Sep 17 00:00:00 2001 From: seanvictory Date: Mon, 9 Mar 2020 01:04:53 -0700 Subject: [PATCH 284/416] Add support for Ubee Router DVW32CB (#32406) * Update PyUbee to 0.9 Adds support for Ubee Router DVW32CB * Add support for Ubee Router DVW32CB * Update pyubee to 0.9 Adds support for router DVW32CB --- homeassistant/components/ubee/device_tracker.py | 2 +- homeassistant/components/ubee/manifest.json | 2 +- requirements_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ubee/device_tracker.py b/homeassistant/components/ubee/device_tracker.py index 6fe7e90f4c7..21d2fd10009 100644 --- a/homeassistant/components/ubee/device_tracker.py +++ b/homeassistant/components/ubee/device_tracker.py @@ -24,7 +24,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_USERNAME): cv.string, vol.Optional(CONF_MODEL, default=DEFAULT_MODEL): vol.Any( - "EVW32C-0N", "EVW320B", "EVW321B", "EVW3200-Wifi", "EVW3226@UPC" + "EVW32C-0N", "EVW320B", "EVW321B", "EVW3200-Wifi", "EVW3226@UPC", "DVW32CB" ), } ) diff --git a/homeassistant/components/ubee/manifest.json b/homeassistant/components/ubee/manifest.json index 910a3debc1e..e853c7490db 100644 --- a/homeassistant/components/ubee/manifest.json +++ b/homeassistant/components/ubee/manifest.json @@ -2,7 +2,7 @@ "domain": "ubee", "name": "Ubee Router", "documentation": "https://www.home-assistant.io/integrations/ubee", - "requirements": ["pyubee==0.8"], + "requirements": ["pyubee==0.9"], "dependencies": [], "codeowners": ["@mzdrale"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2901d6477ae..e1d94fcb98a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1699,7 +1699,7 @@ pytradfri[async]==6.4.0 pytrafikverket==0.1.6.1 # homeassistant.components.ubee -pyubee==0.8 +pyubee==0.9 # homeassistant.components.uptimerobot pyuptimerobot==0.0.5 From 5ace7de1712d2f898d4430f027d9a722c29f0a59 Mon Sep 17 00:00:00 2001 From: jey burrows Date: Mon, 9 Mar 2020 08:35:54 +0000 Subject: [PATCH 285/416] Bump rflink to 0.0.52 (#32588) --- homeassistant/components/rflink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json index 77b6413f994..0386e0c5bf8 100644 --- a/homeassistant/components/rflink/manifest.json +++ b/homeassistant/components/rflink/manifest.json @@ -2,7 +2,7 @@ "domain": "rflink", "name": "RFLink", "documentation": "https://www.home-assistant.io/integrations/rflink", - "requirements": ["rflink==0.0.51"], + "requirements": ["rflink==0.0.52"], "dependencies": [], "codeowners": [] } diff --git a/requirements_all.txt b/requirements_all.txt index e1d94fcb98a..58d5055c15f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1771,7 +1771,7 @@ restrictedpython==5.0 rfk101py==0.0.1 # homeassistant.components.rflink -rflink==0.0.51 +rflink==0.0.52 # homeassistant.components.ring ring_doorbell==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c43a9f33a07..356b5a7530a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -614,7 +614,7 @@ regenmaschine==1.5.1 restrictedpython==5.0 # homeassistant.components.rflink -rflink==0.0.51 +rflink==0.0.52 # homeassistant.components.ring ring_doorbell==0.6.0 From df86668dfc3f1db739a68f4dff119dc083c8a3c1 Mon Sep 17 00:00:00 2001 From: Dave T <17680170+davet2001@users.noreply.github.com> Date: Mon, 9 Mar 2020 08:40:08 +0000 Subject: [PATCH 286/416] Correct grammatical error in CUSTOM_WARNING (#32569) --- homeassistant/loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 4c46d437760..155dd0e059d 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -45,7 +45,7 @@ CUSTOM_WARNING = ( "You are using a custom integration for %s which has not " "been tested by Home Assistant. This component might " "cause stability problems, be sure to disable it if you " - "do experience issues with Home Assistant." + "experience issues with Home Assistant." ) _UNDEF = object() From f7f6c4797309b5ccd1af0788450a9d8ac970d925 Mon Sep 17 00:00:00 2001 From: Kris Bennett <1435262+i00@users.noreply.github.com> Date: Mon, 9 Mar 2020 20:38:24 +1000 Subject: [PATCH 287/416] Add Steam game ID and screenshot paths as attributes (#32005) * Update Steam intergration to list screenshot paths and Steam game ID * Steam entity_picture now changed to game art when in-game * Steam - changing API endpoints to constants * Steam - formatting code for lint * Update sensor.py *Removing entity_picture image switching based on current playing game * Steam - tidying up code * Update homeassistant/components/steam_online/sensor.py Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> Co-authored-by: springstan <46536646+springstan@users.noreply.github.com> --- homeassistant/components/steam_online/sensor.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py index 2ec78c52645..d25ebb7221b 100644 --- a/homeassistant/components/steam_online/sensor.py +++ b/homeassistant/components/steam_online/sensor.py @@ -28,6 +28,10 @@ STATE_SNOOZE = "snooze" STATE_LOOKING_TO_TRADE = "looking_to_trade" STATE_LOOKING_TO_PLAY = "looking_to_play" +STEAM_API_URL = "https://steamcdn-a.akamaihd.net/steam/apps/" +STEAM_HEADER_IMAGE_FILE = "header.jpg" +STEAM_MAIN_IMAGE_FILE = "capsule_616x353.jpg" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_API_KEY): cv.string, @@ -73,6 +77,7 @@ class SteamSensor(Entity): self._account = account self._profile = None self._game = None + self._game_id = None self._state = None self._name = None self._avatar = None @@ -104,6 +109,7 @@ class SteamSensor(Entity): try: self._profile = self._steamod.user.profile(self._account) self._game = self._get_current_game() + self._game_id = self._profile.current_game[0] self._state = { 1: STATE_ONLINE, 2: STATE_BUSY, @@ -119,6 +125,7 @@ class SteamSensor(Entity): except self._steamod.api.HTTPTimeoutError as error: _LOGGER.warning(error) self._game = None + self._game_id = None self._state = None self._name = None self._avatar = None @@ -170,6 +177,11 @@ class SteamSensor(Entity): attr = {} if self._game is not None: attr["game"] = self._game + if self._game_id is not None: + attr["game_id"] = self._game_id + game_url = f"{STEAM_API_URL}{self._game_id}/" + attr["game_image_header"] = f"{game_url}{STEAM_HEADER_IMAGE_FILE}" + attr["game_image_main"] = f"{game_url}{STEAM_MAIN_IMAGE_FILE}" if self._last_online is not None: attr["last_online"] = self._last_online if self._level is not None: From a6e0ab2b3ad5eac056139f34cef4e5eda798dee7 Mon Sep 17 00:00:00 2001 From: Mans Matulewicz Date: Mon, 9 Mar 2020 12:56:26 +0100 Subject: [PATCH 288/416] Add thinkingcleaner optional host param (#32542) * adding support for optional host param as thinkingcleaner backend was having issues (again) -> https://www.thinkingsync.com/ * Update switch.py * Update sensor.py * Update switch.py * Update sensor.py * Update switch.py * Update switch.py * Update sensor.py --- .../components/thinkingcleaner/sensor.py | 17 +++++++++++++---- .../components/thinkingcleaner/switch.py | 18 +++++++++++++----- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/thinkingcleaner/sensor.py b/homeassistant/components/thinkingcleaner/sensor.py index 26175b5368a..724ed54e4da 100644 --- a/homeassistant/components/thinkingcleaner/sensor.py +++ b/homeassistant/components/thinkingcleaner/sensor.py @@ -2,10 +2,13 @@ from datetime import timedelta import logging -from pythinkingcleaner import Discovery +from pythinkingcleaner import Discovery, ThinkingCleaner +import voluptuous as vol from homeassistant import util -from homeassistant.const import UNIT_PERCENTAGE +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_HOST, UNIT_PERCENTAGE +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -46,12 +49,18 @@ STATES = { "st_unknown": "Unknown state", } +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Optional(CONF_HOST): cv.string}) + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the ThinkingCleaner platform.""" - discovery = Discovery() - devices = discovery.discover() + host = config.get(CONF_HOST) + if host: + devices = [ThinkingCleaner(host, "unknown")] + else: + discovery = Discovery() + devices = discovery.discover() @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) def update_devices(): diff --git a/homeassistant/components/thinkingcleaner/switch.py b/homeassistant/components/thinkingcleaner/switch.py index 88d87e4e5fe..172951ed1ef 100644 --- a/homeassistant/components/thinkingcleaner/switch.py +++ b/homeassistant/components/thinkingcleaner/switch.py @@ -3,10 +3,13 @@ from datetime import timedelta import logging import time -from pythinkingcleaner import Discovery +from pythinkingcleaner import Discovery, ThinkingCleaner +import voluptuous as vol from homeassistant import util -from homeassistant.const import STATE_OFF, STATE_ON +from homeassistant.components.switch import PLATFORM_SCHEMA +from homeassistant.const import CONF_HOST, STATE_OFF, STATE_ON +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity _LOGGER = logging.getLogger(__name__) @@ -23,12 +26,17 @@ SWITCH_TYPES = { "find": ["Find", None, None], } +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Optional(CONF_HOST): cv.string}) + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the ThinkingCleaner platform.""" - - discovery = Discovery() - devices = discovery.discover() + host = config.get(CONF_HOST) + if host: + devices = [ThinkingCleaner(host, "unknown")] + else: + discovery = Discovery() + devices = discovery.discover() @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) def update_devices(): From c0c5c33b2044e78a2ba323025b2c0aaac0f41ead Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 9 Mar 2020 13:45:39 +0100 Subject: [PATCH 289/416] Fix Withings timezone test (#32602) --- tests/components/withings/test_common.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/components/withings/test_common.py b/tests/components/withings/test_common.py index 4a48dcee571..65ff65ebbd8 100644 --- a/tests/components/withings/test_common.py +++ b/tests/components/withings/test_common.py @@ -115,7 +115,7 @@ async def test_data_manager_update_sleep_date_range( """Test method.""" patch_time_zone = patch( "homeassistant.util.dt.DEFAULT_TIME_ZONE", - new=dt.get_time_zone("America/Los_Angeles"), + new=dt.get_time_zone("America/Belize"), ) with patch_time_zone: @@ -126,10 +126,10 @@ async def test_data_manager_update_sleep_date_range( startdate = call_args.get("startdate") enddate = call_args.get("enddate") - assert startdate.tzname() == "PST" + assert startdate.tzname() == "CST" - assert enddate.tzname() == "PST" - assert startdate.tzname() == "PST" + assert enddate.tzname() == "CST" + assert startdate.tzname() == "CST" assert update_start_time < enddate assert enddate < update_start_time + timedelta(seconds=1) assert enddate > startdate From fa4f27f78e004ae92ab452d57242e2630393e684 Mon Sep 17 00:00:00 2001 From: ochlocracy <5885236+ochlocracy@users.noreply.github.com> Date: Mon, 9 Mar 2020 11:25:33 -0400 Subject: [PATCH 290/416] =?UTF-8?q?Remove=20AlexaPowerController=20from=20?= =?UTF-8?q?device=5Fclass=20garage=20covers=20in=E2=80=A6=20(#32607)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- homeassistant/components/alexa/entities.py | 5 ++++- tests/components/alexa/test_smart_home.py | 22 +++++++--------------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index b10f11e2bbc..f7b9af4c8fe 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -400,7 +400,10 @@ class CoverCapabilities(AlexaEntity): def interfaces(self): """Yield the supported interfaces.""" - yield AlexaPowerController(self.entity) + device_class = self.entity.attributes.get(ATTR_DEVICE_CLASS) + if device_class != cover.DEVICE_CLASS_GARAGE: + yield AlexaPowerController(self.entity) + supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if supported & cover.SUPPORT_SET_POSITION: yield AlexaRangeController( diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index a714b69461c..6190403f20e 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -2592,33 +2592,25 @@ async def test_mode_unsupported_domain(hass): assert msg["payload"]["type"] == "INVALID_DIRECTIVE" -async def test_cover(hass): - """Test garage cover discovery and powerController.""" +async def test_cover_garage_door(hass): + """Test garage door cover discovery.""" device = ( - "cover.test", + "cover.test_garage_door", "off", { - "friendly_name": "Test cover", + "friendly_name": "Test cover garage door", "supported_features": 3, "device_class": "garage", }, ) appliance = await discovery_test(device, hass) - assert appliance["endpointId"] == "cover#test" + assert appliance["endpointId"] == "cover#test_garage_door" assert appliance["displayCategories"][0] == "GARAGE_DOOR" - assert appliance["friendlyName"] == "Test cover" + assert appliance["friendlyName"] == "Test cover garage door" assert_endpoint_capabilities( - appliance, - "Alexa.ModeController", - "Alexa.PowerController", - "Alexa.EndpointHealth", - "Alexa", - ) - - await assert_power_controller_works( - "cover#test", "cover.open_cover", "cover.close_cover", hass + appliance, "Alexa.ModeController", "Alexa.EndpointHealth", "Alexa" ) From e1d69645894dd22a095eb161b885713f8c09de86 Mon Sep 17 00:00:00 2001 From: lewei50 Date: Mon, 9 Mar 2020 23:39:39 +0800 Subject: [PATCH 291/416] Add IamMeter integration (#30465) * Update .coveragerc * Update requirements_all.txt * Create manifest.json * Add files via upload * add codeowner * add codeowner. * Update sensor.py * Update sensor.py * remove unused import. * Update manifest.json * Update sensor.py * modify requirements_all.txt. * order imports. * Update sensor.py * Use DataUpdateCoordinator rewrite code * set should_poll to False * remove unused code 'serial'. * add available prop * Update homeassistant/components/iammeter/sensor.py Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> * Update homeassistant/components/iammeter/sensor.py Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> * Update homeassistant/components/iammeter/sensor.py Co-Authored-By: springstan <46536646+springstan@users.noreply.github.com> * Update homeassistant/components/iammeter/sensor.py Co-Authored-By: Martin Hjelmare * Update homeassistant/components/iammeter/sensor.py Co-Authored-By: Martin Hjelmare * Update homeassistant/components/iammeter/sensor.py Co-Authored-By: Martin Hjelmare * Update sensor.py * Update sensor.py Co-authored-by: springstan <46536646+springstan@users.noreply.github.com> Co-authored-by: Martin Hjelmare --- .coveragerc | 1 + CODEOWNERS | 1 + homeassistant/components/iammeter/__init__.py | 1 + .../components/iammeter/manifest.json | 12 ++ homeassistant/components/iammeter/sensor.py | 130 ++++++++++++++++++ requirements_all.txt | 3 + 6 files changed, 148 insertions(+) create mode 100644 homeassistant/components/iammeter/__init__.py create mode 100644 homeassistant/components/iammeter/manifest.json create mode 100644 homeassistant/components/iammeter/sensor.py diff --git a/.coveragerc b/.coveragerc index eef39ded2bf..48701a8563a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -316,6 +316,7 @@ omit = homeassistant/components/hydrawise/* homeassistant/components/hyperion/light.py homeassistant/components/ialarm/alarm_control_panel.py + homeassistant/components/iammeter/sensor.py homeassistant/components/iaqualink/binary_sensor.py homeassistant/components/iaqualink/climate.py homeassistant/components/iaqualink/light.py diff --git a/CODEOWNERS b/CODEOWNERS index d76b0abc8a8..89417c4ca56 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -163,6 +163,7 @@ homeassistant/components/http/* @home-assistant/core homeassistant/components/huawei_lte/* @scop homeassistant/components/huawei_router/* @abmantis homeassistant/components/hue/* @balloob +homeassistant/components/iammeter/* @lewei50 homeassistant/components/iaqualink/* @flz homeassistant/components/icloud/* @Quentame homeassistant/components/ign_sismologia/* @exxamalte diff --git a/homeassistant/components/iammeter/__init__.py b/homeassistant/components/iammeter/__init__.py new file mode 100644 index 00000000000..b53cc35197c --- /dev/null +++ b/homeassistant/components/iammeter/__init__.py @@ -0,0 +1 @@ +"""Support for IamMeter Devices.""" diff --git a/homeassistant/components/iammeter/manifest.json b/homeassistant/components/iammeter/manifest.json new file mode 100644 index 00000000000..e1b021c8ce0 --- /dev/null +++ b/homeassistant/components/iammeter/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "iammeter", + "name": "IamMeter", + "documentation": "https://www.home-assistant.io/integrations/iammeter", + "codeowners": [ + "@lewei50" + ], + "requirements": [ + "iammeter==0.1.3" + ], + "dependencies": [] +} diff --git a/homeassistant/components/iammeter/sensor.py b/homeassistant/components/iammeter/sensor.py new file mode 100644 index 00000000000..b043a6e9832 --- /dev/null +++ b/homeassistant/components/iammeter/sensor.py @@ -0,0 +1,130 @@ +"""Support for iammeter via local API.""" +import asyncio +from datetime import timedelta +import logging + +import async_timeout +from iammeter import real_time_api +from iammeter.power_meter import IamMeterError +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers import debounce +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_PORT = 80 +DEFAULT_DEVICE_NAME = "IamMeter" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_DEVICE_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) + +SCAN_INTERVAL = timedelta(seconds=30) +PLATFORM_TIMEOUT = 8 + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Platform setup.""" + config_host = config[CONF_HOST] + config_port = config[CONF_PORT] + config_name = config[CONF_NAME] + try: + with async_timeout.timeout(PLATFORM_TIMEOUT): + api = await real_time_api(config_host, config_port) + except (IamMeterError, asyncio.TimeoutError): + _LOGGER.error("Device is not ready") + raise PlatformNotReady + + async def async_update_data(): + try: + with async_timeout.timeout(PLATFORM_TIMEOUT): + return await api.get_data() + except (IamMeterError, asyncio.TimeoutError): + raise UpdateFailed + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DEFAULT_DEVICE_NAME, + update_method=async_update_data, + update_interval=SCAN_INTERVAL, + request_refresh_debouncer=debounce.Debouncer( + hass, _LOGGER, cooldown=0.3, immediate=True + ), + ) + await coordinator.async_refresh() + entities = [] + for sensor_name, (row, idx, unit) in api.iammeter.sensor_map().items(): + serial_number = api.iammeter.serial_number + uid = f"{serial_number}-{row}-{idx}" + entities.append(IamMeter(coordinator, uid, sensor_name, unit, config_name)) + async_add_entities(entities) + + +class IamMeter(Entity): + """Class for a sensor.""" + + def __init__(self, coordinator, uid, sensor_name, unit, dev_name): + """Initialize an iammeter sensor.""" + self.coordinator = coordinator + self.uid = uid + self.sensor_name = sensor_name + self.unit = unit + self.dev_name = dev_name + + @property + def state(self): + """Return the state of the sensor.""" + return self.coordinator.data.data[self.sensor_name] + + @property + def unique_id(self): + """Return unique id.""" + return self.uid + + @property + def name(self): + """Name of this iammeter attribute.""" + return f"{self.dev_name} {self.sensor_name}" + + @property + def icon(self): + """Icon for each sensor.""" + return "mdi:flash" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self.unit + + @property + def should_poll(self): + """Poll needed.""" + return False + + @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/requirements_all.txt b/requirements_all.txt index 58d5055c15f..3c37befe796 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -719,6 +719,9 @@ hydrawiser==0.1.1 # homeassistant.components.htu21d # i2csense==0.0.4 +# homeassistant.components.iammeter +iammeter==0.1.3 + # homeassistant.components.iaqualink iaqualink==0.3.1 From 22b56906078c4f17bacb6b07c9f24e26e842edd4 Mon Sep 17 00:00:00 2001 From: Thomas Hollstegge Date: Mon, 9 Mar 2020 16:58:47 +0100 Subject: [PATCH 292/416] Alexa: Support vacuums without turn_on/turn_off feature (#32570) * Alexa: Support vacuums without turn_on/turn_off feature --- .../components/alexa/capabilities.py | 2 + homeassistant/components/alexa/entities.py | 7 +- homeassistant/components/alexa/handlers.py | 11 ++ tests/components/alexa/test_smart_home.py | 102 ++++++++++++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 8b38fe4d298..4f675fa8375 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -364,6 +364,8 @@ class AlexaPowerController(AlexaCapability): if self.entity.domain == climate.DOMAIN: is_on = self.entity.state != climate.HVAC_MODE_OFF + elif self.entity.domain == vacuum.DOMAIN: + is_on = self.entity.state == vacuum.STATE_CLEANING else: is_on = self.entity.state != STATE_OFF diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index f7b9af4c8fe..df3be7ee85e 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -741,8 +741,11 @@ class VacuumCapabilities(AlexaEntity): def interfaces(self): """Yield the supported interfaces.""" supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - if (supported & vacuum.SUPPORT_TURN_ON) and ( - supported & vacuum.SUPPORT_TURN_OFF + if ( + (supported & vacuum.SUPPORT_TURN_ON) or (supported & vacuum.SUPPORT_START) + ) and ( + (supported & vacuum.SUPPORT_TURN_OFF) + or (supported & vacuum.SUPPORT_RETURN_HOME) ): yield AlexaPowerController(self.entity) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index b771a8fc50c..a77051cb03b 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -121,6 +121,10 @@ async def async_api_turn_on(hass, config, directive, context): service = SERVICE_TURN_ON if domain == cover.DOMAIN: service = cover.SERVICE_OPEN_COVER + elif domain == vacuum.DOMAIN: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if not supported & vacuum.SUPPORT_TURN_ON and supported & vacuum.SUPPORT_START: + service = vacuum.SERVICE_START elif domain == media_player.DOMAIN: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) power_features = media_player.SUPPORT_TURN_ON | media_player.SUPPORT_TURN_OFF @@ -149,6 +153,13 @@ async def async_api_turn_off(hass, config, directive, context): service = SERVICE_TURN_OFF if entity.domain == cover.DOMAIN: service = cover.SERVICE_CLOSE_COVER + elif domain == vacuum.DOMAIN: + supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) + if ( + not supported & vacuum.SUPPORT_TURN_OFF + and supported & vacuum.SUPPORT_RETURN_HOME + ): + service = vacuum.SERVICE_RETURN_TO_BASE elif domain == media_player.DOMAIN: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) power_features = media_player.SUPPORT_TURN_ON | media_player.SUPPORT_TURN_OFF diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index 6190403f20e..f723832938a 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -3386,6 +3386,7 @@ async def test_vacuum_discovery(hass): | vacuum.SUPPORT_TURN_OFF | vacuum.SUPPORT_START | vacuum.SUPPORT_STOP + | vacuum.SUPPORT_RETURN_HOME | vacuum.SUPPORT_PAUSE, }, ) @@ -3403,6 +3404,17 @@ async def test_vacuum_discovery(hass): "Alexa", ) + properties = await reported_properties(hass, "vacuum#test_1") + properties.assert_equal("Alexa.PowerController", "powerState", "OFF") + + await assert_request_calls_service( + "Alexa.PowerController", "TurnOn", "vacuum#test_1", "vacuum.turn_on", hass, + ) + + await assert_request_calls_service( + "Alexa.PowerController", "TurnOff", "vacuum#test_1", "vacuum.turn_off", hass, + ) + async def test_vacuum_fan_speed(hass): """Test vacuum fan speed with rangeController.""" @@ -3597,3 +3609,93 @@ async def test_vacuum_resume(hass): "vacuum.start_pause", hass, ) + + +async def test_vacuum_discovery_no_turn_on(hass): + """Test vacuum discovery for vacuums without turn_on.""" + device = ( + "vacuum.test_5", + "cleaning", + { + "friendly_name": "Test vacuum 5", + "supported_features": vacuum.SUPPORT_TURN_OFF + | vacuum.SUPPORT_START + | vacuum.SUPPORT_RETURN_HOME, + }, + ) + appliance = await discovery_test(device, hass) + + assert_endpoint_capabilities( + appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa", + ) + + properties = await reported_properties(hass, "vacuum#test_5") + properties.assert_equal("Alexa.PowerController", "powerState", "ON") + + await assert_request_calls_service( + "Alexa.PowerController", "TurnOn", "vacuum#test_5", "vacuum.start", hass, + ) + + await assert_request_calls_service( + "Alexa.PowerController", "TurnOff", "vacuum#test_5", "vacuum.turn_off", hass, + ) + + +async def test_vacuum_discovery_no_turn_off(hass): + """Test vacuum discovery for vacuums without turn_off.""" + device = ( + "vacuum.test_6", + "cleaning", + { + "friendly_name": "Test vacuum 6", + "supported_features": vacuum.SUPPORT_TURN_ON + | vacuum.SUPPORT_START + | vacuum.SUPPORT_RETURN_HOME, + }, + ) + appliance = await discovery_test(device, hass) + + assert_endpoint_capabilities( + appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa", + ) + + await assert_request_calls_service( + "Alexa.PowerController", "TurnOn", "vacuum#test_6", "vacuum.turn_on", hass, + ) + + await assert_request_calls_service( + "Alexa.PowerController", + "TurnOff", + "vacuum#test_6", + "vacuum.return_to_base", + hass, + ) + + +async def test_vacuum_discovery_no_turn_on_or_off(hass): + """Test vacuum discovery vacuums without on or off.""" + device = ( + "vacuum.test_7", + "cleaning", + { + "friendly_name": "Test vacuum 7", + "supported_features": vacuum.SUPPORT_START | vacuum.SUPPORT_RETURN_HOME, + }, + ) + appliance = await discovery_test(device, hass) + + assert_endpoint_capabilities( + appliance, "Alexa.PowerController", "Alexa.EndpointHealth", "Alexa", + ) + + await assert_request_calls_service( + "Alexa.PowerController", "TurnOn", "vacuum#test_7", "vacuum.start", hass, + ) + + await assert_request_calls_service( + "Alexa.PowerController", + "TurnOff", + "vacuum#test_7", + "vacuum.return_to_base", + hass, + ) From 19faf06ce724e243d8e37c451af03d04128547a3 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 9 Mar 2020 16:19:17 +0000 Subject: [PATCH 293/416] homekit_controller fixes from testing with an LG TV: (#32610) * Bump aiohomekit to get better reconnection handling and cleaner shutdowns. * Read the ACTIVE characteristic and set ok/problem state Also gets test coverage to 100%. --- .../homekit_controller/manifest.json | 2 +- .../homekit_controller/media_player.py | 20 +++++++++++++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../specific_devices/test_lg_tv.py | 4 ++++ .../homekit_controller/test_media_player.py | 19 ++++++++++++++++++ 6 files changed, 42 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 4cfc642bf8c..7582bc10ae5 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit[IP]==0.2.21"], + "requirements": ["aiohomekit[IP]==0.2.24"], "dependencies": [], "zeroconf": ["_hap._tcp.local."], "codeowners": ["@Jc2k"] diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py index 38817712def..09693f3e8a8 100644 --- a/homeassistant/components/homekit_controller/media_player.py +++ b/homeassistant/components/homekit_controller/media_player.py @@ -17,7 +17,13 @@ from homeassistant.components.media_player.const import ( SUPPORT_SELECT_SOURCE, SUPPORT_STOP, ) -from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING +from homeassistant.const import ( + STATE_IDLE, + STATE_OK, + STATE_PAUSED, + STATE_PLAYING, + STATE_PROBLEM, +) from homeassistant.core import callback from . import KNOWN_DEVICES, HomeKitEntity @@ -62,6 +68,7 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice): def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" return [ + CharacteristicsTypes.ACTIVE, CharacteristicsTypes.CURRENT_MEDIA_STATE, CharacteristicsTypes.TARGET_MEDIA_STATE, CharacteristicsTypes.REMOTE_KEY, @@ -143,10 +150,15 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice): @property def state(self): """State of the tv.""" + active = self.get_hk_char_value(CharacteristicsTypes.ACTIVE) + if not active: + return STATE_PROBLEM + homekit_state = self.get_hk_char_value(CharacteristicsTypes.CURRENT_MEDIA_STATE) - if homekit_state is None: - return None - return HK_TO_HA_STATE[homekit_state] + if homekit_state is not None: + return HK_TO_HA_STATE[homekit_state] + + return STATE_OK async def async_media_play(self): """Send play command.""" diff --git a/requirements_all.txt b/requirements_all.txt index 3c37befe796..4de51512bd1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -163,7 +163,7 @@ aioftp==0.12.0 aioharmony==0.1.13 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.21 +aiohomekit[IP]==0.2.24 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 356b5a7530a..81ee9addeac 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -62,7 +62,7 @@ aiobotocore==0.11.1 aioesphomeapi==2.6.1 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.21 +aiohomekit[IP]==0.2.24 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/tests/components/homekit_controller/specific_devices/test_lg_tv.py b/tests/components/homekit_controller/specific_devices/test_lg_tv.py index 3ffd906213b..acebac95006 100644 --- a/tests/components/homekit_controller/specific_devices/test_lg_tv.py +++ b/tests/components/homekit_controller/specific_devices/test_lg_tv.py @@ -50,6 +50,10 @@ async def test_lg_tv(hass): SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE ) + # The LG TV doesn't (at least at this patch level) report its media state via + # CURRENT_MEDIA_STATE. Therefore "ok" is the best we can say. + assert state.state == "ok" + device_registry = await hass.helpers.device_registry.async_get_registry() device = device_registry.async_get(entry.device_id) diff --git a/tests/components/homekit_controller/test_media_player.py b/tests/components/homekit_controller/test_media_player.py index 09798c218a8..44c53af02da 100644 --- a/tests/components/homekit_controller/test_media_player.py +++ b/tests/components/homekit_controller/test_media_player.py @@ -4,6 +4,7 @@ from aiohomekit.model.characteristics import ( CharacteristicsTypes, ) from aiohomekit.model.services import ServicesTypes +import pytest from tests.components.homekit_controller.common import setup_test_component @@ -21,6 +22,8 @@ def create_tv_service(accessory): """ tv_service = accessory.add_service(ServicesTypes.TELEVISION) + tv_service.add_char(CharacteristicsTypes.ACTIVE, value=True) + cur_state = tv_service.add_char(CharacteristicsTypes.CURRENT_MEDIA_STATE) cur_state.value = 0 @@ -245,3 +248,19 @@ async def test_tv_set_source(hass, utcnow): state = await helper.poll_and_get_state() assert state.attributes["source"] == "HDMI 2" + + +async def test_tv_set_source_fail(hass, utcnow): + """Test that we can set the input source of a HomeKit TV.""" + helper = await setup_test_component(hass, create_tv_service) + + with pytest.raises(ValueError): + await hass.services.async_call( + "media_player", + "select_source", + {"entity_id": "media_player.testdevice", "source": "HDMI 999"}, + blocking=True, + ) + + state = await helper.poll_and_get_state() + assert state.attributes["source"] == "HDMI 1" From 988b400a9cf7f5ba90c44f9a2bf8f1153986ce18 Mon Sep 17 00:00:00 2001 From: David Nielsen Date: Mon, 9 Mar 2020 22:08:31 +0530 Subject: [PATCH 294/416] Fix TypeError Exception in AlexaSpeaker (#32318) * alexa/capabilities.py: Fix TypeError Exception - Remove division by zero try/catch -- there is no division - Handle TypeError exception when current_volume = None - Simplify math and return logic * Add test for Alexa.Speaker's valid volume range --- .../components/alexa/capabilities.py | 8 ++--- tests/components/alexa/test_capabilities.py | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) mode change 100644 => 100755 tests/components/alexa/test_capabilities.py diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 4f675fa8375..25696ec116a 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1,6 +1,5 @@ """Alexa capabilities.""" import logging -import math from homeassistant.components import ( cover, @@ -671,11 +670,8 @@ class AlexaSpeaker(AlexaCapability): current_level = self.entity.attributes.get( media_player.ATTR_MEDIA_VOLUME_LEVEL ) - try: - current = math.floor(int(current_level * 100)) - except ZeroDivisionError: - current = 0 - return current + if current_level is not None: + return round(float(current_level) * 100) if name == "muted": return bool( diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py old mode 100644 new mode 100755 index f8f4f5f4697..678a8e74027 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -8,6 +8,8 @@ from homeassistant.components.media_player.const import ( SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_STOP, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, ) from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -684,6 +686,36 @@ async def test_report_playback_state(hass): ) +async def test_report_speaker_volume(hass): + """Test Speaker reports volume correctly.""" + hass.states.async_set( + "media_player.test_speaker", + "on", + { + "friendly_name": "Test media player speaker", + "supported_features": SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET, + "volume_level": None, + "device_class": "speaker", + }, + ) + properties = await reported_properties(hass, "media_player.test_speaker") + properties.assert_not_has_property("Alexa.Speaker", "volume") + + for good_value in range(101): + hass.states.async_set( + "media_player.test_speaker", + "on", + { + "friendly_name": "Test media player speaker", + "supported_features": SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET, + "volume_level": good_value / 100, + "device_class": "speaker", + }, + ) + properties = await reported_properties(hass, "media_player.test_speaker") + properties.assert_equal("Alexa.Speaker", "volume", good_value) + + async def test_report_image_processing(hass): """Test EventDetectionSensor implements humanPresenceDetectionState property.""" hass.states.async_set( From 743833e5f3677288e9a51e6f317852b84f6cb116 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Mon, 9 Mar 2020 12:39:41 -0400 Subject: [PATCH 295/416] Bump up ZHA dependencies. (#32609) * Bump up ZHA dependencies. * Update tests to match library changes. --- homeassistant/components/zha/manifest.json | 8 ++++---- requirements_all.txt | 8 ++++---- requirements_test_all.txt | 8 ++++---- tests/components/zha/test_cover.py | 14 ++++++++++---- tests/components/zha/test_discover.py | 1 + tests/components/zha/test_light.py | 14 +++++++++----- tests/components/zha/test_switch.py | 4 ++-- 7 files changed, 34 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 235a3872ec0..fec85625ee4 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -4,12 +4,12 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ - "bellows-homeassistant==0.13.2", - "zha-quirks==0.0.35", + "bellows-homeassistant==0.14.0", + "zha-quirks==0.0.36", "zigpy-cc==0.1.0", "zigpy-deconz==0.7.0", - "zigpy-homeassistant==0.15.0", - "zigpy-xbee-homeassistant==0.9.0", + "zigpy-homeassistant==0.16.0", + "zigpy-xbee-homeassistant==0.10.0", "zigpy-zigate==0.5.1" ], "dependencies": [], diff --git a/requirements_all.txt b/requirements_all.txt index 4de51512bd1..77e1a240241 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -309,7 +309,7 @@ beautifulsoup4==4.8.2 beewi_smartclim==0.0.7 # homeassistant.components.zha -bellows-homeassistant==0.13.2 +bellows-homeassistant==0.14.0 # homeassistant.components.bmw_connected_drive bimmer_connected==0.7.1 @@ -2152,7 +2152,7 @@ zengge==0.2 zeroconf==0.24.5 # homeassistant.components.zha -zha-quirks==0.0.35 +zha-quirks==0.0.36 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2167,10 +2167,10 @@ zigpy-cc==0.1.0 zigpy-deconz==0.7.0 # homeassistant.components.zha -zigpy-homeassistant==0.15.0 +zigpy-homeassistant==0.16.0 # homeassistant.components.zha -zigpy-xbee-homeassistant==0.9.0 +zigpy-xbee-homeassistant==0.10.0 # homeassistant.components.zha zigpy-zigate==0.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 81ee9addeac..8f5e2fb5509 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -118,7 +118,7 @@ av==6.1.2 axis==25 # homeassistant.components.zha -bellows-homeassistant==0.13.2 +bellows-homeassistant==0.14.0 # homeassistant.components.bom bomradarloop==0.1.3 @@ -741,7 +741,7 @@ yahooweather==0.10 zeroconf==0.24.5 # homeassistant.components.zha -zha-quirks==0.0.35 +zha-quirks==0.0.36 # homeassistant.components.zha zigpy-cc==0.1.0 @@ -750,10 +750,10 @@ zigpy-cc==0.1.0 zigpy-deconz==0.7.0 # homeassistant.components.zha -zigpy-homeassistant==0.15.0 +zigpy-homeassistant==0.16.0 # homeassistant.components.zha -zigpy-xbee-homeassistant==0.9.0 +zigpy-xbee-homeassistant==0.10.0 # homeassistant.components.zha zigpy-zigate==0.5.1 diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index 4fbabf4485a..3ece16d8116 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -88,7 +88,7 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device): ) assert cluster.request.call_count == 1 assert cluster.request.call_args == call( - False, 0x1, (), expect_reply=True, manufacturer=None + False, 0x1, (), expect_reply=True, manufacturer=None, tsn=None ) # open from UI @@ -100,7 +100,7 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device): ) assert cluster.request.call_count == 1 assert cluster.request.call_args == call( - False, 0x0, (), expect_reply=True, manufacturer=None + False, 0x0, (), expect_reply=True, manufacturer=None, tsn=None ) # set position UI @@ -115,7 +115,13 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device): ) assert cluster.request.call_count == 1 assert cluster.request.call_args == call( - False, 0x5, (zigpy.types.uint8_t,), 53, expect_reply=True, manufacturer=None + False, + 0x5, + (zigpy.types.uint8_t,), + 53, + expect_reply=True, + manufacturer=None, + tsn=None, ) # stop from UI @@ -127,7 +133,7 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device): ) assert cluster.request.call_count == 1 assert cluster.request.call_args == call( - False, 0x2, (), expect_reply=True, manufacturer=None + False, 0x2, (), expect_reply=True, manufacturer=None, tsn=None ) # test rejoin diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index 9515de32fcd..e1733ac44bd 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -107,6 +107,7 @@ async def test_devices( 0, expect_reply=True, manufacturer=None, + tsn=None, ) event_channels = { diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 726def23fc3..fba57a0020e 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -181,7 +181,7 @@ async def async_test_on_off_from_hass(hass, cluster, entity_id): assert cluster.request.call_count == 1 assert cluster.request.await_count == 1 assert cluster.request.call_args == call( - False, ON, (), expect_reply=True, manufacturer=None + False, ON, (), expect_reply=True, manufacturer=None, tsn=None ) await async_test_off_from_hass(hass, cluster, entity_id) @@ -198,7 +198,7 @@ async def async_test_off_from_hass(hass, cluster, entity_id): assert cluster.request.call_count == 1 assert cluster.request.await_count == 1 assert cluster.request.call_args == call( - False, OFF, (), expect_reply=True, manufacturer=None + False, OFF, (), expect_reply=True, manufacturer=None, tsn=None ) @@ -208,6 +208,7 @@ async def async_test_level_on_off_from_hass( """Test on off functionality from hass.""" on_off_cluster.request.reset_mock() + level_cluster.request.reset_mock() # turn on via UI await hass.services.async_call( DOMAIN, "turn_on", {"entity_id": entity_id}, blocking=True @@ -217,7 +218,7 @@ async def async_test_level_on_off_from_hass( assert level_cluster.request.call_count == 0 assert level_cluster.request.await_count == 0 assert on_off_cluster.request.call_args == call( - False, ON, (), expect_reply=True, manufacturer=None + False, ON, (), expect_reply=True, manufacturer=None, tsn=None ) on_off_cluster.request.reset_mock() level_cluster.request.reset_mock() @@ -230,7 +231,7 @@ async def async_test_level_on_off_from_hass( assert level_cluster.request.call_count == 1 assert level_cluster.request.await_count == 1 assert on_off_cluster.request.call_args == call( - False, ON, (), expect_reply=True, manufacturer=None + False, ON, (), expect_reply=True, manufacturer=None, tsn=None ) assert level_cluster.request.call_args == call( False, @@ -240,6 +241,7 @@ async def async_test_level_on_off_from_hass( 100.0, expect_reply=True, manufacturer=None, + tsn=None, ) on_off_cluster.request.reset_mock() level_cluster.request.reset_mock() @@ -252,7 +254,7 @@ async def async_test_level_on_off_from_hass( assert level_cluster.request.call_count == 1 assert level_cluster.request.await_count == 1 assert on_off_cluster.request.call_args == call( - False, ON, (), expect_reply=True, manufacturer=None + False, ON, (), expect_reply=True, manufacturer=None, tsn=None ) assert level_cluster.request.call_args == call( False, @@ -262,6 +264,7 @@ async def async_test_level_on_off_from_hass( 0, expect_reply=True, manufacturer=None, + tsn=None, ) on_off_cluster.request.reset_mock() level_cluster.request.reset_mock() @@ -299,4 +302,5 @@ async def async_test_flash_from_hass(hass, cluster, entity_id, flash): 0, expect_reply=True, manufacturer=None, + tsn=None, ) diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index a088283834b..22ceb629009 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -76,7 +76,7 @@ async def test_switch(hass, zha_device_joined_restored, zigpy_device): ) assert len(cluster.request.mock_calls) == 1 assert cluster.request.call_args == call( - False, ON, (), expect_reply=True, manufacturer=None + False, ON, (), expect_reply=True, manufacturer=None, tsn=None ) # turn off from HA @@ -90,7 +90,7 @@ async def test_switch(hass, zha_device_joined_restored, zigpy_device): ) assert len(cluster.request.mock_calls) == 1 assert cluster.request.call_args == call( - False, OFF, (), expect_reply=True, manufacturer=None + False, OFF, (), expect_reply=True, manufacturer=None, tsn=None ) # test joining a new switch to the network and HA From 4bb9f1800dd6d01ed58b4af1ba9114876927a386 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 9 Mar 2020 17:40:00 +0100 Subject: [PATCH 296/416] Deduplicate MQTT mixin tests (#32563) * Deduplicate MQTT mixin tests * Remove test of not yet merged function --- .../components/mqtt/vacuum/schema_legacy.py | 4 +- .../components/mqtt/vacuum/schema_state.py | 4 +- tests/components/mqtt/common.py | 221 +++++++++++ .../mqtt/test_alarm_control_panel.py | 336 +++++------------ tests/components/mqtt/test_binary_sensor.py | 316 +++++----------- tests/components/mqtt/test_climate.py | 321 +++++----------- tests/components/mqtt/test_cover.py | 340 ++++++----------- tests/components/mqtt/test_fan.py | 344 ++++++----------- tests/components/mqtt/test_legacy_vacuum.py | 307 ++++++---------- tests/components/mqtt/test_light.py | 318 +++++----------- tests/components/mqtt/test_light_json.py | 329 +++++------------ tests/components/mqtt/test_light_template.py | 345 ++++++------------ tests/components/mqtt/test_lock.py | 322 +++++----------- tests/components/mqtt/test_sensor.py | 308 +++++----------- tests/components/mqtt/test_state_vacuum.py | 314 ++++++---------- tests/components/mqtt/test_switch.py | 326 +++++------------ 16 files changed, 1499 insertions(+), 2956 deletions(-) create mode 100644 tests/components/mqtt/common.py diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index eff7cc1b039..7679b97d62e 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -266,7 +266,9 @@ class MqttVacuum( async def async_will_remove_from_hass(self): """Unsubscribe when removed.""" - await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + self._sub_state = await subscription.async_unsubscribe_topics( + self.hass, self._sub_state + ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) await MqttDiscoveryUpdate.async_will_remove_from_hass(self) diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index f9bcc7e845e..126a2432cb0 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -231,7 +231,9 @@ class MqttStateVacuum( async def async_will_remove_from_hass(self): """Unsubscribe when removed.""" - await subscription.async_unsubscribe_topics(self.hass, self._sub_state) + self._sub_state = await subscription.async_unsubscribe_topics( + self.hass, self._sub_state + ) await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) await MqttDiscoveryUpdate.async_will_remove_from_hass(self) diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py new file mode 100644 index 00000000000..f8c57101445 --- /dev/null +++ b/tests/components/mqtt/common.py @@ -0,0 +1,221 @@ +"""Common test objects.""" +import json +from unittest.mock import ANY + +from homeassistant.components import mqtt +from homeassistant.components.mqtt.discovery import async_start + +from tests.common import ( + MockConfigEntry, + async_fire_mqtt_message, + async_mock_mqtt_component, + async_setup_component, + mock_registry, +) + + +async def help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, domain, config +): + """Test the setting of attribute via MQTT with JSON payload.""" + assert await async_setup_component(hass, domain, config,) + + async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') + state = hass.states.get(f"{domain}.test") + + assert state.attributes.get("val") == "100" + + +async def help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, domain, config +): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, domain, config,) + + async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') + state = hass.states.get(f"{domain}.test") + + assert state.attributes.get("val") is None + assert "JSON result was not a dictionary" in caplog.text + + +async def help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, domain, config +): + """Test attributes get extracted from a JSON result.""" + assert await async_setup_component(hass, domain, config,) + + async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") + + state = hass.states.get(f"{domain}.test") + assert state.attributes.get("val") is None + assert "Erroneous JSON: This is not JSON" in caplog.text + + +async def help_test_discovery_update_attr( + hass, mqtt_mock, caplog, domain, data1, data2 +): + """Test update of discovered MQTTAttributes.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, "homeassistant", {}, entry) + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1) + await hass.async_block_till_done() + async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }') + state = hass.states.get(f"{domain}.beer") + assert state.attributes.get("val") == "100" + + # Change json_attributes_topic + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data2) + await hass.async_block_till_done() + + # Verify we are no longer subscribing to the old topic + async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') + state = hass.states.get(f"{domain}.beer") + assert state.attributes.get("val") == "100" + + # Verify we are subscribing to the new topic + async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') + state = hass.states.get(f"{domain}.beer") + assert state.attributes.get("val") == "75" + + +async def help_test_unique_id(hass, domain, config): + """Test unique id option only creates one alarm per unique_id.""" + await async_mock_mqtt_component(hass) + assert await async_setup_component(hass, domain, config,) + async_fire_mqtt_message(hass, "test-topic", "payload") + assert len(hass.states.async_entity_ids(domain)) == 1 + + +async def help_test_discovery_removal(hass, mqtt_mock, caplog, domain, data): + """Test removal of discovered component.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, "homeassistant", {}, entry) + + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) + 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", "") + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.beer") + assert state is None + + +async def help_test_discovery_update(hass, mqtt_mock, caplog, domain, data1, data2): + """Test update of discovered component.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, "homeassistant", {}, entry) + + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", 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) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.beer") + assert state is not None + assert state.name == "Milk" + + state = hass.states.get(f"{domain}.milk") + assert state is None + + +async def help_test_discovery_broken(hass, mqtt_mock, caplog, domain, data1, data2): + """Test handling of bad discovery message.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + await async_start(hass, "homeassistant", {}, entry) + + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.beer") + assert state is None + + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data2) + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.milk") + assert state is not None + assert state.name == "Milk" + state = hass.states.get(f"{domain}.beer") + assert state is None + + +async def help_test_entity_device_info_with_identifier(hass, mqtt_mock, domain, data): + """Test MQTT alarm control panel device registry integration.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device({("mqtt", "helloworld")}, set()) + assert device is not None + assert device.identifiers == {("mqtt", "helloworld")} + assert device.connections == {("mac", "02:5b:26:a8:dc:12")} + assert device.manufacturer == "Whatever" + assert device.name == "Beer" + assert device.model == "Glass" + assert device.sw_version == "0.1-beta" + + +async def help_test_entity_device_info_update(hass, mqtt_mock, domain, config): + """Test device registry update.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + data = json.dumps(config) + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device({("mqtt", "helloworld")}, set()) + assert device is not None + assert device.name == "Beer" + + config["device"]["name"] = "Milk" + data = json.dumps(config) + async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device({("mqtt", "helloworld")}, set()) + assert device is not None + assert device.name == "Milk" + + +async def help_test_entity_id_update(hass, mqtt_mock, domain, config): + """Test MQTT subscriptions are managed when entity_id is updated.""" + registry = mock_registry(hass, {}) + mock_mqtt = await async_mock_mqtt_component(hass) + assert await async_setup_component(hass, domain, config,) + + state = hass.states.get(f"{domain}.beer") + assert state is not None + assert mock_mqtt.async_subscribe.call_count == 2 + mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") + mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") + mock_mqtt.async_subscribe.reset_mock() + + registry.async_update_entity(f"{domain}.beer", new_entity_id=f"{domain}.milk") + await hass.async_block_till_done() + + state = hass.states.get(f"{domain}.beer") + assert state is None + + state = hass.states.get(f"{domain}.milk") + assert state is not None + assert mock_mqtt.async_subscribe.call_count == 2 + mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") + mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 6ec28b04e4d..6a14f2ebbda 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -1,9 +1,7 @@ """The tests the MQTT alarm control panel component.""" import json -from unittest.mock import ANY -from homeassistant.components import alarm_control_panel, mqtt -from homeassistant.components.mqtt.discovery import async_start +from homeassistant.components import alarm_control_panel from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, @@ -15,13 +13,24 @@ from homeassistant.const import ( STATE_UNKNOWN, ) +from .common import ( + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_entity_device_info_update, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, +) + from tests.common import ( - MockConfigEntry, assert_setup_component, async_fire_mqtt_message, - async_mock_mqtt_component, async_setup_component, - mock_registry, ) from tests.components.alarm_control_panel import common @@ -489,25 +498,19 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + alarm_control_panel.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "state_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, alarm_control_panel.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') - state = hass.states.get("alarm_control_panel.test") - - assert state.attributes.get("val") == "100" - async def test_update_state_via_state_topic_template(hass, mqtt_mock): """Test updating with template_value via state topic.""" @@ -541,54 +544,38 @@ async def test_update_state_via_state_topic_template(hass, mqtt_mock): async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + alarm_control_panel.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "state_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') - state = hass.states.get("alarm_control_panel.test") - - assert state.attributes.get("val") is None - assert "JSON result was not a dictionary" in caplog.text - async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + alarm_control_panel.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "state_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") - - state = hass.states.get("alarm_control_panel.test") - assert state.attributes.get("val") is None - assert "Erroneous JSON: This is not JSON" in caplog.text - async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = ( '{ "name": "Beer",' ' "command_topic": "test_topic",' @@ -599,86 +586,48 @@ async def test_discovery_update_attr(hass, mqtt_mock, caplog): ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) - async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data1) - await hass.async_block_till_done() - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }') - state = hass.states.get("alarm_control_panel.beer") - assert state.attributes.get("val") == "100" - - # Change json_attributes_topic - async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data2) - await hass.async_block_till_done() - - # Verify we are no longer subscribing to the old topic - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') - state = hass.states.get("alarm_control_panel.beer") - assert state.attributes.get("val") == "100" - - # Verify we are subscribing to the new topic - async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') - state = hass.states.get("alarm_control_panel.beer") - assert state.attributes.get("val") == "75" + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data1, data2 + ) async def test_unique_id(hass): """Test unique id option only creates one alarm per unique_id.""" - await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: [ - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "platform": "mqtt", - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - }, - ) - async_fire_mqtt_message(hass, "test-topic", "payload") - assert len(hass.states.async_entity_ids(alarm_control_panel.DOMAIN)) == 1 + config = { + alarm_control_panel.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, alarm_control_panel.DOMAIN, config) async def test_discovery_removal_alarm(hass, mqtt_mock, caplog): """Test removal of discovered alarm_control_panel.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data = ( '{ "name": "Beer",' ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - - async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", "") - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.beer") - assert state is None + await help_test_discovery_removal( + hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data + ) async def test_discovery_update_alarm(hass, mqtt_mock, caplog): """Test update of discovered alarm_control_panel.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data1 = ( '{ "name": "Beer",' ' "state_topic": "test_topic",' @@ -689,66 +638,32 @@ async def test_discovery_update_alarm(hass, mqtt_mock, caplog): ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - - async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.beer") - assert state is not None - assert state.name == "Milk" - - state = hass.states.get("alarm_control_panel.milk") - assert state is None + await help_test_discovery_update( + hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data1, data2 + ) async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data1 = '{ "name": "Beer" }' data2 = ( '{ "name": "Milk",' ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - - async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.beer") - assert state is None - - async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.milk") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("alarm_control_panel.beer") - assert state is None + await help_test_discovery_broken( + hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data1, data2 + ) async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT alarm control panel device registry integration.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - data = json.dumps( { "platform": "mqtt", "name": "Test 1", "state_topic": "test-topic", - "command_topic": "test-topic", + "command_topic": "test-command-topic", "device": { "identifiers": ["helloworld"], "connections": [["mac", "02:5b:26:a8:dc:12"]], @@ -760,26 +675,13 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): "unique_id": "veryunique", } ) - async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.identifiers == {("mqtt", "helloworld")} - assert device.connections == {("mac", "02:5b:26:a8:dc:12")} - assert device.manufacturer == "Whatever" - assert device.name == "Beer" - assert device.model == "Glass" - assert device.sw_version == "0.1-beta" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, alarm_control_panel.DOMAIN, data + ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - config = { "platform": "mqtt", "name": "Test 1", @@ -795,63 +697,25 @@ async def test_entity_device_info_update(hass, mqtt_mock): }, "unique_id": "veryunique", } - - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Beer" - - config["device"]["name"] = "Milk" - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/alarm_control_panel/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Milk" + await help_test_entity_device_info_update( + hass, mqtt_mock, alarm_control_panel.DOMAIN, config + ) async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - registry = mock_registry(hass, {}) - mock_mqtt = await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: [ - { - "platform": "mqtt", - "name": "beer", - "state_topic": "test-topic", - "command_topic": "command-topic", - "availability_topic": "avty-topic", - "unique_id": "TOTALLY_UNIQUE", - } - ] - }, + config = { + alarm_control_panel.DOMAIN: [ + { + "platform": "mqtt", + "name": "beer", + "state_topic": "test-topic", + "command_topic": "command-topic", + "availability_topic": "avty-topic", + "unique_id": "TOTALLY_UNIQUE", + } + ] + } + await help_test_entity_id_update( + hass, mqtt_mock, alarm_control_panel.DOMAIN, config ) - - state = hass.states.get("alarm_control_panel.beer") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.reset_mock() - - registry.async_update_entity( - "alarm_control_panel.beer", new_entity_id="alarm_control_panel.milk" - ) - await hass.async_block_till_done() - - state = hass.states.get("alarm_control_panel.beer") - assert state is None - - state = hass.states.get("alarm_control_panel.milk") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 3bfe32633b3..e77cddda76d 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -1,10 +1,9 @@ """The tests for the MQTT binary sensor platform.""" from datetime import datetime, timedelta import json -from unittest.mock import ANY, patch +from unittest.mock import patch -from homeassistant.components import binary_sensor, mqtt -from homeassistant.components.mqtt.discovery import async_start +from homeassistant.components import binary_sensor from homeassistant.const import ( EVENT_STATE_CHANGED, STATE_OFF, @@ -15,14 +14,22 @@ import homeassistant.core as ha from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import ( - MockConfigEntry, - async_fire_mqtt_message, - async_fire_time_changed, - async_mock_mqtt_component, - mock_registry, +from .common import ( + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_entity_device_info_update, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, ) +from tests.common import async_fire_mqtt_message, async_fire_time_changed + async def test_setting_sensor_value_expires_availability_topic(hass, mqtt_mock, caplog): """Test the expiration of the value.""" @@ -417,73 +424,51 @@ async def test_off_delay(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - binary_sensor.DOMAIN, - { - binary_sensor.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + binary_sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, binary_sensor.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') - state = hass.states.get("binary_sensor.test") - - assert state.attributes.get("val") == "100" - async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - binary_sensor.DOMAIN, - { - binary_sensor.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + binary_sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, binary_sensor.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') - state = hass.states.get("binary_sensor.test") - - assert state.attributes.get("val") is None - assert "JSON result was not a dictionary" in caplog.text - async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - binary_sensor.DOMAIN, - { - binary_sensor.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + binary_sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, binary_sensor.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") - - state = hass.states.get("binary_sensor.test") - assert state.attributes.get("val") is None - assert "Erroneous JSON: This is not JSON" in caplog.text - async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = ( '{ "name": "Beer",' ' "state_topic": "test_topic",' @@ -494,78 +479,46 @@ async def test_discovery_update_attr(hass, mqtt_mock, caplog): ' "state_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) - async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data1) - await hass.async_block_till_done() - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }') - state = hass.states.get("binary_sensor.beer") - assert state.attributes.get("val") == "100" - - # Change json_attributes_topic - async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data2) - await hass.async_block_till_done() - - # Verify we are no longer subscribing to the old topic - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') - state = hass.states.get("binary_sensor.beer") - assert state.attributes.get("val") == "100" - - # Verify we are subscribing to the new topic - async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') - state = hass.states.get("binary_sensor.beer") - assert state.attributes.get("val") == "75" + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data1, data2 + ) async def test_unique_id(hass): """Test unique id option only creates one sensor per unique_id.""" - await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - binary_sensor.DOMAIN, - { - binary_sensor.DOMAIN: [ - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "platform": "mqtt", - "name": "Test 2", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - }, - ) - async_fire_mqtt_message(hass, "test-topic", "payload") - assert len(hass.states.async_all()) == 1 + config = { + binary_sensor.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, binary_sensor.DOMAIN, config) async def test_discovery_removal_binary_sensor(hass, mqtt_mock, caplog): """Test removal of discovered binary_sensor.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data = ( '{ "name": "Beer",' ' "state_topic": "test_topic",' ' "availability_topic": "availability_topic" }' ) - async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") - assert state is not None - assert state.name == "Beer" - async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", "") - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") - assert state is None + await help_test_discovery_removal( + hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data + ) async def test_discovery_update_binary_sensor(hass, mqtt_mock, caplog): """Test update of discovered binary_sensor.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = ( '{ "name": "Beer",' ' "state_topic": "test_topic",' @@ -576,52 +529,22 @@ async def test_discovery_update_binary_sensor(hass, mqtt_mock, caplog): ' "state_topic": "test_topic2",' ' "availability_topic": "availability_topic2" }' ) - async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data1) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") - assert state is not None - assert state.name == "Beer" - async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data2) - await hass.async_block_till_done() - state = hass.states.get("binary_sensor.beer") - assert state is not None - assert state.name == "Milk" - - state = hass.states.get("binary_sensor.milk") - assert state is None + await help_test_discovery_update( + hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data1, data2 + ) async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data1 = '{ "name": "Beer",' ' "off_delay": -1 }' data2 = '{ "name": "Milk",' ' "state_topic": "test_topic" }' - - async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.beer") - assert state is None - - async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.milk") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("binary_sensor.beer") - assert state is None + await help_test_discovery_broken( + hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data1, data2 + ) async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT binary sensor device registry integration.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - data = json.dumps( { "platform": "mqtt", @@ -638,26 +561,13 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): "unique_id": "veryunique", } ) - async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.identifiers == {("mqtt", "helloworld")} - assert device.connections == {("mac", "02:5b:26:a8:dc:12")} - assert device.manufacturer == "Whatever" - assert device.name == "Beer" - assert device.model == "Glass" - assert device.sw_version == "0.1-beta" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, binary_sensor.DOMAIN, data + ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - config = { "platform": "mqtt", "name": "Test 1", @@ -672,62 +582,22 @@ async def test_entity_device_info_update(hass, mqtt_mock): }, "unique_id": "veryunique", } - - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Beer" - - config["device"]["name"] = "Milk" - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Milk" + await help_test_entity_device_info_update( + hass, mqtt_mock, binary_sensor.DOMAIN, config + ) async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - registry = mock_registry(hass, {}) - mock_mqtt = await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - binary_sensor.DOMAIN, - { - binary_sensor.DOMAIN: [ - { - "platform": "mqtt", - "name": "beer", - "state_topic": "test-topic", - "availability_topic": "avty-topic", - "unique_id": "TOTALLY_UNIQUE", - } - ] - }, - ) - - state = hass.states.get("binary_sensor.beer") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.reset_mock() - - registry.async_update_entity( - "binary_sensor.beer", new_entity_id="binary_sensor.milk" - ) - await hass.async_block_till_done() - - state = hass.states.get("binary_sensor.beer") - assert state is None - - state = hass.states.get("binary_sensor.milk") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") + config = { + binary_sensor.DOMAIN: [ + { + "platform": "mqtt", + "name": "beer", + "state_topic": "test-topic", + "availability_topic": "avty-topic", + "unique_id": "TOTALLY_UNIQUE", + } + ] + } + await help_test_entity_id_update(hass, mqtt_mock, binary_sensor.DOMAIN, config) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 29962287dd7..677fef06f22 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -2,12 +2,10 @@ import copy import json import unittest -from unittest.mock import ANY import pytest import voluptuous as vol -from homeassistant.components import mqtt from homeassistant.components.climate import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP from homeassistant.components.climate.const import ( DOMAIN as CLIMATE_DOMAIN, @@ -25,16 +23,23 @@ from homeassistant.components.climate.const import ( SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_RANGE, ) -from homeassistant.components.mqtt.discovery import async_start from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE -from tests.common import ( - MockConfigEntry, - async_fire_mqtt_message, - async_mock_mqtt_component, - async_setup_component, - mock_registry, +from .common import ( + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_entity_device_info_update, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, ) + +from tests.common import async_fire_mqtt_message, async_setup_component from tests.components.climate import common ENTITY_CLIMATE = "climate.test" @@ -768,76 +773,54 @@ async def test_temp_step_custom(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - CLIMATE_DOMAIN, - { - CLIMATE_DOMAIN: { - "platform": "mqtt", - "name": "test", - "power_state_topic": "test-topic", - "power_command_topic": "test_topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + CLIMATE_DOMAIN: { + "platform": "mqtt", + "name": "test", + "power_state_topic": "test-topic", + "power_command_topic": "test_topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, CLIMATE_DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') - state = hass.states.get("climate.test") - - assert state.attributes.get("val") == "100" - async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - CLIMATE_DOMAIN, - { - CLIMATE_DOMAIN: { - "platform": "mqtt", - "name": "test", - "power_state_topic": "test-topic", - "power_command_topic": "test_topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + CLIMATE_DOMAIN: { + "platform": "mqtt", + "name": "test", + "power_state_topic": "test-topic", + "power_command_topic": "test_topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, CLIMATE_DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') - state = hass.states.get("climate.test") - - assert state.attributes.get("val") is None - assert "JSON result was not a dictionary" in caplog.text - async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - CLIMATE_DOMAIN, - { - CLIMATE_DOMAIN: { - "platform": "mqtt", - "name": "test", - "power_state_topic": "test-topic", - "power_command_topic": "test_topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + CLIMATE_DOMAIN: { + "platform": "mqtt", + "name": "test", + "power_state_topic": "test-topic", + "power_command_topic": "test_topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, CLIMATE_DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") - - state = hass.states.get("climate.test") - assert state.attributes.get("val") is None - assert "Erroneous JSON: This is not JSON" in caplog.text - async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = ( '{ "name": "Beer",' ' "power_state_topic": "test-topic",' @@ -850,127 +833,60 @@ async def test_discovery_update_attr(hass, mqtt_mock, caplog): ' "power_command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) - async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data1) - await hass.async_block_till_done() - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }') - state = hass.states.get("climate.beer") - assert state.attributes.get("val") == "100" - - # Change json_attributes_topic - async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data2) - await hass.async_block_till_done() - - # Verify we are no longer subscribing to the old topic - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') - state = hass.states.get("climate.beer") - assert state.attributes.get("val") == "100" - - # Verify we are subscribing to the new topic - async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') - state = hass.states.get("climate.beer") - assert state.attributes.get("val") == "75" + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data1, data2 + ) async def test_unique_id(hass): """Test unique id option only creates one climate per unique_id.""" - await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - CLIMATE_DOMAIN, - { - CLIMATE_DOMAIN: [ - { - "platform": "mqtt", - "name": "Test 1", - "power_state_topic": "test-topic", - "power_command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "platform": "mqtt", - "name": "Test 2", - "power_state_topic": "test-topic", - "power_command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - }, - ) - async_fire_mqtt_message(hass, "test-topic", "payload") - assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1 + config = { + CLIMATE_DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "power_state_topic": "test-topic", + "power_command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "power_state_topic": "test-topic", + "power_command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, CLIMATE_DOMAIN, config) async def test_discovery_removal_climate(hass, mqtt_mock, caplog): """Test removal of discovered climate.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data = '{ "name": "Beer" }' - async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data) - await hass.async_block_till_done() - state = hass.states.get("climate.beer") - assert state is not None - assert state.name == "Beer" - async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", "") - await hass.async_block_till_done() - state = hass.states.get("climate.beer") - assert state is None + await help_test_discovery_removal(hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data) async def test_discovery_update_climate(hass, mqtt_mock, caplog): """Test update of discovered climate.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = '{ "name": "Beer" }' data2 = '{ "name": "Milk" }' - async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("climate.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("climate.beer") - assert state is not None - assert state.name == "Milk" - - state = hass.states.get("climate.milk") - assert state is None + await help_test_discovery_update( + hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data1, data2 + ) async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data1 = '{ "name": "Beer",' ' "power_command_topic": "test_topic#" }' data2 = '{ "name": "Milk", ' ' "power_command_topic": "test_topic" }' - - async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("climate.beer") - assert state is None - - async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("climate.milk") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("climate.beer") - assert state is None + await help_test_discovery_broken( + hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data1, data2 + ) async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT climate device registry integration.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - data = json.dumps( { "platform": "mqtt", @@ -986,26 +902,13 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): "unique_id": "veryunique", } ) - async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.identifiers == {("mqtt", "helloworld")} - assert device.connections == {("mac", "02:5b:26:a8:dc:12")} - assert device.manufacturer == "Whatever" - assert device.name == "Beer" - assert device.model == "Glass" - assert device.sw_version == "0.1-beta" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, CLIMATE_DOMAIN, data + ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - config = { "platform": "mqtt", "name": "Test 1", @@ -1021,63 +924,23 @@ async def test_entity_device_info_update(hass, mqtt_mock): }, "unique_id": "veryunique", } - - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Beer" - - config["device"]["name"] = "Milk" - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/climate/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Milk" + await help_test_entity_device_info_update(hass, mqtt_mock, CLIMATE_DOMAIN, config) async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - registry = mock_registry(hass, {}) - mock_mqtt = await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - CLIMATE_DOMAIN, - { - CLIMATE_DOMAIN: [ - { - "platform": "mqtt", - "name": "beer", - "mode_state_topic": "test-topic", - "availability_topic": "avty-topic", - "unique_id": "TOTALLY_UNIQUE", - } - ] - }, - ) - - state = hass.states.get("climate.beer") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.reset_mock() - - registry.async_update_entity("climate.beer", new_entity_id="climate.milk") - await hass.async_block_till_done() - - state = hass.states.get("climate.beer") - assert state is None - - state = hass.states.get("climate.milk") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") + config = { + CLIMATE_DOMAIN: [ + { + "platform": "mqtt", + "name": "beer", + "mode_state_topic": "test-topic", + "availability_topic": "avty-topic", + "unique_id": "TOTALLY_UNIQUE", + } + ] + } + await help_test_entity_id_update(hass, mqtt_mock, CLIMATE_DOMAIN, config) async def test_precision_default(hass, mqtt_mock): diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 128c18de8df..0bf8aa92150 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -1,11 +1,9 @@ """The tests for the MQTT cover platform.""" import json -from unittest.mock import ANY -from homeassistant.components import cover, mqtt +from homeassistant.components import cover from homeassistant.components.cover import ATTR_POSITION, ATTR_TILT_POSITION from homeassistant.components.mqtt.cover import MqttCover -from homeassistant.components.mqtt.discovery import async_start from homeassistant.const import ( ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, @@ -27,13 +25,22 @@ from homeassistant.const import ( ) from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - async_fire_mqtt_message, - async_mock_mqtt_component, - mock_registry, +from .common import ( + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_entity_device_info_update, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, ) +from tests.common import async_fire_mqtt_message + async def test_state_via_state_topic(hass, mqtt_mock): """Test the controlling state via topic.""" @@ -1693,73 +1700,51 @@ async def test_invalid_device_class(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - cover.DOMAIN, - { - cover.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, cover.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') - state = hass.states.get("cover.test") - - assert state.attributes.get("val") == "100" - async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - cover.DOMAIN, - { - cover.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, cover.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') - state = hass.states.get("cover.test") - - assert state.attributes.get("val") is None - assert "JSON result was not a dictionary" in caplog.text - async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - cover.DOMAIN, - { - cover.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, cover.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") - - state = hass.states.get("cover.test") - assert state.attributes.get("val") is None - assert "Erroneous JSON: This is not JSON" in caplog.text - async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = ( '{ "name": "Beer",' ' "command_topic": "test_topic",' @@ -1770,126 +1755,58 @@ async def test_discovery_update_attr(hass, mqtt_mock, caplog): ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) - async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data1) - await hass.async_block_till_done() - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }') - state = hass.states.get("cover.beer") - assert state.attributes.get("val") == "100" - - # Change json_attributes_topic - async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data2) - await hass.async_block_till_done() - - # Verify we are no longer subscribing to the old topic - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') - state = hass.states.get("cover.beer") - assert state.attributes.get("val") == "100" - - # Verify we are subscribing to the new topic - async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') - state = hass.states.get("cover.beer") - assert state.attributes.get("val") == "75" - - -async def test_discovery_removal_cover(hass, mqtt_mock, caplog): - """Test removal of discovered cover.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data = '{ "name": "Beer",' ' "command_topic": "test_topic" }' - async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data) - await hass.async_block_till_done() - state = hass.states.get("cover.beer") - assert state is not None - assert state.name == "Beer" - async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", "") - await hass.async_block_till_done() - state = hass.states.get("cover.beer") - assert state is None - - -async def test_discovery_update_cover(hass, mqtt_mock, caplog): - """Test update of discovered cover.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' - async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data1) - await hass.async_block_till_done() - state = hass.states.get("cover.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("cover.beer") - assert state is not None - assert state.name == "Milk" - - state = hass.states.get("cover.milk") - assert state is None - - -async def test_discovery_broken(hass, mqtt_mock, caplog): - """Test handling of bad discovery message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - - data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#" }' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' - - async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("cover.beer") - assert state is None - - async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("cover.milk") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("cover.beer") - assert state is None + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, cover.DOMAIN, data1, data2 + ) async def test_unique_id(hass): """Test unique_id option only creates one cover per id.""" - await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - cover.DOMAIN, - { - cover.DOMAIN: [ - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "platform": "mqtt", - "name": "Test 2", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - }, + config = { + cover.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, cover.DOMAIN, config) + + +async def test_discovery_removal_cover(hass, mqtt_mock, caplog): + """Test removal of discovered cover.""" + data = '{ "name": "Beer",' ' "command_topic": "test_topic" }' + await help_test_discovery_removal(hass, mqtt_mock, caplog, cover.DOMAIN, data) + + +async def test_discovery_update_cover(hass, mqtt_mock, caplog): + """Test update of discovered cover.""" + data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }' + data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' + await help_test_discovery_update( + hass, mqtt_mock, caplog, cover.DOMAIN, data1, data2 ) - async_fire_mqtt_message(hass, "test-topic", "payload") - assert len(hass.states.async_entity_ids(cover.DOMAIN)) == 1 +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#" }' + data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' + await help_test_discovery_broken( + hass, mqtt_mock, caplog, cover.DOMAIN, data1, data2 + ) async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT cover device registry integration.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - data = json.dumps( { "platform": "mqtt", @@ -1907,26 +1824,13 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): "unique_id": "veryunique", } ) - async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.identifiers == {("mqtt", "helloworld")} - assert device.connections == {("mac", "02:5b:26:a8:dc:12")} - assert device.manufacturer == "Whatever" - assert device.name == "Beer" - assert device.model == "Glass" - assert device.sw_version == "0.1-beta" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, cover.DOMAIN, data + ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - config = { "platform": "mqtt", "name": "Test 1", @@ -1942,60 +1846,20 @@ async def test_entity_device_info_update(hass, mqtt_mock): }, "unique_id": "veryunique", } - - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Beer" - - config["device"]["name"] = "Milk" - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/cover/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Milk" + await help_test_entity_device_info_update(hass, mqtt_mock, cover.DOMAIN, config) async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - registry = mock_registry(hass, {}) - mock_mqtt = await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - cover.DOMAIN, - { - cover.DOMAIN: [ - { - "platform": "mqtt", - "name": "beer", - "state_topic": "test-topic", - "availability_topic": "avty-topic", - "unique_id": "TOTALLY_UNIQUE", - } - ] - }, - ) - - state = hass.states.get("cover.beer") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.reset_mock() - - registry.async_update_entity("cover.beer", new_entity_id="cover.milk") - await hass.async_block_till_done() - - state = hass.states.get("cover.beer") - assert state is None - - state = hass.states.get("cover.milk") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") + config = { + cover.DOMAIN: [ + { + "platform": "mqtt", + "name": "beer", + "state_topic": "test-topic", + "availability_topic": "avty-topic", + "unique_id": "TOTALLY_UNIQUE", + } + ] + } + await help_test_entity_id_update(hass, mqtt_mock, cover.DOMAIN, config) diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 65e170fba91..556dbbe5528 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -1,9 +1,7 @@ """Test MQTT fans.""" import json -from unittest.mock import ANY -from homeassistant.components import fan, mqtt -from homeassistant.components.mqtt.discovery import async_start +from homeassistant.components import fan from homeassistant.const import ( ATTR_ASSUMED_STATE, STATE_OFF, @@ -12,12 +10,21 @@ from homeassistant.const import ( ) from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - async_fire_mqtt_message, - async_mock_mqtt_component, - mock_registry, +from .common import ( + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_entity_device_info_update, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, ) + +from tests.common import async_fire_mqtt_message from tests.components.fan import common @@ -438,138 +445,53 @@ async def test_custom_availability_payload(hass, mqtt_mock): assert state.state is not STATE_UNAVAILABLE -async def test_discovery_removal_fan(hass, mqtt_mock, caplog): - """Test removal of discovered fan.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data = '{ "name": "Beer",' ' "command_topic": "test_topic" }' - async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data) - await hass.async_block_till_done() - state = hass.states.get("fan.beer") - assert state is not None - assert state.name == "Beer" - async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", "") - await hass.async_block_till_done() - state = hass.states.get("fan.beer") - assert state is None - - -async def test_discovery_update_fan(hass, mqtt_mock, caplog): - """Test update of discovered fan.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' - async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("fan.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("fan.beer") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("fan.milk") - assert state is None - - -async def test_discovery_broken(hass, mqtt_mock, caplog): - """Test handling of bad discovery message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - - data1 = '{ "name": "Beer" }' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' - - async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("fan.beer") - assert state is None - - async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("fan.milk") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("fan.beer") - assert state is None - - async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - fan.DOMAIN, - { - fan.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + fan.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, fan.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') - state = hass.states.get("fan.test") - - assert state.attributes.get("val") == "100" - async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - fan.DOMAIN, - { - fan.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + fan.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, fan.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') - state = hass.states.get("fan.test") - - assert state.attributes.get("val") is None - assert "JSON result was not a dictionary" in caplog.text - async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - fan.DOMAIN, - { - fan.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + fan.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, fan.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") - - state = hass.states.get("fan.test") - assert state.attributes.get("val") is None - assert "Erroneous JSON: This is not JSON" in caplog.text - async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = ( '{ "name": "Beer",' ' "command_topic": "test_topic",' @@ -580,65 +502,56 @@ async def test_discovery_update_attr(hass, mqtt_mock, caplog): ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) - async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data1) - await hass.async_block_till_done() - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }') - state = hass.states.get("fan.beer") - assert state.attributes.get("val") == "100" - - # Change json_attributes_topic - async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data2) - await hass.async_block_till_done() - - # Verify we are no longer subscribing to the old topic - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') - state = hass.states.get("fan.beer") - assert state.attributes.get("val") == "100" - - # Verify we are subscribing to the new topic - async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') - state = hass.states.get("fan.beer") - assert state.attributes.get("val") == "75" + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, fan.DOMAIN, data1, data2 + ) async def test_unique_id(hass): """Test unique_id option only creates one fan per id.""" - await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - fan.DOMAIN, - { - fan.DOMAIN: [ - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "platform": "mqtt", - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - }, - ) + config = { + fan.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, fan.DOMAIN, config) - async_fire_mqtt_message(hass, "test-topic", "payload") - assert len(hass.states.async_entity_ids(fan.DOMAIN)) == 1 +async def test_discovery_removal_fan(hass, mqtt_mock, caplog): + """Test removal of discovered fan.""" + data = '{ "name": "Beer",' ' "command_topic": "test_topic" }' + await help_test_discovery_removal(hass, mqtt_mock, caplog, fan.DOMAIN, data) + + +async def test_discovery_update_fan(hass, mqtt_mock, caplog): + """Test update of discovered fan.""" + data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }' + data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' + await help_test_discovery_update(hass, mqtt_mock, caplog, fan.DOMAIN, data1, data2) + + +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' + await help_test_discovery_broken(hass, mqtt_mock, caplog, fan.DOMAIN, data1, data2) async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT fan device registry integration.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - data = json.dumps( { "platform": "mqtt", @@ -656,26 +569,13 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): "unique_id": "veryunique", } ) - async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.identifiers == {("mqtt", "helloworld")} - assert device.connections == {("mac", "02:5b:26:a8:dc:12")} - assert device.manufacturer == "Whatever" - assert device.name == "Beer" - assert device.model == "Glass" - assert device.sw_version == "0.1-beta" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, fan.DOMAIN, data + ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - config = { "platform": "mqtt", "name": "Test 1", @@ -691,61 +591,21 @@ async def test_entity_device_info_update(hass, mqtt_mock): }, "unique_id": "veryunique", } - - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Beer" - - config["device"]["name"] = "Milk" - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/fan/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Milk" + await help_test_entity_device_info_update(hass, mqtt_mock, fan.DOMAIN, config) async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - registry = mock_registry(hass, {}) - mock_mqtt = await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - fan.DOMAIN, - { - fan.DOMAIN: [ - { - "platform": "mqtt", - "name": "beer", - "state_topic": "test-topic", - "command_topic": "command-topic", - "availability_topic": "avty-topic", - "unique_id": "TOTALLY_UNIQUE", - } - ] - }, - ) - - state = hass.states.get("fan.beer") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.reset_mock() - - registry.async_update_entity("fan.beer", new_entity_id="fan.milk") - await hass.async_block_till_done() - - state = hass.states.get("fan.beer") - assert state is None - - state = hass.states.get("fan.milk") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") + config = { + fan.DOMAIN: [ + { + "platform": "mqtt", + "name": "beer", + "state_topic": "test-topic", + "command_topic": "command-topic", + "availability_topic": "avty-topic", + "unique_id": "TOTALLY_UNIQUE", + } + ] + } + await help_test_entity_id_update(hass, mqtt_mock, fan.DOMAIN, config) diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index f6128decc1a..558a185dbb0 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -2,9 +2,8 @@ from copy import deepcopy import json -from homeassistant.components import mqtt, vacuum +from homeassistant.components import vacuum from homeassistant.components.mqtt import CONF_COMMAND_TOPIC -from homeassistant.components.mqtt.discovery import async_start from homeassistant.components.mqtt.vacuum import schema_legacy as mqttvacuum from homeassistant.components.mqtt.vacuum.schema import services_to_strings from homeassistant.components.mqtt.vacuum.schema_legacy import ( @@ -26,11 +25,21 @@ from homeassistant.const import ( ) from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - async_fire_mqtt_message, - async_mock_mqtt_component, +from .common import ( + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_entity_device_info_update, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, ) + +from tests.common import async_fire_mqtt_message from tests.components.vacuum import common DEFAULT_CONFIG = { @@ -514,142 +523,50 @@ async def test_custom_availability_payload(hass, mqtt_mock): assert state.state == STATE_UNAVAILABLE -async def test_discovery_removal_vacuum(hass, mqtt_mock): - """Test removal of discovered vacuum.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - - data = '{ "name": "Beer",' ' "command_topic": "test_topic" }' - - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data) - await hass.async_block_till_done() - - state = hass.states.get("vacuum.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", "") - await hass.async_block_till_done() - - state = hass.states.get("vacuum.beer") - assert state is None - - -async def test_discovery_broken(hass, mqtt_mock, caplog): - """Test handling of bad discovery message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - - data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#" }' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' - - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("vacuum.beer") - assert state is None - - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("vacuum.milk") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("vacuum.beer") - assert state is None - - -async def test_discovery_update_vacuum(hass, mqtt_mock): - """Test update of discovered vacuum.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - - data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' - - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("vacuum.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("vacuum.beer") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("vacuum.milk") - assert state is None - - async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - vacuum.DOMAIN, - { - vacuum.DOMAIN: { - "platform": "mqtt", - "name": "test", - "json_attributes_topic": "attr-topic", - } - }, + config = { + vacuum.DOMAIN: { + "platform": "mqtt", + "name": "test", + "json_attributes_topic": "attr-topic", + } + } + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, vacuum.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') - state = hass.states.get("vacuum.test") - - assert state.attributes.get("val") == "100" - async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - vacuum.DOMAIN, - { - vacuum.DOMAIN: { - "platform": "mqtt", - "name": "test", - "json_attributes_topic": "attr-topic", - } - }, + config = { + vacuum.DOMAIN: { + "platform": "mqtt", + "name": "test", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, vacuum.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') - state = hass.states.get("vacuum.test") - assert state.attributes.get("val") is None - assert "JSON result was not a dictionary" in caplog.text - - -async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog): +async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - vacuum.DOMAIN, - { - vacuum.DOMAIN: { - "platform": "mqtt", - "name": "test", - "json_attributes_topic": "attr-topic", - } - }, + config = { + vacuum.DOMAIN: { + "platform": "mqtt", + "name": "test", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, vacuum.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") - - state = hass.states.get("vacuum.test") - assert state.attributes.get("val") is None - assert "Erroneous JSON: This is not JSON" in caplog.text - async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = ( '{ "name": "Beer",' ' "command_topic": "test_topic",' @@ -660,63 +577,58 @@ async def test_discovery_update_attr(hass, mqtt_mock, caplog): ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data1) - await hass.async_block_till_done() - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }') - state = hass.states.get("vacuum.beer") - assert state.attributes.get("val") == "100" - - # Change json_attributes_topic - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data2) - await hass.async_block_till_done() - - # Verify we are no longer subscribing to the old topic - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') - state = hass.states.get("vacuum.beer") - assert state.attributes.get("val") == "100" - - # Verify we are subscribing to the new topic - async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') - state = hass.states.get("vacuum.beer") - assert state.attributes.get("val") == "75" + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2 + ) async def test_unique_id(hass, mqtt_mock): """Test unique id option only creates one vacuum per unique_id.""" - await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - vacuum.DOMAIN, - { - vacuum.DOMAIN: [ - { - "platform": "mqtt", - "name": "Test 1", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "platform": "mqtt", - "name": "Test 2", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - }, + config = { + vacuum.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, vacuum.DOMAIN, config) + + +async def test_discovery_removal_vacuum(hass, mqtt_mock, caplog): + """Test removal of discovered vacuum.""" + data = '{ "name": "Beer",' ' "command_topic": "test_topic" }' + await help_test_discovery_removal(hass, mqtt_mock, caplog, vacuum.DOMAIN, data) + + +async def test_discovery_update_vacuum(hass, mqtt_mock, caplog): + """Test update of discovered vacuum.""" + data1 = '{ "name": "Beer",' ' "command_topic": "test_topic" }' + data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' + await help_test_discovery_update( + hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2 ) - async_fire_mqtt_message(hass, "test-topic", "payload") - assert len(hass.states.async_entity_ids()) == 1 +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#" }' + data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' + await help_test_discovery_broken( + hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2 + ) async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT vacuum device registry integration.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - data = json.dumps( { "platform": "mqtt", @@ -733,26 +645,13 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): "unique_id": "veryunique", } ) - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.identifiers == {("mqtt", "helloworld")} - assert device.connections == {("mac", "02:5b:26:a8:dc:12")} - assert device.manufacturer == "Whatever" - assert device.name == "Beer" - assert device.model == "Glass" - assert device.sw_version == "0.1-beta" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, vacuum.DOMAIN, data + ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - config = { "platform": "mqtt", "name": "Test 1", @@ -767,20 +666,22 @@ async def test_entity_device_info_update(hass, mqtt_mock): }, "unique_id": "veryunique", } + await help_test_entity_device_info_update(hass, mqtt_mock, vacuum.DOMAIN, config) - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data) - await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Beer" - - config["device"]["name"] = "Milk" - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Milk" +async def test_entity_id_update(hass, mqtt_mock): + """Test MQTT subscriptions are managed when entity_id is updated.""" + config = { + vacuum.DOMAIN: [ + { + "platform": "mqtt", + "name": "beer", + "battery_level_topic": "test-topic", + "battery_level_template": "{{ value_json.battery_level }}", + "command_topic": "command-topic", + "availability_topic": "avty-topic", + "unique_id": "TOTALLY_UNIQUE", + } + ] + } + await help_test_entity_id_update(hass, mqtt_mock, vacuum.DOMAIN, config) diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 43ccaf6dea5..389806a2b20 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -155,7 +155,7 @@ light: """ import json from unittest import mock -from unittest.mock import ANY, patch +from unittest.mock import patch from homeassistant.components import light, mqtt from homeassistant.components.mqtt.discovery import async_start @@ -168,13 +168,25 @@ from homeassistant.const import ( import homeassistant.core as ha from homeassistant.setup import async_setup_component +from .common import ( + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_entity_device_info_update, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, +) + from tests.common import ( MockConfigEntry, assert_setup_component, async_fire_mqtt_message, - async_mock_mqtt_component, mock_coro, - mock_registry, ) from tests.components.light import common @@ -1063,73 +1075,51 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + light.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, light.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') - state = hass.states.get("light.test") - - assert state.attributes.get("val") == "100" - async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + light.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, light.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') - state = hass.states.get("light.test") - - assert state.attributes.get("val") is None - assert "JSON result was not a dictionary" in caplog.text - async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + light.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, light.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") - - state = hass.states.get("light.test") - assert state.attributes.get("val") is None - assert "Erroneous JSON: This is not JSON" in caplog.text - async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = ( '{ "name": "Beer",' ' "command_topic": "test_topic",' @@ -1140,79 +1130,42 @@ async def test_discovery_update_attr(hass, mqtt_mock, caplog): ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1) - await hass.async_block_till_done() - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }') - state = hass.states.get("light.beer") - assert state.attributes.get("val") == "100" - - # Change json_attributes_topic - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data2) - await hass.async_block_till_done() - - # Verify we are no longer subscribing to the old topic - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') - state = hass.states.get("light.beer") - assert state.attributes.get("val") == "100" - - # Verify we are subscribing to the new topic - async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') - state = hass.states.get("light.beer") - assert state.attributes.get("val") == "75" + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, light.DOMAIN, data1, data2 + ) async def test_unique_id(hass): """Test unique id option only creates one light per unique_id.""" - await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: [ - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "platform": "mqtt", - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - }, - ) - async_fire_mqtt_message(hass, "test-topic", "payload") - assert len(hass.states.async_entity_ids(light.DOMAIN)) == 1 + config = { + light.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, light.DOMAIN, config) async def test_discovery_removal_light(hass, mqtt_mock, caplog): """Test removal of discovered light.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data = ( '{ "name": "Beer",' ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data) - await hass.async_block_till_done() - - state = hass.states.get("light.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", "") - await hass.async_block_till_done() - - state = hass.states.get("light.beer") - assert state is None + await help_test_discovery_removal(hass, mqtt_mock, caplog, light.DOMAIN, data) async def test_discovery_deprecated(hass, mqtt_mock, caplog): @@ -1231,9 +1184,6 @@ async def test_discovery_deprecated(hass, mqtt_mock, caplog): async def test_discovery_update_light(hass, mqtt_mock, caplog): """Test update of discovered light.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data1 = ( '{ "name": "Beer",' ' "state_topic": "test_topic",' @@ -1244,59 +1194,26 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("light.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("light.beer") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("light.milk") - assert state is None + await help_test_discovery_update( + hass, mqtt_mock, caplog, light.DOMAIN, data1, data2 + ) async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data1 = '{ "name": "Beer" }' data2 = ( '{ "name": "Milk",' ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("light.beer") - assert state is None - - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("light.milk") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("light.beer") - assert state is None + await help_test_discovery_broken( + hass, mqtt_mock, caplog, light.DOMAIN, data1, data2 + ) async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT light device registry integration.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - data = json.dumps( { "platform": "mqtt", @@ -1314,26 +1231,13 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): "unique_id": "veryunique", } ) - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.identifiers == {("mqtt", "helloworld")} - assert device.connections == {("mac", "02:5b:26:a8:dc:12")} - assert device.manufacturer == "Whatever" - assert device.name == "Beer" - assert device.model == "Glass" - assert device.sw_version == "0.1-beta" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, light.DOMAIN, data + ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - config = { "platform": "mqtt", "name": "Test 1", @@ -1349,61 +1253,21 @@ async def test_entity_device_info_update(hass, mqtt_mock): }, "unique_id": "veryunique", } - - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Beer" - - config["device"]["name"] = "Milk" - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Milk" + await help_test_entity_device_info_update(hass, mqtt_mock, light.DOMAIN, config) async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - registry = mock_registry(hass, {}) - mock_mqtt = await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: [ - { - "platform": "mqtt", - "name": "beer", - "state_topic": "test-topic", - "command_topic": "command-topic", - "availability_topic": "avty-topic", - "unique_id": "TOTALLY_UNIQUE", - } - ] - }, - ) - - state = hass.states.get("light.beer") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.reset_mock() - - registry.async_update_entity("light.beer", new_entity_id="light.milk") - await hass.async_block_till_done() - - state = hass.states.get("light.beer") - assert state is None - - state = hass.states.get("light.milk") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") + config = { + light.DOMAIN: [ + { + "platform": "mqtt", + "name": "beer", + "state_topic": "test-topic", + "command_topic": "command-topic", + "availability_topic": "avty-topic", + "unique_id": "TOTALLY_UNIQUE", + } + ] + } + await help_test_entity_id_update(hass, mqtt_mock, light.DOMAIN, config) diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 355451f6469..8783f16c9af 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -89,7 +89,7 @@ light: """ import json from unittest import mock -from unittest.mock import ANY, patch +from unittest.mock import patch from homeassistant.components import light, mqtt from homeassistant.components.mqtt.discovery import async_start @@ -103,13 +103,21 @@ from homeassistant.const import ( import homeassistant.core as ha from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - async_fire_mqtt_message, - async_mock_mqtt_component, - mock_coro, - mock_registry, +from .common import ( + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_entity_device_info_update, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, ) + +from tests.common import MockConfigEntry, async_fire_mqtt_message, mock_coro from tests.components.light import common @@ -913,76 +921,54 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: { - "platform": "mqtt", - "schema": "json", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + light.DOMAIN: { + "platform": "mqtt", + "schema": "json", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, light.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') - state = hass.states.get("light.test") - - assert state.attributes.get("val") == "100" - async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: { - "platform": "mqtt", - "schema": "json", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + light.DOMAIN: { + "platform": "mqtt", + "schema": "json", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, light.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') - state = hass.states.get("light.test") - - assert state.attributes.get("val") is None - assert "JSON result was not a dictionary" in caplog.text - async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: { - "platform": "mqtt", - "schema": "json", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + light.DOMAIN: { + "platform": "mqtt", + "schema": "json", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, light.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") - - state = hass.states.get("light.test") - assert state.attributes.get("val") is None - assert "Erroneous JSON: This is not JSON" in caplog.text - async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = ( '{ "name": "Beer",' ' "schema": "json",' @@ -995,72 +981,40 @@ async def test_discovery_update_attr(hass, mqtt_mock, caplog): ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1) - await hass.async_block_till_done() - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }') - state = hass.states.get("light.beer") - assert state.attributes.get("val") == "100" - - # Change json_attributes_topic - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data2) - await hass.async_block_till_done() - - # Verify we are no longer subscribing to the old topic - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') - state = hass.states.get("light.beer") - assert state.attributes.get("val") == "100" - - # Verify we are subscribing to the new topic - async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') - state = hass.states.get("light.beer") - assert state.attributes.get("val") == "75" + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, light.DOMAIN, data1, data2 + ) async def test_unique_id(hass): """Test unique id option only creates one light per unique_id.""" - await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: [ - { - "platform": "mqtt", - "name": "Test 1", - "schema": "json", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "platform": "mqtt", - "name": "Test 2", - "schema": "json", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - }, - ) - async_fire_mqtt_message(hass, "test-topic", "payload") - assert len(hass.states.async_entity_ids(light.DOMAIN)) == 1 + config = { + light.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "schema": "json", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "schema": "json", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, light.DOMAIN, config) async def test_discovery_removal(hass, mqtt_mock, caplog): """Test removal of discovered mqtt_json lights.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {"mqtt": {}}, entry) data = '{ "name": "Beer",' ' "schema": "json",' ' "command_topic": "test_topic" }' - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data) - await hass.async_block_till_done() - state = hass.states.get("light.beer") - assert state is not None - assert state.name == "Beer" - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", "") - await hass.async_block_till_done() - state = hass.states.get("light.beer") - assert state is None + await help_test_discovery_removal(hass, mqtt_mock, caplog, light.DOMAIN, data) async def test_discovery_deprecated(hass, mqtt_mock, caplog): @@ -1081,9 +1035,6 @@ async def test_discovery_deprecated(hass, mqtt_mock, caplog): async def test_discovery_update_light(hass, mqtt_mock, caplog): """Test update of discovered light.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data1 = ( '{ "name": "Beer",' ' "schema": "json",' @@ -1096,29 +1047,13 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("light.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("light.beer") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("light.milk") - assert state is None + await help_test_discovery_update( + hass, mqtt_mock, caplog, light.DOMAIN, data1, data2 + ) async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data1 = '{ "name": "Beer" }' data2 = ( '{ "name": "Milk",' @@ -1126,30 +1061,13 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("light.beer") - assert state is None - - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("light.milk") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("light.beer") - assert state is None + await help_test_discovery_broken( + hass, mqtt_mock, caplog, light.DOMAIN, data1, data2 + ) async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT light device registry integration.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - data = json.dumps( { "platform": "mqtt", @@ -1168,26 +1086,13 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): "unique_id": "veryunique", } ) - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.identifiers == {("mqtt", "helloworld")} - assert device.connections == {("mac", "02:5b:26:a8:dc:12")} - assert device.manufacturer == "Whatever" - assert device.name == "Beer" - assert device.model == "Glass" - assert device.sw_version == "0.1-beta" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, light.DOMAIN, data + ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - config = { "platform": "mqtt", "name": "Test 1", @@ -1204,62 +1109,22 @@ async def test_entity_device_info_update(hass, mqtt_mock): }, "unique_id": "veryunique", } - - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Beer" - - config["device"]["name"] = "Milk" - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Milk" + await help_test_entity_device_info_update(hass, mqtt_mock, light.DOMAIN, config) async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - registry = mock_registry(hass, {}) - mock_mqtt = await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: [ - { - "platform": "mqtt", - "name": "beer", - "schema": "json", - "state_topic": "test-topic", - "command_topic": "command-topic", - "availability_topic": "avty-topic", - "unique_id": "TOTALLY_UNIQUE", - } - ] - }, - ) - - state = hass.states.get("light.beer") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.reset_mock() - - registry.async_update_entity("light.beer", new_entity_id="light.milk") - await hass.async_block_till_done() - - state = hass.states.get("light.beer") - assert state is None - - state = hass.states.get("light.milk") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") + config = { + light.DOMAIN: [ + { + "platform": "mqtt", + "name": "beer", + "schema": "json", + "state_topic": "test-topic", + "command_topic": "command-topic", + "availability_topic": "avty-topic", + "unique_id": "TOTALLY_UNIQUE", + } + ] + } + await help_test_entity_id_update(hass, mqtt_mock, light.DOMAIN, config) diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 1d109af5930..26c58b7522c 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -27,7 +27,7 @@ If your light doesn't support white value feature, omit `white_value_template`. If your light doesn't support RGB feature, omit `(red|green|blue)_template`. """ import json -from unittest.mock import ANY, patch +from unittest.mock import patch from homeassistant.components import light, mqtt from homeassistant.components.mqtt.discovery import async_start @@ -40,13 +40,25 @@ from homeassistant.const import ( import homeassistant.core as ha from homeassistant.setup import async_setup_component +from .common import ( + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_entity_device_info_update, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, +) + from tests.common import ( MockConfigEntry, assert_setup_component, async_fire_mqtt_message, - async_mock_mqtt_component, mock_coro, - mock_registry, ) @@ -510,82 +522,60 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: { - "platform": "mqtt", - "schema": "template", - "name": "test", - "command_topic": "test-topic", - "command_on_template": "on,{{ transition }}", - "command_off_template": "off,{{ transition|d }}", - "json_attributes_topic": "attr-topic", - } - }, + config = { + light.DOMAIN: { + "platform": "mqtt", + "schema": "template", + "name": "test", + "command_topic": "test-topic", + "command_on_template": "on,{{ transition }}", + "command_off_template": "off,{{ transition|d }}", + "json_attributes_topic": "attr-topic", + } + } + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, light.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') - state = hass.states.get("light.test") - - assert state.attributes.get("val") == "100" - async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: { - "platform": "mqtt", - "schema": "template", - "name": "test", - "command_topic": "test-topic", - "command_on_template": "on,{{ transition }}", - "command_off_template": "off,{{ transition|d }}", - "json_attributes_topic": "attr-topic", - } - }, + config = { + light.DOMAIN: { + "platform": "mqtt", + "schema": "template", + "name": "test", + "command_topic": "test-topic", + "command_on_template": "on,{{ transition }}", + "command_off_template": "off,{{ transition|d }}", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, light.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') - state = hass.states.get("light.test") - - assert state.attributes.get("val") is None - assert "JSON result was not a dictionary" in caplog.text - async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: { - "platform": "mqtt", - "schema": "template", - "name": "test", - "command_topic": "test-topic", - "command_on_template": "on,{{ transition }}", - "command_off_template": "off,{{ transition|d }}", - "json_attributes_topic": "attr-topic", - } - }, + config = { + light.DOMAIN: { + "platform": "mqtt", + "schema": "template", + "name": "test", + "command_topic": "test-topic", + "command_on_template": "on,{{ transition }}", + "command_off_template": "off,{{ transition|d }}", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, light.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") - - state = hass.states.get("light.test") - assert state.attributes.get("val") is None - assert "Erroneous JSON: This is not JSON" in caplog.text - async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = ( '{ "name": "Beer",' ' "schema": "template",' @@ -602,64 +592,40 @@ async def test_discovery_update_attr(hass, mqtt_mock, caplog): ' "command_off_template": "off",' ' "json_attributes_topic": "attr-topic2" }' ) - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1) - await hass.async_block_till_done() - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }') - state = hass.states.get("light.beer") - assert state.attributes.get("val") == "100" - - # Change json_attributes_topic - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data2) - await hass.async_block_till_done() - - # Verify we are no longer subscribing to the old topic - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') - state = hass.states.get("light.beer") - assert state.attributes.get("val") == "100" - - # Verify we are subscribing to the new topic - async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') - state = hass.states.get("light.beer") - assert state.attributes.get("val") == "75" + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, light.DOMAIN, data1, data2 + ) async def test_unique_id(hass): """Test unique id option only creates one light per unique_id.""" - await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: [ - { - "platform": "mqtt", - "name": "Test 1", - "schema": "template", - "state_topic": "test-topic", - "command_topic": "test_topic", - "command_on_template": "on,{{ transition }}", - "command_off_template": "off,{{ transition|d }}", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "platform": "mqtt", - "name": "Test 2", - "schema": "template", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - }, - ) - async_fire_mqtt_message(hass, "test-topic", "payload") - assert len(hass.states.async_entity_ids(light.DOMAIN)) == 1 + config = { + light.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "schema": "template", + "state_topic": "test-topic", + "command_topic": "test_topic", + "command_on_template": "on,{{ transition }}", + "command_off_template": "off,{{ transition|d }}", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "schema": "template", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, light.DOMAIN, config) async def test_discovery_removal(hass, mqtt_mock, caplog): """Test removal of discovered mqtt_json lights.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {"mqtt": {}}, entry) data = ( '{ "name": "Beer",' ' "schema": "template",' @@ -667,15 +633,7 @@ async def test_discovery_removal(hass, mqtt_mock, caplog): ' "command_on_template": "on",' ' "command_off_template": "off"}' ) - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data) - await hass.async_block_till_done() - state = hass.states.get("light.beer") - assert state is not None - assert state.name == "Beer" - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", "") - await hass.async_block_till_done() - state = hass.states.get("light.beer") - assert state is None + await help_test_discovery_removal(hass, mqtt_mock, caplog, light.DOMAIN, data) async def test_discovery_deprecated(hass, mqtt_mock, caplog): @@ -698,9 +656,6 @@ async def test_discovery_deprecated(hass, mqtt_mock, caplog): async def test_discovery_update_light(hass, mqtt_mock, caplog): """Test update of discovered light.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data1 = ( '{ "name": "Beer",' ' "schema": "template",' @@ -717,29 +672,13 @@ async def test_discovery_update_light(hass, mqtt_mock, caplog): ' "command_on_template": "on",' ' "command_off_template": "off"}' ) - - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("light.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("light.beer") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("light.milk") - assert state is None + await help_test_discovery_update( + hass, mqtt_mock, caplog, light.DOMAIN, data1, data2 + ) async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data1 = '{ "name": "Beer" }' data2 = ( '{ "name": "Milk",' @@ -749,30 +688,13 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): ' "command_on_template": "on",' ' "command_off_template": "off"}' ) - - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("light.beer") - assert state is None - - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("light.milk") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("light.beer") - assert state is None + await help_test_discovery_broken( + hass, mqtt_mock, caplog, light.DOMAIN, data1, data2 + ) async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT light device registry integration.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - data = json.dumps( { "platform": "mqtt", @@ -793,26 +715,13 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): "unique_id": "veryunique", } ) - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.identifiers == {("mqtt", "helloworld")} - assert device.connections == {("mac", "02:5b:26:a8:dc:12")} - assert device.manufacturer == "Whatever" - assert device.name == "Beer" - assert device.model == "Glass" - assert device.sw_version == "0.1-beta" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, light.DOMAIN, data + ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - config = { "platform": "mqtt", "name": "Test 1", @@ -831,64 +740,24 @@ async def test_entity_device_info_update(hass, mqtt_mock): }, "unique_id": "veryunique", } - - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Beer" - - config["device"]["name"] = "Milk" - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/light/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Milk" + await help_test_entity_device_info_update(hass, mqtt_mock, light.DOMAIN, config) async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - registry = mock_registry(hass, {}) - mock_mqtt = await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - light.DOMAIN, - { - light.DOMAIN: [ - { - "platform": "mqtt", - "name": "beer", - "schema": "template", - "state_topic": "test-topic", - "command_topic": "command-topic", - "command_on_template": "on,{{ transition }}", - "command_off_template": "off,{{ transition|d }}", - "availability_topic": "avty-topic", - "unique_id": "TOTALLY_UNIQUE", - } - ] - }, - ) - - state = hass.states.get("light.beer") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.reset_mock() - - registry.async_update_entity("light.beer", new_entity_id="light.milk") - await hass.async_block_till_done() - - state = hass.states.get("light.beer") - assert state is None - - state = hass.states.get("light.milk") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") + config = { + light.DOMAIN: [ + { + "platform": "mqtt", + "name": "beer", + "schema": "template", + "state_topic": "test-topic", + "command_topic": "command-topic", + "command_on_template": "on,{{ transition }}", + "command_off_template": "off,{{ transition|d }}", + "availability_topic": "avty-topic", + "unique_id": "TOTALLY_UNIQUE", + } + ] + } + await help_test_entity_id_update(hass, mqtt_mock, light.DOMAIN, config) diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index 9b89fa7159d..a9c1fe73952 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -1,9 +1,7 @@ """The tests for the MQTT lock platform.""" import json -from unittest.mock import ANY -from homeassistant.components import lock, mqtt -from homeassistant.components.mqtt.discovery import async_start +from homeassistant.components import lock from homeassistant.const import ( ATTR_ASSUMED_STATE, STATE_LOCKED, @@ -12,12 +10,21 @@ from homeassistant.const import ( ) from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - async_fire_mqtt_message, - async_mock_mqtt_component, - mock_registry, +from .common import ( + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_entity_device_info_update, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, ) + +from tests.common import async_fire_mqtt_message from tests.components.lock import common @@ -309,73 +316,51 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - lock.DOMAIN, - { - lock.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + lock.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, lock.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') - state = hass.states.get("lock.test") - - assert state.attributes.get("val") == "100" - async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - lock.DOMAIN, - { - lock.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + lock.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, lock.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') - state = hass.states.get("lock.test") - - assert state.attributes.get("val") is None - assert "JSON result was not a dictionary" in caplog.text - async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - lock.DOMAIN, - { - lock.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + lock.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, lock.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") - - state = hass.states.get("lock.test") - assert state.attributes.get("val") is None - assert "Erroneous JSON: This is not JSON" in caplog.text - async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = ( '{ "name": "Beer",' ' "command_topic": "test_topic",' @@ -386,100 +371,42 @@ async def test_discovery_update_attr(hass, mqtt_mock, caplog): ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) - async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data1) - await hass.async_block_till_done() - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }') - state = hass.states.get("lock.beer") - assert state.attributes.get("val") == "100" - - # Change json_attributes_topic - async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data2) - await hass.async_block_till_done() - - # Verify we are no longer subscribing to the old topic - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') - state = hass.states.get("lock.beer") - assert state.attributes.get("val") == "100" - - # Verify we are subscribing to the new topic - async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') - state = hass.states.get("lock.beer") - assert state.attributes.get("val") == "75" + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, lock.DOMAIN, data1, data2 + ) async def test_unique_id(hass): - """Test unique id option only creates one light per unique_id.""" - await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - lock.DOMAIN, - { - lock.DOMAIN: [ - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "platform": "mqtt", - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "test_topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - }, - ) - async_fire_mqtt_message(hass, "test-topic", "payload") - assert len(hass.states.async_entity_ids(lock.DOMAIN)) == 1 + """Test unique id option only creates one lock per unique_id.""" + config = { + lock.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "test_topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, lock.DOMAIN, config) async def test_discovery_removal_lock(hass, mqtt_mock, caplog): """Test removal of discovered lock.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data = '{ "name": "Beer",' ' "command_topic": "test_topic" }' - async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data) - await hass.async_block_till_done() - state = hass.states.get("lock.beer") - assert state is not None - assert state.name == "Beer" - async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", "") - await hass.async_block_till_done() - state = hass.states.get("lock.beer") - assert state is None - - -async def test_discovery_broken(hass, mqtt_mock, caplog): - """Test handling of bad discovery message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - - data1 = '{ "name": "Beer" }' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' - - async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("lock.beer") - assert state is None - - async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("lock.milk") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("lock.beer") - assert state is None + await help_test_discovery_removal(hass, mqtt_mock, caplog, lock.DOMAIN, data) async def test_discovery_update_lock(hass, mqtt_mock, caplog): """Test update of discovered lock.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = ( '{ "name": "Beer",' ' "state_topic": "test_topic",' @@ -492,28 +419,18 @@ async def test_discovery_update_lock(hass, mqtt_mock, caplog): ' "command_topic": "command_topic",' ' "availability_topic": "availability_topic2" }' ) - async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data1) - await hass.async_block_till_done() - state = hass.states.get("lock.beer") - assert state is not None - assert state.name == "Beer" - async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data2) - await hass.async_block_till_done() - state = hass.states.get("lock.beer") - assert state is not None - assert state.name == "Milk" + await help_test_discovery_update(hass, mqtt_mock, caplog, lock.DOMAIN, data1, data2) - state = hass.states.get("lock.milk") - assert state is None + +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + data1 = '{ "name": "Beer" }' + data2 = '{ "name": "Milk",' ' "command_topic": "test_topic" }' + await help_test_discovery_broken(hass, mqtt_mock, caplog, lock.DOMAIN, data1, data2) async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT lock device registry integration.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - data = json.dumps( { "platform": "mqtt", @@ -531,26 +448,13 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): "unique_id": "veryunique", } ) - async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.identifiers == {("mqtt", "helloworld")} - assert device.connections == {("mac", "02:5b:26:a8:dc:12")} - assert device.manufacturer == "Whatever" - assert device.name == "Beer" - assert device.model == "Glass" - assert device.sw_version == "0.1-beta" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, lock.DOMAIN, data + ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - config = { "platform": "mqtt", "name": "Test 1", @@ -566,61 +470,21 @@ async def test_entity_device_info_update(hass, mqtt_mock): }, "unique_id": "veryunique", } - - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Beer" - - config["device"]["name"] = "Milk" - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/lock/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Milk" + await help_test_entity_device_info_update(hass, mqtt_mock, lock.DOMAIN, config) async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - registry = mock_registry(hass, {}) - mock_mqtt = await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - lock.DOMAIN, - { - lock.DOMAIN: [ - { - "platform": "mqtt", - "name": "beer", - "state_topic": "test-topic", - "command_topic": "test-topic", - "availability_topic": "avty-topic", - "unique_id": "TOTALLY_UNIQUE", - } - ] - }, - ) - - state = hass.states.get("lock.beer") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.reset_mock() - - registry.async_update_entity("lock.beer", new_entity_id="lock.milk") - await hass.async_block_till_done() - - state = hass.states.get("lock.beer") - assert state is None - - state = hass.states.get("lock.milk") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") + config = { + lock.DOMAIN: [ + { + "platform": "mqtt", + "name": "beer", + "state_topic": "test-topic", + "command_topic": "command-topic", + "availability_topic": "avty-topic", + "unique_id": "TOTALLY_UNIQUE", + } + ] + } + await help_test_entity_id_update(hass, mqtt_mock, lock.DOMAIN, config) diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 66f8996bc2e..be29a297d3d 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -1,7 +1,7 @@ """The tests for the MQTT sensor platform.""" from datetime import datetime, timedelta import json -from unittest.mock import ANY, patch +from unittest.mock import patch from homeassistant.components import mqtt from homeassistant.components.mqtt.discovery import async_start @@ -11,12 +11,24 @@ import homeassistant.core as ha from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util +from .common import ( + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_entity_device_info_update, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, +) + from tests.common import ( MockConfigEntry, async_fire_mqtt_message, async_fire_time_changed, - async_mock_mqtt_component, - mock_registry, ) @@ -395,24 +407,18 @@ async def test_valid_device_class(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - sensor.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, sensor.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') - state = hass.states.get("sensor.test") - - assert state.attributes.get("val") == "100" - async def test_setting_attribute_with_template(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" @@ -441,52 +447,36 @@ async def test_setting_attribute_with_template(hass, mqtt_mock): async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - sensor.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, sensor.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') - state = hass.states.get("sensor.test") - - assert state.attributes.get("val") is None - assert "JSON result was not a dictionary" in caplog.text - async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - sensor.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, sensor.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") - - state = hass.states.get("sensor.test") - assert state.attributes.get("val") is None - assert "Erroneous JSON: This is not JSON" in caplog.text - async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = ( '{ "name": "Beer",' ' "state_topic": "test_topic",' @@ -497,127 +487,58 @@ async def test_discovery_update_attr(hass, mqtt_mock, caplog): ' "state_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) - async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data1) - await hass.async_block_till_done() - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }') - state = hass.states.get("sensor.beer") - assert state.attributes.get("val") == "100" - - # Change json_attributes_topic - async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data2) - await hass.async_block_till_done() - - # Verify we are no longer subscribing to the old topic - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') - state = hass.states.get("sensor.beer") - assert state.attributes.get("val") == "100" - - # Verify we are subscribing to the new topic - async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') - state = hass.states.get("sensor.beer") - assert state.attributes.get("val") == "75" + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, sensor.DOMAIN, data1, data2 + ) async def test_unique_id(hass): """Test unique id option only creates one sensor per unique_id.""" - await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - sensor.DOMAIN: [ - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "platform": "mqtt", - "name": "Test 2", - "state_topic": "test-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - }, - ) - - async_fire_mqtt_message(hass, "test-topic", "payload") - - assert len(hass.states.async_all()) == 1 + config = { + sensor.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "state_topic": "test-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, sensor.DOMAIN, config) async def test_discovery_removal_sensor(hass, mqtt_mock, caplog): """Test removal of discovered sensor.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data = '{ "name": "Beer",' ' "state_topic": "test_topic" }' - async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) - await hass.async_block_till_done() - state = hass.states.get("sensor.beer") - assert state is not None - assert state.name == "Beer" - async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", "") - await hass.async_block_till_done() - state = hass.states.get("sensor.beer") - assert state is None + await help_test_discovery_removal(hass, mqtt_mock, caplog, sensor.DOMAIN, data) async def test_discovery_update_sensor(hass, mqtt_mock, caplog): """Test update of discovered sensor.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = '{ "name": "Beer",' ' "state_topic": "test_topic" }' data2 = '{ "name": "Milk",' ' "state_topic": "test_topic" }' - async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("sensor.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("sensor.beer") - assert state is not None - assert state.name == "Milk" - - state = hass.states.get("sensor.milk") - assert state is None + await help_test_discovery_update( + hass, mqtt_mock, caplog, sensor.DOMAIN, data1, data2 + ) async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data1 = '{ "name": "Beer",' ' "state_topic": "test_topic#" }' data2 = '{ "name": "Milk",' ' "state_topic": "test_topic" }' - - async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("sensor.beer") - assert state is None - - async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("sensor.milk") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("sensor.beer") - assert state is None + await help_test_discovery_broken( + hass, mqtt_mock, caplog, sensor.DOMAIN, data1, data2 + ) async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT sensor device registry integration.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - data = json.dumps( { "platform": "mqtt", @@ -634,26 +555,13 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): "unique_id": "veryunique", } ) - async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.identifiers == {("mqtt", "helloworld")} - assert device.connections == {("mac", "02:5b:26:a8:dc:12")} - assert device.manufacturer == "Whatever" - assert device.name == "Beer" - assert device.model == "Glass" - assert device.sw_version == "0.1-beta" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, sensor.DOMAIN, data + ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - config = { "platform": "mqtt", "name": "Test 1", @@ -668,63 +576,23 @@ async def test_entity_device_info_update(hass, mqtt_mock): }, "unique_id": "veryunique", } - - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Beer" - - config["device"]["name"] = "Milk" - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/sensor/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Milk" + await help_test_entity_device_info_update(hass, mqtt_mock, sensor.DOMAIN, config) async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - registry = mock_registry(hass, {}) - mock_mqtt = await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - sensor.DOMAIN, - { - sensor.DOMAIN: [ - { - "platform": "mqtt", - "name": "beer", - "state_topic": "test-topic", - "availability_topic": "avty-topic", - "unique_id": "TOTALLY_UNIQUE", - } - ] - }, - ) - - state = hass.states.get("sensor.beer") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.reset_mock() - - registry.async_update_entity("sensor.beer", new_entity_id="sensor.milk") - await hass.async_block_till_done() - - state = hass.states.get("sensor.beer") - assert state is None - - state = hass.states.get("sensor.milk") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") + config = { + sensor.DOMAIN: [ + { + "platform": "mqtt", + "name": "beer", + "state_topic": "test-topic", + "availability_topic": "avty-topic", + "unique_id": "TOTALLY_UNIQUE", + } + ] + } + await help_test_entity_id_update(hass, mqtt_mock, sensor.DOMAIN, config) async def test_entity_device_info_with_hub(hass, mqtt_mock): diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index d7e45004f13..146f69b30d7 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -2,9 +2,8 @@ from copy import deepcopy import json -from homeassistant.components import mqtt, vacuum +from homeassistant.components import vacuum from homeassistant.components.mqtt import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC -from homeassistant.components.mqtt.discovery import async_start from homeassistant.components.mqtt.vacuum import CONF_SCHEMA, schema_state as mqttvacuum from homeassistant.components.mqtt.vacuum.schema import services_to_strings from homeassistant.components.mqtt.vacuum.schema_state import SERVICE_TO_STRING @@ -32,11 +31,21 @@ from homeassistant.const import ( ) from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - async_fire_mqtt_message, - async_mock_mqtt_component, +from .common import ( + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_entity_device_info_update, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, ) + +from tests.common import async_fire_mqtt_message from tests.components.vacuum import common COMMAND_TOPIC = "vacuum/command" @@ -340,212 +349,123 @@ async def test_custom_availability_payload(hass, mqtt_mock): assert state.state == STATE_UNAVAILABLE -async def test_discovery_removal_vacuum(hass, mqtt_mock): - """Test removal of discovered vacuum.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - - data = '{ "name": "Beer",' ' "command_topic": "test_topic"}' - - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data) - await hass.async_block_till_done() - - state = hass.states.get("vacuum.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", "") - await hass.async_block_till_done() - - state = hass.states.get("vacuum.beer") - assert state is None - - -async def test_discovery_broken(hass, mqtt_mock, caplog): - """Test handling of bad discovery message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - - data1 = '{ "name": "Beer",' ' "command_topic": "test_topic#"}' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic"}' - - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("vacuum.beer") - assert state is None - - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("vacuum.milk") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("vacuum.beer") - assert state is None - - -async def test_discovery_update_vacuum(hass, mqtt_mock): - """Test update of discovered vacuum.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - - data1 = '{ "name": "Beer",' ' "command_topic": "test_topic"}' - data2 = '{ "name": "Milk",' ' "command_topic": "test_topic"}' - - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("vacuum.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("vacuum.beer") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("vacuum.milk") - assert state is None - - async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - vacuum.DOMAIN, - { - vacuum.DOMAIN: { - "platform": "mqtt", - "name": "test", - "json_attributes_topic": "attr-topic", - } - }, + config = { + vacuum.DOMAIN: { + "platform": "mqtt", + "schema": "state", + "name": "test", + "json_attributes_topic": "attr-topic", + } + } + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, vacuum.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') - state = hass.states.get("vacuum.test") - - assert state.attributes.get("val") == "100" - async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - vacuum.DOMAIN, - { - vacuum.DOMAIN: { - "platform": "mqtt", - "name": "test", - "json_attributes_topic": "attr-topic", - } - }, + config = { + vacuum.DOMAIN: { + "platform": "mqtt", + "schema": "state", + "name": "test", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, vacuum.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') - state = hass.states.get("vacuum.test") - - assert state.attributes.get("val") is None - assert "JSON result was not a dictionary" in caplog.text - async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - vacuum.DOMAIN, - { - vacuum.DOMAIN: { - "platform": "mqtt", - "name": "test", - "json_attributes_topic": "attr-topic", - } - }, + config = { + vacuum.DOMAIN: { + "platform": "mqtt", + "schema": "state", + "name": "test", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, vacuum.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") - - state = hass.states.get("vacuum.test") - assert state.attributes.get("val") is None - assert "Erroneous JSON: This is not JSON" in caplog.text - async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = ( '{ "name": "Beer",' + ' "schema": "state",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic1" }' ) data2 = ( '{ "name": "Beer",' + ' "schema": "state",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data1) - await hass.async_block_till_done() - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }') - state = hass.states.get("vacuum.beer") - assert state.attributes.get("val") == "100" - - # Change json_attributes_topic - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data2) - await hass.async_block_till_done() - - # Verify we are no longer subscribing to the old topic - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') - state = hass.states.get("vacuum.beer") - assert state.attributes.get("val") == "100" - - # Verify we are subscribing to the new topic - async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') - state = hass.states.get("vacuum.beer") - assert state.attributes.get("val") == "75" + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2 + ) async def test_unique_id(hass, mqtt_mock): """Test unique id option only creates one vacuum per unique_id.""" - await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - vacuum.DOMAIN, - { - vacuum.DOMAIN: [ - { - "platform": "mqtt", - "name": "Test 1", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "platform": "mqtt", - "name": "Test 2", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - }, + config = { + vacuum.DOMAIN: [ + { + "platform": "mqtt", + "schema": "state", + "name": "Test 1", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "schema": "state", + "name": "Test 2", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, vacuum.DOMAIN, config) + + +async def test_discovery_removal_vacuum(hass, mqtt_mock, caplog): + """Test removal of discovered vacuum.""" + data = '{ "schema": "state", "name": "Beer",' ' "command_topic": "test_topic"}' + await help_test_discovery_removal(hass, mqtt_mock, caplog, vacuum.DOMAIN, data) + + +async def test_discovery_update_vacuum(hass, mqtt_mock, caplog): + """Test update of discovered vacuum.""" + data1 = '{ "schema": "state", "name": "Beer",' ' "command_topic": "test_topic"}' + data2 = '{ "schema": "state", "name": "Milk",' ' "command_topic": "test_topic"}' + await help_test_discovery_update( + hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2 ) - async_fire_mqtt_message(hass, "test-topic", "payload") - assert len(hass.states.async_entity_ids()) == 1 +async def test_discovery_broken(hass, mqtt_mock, caplog): + """Test handling of bad discovery message.""" + data1 = '{ "schema": "state", "name": "Beer",' ' "command_topic": "test_topic#"}' + data2 = '{ "schema": "state", "name": "Milk",' ' "command_topic": "test_topic"}' + await help_test_discovery_broken( + hass, mqtt_mock, caplog, vacuum.DOMAIN, data1, data2 + ) async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT vacuum device registry integration.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - data = json.dumps( { "platform": "mqtt", + "schema": "state", "name": "Test 1", "command_topic": "test-command-topic", "device": { @@ -559,28 +479,16 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): "unique_id": "veryunique", } ) - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.identifiers == {("mqtt", "helloworld")} - assert device.connections == {("mac", "02:5b:26:a8:dc:12")} - assert device.manufacturer == "Whatever" - assert device.name == "Beer" - assert device.model == "Glass" - assert device.sw_version == "0.1-beta" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, vacuum.DOMAIN, data + ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - config = { "platform": "mqtt", + "schema": "state", "name": "Test 1", "command_topic": "test-command-topic", "device": { @@ -593,20 +501,22 @@ async def test_entity_device_info_update(hass, mqtt_mock): }, "unique_id": "veryunique", } + await help_test_entity_device_info_update(hass, mqtt_mock, vacuum.DOMAIN, config) - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data) - await hass.async_block_till_done() - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Beer" - - config["device"]["name"] = "Milk" - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/vacuum/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Milk" +async def test_entity_id_update(hass, mqtt_mock): + """Test MQTT subscriptions are managed when entity_id is updated.""" + config = { + vacuum.DOMAIN: [ + { + "platform": "mqtt", + "schema": "state", + "name": "beer", + "state_topic": "test-topic", + "command_topic": "command-topic", + "availability_topic": "avty-topic", + "unique_id": "TOTALLY_UNIQUE", + } + ] + } + await help_test_entity_id_update(hass, mqtt_mock, vacuum.DOMAIN, config) diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index 8412edffbbe..cfb5c3598b2 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -1,12 +1,10 @@ """The tests for the MQTT switch platform.""" import json -from unittest.mock import ANY from asynctest import patch import pytest -from homeassistant.components import mqtt, switch -from homeassistant.components.mqtt.discovery import async_start +from homeassistant.components import switch from homeassistant.const import ( ATTR_ASSUMED_STATE, STATE_OFF, @@ -16,13 +14,21 @@ from homeassistant.const import ( import homeassistant.core as ha from homeassistant.setup import async_setup_component -from tests.common import ( - MockConfigEntry, - async_fire_mqtt_message, - async_mock_mqtt_component, - mock_coro, - mock_registry, +from .common import ( + help_test_discovery_broken, + help_test_discovery_removal, + help_test_discovery_update, + help_test_discovery_update_attr, + help_test_entity_device_info_update, + help_test_entity_device_info_with_identifier, + help_test_entity_id_update, + help_test_setting_attribute_via_mqtt_json_message, + help_test_unique_id, + help_test_update_with_json_attrs_bad_JSON, + help_test_update_with_json_attrs_not_dict, ) + +from tests.common import async_fire_mqtt_message, async_mock_mqtt_component, mock_coro from tests.components.switch import common @@ -265,73 +271,51 @@ async def test_custom_state_payload(hass, mock_publish): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - assert await async_setup_component( - hass, - switch.DOMAIN, - { - switch.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + switch.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, switch.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') - state = hass.states.get("switch.test") - - assert state.attributes.get("val") == "100" - async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - switch.DOMAIN, - { - switch.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + switch.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_not_dict( + hass, mqtt_mock, caplog, switch.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') - state = hass.states.get("switch.test") - - assert state.attributes.get("val") is None - assert "JSON result was not a dictionary" in caplog.text - async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - assert await async_setup_component( - hass, - switch.DOMAIN, - { - switch.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - }, + config = { + switch.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } + } + await help_test_update_with_json_attrs_bad_JSON( + hass, mqtt_mock, caplog, switch.DOMAIN, config ) - async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") - - state = hass.states.get("switch.test") - assert state.attributes.get("val") is None - assert "Erroneous JSON: This is not JSON" in caplog.text - async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) data1 = ( '{ "name": "Beer",' ' "command_topic": "test_topic",' @@ -342,88 +326,46 @@ async def test_discovery_update_attr(hass, mqtt_mock, caplog): ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) - async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data1) - await hass.async_block_till_done() - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }') - state = hass.states.get("switch.beer") - assert state.attributes.get("val") == "100" - - # Change json_attributes_topic - async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data2) - await hass.async_block_till_done() - - # Verify we are no longer subscribing to the old topic - async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') - state = hass.states.get("switch.beer") - assert state.attributes.get("val") == "100" - - # Verify we are subscribing to the new topic - async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') - state = hass.states.get("switch.beer") - assert state.attributes.get("val") == "75" + await help_test_discovery_update_attr( + hass, mqtt_mock, caplog, switch.DOMAIN, data1, data2 + ) async def test_unique_id(hass): """Test unique id option only creates one switch per unique_id.""" - await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - switch.DOMAIN, - { - switch.DOMAIN: [ - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - { - "platform": "mqtt", - "name": "Test 2", - "state_topic": "test-topic", - "command_topic": "command-topic", - "unique_id": "TOTALLY_UNIQUE", - }, - ] - }, - ) - - async_fire_mqtt_message(hass, "test-topic", "payload") - - assert len(hass.states.async_entity_ids()) == 1 + config = { + switch.DOMAIN: [ + { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + { + "platform": "mqtt", + "name": "Test 2", + "state_topic": "test-topic", + "command_topic": "command-topic", + "unique_id": "TOTALLY_UNIQUE", + }, + ] + } + await help_test_unique_id(hass, switch.DOMAIN, config) async def test_discovery_removal_switch(hass, mqtt_mock, caplog): """Test removal of discovered switch.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data = ( '{ "name": "Beer",' ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - - async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data) - await hass.async_block_till_done() - - state = hass.states.get("switch.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", "") - await hass.async_block_till_done() - - state = hass.states.get("switch.beer") - assert state is None + await help_test_discovery_removal(hass, mqtt_mock, caplog, switch.DOMAIN, data) async def test_discovery_update_switch(hass, mqtt_mock, caplog): """Test update of discovered switch.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data1 = ( '{ "name": "Beer",' ' "state_topic": "test_topic",' @@ -434,59 +376,26 @@ async def test_discovery_update_switch(hass, mqtt_mock, caplog): ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - - async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("switch.beer") - assert state is not None - assert state.name == "Beer" - - async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("switch.beer") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("switch.milk") - assert state is None + await help_test_discovery_update( + hass, mqtt_mock, caplog, switch.DOMAIN, data1, data2 + ) async def test_discovery_broken(hass, mqtt_mock, caplog): """Test handling of bad discovery message.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - await async_start(hass, "homeassistant", {}, entry) - data1 = '{ "name": "Beer" }' data2 = ( '{ "name": "Milk",' ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) - - async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data1) - await hass.async_block_till_done() - - state = hass.states.get("switch.beer") - assert state is None - - async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data2) - await hass.async_block_till_done() - - state = hass.states.get("switch.milk") - assert state is not None - assert state.name == "Milk" - state = hass.states.get("switch.beer") - assert state is None + await help_test_discovery_broken( + hass, mqtt_mock, caplog, switch.DOMAIN, data1, data2 + ) async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT switch device registry integration.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - data = json.dumps( { "platform": "mqtt", @@ -504,26 +413,13 @@ async def test_entity_device_info_with_identifier(hass, mqtt_mock): "unique_id": "veryunique", } ) - async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.identifiers == {("mqtt", "helloworld")} - assert device.connections == {("mac", "02:5b:26:a8:dc:12")} - assert device.manufacturer == "Whatever" - assert device.name == "Beer" - assert device.model == "Glass" - assert device.sw_version == "0.1-beta" + await help_test_entity_device_info_with_identifier( + hass, mqtt_mock, switch.DOMAIN, data + ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - entry = MockConfigEntry(domain=mqtt.DOMAIN) - entry.add_to_hass(hass) - await async_start(hass, "homeassistant", {}, entry) - registry = await hass.helpers.device_registry.async_get_registry() - config = { "platform": "mqtt", "name": "Test 1", @@ -539,61 +435,21 @@ async def test_entity_device_info_update(hass, mqtt_mock): }, "unique_id": "veryunique", } - - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Beer" - - config["device"]["name"] = "Milk" - data = json.dumps(config) - async_fire_mqtt_message(hass, "homeassistant/switch/bla/config", data) - await hass.async_block_till_done() - - device = registry.async_get_device({("mqtt", "helloworld")}, set()) - assert device is not None - assert device.name == "Milk" + await help_test_entity_device_info_update(hass, mqtt_mock, switch.DOMAIN, config) async def test_entity_id_update(hass, mqtt_mock): """Test MQTT subscriptions are managed when entity_id is updated.""" - registry = mock_registry(hass, {}) - mock_mqtt = await async_mock_mqtt_component(hass) - assert await async_setup_component( - hass, - switch.DOMAIN, - { - switch.DOMAIN: [ - { - "platform": "mqtt", - "name": "beer", - "state_topic": "test-topic", - "command_topic": "command-topic", - "availability_topic": "avty-topic", - "unique_id": "TOTALLY_UNIQUE", - } - ] - }, - ) - - state = hass.states.get("switch.beer") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.reset_mock() - - registry.async_update_entity("switch.beer", new_entity_id="switch.milk") - await hass.async_block_till_done() - - state = hass.states.get("switch.beer") - assert state is None - - state = hass.states.get("switch.milk") - assert state is not None - assert mock_mqtt.async_subscribe.call_count == 2 - mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, "utf-8") - mock_mqtt.async_subscribe.assert_any_call("avty-topic", ANY, 0, "utf-8") + config = { + switch.DOMAIN: [ + { + "platform": "mqtt", + "name": "beer", + "state_topic": "test-topic", + "command_topic": "command-topic", + "availability_topic": "avty-topic", + "unique_id": "TOTALLY_UNIQUE", + } + ] + } + await help_test_entity_id_update(hass, mqtt_mock, switch.DOMAIN, config) From 96a0aa3d2b95d042a480d2cbfeef1f7c7bb9a86a Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 9 Mar 2020 19:54:43 +0100 Subject: [PATCH 297/416] Add new sensors to Brother integration (#32466) * Add new sensors * Suggested change --- homeassistant/components/brother/const.py | 38 ++++++++++++++++++++++ homeassistant/components/brother/sensor.py | 38 +++++++++++++++++++--- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/brother/const.py b/homeassistant/components/brother/const.py index e887ad6de21..94d88162d76 100644 --- a/homeassistant/components/brother/const.py +++ b/homeassistant/components/brother/const.py @@ -2,19 +2,29 @@ from homeassistant.const import TIME_DAYS, UNIT_PERCENTAGE ATTR_BELT_UNIT_REMAINING_LIFE = "belt_unit_remaining_life" +ATTR_BLACK_DRUM_COUNTER = "black_drum_counter" +ATTR_BLACK_DRUM_REMAINING_LIFE = "black_drum_remaining_life" +ATTR_BLACK_DRUM_REMAINING_PAGES = "black_drum_remaining_pages" ATTR_BLACK_INK_REMAINING = "black_ink_remaining" ATTR_BLACK_TONER_REMAINING = "black_toner_remaining" ATTR_BW_COUNTER = "b/w_counter" ATTR_COLOR_COUNTER = "color_counter" +ATTR_CYAN_DRUM_COUNTER = "cyan_drum_counter" +ATTR_CYAN_DRUM_REMAINING_LIFE = "cyan_drum_remaining_life" +ATTR_CYAN_DRUM_REMAINING_PAGES = "cyan_drum_remaining_pages" ATTR_CYAN_INK_REMAINING = "cyan_ink_remaining" ATTR_CYAN_TONER_REMAINING = "cyan_toner_remaining" ATTR_DRUM_COUNTER = "drum_counter" ATTR_DRUM_REMAINING_LIFE = "drum_remaining_life" ATTR_DRUM_REMAINING_PAGES = "drum_remaining_pages" +ATTR_DUPLEX_COUNTER = "duplex_unit_pages_counter" ATTR_FUSER_REMAINING_LIFE = "fuser_remaining_life" ATTR_ICON = "icon" ATTR_LABEL = "label" ATTR_LASER_REMAINING_LIFE = "laser_remaining_life" +ATTR_MAGENTA_DRUM_COUNTER = "magenta_drum_counter" +ATTR_MAGENTA_DRUM_REMAINING_LIFE = "magenta_drum_remaining_life" +ATTR_MAGENTA_DRUM_REMAINING_PAGES = "magenta_drum_remaining_pages" ATTR_MAGENTA_INK_REMAINING = "magenta_ink_remaining" ATTR_MAGENTA_TONER_REMAINING = "magenta_toner_remaining" ATTR_MANUFACTURER = "Brother" @@ -24,6 +34,9 @@ ATTR_PF_KIT_MP_REMAINING_LIFE = "pf_kit_mp_remaining_life" ATTR_STATUS = "status" ATTR_UNIT = "unit" ATTR_UPTIME = "uptime" +ATTR_YELLOW_DRUM_COUNTER = "yellow_drum_counter" +ATTR_YELLOW_DRUM_REMAINING_LIFE = "yellow_drum_remaining_life" +ATTR_YELLOW_DRUM_REMAINING_PAGES = "yellow_drum_remaining_pages" ATTR_YELLOW_INK_REMAINING = "yellow_ink_remaining" ATTR_YELLOW_TONER_REMAINING = "yellow_toner_remaining" @@ -54,11 +67,36 @@ SENSOR_TYPES = { ATTR_LABEL: ATTR_COLOR_COUNTER.replace("_", " ").title(), ATTR_UNIT: UNIT_PAGES, }, + ATTR_DUPLEX_COUNTER: { + ATTR_ICON: "mdi:file-document-outline", + ATTR_LABEL: ATTR_DUPLEX_COUNTER.replace("_", " ").title(), + ATTR_UNIT: UNIT_PAGES, + }, ATTR_DRUM_REMAINING_LIFE: { ATTR_ICON: "mdi:chart-donut", ATTR_LABEL: ATTR_DRUM_REMAINING_LIFE.replace("_", " ").title(), ATTR_UNIT: UNIT_PERCENTAGE, }, + ATTR_BLACK_DRUM_REMAINING_LIFE: { + ATTR_ICON: "mdi:chart-donut", + ATTR_LABEL: ATTR_BLACK_DRUM_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_CYAN_DRUM_REMAINING_LIFE: { + ATTR_ICON: "mdi:chart-donut", + ATTR_LABEL: ATTR_CYAN_DRUM_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_MAGENTA_DRUM_REMAINING_LIFE: { + ATTR_ICON: "mdi:chart-donut", + ATTR_LABEL: ATTR_MAGENTA_DRUM_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, + ATTR_YELLOW_DRUM_REMAINING_LIFE: { + ATTR_ICON: "mdi:chart-donut", + ATTR_LABEL: ATTR_YELLOW_DRUM_REMAINING_LIFE.replace("_", " ").title(), + ATTR_UNIT: UNIT_PERCENTAGE, + }, ATTR_BELT_UNIT_REMAINING_LIFE: { ATTR_ICON: "mdi:current-ac", ATTR_LABEL: ATTR_BELT_UNIT_REMAINING_LIFE.replace("_", " ").title(), diff --git a/homeassistant/components/brother/sensor.py b/homeassistant/components/brother/sensor.py index 9ad075f81cd..e118e65e9a5 100644 --- a/homeassistant/components/brother/sensor.py +++ b/homeassistant/components/brother/sensor.py @@ -4,17 +4,32 @@ import logging from homeassistant.helpers.entity import Entity from .const import ( + ATTR_BLACK_DRUM_COUNTER, + ATTR_BLACK_DRUM_REMAINING_LIFE, + ATTR_BLACK_DRUM_REMAINING_PAGES, + ATTR_CYAN_DRUM_COUNTER, + ATTR_CYAN_DRUM_REMAINING_LIFE, + ATTR_CYAN_DRUM_REMAINING_PAGES, ATTR_DRUM_COUNTER, ATTR_DRUM_REMAINING_LIFE, ATTR_DRUM_REMAINING_PAGES, ATTR_ICON, ATTR_LABEL, + ATTR_MAGENTA_DRUM_COUNTER, + ATTR_MAGENTA_DRUM_REMAINING_LIFE, + ATTR_MAGENTA_DRUM_REMAINING_PAGES, ATTR_MANUFACTURER, ATTR_UNIT, + ATTR_YELLOW_DRUM_COUNTER, + ATTR_YELLOW_DRUM_REMAINING_LIFE, + ATTR_YELLOW_DRUM_REMAINING_PAGES, DOMAIN, SENSOR_TYPES, ) +ATTR_COUNTER = "counter" +ATTR_REMAINING_PAGES = "remaining_pages" + _LOGGER = logging.getLogger(__name__) @@ -65,11 +80,26 @@ class BrotherPrinterSensor(Entity): @property def device_state_attributes(self): """Return the state attributes.""" + remaining_pages = None + drum_counter = None if self.kind == ATTR_DRUM_REMAINING_LIFE: - self._attrs["remaining_pages"] = self.printer.data.get( - ATTR_DRUM_REMAINING_PAGES - ) - self._attrs["counter"] = self.printer.data.get(ATTR_DRUM_COUNTER) + remaining_pages = ATTR_DRUM_REMAINING_PAGES + drum_counter = ATTR_DRUM_COUNTER + elif self.kind == ATTR_BLACK_DRUM_REMAINING_LIFE: + remaining_pages = ATTR_BLACK_DRUM_REMAINING_PAGES + drum_counter = ATTR_BLACK_DRUM_COUNTER + elif self.kind == ATTR_CYAN_DRUM_REMAINING_LIFE: + remaining_pages = ATTR_CYAN_DRUM_REMAINING_PAGES + drum_counter = ATTR_CYAN_DRUM_COUNTER + elif self.kind == ATTR_MAGENTA_DRUM_REMAINING_LIFE: + remaining_pages = ATTR_MAGENTA_DRUM_REMAINING_PAGES + drum_counter = ATTR_MAGENTA_DRUM_COUNTER + elif self.kind == ATTR_YELLOW_DRUM_REMAINING_LIFE: + remaining_pages = ATTR_YELLOW_DRUM_REMAINING_PAGES + drum_counter = ATTR_YELLOW_DRUM_COUNTER + if remaining_pages and drum_counter: + self._attrs[ATTR_REMAINING_PAGES] = self.printer.data.get(remaining_pages) + self._attrs[ATTR_COUNTER] = self.printer.data.get(drum_counter) return self._attrs @property From 09512e9f8b1f5aaac79c9200faea17ed1d67eb7d Mon Sep 17 00:00:00 2001 From: marengaz Date: Mon, 9 Mar 2020 13:46:52 -0600 Subject: [PATCH 298/416] Reflect new repo name (#32611) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 0564b7f4773..7794a177b1f 100755 --- a/setup.py +++ b/setup.py @@ -15,7 +15,7 @@ PROJECT_URL = "https://www.home-assistant.io/" PROJECT_EMAIL = "hello@home-assistant.io" PROJECT_GITHUB_USERNAME = "home-assistant" -PROJECT_GITHUB_REPOSITORY = "home-assistant" +PROJECT_GITHUB_REPOSITORY = "core" PYPI_URL = "https://pypi.python.org/pypi/{}".format(PROJECT_PACKAGE_NAME) GITHUB_PATH = "{}/{}".format(PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY) From 3318e659483b8eee668584785335d97410f07116 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Mar 2020 15:54:05 -0500 Subject: [PATCH 299/416] Convert august to async (#32586) * Convert august to async Async io was added to py-august 0.24 * Fix lint --- homeassistant/components/august/__init__.py | 88 +++++++++++-------- homeassistant/components/august/activity.py | 18 ++-- homeassistant/components/august/camera.py | 9 +- .../components/august/config_flow.py | 12 ++- homeassistant/components/august/gateway.py | 58 +++++------- homeassistant/components/august/lock.py | 8 +- homeassistant/components/august/manifest.json | 2 +- homeassistant/components/august/subscriber.py | 10 +-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/august/mocks.py | 49 ++++++----- tests/components/august/test_camera.py | 2 +- tests/components/august/test_config_flow.py | 22 ++--- tests/components/august/test_gateway.py | 12 +-- tests/components/august/test_init.py | 8 +- 15 files changed, 144 insertions(+), 158 deletions(-) diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index b3cbc161dda..e51d087d519 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -3,9 +3,9 @@ import asyncio import itertools import logging -from august.api import AugustApiHTTPError +from aiohttp import ClientError from august.authenticator import ValidationResult -from requests import RequestException +from august.exceptions import AugustApiAIOHTTPError import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry @@ -66,8 +66,8 @@ async def async_request_validation(hass, config_entry, august_gateway): async def async_august_configuration_validation_callback(data): code = data.get(VERIFICATION_CODE_KEY) - result = await hass.async_add_executor_job( - august_gateway.authenticator.validate_verification_code, code + result = await august_gateway.authenticator.async_validate_verification_code( + code ) if result == ValidationResult.INVALID_VERIFICATION_CODE: @@ -81,9 +81,7 @@ async def async_request_validation(hass, config_entry, august_gateway): return False if TWO_FA_REVALIDATE not in hass.data[DOMAIN][entry_id]: - await hass.async_add_executor_job( - august_gateway.authenticator.send_verification_code - ) + await august_gateway.authenticator.async_send_verification_code() entry_data = config_entry.data login_method = entry_data.get(CONF_LOGIN_METHOD) @@ -109,7 +107,7 @@ async def async_setup_august(hass, config_entry, august_gateway): hass.data[DOMAIN].setdefault(entry_id, {}) try: - august_gateway.authenticate() + await august_gateway.async_authenticate() except RequireValidation: await async_request_validation(hass, config_entry, august_gateway) return False @@ -125,10 +123,9 @@ async def async_setup_august(hass, config_entry, august_gateway): hass.data[DOMAIN][entry_id].pop(TWO_FA_REVALIDATE) ) - hass.data[DOMAIN][entry_id][DATA_AUGUST] = await hass.async_add_executor_job( - AugustData, hass, august_gateway - ) - await hass.data[DOMAIN][entry_id][DATA_AUGUST].activity_stream.async_setup() + hass.data[DOMAIN][entry_id][DATA_AUGUST] = AugustData(hass, august_gateway) + + await hass.data[DOMAIN][entry_id][DATA_AUGUST].async_setup() for component in AUGUST_COMPONENTS: hass.async_create_task( @@ -167,7 +164,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up August from a config entry.""" august_gateway = AugustGateway(hass) - august_gateway.async_setup(entry.data) + await august_gateway.async_setup(entry.data) return await async_setup_august(hass, entry, august_gateway) @@ -197,11 +194,22 @@ class AugustData(AugustSubscriberMixin): super().__init__(hass, MIN_TIME_BETWEEN_DETAIL_UPDATES) self._hass = hass self._august_gateway = august_gateway + self.activity_stream = None self._api = august_gateway.api self._device_detail_by_id = {} + self._doorbells_by_id = {} + self._locks_by_id = {} + self._house_ids = set() - locks = self._api.get_operable_locks(self._august_gateway.access_token) or [] - doorbells = self._api.get_doorbells(self._august_gateway.access_token) or [] + async def async_setup(self): + """Async setup of august device data and activities.""" + locks = ( + await self._api.async_get_operable_locks(self._august_gateway.access_token) + or [] + ) + doorbells = ( + await self._api.async_get_doorbells(self._august_gateway.access_token) or [] + ) self._doorbells_by_id = dict((device.device_id, device) for device in doorbells) self._locks_by_id = dict((device.device_id, device) for device in locks) @@ -209,7 +217,7 @@ class AugustData(AugustSubscriberMixin): device.house_id for device in itertools.chain(locks, doorbells) ) - self._refresh_device_detail_by_ids( + await self._async_refresh_device_detail_by_ids( [device.device_id for device in itertools.chain(locks, doorbells)] ) @@ -221,8 +229,9 @@ class AugustData(AugustSubscriberMixin): self._remove_inoperative_doorbells() self.activity_stream = ActivityStream( - hass, self._api, self._august_gateway, self._house_ids + self._hass, self._api, self._august_gateway, self._house_ids ) + await self.activity_stream.async_setup() @property def doorbells(self): @@ -238,25 +247,26 @@ class AugustData(AugustSubscriberMixin): """Return the py-august LockDetail or DoorbellDetail object for a device.""" return self._device_detail_by_id[device_id] - def _refresh(self, time): - self._refresh_device_detail_by_ids(self._subscriptions.keys()) + async def _async_refresh(self, time): + await self._async_refresh_device_detail_by_ids(self._subscriptions.keys()) - def _refresh_device_detail_by_ids(self, device_ids_list): + async def _async_refresh_device_detail_by_ids(self, device_ids_list): for device_id in device_ids_list: if device_id in self._locks_by_id: - self._update_device_detail( - self._locks_by_id[device_id], self._api.get_lock_detail + await self._async_update_device_detail( + self._locks_by_id[device_id], self._api.async_get_lock_detail ) elif device_id in self._doorbells_by_id: - self._update_device_detail( - self._doorbells_by_id[device_id], self._api.get_doorbell_detail + await self._async_update_device_detail( + self._doorbells_by_id[device_id], + self._api.async_get_doorbell_detail, ) _LOGGER.debug( - "signal_device_id_update (from detail updates): %s", device_id, + "async_signal_device_id_update (from detail updates): %s", device_id, ) - self.signal_device_id_update(device_id) + self.async_signal_device_id_update(device_id) - def _update_device_detail(self, device, api_call): + async def _async_update_device_detail(self, device, api_call): _LOGGER.debug( "Started retrieving detail for %s (%s)", device.device_name, @@ -264,10 +274,10 @@ class AugustData(AugustSubscriberMixin): ) try: - self._device_detail_by_id[device.device_id] = api_call( + self._device_detail_by_id[device.device_id] = await api_call( self._august_gateway.access_token, device.device_id ) - except RequestException as ex: + except ClientError as ex: _LOGGER.error( "Request error trying to retrieve %s details for %s. %s", device.device_id, @@ -287,30 +297,32 @@ class AugustData(AugustSubscriberMixin): if self._doorbells_by_id.get(device_id): return self._doorbells_by_id[device_id].device_name - def lock(self, device_id): + async def async_lock(self, device_id): """Lock the device.""" - return self._call_api_op_requires_bridge( + return await self._async_call_api_op_requires_bridge( device_id, - self._api.lock_return_activities, + self._api.async_lock_return_activities, self._august_gateway.access_token, device_id, ) - def unlock(self, device_id): + async def async_unlock(self, device_id): """Unlock the device.""" - return self._call_api_op_requires_bridge( + return await self._async_call_api_op_requires_bridge( device_id, - self._api.unlock_return_activities, + self._api.async_unlock_return_activities, self._august_gateway.access_token, device_id, ) - def _call_api_op_requires_bridge(self, device_id, func, *args, **kwargs): + async def _async_call_api_op_requires_bridge( + self, device_id, func, *args, **kwargs + ): """Call an API that requires the bridge to be online and will change the device state.""" ret = None try: - ret = func(*args, **kwargs) - except AugustApiHTTPError as err: + ret = await func(*args, **kwargs) + except AugustApiAIOHTTPError as err: device_name = self._get_device_name(device_id) if device_name is None: device_name = f"DeviceID: {device_id}" diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index c65083363a4..0b583d73886 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -1,8 +1,7 @@ """Consume the august activity stream.""" -from functools import partial import logging -from requests import RequestException +from aiohttp import ClientError from homeassistant.util.dt import utcnow @@ -31,7 +30,7 @@ class ActivityStream(AugustSubscriberMixin): async def async_setup(self): """Token refresh check and catch up the activity stream.""" - await self._refresh(utcnow) + await self._async_refresh(utcnow) def get_latest_device_activity(self, device_id, activity_types): """Return latest activity that is one of the acitivty_types.""" @@ -53,7 +52,7 @@ class ActivityStream(AugustSubscriberMixin): return latest_activity - async def _refresh(self, time): + async def _async_refresh(self, time): """Update the activity stream from August.""" # This is the only place we refresh the api token @@ -72,15 +71,10 @@ class ActivityStream(AugustSubscriberMixin): for house_id in self._house_ids: _LOGGER.debug("Updating device activity for house id %s", house_id) try: - activities = await self._hass.async_add_executor_job( - partial( - self._api.get_house_activities, - self._august_gateway.access_token, - house_id, - limit=limit, - ) + activities = await self._api.async_get_house_activities( + self._august_gateway.access_token, house_id, limit=limit ) - except RequestException as ex: + except ClientError as ex: _LOGGER.error( "Request error trying to retrieve activity for house id %s: %s", house_id, diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py index ecadbb931c0..4037489fa22 100644 --- a/homeassistant/components/august/camera.py +++ b/homeassistant/components/august/camera.py @@ -5,6 +5,7 @@ from august.util import update_doorbell_image_from_activity from homeassistant.components.camera import Camera from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client from .const import DATA_AUGUST, DEFAULT_NAME, DEFAULT_TIMEOUT, DOMAIN from .entity import AugustEntityMixin @@ -74,15 +75,11 @@ class AugustCamera(AugustEntityMixin, Camera): if self._image_url is not self._detail.image_url: self._image_url = self._detail.image_url - self._image_content = await self.hass.async_add_executor_job( - self._camera_image + self._image_content = await self._detail.async_get_doorbell_image( + aiohttp_client.async_get_clientsession(self.hass), timeout=self._timeout ) return self._image_content - def _camera_image(self): - """Return bytes of camera image.""" - return self._detail.get_doorbell_image(timeout=self._timeout) - @property def unique_id(self) -> str: """Get the unique id of the camera.""" diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index 1fa446ea566..acdfb1d4b63 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -42,15 +42,15 @@ async def async_validate_input( code = data.get(VERIFICATION_CODE_KEY) if code is not None: - result = await hass.async_add_executor_job( - august_gateway.authenticator.validate_verification_code, code + result = await august_gateway.authenticator.async_validate_verification_code( + code ) _LOGGER.debug("Verification code validation: %s", result) if result != ValidationResult.VALIDATED: raise RequireValidation try: - august_gateway.authenticate() + await august_gateway.async_authenticate() except RequireValidation: _LOGGER.debug( "Requesting new verification code for %s via %s", @@ -58,9 +58,7 @@ async def async_validate_input( data.get(CONF_LOGIN_METHOD), ) if code is None: - await hass.async_add_executor_job( - august_gateway.authenticator.send_verification_code - ) + await august_gateway.authenticator.async_send_verification_code() raise return { @@ -87,7 +85,7 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._august_gateway = AugustGateway(self.hass) errors = {} if user_input is not None: - self._august_gateway.async_setup(user_input) + await self._august_gateway.async_setup(user_input) try: info = await async_validate_input( diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py index e01e2fb9a8f..356414173ac 100644 --- a/homeassistant/components/august/gateway.py +++ b/homeassistant/components/august/gateway.py @@ -3,12 +3,12 @@ import asyncio import logging -from august.api import Api -from august.authenticator import AuthenticationState, Authenticator -from requests import RequestException, Session +from aiohttp import ClientError +from august.api_async import ApiAsync +from august.authenticator_async import AuthenticationState, AuthenticatorAsync from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME -from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client from .const import ( CONF_ACCESS_TOKEN_CACHE_FILE, @@ -27,7 +27,7 @@ class AugustGateway: def __init__(self, hass): """Init the connection.""" - self._api_http_session = Session() + self._aiohttp_session = aiohttp_client.async_get_clientsession(hass) self._token_refresh_lock = asyncio.Lock() self._hass = hass self._config = None @@ -66,8 +66,7 @@ class AugustGateway: CONF_ACCESS_TOKEN_CACHE_FILE: self._config[CONF_ACCESS_TOKEN_CACHE_FILE], } - @callback - def async_setup(self, conf): + async def async_setup(self, conf): """Create the api and authenticator objects.""" if conf.get(VERIFICATION_CODE_KEY): return @@ -77,11 +76,11 @@ class AugustGateway: ] = f".{conf[CONF_USERNAME]}{DEFAULT_AUGUST_CONFIG_FILE}" self._config = conf - self._api = Api( - timeout=self._config.get(CONF_TIMEOUT), http_session=self._api_http_session, + self._api = ApiAsync( + self._aiohttp_session, timeout=self._config.get(CONF_TIMEOUT) ) - self._authenticator = Authenticator( + self._authenticator = AuthenticatorAsync( self._api, self._config[CONF_LOGIN_METHOD], self._config[CONF_USERNAME], @@ -92,12 +91,14 @@ class AugustGateway: ), ) - def authenticate(self): + await self._authenticator.async_setup_authentication() + + async def async_authenticate(self): """Authenticate with the details provided to setup.""" self._authentication = None try: - self._authentication = self.authenticator.authenticate() - except RequestException as ex: + self._authentication = await self.authenticator.async_authenticate() + except ClientError as ex: _LOGGER.error("Unable to connect to August service: %s", str(ex)) raise CannotConnect @@ -119,25 +120,12 @@ class AugustGateway: """Refresh the august access token if needed.""" if self.authenticator.should_refresh(): async with self._token_refresh_lock: - await self._hass.async_add_executor_job(self._refresh_access_token) - - def _refresh_access_token(self): - refreshed_authentication = self.authenticator.refresh_access_token(force=False) - _LOGGER.info( - "Refreshed august access token. The old token expired at %s, and the new token expires at %s", - self.authentication.access_token_expires, - refreshed_authentication.access_token_expires, - ) - self._authentication = refreshed_authentication - - def _close_http_session(self): - """Close API sessions used to connect to August.""" - if self._api_http_session: - try: - self._api_http_session.close() - except RequestException: - pass - - def __del__(self): - """Close out the http session on destroy.""" - self._close_http_session() + refreshed_authentication = await self.authenticator.async_refresh_access_token( + force=False + ) + _LOGGER.info( + "Refreshed august access token. The old token expired at %s, and the new token expires at %s", + self.authentication.access_token_expires, + refreshed_authentication.access_token_expires, + ) + self._authentication = refreshed_authentication diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py index 8e10f957ce6..495c215edad 100644 --- a/homeassistant/components/august/lock.py +++ b/homeassistant/components/august/lock.py @@ -43,16 +43,14 @@ class AugustLock(AugustEntityMixin, RestoreEntity, LockDevice): async def async_lock(self, **kwargs): """Lock the device.""" - await self._call_lock_operation(self._data.lock) + await self._call_lock_operation(self._data.async_lock) async def async_unlock(self, **kwargs): """Unlock the device.""" - await self._call_lock_operation(self._data.unlock) + await self._call_lock_operation(self._data.async_unlock) async def _call_lock_operation(self, lock_operation): - activities = await self.hass.async_add_executor_job( - lock_operation, self._device_id - ) + activities = await lock_operation(self._device_id) for lock_activity in activities: update_lock_detail_from_activity(self._detail, lock_activity) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index ef1df806575..ca757ae5ad3 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -3,7 +3,7 @@ "name": "August", "documentation": "https://www.home-assistant.io/integrations/august", "requirements": [ - "py-august==0.22.0" + "py-august==0.24.0" ], "dependencies": [ "configurator" diff --git a/homeassistant/components/august/subscriber.py b/homeassistant/components/august/subscriber.py index 62861270c30..81538fa011e 100644 --- a/homeassistant/components/august/subscriber.py +++ b/homeassistant/components/august/subscriber.py @@ -21,7 +21,7 @@ class AugustSubscriberMixin: """Add an callback subscriber.""" if not self._subscriptions: self._unsub_interval = async_track_time_interval( - self._hass, self._refresh, self._update_interval + self._hass, self._async_refresh, self._update_interval ) self._subscriptions.setdefault(device_id, []).append(update_callback) @@ -43,11 +43,3 @@ class AugustSubscriberMixin: for update_callback in self._subscriptions[device_id]: update_callback() - - def signal_device_id_update(self, device_id): - """Call the callbacks for a device_id.""" - if not self._subscriptions.get(device_id): - return - - for update_callback in self._subscriptions[device_id]: - self._hass.loop.call_soon_threadsafe(update_callback) diff --git a/requirements_all.txt b/requirements_all.txt index 77e1a240241..8c8c005bd96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1085,7 +1085,7 @@ pushover_complete==1.1.1 pwmled==1.5.0 # homeassistant.components.august -py-august==0.22.0 +py-august==0.24.0 # homeassistant.components.canary py-canary==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8f5e2fb5509..39f9492ab7a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -394,7 +394,7 @@ pure-python-adb==0.2.2.dev0 pushbullet.py==0.11.0 # homeassistant.components.august -py-august==0.22.0 +py-august==0.24.0 # homeassistant.components.canary py-canary==0.5.0 diff --git a/tests/components/august/mocks.py b/tests/components/august/mocks.py index cb78049d149..651a17ac80f 100644 --- a/tests/components/august/mocks.py +++ b/tests/components/august/mocks.py @@ -2,9 +2,9 @@ import json import os import time -from unittest.mock import MagicMock, PropertyMock from asynctest import mock +from asynctest.mock import CoroutineMock, MagicMock, PropertyMock from august.activity import ( ACTIVITY_ACTIONS_DOOR_OPERATION, ACTIVITY_ACTIONS_DOORBELL_DING, @@ -43,8 +43,10 @@ def _mock_get_config(): } -@mock.patch("homeassistant.components.august.gateway.Api") -@mock.patch("homeassistant.components.august.gateway.Authenticator.authenticate") +@mock.patch("homeassistant.components.august.gateway.ApiAsync") +@mock.patch( + "homeassistant.components.august.gateway.AuthenticatorAsync.async_authenticate" +) async def _mock_setup_august(hass, api_instance, authenticate_mock, api_mock): """Set up august integration.""" authenticate_mock.side_effect = MagicMock( @@ -147,37 +149,40 @@ async def _mock_setup_august_with_api_side_effects(hass, api_call_side_effects): api_instance = MagicMock(name="Api") if api_call_side_effects["get_lock_detail"]: - api_instance.get_lock_detail.side_effect = api_call_side_effects[ - "get_lock_detail" - ] + type(api_instance).async_get_lock_detail = CoroutineMock( + side_effect=api_call_side_effects["get_lock_detail"] + ) if api_call_side_effects["get_operable_locks"]: - api_instance.get_operable_locks.side_effect = api_call_side_effects[ - "get_operable_locks" - ] + type(api_instance).async_get_operable_locks = CoroutineMock( + side_effect=api_call_side_effects["get_operable_locks"] + ) if api_call_side_effects["get_doorbells"]: - api_instance.get_doorbells.side_effect = api_call_side_effects["get_doorbells"] + type(api_instance).async_get_doorbells = CoroutineMock( + side_effect=api_call_side_effects["get_doorbells"] + ) if api_call_side_effects["get_doorbell_detail"]: - api_instance.get_doorbell_detail.side_effect = api_call_side_effects[ - "get_doorbell_detail" - ] + type(api_instance).async_get_doorbell_detail = CoroutineMock( + side_effect=api_call_side_effects["get_doorbell_detail"] + ) if api_call_side_effects["get_house_activities"]: - api_instance.get_house_activities.side_effect = api_call_side_effects[ - "get_house_activities" - ] + type(api_instance).async_get_house_activities = CoroutineMock( + side_effect=api_call_side_effects["get_house_activities"] + ) if api_call_side_effects["lock_return_activities"]: - api_instance.lock_return_activities.side_effect = api_call_side_effects[ - "lock_return_activities" - ] + type(api_instance).async_lock_return_activities = CoroutineMock( + side_effect=api_call_side_effects["lock_return_activities"] + ) if api_call_side_effects["unlock_return_activities"]: - api_instance.unlock_return_activities.side_effect = api_call_side_effects[ - "unlock_return_activities" - ] + type(api_instance).async_unlock_return_activities = CoroutineMock( + side_effect=api_call_side_effects["unlock_return_activities"] + ) + return await _mock_setup_august(hass, api_instance) diff --git a/tests/components/august/test_camera.py b/tests/components/august/test_camera.py index 632525c0c4e..e47bafece42 100644 --- a/tests/components/august/test_camera.py +++ b/tests/components/august/test_camera.py @@ -15,7 +15,7 @@ async def test_create_doorbell(hass, aiohttp_client): doorbell_one = await _mock_doorbell_from_fixture(hass, "get_doorbell.json") with mock.patch.object( - doorbell_one, "get_doorbell_image", create=False, return_value="image" + doorbell_one, "async_get_doorbell_image", create=False, return_value="image" ): await _create_august_with_devices(hass, [doorbell_one]) diff --git a/tests/components/august/test_config_flow.py b/tests/components/august/test_config_flow.py index 3e81986d9f4..8d29ba650fa 100644 --- a/tests/components/august/test_config_flow.py +++ b/tests/components/august/test_config_flow.py @@ -28,7 +28,7 @@ async def test_form(hass): assert result["errors"] == {} with patch( - "homeassistant.components.august.config_flow.AugustGateway.authenticate", + "homeassistant.components.august.config_flow.AugustGateway.async_authenticate", return_value=True, ), patch( "homeassistant.components.august.async_setup", return_value=True @@ -66,7 +66,7 @@ async def test_form_invalid_auth(hass): ) with patch( - "homeassistant.components.august.config_flow.AugustGateway.authenticate", + "homeassistant.components.august.config_flow.AugustGateway.async_authenticate", side_effect=InvalidAuth, ): result2 = await hass.config_entries.flow.async_configure( @@ -89,7 +89,7 @@ async def test_form_cannot_connect(hass): ) with patch( - "homeassistant.components.august.config_flow.AugustGateway.authenticate", + "homeassistant.components.august.config_flow.AugustGateway.async_authenticate", side_effect=CannotConnect, ): result2 = await hass.config_entries.flow.async_configure( @@ -112,10 +112,10 @@ async def test_form_needs_validate(hass): ) with patch( - "homeassistant.components.august.config_flow.AugustGateway.authenticate", + "homeassistant.components.august.config_flow.AugustGateway.async_authenticate", side_effect=RequireValidation, ), patch( - "homeassistant.components.august.gateway.Authenticator.send_verification_code", + "homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code", return_value=True, ) as mock_send_verification_code: result2 = await hass.config_entries.flow.async_configure( @@ -134,13 +134,13 @@ async def test_form_needs_validate(hass): # Try with the WRONG verification code give us the form back again with patch( - "homeassistant.components.august.config_flow.AugustGateway.authenticate", + "homeassistant.components.august.config_flow.AugustGateway.async_authenticate", side_effect=RequireValidation, ), patch( - "homeassistant.components.august.gateway.Authenticator.validate_verification_code", + "homeassistant.components.august.gateway.AuthenticatorAsync.async_validate_verification_code", return_value=ValidationResult.INVALID_VERIFICATION_CODE, ) as mock_validate_verification_code, patch( - "homeassistant.components.august.gateway.Authenticator.send_verification_code", + "homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code", return_value=True, ) as mock_send_verification_code, patch( "homeassistant.components.august.async_setup", return_value=True @@ -161,13 +161,13 @@ async def test_form_needs_validate(hass): # Try with the CORRECT verification code and we setup with patch( - "homeassistant.components.august.config_flow.AugustGateway.authenticate", + "homeassistant.components.august.config_flow.AugustGateway.async_authenticate", return_value=True, ), patch( - "homeassistant.components.august.gateway.Authenticator.validate_verification_code", + "homeassistant.components.august.gateway.AuthenticatorAsync.async_validate_verification_code", return_value=ValidationResult.VALIDATED, ) as mock_validate_verification_code, patch( - "homeassistant.components.august.gateway.Authenticator.send_verification_code", + "homeassistant.components.august.gateway.AuthenticatorAsync.async_send_verification_code", return_value=True, ) as mock_send_verification_code, patch( "homeassistant.components.august.async_setup", return_value=True diff --git a/tests/components/august/test_gateway.py b/tests/components/august/test_gateway.py index 38696d316ca..f5fe35b4b19 100644 --- a/tests/components/august/test_gateway.py +++ b/tests/components/august/test_gateway.py @@ -14,10 +14,12 @@ async def test_refresh_access_token(hass): await _patched_refresh_access_token(hass, "new_token", 5678) -@mock.patch("homeassistant.components.august.gateway.Authenticator.authenticate") -@mock.patch("homeassistant.components.august.gateway.Authenticator.should_refresh") @mock.patch( - "homeassistant.components.august.gateway.Authenticator.refresh_access_token" + "homeassistant.components.august.gateway.AuthenticatorAsync.async_authenticate" +) +@mock.patch("homeassistant.components.august.gateway.AuthenticatorAsync.should_refresh") +@mock.patch( + "homeassistant.components.august.gateway.AuthenticatorAsync.async_refresh_access_token" ) async def _patched_refresh_access_token( hass, @@ -32,8 +34,8 @@ async def _patched_refresh_access_token( ) august_gateway = AugustGateway(hass) mocked_config = _mock_get_config() - august_gateway.async_setup(mocked_config[DOMAIN]) - august_gateway.authenticate() + await august_gateway.async_setup(mocked_config[DOMAIN]) + await august_gateway.async_authenticate() should_refresh_mock.return_value = False await august_gateway.async_refresh_access_token_if_needed() diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index 4767f24e113..906eeff6213 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -1,6 +1,6 @@ """The tests for the august platform.""" from asynctest import patch -from august.exceptions import AugustApiHTTPError +from august.exceptions import AugustApiAIOHTTPError from homeassistant import setup from homeassistant.components.august.const import ( @@ -38,7 +38,7 @@ async def test_unlock_throws_august_api_http_error(hass): mocked_lock_detail = await _mock_operative_august_lock_detail(hass) def _unlock_return_activities_side_effect(access_token, device_id): - raise AugustApiHTTPError("This should bubble up as its user consumable") + raise AugustApiAIOHTTPError("This should bubble up as its user consumable") await _create_august_with_devices( hass, @@ -64,7 +64,7 @@ async def test_lock_throws_august_api_http_error(hass): mocked_lock_detail = await _mock_operative_august_lock_detail(hass) def _lock_return_activities_side_effect(access_token, device_id): - raise AugustApiHTTPError("This should bubble up as its user consumable") + raise AugustApiAIOHTTPError("This should bubble up as its user consumable") await _create_august_with_devices( hass, @@ -124,7 +124,7 @@ async def test_set_up_from_yaml(hass): with patch( "homeassistant.components.august.async_setup_august", return_value=True, ) as mock_setup_august, patch( - "homeassistant.components.august.config_flow.AugustGateway.authenticate", + "homeassistant.components.august.config_flow.AugustGateway.async_authenticate", return_value=True, ): mocked_config = _mock_get_config() From d4615fd432f0c3c66eb0cf682c3da9a4accd7e6c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 9 Mar 2020 14:07:50 -0700 Subject: [PATCH 300/416] Freeze config entry data (#32615) * Freeze config entry data * Fix mutating entry.data * Fix config entry options tests --- homeassistant/components/heos/__init__.py | 6 +++--- homeassistant/components/mqtt/__init__.py | 2 +- homeassistant/components/plex/config_flow.py | 2 +- .../components/samsungtv/config_flow.py | 15 ++++++------- .../components/smartthings/__init__.py | 14 +++++++++---- .../components/smartthings/smartapp.py | 5 +++-- .../components/transmission/__init__.py | 2 +- homeassistant/components/upnp/__init__.py | 5 +++-- homeassistant/config_entries.py | 21 ++++++++++++------- tests/components/axis/test_init.py | 2 +- .../homematicip_cloud/test_device.py | 2 +- tests/components/met/test_config_flow.py | 9 ++++---- tests/components/mikrotik/test_hub.py | 4 ++-- .../components/samsungtv/test_config_flow.py | 13 ++++++------ tests/components/smartthings/test_init.py | 10 +++++++-- tests/test_config_entries.py | 4 ++++ 16 files changed, 71 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py index f7e1ce5bc58..53c65a6ab07 100644 --- a/homeassistant/components/heos/__init__.py +++ b/homeassistant/components/heos/__init__.py @@ -52,9 +52,9 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): # Check if host needs to be updated entry = entries[0] if entry.data[CONF_HOST] != host: - entry.data[CONF_HOST] = host - entry.title = format_title(host) - hass.config_entries.async_update_entry(entry) + hass.config_entries.async_update_entry( + entry, title=format_title(host), data={**entry.data, CONF_HOST: host} + ) return True diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 4014c2162dd..bb38c36090a 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -568,7 +568,7 @@ async def async_setup_entry(hass, entry): # If user didn't have configuration.yaml config, generate defaults if conf is None: - conf = CONFIG_SCHEMA({DOMAIN: entry.data})[DOMAIN] + conf = CONFIG_SCHEMA({DOMAIN: dict(entry.data)})[DOMAIN] elif any(key in conf for key in entry.data): _LOGGER.warning( "Data in your configuration entry is going to override your " diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py index 84b16817ca8..6edbecf055d 100644 --- a/homeassistant/components/plex/config_flow.py +++ b/homeassistant/components/plex/config_flow.py @@ -227,7 +227,7 @@ class PlexOptionsFlowHandler(config_entries.OptionsFlow): def __init__(self, config_entry): """Initialize Plex options flow.""" - self.options = copy.deepcopy(config_entry.options) + self.options = copy.deepcopy(dict(config_entry.options)) self.server_id = config_entry.data[CONF_SERVER_IDENTIFIER] async def async_step_init(self, user_input=None): diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index e52123297ab..b3c5ecd1bf5 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -158,13 +158,14 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): if self._id.startswith("uuid:"): self._id = self._id[5:] - config_entry = await self.async_set_unique_id(ip_address) - if config_entry: - config_entry.data[CONF_ID] = self._id - config_entry.data[CONF_MANUFACTURER] = self._manufacturer - config_entry.data[CONF_MODEL] = self._model - self.hass.config_entries.async_update_entry(config_entry) - return self.async_abort(reason="already_configured") + await self.async_set_unique_id(ip_address) + self._abort_if_unique_id_configured( + { + CONF_ID: self._id, + CONF_MANUFACTURER: self._manufacturer, + CONF_MODEL: self._model, + } + ) self.context["title_placeholders"] = {"model": self._model} return await self.async_step_confirm() diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 1539fa076e4..a1ea4f98c85 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -109,8 +109,9 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): entry.data[CONF_OAUTH_CLIENT_SECRET], entry.data[CONF_REFRESH_TOKEN], ) - entry.data[CONF_REFRESH_TOKEN] = token.refresh_token - hass.config_entries.async_update_entry(entry) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_REFRESH_TOKEN: token.refresh_token} + ) # Get devices and their current status devices = await api.devices(location_ids=[installed_app.location_id]) @@ -304,8 +305,13 @@ class DeviceBroker: self._entry.data[CONF_OAUTH_CLIENT_ID], self._entry.data[CONF_OAUTH_CLIENT_SECRET], ) - self._entry.data[CONF_REFRESH_TOKEN] = self._token.refresh_token - self._hass.config_entries.async_update_entry(self._entry) + self._hass.config_entries.async_update_entry( + self._entry, + data={ + **self._entry.data, + CONF_REFRESH_TOKEN: self._token.refresh_token, + }, + ) _LOGGER.debug( "Regenerated refresh token for installed app: %s", self._installed_app_id, diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index f2bfef960cd..402fdbd0715 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -428,8 +428,9 @@ async def smartapp_update(hass: HomeAssistantType, req, resp, app): None, ) if entry: - entry.data[CONF_REFRESH_TOKEN] = req.refresh_token - hass.config_entries.async_update_entry(entry) + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_REFRESH_TOKEN: req.refresh_token} + ) _LOGGER.debug( "Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py index 3e6f2407d17..08aa52e3a13 100644 --- a/homeassistant/components/transmission/__init__.py +++ b/homeassistant/components/transmission/__init__.py @@ -196,7 +196,7 @@ class TransmissionClient: def add_options(self): """Add options for entry.""" if not self.config_entry.options: - scan_interval = self.config_entry.data.pop( + scan_interval = self.config_entry.data.get( CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL ) options = {CONF_SCAN_INTERVAL: scan_interval} diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index 9a7e06738db..ce97c7944c6 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -151,9 +151,10 @@ async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigEntry): return False # 'register'/save UDN - config_entry.data["udn"] = device.udn hass.data[DOMAIN]["devices"][device.udn] = device - hass.config_entries.async_update_entry(entry=config_entry, data=config_entry.data) + hass.config_entries.async_update_entry( + entry=config_entry, data={**config_entry.data, "udn": device.udn} + ) # create device registry entry device_registry = await dr.async_get_registry(hass) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 1cec1e75fe9..945bd3865c3 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2,6 +2,7 @@ import asyncio import functools import logging +from types import MappingProxyType from typing import Any, Callable, Dict, List, Optional, Set, Union, cast import uuid import weakref @@ -139,10 +140,10 @@ class ConfigEntry: self.title = title # Config data - self.data = data + self.data = MappingProxyType(data) # Entry options - self.options = options or {} + self.options = MappingProxyType(options or {}) # Entry system options self.system_options = SystemOptions(**system_options) @@ -396,8 +397,8 @@ class ConfigEntry: "version": self.version, "domain": self.domain, "title": self.title, - "data": self.data, - "options": self.options, + "data": dict(self.data), + "options": dict(self.options), "system_options": self.system_options.as_dict(), "source": self.source, "connection_class": self.connection_class, @@ -720,6 +721,7 @@ class ConfigEntries: entry: ConfigEntry, *, unique_id: Union[str, dict, None] = _UNDEF, + title: Union[str, dict] = _UNDEF, data: dict = _UNDEF, options: dict = _UNDEF, system_options: dict = _UNDEF, @@ -728,11 +730,14 @@ class ConfigEntries: if unique_id is not _UNDEF: entry.unique_id = cast(Optional[str], unique_id) + if title is not _UNDEF: + entry.title = cast(str, title) + if data is not _UNDEF: - entry.data = data + entry.data = MappingProxyType(data) if options is not _UNDEF: - entry.options = options + entry.options = MappingProxyType(options) if system_options is not _UNDEF: entry.system_options.update(**system_options) @@ -818,7 +823,9 @@ class ConfigFlow(data_entry_flow.FlowHandler): raise data_entry_flow.UnknownHandler @callback - def _abort_if_unique_id_configured(self, updates: Dict[Any, Any] = None) -> None: + def _abort_if_unique_id_configured( + self, updates: Optional[Dict[Any, Any]] = None + ) -> None: """Abort if the unique ID is already configured.""" assert self.hass if self.unique_id is None: diff --git a/tests/components/axis/test_init.py b/tests/components/axis/test_init.py index cf5a3b2785a..83e1337b079 100644 --- a/tests/components/axis/test_init.py +++ b/tests/components/axis/test_init.py @@ -38,7 +38,7 @@ async def test_setup_entry(hass): async def test_setup_entry_fails(hass): """Test successful setup of entry.""" config_entry = MockConfigEntry( - domain=axis.DOMAIN, data={axis.CONF_MAC: "0123"}, options=True, version=2 + domain=axis.DOMAIN, data={axis.CONF_MAC: "0123"}, version=2 ) config_entry.add_to_hass(hass) diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index c678bee5e32..3cb45182399 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -196,7 +196,7 @@ async def test_hap_with_name(hass, mock_connection, hmip_config_entry): entity_name = f"{home_name} Treppe" device_model = "HmIP-BSL" - hmip_config_entry.data["name"] = home_name + hmip_config_entry.data = {**hmip_config_entry.data, "name": home_name} mock_hap = await HomeFactory( hass, mock_connection, hmip_config_entry ).async_get_mock_hap(test_devices=["Treppe"]) diff --git a/tests/components/met/test_config_flow.py b/tests/components/met/test_config_flow.py index 8a81d137672..980994f3fb2 100644 --- a/tests/components/met/test_config_flow.py +++ b/tests/components/met/test_config_flow.py @@ -73,11 +73,10 @@ async def test_flow_entry_already_exists(hass): Test when the form should show when user puts existing location in the config gui. Then the form should show with error. """ - first_entry = MockConfigEntry(domain="met") - first_entry.data["name"] = "home" - first_entry.data[CONF_LONGITUDE] = 0 - first_entry.data[CONF_LATITUDE] = 0 - first_entry.data[CONF_ELEVATION] = 0 + first_entry = MockConfigEntry( + domain="met", + data={"name": "home", CONF_LATITUDE: 0, CONF_LONGITUDE: 0, CONF_ELEVATION: 0}, + ) first_entry.add_to_hass(hass) test_data = { diff --git a/tests/components/mikrotik/test_hub.py b/tests/components/mikrotik/test_hub.py index fc37c9113ae..9a2f75f7015 100644 --- a/tests/components/mikrotik/test_hub.py +++ b/tests/components/mikrotik/test_hub.py @@ -33,10 +33,10 @@ async def setup_mikrotik_entry(hass, **kwargs): config_entry.add_to_hass(hass) if "force_dhcp" in kwargs: - config_entry.options["force_dhcp"] = True + config_entry.options = {**config_entry.options, "force_dhcp": True} if "arp_ping" in kwargs: - config_entry.options["arp_ping"] = True + config_entry.options = {**config_entry.options, "arp_ping": True} with patch("librouteros.connect"), patch.object( mikrotik.hub.MikrotikData, "command", new=mock_command diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 91ee8a7205f..8bca98f78f3 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -316,9 +316,10 @@ async def test_ssdp_already_configured(hass, remote): DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA ) assert result["type"] == "create_entry" - assert result["data"][CONF_MANUFACTURER] is None - assert result["data"][CONF_MODEL] is None - assert result["data"][CONF_ID] is None + entry = result["result"] + assert entry.data[CONF_MANUFACTURER] is None + assert entry.data[CONF_MODEL] is None + assert entry.data[CONF_ID] is None # failed as already configured result2 = await hass.config_entries.flow.async_init( @@ -328,9 +329,9 @@ async def test_ssdp_already_configured(hass, remote): assert result2["reason"] == "already_configured" # check updated device info - assert result["data"][CONF_MANUFACTURER] == "fake_manufacturer" - assert result["data"][CONF_MODEL] == "fake_model" - assert result["data"][CONF_ID] == "fake_uuid" + assert entry.data[CONF_MANUFACTURER] == "fake_manufacturer" + assert entry.data[CONF_MODEL] == "fake_model" + assert entry.data[CONF_ID] == "fake_uuid" async def test_autodetect_websocket(hass, remote): diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 0c9d889d558..ae80316676a 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -423,7 +423,10 @@ async def test_event_handler_dispatches_updated_devices( data={"codeId": "1"}, ) request = event_request_factory(device_ids=device_ids, events=[event]) - config_entry.data[CONF_INSTALLED_APP_ID] = request.installed_app_id + config_entry.data = { + **config_entry.data, + CONF_INSTALLED_APP_ID: request.installed_app_id, + } called = False def signal(ids): @@ -479,7 +482,10 @@ async def test_event_handler_fires_button_events( device.device_id, capability="button", attribute="button", value="pushed" ) request = event_request_factory(events=[event]) - config_entry.data[CONF_INSTALLED_APP_ID] = request.installed_app_id + config_entry.data = { + **config_entry.data, + CONF_INSTALLED_APP_ID: request.installed_app_id, + } called = False def handler(evt): diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index da3fb740694..17494b6b110 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -456,6 +456,8 @@ async def test_saving_and_loading(hass): "test", context={"source": config_entries.SOURCE_USER} ) + assert len(hass.config_entries.async_entries()) == 2 + # To trigger the call_later async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) # To execute the save @@ -465,6 +467,8 @@ async def test_saving_and_loading(hass): manager = config_entries.ConfigEntries(hass, {}) await manager.async_initialize() + assert len(manager.async_entries()) == 2 + # Ensure same order for orig, loaded in zip( hass.config_entries.async_entries(), manager.async_entries() From 9a3c58213b68a9905bfc344b88ab230dbbbb1acd Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 9 Mar 2020 17:18:39 -0600 Subject: [PATCH 301/416] Validate WWLLN window size in config schema (#32621) * Validate WWLLN window size in config schema * Cleanup * Clean up imports * Fix tests --- .../components/wwlln/.translations/en.json | 3 +-- homeassistant/components/wwlln/__init__.py | 18 ++++++++---------- homeassistant/components/wwlln/config_flow.py | 13 ------------- homeassistant/components/wwlln/const.py | 3 --- homeassistant/components/wwlln/geo_location.py | 13 ++++++++----- homeassistant/components/wwlln/strings.json | 3 +-- tests/components/wwlln/test_config_flow.py | 17 ----------------- 7 files changed, 18 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/wwlln/.translations/en.json b/homeassistant/components/wwlln/.translations/en.json index 62d0dea656d..48896cc8682 100644 --- a/homeassistant/components/wwlln/.translations/en.json +++ b/homeassistant/components/wwlln/.translations/en.json @@ -1,8 +1,7 @@ { "config": { "abort": { - "already_configured": "This location is already registered.", - "window_too_small": "A too-small window will cause Home Assistant to miss events." + "already_configured": "This location is already registered." }, "step": { "user": { diff --git a/homeassistant/components/wwlln/__init__.py b/homeassistant/components/wwlln/__init__.py index d896e16319c..d83e19bd391 100644 --- a/homeassistant/components/wwlln/__init__.py +++ b/homeassistant/components/wwlln/__init__.py @@ -1,4 +1,6 @@ """Support for World Wide Lightning Location Network.""" +import logging + from aiowwlln import Client import voluptuous as vol @@ -6,14 +8,9 @@ from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.helpers import aiohttp_client, config_validation as cv -from .const import ( - CONF_WINDOW, - DATA_CLIENT, - DEFAULT_RADIUS, - DEFAULT_WINDOW, - DOMAIN, - LOGGER, -) +from .const import CONF_WINDOW, DATA_CLIENT, DEFAULT_RADIUS, DEFAULT_WINDOW, DOMAIN + +_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( { @@ -26,6 +23,7 @@ CONFIG_SCHEMA = vol.Schema( cv.time_period, cv.positive_timedelta, lambda value: value.total_seconds(), + vol.Range(min=DEFAULT_WINDOW.total_seconds()), ), } ) @@ -91,7 +89,7 @@ async def async_migrate_entry(hass, config_entry): default_total_seconds = DEFAULT_WINDOW.total_seconds() - LOGGER.debug("Migrating from version %s", version) + _LOGGER.debug("Migrating from version %s", version) # 1 -> 2: Expanding the default window to 1 hour (if needed): if version == 1: @@ -99,6 +97,6 @@ async def async_migrate_entry(hass, config_entry): data[CONF_WINDOW] = default_total_seconds version = config_entry.version = 2 hass.config_entries.async_update_entry(config_entry, data=data) - LOGGER.info("Migration to version %s successful", version) + _LOGGER.info("Migration to version %s successful", version) return True diff --git a/homeassistant/components/wwlln/config_flow.py b/homeassistant/components/wwlln/config_flow.py index 51b705b04ee..4ec7c2a9a0c 100644 --- a/homeassistant/components/wwlln/config_flow.py +++ b/homeassistant/components/wwlln/config_flow.py @@ -10,7 +10,6 @@ from .const import ( # pylint: disable=unused-import DEFAULT_RADIUS, DEFAULT_WINDOW, DOMAIN, - LOGGER, ) @@ -43,18 +42,6 @@ class WWLLNFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" - default_window_seconds = DEFAULT_WINDOW.total_seconds() - if ( - CONF_WINDOW in import_config - and import_config[CONF_WINDOW] < default_window_seconds - ): - LOGGER.error( - "Refusing to use too-small window (%s < %s)", - import_config[CONF_WINDOW], - default_window_seconds, - ) - return self.async_abort(reason="window_too_small") - return await self.async_step_user(import_config) async def async_step_user(self, user_input=None): diff --git a/homeassistant/components/wwlln/const.py b/homeassistant/components/wwlln/const.py index c21f30fdd59..141baf38cda 100644 --- a/homeassistant/components/wwlln/const.py +++ b/homeassistant/components/wwlln/const.py @@ -1,8 +1,5 @@ """Define constants for the WWLLN integration.""" from datetime import timedelta -import logging - -LOGGER = logging.getLogger(__package__) DOMAIN = "wwlln" diff --git a/homeassistant/components/wwlln/geo_location.py b/homeassistant/components/wwlln/geo_location.py index 3e42f0245b2..ed4d4fcd6b8 100644 --- a/homeassistant/components/wwlln/geo_location.py +++ b/homeassistant/components/wwlln/geo_location.py @@ -1,5 +1,6 @@ """Support for WWLLN geo location events.""" from datetime import timedelta +import logging from aiowwlln.errors import WWLLNError @@ -21,7 +22,9 @@ from homeassistant.helpers.dispatcher import ( from homeassistant.helpers.event import async_track_time_interval from homeassistant.util.dt import utc_from_timestamp -from .const import CONF_WINDOW, DATA_CLIENT, DOMAIN, LOGGER +from .const import CONF_WINDOW, DATA_CLIENT, DOMAIN + +_LOGGER = logging.getLogger(__name__) ATTR_EXTERNAL_ID = "external_id" ATTR_PUBLICATION_DATE = "publication_date" @@ -81,7 +84,7 @@ class WWLLNEventManager: @callback def _create_events(self, ids_to_create): """Create new geo location events.""" - LOGGER.debug("Going to create %s", ids_to_create) + _LOGGER.debug("Going to create %s", ids_to_create) events = [] for strike_id in ids_to_create: strike = self._strikes[strike_id] @@ -100,7 +103,7 @@ class WWLLNEventManager: @callback def _remove_events(self, ids_to_remove): """Remove old geo location events.""" - LOGGER.debug("Going to remove %s", ids_to_remove) + _LOGGER.debug("Going to remove %s", ids_to_remove) for strike_id in ids_to_remove: async_dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(strike_id)) @@ -116,7 +119,7 @@ class WWLLNEventManager: async def async_update(self): """Refresh data.""" - LOGGER.debug("Refreshing WWLLN data") + _LOGGER.debug("Refreshing WWLLN data") try: self._strikes = await self._client.within_radius( @@ -127,7 +130,7 @@ class WWLLNEventManager: window=self._window, ) except WWLLNError as err: - LOGGER.error("Error while updating WWLLN data: %s", err) + _LOGGER.error("Error while updating WWLLN data: %s", err) return new_strike_ids = set(self._strikes) diff --git a/homeassistant/components/wwlln/strings.json b/homeassistant/components/wwlln/strings.json index 4385e70cd7b..0ab731eaf50 100644 --- a/homeassistant/components/wwlln/strings.json +++ b/homeassistant/components/wwlln/strings.json @@ -12,8 +12,7 @@ } }, "abort": { - "already_configured": "This location is already registered.", - "window_too_small": "A too-small window will cause Home Assistant to miss events." + "already_configured": "This location is already registered." } } } diff --git a/tests/components/wwlln/test_config_flow.py b/tests/components/wwlln/test_config_flow.py index 2894ae76571..e9e32ae75e1 100644 --- a/tests/components/wwlln/test_config_flow.py +++ b/tests/components/wwlln/test_config_flow.py @@ -62,23 +62,6 @@ async def test_step_import(hass): } -async def test_step_import_too_small_window(hass): - """Test that the import step with a too-small window is aborted.""" - conf = { - CONF_LATITUDE: 39.128712, - CONF_LONGITUDE: -104.9812612, - CONF_RADIUS: 25, - CONF_WINDOW: 60, - } - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) - - assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT - assert result["reason"] == "window_too_small" - - async def test_step_user(hass): """Test that the user step works.""" conf = {CONF_LATITUDE: 39.128712, CONF_LONGITUDE: -104.9812612, CONF_RADIUS: 25} From 87b770be08e2e78d1fc0f3d47423d10f9acb09c6 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Mon, 9 Mar 2020 19:55:25 -0400 Subject: [PATCH 302/416] Add PollControl cluster support to ZHA (#32618) * Poll control channel Set check-in interval to 55min. Set long-poll interval to 6s. * Update tests. * Don't use magic numbers. --- .../components/zha/core/channels/general.py | 35 ++++++- tests/components/zha/common.py | 6 +- tests/components/zha/test_channels.py | 96 ++++++++++++++++++- 3 files changed, 133 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index d51c03b33c9..783188248f3 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -1,6 +1,9 @@ """General channels module for Zigbee Home Automation.""" +import asyncio import logging +from typing import Any, List, Optional +import zigpy.exceptions import zigpy.zcl.clusters.general as general from homeassistant.core import callback @@ -332,11 +335,41 @@ class Partition(ZigbeeChannel): pass +@registries.CHANNEL_ONLY_CLUSTERS.register(general.PollControl.cluster_id) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.PollControl.cluster_id) class PollControl(ZigbeeChannel): """Poll Control channel.""" - pass + CHECKIN_INTERVAL = 55 * 60 * 4 # 55min + CHECKIN_FAST_POLL_TIMEOUT = 2 * 4 # 2s + LONG_POLL = 6 * 4 # 6s + + async def async_configure(self) -> None: + """Configure channel: set check-in interval.""" + try: + res = await self.cluster.write_attributes( + {"checkin_interval": self.CHECKIN_INTERVAL} + ) + self.debug("%ss check-in interval set: %s", self.CHECKIN_INTERVAL / 4, res) + except (asyncio.TimeoutError, zigpy.exceptions.ZigbeeException) as ex: + self.debug("Couldn't set check-in interval: %s", ex) + await super().async_configure() + + @callback + def cluster_command( + self, tsn: int, command_id: int, args: Optional[List[Any]] + ) -> None: + """Handle commands received to this cluster.""" + cmd_name = self.cluster.client_commands.get(command_id, [command_id])[0] + self.debug("Received %s tsn command '%s': %s", tsn, cmd_name, args) + self.zha_send_event(cmd_name, args) + if cmd_name == "checkin": + self.cluster.create_catching_task(self.check_in_response(tsn)) + + async def check_in_response(self, tsn: int) -> None: + """Respond to checkin command.""" + await self.checkin_response(True, self.CHECKIN_FAST_POLL_TIMEOUT, tsn=tsn) + await self.set_long_poll_interval(self.LONG_POLL) @registries.DEVICE_TRACKER_CLUSTERS.register(general.PowerConfiguration.cluster_id) diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index c21d05aa364..3eb6f407f32 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -127,13 +127,15 @@ async def async_enable_traffic(hass, zha_devices): await hass.async_block_till_done() -def make_zcl_header(command_id: int, global_command: bool = True) -> zcl_f.ZCLHeader: +def make_zcl_header( + command_id: int, global_command: bool = True, tsn: int = 1 +) -> zcl_f.ZCLHeader: """Cluster.handle_message() ZCL Header helper.""" if global_command: frc = zcl_f.FrameControl(zcl_f.FrameType.GLOBAL_COMMAND) else: frc = zcl_f.FrameControl(zcl_f.FrameType.CLUSTER_COMMAND) - return zcl_f.ZCLHeader(frc, tsn=1, command_id=command_id) + return zcl_f.ZCLHeader(frc, tsn=tsn, command_id=command_id) def reset_clusters(clusters): diff --git a/tests/components/zha/test_channels.py b/tests/components/zha/test_channels.py index 9eac267273b..ec9c172430c 100644 --- a/tests/components/zha/test_channels.py +++ b/tests/components/zha/test_channels.py @@ -5,13 +5,14 @@ from unittest import mock import asynctest import pytest import zigpy.types as t +import zigpy.zcl.clusters import homeassistant.components.zha.core.channels as zha_channels import homeassistant.components.zha.core.channels.base as base_channels import homeassistant.components.zha.core.const as zha_const import homeassistant.components.zha.core.registries as registries -from .common import get_zha_gateway +from .common import get_zha_gateway, make_zcl_header @pytest.fixture @@ -42,6 +43,37 @@ def channel_pool(): return ch_pool_mock +@pytest.fixture +def poll_control_ch(channel_pool, zigpy_device_mock): + """Poll control channel fixture.""" + cluster_id = zigpy.zcl.clusters.general.PollControl.cluster_id + zigpy_dev = zigpy_device_mock( + {1: {"in_clusters": [cluster_id], "out_clusters": [], "device_type": 0x1234}}, + "00:11:22:33:44:55:66:77", + "test manufacturer", + "test model", + ) + + cluster = zigpy_dev.endpoints[1].in_clusters[cluster_id] + channel_class = registries.ZIGBEE_CHANNEL_REGISTRY.get(cluster_id) + return channel_class(cluster, channel_pool) + + +@pytest.fixture +async def poll_control_device(zha_device_restored, zigpy_device_mock): + """Poll control device fixture.""" + cluster_id = zigpy.zcl.clusters.general.PollControl.cluster_id + zigpy_dev = zigpy_device_mock( + {1: {"in_clusters": [cluster_id], "out_clusters": [], "device_type": 0x1234}}, + "00:11:22:33:44:55:66:77", + "test manufacturer", + "test model", + ) + + zha_device = await zha_device_restored(zigpy_dev) + return zha_device + + @pytest.mark.parametrize( "cluster_id, bind_count, attrs", [ @@ -371,3 +403,65 @@ async def test_ep_channels_configure(channel): assert ch_3.warning.call_count == 2 assert ch_5.warning.call_count == 2 + + +async def test_poll_control_configure(poll_control_ch): + """Test poll control channel configuration.""" + await poll_control_ch.async_configure() + assert poll_control_ch.cluster.write_attributes.call_count == 1 + assert poll_control_ch.cluster.write_attributes.call_args[0][0] == { + "checkin_interval": poll_control_ch.CHECKIN_INTERVAL + } + + +async def test_poll_control_checkin_response(poll_control_ch): + """Test poll control channel checkin response.""" + rsp_mock = asynctest.CoroutineMock() + set_interval_mock = asynctest.CoroutineMock() + cluster = poll_control_ch.cluster + patch_1 = mock.patch.object(cluster, "checkin_response", rsp_mock) + patch_2 = mock.patch.object(cluster, "set_long_poll_interval", set_interval_mock) + + with patch_1, patch_2: + await poll_control_ch.check_in_response(33) + + assert rsp_mock.call_count == 1 + assert set_interval_mock.call_count == 1 + + await poll_control_ch.check_in_response(33) + assert cluster.endpoint.request.call_count == 2 + assert cluster.endpoint.request.await_count == 2 + assert cluster.endpoint.request.call_args_list[0][0][1] == 33 + assert cluster.endpoint.request.call_args_list[0][0][0] == 0x0020 + assert cluster.endpoint.request.call_args_list[1][0][0] == 0x0020 + + +async def test_poll_control_cluster_command(hass, poll_control_device): + """Test poll control channel response to cluster command.""" + checkin_mock = asynctest.CoroutineMock() + poll_control_ch = poll_control_device.channels.pools[0].all_channels["1:0x0020"] + cluster = poll_control_ch.cluster + + events = [] + hass.bus.async_listen("zha_event", lambda x: events.append(x)) + await hass.async_block_till_done() + + with mock.patch.object(poll_control_ch, "check_in_response", checkin_mock): + tsn = 22 + hdr = make_zcl_header(0, global_command=False, tsn=tsn) + assert not events + cluster.handle_message( + hdr, [mock.sentinel.args, mock.sentinel.args2, mock.sentinel.args3] + ) + await hass.async_block_till_done() + + assert checkin_mock.call_count == 1 + assert checkin_mock.await_count == 1 + assert checkin_mock.await_args[0][0] == tsn + assert len(events) == 1 + data = events[0].data + assert data["command"] == "checkin" + assert data["args"][0] is mock.sentinel.args + assert data["args"][1] is mock.sentinel.args2 + assert data["args"][2] is mock.sentinel.args3 + assert data["unique_id"] == "00:11:22:33:44:55:66:77:1:0x0020" From 8a46d93be4bd0f56e0894035d6a63d9ad418da02 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 9 Mar 2020 17:42:26 -0700 Subject: [PATCH 303/416] Upgrade hass_nabucasa to 0.32.2 (#32522) * Upgrade hass_nabucasa to 32.1 * Fix tests * Update hass-nabucasa to 0.32.2 Co-authored-by: Pascal Vizeli --- homeassistant/components/cloud/http_api.py | 10 ++++------ homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/test_http_api.py | 21 +++++++++++--------- 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 9afaad422ba..c532a2063a7 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -236,9 +236,7 @@ class CloudRegisterView(HomeAssistantView): cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT): - await hass.async_add_job( - cloud.auth.register, data["email"], data["password"] - ) + await cloud.auth.async_register(data["email"], data["password"]) return self.json_message("ok") @@ -257,7 +255,7 @@ class CloudResendConfirmView(HomeAssistantView): cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT): - await hass.async_add_job(cloud.auth.resend_email_confirm, data["email"]) + await cloud.auth.async_resend_email_confirm(data["email"]) return self.json_message("ok") @@ -276,7 +274,7 @@ class CloudForgotPasswordView(HomeAssistantView): cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT): - await hass.async_add_job(cloud.auth.forgot_password, data["email"]) + await cloud.auth.async_forgot_password(data["email"]) return self.json_message("ok") @@ -336,7 +334,7 @@ async def websocket_subscription(hass, connection, msg): # In that case, let's refresh and reconnect if data.get("provider") and not cloud.is_connected: _LOGGER.debug("Found disconnected account with valid subscriotion, connecting") - await hass.async_add_executor_job(cloud.auth.renew_access_token) + await cloud.auth.async_renew_access_token() # Cancel reconnect in progress if cloud.iot.state != STATE_DISCONNECTED: diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index b91af34a96a..cfbb221c164 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -2,7 +2,7 @@ "domain": "cloud", "name": "Home Assistant Cloud", "documentation": "https://www.home-assistant.io/integrations/cloud", - "requirements": ["hass-nabucasa==0.32"], + "requirements": ["hass-nabucasa==0.32.2"], "dependencies": ["http", "webhook", "alexa"], "after_dependencies": ["google_assistant"], "codeowners": ["@home-assistant/cloud"] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c782bbbd4ea..523926ca22e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ ciso8601==2.1.3 cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 -hass-nabucasa==0.32 +hass-nabucasa==0.32.2 home-assistant-frontend==20200306.0 importlib-metadata==1.5.0 jinja2>=2.10.3 diff --git a/requirements_all.txt b/requirements_all.txt index 8c8c005bd96..acfebe8c4d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -663,7 +663,7 @@ habitipy==0.2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.32 +hass-nabucasa==0.32.2 # homeassistant.components.mqtt hbmqtt==0.9.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 39f9492ab7a..54ecf61d031 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -242,7 +242,7 @@ ha-ffmpeg==2.0 hangups==0.4.9 # homeassistant.components.cloud -hass-nabucasa==0.32 +hass-nabucasa==0.32.2 # homeassistant.components.mqtt hbmqtt==0.9.5 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index dbc936b9216..8bfa6185e9b 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -1,8 +1,9 @@ """Tests for the HTTP API for the cloud component.""" import asyncio from ipaddress import ip_network -from unittest.mock import MagicMock, Mock, patch +from unittest.mock import MagicMock, Mock +from asynctest import patch from hass_nabucasa import thingtalk from hass_nabucasa.auth import Unauthenticated, UnknownError from hass_nabucasa.const import STATE_CONNECTED @@ -131,7 +132,7 @@ async def test_login_view_random_exception(cloud_client): async def test_login_view_invalid_json(cloud_client): """Try logging in with invalid JSON.""" - with patch("hass_nabucasa.auth.CognitoAuth.login") as mock_login: + with patch("hass_nabucasa.auth.CognitoAuth.async_login") as mock_login: req = await cloud_client.post("/api/cloud/login", data="Not JSON") assert req.status == 400 assert len(mock_login.mock_calls) == 0 @@ -139,7 +140,7 @@ async def test_login_view_invalid_json(cloud_client): async def test_login_view_invalid_schema(cloud_client): """Try logging in with invalid schema.""" - with patch("hass_nabucasa.auth.CognitoAuth.login") as mock_login: + with patch("hass_nabucasa.auth.CognitoAuth.async_login") as mock_login: req = await cloud_client.post("/api/cloud/login", json={"invalid": "schema"}) assert req.status == 400 assert len(mock_login.mock_calls) == 0 @@ -148,7 +149,7 @@ async def test_login_view_invalid_schema(cloud_client): async def test_login_view_request_timeout(cloud_client): """Test request timeout while trying to log in.""" with patch( - "hass_nabucasa.auth.CognitoAuth.login", side_effect=asyncio.TimeoutError + "hass_nabucasa.auth.CognitoAuth.async_login", side_effect=asyncio.TimeoutError ): req = await cloud_client.post( "/api/cloud/login", json={"email": "my_username", "password": "my_password"} @@ -159,7 +160,9 @@ async def test_login_view_request_timeout(cloud_client): async def test_login_view_invalid_credentials(cloud_client): """Test logging in with invalid credentials.""" - with patch("hass_nabucasa.auth.CognitoAuth.login", side_effect=Unauthenticated): + with patch( + "hass_nabucasa.auth.CognitoAuth.async_login", side_effect=Unauthenticated + ): req = await cloud_client.post( "/api/cloud/login", json={"email": "my_username", "password": "my_password"} ) @@ -169,7 +172,7 @@ async def test_login_view_invalid_credentials(cloud_client): async def test_login_view_unknown_error(cloud_client): """Test unknown error while logging in.""" - with patch("hass_nabucasa.auth.CognitoAuth.login", side_effect=UnknownError): + with patch("hass_nabucasa.auth.CognitoAuth.async_login", side_effect=UnknownError): req = await cloud_client.post( "/api/cloud/login", json={"email": "my_username", "password": "my_password"} ) @@ -382,7 +385,7 @@ async def test_websocket_subscription_reconnect( client = await hass_ws_client(hass) with patch( - "hass_nabucasa.auth.CognitoAuth.renew_access_token" + "hass_nabucasa.auth.CognitoAuth.async_renew_access_token" ) as mock_renew, patch("hass_nabucasa.iot.CloudIoT.connect") as mock_connect: await client.send_json({"id": 5, "type": "cloud/subscription"}) response = await client.receive_json() @@ -401,7 +404,7 @@ async def test_websocket_subscription_no_reconnect_if_connected( client = await hass_ws_client(hass) with patch( - "hass_nabucasa.auth.CognitoAuth.renew_access_token" + "hass_nabucasa.auth.CognitoAuth.async_renew_access_token" ) as mock_renew, patch("hass_nabucasa.iot.CloudIoT.connect") as mock_connect: await client.send_json({"id": 5, "type": "cloud/subscription"}) response = await client.receive_json() @@ -419,7 +422,7 @@ async def test_websocket_subscription_no_reconnect_if_expired( client = await hass_ws_client(hass) with patch( - "hass_nabucasa.auth.CognitoAuth.renew_access_token" + "hass_nabucasa.auth.CognitoAuth.async_renew_access_token" ) as mock_renew, patch("hass_nabucasa.iot.CloudIoT.connect") as mock_connect: await client.send_json({"id": 5, "type": "cloud/subscription"}) response = await client.receive_json() From 3a680bf7b78284762713cab7cb67223f41d8871d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Mar 2020 19:43:26 -0500 Subject: [PATCH 304/416] Add a commit interval setting to recorder (#32596) * Add a commit interval setting to recorder * Make the default every 1s instead of immediate * See attached py-spy flamegraphs for why 1s * This avoids disk thrashing during event storms * Make Home Assistant significantly more responsive on busy systems * remove debug * Add commit forces for tests that expect commits to be immediate * Add commit forces for tests that expect commits to be immediate * make sure _trigger_db_commit is in the right place (all effective "wait_recording_done" calls) * De-duplicate wait_recording_done code --- homeassistant/components/recorder/__init__.py | 129 ++++++++++++------ tests/components/history/test_init.py | 19 ++- tests/components/logbook/test_init.py | 3 + tests/components/recorder/common.py | 22 +++ tests/components/recorder/test_init.py | 14 +- 5 files changed, 124 insertions(+), 63 deletions(-) create mode 100644 tests/components/recorder/common.py diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index af34d4dd9f6..a662a457add 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -68,6 +68,7 @@ CONF_DB_RETRY_WAIT = "db_retry_wait" CONF_PURGE_KEEP_DAYS = "purge_keep_days" CONF_PURGE_INTERVAL = "purge_interval" CONF_EVENT_TYPES = "event_types" +CONF_COMMIT_INTERVAL = "commit_interval" FILTER_SCHEMA = vol.Schema( { @@ -98,6 +99,9 @@ CONFIG_SCHEMA = vol.Schema( vol.Coerce(int), vol.Range(min=0) ), vol.Optional(CONF_DB_URL): cv.string, + vol.Optional(CONF_COMMIT_INTERVAL, default=1): vol.All( + vol.Coerce(int), vol.Range(min=0) + ), vol.Optional( CONF_DB_MAX_RETRIES, default=DEFAULT_DB_MAX_RETRIES ): cv.positive_int, @@ -141,6 +145,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf = config[DOMAIN] keep_days = conf.get(CONF_PURGE_KEEP_DAYS) purge_interval = conf.get(CONF_PURGE_INTERVAL) + commit_interval = conf[CONF_COMMIT_INTERVAL] db_max_retries = conf[CONF_DB_MAX_RETRIES] db_retry_wait = conf[CONF_DB_RETRY_WAIT] @@ -154,6 +159,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass=hass, keep_days=keep_days, purge_interval=purge_interval, + commit_interval=commit_interval, uri=db_url, db_max_retries=db_max_retries, db_retry_wait=db_retry_wait, @@ -185,6 +191,7 @@ class Recorder(threading.Thread): hass: HomeAssistant, keep_days: int, purge_interval: int, + commit_interval: int, uri: str, db_max_retries: int, db_retry_wait: int, @@ -197,6 +204,7 @@ class Recorder(threading.Thread): self.hass = hass self.keep_days = keep_days self.purge_interval = purge_interval + self.commit_interval = commit_interval self.queue: Any = queue.Queue() self.recording_start = dt_util.utcnow() self.db_url = uri @@ -214,6 +222,8 @@ class Recorder(threading.Thread): ) self.exclude_t = exclude.get(CONF_EVENT_TYPES, []) + self._timechanges_seen = 0 + self.event_session = None self.get_session = None @callback @@ -326,6 +336,10 @@ class Recorder(threading.Thread): self.hass.helpers.event.track_point_in_time(async_purge, run) + self.event_session = self.get_session() + # Use a session for the event read loop + # with a commit every time the event time + # has changed. This reduces the disk io. while True: event = self.queue.get() @@ -340,6 +354,11 @@ class Recorder(threading.Thread): continue if event.event_type == EVENT_TIME_CHANGED: self.queue.task_done() + if self.commit_interval: + self._timechanges_seen += 1 + if self.commit_interval >= self._timechanges_seen: + self._timechanges_seen = 0 + self._commit_event_session_or_retry() continue if event.event_type in self.exclude_t: self.queue.task_done() @@ -351,55 +370,72 @@ class Recorder(threading.Thread): self.queue.task_done() continue - tries = 1 - updated = False - while not updated and tries <= self.db_max_retries: - if tries != 1: - time.sleep(self.db_retry_wait) + try: + dbevent = Events.from_event(event) + self.event_session.add(dbevent) + self.event_session.flush() + except (TypeError, ValueError): + _LOGGER.warning("Event is not JSON serializable: %s", event) + + if dbevent and event.event_type == EVENT_STATE_CHANGED: try: - with session_scope(session=self.get_session()) as session: - try: - dbevent = Events.from_event(event) - session.add(dbevent) - session.flush() - except (TypeError, ValueError): - _LOGGER.warning("Event is not JSON serializable: %s", event) - - if event.event_type == EVENT_STATE_CHANGED: - try: - dbstate = States.from_event(event) - dbstate.event_id = dbevent.event_id - session.add(dbstate) - except (TypeError, ValueError): - _LOGGER.warning( - "State is not JSON serializable: %s", - event.data.get("new_state"), - ) - - updated = True - - except exc.OperationalError as err: - _LOGGER.error( - "Error in database connectivity: %s. " - "(retrying in %s seconds)", - err, - self.db_retry_wait, + dbstate = States.from_event(event) + dbstate.event_id = dbevent.event_id + self.event_session.add(dbstate) + except (TypeError, ValueError): + _LOGGER.warning( + "State is not JSON serializable: %s", + event.data.get("new_state"), ) - tries += 1 - except exc.SQLAlchemyError: - updated = True - _LOGGER.exception("Error saving event: %s", event) - - if not updated: - _LOGGER.error( - "Error in database update. Could not save " - "after %d tries. Giving up", - tries, - ) + # If they do not have a commit interval + # than we commit right away + if not self.commit_interval: + self._commit_event_session_or_retry() self.queue.task_done() + def _commit_event_session_or_retry(self): + tries = 1 + while tries <= self.db_max_retries: + if tries != 1: + time.sleep(self.db_retry_wait) + + try: + self._commit_event_session() + return + + except exc.OperationalError as err: + _LOGGER.error( + "Error in database connectivity: %s. " "(retrying in %s seconds)", + err, + self.db_retry_wait, + ) + tries += 1 + + except exc.SQLAlchemyError: + _LOGGER.exception("Error saving events") + return + + _LOGGER.error( + "Error in database update. Could not save " "after %d tries. Giving up", + tries, + ) + try: + self.event_session.close() + except exc.SQLAlchemyError: + _LOGGER.exception("Failed to close event session.") + + self.event_session = self.get_session() + + def _commit_event_session(self): + try: + self.event_session.commit() + except Exception as err: + _LOGGER.error("Error executing query: %s", err) + self.event_session.rollback() + raise + @callback def event_listener(self, event): """Listen for new events and put them in the process queue.""" @@ -465,7 +501,10 @@ class Recorder(threading.Thread): def _close_run(self): """Save end time for current run.""" - with session_scope(session=self.get_session()) as session: + if self.event_session is not None: self.run_info.end = dt_util.utcnow() - session.add(self.run_info) + self.event_session.add(self.run_info) + self._commit_event_session_or_retry() + self.event_session.close() + self.run_info = None diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 051024999e4..65c0a717bee 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -14,6 +14,7 @@ from tests.common import ( init_recorder_component, mock_state_change_event, ) +from tests.components.recorder.common import wait_recording_done class TestComponentHistory(unittest.TestCase): @@ -31,12 +32,7 @@ class TestComponentHistory(unittest.TestCase): """Initialize the recorder.""" init_recorder_component(self.hass) self.hass.start() - self.wait_recording_done() - - def wait_recording_done(self): - """Block till recording is done.""" - self.hass.block_till_done() - self.hass.data[recorder.DATA_INSTANCE].block_till_done() + wait_recording_done(self.hass) def test_setup(self): """Test setup method of history.""" @@ -78,7 +74,7 @@ class TestComponentHistory(unittest.TestCase): states.append(state) - self.wait_recording_done() + wait_recording_done(self.hass) future = now + timedelta(seconds=1) with patch( @@ -93,7 +89,7 @@ class TestComponentHistory(unittest.TestCase): mock_state_change_event(self.hass, state) - self.wait_recording_done() + wait_recording_done(self.hass) # Get states returns everything before POINT for state1, state2 in zip( @@ -115,7 +111,7 @@ class TestComponentHistory(unittest.TestCase): def set_state(state): """Set the state.""" self.hass.states.set(entity_id, state) - self.wait_recording_done() + wait_recording_done(self.hass) return self.hass.states.get(entity_id) start = dt_util.utcnow() @@ -156,7 +152,7 @@ class TestComponentHistory(unittest.TestCase): def set_state(state): """Set the state.""" self.hass.states.set(entity_id, state) - self.wait_recording_done() + wait_recording_done(self.hass) return self.hass.states.get(entity_id) start = dt_util.utcnow() - timedelta(minutes=2) @@ -559,7 +555,7 @@ class TestComponentHistory(unittest.TestCase): def set_state(entity_id, state, **kwargs): """Set the state.""" self.hass.states.set(entity_id, state, **kwargs) - self.wait_recording_done() + wait_recording_done(self.hass) return self.hass.states.get(entity_id) zero = dt_util.utcnow() @@ -615,6 +611,7 @@ class TestComponentHistory(unittest.TestCase): ) # state will be skipped since entity is hidden set_state(therm, 22, attributes={"current_temperature": 21, "hidden": True}) + return zero, four, states diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index cc9a459d2c1..98653dc5a6c 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -1,6 +1,7 @@ """The tests for the logbook component.""" # pylint: disable=protected-access,invalid-name from datetime import datetime, timedelta +from functools import partial import logging import unittest @@ -35,6 +36,7 @@ from homeassistant.setup import async_setup_component, setup_component import homeassistant.util.dt as dt_util from tests.common import get_test_home_assistant, init_recorder_component +from tests.components.recorder.common import trigger_db_commit _LOGGER = logging.getLogger(__name__) @@ -1288,6 +1290,7 @@ async def test_logbook_view_period_entity(hass, hass_client): entity_id_second = "switch.second" hass.states.async_set(entity_id_second, STATE_OFF) hass.states.async_set(entity_id_second, STATE_ON) + await hass.async_add_job(partial(trigger_db_commit, hass)) await hass.async_block_till_done() await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done) diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py new file mode 100644 index 00000000000..07bfcb0bf4f --- /dev/null +++ b/tests/components/recorder/common.py @@ -0,0 +1,22 @@ +"""Common test utils for working with recorder.""" + +from homeassistant.components import recorder +from homeassistant.util import dt as dt_util + +from tests.common import fire_time_changed + +DB_COMMIT_INTERVAL = 50 + + +def wait_recording_done(hass): + """Block till recording is done.""" + trigger_db_commit(hass) + hass.block_till_done() + hass.data[recorder.DATA_INSTANCE].block_till_done() + + +def trigger_db_commit(hass): + """Force the recorder to commit.""" + for _ in range(DB_COMMIT_INTERVAL): + # We only commit on time change + fire_time_changed(hass, dt_util.utcnow()) diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py index a21ef578ca9..8a56ba3d977 100644 --- a/tests/components/recorder/test_init.py +++ b/tests/components/recorder/test_init.py @@ -13,6 +13,8 @@ from homeassistant.const import MATCH_ALL from homeassistant.core import callback from homeassistant.setup import async_setup_component +from .common import wait_recording_done + from tests.common import get_test_home_assistant, init_recorder_component @@ -37,8 +39,7 @@ class TestRecorder(unittest.TestCase): self.hass.states.set(entity_id, state, attributes) - self.hass.block_till_done() - self.hass.data[DATA_INSTANCE].block_till_done() + wait_recording_done(self.hass) with session_scope(hass=self.hass) as session: db_states = list(session.query(States)) @@ -65,7 +66,7 @@ class TestRecorder(unittest.TestCase): self.hass.bus.fire(event_type, event_data) - self.hass.block_till_done() + wait_recording_done(self.hass) assert len(events) == 1 event = events[0] @@ -109,8 +110,7 @@ def _add_entities(hass, entity_ids): attributes = {"test_attr": 5, "test_attr_10": "nice"} for idx, entity_id in enumerate(entity_ids): hass.states.set(entity_id, "state{}".format(idx), attributes) - hass.block_till_done() - hass.data[DATA_INSTANCE].block_till_done() + wait_recording_done(hass) with session_scope(hass=hass) as session: return [st.to_native() for st in session.query(States)] @@ -121,8 +121,7 @@ def _add_events(hass, events): session.query(Events).delete(synchronize_session=False) for event_type in events: hass.bus.fire(event_type) - hass.block_till_done() - hass.data[DATA_INSTANCE].block_till_done() + wait_recording_done(hass) with session_scope(hass=hass) as session: return [ev.to_native() for ev in session.query(Events)] @@ -201,6 +200,7 @@ def test_recorder_setup_failure(): hass, keep_days=7, purge_interval=2, + commit_interval=1, uri="sqlite://", db_max_retries=10, db_retry_wait=3, From c2b03332a0e08d0a4347aaad3c233908029e9864 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 9 Mar 2020 19:44:34 -0500 Subject: [PATCH 305/416] Breakout tado zone code into a single place (#32564) * Breakout tado zone code into a single place * Resolve various incorrect states and add tests for known tado zone states * Fix home and away presets * Upgrade to PyTado 0.4.0 which improves http performance and fixes setting fan speed. * Write state instead of calling for an update * adjust codeowners * Add tests for michael's tado and fix heatingPower.value * Guards are much cleaner * Adjust per review * Remove hass passing --- CODEOWNERS | 2 +- homeassistant/components/tado/__init__.py | 70 ++- homeassistant/components/tado/climate.py | 486 +++++++++--------- homeassistant/components/tado/const.py | 94 +++- homeassistant/components/tado/manifest.json | 4 +- homeassistant/components/tado/sensor.py | 122 ++--- homeassistant/components/tado/tado_adapter.py | 285 ++++++++++ homeassistant/components/tado/water_heater.py | 126 +++-- requirements_all.txt | 2 +- requirements_test_all.txt | 3 + tests/components/tado/mocks.py | 18 + tests/components/tado/test_tado_adapter.py | 423 +++++++++++++++ .../tado/ac_issue_32294.heat_mode.json | 60 +++ tests/fixtures/tado/hvac_action_heat.json | 67 +++ tests/fixtures/tado/michael_heat_mode.json | 58 +++ tests/fixtures/tado/smartac3.auto_mode.json | 57 ++ tests/fixtures/tado/smartac3.cool_mode.json | 67 +++ tests/fixtures/tado/smartac3.dry_mode.json | 57 ++ tests/fixtures/tado/smartac3.fan_mode.json | 57 ++ tests/fixtures/tado/smartac3.heat_mode.json | 67 +++ tests/fixtures/tado/smartac3.hvac_off.json | 55 ++ tests/fixtures/tado/smartac3.manual_off.json | 55 ++ tests/fixtures/tado/smartac3.offline.json | 71 +++ tests/fixtures/tado/smartac3.smart_mode.json | 50 ++ tests/fixtures/tado/smartac3.turning_off.json | 55 ++ 25 files changed, 2000 insertions(+), 411 deletions(-) create mode 100644 homeassistant/components/tado/tado_adapter.py create mode 100644 tests/components/tado/mocks.py create mode 100644 tests/components/tado/test_tado_adapter.py create mode 100644 tests/fixtures/tado/ac_issue_32294.heat_mode.json create mode 100644 tests/fixtures/tado/hvac_action_heat.json create mode 100644 tests/fixtures/tado/michael_heat_mode.json create mode 100644 tests/fixtures/tado/smartac3.auto_mode.json create mode 100644 tests/fixtures/tado/smartac3.cool_mode.json create mode 100644 tests/fixtures/tado/smartac3.dry_mode.json create mode 100644 tests/fixtures/tado/smartac3.fan_mode.json create mode 100644 tests/fixtures/tado/smartac3.heat_mode.json create mode 100644 tests/fixtures/tado/smartac3.hvac_off.json create mode 100644 tests/fixtures/tado/smartac3.manual_off.json create mode 100644 tests/fixtures/tado/smartac3.offline.json create mode 100644 tests/fixtures/tado/smartac3.smart_mode.json create mode 100644 tests/fixtures/tado/smartac3.turning_off.json diff --git a/CODEOWNERS b/CODEOWNERS index 89417c4ca56..f59eb6322f2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -350,7 +350,7 @@ homeassistant/components/switchmate/* @danielhiversen homeassistant/components/syncthru/* @nielstron homeassistant/components/synology_srm/* @aerialls homeassistant/components/syslog/* @fabaff -homeassistant/components/tado/* @michaelarnauts +homeassistant/components/tado/* @michaelarnauts @bdraco homeassistant/components/tahoma/* @philklei homeassistant/components/tankerkoenig/* @guillempages homeassistant/components/tautulli/* @ludeeus diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 727fb868a33..5442493cbaa 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -1,12 +1,13 @@ """Support for the (unofficial) Tado API.""" from datetime import timedelta import logging -import urllib from PyTado.interface import Tado +from requests import RequestException import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import dispatcher_send @@ -109,7 +110,7 @@ class TadoConnector: """Connect to Tado and fetch the zones.""" try: self.tado = Tado(self._username, self._password) - except (RuntimeError, urllib.error.HTTPError) as exc: + except (RuntimeError, RequestException) as exc: _LOGGER.error("Unable to connect: %s", exc) return False @@ -136,7 +137,12 @@ class TadoConnector: if sensor_type == "zone": data = self.tado.getState(sensor) elif sensor_type == "device": - data = self.tado.getDevices()[0] + devices_data = self.tado.getDevices() + if not devices_data: + _LOGGER.info("There are no devices to setup on this tado account.") + return + + data = devices_data[0] else: _LOGGER.debug("Unknown sensor: %s", sensor_type) return @@ -162,31 +168,62 @@ class TadoConnector: self.tado.resetZoneOverlay(zone_id) self.update_sensor("zone", zone_id) + def set_home(self): + """Put tado in home mode.""" + response_json = None + try: + response_json = self.tado.setHome() + except RequestException as exc: + _LOGGER.error("Could not set home: %s", exc) + + _raise_home_away_errors(response_json) + + def set_away(self): + """Put tado in away mode.""" + response_json = None + try: + response_json = self.tado.setAway() + except RequestException as exc: + _LOGGER.error("Could not set away: %s", exc) + + _raise_home_away_errors(response_json) + def set_zone_overlay( self, - zone_id, - overlay_mode, + zone_id=None, + overlay_mode=None, temperature=None, duration=None, device_type="HEATING", mode=None, + fan_speed=None, ): """Set a zone overlay.""" _LOGGER.debug( - "Set overlay for zone %s: mode=%s, temp=%s, duration=%s, type=%s, mode=%s", + "Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s, type=%s, mode=%s fan_speed=%s", zone_id, overlay_mode, temperature, duration, device_type, mode, + fan_speed, ) + try: self.tado.setZoneOverlay( - zone_id, overlay_mode, temperature, duration, device_type, "ON", mode + zone_id, + overlay_mode, + temperature, + duration, + device_type, + "ON", + mode, + fan_speed, ) - except urllib.error.HTTPError as exc: - _LOGGER.error("Could not set zone overlay: %s", exc.read()) + + except RequestException as exc: + _LOGGER.error("Could not set zone overlay: %s", exc) self.update_sensor("zone", zone_id) @@ -196,7 +233,18 @@ class TadoConnector: self.tado.setZoneOverlay( zone_id, overlay_mode, None, None, device_type, "OFF" ) - except urllib.error.HTTPError as exc: - _LOGGER.error("Could not set zone overlay: %s", exc.read()) + except RequestException as exc: + _LOGGER.error("Could not set zone overlay: %s", exc) self.update_sensor("zone", zone_id) + + +def _raise_home_away_errors(response_json): + if response_json is None: + return + + # Likely we are displaying to the user: + # Tried to update to HOME though all mobile devices are detected outside the home fence + if "errors" in response_json and len(response_json["errors"]) > 0: + error_list = response_json["errors"] + raise HomeAssistantError(error_list[0]["title"]) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index b92a54edd5e..52b26738373 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -3,21 +3,12 @@ import logging from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( - CURRENT_HVAC_COOL, - CURRENT_HVAC_HEAT, - CURRENT_HVAC_IDLE, CURRENT_HVAC_OFF, - FAN_HIGH, - FAN_LOW, - FAN_MIDDLE, - FAN_OFF, - HVAC_MODE_AUTO, - HVAC_MODE_COOL, + FAN_AUTO, HVAC_MODE_HEAT, - HVAC_MODE_HEAT_COOL, - HVAC_MODE_OFF, PRESET_AWAY, PRESET_HOME, + SUPPORT_FAN_MODE, SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) @@ -27,49 +18,29 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED from .const import ( + CONST_FAN_AUTO, + CONST_FAN_OFF, + CONST_MODE_COOL, + CONST_MODE_HEAT, CONST_MODE_OFF, CONST_MODE_SMART_SCHEDULE, CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_MODE, - CONST_OVERLAY_TIMER, DATA, + HA_TO_TADO_FAN_MODE_MAP, + HA_TO_TADO_HVAC_MODE_MAP, + ORDERED_KNOWN_TADO_MODES, + SUPPORT_PRESET, + TADO_MODES_WITH_NO_TEMP_SETTING, + TADO_TO_HA_FAN_MODE_MAP, + TADO_TO_HA_HVAC_MODE_MAP, TYPE_AIR_CONDITIONING, TYPE_HEATING, ) +from .tado_adapter import TadoZoneData _LOGGER = logging.getLogger(__name__) -FAN_MAP_TADO = {"HIGH": FAN_HIGH, "MIDDLE": FAN_MIDDLE, "LOW": FAN_LOW} - -HVAC_MAP_TADO_HEAT = { - CONST_OVERLAY_MANUAL: HVAC_MODE_HEAT, - CONST_OVERLAY_TIMER: HVAC_MODE_HEAT, - CONST_OVERLAY_TADO_MODE: HVAC_MODE_HEAT, - CONST_MODE_SMART_SCHEDULE: HVAC_MODE_AUTO, - CONST_MODE_OFF: HVAC_MODE_OFF, -} -HVAC_MAP_TADO_COOL = { - CONST_OVERLAY_MANUAL: HVAC_MODE_COOL, - CONST_OVERLAY_TIMER: HVAC_MODE_COOL, - CONST_OVERLAY_TADO_MODE: HVAC_MODE_COOL, - CONST_MODE_SMART_SCHEDULE: HVAC_MODE_AUTO, - CONST_MODE_OFF: HVAC_MODE_OFF, -} -HVAC_MAP_TADO_HEAT_COOL = { - CONST_OVERLAY_MANUAL: HVAC_MODE_HEAT_COOL, - CONST_OVERLAY_TIMER: HVAC_MODE_HEAT_COOL, - CONST_OVERLAY_TADO_MODE: HVAC_MODE_HEAT_COOL, - CONST_MODE_SMART_SCHEDULE: HVAC_MODE_AUTO, - CONST_MODE_OFF: HVAC_MODE_OFF, -} - -SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE -SUPPORT_HVAC_HEAT = [HVAC_MODE_HEAT, HVAC_MODE_AUTO, HVAC_MODE_OFF] -SUPPORT_HVAC_COOL = [HVAC_MODE_COOL, HVAC_MODE_AUTO, HVAC_MODE_OFF] -SUPPORT_HVAC_HEAT_COOL = [HVAC_MODE_HEAT_COOL, HVAC_MODE_AUTO, HVAC_MODE_OFF] -SUPPORT_FAN = [FAN_HIGH, FAN_MIDDLE, FAN_LOW, FAN_OFF] -SUPPORT_PRESET = [PRESET_AWAY, PRESET_HOME] - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Tado climate platform.""" @@ -96,29 +67,80 @@ def create_climate_entity(tado, name: str, zone_id: int): _LOGGER.debug("Capabilities for zone %s: %s", zone_id, capabilities) zone_type = capabilities["type"] + support_flags = SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE + supported_hvac_modes = [ + TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_OFF], + TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_SMART_SCHEDULE], + ] + supported_fan_modes = None + heat_temperatures = None + cool_temperatures = None - ac_support_heat = False if zone_type == TYPE_AIR_CONDITIONING: - # Only use heat if available - # (you don't have to setup a heat mode, but cool is required) # Heat is preferred as it generally has a lower minimum temperature - if "HEAT" in capabilities: - temperatures = capabilities["HEAT"]["temperatures"] - ac_support_heat = True - else: - temperatures = capabilities["COOL"]["temperatures"] - elif "temperatures" in capabilities: - temperatures = capabilities["temperatures"] + for mode in ORDERED_KNOWN_TADO_MODES: + if mode not in capabilities: + continue + + supported_hvac_modes.append(TADO_TO_HA_HVAC_MODE_MAP[mode]) + if not capabilities[mode].get("fanSpeeds"): + continue + + support_flags |= SUPPORT_FAN_MODE + + if supported_fan_modes: + continue + + supported_fan_modes = [ + TADO_TO_HA_FAN_MODE_MAP[speed] + for speed in capabilities[mode]["fanSpeeds"] + ] + + cool_temperatures = capabilities[CONST_MODE_COOL]["temperatures"] else: - _LOGGER.debug("Not adding zone %s since it has no temperature", name) + supported_hvac_modes.append(HVAC_MODE_HEAT) + + if CONST_MODE_HEAT in capabilities: + heat_temperatures = capabilities[CONST_MODE_HEAT]["temperatures"] + + if heat_temperatures is None and "temperatures" in capabilities: + heat_temperatures = capabilities["temperatures"] + + if cool_temperatures is None and heat_temperatures is None: + _LOGGER.debug("Not adding zone %s since it has no temperatures", name) return None - min_temp = float(temperatures["celsius"]["min"]) - max_temp = float(temperatures["celsius"]["max"]) - step = temperatures["celsius"].get("step", PRECISION_TENTHS) + heat_min_temp = None + heat_max_temp = None + heat_step = None + cool_min_temp = None + cool_max_temp = None + cool_step = None + + if heat_temperatures is not None: + heat_min_temp = float(heat_temperatures["celsius"]["min"]) + heat_max_temp = float(heat_temperatures["celsius"]["max"]) + heat_step = heat_temperatures["celsius"].get("step", PRECISION_TENTHS) + + if cool_temperatures is not None: + cool_min_temp = float(cool_temperatures["celsius"]["min"]) + cool_max_temp = float(cool_temperatures["celsius"]["max"]) + cool_step = cool_temperatures["celsius"].get("step", PRECISION_TENTHS) entity = TadoClimate( - tado, name, zone_id, zone_type, min_temp, max_temp, step, ac_support_heat, + tado, + name, + zone_id, + zone_type, + heat_min_temp, + heat_max_temp, + heat_step, + cool_min_temp, + cool_max_temp, + cool_step, + supported_hvac_modes, + supported_fan_modes, + support_flags, ) return entity @@ -132,10 +154,15 @@ class TadoClimate(ClimateDevice): zone_name, zone_id, zone_type, - min_temp, - max_temp, - step, - ac_support_heat, + heat_min_temp, + heat_max_temp, + heat_step, + cool_min_temp, + cool_max_temp, + cool_step, + supported_hvac_modes, + supported_fan_modes, + support_flags, ): """Initialize of Tado climate entity.""" self._tado = tado @@ -146,49 +173,45 @@ class TadoClimate(ClimateDevice): self._unique_id = f"{zone_type} {zone_id} {tado.device_id}" self._ac_device = zone_type == TYPE_AIR_CONDITIONING - self._ac_support_heat = ac_support_heat - self._cooling = False + self._supported_hvac_modes = supported_hvac_modes + self._supported_fan_modes = supported_fan_modes + self._support_flags = support_flags - self._active = False - self._device_is_active = False + self._available = False self._cur_temp = None self._cur_humidity = None - self._is_away = False - self._min_temp = min_temp - self._max_temp = max_temp - self._step = step + + self._heat_min_temp = heat_min_temp + self._heat_max_temp = heat_max_temp + self._heat_step = heat_step + + self._cool_min_temp = cool_min_temp + self._cool_max_temp = cool_max_temp + self._cool_step = cool_step + self._target_temp = None - if tado.fallback: - # Fallback to Smart Schedule at next Schedule switch - self._default_overlay = CONST_OVERLAY_TADO_MODE - else: - # Don't fallback to Smart Schedule, but keep in manual mode - self._default_overlay = CONST_OVERLAY_MANUAL + self._current_tado_fan_speed = CONST_FAN_OFF + self._current_tado_hvac_mode = CONST_MODE_OFF + self._current_hvac_action = CURRENT_HVAC_OFF - self._current_fan = CONST_MODE_OFF - self._current_operation = CONST_MODE_SMART_SCHEDULE - self._overlay_mode = CONST_MODE_SMART_SCHEDULE + self._tado_zone_data = None + self._async_update_zone_data() async def async_added_to_hass(self): """Register for sensor updates.""" - @callback - def async_update_callback(): - """Schedule an entity update.""" - self.async_schedule_update_ha_state(True) - async_dispatcher_connect( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format("zone", self.zone_id), - async_update_callback, + self._async_update_callback, ) @property def supported_features(self): """Return the list of supported features.""" - return SUPPORT_FLAGS + return self._support_flags @property def name(self): @@ -208,12 +231,12 @@ class TadoClimate(ClimateDevice): @property def current_humidity(self): """Return the current humidity.""" - return self._cur_humidity + return self._tado_zone_data.current_humidity @property def current_temperature(self): """Return the sensor temperature.""" - return self._cur_temp + return self._tado_zone_data.current_temp @property def hvac_mode(self): @@ -221,11 +244,9 @@ class TadoClimate(ClimateDevice): Need to be one of HVAC_MODE_*. """ - if self._ac_device and self._ac_support_heat: - return HVAC_MAP_TADO_HEAT_COOL.get(self._current_operation) - if self._ac_device and not self._ac_support_heat: - return HVAC_MAP_TADO_COOL.get(self._current_operation) - return HVAC_MAP_TADO_HEAT.get(self._current_operation) + return TADO_TO_HA_HVAC_MODE_MAP.get( + self._tado_zone_data.current_tado_hvac_mode, CURRENT_HVAC_OFF + ) @property def hvac_modes(self): @@ -233,11 +254,7 @@ class TadoClimate(ClimateDevice): Need to be a subset of HVAC_MODES. """ - if self._ac_device: - if self._ac_support_heat: - return SUPPORT_HVAC_HEAT_COOL - return SUPPORT_HVAC_COOL - return SUPPORT_HVAC_HEAT + return self._supported_hvac_modes @property def hvac_action(self): @@ -245,40 +262,28 @@ class TadoClimate(ClimateDevice): Need to be one of CURRENT_HVAC_*. """ - if not self._device_is_active: - return CURRENT_HVAC_OFF - if self._ac_device: - if self._active: - if self._ac_support_heat and not self._cooling: - return CURRENT_HVAC_HEAT - return CURRENT_HVAC_COOL - return CURRENT_HVAC_IDLE - if self._active: - return CURRENT_HVAC_HEAT - return CURRENT_HVAC_IDLE + return self._tado_zone_data.current_hvac_action @property def fan_mode(self): """Return the fan setting.""" if self._ac_device: - return FAN_MAP_TADO.get(self._current_fan) + return TADO_TO_HA_FAN_MODE_MAP.get(self._current_tado_fan_speed, FAN_AUTO) return None @property def fan_modes(self): """List of available fan modes.""" - if self._ac_device: - return SUPPORT_FAN - return None + return self._supported_fan_modes def set_fan_mode(self, fan_mode: str): """Turn fan on/off.""" - pass + self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) @property def preset_mode(self): """Return the current preset mode (home, away).""" - if self._is_away: + if self._tado_zone_data.is_away: return PRESET_AWAY return PRESET_HOME @@ -289,7 +294,10 @@ class TadoClimate(ClimateDevice): def set_preset_mode(self, preset_mode): """Set new preset mode.""" - pass + if preset_mode == PRESET_HOME: + self._tado.set_home() + else: + self._tado.set_away() @property def temperature_unit(self): @@ -299,12 +307,14 @@ class TadoClimate(ClimateDevice): @property def target_temperature_step(self): """Return the supported step of target temperature.""" - return self._step + if self._tado_zone_data.current_tado_hvac_mode == CONST_MODE_COOL: + return self._cool_step or self._heat_step + return self._heat_step or self._cool_step @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._target_temp + return self._tado_zone_data.target_temp def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -312,174 +322,142 @@ class TadoClimate(ClimateDevice): if temperature is None: return - self._current_operation = self._default_overlay - self._overlay_mode = None - self._target_temp = temperature - self._control_heating() + self._control_hvac(target_temp=temperature) def set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" - mode = None - if hvac_mode == HVAC_MODE_OFF: - mode = CONST_MODE_OFF - elif hvac_mode == HVAC_MODE_AUTO: - mode = CONST_MODE_SMART_SCHEDULE - elif hvac_mode == HVAC_MODE_HEAT: - mode = self._default_overlay - elif hvac_mode == HVAC_MODE_COOL: - mode = self._default_overlay - elif hvac_mode == HVAC_MODE_HEAT_COOL: - mode = self._default_overlay + self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode]) - self._current_operation = mode - self._overlay_mode = None - - # Set a target temperature if we don't have any - # This can happen when we switch from Off to On - if self._target_temp is None: - if self._ac_device: - self._target_temp = self.max_temp - else: - self._target_temp = self.min_temp - self.schedule_update_ha_state() - - self._control_heating() + @property + def available(self): + """Return if the device is available.""" + return self._tado_zone_data.available @property def min_temp(self): """Return the minimum temperature.""" - return self._min_temp + if ( + self._current_tado_hvac_mode == CONST_MODE_COOL + and self._cool_min_temp is not None + ): + return self._cool_min_temp + if self._heat_min_temp is not None: + return self._heat_min_temp + + return self._cool_min_temp @property def max_temp(self): """Return the maximum temperature.""" - return self._max_temp - - def update(self): - """Handle update callbacks.""" - _LOGGER.debug("Updating climate platform for zone %d", self.zone_id) - data = self._tado.data["zone"][self.zone_id] - - if "sensorDataPoints" in data: - sensor_data = data["sensorDataPoints"] - - if "insideTemperature" in sensor_data: - temperature = float(sensor_data["insideTemperature"]["celsius"]) - self._cur_temp = temperature - - if "humidity" in sensor_data: - humidity = float(sensor_data["humidity"]["percentage"]) - self._cur_humidity = humidity - - # temperature setting will not exist when device is off if ( - "temperature" in data["setting"] - and data["setting"]["temperature"] is not None + self._current_tado_hvac_mode == CONST_MODE_HEAT + and self._heat_max_temp is not None ): - setting = float(data["setting"]["temperature"]["celsius"]) - self._target_temp = setting + return self._heat_max_temp + if self._heat_max_temp is not None: + return self._heat_max_temp - if "tadoMode" in data: - mode = data["tadoMode"] - self._is_away = mode == "AWAY" + return self._heat_max_temp - if "setting" in data: - power = data["setting"]["power"] - if power == "OFF": - self._current_operation = CONST_MODE_OFF - self._current_fan = CONST_MODE_OFF - # There is no overlay, the mode will always be - # "SMART_SCHEDULE" - self._overlay_mode = CONST_MODE_SMART_SCHEDULE - self._device_is_active = False + @callback + def _async_update_zone_data(self): + """Load tado data into zone.""" + self._tado_zone_data = TadoZoneData( + self._tado.data["zone"][self.zone_id], self.zone_id + ) + + @callback + def _async_update_callback(self): + """Load tado data and update state.""" + self._async_update_zone_data() + self.async_write_ha_state() + + def _normalize_target_temp_for_hvac_mode(self): + # Set a target temperature if we don't have any + # This can happen when we switch from Off to On + if self._target_temp is None: + if self._current_tado_hvac_mode == CONST_MODE_COOL: + self._target_temp = self._cool_max_temp else: - self._device_is_active = True + self._target_temp = self._heat_min_temp + elif self._current_tado_hvac_mode == CONST_MODE_COOL: + if self._target_temp > self._cool_max_temp: + self._target_temp = self._cool_max_temp + elif self._target_temp < self._cool_min_temp: + self._target_temp = self._cool_min_temp + elif self._current_tado_hvac_mode == CONST_MODE_HEAT: + if self._target_temp > self._heat_max_temp: + self._target_temp = self._heat_max_temp + elif self._target_temp < self._heat_min_temp: + self._target_temp = self._heat_min_temp - active = False - if "activityDataPoints" in data: - activity_data = data["activityDataPoints"] - if self._ac_device: - if "acPower" in activity_data and activity_data["acPower"] is not None: - if not activity_data["acPower"]["value"] == "OFF": - active = True - else: - if ( - "heatingPower" in activity_data - and activity_data["heatingPower"] is not None - ): - if float(activity_data["heatingPower"]["percentage"]) > 0.0: - active = True - self._active = active - - overlay = False - overlay_data = None - termination = CONST_MODE_SMART_SCHEDULE - cooling = False - fan_speed = CONST_MODE_OFF - - if "overlay" in data: - overlay_data = data["overlay"] - overlay = overlay_data is not None - - if overlay: - termination = overlay_data["termination"]["type"] - setting = False - setting_data = None - - if "setting" in overlay_data: - setting_data = overlay_data["setting"] - setting = setting_data is not None - - if setting: - if "mode" in setting_data: - cooling = setting_data["mode"] == "COOL" - - if "fanSpeed" in setting_data: - fan_speed = setting_data["fanSpeed"] - - if self._device_is_active: - # If you set mode manually to off, there will be an overlay - # and a termination, but we want to see the mode "OFF" - self._overlay_mode = termination - self._current_operation = termination - - self._cooling = cooling - self._current_fan = fan_speed - - def _control_heating(self): + def _control_hvac(self, hvac_mode=None, target_temp=None, fan_mode=None): """Send new target temperature to Tado.""" - if self._current_operation == CONST_MODE_SMART_SCHEDULE: + + if hvac_mode: + self._current_tado_hvac_mode = hvac_mode + + if target_temp: + self._target_temp = target_temp + + if fan_mode: + self._current_tado_fan_speed = fan_mode + + self._normalize_target_temp_for_hvac_mode() + + # tado does not permit setting the fan speed to + # off, you must turn off the device + if ( + self._current_tado_fan_speed == CONST_FAN_OFF + and self._current_tado_hvac_mode != CONST_MODE_OFF + ): + self._current_tado_fan_speed = CONST_FAN_AUTO + + if self._current_tado_hvac_mode == CONST_MODE_OFF: + _LOGGER.debug( + "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id + ) + self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type) + return + + if self._current_tado_hvac_mode == CONST_MODE_SMART_SCHEDULE: _LOGGER.debug( "Switching to SMART_SCHEDULE for zone %s (%d)", self.zone_name, self.zone_id, ) self._tado.reset_zone_overlay(self.zone_id) - self._overlay_mode = self._current_operation - return - - if self._current_operation == CONST_MODE_OFF: - _LOGGER.debug( - "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id - ) - self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type) - self._overlay_mode = self._current_operation return _LOGGER.debug( "Switching to %s for zone %s (%d) with temperature %s °C", - self._current_operation, + self._current_tado_hvac_mode, self.zone_name, self.zone_id, self._target_temp, ) - self._tado.set_zone_overlay( - self.zone_id, - self._current_operation, - self._target_temp, - None, - self.zone_type, - "COOL" if self._ac_device else None, + + # Fallback to Smart Schedule at next Schedule switch if we have fallback enabled + overlay_mode = ( + CONST_OVERLAY_TADO_MODE if self._tado.fallback else CONST_OVERLAY_MANUAL + ) + + temperature_to_send = self._target_temp + if self._current_tado_hvac_mode in TADO_MODES_WITH_NO_TEMP_SETTING: + # A temperature cannot be passed with these modes + temperature_to_send = None + + self._tado.set_zone_overlay( + zone_id=self.zone_id, + overlay_mode=overlay_mode, # What to do when the period ends + temperature=temperature_to_send, + duration=None, + device_type=self.zone_type, + mode=self._current_tado_hvac_mode, + fan_speed=( + self._current_tado_fan_speed + if (self._support_flags & SUPPORT_FAN_MODE) + else None + ), # api defaults to not sending fanSpeed if not specified ) - self._overlay_mode = self._current_operation diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index 8d67e3bf9f8..a2630a8f9c2 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -1,5 +1,26 @@ """Constant values for the Tado component.""" +from homeassistant.components.climate.const import ( + CURRENT_HVAC_COOL, + CURRENT_HVAC_DRY, + CURRENT_HVAC_FAN, + CURRENT_HVAC_HEAT, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + FAN_OFF, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_HOME, +) + # Configuration CONF_FALLBACK = "fallback" DATA = "data" @@ -10,10 +31,81 @@ TYPE_HEATING = "HEATING" TYPE_HOT_WATER = "HOT_WATER" # Base modes +CONST_MODE_OFF = "OFF" CONST_MODE_SMART_SCHEDULE = "SMART_SCHEDULE" # Use the schedule -CONST_MODE_OFF = "OFF" # Switch off heating in a zone +CONST_MODE_AUTO = "AUTO" +CONST_MODE_COOL = "COOL" +CONST_MODE_HEAT = "HEAT" +CONST_MODE_DRY = "DRY" +CONST_MODE_FAN = "FAN" + +CONST_LINK_OFFLINE = "OFFLINE" + +CONST_FAN_OFF = "OFF" +CONST_FAN_AUTO = "AUTO" +CONST_FAN_LOW = "LOW" +CONST_FAN_MIDDLE = "MIDDLE" +CONST_FAN_HIGH = "HIGH" + # When we change the temperature setting, we need an overlay mode CONST_OVERLAY_TADO_MODE = "TADO_MODE" # wait until tado changes the mode automatic CONST_OVERLAY_MANUAL = "MANUAL" # the user has change the temperature or mode manually CONST_OVERLAY_TIMER = "TIMER" # the temperature will be reset after a timespan + + +# Heat always comes first since we get the +# min and max tempatures for the zone from +# it. +# Heat is preferred as it generally has a lower minimum temperature +ORDERED_KNOWN_TADO_MODES = [ + CONST_MODE_HEAT, + CONST_MODE_COOL, + CONST_MODE_AUTO, + CONST_MODE_DRY, + CONST_MODE_FAN, +] + +TADO_MODES_TO_HA_CURRENT_HVAC_ACTION = { + CONST_MODE_HEAT: CURRENT_HVAC_HEAT, + CONST_MODE_DRY: CURRENT_HVAC_DRY, + CONST_MODE_FAN: CURRENT_HVAC_FAN, + CONST_MODE_COOL: CURRENT_HVAC_COOL, +} + +# These modes will not allow a temp to be set +TADO_MODES_WITH_NO_TEMP_SETTING = [CONST_MODE_AUTO, CONST_MODE_DRY, CONST_MODE_FAN] +# +# HVAC_MODE_HEAT_COOL is mapped to CONST_MODE_AUTO +# This lets tado decide on a temp +# +# HVAC_MODE_AUTO is mapped to CONST_MODE_SMART_SCHEDULE +# This runs the smart schedule +# +HA_TO_TADO_HVAC_MODE_MAP = { + HVAC_MODE_OFF: CONST_MODE_OFF, + HVAC_MODE_HEAT_COOL: CONST_MODE_AUTO, + HVAC_MODE_AUTO: CONST_MODE_SMART_SCHEDULE, + HVAC_MODE_HEAT: CONST_MODE_HEAT, + HVAC_MODE_COOL: CONST_MODE_COOL, + HVAC_MODE_DRY: CONST_MODE_DRY, + HVAC_MODE_FAN_ONLY: CONST_MODE_FAN, +} + +HA_TO_TADO_FAN_MODE_MAP = { + FAN_AUTO: CONST_FAN_AUTO, + FAN_OFF: CONST_FAN_OFF, + FAN_LOW: CONST_FAN_LOW, + FAN_MEDIUM: CONST_FAN_MIDDLE, + FAN_HIGH: CONST_FAN_HIGH, +} + +TADO_TO_HA_HVAC_MODE_MAP = { + value: key for key, value in HA_TO_TADO_HVAC_MODE_MAP.items() +} + +TADO_TO_HA_FAN_MODE_MAP = {value: key for key, value in HA_TO_TADO_FAN_MODE_MAP.items()} + +DEFAULT_TADO_PRECISION = 0.1 + +SUPPORT_PRESET = [PRESET_AWAY, PRESET_HOME] diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index e51cc53caa5..2589388a4da 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -3,10 +3,10 @@ "name": "Tado", "documentation": "https://www.home-assistant.io/integrations/tado", "requirements": [ - "python-tado==0.3.0" + "python-tado==0.4.0" ], "dependencies": [], "codeowners": [ - "@michaelarnauts" + "@michaelarnauts", "@bdraco" ] } diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 2cd40bee3fa..70014380512 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -8,6 +8,7 @@ from homeassistant.helpers.entity import Entity from . import DATA, DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED from .const import TYPE_AIR_CONDITIONING, TYPE_HEATING, TYPE_HOT_WATER +from .tado_adapter import TadoZoneData _LOGGER = logging.getLogger(__name__) @@ -50,7 +51,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for zone in tado.zones: entities.extend( [ - create_zone_sensor(tado, zone["name"], zone["id"], variable) + create_zone_sensor(hass, tado, zone["name"], zone["id"], variable) for variable in ZONE_SENSORS.get(zone["type"]) ] ) @@ -59,7 +60,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for home in tado.devices: entities.extend( [ - create_device_sensor(tado, home["name"], home["id"], variable) + create_device_sensor(hass, tado, home["name"], home["id"], variable) for variable in DEVICE_SENSORS ] ) @@ -67,21 +68,22 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(entities, True) -def create_zone_sensor(tado, name, zone_id, variable): +def create_zone_sensor(hass, tado, name, zone_id, variable): """Create a zone sensor.""" - return TadoSensor(tado, name, "zone", zone_id, variable) + return TadoSensor(hass, tado, name, "zone", zone_id, variable) -def create_device_sensor(tado, name, device_id, variable): +def create_device_sensor(hass, tado, name, device_id, variable): """Create a device sensor.""" - return TadoSensor(tado, name, "device", device_id, variable) + return TadoSensor(hass, tado, name, "device", device_id, variable) class TadoSensor(Entity): """Representation of a tado Sensor.""" - def __init__(self, tado, zone_name, sensor_type, zone_id, zone_variable): + def __init__(self, hass, tado, zone_name, sensor_type, zone_id, zone_variable): """Initialize of the Tado Sensor.""" + self.hass = hass self._tado = tado self.zone_name = zone_name @@ -93,19 +95,16 @@ class TadoSensor(Entity): self._state = None self._state_attributes = None + self._tado_zone_data = None + self._async_update_zone_data() async def async_added_to_hass(self): """Register for sensor updates.""" - @callback - def async_update_callback(): - """Schedule an entity update.""" - self.async_schedule_update_ha_state(True) - async_dispatcher_connect( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format(self.sensor_type, self.zone_id), - async_update_callback, + self._async_update_callback, ) @property @@ -149,97 +148,74 @@ class TadoSensor(Entity): return "mdi:water-percent" @property - def should_poll(self) -> bool: + def should_poll(self): """Do not poll.""" return False - def update(self): + @callback + def _async_update_callback(self): + """Update and write state.""" + self._async_update_zone_data() + self.async_write_ha_state() + + @callback + def _async_update_zone_data(self): """Handle update callbacks.""" try: data = self._tado.data[self.sensor_type][self.zone_id] except KeyError: return - unit = TEMP_CELSIUS + self._tado_zone_data = TadoZoneData(data, self.zone_id) if self.zone_variable == "temperature": - if "sensorDataPoints" in data: - sensor_data = data["sensorDataPoints"] - temperature = float(sensor_data["insideTemperature"]["celsius"]) - - self._state = self.hass.config.units.temperature(temperature, unit) - self._state_attributes = { - "time": sensor_data["insideTemperature"]["timestamp"], - "setting": 0, # setting is used in climate device - } - - # temperature setting will not exist when device is off - if ( - "temperature" in data["setting"] - and data["setting"]["temperature"] is not None - ): - temperature = float(data["setting"]["temperature"]["celsius"]) - - self._state_attributes[ - "setting" - ] = self.hass.config.units.temperature(temperature, unit) + self._state = self.hass.config.units.temperature( + self._tado_zone_data.current_temp, TEMP_CELSIUS + ) + self._state_attributes = { + "time": self._tado_zone_data.current_temp_timestamp, + "setting": 0, # setting is used in climate device + } elif self.zone_variable == "humidity": - if "sensorDataPoints" in data: - sensor_data = data["sensorDataPoints"] - self._state = float(sensor_data["humidity"]["percentage"]) - self._state_attributes = {"time": sensor_data["humidity"]["timestamp"]} + self._state = self._tado_zone_data.current_humidity + self._state_attributes = { + "time": self._tado_zone_data.current_humidity_timestamp + } elif self.zone_variable == "power": - if "setting" in data: - self._state = data["setting"]["power"] + self._state = self._tado_zone_data.power elif self.zone_variable == "link": - if "link" in data: - self._state = data["link"]["state"] + self._state = self._tado_zone_data.link elif self.zone_variable == "heating": - if "activityDataPoints" in data: - activity_data = data["activityDataPoints"] - - if ( - "heatingPower" in activity_data - and activity_data["heatingPower"] is not None - ): - self._state = float(activity_data["heatingPower"]["percentage"]) - self._state_attributes = { - "time": activity_data["heatingPower"]["timestamp"] - } + self._state = self._tado_zone_data.heating_power_percentage + self._state_attributes = { + "time": self._tado_zone_data.heating_power_timestamp + } elif self.zone_variable == "ac": - if "activityDataPoints" in data: - activity_data = data["activityDataPoints"] - - if "acPower" in activity_data and activity_data["acPower"] is not None: - self._state = activity_data["acPower"]["value"] - self._state_attributes = { - "time": activity_data["acPower"]["timestamp"] - } + self._state = self._tado_zone_data.ac_power + self._state_attributes = {"time": self._tado_zone_data.ac_power_timestamp} elif self.zone_variable == "tado bridge status": - if "connectionState" in data: - self._state = data["connectionState"]["value"] + self._state = self._tado_zone_data.connection elif self.zone_variable == "tado mode": - if "tadoMode" in data: - self._state = data["tadoMode"] + self._state = self._tado_zone_data.tado_mode elif self.zone_variable == "overlay": - self._state = "overlay" in data and data["overlay"] is not None + self._state = self._tado_zone_data.overlay_active self._state_attributes = ( - {"termination": data["overlay"]["termination"]["type"]} - if self._state + {"termination": self._tado_zone_data.overlay_termination_type} + if self._tado_zone_data.overlay_active else {} ) elif self.zone_variable == "early start": - self._state = "preparation" in data and data["preparation"] is not None + self._state = self._tado_zone_data.preparation is not None elif self.zone_variable == "open window": - self._state = "openWindow" in data and data["openWindow"] is not None - self._state_attributes = data["openWindow"] if self._state else {} + self._state = self._tado_zone_data.open_window is not None + self._state_attributes = self._tado_zone_data.open_window_attr diff --git a/homeassistant/components/tado/tado_adapter.py b/homeassistant/components/tado/tado_adapter.py new file mode 100644 index 00000000000..211ff9baf84 --- /dev/null +++ b/homeassistant/components/tado/tado_adapter.py @@ -0,0 +1,285 @@ +"""Adapter to represent a tado zones and state.""" +import logging + +from homeassistant.components.climate.const import ( + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_OFF, +) + +from .const import ( + CONST_FAN_AUTO, + CONST_FAN_OFF, + CONST_LINK_OFFLINE, + CONST_MODE_OFF, + CONST_MODE_SMART_SCHEDULE, + DEFAULT_TADO_PRECISION, + TADO_MODES_TO_HA_CURRENT_HVAC_ACTION, +) + +_LOGGER = logging.getLogger(__name__) + + +class TadoZoneData: + """Represent a tado zone.""" + + def __init__(self, data, zone_id): + """Create a tado zone.""" + self._data = data + self._zone_id = zone_id + self._current_temp = None + self._connection = None + self._current_temp_timestamp = None + self._current_humidity = None + self._is_away = False + self._current_hvac_action = None + self._current_tado_fan_speed = None + self._current_tado_hvac_mode = None + self._target_temp = None + self._available = False + self._power = None + self._link = None + self._ac_power_timestamp = None + self._heating_power_timestamp = None + self._ac_power = None + self._heating_power = None + self._heating_power_percentage = None + self._tado_mode = None + self._overlay_active = None + self._overlay_termination_type = None + self._preparation = None + self._open_window = None + self._open_window_attr = None + self._precision = DEFAULT_TADO_PRECISION + self.update_data(data) + + @property + def preparation(self): + """Zone is preparing to heat.""" + return self._preparation + + @property + def open_window(self): + """Window is open.""" + return self._open_window + + @property + def open_window_attr(self): + """Window open attributes.""" + return self._open_window_attr + + @property + def current_temp(self): + """Temperature of the zone.""" + return self._current_temp + + @property + def current_temp_timestamp(self): + """Temperature of the zone timestamp.""" + return self._current_temp_timestamp + + @property + def connection(self): + """Up or down internet connection.""" + return self._connection + + @property + def tado_mode(self): + """Tado mode.""" + return self._tado_mode + + @property + def overlay_active(self): + """Overlay acitive.""" + return self._current_tado_hvac_mode != CONST_MODE_SMART_SCHEDULE + + @property + def overlay_termination_type(self): + """Overlay termination type (what happens when period ends).""" + return self._overlay_termination_type + + @property + def current_humidity(self): + """Humidity of the zone.""" + return self._current_humidity + + @property + def current_humidity_timestamp(self): + """Humidity of the zone timestamp.""" + return self._current_humidity_timestamp + + @property + def ac_power_timestamp(self): + """AC power timestamp.""" + return self._ac_power_timestamp + + @property + def heating_power_timestamp(self): + """Heating power timestamp.""" + return self._heating_power_timestamp + + @property + def ac_power(self): + """AC power.""" + return self._ac_power + + @property + def heating_power(self): + """Heating power.""" + return self._heating_power + + @property + def heating_power_percentage(self): + """Heating power percentage.""" + return self._heating_power_percentage + + @property + def is_away(self): + """Is Away (not home).""" + return self._is_away + + @property + def power(self): + """Power is on.""" + return self._power + + @property + def current_hvac_action(self): + """HVAC Action (home assistant const).""" + return self._current_hvac_action + + @property + def current_tado_fan_speed(self): + """TADO Fan speed (tado const).""" + return self._current_tado_fan_speed + + @property + def link(self): + """Link (internet connection state).""" + return self._link + + @property + def precision(self): + """Precision of temp units.""" + return self._precision + + @property + def current_tado_hvac_mode(self): + """TADO HVAC Mode (tado const).""" + return self._current_tado_hvac_mode + + @property + def target_temp(self): + """Target temperature (C).""" + return self._target_temp + + @property + def available(self): + """Device is available and link is up.""" + return self._available + + def update_data(self, data): + """Handle update callbacks.""" + _LOGGER.debug("Updating climate platform for zone %d", self._zone_id) + if "sensorDataPoints" in data: + sensor_data = data["sensorDataPoints"] + + if "insideTemperature" in sensor_data: + temperature = float(sensor_data["insideTemperature"]["celsius"]) + self._current_temp = temperature + self._current_temp_timestamp = sensor_data["insideTemperature"][ + "timestamp" + ] + if "precision" in sensor_data["insideTemperature"]: + self._precision = sensor_data["insideTemperature"]["precision"][ + "celsius" + ] + + if "humidity" in sensor_data: + humidity = float(sensor_data["humidity"]["percentage"]) + self._current_humidity = humidity + self._current_humidity_timestamp = sensor_data["humidity"]["timestamp"] + + self._is_away = None + self._tado_mode = None + if "tadoMode" in data: + self._is_away = data["tadoMode"] == "AWAY" + self._tado_mode = data["tadoMode"] + + self._link = None + if "link" in data: + self._link = data["link"]["state"] + + self._current_hvac_action = CURRENT_HVAC_OFF + + if "setting" in data: + # temperature setting will not exist when device is off + if ( + "temperature" in data["setting"] + and data["setting"]["temperature"] is not None + ): + setting = float(data["setting"]["temperature"]["celsius"]) + self._target_temp = setting + + setting = data["setting"] + + self._current_tado_fan_speed = CONST_FAN_OFF + # If there is no overlay, the mode will always be + # "SMART_SCHEDULE" + if "mode" in setting: + self._current_tado_hvac_mode = setting["mode"] + else: + self._current_tado_hvac_mode = CONST_MODE_OFF + + self._power = setting["power"] + if self._power == "ON": + # Not all devices have fans + self._current_tado_fan_speed = setting.get("fanSpeed", CONST_FAN_AUTO) + self._current_hvac_action = CURRENT_HVAC_IDLE + + self._preparation = "preparation" in data and data["preparation"] is not None + self._open_window = "openWindow" in data and data["openWindow"] is not None + self._open_window_attr = data["openWindow"] if self._open_window else {} + + if "activityDataPoints" in data: + activity_data = data["activityDataPoints"] + if "acPower" in activity_data and activity_data["acPower"] is not None: + self._ac_power = activity_data["acPower"]["value"] + self._ac_power_timestamp = activity_data["acPower"]["timestamp"] + if activity_data["acPower"]["value"] == "ON" and self._power == "ON": + # acPower means the unit has power so we need to map the mode + self._current_hvac_action = TADO_MODES_TO_HA_CURRENT_HVAC_ACTION.get( + self._current_tado_hvac_mode, CURRENT_HVAC_COOL + ) + if ( + "heatingPower" in activity_data + and activity_data["heatingPower"] is not None + ): + self._heating_power = activity_data["heatingPower"].get("value", None) + self._heating_power_timestamp = activity_data["heatingPower"][ + "timestamp" + ] + self._heating_power_percentage = float( + activity_data["heatingPower"].get("percentage", 0) + ) + + if self._heating_power_percentage > 0.0 and self._power == "ON": + self._current_hvac_action = CURRENT_HVAC_HEAT + + # If there is no overlay + # then we are running the smart schedule + self._overlay_termination_type = None + if "overlay" in data and data["overlay"] is not None: + if ( + "termination" in data["overlay"] + and "type" in data["overlay"]["termination"] + ): + self._overlay_termination_type = data["overlay"]["termination"]["type"] + else: + self._current_tado_hvac_mode = CONST_MODE_SMART_SCHEDULE + + self._connection = ( + data["connectionState"]["value"] if "connectionState" in data else None + ) + self._available = self._link != CONST_LINK_OFFLINE diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index fc3a9ce9cf4..52c085d8ec3 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -12,6 +12,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED from .const import ( + CONST_MODE_HEAT, CONST_MODE_OFF, CONST_MODE_SMART_SCHEDULE, CONST_OVERLAY_MANUAL, @@ -51,14 +52,16 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for tado in api_list: for zone in tado.zones: if zone["type"] in [TYPE_HOT_WATER]: - entity = create_water_heater_entity(tado, zone["name"], zone["id"]) + entity = create_water_heater_entity( + hass, tado, zone["name"], zone["id"] + ) entities.append(entity) if entities: add_entities(entities, True) -def create_water_heater_entity(tado, name: str, zone_id: int): +def create_water_heater_entity(hass, tado, name: str, zone_id: int): """Create a Tado water heater device.""" capabilities = tado.get_capabilities(zone_id) supports_temperature_control = capabilities["canSetTemperature"] @@ -72,7 +75,7 @@ def create_water_heater_entity(tado, name: str, zone_id: int): max_temp = None entity = TadoWaterHeater( - tado, name, zone_id, supports_temperature_control, min_temp, max_temp + hass, tado, name, zone_id, supports_temperature_control, min_temp, max_temp ) return entity @@ -83,6 +86,7 @@ class TadoWaterHeater(WaterHeaterDevice): def __init__( self, + hass, tado, zone_name, zone_id, @@ -91,6 +95,7 @@ class TadoWaterHeater(WaterHeaterDevice): max_temp, ): """Initialize of Tado water heater entity.""" + self.hass = hass self._tado = tado self.zone_name = zone_name @@ -110,28 +115,17 @@ class TadoWaterHeater(WaterHeaterDevice): if self._supports_temperature_control: self._supported_features |= SUPPORT_TARGET_TEMPERATURE - if tado.fallback: - # Fallback to Smart Schedule at next Schedule switch - self._default_overlay = CONST_OVERLAY_TADO_MODE - else: - # Don't fallback to Smart Schedule, but keep in manual mode - self._default_overlay = CONST_OVERLAY_MANUAL - - self._current_operation = CONST_MODE_SMART_SCHEDULE + self._current_tado_heat_mode = CONST_MODE_SMART_SCHEDULE self._overlay_mode = CONST_MODE_SMART_SCHEDULE + self._async_update_data() async def async_added_to_hass(self): """Register for sensor updates.""" - @callback - def async_update_callback(): - """Schedule an entity update.""" - self.async_schedule_update_ha_state(True) - async_dispatcher_connect( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format("zone", self.zone_id), - async_update_callback, + self._async_update_callback, ) @property @@ -157,7 +151,7 @@ class TadoWaterHeater(WaterHeaterDevice): @property def current_operation(self): """Return current readable operation mode.""" - return WATER_HEATER_MAP_TADO.get(self._current_operation) + return WATER_HEATER_MAP_TADO.get(self._current_tado_heat_mode) @property def target_temperature(self): @@ -198,16 +192,9 @@ class TadoWaterHeater(WaterHeaterDevice): elif operation_mode == MODE_AUTO: mode = CONST_MODE_SMART_SCHEDULE elif operation_mode == MODE_HEAT: - mode = self._default_overlay + mode = CONST_MODE_HEAT - self._current_operation = mode - self._overlay_mode = None - - # Set a target temperature if we don't have any - if mode == CONST_OVERLAY_TADO_MODE and self._target_temp is None: - self._target_temp = self.min_temp - - self._control_heater() + self._control_heater(heat_mode=mode) def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -215,13 +202,17 @@ class TadoWaterHeater(WaterHeaterDevice): if not self._supports_temperature_control or temperature is None: return - self._current_operation = self._default_overlay - self._overlay_mode = None - self._target_temp = temperature - self._control_heater() + self._control_heater(target_temp=temperature) - def update(self): - """Handle update callbacks.""" + @callback + def _async_update_callback(self): + """Load tado data and update state.""" + self._async_update_data() + self.async_write_ha_state() + + @callback + def _async_update_data(self): + """Load tado data.""" _LOGGER.debug("Updating water_heater platform for zone %d", self.zone_id) data = self._tado.data["zone"][self.zone_id] @@ -232,71 +223,70 @@ class TadoWaterHeater(WaterHeaterDevice): if "setting" in data: power = data["setting"]["power"] if power == "OFF": - self._current_operation = CONST_MODE_OFF - # There is no overlay, the mode will always be - # "SMART_SCHEDULE" - self._overlay_mode = CONST_MODE_SMART_SCHEDULE - self._device_is_active = False + self._current_tado_heat_mode = CONST_MODE_OFF else: - self._device_is_active = True + self._current_tado_heat_mode = CONST_MODE_HEAT # temperature setting will not exist when device is off if ( "temperature" in data["setting"] and data["setting"]["temperature"] is not None ): - setting = float(data["setting"]["temperature"]["celsius"]) - self._target_temp = setting + self._target_temp = float(data["setting"]["temperature"]["celsius"]) - overlay = False - overlay_data = None - termination = CONST_MODE_SMART_SCHEDULE + # If there is no overlay + # then we are running the smart schedule + if "overlay" in data and data["overlay"] is None: + self._current_tado_heat_mode = CONST_MODE_SMART_SCHEDULE - if "overlay" in data: - overlay_data = data["overlay"] - overlay = overlay_data is not None + self.async_write_ha_state() - if overlay: - termination = overlay_data["termination"]["type"] - - if self._device_is_active: - # If you set mode manually to off, there will be an overlay - # and a termination, but we want to see the mode "OFF" - self._overlay_mode = termination - self._current_operation = termination - - def _control_heater(self): + def _control_heater(self, heat_mode=None, target_temp=None): """Send new target temperature.""" - if self._current_operation == CONST_MODE_SMART_SCHEDULE: + + if heat_mode: + self._current_tado_heat_mode = heat_mode + + if target_temp: + self._target_temp = target_temp + + # Set a target temperature if we don't have any + if self._target_temp is None: + self._target_temp = self.min_temp + + if self._current_tado_heat_mode == CONST_MODE_SMART_SCHEDULE: _LOGGER.debug( "Switching to SMART_SCHEDULE for zone %s (%d)", self.zone_name, self.zone_id, ) self._tado.reset_zone_overlay(self.zone_id) - self._overlay_mode = self._current_operation return - if self._current_operation == CONST_MODE_OFF: + if self._current_tado_heat_mode == CONST_MODE_OFF: _LOGGER.debug( "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id ) self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER) - self._overlay_mode = self._current_operation return + # Fallback to Smart Schedule at next Schedule switch if we have fallback enabled + overlay_mode = ( + CONST_OVERLAY_TADO_MODE if self._tado.fallback else CONST_OVERLAY_MANUAL + ) + _LOGGER.debug( "Switching to %s for zone %s (%d) with temperature %s", - self._current_operation, + self._current_tado_heat_mode, self.zone_name, self.zone_id, self._target_temp, ) self._tado.set_zone_overlay( - self.zone_id, - self._current_operation, - self._target_temp, - None, - TYPE_HOT_WATER, + zone_id=self.zone_id, + overlay_mode=overlay_mode, + temperature=self._target_temp, + duration=None, + device_type=TYPE_HOT_WATER, ) - self._overlay_mode = self._current_operation + self._overlay_mode = self._current_tado_heat_mode diff --git a/requirements_all.txt b/requirements_all.txt index acfebe8c4d0..5a8199f2c14 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1650,7 +1650,7 @@ python-songpal==0.11.2 python-synology==0.4.0 # homeassistant.components.tado -python-tado==0.3.0 +python-tado==0.4.0 # homeassistant.components.telegram_bot python-telegram-bot==11.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 54ecf61d031..54732560fe9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -580,6 +580,9 @@ python-miio==0.4.8 # homeassistant.components.nest python-nest==4.1.0 +# homeassistant.components.tado +python-tado==0.4.0 + # homeassistant.components.twitch python-twitch-client==0.6.0 diff --git a/tests/components/tado/mocks.py b/tests/components/tado/mocks.py new file mode 100644 index 00000000000..149bcbc24c6 --- /dev/null +++ b/tests/components/tado/mocks.py @@ -0,0 +1,18 @@ +"""Mocks for the tado component.""" +import json +import os + +from homeassistant.components.tado.tado_adapter import TadoZoneData + +from tests.common import load_fixture + + +async def _mock_tado_climate_zone_from_fixture(hass, file): + return TadoZoneData(await _load_json_fixture(hass, file), 1) + + +async def _load_json_fixture(hass, path): + fixture = await hass.async_add_executor_job( + load_fixture, os.path.join("tado", path) + ) + return json.loads(fixture) diff --git a/tests/components/tado/test_tado_adapter.py b/tests/components/tado/test_tado_adapter.py new file mode 100644 index 00000000000..ccbf2291b7f --- /dev/null +++ b/tests/components/tado/test_tado_adapter.py @@ -0,0 +1,423 @@ +"""The tado_adapter tests for the tado platform.""" + + +from tests.components.tado.mocks import _mock_tado_climate_zone_from_fixture + + +async def test_ac_issue_32294_heat_mode(hass): + """Test smart ac cool mode.""" + ac_issue_32294_heat_mode = await _mock_tado_climate_zone_from_fixture( + hass, "ac_issue_32294.heat_mode.json" + ) + assert ac_issue_32294_heat_mode.preparation is False + assert ac_issue_32294_heat_mode.open_window is False + assert ac_issue_32294_heat_mode.open_window_attr == {} + assert ac_issue_32294_heat_mode.current_temp == 21.82 + assert ac_issue_32294_heat_mode.current_temp_timestamp == "2020-02-29T22:51:05.016Z" + assert ac_issue_32294_heat_mode.connection is None + assert ac_issue_32294_heat_mode.tado_mode == "HOME" + assert ac_issue_32294_heat_mode.overlay_active is False + assert ac_issue_32294_heat_mode.overlay_termination_type is None + assert ac_issue_32294_heat_mode.current_humidity == 40.4 + assert ( + ac_issue_32294_heat_mode.current_humidity_timestamp + == "2020-02-29T22:51:05.016Z" + ) + assert ac_issue_32294_heat_mode.ac_power_timestamp == "2020-02-29T22:50:34.850Z" + assert ac_issue_32294_heat_mode.heating_power_timestamp is None + assert ac_issue_32294_heat_mode.ac_power == "ON" + assert ac_issue_32294_heat_mode.heating_power is None + assert ac_issue_32294_heat_mode.heating_power_percentage is None + assert ac_issue_32294_heat_mode.is_away is False + assert ac_issue_32294_heat_mode.power == "ON" + assert ac_issue_32294_heat_mode.current_hvac_action == "heating" + assert ac_issue_32294_heat_mode.current_tado_fan_speed == "AUTO" + assert ac_issue_32294_heat_mode.link == "ONLINE" + assert ac_issue_32294_heat_mode.current_tado_hvac_mode == "SMART_SCHEDULE" + assert ac_issue_32294_heat_mode.target_temp == 25.0 + assert ac_issue_32294_heat_mode.available is True + assert ac_issue_32294_heat_mode.precision == 0.1 + + +async def test_smartac3_smart_mode(hass): + """Test smart ac smart mode.""" + smartac3_smart_mode = await _mock_tado_climate_zone_from_fixture( + hass, "smartac3.smart_mode.json" + ) + assert smartac3_smart_mode.preparation is False + assert smartac3_smart_mode.open_window is False + assert smartac3_smart_mode.open_window_attr == {} + assert smartac3_smart_mode.current_temp == 24.43 + assert smartac3_smart_mode.current_temp_timestamp == "2020-03-05T03:50:24.769Z" + assert smartac3_smart_mode.connection is None + assert smartac3_smart_mode.tado_mode == "HOME" + assert smartac3_smart_mode.overlay_active is False + assert smartac3_smart_mode.overlay_termination_type is None + assert smartac3_smart_mode.current_humidity == 60.0 + assert smartac3_smart_mode.current_humidity_timestamp == "2020-03-05T03:50:24.769Z" + assert smartac3_smart_mode.ac_power_timestamp == "2020-03-05T03:52:22.253Z" + assert smartac3_smart_mode.heating_power_timestamp is None + assert smartac3_smart_mode.ac_power == "OFF" + assert smartac3_smart_mode.heating_power is None + assert smartac3_smart_mode.heating_power_percentage is None + assert smartac3_smart_mode.is_away is False + assert smartac3_smart_mode.power == "ON" + assert smartac3_smart_mode.current_hvac_action == "idle" + assert smartac3_smart_mode.current_tado_fan_speed == "MIDDLE" + assert smartac3_smart_mode.link == "ONLINE" + assert smartac3_smart_mode.current_tado_hvac_mode == "SMART_SCHEDULE" + assert smartac3_smart_mode.target_temp == 20.0 + assert smartac3_smart_mode.available is True + assert smartac3_smart_mode.precision == 0.1 + + +async def test_smartac3_cool_mode(hass): + """Test smart ac cool mode.""" + smartac3_cool_mode = await _mock_tado_climate_zone_from_fixture( + hass, "smartac3.cool_mode.json" + ) + assert smartac3_cool_mode.preparation is False + assert smartac3_cool_mode.open_window is False + assert smartac3_cool_mode.open_window_attr == {} + assert smartac3_cool_mode.current_temp == 24.76 + assert smartac3_cool_mode.current_temp_timestamp == "2020-03-05T03:57:38.850Z" + assert smartac3_cool_mode.connection is None + assert smartac3_cool_mode.tado_mode == "HOME" + assert smartac3_cool_mode.overlay_active is True + assert smartac3_cool_mode.overlay_termination_type == "TADO_MODE" + assert smartac3_cool_mode.current_humidity == 60.9 + assert smartac3_cool_mode.current_humidity_timestamp == "2020-03-05T03:57:38.850Z" + assert smartac3_cool_mode.ac_power_timestamp == "2020-03-05T04:01:07.162Z" + assert smartac3_cool_mode.heating_power_timestamp is None + assert smartac3_cool_mode.ac_power == "ON" + assert smartac3_cool_mode.heating_power is None + assert smartac3_cool_mode.heating_power_percentage is None + assert smartac3_cool_mode.is_away is False + assert smartac3_cool_mode.power == "ON" + assert smartac3_cool_mode.current_hvac_action == "cooling" + assert smartac3_cool_mode.current_tado_fan_speed == "AUTO" + assert smartac3_cool_mode.link == "ONLINE" + assert smartac3_cool_mode.current_tado_hvac_mode == "COOL" + assert smartac3_cool_mode.target_temp == 17.78 + assert smartac3_cool_mode.available is True + assert smartac3_cool_mode.precision == 0.1 + + +async def test_smartac3_auto_mode(hass): + """Test smart ac cool mode.""" + smartac3_auto_mode = await _mock_tado_climate_zone_from_fixture( + hass, "smartac3.auto_mode.json" + ) + assert smartac3_auto_mode.preparation is False + assert smartac3_auto_mode.open_window is False + assert smartac3_auto_mode.open_window_attr == {} + assert smartac3_auto_mode.current_temp == 24.8 + assert smartac3_auto_mode.current_temp_timestamp == "2020-03-05T03:55:38.160Z" + assert smartac3_auto_mode.connection is None + assert smartac3_auto_mode.tado_mode == "HOME" + assert smartac3_auto_mode.overlay_active is True + assert smartac3_auto_mode.overlay_termination_type == "TADO_MODE" + assert smartac3_auto_mode.current_humidity == 62.5 + assert smartac3_auto_mode.current_humidity_timestamp == "2020-03-05T03:55:38.160Z" + assert smartac3_auto_mode.ac_power_timestamp == "2020-03-05T03:56:38.627Z" + assert smartac3_auto_mode.heating_power_timestamp is None + assert smartac3_auto_mode.ac_power == "ON" + assert smartac3_auto_mode.heating_power is None + assert smartac3_auto_mode.heating_power_percentage is None + assert smartac3_auto_mode.is_away is False + assert smartac3_auto_mode.power == "ON" + assert smartac3_auto_mode.current_hvac_action == "cooling" + assert smartac3_auto_mode.current_tado_fan_speed == "AUTO" + assert smartac3_auto_mode.link == "ONLINE" + assert smartac3_auto_mode.current_tado_hvac_mode == "AUTO" + assert smartac3_auto_mode.target_temp is None + assert smartac3_auto_mode.available is True + assert smartac3_auto_mode.precision == 0.1 + + +async def test_smartac3_dry_mode(hass): + """Test smart ac cool mode.""" + smartac3_dry_mode = await _mock_tado_climate_zone_from_fixture( + hass, "smartac3.dry_mode.json" + ) + assert smartac3_dry_mode.preparation is False + assert smartac3_dry_mode.open_window is False + assert smartac3_dry_mode.open_window_attr == {} + assert smartac3_dry_mode.current_temp == 25.01 + assert smartac3_dry_mode.current_temp_timestamp == "2020-03-05T04:02:07.396Z" + assert smartac3_dry_mode.connection is None + assert smartac3_dry_mode.tado_mode == "HOME" + assert smartac3_dry_mode.overlay_active is True + assert smartac3_dry_mode.overlay_termination_type == "TADO_MODE" + assert smartac3_dry_mode.current_humidity == 62.0 + assert smartac3_dry_mode.current_humidity_timestamp == "2020-03-05T04:02:07.396Z" + assert smartac3_dry_mode.ac_power_timestamp == "2020-03-05T04:02:40.867Z" + assert smartac3_dry_mode.heating_power_timestamp is None + assert smartac3_dry_mode.ac_power == "ON" + assert smartac3_dry_mode.heating_power is None + assert smartac3_dry_mode.heating_power_percentage is None + assert smartac3_dry_mode.is_away is False + assert smartac3_dry_mode.power == "ON" + assert smartac3_dry_mode.current_hvac_action == "drying" + assert smartac3_dry_mode.current_tado_fan_speed == "AUTO" + assert smartac3_dry_mode.link == "ONLINE" + assert smartac3_dry_mode.current_tado_hvac_mode == "DRY" + assert smartac3_dry_mode.target_temp is None + assert smartac3_dry_mode.available is True + assert smartac3_dry_mode.precision == 0.1 + + +async def test_smartac3_fan_mode(hass): + """Test smart ac cool mode.""" + smartac3_fan_mode = await _mock_tado_climate_zone_from_fixture( + hass, "smartac3.fan_mode.json" + ) + assert smartac3_fan_mode.preparation is False + assert smartac3_fan_mode.open_window is False + assert smartac3_fan_mode.open_window_attr == {} + assert smartac3_fan_mode.current_temp == 25.01 + assert smartac3_fan_mode.current_temp_timestamp == "2020-03-05T04:02:07.396Z" + assert smartac3_fan_mode.connection is None + assert smartac3_fan_mode.tado_mode == "HOME" + assert smartac3_fan_mode.overlay_active is True + assert smartac3_fan_mode.overlay_termination_type == "TADO_MODE" + assert smartac3_fan_mode.current_humidity == 62.0 + assert smartac3_fan_mode.current_humidity_timestamp == "2020-03-05T04:02:07.396Z" + assert smartac3_fan_mode.ac_power_timestamp == "2020-03-05T04:03:44.328Z" + assert smartac3_fan_mode.heating_power_timestamp is None + assert smartac3_fan_mode.ac_power == "ON" + assert smartac3_fan_mode.heating_power is None + assert smartac3_fan_mode.heating_power_percentage is None + assert smartac3_fan_mode.is_away is False + assert smartac3_fan_mode.power == "ON" + assert smartac3_fan_mode.current_hvac_action == "fan" + assert smartac3_fan_mode.current_tado_fan_speed == "AUTO" + assert smartac3_fan_mode.link == "ONLINE" + assert smartac3_fan_mode.current_tado_hvac_mode == "FAN" + assert smartac3_fan_mode.target_temp is None + assert smartac3_fan_mode.available is True + assert smartac3_fan_mode.precision == 0.1 + + +async def test_smartac3_heat_mode(hass): + """Test smart ac cool mode.""" + smartac3_heat_mode = await _mock_tado_climate_zone_from_fixture( + hass, "smartac3.heat_mode.json" + ) + assert smartac3_heat_mode.preparation is False + assert smartac3_heat_mode.open_window is False + assert smartac3_heat_mode.open_window_attr == {} + assert smartac3_heat_mode.current_temp == 24.76 + assert smartac3_heat_mode.current_temp_timestamp == "2020-03-05T03:57:38.850Z" + assert smartac3_heat_mode.connection is None + assert smartac3_heat_mode.tado_mode == "HOME" + assert smartac3_heat_mode.overlay_active is True + assert smartac3_heat_mode.overlay_termination_type == "TADO_MODE" + assert smartac3_heat_mode.current_humidity == 60.9 + assert smartac3_heat_mode.current_humidity_timestamp == "2020-03-05T03:57:38.850Z" + assert smartac3_heat_mode.ac_power_timestamp == "2020-03-05T03:59:36.390Z" + assert smartac3_heat_mode.heating_power_timestamp is None + assert smartac3_heat_mode.ac_power == "ON" + assert smartac3_heat_mode.heating_power is None + assert smartac3_heat_mode.heating_power_percentage is None + assert smartac3_heat_mode.is_away is False + assert smartac3_heat_mode.power == "ON" + assert smartac3_heat_mode.current_hvac_action == "heating" + assert smartac3_heat_mode.current_tado_fan_speed == "AUTO" + assert smartac3_heat_mode.link == "ONLINE" + assert smartac3_heat_mode.current_tado_hvac_mode == "HEAT" + assert smartac3_heat_mode.target_temp == 16.11 + assert smartac3_heat_mode.available is True + assert smartac3_heat_mode.precision == 0.1 + + +async def test_smartac3_hvac_off(hass): + """Test smart ac cool mode.""" + smartac3_hvac_off = await _mock_tado_climate_zone_from_fixture( + hass, "smartac3.hvac_off.json" + ) + assert smartac3_hvac_off.preparation is False + assert smartac3_hvac_off.open_window is False + assert smartac3_hvac_off.open_window_attr == {} + assert smartac3_hvac_off.current_temp == 21.44 + assert smartac3_hvac_off.current_temp_timestamp == "2020-03-05T01:21:44.089Z" + assert smartac3_hvac_off.connection is None + assert smartac3_hvac_off.tado_mode == "AWAY" + assert smartac3_hvac_off.overlay_active is True + assert smartac3_hvac_off.overlay_termination_type == "MANUAL" + assert smartac3_hvac_off.current_humidity == 48.2 + assert smartac3_hvac_off.current_humidity_timestamp == "2020-03-05T01:21:44.089Z" + assert smartac3_hvac_off.ac_power_timestamp == "2020-02-29T05:34:10.318Z" + assert smartac3_hvac_off.heating_power_timestamp is None + assert smartac3_hvac_off.ac_power == "OFF" + assert smartac3_hvac_off.heating_power is None + assert smartac3_hvac_off.heating_power_percentage is None + assert smartac3_hvac_off.is_away is True + assert smartac3_hvac_off.power == "OFF" + assert smartac3_hvac_off.current_hvac_action == "off" + assert smartac3_hvac_off.current_tado_fan_speed == "OFF" + assert smartac3_hvac_off.link == "ONLINE" + assert smartac3_hvac_off.current_tado_hvac_mode == "OFF" + assert smartac3_hvac_off.target_temp is None + assert smartac3_hvac_off.available is True + assert smartac3_hvac_off.precision == 0.1 + + +async def test_smartac3_manual_off(hass): + """Test smart ac cool mode.""" + smartac3_manual_off = await _mock_tado_climate_zone_from_fixture( + hass, "smartac3.manual_off.json" + ) + assert smartac3_manual_off.preparation is False + assert smartac3_manual_off.open_window is False + assert smartac3_manual_off.open_window_attr == {} + assert smartac3_manual_off.current_temp == 25.01 + assert smartac3_manual_off.current_temp_timestamp == "2020-03-05T04:02:07.396Z" + assert smartac3_manual_off.connection is None + assert smartac3_manual_off.tado_mode == "HOME" + assert smartac3_manual_off.overlay_active is True + assert smartac3_manual_off.overlay_termination_type == "MANUAL" + assert smartac3_manual_off.current_humidity == 62.0 + assert smartac3_manual_off.current_humidity_timestamp == "2020-03-05T04:02:07.396Z" + assert smartac3_manual_off.ac_power_timestamp == "2020-03-05T04:05:08.804Z" + assert smartac3_manual_off.heating_power_timestamp is None + assert smartac3_manual_off.ac_power == "OFF" + assert smartac3_manual_off.heating_power is None + assert smartac3_manual_off.heating_power_percentage is None + assert smartac3_manual_off.is_away is False + assert smartac3_manual_off.power == "OFF" + assert smartac3_manual_off.current_hvac_action == "off" + assert smartac3_manual_off.current_tado_fan_speed == "OFF" + assert smartac3_manual_off.link == "ONLINE" + assert smartac3_manual_off.current_tado_hvac_mode == "OFF" + assert smartac3_manual_off.target_temp is None + assert smartac3_manual_off.available is True + assert smartac3_manual_off.precision == 0.1 + + +async def test_smartac3_offline(hass): + """Test smart ac cool mode.""" + smartac3_offline = await _mock_tado_climate_zone_from_fixture( + hass, "smartac3.offline.json" + ) + assert smartac3_offline.preparation is False + assert smartac3_offline.open_window is False + assert smartac3_offline.open_window_attr == {} + assert smartac3_offline.current_temp == 25.05 + assert smartac3_offline.current_temp_timestamp == "2020-03-03T21:23:57.846Z" + assert smartac3_offline.connection is None + assert smartac3_offline.tado_mode == "HOME" + assert smartac3_offline.overlay_active is True + assert smartac3_offline.overlay_termination_type == "TADO_MODE" + assert smartac3_offline.current_humidity == 61.6 + assert smartac3_offline.current_humidity_timestamp == "2020-03-03T21:23:57.846Z" + assert smartac3_offline.ac_power_timestamp == "2020-02-29T18:42:26.683Z" + assert smartac3_offline.heating_power_timestamp is None + assert smartac3_offline.ac_power == "OFF" + assert smartac3_offline.heating_power is None + assert smartac3_offline.heating_power_percentage is None + assert smartac3_offline.is_away is False + assert smartac3_offline.power == "ON" + assert smartac3_offline.current_hvac_action == "idle" + assert smartac3_offline.current_tado_fan_speed == "AUTO" + assert smartac3_offline.link == "OFFLINE" + assert smartac3_offline.current_tado_hvac_mode == "COOL" + assert smartac3_offline.target_temp == 17.78 + assert smartac3_offline.available is False + assert smartac3_offline.precision == 0.1 + + +async def test_hvac_action_heat(hass): + """Test smart ac cool mode.""" + hvac_action_heat = await _mock_tado_climate_zone_from_fixture( + hass, "hvac_action_heat.json" + ) + assert hvac_action_heat.preparation is False + assert hvac_action_heat.open_window is False + assert hvac_action_heat.open_window_attr == {} + assert hvac_action_heat.current_temp == 21.4 + assert hvac_action_heat.current_temp_timestamp == "2020-03-06T18:06:09.546Z" + assert hvac_action_heat.connection is None + assert hvac_action_heat.tado_mode == "HOME" + assert hvac_action_heat.overlay_active is True + assert hvac_action_heat.overlay_termination_type == "TADO_MODE" + assert hvac_action_heat.current_humidity == 50.4 + assert hvac_action_heat.current_humidity_timestamp == "2020-03-06T18:06:09.546Z" + assert hvac_action_heat.ac_power_timestamp == "2020-03-06T17:38:30.302Z" + assert hvac_action_heat.heating_power_timestamp is None + assert hvac_action_heat.ac_power == "OFF" + assert hvac_action_heat.heating_power is None + assert hvac_action_heat.heating_power_percentage is None + assert hvac_action_heat.is_away is False + assert hvac_action_heat.power == "ON" + assert hvac_action_heat.current_hvac_action == "idle" + assert hvac_action_heat.current_tado_fan_speed == "AUTO" + assert hvac_action_heat.link == "ONLINE" + assert hvac_action_heat.current_tado_hvac_mode == "HEAT" + assert hvac_action_heat.target_temp == 16.11 + assert hvac_action_heat.available is True + assert hvac_action_heat.precision == 0.1 + + +async def test_smartac3_turning_off(hass): + """Test smart ac cool mode.""" + smartac3_turning_off = await _mock_tado_climate_zone_from_fixture( + hass, "smartac3.turning_off.json" + ) + assert smartac3_turning_off.preparation is False + assert smartac3_turning_off.open_window is False + assert smartac3_turning_off.open_window_attr == {} + assert smartac3_turning_off.current_temp == 21.4 + assert smartac3_turning_off.current_temp_timestamp == "2020-03-06T19:06:13.185Z" + assert smartac3_turning_off.connection is None + assert smartac3_turning_off.tado_mode == "HOME" + assert smartac3_turning_off.overlay_active is True + assert smartac3_turning_off.overlay_termination_type == "MANUAL" + assert smartac3_turning_off.current_humidity == 49.2 + assert smartac3_turning_off.current_humidity_timestamp == "2020-03-06T19:06:13.185Z" + assert smartac3_turning_off.ac_power_timestamp == "2020-03-06T19:05:21.835Z" + assert smartac3_turning_off.heating_power_timestamp is None + assert smartac3_turning_off.ac_power == "ON" + assert smartac3_turning_off.heating_power is None + assert smartac3_turning_off.heating_power_percentage is None + assert smartac3_turning_off.is_away is False + assert smartac3_turning_off.power == "OFF" + assert smartac3_turning_off.current_hvac_action == "off" + assert smartac3_turning_off.current_tado_fan_speed == "OFF" + assert smartac3_turning_off.link == "ONLINE" + assert smartac3_turning_off.current_tado_hvac_mode == "OFF" + assert smartac3_turning_off.target_temp is None + assert smartac3_turning_off.available is True + assert smartac3_turning_off.precision == 0.1 + + +async def test_michael_heat_mode(hass): + """Test michael's tado.""" + michael_heat_mode = await _mock_tado_climate_zone_from_fixture( + hass, "michael_heat_mode.json" + ) + assert michael_heat_mode.preparation is False + assert michael_heat_mode.open_window is False + assert michael_heat_mode.open_window_attr == {} + assert michael_heat_mode.current_temp == 20.06 + assert michael_heat_mode.current_temp_timestamp == "2020-03-09T08:16:49.271Z" + assert michael_heat_mode.connection is None + assert michael_heat_mode.tado_mode == "HOME" + assert michael_heat_mode.overlay_active is False + assert michael_heat_mode.overlay_termination_type is None + assert michael_heat_mode.current_humidity == 41.8 + assert michael_heat_mode.current_humidity_timestamp == "2020-03-09T08:16:49.271Z" + assert michael_heat_mode.ac_power_timestamp is None + assert michael_heat_mode.heating_power_timestamp == "2020-03-09T08:20:47.299Z" + assert michael_heat_mode.ac_power is None + assert michael_heat_mode.heating_power is None + assert michael_heat_mode.heating_power_percentage == 0.0 + assert michael_heat_mode.is_away is False + assert michael_heat_mode.power == "ON" + assert michael_heat_mode.current_hvac_action == "idle" + assert michael_heat_mode.current_tado_fan_speed == "AUTO" + assert michael_heat_mode.link == "ONLINE" + assert michael_heat_mode.current_tado_hvac_mode == "SMART_SCHEDULE" + assert michael_heat_mode.target_temp == 20.0 + assert michael_heat_mode.available is True + assert michael_heat_mode.precision == 0.1 diff --git a/tests/fixtures/tado/ac_issue_32294.heat_mode.json b/tests/fixtures/tado/ac_issue_32294.heat_mode.json new file mode 100644 index 00000000000..098afd018aa --- /dev/null +++ b/tests/fixtures/tado/ac_issue_32294.heat_mode.json @@ -0,0 +1,60 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 71.28, + "timestamp": "2020-02-29T22:51:05.016Z", + "celsius": 21.82, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-02-29T22:51:05.016Z", + "percentage": 40.4, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": null, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-02-29T22:50:34.850Z", + "type": "POWER", + "value": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-01T00:00:00.000Z" + }, + "preparation": null, + "overlayType": null, + "nextScheduleChange": { + "start": "2020-03-01T00:00:00Z", + "setting": { + "type": "AIR_CONDITIONING", + "mode": "HEAT", + "power": "ON", + "temperature": { + "fahrenheit": 59.0, + "celsius": 15.0 + } + } + }, + "setting": { + "type": "AIR_CONDITIONING", + "mode": "HEAT", + "power": "ON", + "temperature": { + "fahrenheit": 77.0, + "celsius": 25.0 + } + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/hvac_action_heat.json b/tests/fixtures/tado/hvac_action_heat.json new file mode 100644 index 00000000000..9cbf1fd5f82 --- /dev/null +++ b/tests/fixtures/tado/hvac_action_heat.json @@ -0,0 +1,67 @@ +{ + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "AIR_CONDITIONING", + "power": "ON", + "mode": "HEAT", + "temperature": { + "celsius": 16.11, + "fahrenheit": 61.00 + }, + "fanSpeed": "AUTO" + }, + "overlayType": "MANUAL", + "overlay": { + "type": "MANUAL", + "setting": { + "type": "AIR_CONDITIONING", + "power": "ON", + "mode": "HEAT", + "temperature": { + "celsius": 16.11, + "fahrenheit": 61.00 + }, + "fanSpeed": "AUTO" + }, + "termination": { + "type": "TADO_MODE", + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null + } + }, + "openWindow": null, + "nextScheduleChange": null, + "nextTimeBlock": { + "start": "2020-03-07T04:00:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-06T17:38:30.302Z", + "type": "POWER", + "value": "OFF" + } + }, + "sensorDataPoints": { + "insideTemperature": { + "celsius": 21.40, + "fahrenheit": 70.52, + "timestamp": "2020-03-06T18:06:09.546Z", + "type": "TEMPERATURE", + "precision": { + "celsius": 0.1, + "fahrenheit": 0.1 + } + }, + "humidity": { + "type": "PERCENTAGE", + "percentage": 50.40, + "timestamp": "2020-03-06T18:06:09.546Z" + } + } +} diff --git a/tests/fixtures/tado/michael_heat_mode.json b/tests/fixtures/tado/michael_heat_mode.json new file mode 100644 index 00000000000..958ead7635a --- /dev/null +++ b/tests/fixtures/tado/michael_heat_mode.json @@ -0,0 +1,58 @@ +{ + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 20.0, + "fahrenheit": 68.0 + } + }, + "overlayType": null, + "overlay": null, + "openWindow": null, + "nextScheduleChange": { + "start": "2020-03-09T17:00:00Z", + "setting": { + "type": "HEATING", + "power": "ON", + "temperature": { + "celsius": 21.0, + "fahrenheit": 69.8 + } + } + }, + "nextTimeBlock": { + "start": "2020-03-09T17:00:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": { + "heatingPower": { + "type": "PERCENTAGE", + "percentage": 0.0, + "timestamp": "2020-03-09T08:20:47.299Z" + } + }, + "sensorDataPoints": { + "insideTemperature": { + "celsius": 20.06, + "fahrenheit": 68.11, + "timestamp": "2020-03-09T08:16:49.271Z", + "type": "TEMPERATURE", + "precision": { + "celsius": 0.1, + "fahrenheit": 0.1 + } + }, + "humidity": { + "type": "PERCENTAGE", + "percentage": 41.8, + "timestamp": "2020-03-09T08:16:49.271Z" + } + } +} diff --git a/tests/fixtures/tado/smartac3.auto_mode.json b/tests/fixtures/tado/smartac3.auto_mode.json new file mode 100644 index 00000000000..254b409ddd9 --- /dev/null +++ b/tests/fixtures/tado/smartac3.auto_mode.json @@ -0,0 +1,57 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 76.64, + "timestamp": "2020-03-05T03:55:38.160Z", + "celsius": 24.8, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T03:55:38.160Z", + "percentage": 62.5, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null, + "type": "TADO_MODE" + }, + "setting": { + "type": "AIR_CONDITIONING", + "mode": "AUTO", + "power": "ON" + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T03:56:38.627Z", + "type": "POWER", + "value": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "type": "AIR_CONDITIONING", + "mode": "AUTO", + "power": "ON" + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.cool_mode.json b/tests/fixtures/tado/smartac3.cool_mode.json new file mode 100644 index 00000000000..a7db2cc75bc --- /dev/null +++ b/tests/fixtures/tado/smartac3.cool_mode.json @@ -0,0 +1,67 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 76.57, + "timestamp": "2020-03-05T03:57:38.850Z", + "celsius": 24.76, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T03:57:38.850Z", + "percentage": 60.9, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null, + "type": "TADO_MODE" + }, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "COOL", + "power": "ON", + "temperature": { + "fahrenheit": 64.0, + "celsius": 17.78 + } + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T04:01:07.162Z", + "type": "POWER", + "value": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "COOL", + "power": "ON", + "temperature": { + "fahrenheit": 64.0, + "celsius": 17.78 + } + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.dry_mode.json b/tests/fixtures/tado/smartac3.dry_mode.json new file mode 100644 index 00000000000..d04612d1105 --- /dev/null +++ b/tests/fixtures/tado/smartac3.dry_mode.json @@ -0,0 +1,57 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 77.02, + "timestamp": "2020-03-05T04:02:07.396Z", + "celsius": 25.01, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T04:02:07.396Z", + "percentage": 62.0, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null, + "type": "TADO_MODE" + }, + "setting": { + "type": "AIR_CONDITIONING", + "mode": "DRY", + "power": "ON" + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T04:02:40.867Z", + "type": "POWER", + "value": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "type": "AIR_CONDITIONING", + "mode": "DRY", + "power": "ON" + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.fan_mode.json b/tests/fixtures/tado/smartac3.fan_mode.json new file mode 100644 index 00000000000..6907c31c517 --- /dev/null +++ b/tests/fixtures/tado/smartac3.fan_mode.json @@ -0,0 +1,57 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 77.02, + "timestamp": "2020-03-05T04:02:07.396Z", + "celsius": 25.01, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T04:02:07.396Z", + "percentage": 62.0, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null, + "type": "TADO_MODE" + }, + "setting": { + "type": "AIR_CONDITIONING", + "mode": "FAN", + "power": "ON" + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T04:03:44.328Z", + "type": "POWER", + "value": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "type": "AIR_CONDITIONING", + "mode": "FAN", + "power": "ON" + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.heat_mode.json b/tests/fixtures/tado/smartac3.heat_mode.json new file mode 100644 index 00000000000..06b5a350d83 --- /dev/null +++ b/tests/fixtures/tado/smartac3.heat_mode.json @@ -0,0 +1,67 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 76.57, + "timestamp": "2020-03-05T03:57:38.850Z", + "celsius": 24.76, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T03:57:38.850Z", + "percentage": 60.9, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null, + "type": "TADO_MODE" + }, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "HEAT", + "power": "ON", + "temperature": { + "fahrenheit": 61.0, + "celsius": 16.11 + } + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T03:59:36.390Z", + "type": "POWER", + "value": "ON" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "HEAT", + "power": "ON", + "temperature": { + "fahrenheit": 61.0, + "celsius": 16.11 + } + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.hvac_off.json b/tests/fixtures/tado/smartac3.hvac_off.json new file mode 100644 index 00000000000..83e9d1a83d5 --- /dev/null +++ b/tests/fixtures/tado/smartac3.hvac_off.json @@ -0,0 +1,55 @@ +{ + "tadoMode": "AWAY", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 70.59, + "timestamp": "2020-03-05T01:21:44.089Z", + "celsius": 21.44, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T01:21:44.089Z", + "percentage": 48.2, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "MANUAL", + "projectedExpiry": null, + "type": "MANUAL" + }, + "setting": { + "type": "AIR_CONDITIONING", + "power": "OFF" + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-02-29T05:34:10.318Z", + "type": "POWER", + "value": "OFF" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T04:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "type": "AIR_CONDITIONING", + "power": "OFF" + } +} diff --git a/tests/fixtures/tado/smartac3.manual_off.json b/tests/fixtures/tado/smartac3.manual_off.json new file mode 100644 index 00000000000..a9538f30dbe --- /dev/null +++ b/tests/fixtures/tado/smartac3.manual_off.json @@ -0,0 +1,55 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 77.02, + "timestamp": "2020-03-05T04:02:07.396Z", + "celsius": 25.01, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T04:02:07.396Z", + "percentage": 62.0, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "MANUAL", + "projectedExpiry": null, + "type": "MANUAL" + }, + "setting": { + "type": "AIR_CONDITIONING", + "power": "OFF" + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T04:05:08.804Z", + "type": "POWER", + "value": "OFF" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "type": "AIR_CONDITIONING", + "power": "OFF" + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.offline.json b/tests/fixtures/tado/smartac3.offline.json new file mode 100644 index 00000000000..fda1e6468eb --- /dev/null +++ b/tests/fixtures/tado/smartac3.offline.json @@ -0,0 +1,71 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 77.09, + "timestamp": "2020-03-03T21:23:57.846Z", + "celsius": 25.05, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-03T21:23:57.846Z", + "percentage": 61.6, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "OFFLINE", + "reason": { + "code": "disconnectedDevice", + "title": "There is a disconnected device." + } + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": { + "termination": { + "typeSkillBasedApp": "TADO_MODE", + "projectedExpiry": null, + "type": "TADO_MODE" + }, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "COOL", + "power": "ON", + "temperature": { + "fahrenheit": 64.0, + "celsius": 17.78 + } + }, + "type": "MANUAL" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-02-29T18:42:26.683Z", + "type": "POWER", + "value": "OFF" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": "MANUAL", + "nextScheduleChange": null, + "setting": { + "fanSpeed": "AUTO", + "type": "AIR_CONDITIONING", + "mode": "COOL", + "power": "ON", + "temperature": { + "fahrenheit": 64.0, + "celsius": 17.78 + } + } +} diff --git a/tests/fixtures/tado/smartac3.smart_mode.json b/tests/fixtures/tado/smartac3.smart_mode.json new file mode 100644 index 00000000000..357a1a96658 --- /dev/null +++ b/tests/fixtures/tado/smartac3.smart_mode.json @@ -0,0 +1,50 @@ +{ + "tadoMode": "HOME", + "sensorDataPoints": { + "insideTemperature": { + "fahrenheit": 75.97, + "timestamp": "2020-03-05T03:50:24.769Z", + "celsius": 24.43, + "type": "TEMPERATURE", + "precision": { + "fahrenheit": 0.1, + "celsius": 0.1 + } + }, + "humidity": { + "timestamp": "2020-03-05T03:50:24.769Z", + "percentage": 60.0, + "type": "PERCENTAGE" + } + }, + "link": { + "state": "ONLINE" + }, + "openWindow": null, + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "overlay": null, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-05T03:52:22.253Z", + "type": "POWER", + "value": "OFF" + } + }, + "nextTimeBlock": { + "start": "2020-03-05T08:00:00.000Z" + }, + "preparation": null, + "overlayType": null, + "nextScheduleChange": null, + "setting": { + "fanSpeed": "MIDDLE", + "type": "AIR_CONDITIONING", + "mode": "COOL", + "power": "ON", + "temperature": { + "fahrenheit": 68.0, + "celsius": 20.0 + } + } +} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.turning_off.json b/tests/fixtures/tado/smartac3.turning_off.json new file mode 100644 index 00000000000..0c16f85811a --- /dev/null +++ b/tests/fixtures/tado/smartac3.turning_off.json @@ -0,0 +1,55 @@ +{ + "tadoMode": "HOME", + "geolocationOverride": false, + "geolocationOverrideDisableTime": null, + "preparation": null, + "setting": { + "type": "AIR_CONDITIONING", + "power": "OFF" + }, + "overlayType": "MANUAL", + "overlay": { + "type": "MANUAL", + "setting": { + "type": "AIR_CONDITIONING", + "power": "OFF" + }, + "termination": { + "type": "MANUAL", + "typeSkillBasedApp": "MANUAL", + "projectedExpiry": null + } + }, + "openWindow": null, + "nextScheduleChange": null, + "nextTimeBlock": { + "start": "2020-03-07T04:00:00.000Z" + }, + "link": { + "state": "ONLINE" + }, + "activityDataPoints": { + "acPower": { + "timestamp": "2020-03-06T19:05:21.835Z", + "type": "POWER", + "value": "ON" + } + }, + "sensorDataPoints": { + "insideTemperature": { + "celsius": 21.40, + "fahrenheit": 70.52, + "timestamp": "2020-03-06T19:06:13.185Z", + "type": "TEMPERATURE", + "precision": { + "celsius": 0.1, + "fahrenheit": 0.1 + } + }, + "humidity": { + "type": "PERCENTAGE", + "percentage": 49.20, + "timestamp": "2020-03-06T19:06:13.185Z" + } + } +} From f4561891ae59d378f5940e4ed332a6a6e99b6308 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 9 Mar 2020 21:39:42 -0600 Subject: [PATCH 306/416] Ensure AirVisual import config flow checks API key correctness (#32624) * Add small improvements to AirVisual config flow tests * Code review comments * Code review comments --- .../components/airvisual/config_flow.py | 47 +++++++++---------- .../components/airvisual/test_config_flow.py | 33 ++++++------- 2 files changed, 38 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index 4dd7fb80de8..bdd1d9a7b70 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -1,4 +1,6 @@ """Define a config flow manager for AirVisual.""" +import logging + from pyairvisual import Client from pyairvisual.errors import InvalidKeyError import voluptuous as vol @@ -10,6 +12,8 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import CONF_GEOGRAPHIES, DOMAIN # pylint: disable=unused-import +_LOGGER = logging.getLogger("homeassistant.components.airvisual") + class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle a AirVisual config flow.""" @@ -46,21 +50,7 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" - await self._async_set_unique_id(import_config[CONF_API_KEY]) - - data = {**import_config} - if not data.get(CONF_GEOGRAPHIES): - data[CONF_GEOGRAPHIES] = [ - { - CONF_LATITUDE: self.hass.config.latitude, - CONF_LONGITUDE: self.hass.config.longitude, - } - ] - - return self.async_create_entry( - title=f"Cloud API (API key: {import_config[CONF_API_KEY][:4]}...)", - data=data, - ) + return await self.async_step_user(import_config) async def async_step_user(self, user_input=None): """Handle the start of the config flow.""" @@ -70,7 +60,6 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): await self._async_set_unique_id(user_input[CONF_API_KEY]) websession = aiohttp_client.async_get_clientsession(self.hass) - client = Client(websession, api_key=user_input[CONF_API_KEY]) try: @@ -78,15 +67,21 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): except InvalidKeyError: return await self._show_form(errors={CONF_API_KEY: "invalid_api_key"}) + data = {CONF_API_KEY: user_input[CONF_API_KEY]} + if user_input.get(CONF_GEOGRAPHIES): + data[CONF_GEOGRAPHIES] = user_input[CONF_GEOGRAPHIES] + else: + data[CONF_GEOGRAPHIES] = [ + { + CONF_LATITUDE: user_input.get( + CONF_LATITUDE, self.hass.config.latitude + ), + CONF_LONGITUDE: user_input.get( + CONF_LONGITUDE, self.hass.config.longitude + ), + } + ] + return self.async_create_entry( - title=f"Cloud API (API key: {user_input[CONF_API_KEY][:4]}...)", - data={ - CONF_API_KEY: user_input[CONF_API_KEY], - CONF_GEOGRAPHIES: [ - { - CONF_LATITUDE: user_input[CONF_LATITUDE], - CONF_LONGITUDE: user_input[CONF_LONGITUDE], - } - ], - }, + title=f"Cloud API (API key: {user_input[CONF_API_KEY][:4]}...)", data=data ) diff --git a/tests/components/airvisual/test_config_flow.py b/tests/components/airvisual/test_config_flow.py index fb4325bd6ee..5057f1c3345 100644 --- a/tests/components/airvisual/test_config_flow.py +++ b/tests/components/airvisual/test_config_flow.py @@ -1,6 +1,5 @@ """Define tests for the AirVisual config flow.""" -from unittest.mock import patch - +from asynctest import patch from pyairvisual.errors import InvalidKeyError from homeassistant import data_entry_flow @@ -8,7 +7,7 @@ from homeassistant.components.airvisual import CONF_GEOGRAPHIES, DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE -from tests.common import MockConfigEntry, mock_coro +from tests.common import MockConfigEntry async def test_duplicate_error(hass): @@ -30,8 +29,7 @@ async def test_invalid_api_key(hass): conf = {CONF_API_KEY: "abcde12345"} with patch( - "pyairvisual.api.API.nearest_city", - return_value=mock_coro(exception=InvalidKeyError), + "pyairvisual.api.API.nearest_city", side_effect=InvalidKeyError, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf @@ -53,16 +51,19 @@ async def test_step_import(hass): """Test that the import step works.""" conf = {CONF_API_KEY: "abcde12345"} - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_IMPORT}, data=conf - ) + with patch( + "homeassistant.components.wwlln.async_setup_entry", return_value=True + ), patch("pyairvisual.api.API.nearest_city"): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result["title"] == "Cloud API (API key: abcd...)" - assert result["data"] == { - CONF_API_KEY: "abcde12345", - CONF_GEOGRAPHIES: [{CONF_LATITUDE: 32.87336, CONF_LONGITUDE: -117.22743}], - } + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Cloud API (API key: abcd...)" + assert result["data"] == { + CONF_API_KEY: "abcde12345", + CONF_GEOGRAPHIES: [{CONF_LATITUDE: 32.87336, CONF_LONGITUDE: -117.22743}], + } async def test_step_user(hass): @@ -74,8 +75,8 @@ async def test_step_user(hass): } with patch( - "pyairvisual.api.API.nearest_city", return_value=mock_coro(), - ): + "homeassistant.components.wwlln.async_setup_entry", return_value=True + ), patch("pyairvisual.api.API.nearest_city"): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf ) From f3c07a5653f5d4133c4ca51cba6eb076c90417fb Mon Sep 17 00:00:00 2001 From: Thomas Hollstegge Date: Tue, 10 Mar 2020 05:59:06 +0100 Subject: [PATCH 307/416] Alexa: Add support for starting and cancelling timers (#32616) --- .../components/alexa/capabilities.py | 4 ++ homeassistant/components/alexa/entities.py | 1 + homeassistant/components/alexa/handlers.py | 4 ++ tests/components/alexa/test_smart_home.py | 39 ++++++++++++++++++- 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index 25696ec116a..6ab086ddda3 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -7,6 +7,7 @@ from homeassistant.components import ( image_processing, input_number, light, + timer, vacuum, ) from homeassistant.components.alarm_control_panel import ATTR_CODE_FORMAT, FORMAT_NUMBER @@ -25,6 +26,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, + STATE_IDLE, STATE_LOCKED, STATE_OFF, STATE_ON, @@ -365,6 +367,8 @@ class AlexaPowerController(AlexaCapability): is_on = self.entity.state != climate.HVAC_MODE_OFF elif self.entity.domain == vacuum.DOMAIN: is_on = self.entity.state == vacuum.STATE_CLEANING + elif self.entity.domain == timer.DOMAIN: + is_on = self.entity.state != STATE_IDLE else: is_on = self.entity.state != STATE_OFF diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index df3be7ee85e..aa9fe40164c 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -727,6 +727,7 @@ class TimerCapabilities(AlexaEntity): def interfaces(self): """Yield the supported interfaces.""" yield AlexaTimeHoldController(self.entity, allow_remote_resume=True) + yield AlexaPowerController(self.entity) yield Alexa(self.entity) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index a77051cb03b..67083607769 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -125,6 +125,8 @@ async def async_api_turn_on(hass, config, directive, context): supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if not supported & vacuum.SUPPORT_TURN_ON and supported & vacuum.SUPPORT_START: service = vacuum.SERVICE_START + elif domain == timer.DOMAIN: + service = timer.SERVICE_START elif domain == media_player.DOMAIN: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) power_features = media_player.SUPPORT_TURN_ON | media_player.SUPPORT_TURN_OFF @@ -160,6 +162,8 @@ async def async_api_turn_off(hass, config, directive, context): and supported & vacuum.SUPPORT_RETURN_HOME ): service = vacuum.SERVICE_RETURN_TO_BASE + elif domain == timer.DOMAIN: + service = timer.SERVICE_CANCEL elif domain == media_player.DOMAIN: supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0) power_features = media_player.SUPPORT_TURN_ON | media_player.SUPPORT_TURN_OFF diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index f723832938a..fa8f7fbdc9a 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -3348,7 +3348,7 @@ async def test_timer_hold(hass): assert appliance["friendlyName"] == "Laundry" capabilities = assert_endpoint_capabilities( - appliance, "Alexa", "Alexa.TimeHoldController" + appliance, "Alexa", "Alexa.TimeHoldController", "Alexa.PowerController" ) time_hold_capability = get_capability(capabilities, "Alexa.TimeHoldController") @@ -3370,11 +3370,48 @@ async def test_timer_resume(hass): ) await discovery_test(device, hass) + properties = await reported_properties(hass, "timer#laundry") + properties.assert_equal("Alexa.PowerController", "powerState", "ON") + await assert_request_calls_service( "Alexa.TimeHoldController", "Resume", "timer#laundry", "timer.start", hass ) +async def test_timer_start(hass): + """Test timer start.""" + device = ( + "timer.laundry", + "idle", + {"friendly_name": "Laundry", "duration": "00:01:00", "remaining": "00:50:00"}, + ) + await discovery_test(device, hass) + + properties = await reported_properties(hass, "timer#laundry") + properties.assert_equal("Alexa.PowerController", "powerState", "OFF") + + await assert_request_calls_service( + "Alexa.PowerController", "TurnOn", "timer#laundry", "timer.start", hass + ) + + +async def test_timer_cancel(hass): + """Test timer cancel.""" + device = ( + "timer.laundry", + "active", + {"friendly_name": "Laundry", "duration": "00:01:00", "remaining": "00:50:00"}, + ) + await discovery_test(device, hass) + + properties = await reported_properties(hass, "timer#laundry") + properties.assert_equal("Alexa.PowerController", "powerState", "ON") + + await assert_request_calls_service( + "Alexa.PowerController", "TurnOff", "timer#laundry", "timer.cancel", hass + ) + + async def test_vacuum_discovery(hass): """Test vacuum discovery.""" device = ( From 324dfe07b464afd4e1c66622e917cd44bea56a35 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 10 Mar 2020 05:59:38 +0100 Subject: [PATCH 308/416] Clear discovery topic for MQTT device triggers (#32617) --- .../components/mqtt/device_trigger.py | 16 +++++--- tests/components/mqtt/test_device_trigger.py | 39 +++++++++++++++++++ 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index 92bef0578c9..5bb5ccbd9d4 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -18,6 +18,7 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType from . import ( ATTR_DISCOVERY_HASH, + ATTR_DISCOVERY_TOPIC, CONF_CONNECTIONS, CONF_DEVICE, CONF_IDENTIFIERS, @@ -99,7 +100,7 @@ class Trigger: """Device trigger settings.""" device_id = attr.ib(type=str) - discovery_hash = attr.ib(type=tuple) + discovery_data = attr.ib(type=dict) hass = attr.ib(type=HomeAssistantType) payload = attr.ib(type=str) qos = attr.ib(type=int) @@ -132,7 +133,6 @@ class Trigger: async def update_trigger(self, config, discovery_hash, remove_signal): """Update MQTT device trigger.""" - self.discovery_hash = discovery_hash self.remove_signal = remove_signal self.type = config[CONF_TYPE] self.subtype = config[CONF_SUBTYPE] @@ -216,7 +216,7 @@ async def async_setup_trigger(hass, config, config_entry, discovery_data): hass.data[DEVICE_TRIGGERS][discovery_id] = Trigger( hass=hass, device_id=device.id, - discovery_hash=discovery_hash, + discovery_data=discovery_data, type=config[CONF_TYPE], subtype=config[CONF_SUBTYPE], topic=config[CONF_TOPIC], @@ -236,9 +236,15 @@ async def async_device_removed(hass: HomeAssistant, device_id: str): for trig in triggers: device_trigger = hass.data[DEVICE_TRIGGERS].pop(trig[CONF_DISCOVERY_ID]) if device_trigger: + discovery_hash = device_trigger.discovery_data[ATTR_DISCOVERY_HASH] + discovery_topic = device_trigger.discovery_data[ATTR_DISCOVERY_TOPIC] + device_trigger.detach_trigger() - clear_discovery_hash(hass, device_trigger.discovery_hash) + clear_discovery_hash(hass, discovery_hash) device_trigger.remove_signal() + mqtt.publish( + hass, discovery_topic, "", retain=True, + ) async def async_get_triggers(hass: HomeAssistant, device_id: str) -> List[dict]: @@ -281,7 +287,7 @@ async def async_attach_trigger( hass.data[DEVICE_TRIGGERS][discovery_id] = Trigger( hass=hass, device_id=device_id, - discovery_hash=None, + discovery_data=None, remove_signal=None, type=config[CONF_TYPE], subtype=config[CONF_SUBTYPE], diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index c9d9ec4ad08..c7d1f636c02 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -831,3 +831,42 @@ async def test_entity_device_info_update(hass, mqtt_mock): device = registry.async_get_device({("mqtt", "helloworld")}, set()) assert device is not None assert device.name == "Milk" + + +async def test_cleanup_device(hass, device_reg, entity_reg, mqtt_mock): + """Test discovered device is cleaned up when removed from registry.""" + config_entry = MockConfigEntry(domain=DOMAIN) + config_entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, config_entry) + + config = { + "automation_type": "trigger", + "topic": "test-topic", + "type": "foo", + "subtype": "bar", + "device": {"identifiers": ["helloworld"]}, + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, "homeassistant/device_automation/bla/config", data) + await hass.async_block_till_done() + + # Verify device registry entry is created + device_entry = device_reg.async_get_device({("mqtt", "helloworld")}, set()) + assert device_entry is not None + + triggers = await async_get_device_automations(hass, "trigger", device_entry.id) + assert triggers[0]["type"] == "foo" + + device_reg.async_remove_device(device_entry.id) + await hass.async_block_till_done() + await hass.async_block_till_done() + + # Verify device registry entry is cleared + device_entry = device_reg.async_get_device({("mqtt", "0AFFD2")}, set()) + assert device_entry is None + + # Verify retained discovery topic has been cleared + mqtt_mock.async_publish.assert_called_once_with( + "homeassistant/device_automation/bla/config", "", 0, True + ) From aed15761deed0e661883c394643b2fa169cc2e27 Mon Sep 17 00:00:00 2001 From: Georgi Gardev Date: Tue, 10 Mar 2020 09:17:07 +0200 Subject: [PATCH 309/416] Sonos: Return URI as media_content_id (#32626) --- homeassistant/components/sonos/media_player.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 37b479a90b1..0ab78195cb2 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -366,6 +366,7 @@ class SonosEntity(MediaPlayerDevice): self._coordinator = None self._sonos_group = [self] self._status = None + self._uri = None self._media_duration = None self._media_position = None self._media_position_updated_at = None @@ -570,6 +571,7 @@ class SonosEntity(MediaPlayerDevice): return self._shuffle = self.soco.shuffle + self._uri = None update_position = new_status != self._status self._status = new_status @@ -580,6 +582,7 @@ class SonosEntity(MediaPlayerDevice): self.update_media_linein(SOURCE_LINEIN) else: track_info = self.soco.get_current_track_info() + self._uri = track_info["uri"] if _is_radio_uri(track_info["uri"]): variables = event and event.variables @@ -826,6 +829,11 @@ class SonosEntity(MediaPlayerDevice): """Shuffling state.""" return self._shuffle + @property + def media_content_id(self): + """Content id of current playing media.""" + return self._uri + @property def media_content_type(self): """Content type of current playing media.""" From 11eee43fc7d5ed5b758dd300b341cc53643c7eb8 Mon Sep 17 00:00:00 2001 From: Robert Chmielowiec Date: Tue, 10 Mar 2020 08:21:04 +0100 Subject: [PATCH 310/416] Remove facebook broadcast api (#32517) * Remove facebook broadcast api * Update notify.py * Revert API Version change --- homeassistant/components/facebook/notify.py | 62 +++++---------------- 1 file changed, 14 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py index dbd9be61516..34d5b14cf25 100644 --- a/homeassistant/components/facebook/notify.py +++ b/homeassistant/components/facebook/notify.py @@ -19,8 +19,6 @@ _LOGGER = logging.getLogger(__name__) CONF_PAGE_ACCESS_TOKEN = "page_access_token" BASE_URL = "https://graph.facebook.com/v2.6/me/messages" -CREATE_BROADCAST_URL = "https://graph.facebook.com/v2.11/me/message_creatives" -SEND_BROADCAST_URL = "https://graph.facebook.com/v2.11/me/broadcast_messages" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_PAGE_ACCESS_TOKEN): cv.string} @@ -57,29 +55,23 @@ class FacebookNotificationService(BaseNotificationService): _LOGGER.error("At least 1 target is required") return - # broadcast message - if targets[0].lower() == "broadcast": - broadcast_create_body = {"messages": [body_message]} - _LOGGER.debug("Broadcast body %s : ", broadcast_create_body) + for target in targets: + # If the target starts with a "+", it's a phone number, + # otherwise it's a user id. + if target.startswith("+"): + recipient = {"phone_number": target} + else: + recipient = {"id": target} - resp = requests.post( - CREATE_BROADCAST_URL, - data=json.dumps(broadcast_create_body), - params=payload, - headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, - timeout=10, - ) - _LOGGER.debug("FB Messager broadcast id %s : ", resp.json()) - - # at this point we get broadcast id - broadcast_body = { - "message_creative_id": resp.json().get("message_creative_id"), - "notification_type": "REGULAR", + body = { + "recipient": recipient, + "message": body_message, + "messaging_type": "MESSAGE_TAG", + "tag": "ACCOUNT_UPDATE", } - resp = requests.post( - SEND_BROADCAST_URL, - data=json.dumps(broadcast_body), + BASE_URL, + data=json.dumps(body), params=payload, headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, timeout=10, @@ -87,32 +79,6 @@ class FacebookNotificationService(BaseNotificationService): if resp.status_code != 200: log_error(resp) - # non-broadcast message - else: - for target in targets: - # If the target starts with a "+", it's a phone number, - # otherwise it's a user id. - if target.startswith("+"): - recipient = {"phone_number": target} - else: - recipient = {"id": target} - - body = { - "recipient": recipient, - "message": body_message, - "messaging_type": "MESSAGE_TAG", - "tag": "ACCOUNT_UPDATE", - } - resp = requests.post( - BASE_URL, - data=json.dumps(body), - params=payload, - headers={CONTENT_TYPE: CONTENT_TYPE_JSON}, - timeout=10, - ) - if resp.status_code != 200: - log_error(resp) - def log_error(response): """Log error message.""" From ac9c9377c22afe62f8c6b1458c09d6d52553e8ca Mon Sep 17 00:00:00 2001 From: Paul Enright Date: Tue, 10 Mar 2020 02:53:06 -0500 Subject: [PATCH 311/416] Add tests for workday sensor (#31832) * Workday Fix * fix pylint errors * Update binary_sensor.py * Update test_binary_sensor.py Added tests to match the document new configuration examples --- .../components/workday/test_binary_sensor.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py index 8e2d7d86e9a..29d5e4f03ef 100644 --- a/tests/components/workday/test_binary_sensor.py +++ b/tests/components/workday/test_binary_sensor.py @@ -55,6 +55,26 @@ class TestWorkdaySetup: } } + self.config_example1 = { + "binary_sensor": { + "platform": "workday", + "country": "US", + "workdays": ["mon", "tue", "wed", "thu", "fri"], + "excludes": ["sat", "sun"], + } + } + + self.config_example2 = { + "binary_sensor": { + "platform": "workday", + "country": "DE", + "province": "BW", + "workdays": ["mon", "wed", "fri"], + "excludes": ["sat", "sun", "holiday"], + "add_holidays": ["2020-02-24"], + } + } + self.config_tomorrow = { "binary_sensor": {"platform": "workday", "country": "DE", "days_offset": 1} } @@ -229,6 +249,43 @@ class TestWorkdaySetup: entity = self.hass.states.get("binary_sensor.workday_sensor") assert entity.state == "on" + # Freeze time to a Presidents day to test Holiday on a Work day - Jan 20th, 2020 + # Presidents day Feb 17th 2020 is mon. + @patch(FUNCTION_PATH, return_value=date(2020, 2, 17)) + def test_config_example1_holiday(self, mock_date): + """Test if public holidays are reported correctly.""" + with assert_setup_component(1, "binary_sensor"): + setup_component(self.hass, "binary_sensor", self.config_example1) + + self.hass.start() + + entity = self.hass.states.get("binary_sensor.workday_sensor") + assert entity.state == "on" + + # Freeze time to test tue - Feb 18th, 2020 + @patch(FUNCTION_PATH, return_value=date(2020, 2, 18)) + def test_config_example2_tue(self, mock_date): + """Test if public holidays are reported correctly.""" + with assert_setup_component(1, "binary_sensor"): + setup_component(self.hass, "binary_sensor", self.config_example2) + + self.hass.start() + + entity = self.hass.states.get("binary_sensor.workday_sensor") + assert entity.state == "off" + + # Freeze time to test mon, but added as holiday - Feb 24th, 2020 + @patch(FUNCTION_PATH, return_value=date(2020, 2, 24)) + def test_config_example2_add_holiday(self, mock_date): + """Test if public holidays are reported correctly.""" + with assert_setup_component(1, "binary_sensor"): + setup_component(self.hass, "binary_sensor", self.config_example2) + + self.hass.start() + + entity = self.hass.states.get("binary_sensor.workday_sensor") + assert entity.state == "off" + def test_day_to_string(self): """Test if day_to_string is behaving correctly.""" assert binary_sensor.day_to_string(0) == "mon" From 8c52e2c92305144d483fa8e3b72773dfdc3f908d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Arnauts?= Date: Tue, 10 Mar 2020 09:32:56 +0100 Subject: [PATCH 312/416] Revert "Breakout tado zone code into a single place (#32564)" (#32639) This reverts commit c2b03332a0e08d0a4347aaad3c233908029e9864. --- CODEOWNERS | 2 +- homeassistant/components/tado/__init__.py | 70 +-- homeassistant/components/tado/climate.py | 484 +++++++++--------- homeassistant/components/tado/const.py | 94 +--- homeassistant/components/tado/manifest.json | 4 +- homeassistant/components/tado/sensor.py | 122 +++-- homeassistant/components/tado/tado_adapter.py | 285 ----------- homeassistant/components/tado/water_heater.py | 126 ++--- requirements_all.txt | 2 +- requirements_test_all.txt | 3 - tests/components/tado/mocks.py | 18 - tests/components/tado/test_tado_adapter.py | 423 --------------- .../tado/ac_issue_32294.heat_mode.json | 60 --- tests/fixtures/tado/hvac_action_heat.json | 67 --- tests/fixtures/tado/michael_heat_mode.json | 58 --- tests/fixtures/tado/smartac3.auto_mode.json | 57 --- tests/fixtures/tado/smartac3.cool_mode.json | 67 --- tests/fixtures/tado/smartac3.dry_mode.json | 57 --- tests/fixtures/tado/smartac3.fan_mode.json | 57 --- tests/fixtures/tado/smartac3.heat_mode.json | 67 --- tests/fixtures/tado/smartac3.hvac_off.json | 55 -- tests/fixtures/tado/smartac3.manual_off.json | 55 -- tests/fixtures/tado/smartac3.offline.json | 71 --- tests/fixtures/tado/smartac3.smart_mode.json | 50 -- tests/fixtures/tado/smartac3.turning_off.json | 55 -- 25 files changed, 410 insertions(+), 1999 deletions(-) delete mode 100644 homeassistant/components/tado/tado_adapter.py delete mode 100644 tests/components/tado/mocks.py delete mode 100644 tests/components/tado/test_tado_adapter.py delete mode 100644 tests/fixtures/tado/ac_issue_32294.heat_mode.json delete mode 100644 tests/fixtures/tado/hvac_action_heat.json delete mode 100644 tests/fixtures/tado/michael_heat_mode.json delete mode 100644 tests/fixtures/tado/smartac3.auto_mode.json delete mode 100644 tests/fixtures/tado/smartac3.cool_mode.json delete mode 100644 tests/fixtures/tado/smartac3.dry_mode.json delete mode 100644 tests/fixtures/tado/smartac3.fan_mode.json delete mode 100644 tests/fixtures/tado/smartac3.heat_mode.json delete mode 100644 tests/fixtures/tado/smartac3.hvac_off.json delete mode 100644 tests/fixtures/tado/smartac3.manual_off.json delete mode 100644 tests/fixtures/tado/smartac3.offline.json delete mode 100644 tests/fixtures/tado/smartac3.smart_mode.json delete mode 100644 tests/fixtures/tado/smartac3.turning_off.json diff --git a/CODEOWNERS b/CODEOWNERS index f59eb6322f2..89417c4ca56 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -350,7 +350,7 @@ homeassistant/components/switchmate/* @danielhiversen homeassistant/components/syncthru/* @nielstron homeassistant/components/synology_srm/* @aerialls homeassistant/components/syslog/* @fabaff -homeassistant/components/tado/* @michaelarnauts @bdraco +homeassistant/components/tado/* @michaelarnauts homeassistant/components/tahoma/* @philklei homeassistant/components/tankerkoenig/* @guillempages homeassistant/components/tautulli/* @ludeeus diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 5442493cbaa..727fb868a33 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -1,13 +1,12 @@ """Support for the (unofficial) Tado API.""" from datetime import timedelta import logging +import urllib from PyTado.interface import Tado -from requests import RequestException import voluptuous as vol from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import dispatcher_send @@ -110,7 +109,7 @@ class TadoConnector: """Connect to Tado and fetch the zones.""" try: self.tado = Tado(self._username, self._password) - except (RuntimeError, RequestException) as exc: + except (RuntimeError, urllib.error.HTTPError) as exc: _LOGGER.error("Unable to connect: %s", exc) return False @@ -137,12 +136,7 @@ class TadoConnector: if sensor_type == "zone": data = self.tado.getState(sensor) elif sensor_type == "device": - devices_data = self.tado.getDevices() - if not devices_data: - _LOGGER.info("There are no devices to setup on this tado account.") - return - - data = devices_data[0] + data = self.tado.getDevices()[0] else: _LOGGER.debug("Unknown sensor: %s", sensor_type) return @@ -168,62 +162,31 @@ class TadoConnector: self.tado.resetZoneOverlay(zone_id) self.update_sensor("zone", zone_id) - def set_home(self): - """Put tado in home mode.""" - response_json = None - try: - response_json = self.tado.setHome() - except RequestException as exc: - _LOGGER.error("Could not set home: %s", exc) - - _raise_home_away_errors(response_json) - - def set_away(self): - """Put tado in away mode.""" - response_json = None - try: - response_json = self.tado.setAway() - except RequestException as exc: - _LOGGER.error("Could not set away: %s", exc) - - _raise_home_away_errors(response_json) - def set_zone_overlay( self, - zone_id=None, - overlay_mode=None, + zone_id, + overlay_mode, temperature=None, duration=None, device_type="HEATING", mode=None, - fan_speed=None, ): """Set a zone overlay.""" _LOGGER.debug( - "Set overlay for zone %s: overlay_mode=%s, temp=%s, duration=%s, type=%s, mode=%s fan_speed=%s", + "Set overlay for zone %s: mode=%s, temp=%s, duration=%s, type=%s, mode=%s", zone_id, overlay_mode, temperature, duration, device_type, mode, - fan_speed, ) - try: self.tado.setZoneOverlay( - zone_id, - overlay_mode, - temperature, - duration, - device_type, - "ON", - mode, - fan_speed, + zone_id, overlay_mode, temperature, duration, device_type, "ON", mode ) - - except RequestException as exc: - _LOGGER.error("Could not set zone overlay: %s", exc) + except urllib.error.HTTPError as exc: + _LOGGER.error("Could not set zone overlay: %s", exc.read()) self.update_sensor("zone", zone_id) @@ -233,18 +196,7 @@ class TadoConnector: self.tado.setZoneOverlay( zone_id, overlay_mode, None, None, device_type, "OFF" ) - except RequestException as exc: - _LOGGER.error("Could not set zone overlay: %s", exc) + except urllib.error.HTTPError as exc: + _LOGGER.error("Could not set zone overlay: %s", exc.read()) self.update_sensor("zone", zone_id) - - -def _raise_home_away_errors(response_json): - if response_json is None: - return - - # Likely we are displaying to the user: - # Tried to update to HOME though all mobile devices are detected outside the home fence - if "errors" in response_json and len(response_json["errors"]) > 0: - error_list = response_json["errors"] - raise HomeAssistantError(error_list[0]["title"]) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 52b26738373..b92a54edd5e 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -3,12 +3,21 @@ import logging from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, CURRENT_HVAC_OFF, - FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MIDDLE, + FAN_OFF, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, PRESET_AWAY, PRESET_HOME, - SUPPORT_FAN_MODE, SUPPORT_PRESET_MODE, SUPPORT_TARGET_TEMPERATURE, ) @@ -18,29 +27,49 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED from .const import ( - CONST_FAN_AUTO, - CONST_FAN_OFF, - CONST_MODE_COOL, - CONST_MODE_HEAT, CONST_MODE_OFF, CONST_MODE_SMART_SCHEDULE, CONST_OVERLAY_MANUAL, CONST_OVERLAY_TADO_MODE, + CONST_OVERLAY_TIMER, DATA, - HA_TO_TADO_FAN_MODE_MAP, - HA_TO_TADO_HVAC_MODE_MAP, - ORDERED_KNOWN_TADO_MODES, - SUPPORT_PRESET, - TADO_MODES_WITH_NO_TEMP_SETTING, - TADO_TO_HA_FAN_MODE_MAP, - TADO_TO_HA_HVAC_MODE_MAP, TYPE_AIR_CONDITIONING, TYPE_HEATING, ) -from .tado_adapter import TadoZoneData _LOGGER = logging.getLogger(__name__) +FAN_MAP_TADO = {"HIGH": FAN_HIGH, "MIDDLE": FAN_MIDDLE, "LOW": FAN_LOW} + +HVAC_MAP_TADO_HEAT = { + CONST_OVERLAY_MANUAL: HVAC_MODE_HEAT, + CONST_OVERLAY_TIMER: HVAC_MODE_HEAT, + CONST_OVERLAY_TADO_MODE: HVAC_MODE_HEAT, + CONST_MODE_SMART_SCHEDULE: HVAC_MODE_AUTO, + CONST_MODE_OFF: HVAC_MODE_OFF, +} +HVAC_MAP_TADO_COOL = { + CONST_OVERLAY_MANUAL: HVAC_MODE_COOL, + CONST_OVERLAY_TIMER: HVAC_MODE_COOL, + CONST_OVERLAY_TADO_MODE: HVAC_MODE_COOL, + CONST_MODE_SMART_SCHEDULE: HVAC_MODE_AUTO, + CONST_MODE_OFF: HVAC_MODE_OFF, +} +HVAC_MAP_TADO_HEAT_COOL = { + CONST_OVERLAY_MANUAL: HVAC_MODE_HEAT_COOL, + CONST_OVERLAY_TIMER: HVAC_MODE_HEAT_COOL, + CONST_OVERLAY_TADO_MODE: HVAC_MODE_HEAT_COOL, + CONST_MODE_SMART_SCHEDULE: HVAC_MODE_AUTO, + CONST_MODE_OFF: HVAC_MODE_OFF, +} + +SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE +SUPPORT_HVAC_HEAT = [HVAC_MODE_HEAT, HVAC_MODE_AUTO, HVAC_MODE_OFF] +SUPPORT_HVAC_COOL = [HVAC_MODE_COOL, HVAC_MODE_AUTO, HVAC_MODE_OFF] +SUPPORT_HVAC_HEAT_COOL = [HVAC_MODE_HEAT_COOL, HVAC_MODE_AUTO, HVAC_MODE_OFF] +SUPPORT_FAN = [FAN_HIGH, FAN_MIDDLE, FAN_LOW, FAN_OFF] +SUPPORT_PRESET = [PRESET_AWAY, PRESET_HOME] + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Tado climate platform.""" @@ -67,80 +96,29 @@ def create_climate_entity(tado, name: str, zone_id: int): _LOGGER.debug("Capabilities for zone %s: %s", zone_id, capabilities) zone_type = capabilities["type"] - support_flags = SUPPORT_PRESET_MODE | SUPPORT_TARGET_TEMPERATURE - supported_hvac_modes = [ - TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_OFF], - TADO_TO_HA_HVAC_MODE_MAP[CONST_MODE_SMART_SCHEDULE], - ] - supported_fan_modes = None - heat_temperatures = None - cool_temperatures = None + ac_support_heat = False if zone_type == TYPE_AIR_CONDITIONING: + # Only use heat if available + # (you don't have to setup a heat mode, but cool is required) # Heat is preferred as it generally has a lower minimum temperature - for mode in ORDERED_KNOWN_TADO_MODES: - if mode not in capabilities: - continue - - supported_hvac_modes.append(TADO_TO_HA_HVAC_MODE_MAP[mode]) - if not capabilities[mode].get("fanSpeeds"): - continue - - support_flags |= SUPPORT_FAN_MODE - - if supported_fan_modes: - continue - - supported_fan_modes = [ - TADO_TO_HA_FAN_MODE_MAP[speed] - for speed in capabilities[mode]["fanSpeeds"] - ] - - cool_temperatures = capabilities[CONST_MODE_COOL]["temperatures"] + if "HEAT" in capabilities: + temperatures = capabilities["HEAT"]["temperatures"] + ac_support_heat = True + else: + temperatures = capabilities["COOL"]["temperatures"] + elif "temperatures" in capabilities: + temperatures = capabilities["temperatures"] else: - supported_hvac_modes.append(HVAC_MODE_HEAT) - - if CONST_MODE_HEAT in capabilities: - heat_temperatures = capabilities[CONST_MODE_HEAT]["temperatures"] - - if heat_temperatures is None and "temperatures" in capabilities: - heat_temperatures = capabilities["temperatures"] - - if cool_temperatures is None and heat_temperatures is None: - _LOGGER.debug("Not adding zone %s since it has no temperatures", name) + _LOGGER.debug("Not adding zone %s since it has no temperature", name) return None - heat_min_temp = None - heat_max_temp = None - heat_step = None - cool_min_temp = None - cool_max_temp = None - cool_step = None - - if heat_temperatures is not None: - heat_min_temp = float(heat_temperatures["celsius"]["min"]) - heat_max_temp = float(heat_temperatures["celsius"]["max"]) - heat_step = heat_temperatures["celsius"].get("step", PRECISION_TENTHS) - - if cool_temperatures is not None: - cool_min_temp = float(cool_temperatures["celsius"]["min"]) - cool_max_temp = float(cool_temperatures["celsius"]["max"]) - cool_step = cool_temperatures["celsius"].get("step", PRECISION_TENTHS) + min_temp = float(temperatures["celsius"]["min"]) + max_temp = float(temperatures["celsius"]["max"]) + step = temperatures["celsius"].get("step", PRECISION_TENTHS) entity = TadoClimate( - tado, - name, - zone_id, - zone_type, - heat_min_temp, - heat_max_temp, - heat_step, - cool_min_temp, - cool_max_temp, - cool_step, - supported_hvac_modes, - supported_fan_modes, - support_flags, + tado, name, zone_id, zone_type, min_temp, max_temp, step, ac_support_heat, ) return entity @@ -154,15 +132,10 @@ class TadoClimate(ClimateDevice): zone_name, zone_id, zone_type, - heat_min_temp, - heat_max_temp, - heat_step, - cool_min_temp, - cool_max_temp, - cool_step, - supported_hvac_modes, - supported_fan_modes, - support_flags, + min_temp, + max_temp, + step, + ac_support_heat, ): """Initialize of Tado climate entity.""" self._tado = tado @@ -173,45 +146,49 @@ class TadoClimate(ClimateDevice): self._unique_id = f"{zone_type} {zone_id} {tado.device_id}" self._ac_device = zone_type == TYPE_AIR_CONDITIONING - self._supported_hvac_modes = supported_hvac_modes - self._supported_fan_modes = supported_fan_modes - self._support_flags = support_flags + self._ac_support_heat = ac_support_heat + self._cooling = False - self._available = False + self._active = False + self._device_is_active = False self._cur_temp = None self._cur_humidity = None - - self._heat_min_temp = heat_min_temp - self._heat_max_temp = heat_max_temp - self._heat_step = heat_step - - self._cool_min_temp = cool_min_temp - self._cool_max_temp = cool_max_temp - self._cool_step = cool_step - + self._is_away = False + self._min_temp = min_temp + self._max_temp = max_temp + self._step = step self._target_temp = None - self._current_tado_fan_speed = CONST_FAN_OFF - self._current_tado_hvac_mode = CONST_MODE_OFF - self._current_hvac_action = CURRENT_HVAC_OFF + if tado.fallback: + # Fallback to Smart Schedule at next Schedule switch + self._default_overlay = CONST_OVERLAY_TADO_MODE + else: + # Don't fallback to Smart Schedule, but keep in manual mode + self._default_overlay = CONST_OVERLAY_MANUAL - self._tado_zone_data = None - self._async_update_zone_data() + self._current_fan = CONST_MODE_OFF + self._current_operation = CONST_MODE_SMART_SCHEDULE + self._overlay_mode = CONST_MODE_SMART_SCHEDULE async def async_added_to_hass(self): """Register for sensor updates.""" + @callback + def async_update_callback(): + """Schedule an entity update.""" + self.async_schedule_update_ha_state(True) + async_dispatcher_connect( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format("zone", self.zone_id), - self._async_update_callback, + async_update_callback, ) @property def supported_features(self): """Return the list of supported features.""" - return self._support_flags + return SUPPORT_FLAGS @property def name(self): @@ -231,12 +208,12 @@ class TadoClimate(ClimateDevice): @property def current_humidity(self): """Return the current humidity.""" - return self._tado_zone_data.current_humidity + return self._cur_humidity @property def current_temperature(self): """Return the sensor temperature.""" - return self._tado_zone_data.current_temp + return self._cur_temp @property def hvac_mode(self): @@ -244,9 +221,11 @@ class TadoClimate(ClimateDevice): Need to be one of HVAC_MODE_*. """ - return TADO_TO_HA_HVAC_MODE_MAP.get( - self._tado_zone_data.current_tado_hvac_mode, CURRENT_HVAC_OFF - ) + if self._ac_device and self._ac_support_heat: + return HVAC_MAP_TADO_HEAT_COOL.get(self._current_operation) + if self._ac_device and not self._ac_support_heat: + return HVAC_MAP_TADO_COOL.get(self._current_operation) + return HVAC_MAP_TADO_HEAT.get(self._current_operation) @property def hvac_modes(self): @@ -254,7 +233,11 @@ class TadoClimate(ClimateDevice): Need to be a subset of HVAC_MODES. """ - return self._supported_hvac_modes + if self._ac_device: + if self._ac_support_heat: + return SUPPORT_HVAC_HEAT_COOL + return SUPPORT_HVAC_COOL + return SUPPORT_HVAC_HEAT @property def hvac_action(self): @@ -262,28 +245,40 @@ class TadoClimate(ClimateDevice): Need to be one of CURRENT_HVAC_*. """ - return self._tado_zone_data.current_hvac_action + if not self._device_is_active: + return CURRENT_HVAC_OFF + if self._ac_device: + if self._active: + if self._ac_support_heat and not self._cooling: + return CURRENT_HVAC_HEAT + return CURRENT_HVAC_COOL + return CURRENT_HVAC_IDLE + if self._active: + return CURRENT_HVAC_HEAT + return CURRENT_HVAC_IDLE @property def fan_mode(self): """Return the fan setting.""" if self._ac_device: - return TADO_TO_HA_FAN_MODE_MAP.get(self._current_tado_fan_speed, FAN_AUTO) + return FAN_MAP_TADO.get(self._current_fan) return None @property def fan_modes(self): """List of available fan modes.""" - return self._supported_fan_modes + if self._ac_device: + return SUPPORT_FAN + return None def set_fan_mode(self, fan_mode: str): """Turn fan on/off.""" - self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) + pass @property def preset_mode(self): """Return the current preset mode (home, away).""" - if self._tado_zone_data.is_away: + if self._is_away: return PRESET_AWAY return PRESET_HOME @@ -294,10 +289,7 @@ class TadoClimate(ClimateDevice): def set_preset_mode(self, preset_mode): """Set new preset mode.""" - if preset_mode == PRESET_HOME: - self._tado.set_home() - else: - self._tado.set_away() + pass @property def temperature_unit(self): @@ -307,14 +299,12 @@ class TadoClimate(ClimateDevice): @property def target_temperature_step(self): """Return the supported step of target temperature.""" - if self._tado_zone_data.current_tado_hvac_mode == CONST_MODE_COOL: - return self._cool_step or self._heat_step - return self._heat_step or self._cool_step + return self._step @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._tado_zone_data.target_temp + return self._target_temp def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -322,142 +312,174 @@ class TadoClimate(ClimateDevice): if temperature is None: return - self._control_hvac(target_temp=temperature) + self._current_operation = self._default_overlay + self._overlay_mode = None + self._target_temp = temperature + self._control_heating() def set_hvac_mode(self, hvac_mode): """Set new target hvac mode.""" + mode = None - self._control_hvac(hvac_mode=HA_TO_TADO_HVAC_MODE_MAP[hvac_mode]) + if hvac_mode == HVAC_MODE_OFF: + mode = CONST_MODE_OFF + elif hvac_mode == HVAC_MODE_AUTO: + mode = CONST_MODE_SMART_SCHEDULE + elif hvac_mode == HVAC_MODE_HEAT: + mode = self._default_overlay + elif hvac_mode == HVAC_MODE_COOL: + mode = self._default_overlay + elif hvac_mode == HVAC_MODE_HEAT_COOL: + mode = self._default_overlay - @property - def available(self): - """Return if the device is available.""" - return self._tado_zone_data.available + self._current_operation = mode + self._overlay_mode = None + + # Set a target temperature if we don't have any + # This can happen when we switch from Off to On + if self._target_temp is None: + if self._ac_device: + self._target_temp = self.max_temp + else: + self._target_temp = self.min_temp + self.schedule_update_ha_state() + + self._control_heating() @property def min_temp(self): """Return the minimum temperature.""" - if ( - self._current_tado_hvac_mode == CONST_MODE_COOL - and self._cool_min_temp is not None - ): - return self._cool_min_temp - if self._heat_min_temp is not None: - return self._heat_min_temp - - return self._cool_min_temp + return self._min_temp @property def max_temp(self): """Return the maximum temperature.""" + return self._max_temp + + def update(self): + """Handle update callbacks.""" + _LOGGER.debug("Updating climate platform for zone %d", self.zone_id) + data = self._tado.data["zone"][self.zone_id] + + if "sensorDataPoints" in data: + sensor_data = data["sensorDataPoints"] + + if "insideTemperature" in sensor_data: + temperature = float(sensor_data["insideTemperature"]["celsius"]) + self._cur_temp = temperature + + if "humidity" in sensor_data: + humidity = float(sensor_data["humidity"]["percentage"]) + self._cur_humidity = humidity + + # temperature setting will not exist when device is off if ( - self._current_tado_hvac_mode == CONST_MODE_HEAT - and self._heat_max_temp is not None + "temperature" in data["setting"] + and data["setting"]["temperature"] is not None ): - return self._heat_max_temp - if self._heat_max_temp is not None: - return self._heat_max_temp + setting = float(data["setting"]["temperature"]["celsius"]) + self._target_temp = setting - return self._heat_max_temp + if "tadoMode" in data: + mode = data["tadoMode"] + self._is_away = mode == "AWAY" - @callback - def _async_update_zone_data(self): - """Load tado data into zone.""" - self._tado_zone_data = TadoZoneData( - self._tado.data["zone"][self.zone_id], self.zone_id - ) - - @callback - def _async_update_callback(self): - """Load tado data and update state.""" - self._async_update_zone_data() - self.async_write_ha_state() - - def _normalize_target_temp_for_hvac_mode(self): - # Set a target temperature if we don't have any - # This can happen when we switch from Off to On - if self._target_temp is None: - if self._current_tado_hvac_mode == CONST_MODE_COOL: - self._target_temp = self._cool_max_temp + if "setting" in data: + power = data["setting"]["power"] + if power == "OFF": + self._current_operation = CONST_MODE_OFF + self._current_fan = CONST_MODE_OFF + # There is no overlay, the mode will always be + # "SMART_SCHEDULE" + self._overlay_mode = CONST_MODE_SMART_SCHEDULE + self._device_is_active = False else: - self._target_temp = self._heat_min_temp - elif self._current_tado_hvac_mode == CONST_MODE_COOL: - if self._target_temp > self._cool_max_temp: - self._target_temp = self._cool_max_temp - elif self._target_temp < self._cool_min_temp: - self._target_temp = self._cool_min_temp - elif self._current_tado_hvac_mode == CONST_MODE_HEAT: - if self._target_temp > self._heat_max_temp: - self._target_temp = self._heat_max_temp - elif self._target_temp < self._heat_min_temp: - self._target_temp = self._heat_min_temp + self._device_is_active = True - def _control_hvac(self, hvac_mode=None, target_temp=None, fan_mode=None): + active = False + if "activityDataPoints" in data: + activity_data = data["activityDataPoints"] + if self._ac_device: + if "acPower" in activity_data and activity_data["acPower"] is not None: + if not activity_data["acPower"]["value"] == "OFF": + active = True + else: + if ( + "heatingPower" in activity_data + and activity_data["heatingPower"] is not None + ): + if float(activity_data["heatingPower"]["percentage"]) > 0.0: + active = True + self._active = active + + overlay = False + overlay_data = None + termination = CONST_MODE_SMART_SCHEDULE + cooling = False + fan_speed = CONST_MODE_OFF + + if "overlay" in data: + overlay_data = data["overlay"] + overlay = overlay_data is not None + + if overlay: + termination = overlay_data["termination"]["type"] + setting = False + setting_data = None + + if "setting" in overlay_data: + setting_data = overlay_data["setting"] + setting = setting_data is not None + + if setting: + if "mode" in setting_data: + cooling = setting_data["mode"] == "COOL" + + if "fanSpeed" in setting_data: + fan_speed = setting_data["fanSpeed"] + + if self._device_is_active: + # If you set mode manually to off, there will be an overlay + # and a termination, but we want to see the mode "OFF" + self._overlay_mode = termination + self._current_operation = termination + + self._cooling = cooling + self._current_fan = fan_speed + + def _control_heating(self): """Send new target temperature to Tado.""" - - if hvac_mode: - self._current_tado_hvac_mode = hvac_mode - - if target_temp: - self._target_temp = target_temp - - if fan_mode: - self._current_tado_fan_speed = fan_mode - - self._normalize_target_temp_for_hvac_mode() - - # tado does not permit setting the fan speed to - # off, you must turn off the device - if ( - self._current_tado_fan_speed == CONST_FAN_OFF - and self._current_tado_hvac_mode != CONST_MODE_OFF - ): - self._current_tado_fan_speed = CONST_FAN_AUTO - - if self._current_tado_hvac_mode == CONST_MODE_OFF: - _LOGGER.debug( - "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id - ) - self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type) - return - - if self._current_tado_hvac_mode == CONST_MODE_SMART_SCHEDULE: + if self._current_operation == CONST_MODE_SMART_SCHEDULE: _LOGGER.debug( "Switching to SMART_SCHEDULE for zone %s (%d)", self.zone_name, self.zone_id, ) self._tado.reset_zone_overlay(self.zone_id) + self._overlay_mode = self._current_operation + return + + if self._current_operation == CONST_MODE_OFF: + _LOGGER.debug( + "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id + ) + self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, self.zone_type) + self._overlay_mode = self._current_operation return _LOGGER.debug( "Switching to %s for zone %s (%d) with temperature %s °C", - self._current_tado_hvac_mode, + self._current_operation, self.zone_name, self.zone_id, self._target_temp, ) - - # Fallback to Smart Schedule at next Schedule switch if we have fallback enabled - overlay_mode = ( - CONST_OVERLAY_TADO_MODE if self._tado.fallback else CONST_OVERLAY_MANUAL - ) - - temperature_to_send = self._target_temp - if self._current_tado_hvac_mode in TADO_MODES_WITH_NO_TEMP_SETTING: - # A temperature cannot be passed with these modes - temperature_to_send = None - self._tado.set_zone_overlay( - zone_id=self.zone_id, - overlay_mode=overlay_mode, # What to do when the period ends - temperature=temperature_to_send, - duration=None, - device_type=self.zone_type, - mode=self._current_tado_hvac_mode, - fan_speed=( - self._current_tado_fan_speed - if (self._support_flags & SUPPORT_FAN_MODE) - else None - ), # api defaults to not sending fanSpeed if not specified + self.zone_id, + self._current_operation, + self._target_temp, + None, + self.zone_type, + "COOL" if self._ac_device else None, ) + self._overlay_mode = self._current_operation diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index a2630a8f9c2..8d67e3bf9f8 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -1,26 +1,5 @@ """Constant values for the Tado component.""" -from homeassistant.components.climate.const import ( - CURRENT_HVAC_COOL, - CURRENT_HVAC_DRY, - CURRENT_HVAC_FAN, - CURRENT_HVAC_HEAT, - FAN_AUTO, - FAN_HIGH, - FAN_LOW, - FAN_MEDIUM, - FAN_OFF, - HVAC_MODE_AUTO, - HVAC_MODE_COOL, - HVAC_MODE_DRY, - HVAC_MODE_FAN_ONLY, - HVAC_MODE_HEAT, - HVAC_MODE_HEAT_COOL, - HVAC_MODE_OFF, - PRESET_AWAY, - PRESET_HOME, -) - # Configuration CONF_FALLBACK = "fallback" DATA = "data" @@ -31,81 +10,10 @@ TYPE_HEATING = "HEATING" TYPE_HOT_WATER = "HOT_WATER" # Base modes -CONST_MODE_OFF = "OFF" CONST_MODE_SMART_SCHEDULE = "SMART_SCHEDULE" # Use the schedule -CONST_MODE_AUTO = "AUTO" -CONST_MODE_COOL = "COOL" -CONST_MODE_HEAT = "HEAT" -CONST_MODE_DRY = "DRY" -CONST_MODE_FAN = "FAN" - -CONST_LINK_OFFLINE = "OFFLINE" - -CONST_FAN_OFF = "OFF" -CONST_FAN_AUTO = "AUTO" -CONST_FAN_LOW = "LOW" -CONST_FAN_MIDDLE = "MIDDLE" -CONST_FAN_HIGH = "HIGH" - +CONST_MODE_OFF = "OFF" # Switch off heating in a zone # When we change the temperature setting, we need an overlay mode CONST_OVERLAY_TADO_MODE = "TADO_MODE" # wait until tado changes the mode automatic CONST_OVERLAY_MANUAL = "MANUAL" # the user has change the temperature or mode manually CONST_OVERLAY_TIMER = "TIMER" # the temperature will be reset after a timespan - - -# Heat always comes first since we get the -# min and max tempatures for the zone from -# it. -# Heat is preferred as it generally has a lower minimum temperature -ORDERED_KNOWN_TADO_MODES = [ - CONST_MODE_HEAT, - CONST_MODE_COOL, - CONST_MODE_AUTO, - CONST_MODE_DRY, - CONST_MODE_FAN, -] - -TADO_MODES_TO_HA_CURRENT_HVAC_ACTION = { - CONST_MODE_HEAT: CURRENT_HVAC_HEAT, - CONST_MODE_DRY: CURRENT_HVAC_DRY, - CONST_MODE_FAN: CURRENT_HVAC_FAN, - CONST_MODE_COOL: CURRENT_HVAC_COOL, -} - -# These modes will not allow a temp to be set -TADO_MODES_WITH_NO_TEMP_SETTING = [CONST_MODE_AUTO, CONST_MODE_DRY, CONST_MODE_FAN] -# -# HVAC_MODE_HEAT_COOL is mapped to CONST_MODE_AUTO -# This lets tado decide on a temp -# -# HVAC_MODE_AUTO is mapped to CONST_MODE_SMART_SCHEDULE -# This runs the smart schedule -# -HA_TO_TADO_HVAC_MODE_MAP = { - HVAC_MODE_OFF: CONST_MODE_OFF, - HVAC_MODE_HEAT_COOL: CONST_MODE_AUTO, - HVAC_MODE_AUTO: CONST_MODE_SMART_SCHEDULE, - HVAC_MODE_HEAT: CONST_MODE_HEAT, - HVAC_MODE_COOL: CONST_MODE_COOL, - HVAC_MODE_DRY: CONST_MODE_DRY, - HVAC_MODE_FAN_ONLY: CONST_MODE_FAN, -} - -HA_TO_TADO_FAN_MODE_MAP = { - FAN_AUTO: CONST_FAN_AUTO, - FAN_OFF: CONST_FAN_OFF, - FAN_LOW: CONST_FAN_LOW, - FAN_MEDIUM: CONST_FAN_MIDDLE, - FAN_HIGH: CONST_FAN_HIGH, -} - -TADO_TO_HA_HVAC_MODE_MAP = { - value: key for key, value in HA_TO_TADO_HVAC_MODE_MAP.items() -} - -TADO_TO_HA_FAN_MODE_MAP = {value: key for key, value in HA_TO_TADO_FAN_MODE_MAP.items()} - -DEFAULT_TADO_PRECISION = 0.1 - -SUPPORT_PRESET = [PRESET_AWAY, PRESET_HOME] diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 2589388a4da..e51cc53caa5 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -3,10 +3,10 @@ "name": "Tado", "documentation": "https://www.home-assistant.io/integrations/tado", "requirements": [ - "python-tado==0.4.0" + "python-tado==0.3.0" ], "dependencies": [], "codeowners": [ - "@michaelarnauts", "@bdraco" + "@michaelarnauts" ] } diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index 70014380512..2cd40bee3fa 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -8,7 +8,6 @@ from homeassistant.helpers.entity import Entity from . import DATA, DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED from .const import TYPE_AIR_CONDITIONING, TYPE_HEATING, TYPE_HOT_WATER -from .tado_adapter import TadoZoneData _LOGGER = logging.getLogger(__name__) @@ -51,7 +50,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for zone in tado.zones: entities.extend( [ - create_zone_sensor(hass, tado, zone["name"], zone["id"], variable) + create_zone_sensor(tado, zone["name"], zone["id"], variable) for variable in ZONE_SENSORS.get(zone["type"]) ] ) @@ -60,7 +59,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for home in tado.devices: entities.extend( [ - create_device_sensor(hass, tado, home["name"], home["id"], variable) + create_device_sensor(tado, home["name"], home["id"], variable) for variable in DEVICE_SENSORS ] ) @@ -68,22 +67,21 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(entities, True) -def create_zone_sensor(hass, tado, name, zone_id, variable): +def create_zone_sensor(tado, name, zone_id, variable): """Create a zone sensor.""" - return TadoSensor(hass, tado, name, "zone", zone_id, variable) + return TadoSensor(tado, name, "zone", zone_id, variable) -def create_device_sensor(hass, tado, name, device_id, variable): +def create_device_sensor(tado, name, device_id, variable): """Create a device sensor.""" - return TadoSensor(hass, tado, name, "device", device_id, variable) + return TadoSensor(tado, name, "device", device_id, variable) class TadoSensor(Entity): """Representation of a tado Sensor.""" - def __init__(self, hass, tado, zone_name, sensor_type, zone_id, zone_variable): + def __init__(self, tado, zone_name, sensor_type, zone_id, zone_variable): """Initialize of the Tado Sensor.""" - self.hass = hass self._tado = tado self.zone_name = zone_name @@ -95,16 +93,19 @@ class TadoSensor(Entity): self._state = None self._state_attributes = None - self._tado_zone_data = None - self._async_update_zone_data() async def async_added_to_hass(self): """Register for sensor updates.""" + @callback + def async_update_callback(): + """Schedule an entity update.""" + self.async_schedule_update_ha_state(True) + async_dispatcher_connect( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format(self.sensor_type, self.zone_id), - self._async_update_callback, + async_update_callback, ) @property @@ -148,74 +149,97 @@ class TadoSensor(Entity): return "mdi:water-percent" @property - def should_poll(self): + def should_poll(self) -> bool: """Do not poll.""" return False - @callback - def _async_update_callback(self): - """Update and write state.""" - self._async_update_zone_data() - self.async_write_ha_state() - - @callback - def _async_update_zone_data(self): + def update(self): """Handle update callbacks.""" try: data = self._tado.data[self.sensor_type][self.zone_id] except KeyError: return - self._tado_zone_data = TadoZoneData(data, self.zone_id) + unit = TEMP_CELSIUS if self.zone_variable == "temperature": - self._state = self.hass.config.units.temperature( - self._tado_zone_data.current_temp, TEMP_CELSIUS - ) - self._state_attributes = { - "time": self._tado_zone_data.current_temp_timestamp, - "setting": 0, # setting is used in climate device - } + if "sensorDataPoints" in data: + sensor_data = data["sensorDataPoints"] + temperature = float(sensor_data["insideTemperature"]["celsius"]) + + self._state = self.hass.config.units.temperature(temperature, unit) + self._state_attributes = { + "time": sensor_data["insideTemperature"]["timestamp"], + "setting": 0, # setting is used in climate device + } + + # temperature setting will not exist when device is off + if ( + "temperature" in data["setting"] + and data["setting"]["temperature"] is not None + ): + temperature = float(data["setting"]["temperature"]["celsius"]) + + self._state_attributes[ + "setting" + ] = self.hass.config.units.temperature(temperature, unit) elif self.zone_variable == "humidity": - self._state = self._tado_zone_data.current_humidity - self._state_attributes = { - "time": self._tado_zone_data.current_humidity_timestamp - } + if "sensorDataPoints" in data: + sensor_data = data["sensorDataPoints"] + self._state = float(sensor_data["humidity"]["percentage"]) + self._state_attributes = {"time": sensor_data["humidity"]["timestamp"]} elif self.zone_variable == "power": - self._state = self._tado_zone_data.power + if "setting" in data: + self._state = data["setting"]["power"] elif self.zone_variable == "link": - self._state = self._tado_zone_data.link + if "link" in data: + self._state = data["link"]["state"] elif self.zone_variable == "heating": - self._state = self._tado_zone_data.heating_power_percentage - self._state_attributes = { - "time": self._tado_zone_data.heating_power_timestamp - } + if "activityDataPoints" in data: + activity_data = data["activityDataPoints"] + + if ( + "heatingPower" in activity_data + and activity_data["heatingPower"] is not None + ): + self._state = float(activity_data["heatingPower"]["percentage"]) + self._state_attributes = { + "time": activity_data["heatingPower"]["timestamp"] + } elif self.zone_variable == "ac": - self._state = self._tado_zone_data.ac_power - self._state_attributes = {"time": self._tado_zone_data.ac_power_timestamp} + if "activityDataPoints" in data: + activity_data = data["activityDataPoints"] + + if "acPower" in activity_data and activity_data["acPower"] is not None: + self._state = activity_data["acPower"]["value"] + self._state_attributes = { + "time": activity_data["acPower"]["timestamp"] + } elif self.zone_variable == "tado bridge status": - self._state = self._tado_zone_data.connection + if "connectionState" in data: + self._state = data["connectionState"]["value"] elif self.zone_variable == "tado mode": - self._state = self._tado_zone_data.tado_mode + if "tadoMode" in data: + self._state = data["tadoMode"] elif self.zone_variable == "overlay": - self._state = self._tado_zone_data.overlay_active + self._state = "overlay" in data and data["overlay"] is not None self._state_attributes = ( - {"termination": self._tado_zone_data.overlay_termination_type} - if self._tado_zone_data.overlay_active + {"termination": data["overlay"]["termination"]["type"]} + if self._state else {} ) elif self.zone_variable == "early start": - self._state = self._tado_zone_data.preparation is not None + self._state = "preparation" in data and data["preparation"] is not None elif self.zone_variable == "open window": - self._state = self._tado_zone_data.open_window is not None - self._state_attributes = self._tado_zone_data.open_window_attr + self._state = "openWindow" in data and data["openWindow"] is not None + self._state_attributes = data["openWindow"] if self._state else {} diff --git a/homeassistant/components/tado/tado_adapter.py b/homeassistant/components/tado/tado_adapter.py deleted file mode 100644 index 211ff9baf84..00000000000 --- a/homeassistant/components/tado/tado_adapter.py +++ /dev/null @@ -1,285 +0,0 @@ -"""Adapter to represent a tado zones and state.""" -import logging - -from homeassistant.components.climate.const import ( - CURRENT_HVAC_COOL, - CURRENT_HVAC_HEAT, - CURRENT_HVAC_IDLE, - CURRENT_HVAC_OFF, -) - -from .const import ( - CONST_FAN_AUTO, - CONST_FAN_OFF, - CONST_LINK_OFFLINE, - CONST_MODE_OFF, - CONST_MODE_SMART_SCHEDULE, - DEFAULT_TADO_PRECISION, - TADO_MODES_TO_HA_CURRENT_HVAC_ACTION, -) - -_LOGGER = logging.getLogger(__name__) - - -class TadoZoneData: - """Represent a tado zone.""" - - def __init__(self, data, zone_id): - """Create a tado zone.""" - self._data = data - self._zone_id = zone_id - self._current_temp = None - self._connection = None - self._current_temp_timestamp = None - self._current_humidity = None - self._is_away = False - self._current_hvac_action = None - self._current_tado_fan_speed = None - self._current_tado_hvac_mode = None - self._target_temp = None - self._available = False - self._power = None - self._link = None - self._ac_power_timestamp = None - self._heating_power_timestamp = None - self._ac_power = None - self._heating_power = None - self._heating_power_percentage = None - self._tado_mode = None - self._overlay_active = None - self._overlay_termination_type = None - self._preparation = None - self._open_window = None - self._open_window_attr = None - self._precision = DEFAULT_TADO_PRECISION - self.update_data(data) - - @property - def preparation(self): - """Zone is preparing to heat.""" - return self._preparation - - @property - def open_window(self): - """Window is open.""" - return self._open_window - - @property - def open_window_attr(self): - """Window open attributes.""" - return self._open_window_attr - - @property - def current_temp(self): - """Temperature of the zone.""" - return self._current_temp - - @property - def current_temp_timestamp(self): - """Temperature of the zone timestamp.""" - return self._current_temp_timestamp - - @property - def connection(self): - """Up or down internet connection.""" - return self._connection - - @property - def tado_mode(self): - """Tado mode.""" - return self._tado_mode - - @property - def overlay_active(self): - """Overlay acitive.""" - return self._current_tado_hvac_mode != CONST_MODE_SMART_SCHEDULE - - @property - def overlay_termination_type(self): - """Overlay termination type (what happens when period ends).""" - return self._overlay_termination_type - - @property - def current_humidity(self): - """Humidity of the zone.""" - return self._current_humidity - - @property - def current_humidity_timestamp(self): - """Humidity of the zone timestamp.""" - return self._current_humidity_timestamp - - @property - def ac_power_timestamp(self): - """AC power timestamp.""" - return self._ac_power_timestamp - - @property - def heating_power_timestamp(self): - """Heating power timestamp.""" - return self._heating_power_timestamp - - @property - def ac_power(self): - """AC power.""" - return self._ac_power - - @property - def heating_power(self): - """Heating power.""" - return self._heating_power - - @property - def heating_power_percentage(self): - """Heating power percentage.""" - return self._heating_power_percentage - - @property - def is_away(self): - """Is Away (not home).""" - return self._is_away - - @property - def power(self): - """Power is on.""" - return self._power - - @property - def current_hvac_action(self): - """HVAC Action (home assistant const).""" - return self._current_hvac_action - - @property - def current_tado_fan_speed(self): - """TADO Fan speed (tado const).""" - return self._current_tado_fan_speed - - @property - def link(self): - """Link (internet connection state).""" - return self._link - - @property - def precision(self): - """Precision of temp units.""" - return self._precision - - @property - def current_tado_hvac_mode(self): - """TADO HVAC Mode (tado const).""" - return self._current_tado_hvac_mode - - @property - def target_temp(self): - """Target temperature (C).""" - return self._target_temp - - @property - def available(self): - """Device is available and link is up.""" - return self._available - - def update_data(self, data): - """Handle update callbacks.""" - _LOGGER.debug("Updating climate platform for zone %d", self._zone_id) - if "sensorDataPoints" in data: - sensor_data = data["sensorDataPoints"] - - if "insideTemperature" in sensor_data: - temperature = float(sensor_data["insideTemperature"]["celsius"]) - self._current_temp = temperature - self._current_temp_timestamp = sensor_data["insideTemperature"][ - "timestamp" - ] - if "precision" in sensor_data["insideTemperature"]: - self._precision = sensor_data["insideTemperature"]["precision"][ - "celsius" - ] - - if "humidity" in sensor_data: - humidity = float(sensor_data["humidity"]["percentage"]) - self._current_humidity = humidity - self._current_humidity_timestamp = sensor_data["humidity"]["timestamp"] - - self._is_away = None - self._tado_mode = None - if "tadoMode" in data: - self._is_away = data["tadoMode"] == "AWAY" - self._tado_mode = data["tadoMode"] - - self._link = None - if "link" in data: - self._link = data["link"]["state"] - - self._current_hvac_action = CURRENT_HVAC_OFF - - if "setting" in data: - # temperature setting will not exist when device is off - if ( - "temperature" in data["setting"] - and data["setting"]["temperature"] is not None - ): - setting = float(data["setting"]["temperature"]["celsius"]) - self._target_temp = setting - - setting = data["setting"] - - self._current_tado_fan_speed = CONST_FAN_OFF - # If there is no overlay, the mode will always be - # "SMART_SCHEDULE" - if "mode" in setting: - self._current_tado_hvac_mode = setting["mode"] - else: - self._current_tado_hvac_mode = CONST_MODE_OFF - - self._power = setting["power"] - if self._power == "ON": - # Not all devices have fans - self._current_tado_fan_speed = setting.get("fanSpeed", CONST_FAN_AUTO) - self._current_hvac_action = CURRENT_HVAC_IDLE - - self._preparation = "preparation" in data and data["preparation"] is not None - self._open_window = "openWindow" in data and data["openWindow"] is not None - self._open_window_attr = data["openWindow"] if self._open_window else {} - - if "activityDataPoints" in data: - activity_data = data["activityDataPoints"] - if "acPower" in activity_data and activity_data["acPower"] is not None: - self._ac_power = activity_data["acPower"]["value"] - self._ac_power_timestamp = activity_data["acPower"]["timestamp"] - if activity_data["acPower"]["value"] == "ON" and self._power == "ON": - # acPower means the unit has power so we need to map the mode - self._current_hvac_action = TADO_MODES_TO_HA_CURRENT_HVAC_ACTION.get( - self._current_tado_hvac_mode, CURRENT_HVAC_COOL - ) - if ( - "heatingPower" in activity_data - and activity_data["heatingPower"] is not None - ): - self._heating_power = activity_data["heatingPower"].get("value", None) - self._heating_power_timestamp = activity_data["heatingPower"][ - "timestamp" - ] - self._heating_power_percentage = float( - activity_data["heatingPower"].get("percentage", 0) - ) - - if self._heating_power_percentage > 0.0 and self._power == "ON": - self._current_hvac_action = CURRENT_HVAC_HEAT - - # If there is no overlay - # then we are running the smart schedule - self._overlay_termination_type = None - if "overlay" in data and data["overlay"] is not None: - if ( - "termination" in data["overlay"] - and "type" in data["overlay"]["termination"] - ): - self._overlay_termination_type = data["overlay"]["termination"]["type"] - else: - self._current_tado_hvac_mode = CONST_MODE_SMART_SCHEDULE - - self._connection = ( - data["connectionState"]["value"] if "connectionState" in data else None - ) - self._available = self._link != CONST_LINK_OFFLINE diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index 52c085d8ec3..fc3a9ce9cf4 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -12,7 +12,6 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import DOMAIN, SIGNAL_TADO_UPDATE_RECEIVED from .const import ( - CONST_MODE_HEAT, CONST_MODE_OFF, CONST_MODE_SMART_SCHEDULE, CONST_OVERLAY_MANUAL, @@ -52,16 +51,14 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for tado in api_list: for zone in tado.zones: if zone["type"] in [TYPE_HOT_WATER]: - entity = create_water_heater_entity( - hass, tado, zone["name"], zone["id"] - ) + entity = create_water_heater_entity(tado, zone["name"], zone["id"]) entities.append(entity) if entities: add_entities(entities, True) -def create_water_heater_entity(hass, tado, name: str, zone_id: int): +def create_water_heater_entity(tado, name: str, zone_id: int): """Create a Tado water heater device.""" capabilities = tado.get_capabilities(zone_id) supports_temperature_control = capabilities["canSetTemperature"] @@ -75,7 +72,7 @@ def create_water_heater_entity(hass, tado, name: str, zone_id: int): max_temp = None entity = TadoWaterHeater( - hass, tado, name, zone_id, supports_temperature_control, min_temp, max_temp + tado, name, zone_id, supports_temperature_control, min_temp, max_temp ) return entity @@ -86,7 +83,6 @@ class TadoWaterHeater(WaterHeaterDevice): def __init__( self, - hass, tado, zone_name, zone_id, @@ -95,7 +91,6 @@ class TadoWaterHeater(WaterHeaterDevice): max_temp, ): """Initialize of Tado water heater entity.""" - self.hass = hass self._tado = tado self.zone_name = zone_name @@ -115,17 +110,28 @@ class TadoWaterHeater(WaterHeaterDevice): if self._supports_temperature_control: self._supported_features |= SUPPORT_TARGET_TEMPERATURE - self._current_tado_heat_mode = CONST_MODE_SMART_SCHEDULE + if tado.fallback: + # Fallback to Smart Schedule at next Schedule switch + self._default_overlay = CONST_OVERLAY_TADO_MODE + else: + # Don't fallback to Smart Schedule, but keep in manual mode + self._default_overlay = CONST_OVERLAY_MANUAL + + self._current_operation = CONST_MODE_SMART_SCHEDULE self._overlay_mode = CONST_MODE_SMART_SCHEDULE - self._async_update_data() async def async_added_to_hass(self): """Register for sensor updates.""" + @callback + def async_update_callback(): + """Schedule an entity update.""" + self.async_schedule_update_ha_state(True) + async_dispatcher_connect( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format("zone", self.zone_id), - self._async_update_callback, + async_update_callback, ) @property @@ -151,7 +157,7 @@ class TadoWaterHeater(WaterHeaterDevice): @property def current_operation(self): """Return current readable operation mode.""" - return WATER_HEATER_MAP_TADO.get(self._current_tado_heat_mode) + return WATER_HEATER_MAP_TADO.get(self._current_operation) @property def target_temperature(self): @@ -192,9 +198,16 @@ class TadoWaterHeater(WaterHeaterDevice): elif operation_mode == MODE_AUTO: mode = CONST_MODE_SMART_SCHEDULE elif operation_mode == MODE_HEAT: - mode = CONST_MODE_HEAT + mode = self._default_overlay - self._control_heater(heat_mode=mode) + self._current_operation = mode + self._overlay_mode = None + + # Set a target temperature if we don't have any + if mode == CONST_OVERLAY_TADO_MODE and self._target_temp is None: + self._target_temp = self.min_temp + + self._control_heater() def set_temperature(self, **kwargs): """Set new target temperature.""" @@ -202,17 +215,13 @@ class TadoWaterHeater(WaterHeaterDevice): if not self._supports_temperature_control or temperature is None: return - self._control_heater(target_temp=temperature) + self._current_operation = self._default_overlay + self._overlay_mode = None + self._target_temp = temperature + self._control_heater() - @callback - def _async_update_callback(self): - """Load tado data and update state.""" - self._async_update_data() - self.async_write_ha_state() - - @callback - def _async_update_data(self): - """Load tado data.""" + def update(self): + """Handle update callbacks.""" _LOGGER.debug("Updating water_heater platform for zone %d", self.zone_id) data = self._tado.data["zone"][self.zone_id] @@ -223,70 +232,71 @@ class TadoWaterHeater(WaterHeaterDevice): if "setting" in data: power = data["setting"]["power"] if power == "OFF": - self._current_tado_heat_mode = CONST_MODE_OFF + self._current_operation = CONST_MODE_OFF + # There is no overlay, the mode will always be + # "SMART_SCHEDULE" + self._overlay_mode = CONST_MODE_SMART_SCHEDULE + self._device_is_active = False else: - self._current_tado_heat_mode = CONST_MODE_HEAT + self._device_is_active = True # temperature setting will not exist when device is off if ( "temperature" in data["setting"] and data["setting"]["temperature"] is not None ): - self._target_temp = float(data["setting"]["temperature"]["celsius"]) + setting = float(data["setting"]["temperature"]["celsius"]) + self._target_temp = setting - # If there is no overlay - # then we are running the smart schedule - if "overlay" in data and data["overlay"] is None: - self._current_tado_heat_mode = CONST_MODE_SMART_SCHEDULE + overlay = False + overlay_data = None + termination = CONST_MODE_SMART_SCHEDULE - self.async_write_ha_state() + if "overlay" in data: + overlay_data = data["overlay"] + overlay = overlay_data is not None - def _control_heater(self, heat_mode=None, target_temp=None): + if overlay: + termination = overlay_data["termination"]["type"] + + if self._device_is_active: + # If you set mode manually to off, there will be an overlay + # and a termination, but we want to see the mode "OFF" + self._overlay_mode = termination + self._current_operation = termination + + def _control_heater(self): """Send new target temperature.""" - - if heat_mode: - self._current_tado_heat_mode = heat_mode - - if target_temp: - self._target_temp = target_temp - - # Set a target temperature if we don't have any - if self._target_temp is None: - self._target_temp = self.min_temp - - if self._current_tado_heat_mode == CONST_MODE_SMART_SCHEDULE: + if self._current_operation == CONST_MODE_SMART_SCHEDULE: _LOGGER.debug( "Switching to SMART_SCHEDULE for zone %s (%d)", self.zone_name, self.zone_id, ) self._tado.reset_zone_overlay(self.zone_id) + self._overlay_mode = self._current_operation return - if self._current_tado_heat_mode == CONST_MODE_OFF: + if self._current_operation == CONST_MODE_OFF: _LOGGER.debug( "Switching to OFF for zone %s (%d)", self.zone_name, self.zone_id ) self._tado.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL, TYPE_HOT_WATER) + self._overlay_mode = self._current_operation return - # Fallback to Smart Schedule at next Schedule switch if we have fallback enabled - overlay_mode = ( - CONST_OVERLAY_TADO_MODE if self._tado.fallback else CONST_OVERLAY_MANUAL - ) - _LOGGER.debug( "Switching to %s for zone %s (%d) with temperature %s", - self._current_tado_heat_mode, + self._current_operation, self.zone_name, self.zone_id, self._target_temp, ) self._tado.set_zone_overlay( - zone_id=self.zone_id, - overlay_mode=overlay_mode, - temperature=self._target_temp, - duration=None, - device_type=TYPE_HOT_WATER, + self.zone_id, + self._current_operation, + self._target_temp, + None, + TYPE_HOT_WATER, ) - self._overlay_mode = self._current_tado_heat_mode + self._overlay_mode = self._current_operation diff --git a/requirements_all.txt b/requirements_all.txt index 5a8199f2c14..acfebe8c4d0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1650,7 +1650,7 @@ python-songpal==0.11.2 python-synology==0.4.0 # homeassistant.components.tado -python-tado==0.4.0 +python-tado==0.3.0 # homeassistant.components.telegram_bot python-telegram-bot==11.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 54732560fe9..54ecf61d031 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -580,9 +580,6 @@ python-miio==0.4.8 # homeassistant.components.nest python-nest==4.1.0 -# homeassistant.components.tado -python-tado==0.4.0 - # homeassistant.components.twitch python-twitch-client==0.6.0 diff --git a/tests/components/tado/mocks.py b/tests/components/tado/mocks.py deleted file mode 100644 index 149bcbc24c6..00000000000 --- a/tests/components/tado/mocks.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Mocks for the tado component.""" -import json -import os - -from homeassistant.components.tado.tado_adapter import TadoZoneData - -from tests.common import load_fixture - - -async def _mock_tado_climate_zone_from_fixture(hass, file): - return TadoZoneData(await _load_json_fixture(hass, file), 1) - - -async def _load_json_fixture(hass, path): - fixture = await hass.async_add_executor_job( - load_fixture, os.path.join("tado", path) - ) - return json.loads(fixture) diff --git a/tests/components/tado/test_tado_adapter.py b/tests/components/tado/test_tado_adapter.py deleted file mode 100644 index ccbf2291b7f..00000000000 --- a/tests/components/tado/test_tado_adapter.py +++ /dev/null @@ -1,423 +0,0 @@ -"""The tado_adapter tests for the tado platform.""" - - -from tests.components.tado.mocks import _mock_tado_climate_zone_from_fixture - - -async def test_ac_issue_32294_heat_mode(hass): - """Test smart ac cool mode.""" - ac_issue_32294_heat_mode = await _mock_tado_climate_zone_from_fixture( - hass, "ac_issue_32294.heat_mode.json" - ) - assert ac_issue_32294_heat_mode.preparation is False - assert ac_issue_32294_heat_mode.open_window is False - assert ac_issue_32294_heat_mode.open_window_attr == {} - assert ac_issue_32294_heat_mode.current_temp == 21.82 - assert ac_issue_32294_heat_mode.current_temp_timestamp == "2020-02-29T22:51:05.016Z" - assert ac_issue_32294_heat_mode.connection is None - assert ac_issue_32294_heat_mode.tado_mode == "HOME" - assert ac_issue_32294_heat_mode.overlay_active is False - assert ac_issue_32294_heat_mode.overlay_termination_type is None - assert ac_issue_32294_heat_mode.current_humidity == 40.4 - assert ( - ac_issue_32294_heat_mode.current_humidity_timestamp - == "2020-02-29T22:51:05.016Z" - ) - assert ac_issue_32294_heat_mode.ac_power_timestamp == "2020-02-29T22:50:34.850Z" - assert ac_issue_32294_heat_mode.heating_power_timestamp is None - assert ac_issue_32294_heat_mode.ac_power == "ON" - assert ac_issue_32294_heat_mode.heating_power is None - assert ac_issue_32294_heat_mode.heating_power_percentage is None - assert ac_issue_32294_heat_mode.is_away is False - assert ac_issue_32294_heat_mode.power == "ON" - assert ac_issue_32294_heat_mode.current_hvac_action == "heating" - assert ac_issue_32294_heat_mode.current_tado_fan_speed == "AUTO" - assert ac_issue_32294_heat_mode.link == "ONLINE" - assert ac_issue_32294_heat_mode.current_tado_hvac_mode == "SMART_SCHEDULE" - assert ac_issue_32294_heat_mode.target_temp == 25.0 - assert ac_issue_32294_heat_mode.available is True - assert ac_issue_32294_heat_mode.precision == 0.1 - - -async def test_smartac3_smart_mode(hass): - """Test smart ac smart mode.""" - smartac3_smart_mode = await _mock_tado_climate_zone_from_fixture( - hass, "smartac3.smart_mode.json" - ) - assert smartac3_smart_mode.preparation is False - assert smartac3_smart_mode.open_window is False - assert smartac3_smart_mode.open_window_attr == {} - assert smartac3_smart_mode.current_temp == 24.43 - assert smartac3_smart_mode.current_temp_timestamp == "2020-03-05T03:50:24.769Z" - assert smartac3_smart_mode.connection is None - assert smartac3_smart_mode.tado_mode == "HOME" - assert smartac3_smart_mode.overlay_active is False - assert smartac3_smart_mode.overlay_termination_type is None - assert smartac3_smart_mode.current_humidity == 60.0 - assert smartac3_smart_mode.current_humidity_timestamp == "2020-03-05T03:50:24.769Z" - assert smartac3_smart_mode.ac_power_timestamp == "2020-03-05T03:52:22.253Z" - assert smartac3_smart_mode.heating_power_timestamp is None - assert smartac3_smart_mode.ac_power == "OFF" - assert smartac3_smart_mode.heating_power is None - assert smartac3_smart_mode.heating_power_percentage is None - assert smartac3_smart_mode.is_away is False - assert smartac3_smart_mode.power == "ON" - assert smartac3_smart_mode.current_hvac_action == "idle" - assert smartac3_smart_mode.current_tado_fan_speed == "MIDDLE" - assert smartac3_smart_mode.link == "ONLINE" - assert smartac3_smart_mode.current_tado_hvac_mode == "SMART_SCHEDULE" - assert smartac3_smart_mode.target_temp == 20.0 - assert smartac3_smart_mode.available is True - assert smartac3_smart_mode.precision == 0.1 - - -async def test_smartac3_cool_mode(hass): - """Test smart ac cool mode.""" - smartac3_cool_mode = await _mock_tado_climate_zone_from_fixture( - hass, "smartac3.cool_mode.json" - ) - assert smartac3_cool_mode.preparation is False - assert smartac3_cool_mode.open_window is False - assert smartac3_cool_mode.open_window_attr == {} - assert smartac3_cool_mode.current_temp == 24.76 - assert smartac3_cool_mode.current_temp_timestamp == "2020-03-05T03:57:38.850Z" - assert smartac3_cool_mode.connection is None - assert smartac3_cool_mode.tado_mode == "HOME" - assert smartac3_cool_mode.overlay_active is True - assert smartac3_cool_mode.overlay_termination_type == "TADO_MODE" - assert smartac3_cool_mode.current_humidity == 60.9 - assert smartac3_cool_mode.current_humidity_timestamp == "2020-03-05T03:57:38.850Z" - assert smartac3_cool_mode.ac_power_timestamp == "2020-03-05T04:01:07.162Z" - assert smartac3_cool_mode.heating_power_timestamp is None - assert smartac3_cool_mode.ac_power == "ON" - assert smartac3_cool_mode.heating_power is None - assert smartac3_cool_mode.heating_power_percentage is None - assert smartac3_cool_mode.is_away is False - assert smartac3_cool_mode.power == "ON" - assert smartac3_cool_mode.current_hvac_action == "cooling" - assert smartac3_cool_mode.current_tado_fan_speed == "AUTO" - assert smartac3_cool_mode.link == "ONLINE" - assert smartac3_cool_mode.current_tado_hvac_mode == "COOL" - assert smartac3_cool_mode.target_temp == 17.78 - assert smartac3_cool_mode.available is True - assert smartac3_cool_mode.precision == 0.1 - - -async def test_smartac3_auto_mode(hass): - """Test smart ac cool mode.""" - smartac3_auto_mode = await _mock_tado_climate_zone_from_fixture( - hass, "smartac3.auto_mode.json" - ) - assert smartac3_auto_mode.preparation is False - assert smartac3_auto_mode.open_window is False - assert smartac3_auto_mode.open_window_attr == {} - assert smartac3_auto_mode.current_temp == 24.8 - assert smartac3_auto_mode.current_temp_timestamp == "2020-03-05T03:55:38.160Z" - assert smartac3_auto_mode.connection is None - assert smartac3_auto_mode.tado_mode == "HOME" - assert smartac3_auto_mode.overlay_active is True - assert smartac3_auto_mode.overlay_termination_type == "TADO_MODE" - assert smartac3_auto_mode.current_humidity == 62.5 - assert smartac3_auto_mode.current_humidity_timestamp == "2020-03-05T03:55:38.160Z" - assert smartac3_auto_mode.ac_power_timestamp == "2020-03-05T03:56:38.627Z" - assert smartac3_auto_mode.heating_power_timestamp is None - assert smartac3_auto_mode.ac_power == "ON" - assert smartac3_auto_mode.heating_power is None - assert smartac3_auto_mode.heating_power_percentage is None - assert smartac3_auto_mode.is_away is False - assert smartac3_auto_mode.power == "ON" - assert smartac3_auto_mode.current_hvac_action == "cooling" - assert smartac3_auto_mode.current_tado_fan_speed == "AUTO" - assert smartac3_auto_mode.link == "ONLINE" - assert smartac3_auto_mode.current_tado_hvac_mode == "AUTO" - assert smartac3_auto_mode.target_temp is None - assert smartac3_auto_mode.available is True - assert smartac3_auto_mode.precision == 0.1 - - -async def test_smartac3_dry_mode(hass): - """Test smart ac cool mode.""" - smartac3_dry_mode = await _mock_tado_climate_zone_from_fixture( - hass, "smartac3.dry_mode.json" - ) - assert smartac3_dry_mode.preparation is False - assert smartac3_dry_mode.open_window is False - assert smartac3_dry_mode.open_window_attr == {} - assert smartac3_dry_mode.current_temp == 25.01 - assert smartac3_dry_mode.current_temp_timestamp == "2020-03-05T04:02:07.396Z" - assert smartac3_dry_mode.connection is None - assert smartac3_dry_mode.tado_mode == "HOME" - assert smartac3_dry_mode.overlay_active is True - assert smartac3_dry_mode.overlay_termination_type == "TADO_MODE" - assert smartac3_dry_mode.current_humidity == 62.0 - assert smartac3_dry_mode.current_humidity_timestamp == "2020-03-05T04:02:07.396Z" - assert smartac3_dry_mode.ac_power_timestamp == "2020-03-05T04:02:40.867Z" - assert smartac3_dry_mode.heating_power_timestamp is None - assert smartac3_dry_mode.ac_power == "ON" - assert smartac3_dry_mode.heating_power is None - assert smartac3_dry_mode.heating_power_percentage is None - assert smartac3_dry_mode.is_away is False - assert smartac3_dry_mode.power == "ON" - assert smartac3_dry_mode.current_hvac_action == "drying" - assert smartac3_dry_mode.current_tado_fan_speed == "AUTO" - assert smartac3_dry_mode.link == "ONLINE" - assert smartac3_dry_mode.current_tado_hvac_mode == "DRY" - assert smartac3_dry_mode.target_temp is None - assert smartac3_dry_mode.available is True - assert smartac3_dry_mode.precision == 0.1 - - -async def test_smartac3_fan_mode(hass): - """Test smart ac cool mode.""" - smartac3_fan_mode = await _mock_tado_climate_zone_from_fixture( - hass, "smartac3.fan_mode.json" - ) - assert smartac3_fan_mode.preparation is False - assert smartac3_fan_mode.open_window is False - assert smartac3_fan_mode.open_window_attr == {} - assert smartac3_fan_mode.current_temp == 25.01 - assert smartac3_fan_mode.current_temp_timestamp == "2020-03-05T04:02:07.396Z" - assert smartac3_fan_mode.connection is None - assert smartac3_fan_mode.tado_mode == "HOME" - assert smartac3_fan_mode.overlay_active is True - assert smartac3_fan_mode.overlay_termination_type == "TADO_MODE" - assert smartac3_fan_mode.current_humidity == 62.0 - assert smartac3_fan_mode.current_humidity_timestamp == "2020-03-05T04:02:07.396Z" - assert smartac3_fan_mode.ac_power_timestamp == "2020-03-05T04:03:44.328Z" - assert smartac3_fan_mode.heating_power_timestamp is None - assert smartac3_fan_mode.ac_power == "ON" - assert smartac3_fan_mode.heating_power is None - assert smartac3_fan_mode.heating_power_percentage is None - assert smartac3_fan_mode.is_away is False - assert smartac3_fan_mode.power == "ON" - assert smartac3_fan_mode.current_hvac_action == "fan" - assert smartac3_fan_mode.current_tado_fan_speed == "AUTO" - assert smartac3_fan_mode.link == "ONLINE" - assert smartac3_fan_mode.current_tado_hvac_mode == "FAN" - assert smartac3_fan_mode.target_temp is None - assert smartac3_fan_mode.available is True - assert smartac3_fan_mode.precision == 0.1 - - -async def test_smartac3_heat_mode(hass): - """Test smart ac cool mode.""" - smartac3_heat_mode = await _mock_tado_climate_zone_from_fixture( - hass, "smartac3.heat_mode.json" - ) - assert smartac3_heat_mode.preparation is False - assert smartac3_heat_mode.open_window is False - assert smartac3_heat_mode.open_window_attr == {} - assert smartac3_heat_mode.current_temp == 24.76 - assert smartac3_heat_mode.current_temp_timestamp == "2020-03-05T03:57:38.850Z" - assert smartac3_heat_mode.connection is None - assert smartac3_heat_mode.tado_mode == "HOME" - assert smartac3_heat_mode.overlay_active is True - assert smartac3_heat_mode.overlay_termination_type == "TADO_MODE" - assert smartac3_heat_mode.current_humidity == 60.9 - assert smartac3_heat_mode.current_humidity_timestamp == "2020-03-05T03:57:38.850Z" - assert smartac3_heat_mode.ac_power_timestamp == "2020-03-05T03:59:36.390Z" - assert smartac3_heat_mode.heating_power_timestamp is None - assert smartac3_heat_mode.ac_power == "ON" - assert smartac3_heat_mode.heating_power is None - assert smartac3_heat_mode.heating_power_percentage is None - assert smartac3_heat_mode.is_away is False - assert smartac3_heat_mode.power == "ON" - assert smartac3_heat_mode.current_hvac_action == "heating" - assert smartac3_heat_mode.current_tado_fan_speed == "AUTO" - assert smartac3_heat_mode.link == "ONLINE" - assert smartac3_heat_mode.current_tado_hvac_mode == "HEAT" - assert smartac3_heat_mode.target_temp == 16.11 - assert smartac3_heat_mode.available is True - assert smartac3_heat_mode.precision == 0.1 - - -async def test_smartac3_hvac_off(hass): - """Test smart ac cool mode.""" - smartac3_hvac_off = await _mock_tado_climate_zone_from_fixture( - hass, "smartac3.hvac_off.json" - ) - assert smartac3_hvac_off.preparation is False - assert smartac3_hvac_off.open_window is False - assert smartac3_hvac_off.open_window_attr == {} - assert smartac3_hvac_off.current_temp == 21.44 - assert smartac3_hvac_off.current_temp_timestamp == "2020-03-05T01:21:44.089Z" - assert smartac3_hvac_off.connection is None - assert smartac3_hvac_off.tado_mode == "AWAY" - assert smartac3_hvac_off.overlay_active is True - assert smartac3_hvac_off.overlay_termination_type == "MANUAL" - assert smartac3_hvac_off.current_humidity == 48.2 - assert smartac3_hvac_off.current_humidity_timestamp == "2020-03-05T01:21:44.089Z" - assert smartac3_hvac_off.ac_power_timestamp == "2020-02-29T05:34:10.318Z" - assert smartac3_hvac_off.heating_power_timestamp is None - assert smartac3_hvac_off.ac_power == "OFF" - assert smartac3_hvac_off.heating_power is None - assert smartac3_hvac_off.heating_power_percentage is None - assert smartac3_hvac_off.is_away is True - assert smartac3_hvac_off.power == "OFF" - assert smartac3_hvac_off.current_hvac_action == "off" - assert smartac3_hvac_off.current_tado_fan_speed == "OFF" - assert smartac3_hvac_off.link == "ONLINE" - assert smartac3_hvac_off.current_tado_hvac_mode == "OFF" - assert smartac3_hvac_off.target_temp is None - assert smartac3_hvac_off.available is True - assert smartac3_hvac_off.precision == 0.1 - - -async def test_smartac3_manual_off(hass): - """Test smart ac cool mode.""" - smartac3_manual_off = await _mock_tado_climate_zone_from_fixture( - hass, "smartac3.manual_off.json" - ) - assert smartac3_manual_off.preparation is False - assert smartac3_manual_off.open_window is False - assert smartac3_manual_off.open_window_attr == {} - assert smartac3_manual_off.current_temp == 25.01 - assert smartac3_manual_off.current_temp_timestamp == "2020-03-05T04:02:07.396Z" - assert smartac3_manual_off.connection is None - assert smartac3_manual_off.tado_mode == "HOME" - assert smartac3_manual_off.overlay_active is True - assert smartac3_manual_off.overlay_termination_type == "MANUAL" - assert smartac3_manual_off.current_humidity == 62.0 - assert smartac3_manual_off.current_humidity_timestamp == "2020-03-05T04:02:07.396Z" - assert smartac3_manual_off.ac_power_timestamp == "2020-03-05T04:05:08.804Z" - assert smartac3_manual_off.heating_power_timestamp is None - assert smartac3_manual_off.ac_power == "OFF" - assert smartac3_manual_off.heating_power is None - assert smartac3_manual_off.heating_power_percentage is None - assert smartac3_manual_off.is_away is False - assert smartac3_manual_off.power == "OFF" - assert smartac3_manual_off.current_hvac_action == "off" - assert smartac3_manual_off.current_tado_fan_speed == "OFF" - assert smartac3_manual_off.link == "ONLINE" - assert smartac3_manual_off.current_tado_hvac_mode == "OFF" - assert smartac3_manual_off.target_temp is None - assert smartac3_manual_off.available is True - assert smartac3_manual_off.precision == 0.1 - - -async def test_smartac3_offline(hass): - """Test smart ac cool mode.""" - smartac3_offline = await _mock_tado_climate_zone_from_fixture( - hass, "smartac3.offline.json" - ) - assert smartac3_offline.preparation is False - assert smartac3_offline.open_window is False - assert smartac3_offline.open_window_attr == {} - assert smartac3_offline.current_temp == 25.05 - assert smartac3_offline.current_temp_timestamp == "2020-03-03T21:23:57.846Z" - assert smartac3_offline.connection is None - assert smartac3_offline.tado_mode == "HOME" - assert smartac3_offline.overlay_active is True - assert smartac3_offline.overlay_termination_type == "TADO_MODE" - assert smartac3_offline.current_humidity == 61.6 - assert smartac3_offline.current_humidity_timestamp == "2020-03-03T21:23:57.846Z" - assert smartac3_offline.ac_power_timestamp == "2020-02-29T18:42:26.683Z" - assert smartac3_offline.heating_power_timestamp is None - assert smartac3_offline.ac_power == "OFF" - assert smartac3_offline.heating_power is None - assert smartac3_offline.heating_power_percentage is None - assert smartac3_offline.is_away is False - assert smartac3_offline.power == "ON" - assert smartac3_offline.current_hvac_action == "idle" - assert smartac3_offline.current_tado_fan_speed == "AUTO" - assert smartac3_offline.link == "OFFLINE" - assert smartac3_offline.current_tado_hvac_mode == "COOL" - assert smartac3_offline.target_temp == 17.78 - assert smartac3_offline.available is False - assert smartac3_offline.precision == 0.1 - - -async def test_hvac_action_heat(hass): - """Test smart ac cool mode.""" - hvac_action_heat = await _mock_tado_climate_zone_from_fixture( - hass, "hvac_action_heat.json" - ) - assert hvac_action_heat.preparation is False - assert hvac_action_heat.open_window is False - assert hvac_action_heat.open_window_attr == {} - assert hvac_action_heat.current_temp == 21.4 - assert hvac_action_heat.current_temp_timestamp == "2020-03-06T18:06:09.546Z" - assert hvac_action_heat.connection is None - assert hvac_action_heat.tado_mode == "HOME" - assert hvac_action_heat.overlay_active is True - assert hvac_action_heat.overlay_termination_type == "TADO_MODE" - assert hvac_action_heat.current_humidity == 50.4 - assert hvac_action_heat.current_humidity_timestamp == "2020-03-06T18:06:09.546Z" - assert hvac_action_heat.ac_power_timestamp == "2020-03-06T17:38:30.302Z" - assert hvac_action_heat.heating_power_timestamp is None - assert hvac_action_heat.ac_power == "OFF" - assert hvac_action_heat.heating_power is None - assert hvac_action_heat.heating_power_percentage is None - assert hvac_action_heat.is_away is False - assert hvac_action_heat.power == "ON" - assert hvac_action_heat.current_hvac_action == "idle" - assert hvac_action_heat.current_tado_fan_speed == "AUTO" - assert hvac_action_heat.link == "ONLINE" - assert hvac_action_heat.current_tado_hvac_mode == "HEAT" - assert hvac_action_heat.target_temp == 16.11 - assert hvac_action_heat.available is True - assert hvac_action_heat.precision == 0.1 - - -async def test_smartac3_turning_off(hass): - """Test smart ac cool mode.""" - smartac3_turning_off = await _mock_tado_climate_zone_from_fixture( - hass, "smartac3.turning_off.json" - ) - assert smartac3_turning_off.preparation is False - assert smartac3_turning_off.open_window is False - assert smartac3_turning_off.open_window_attr == {} - assert smartac3_turning_off.current_temp == 21.4 - assert smartac3_turning_off.current_temp_timestamp == "2020-03-06T19:06:13.185Z" - assert smartac3_turning_off.connection is None - assert smartac3_turning_off.tado_mode == "HOME" - assert smartac3_turning_off.overlay_active is True - assert smartac3_turning_off.overlay_termination_type == "MANUAL" - assert smartac3_turning_off.current_humidity == 49.2 - assert smartac3_turning_off.current_humidity_timestamp == "2020-03-06T19:06:13.185Z" - assert smartac3_turning_off.ac_power_timestamp == "2020-03-06T19:05:21.835Z" - assert smartac3_turning_off.heating_power_timestamp is None - assert smartac3_turning_off.ac_power == "ON" - assert smartac3_turning_off.heating_power is None - assert smartac3_turning_off.heating_power_percentage is None - assert smartac3_turning_off.is_away is False - assert smartac3_turning_off.power == "OFF" - assert smartac3_turning_off.current_hvac_action == "off" - assert smartac3_turning_off.current_tado_fan_speed == "OFF" - assert smartac3_turning_off.link == "ONLINE" - assert smartac3_turning_off.current_tado_hvac_mode == "OFF" - assert smartac3_turning_off.target_temp is None - assert smartac3_turning_off.available is True - assert smartac3_turning_off.precision == 0.1 - - -async def test_michael_heat_mode(hass): - """Test michael's tado.""" - michael_heat_mode = await _mock_tado_climate_zone_from_fixture( - hass, "michael_heat_mode.json" - ) - assert michael_heat_mode.preparation is False - assert michael_heat_mode.open_window is False - assert michael_heat_mode.open_window_attr == {} - assert michael_heat_mode.current_temp == 20.06 - assert michael_heat_mode.current_temp_timestamp == "2020-03-09T08:16:49.271Z" - assert michael_heat_mode.connection is None - assert michael_heat_mode.tado_mode == "HOME" - assert michael_heat_mode.overlay_active is False - assert michael_heat_mode.overlay_termination_type is None - assert michael_heat_mode.current_humidity == 41.8 - assert michael_heat_mode.current_humidity_timestamp == "2020-03-09T08:16:49.271Z" - assert michael_heat_mode.ac_power_timestamp is None - assert michael_heat_mode.heating_power_timestamp == "2020-03-09T08:20:47.299Z" - assert michael_heat_mode.ac_power is None - assert michael_heat_mode.heating_power is None - assert michael_heat_mode.heating_power_percentage == 0.0 - assert michael_heat_mode.is_away is False - assert michael_heat_mode.power == "ON" - assert michael_heat_mode.current_hvac_action == "idle" - assert michael_heat_mode.current_tado_fan_speed == "AUTO" - assert michael_heat_mode.link == "ONLINE" - assert michael_heat_mode.current_tado_hvac_mode == "SMART_SCHEDULE" - assert michael_heat_mode.target_temp == 20.0 - assert michael_heat_mode.available is True - assert michael_heat_mode.precision == 0.1 diff --git a/tests/fixtures/tado/ac_issue_32294.heat_mode.json b/tests/fixtures/tado/ac_issue_32294.heat_mode.json deleted file mode 100644 index 098afd018aa..00000000000 --- a/tests/fixtures/tado/ac_issue_32294.heat_mode.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "tadoMode": "HOME", - "sensorDataPoints": { - "insideTemperature": { - "fahrenheit": 71.28, - "timestamp": "2020-02-29T22:51:05.016Z", - "celsius": 21.82, - "type": "TEMPERATURE", - "precision": { - "fahrenheit": 0.1, - "celsius": 0.1 - } - }, - "humidity": { - "timestamp": "2020-02-29T22:51:05.016Z", - "percentage": 40.4, - "type": "PERCENTAGE" - } - }, - "link": { - "state": "ONLINE" - }, - "openWindow": null, - "geolocationOverride": false, - "geolocationOverrideDisableTime": null, - "overlay": null, - "activityDataPoints": { - "acPower": { - "timestamp": "2020-02-29T22:50:34.850Z", - "type": "POWER", - "value": "ON" - } - }, - "nextTimeBlock": { - "start": "2020-03-01T00:00:00.000Z" - }, - "preparation": null, - "overlayType": null, - "nextScheduleChange": { - "start": "2020-03-01T00:00:00Z", - "setting": { - "type": "AIR_CONDITIONING", - "mode": "HEAT", - "power": "ON", - "temperature": { - "fahrenheit": 59.0, - "celsius": 15.0 - } - } - }, - "setting": { - "type": "AIR_CONDITIONING", - "mode": "HEAT", - "power": "ON", - "temperature": { - "fahrenheit": 77.0, - "celsius": 25.0 - } - } -} \ No newline at end of file diff --git a/tests/fixtures/tado/hvac_action_heat.json b/tests/fixtures/tado/hvac_action_heat.json deleted file mode 100644 index 9cbf1fd5f82..00000000000 --- a/tests/fixtures/tado/hvac_action_heat.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "tadoMode": "HOME", - "geolocationOverride": false, - "geolocationOverrideDisableTime": null, - "preparation": null, - "setting": { - "type": "AIR_CONDITIONING", - "power": "ON", - "mode": "HEAT", - "temperature": { - "celsius": 16.11, - "fahrenheit": 61.00 - }, - "fanSpeed": "AUTO" - }, - "overlayType": "MANUAL", - "overlay": { - "type": "MANUAL", - "setting": { - "type": "AIR_CONDITIONING", - "power": "ON", - "mode": "HEAT", - "temperature": { - "celsius": 16.11, - "fahrenheit": 61.00 - }, - "fanSpeed": "AUTO" - }, - "termination": { - "type": "TADO_MODE", - "typeSkillBasedApp": "TADO_MODE", - "projectedExpiry": null - } - }, - "openWindow": null, - "nextScheduleChange": null, - "nextTimeBlock": { - "start": "2020-03-07T04:00:00.000Z" - }, - "link": { - "state": "ONLINE" - }, - "activityDataPoints": { - "acPower": { - "timestamp": "2020-03-06T17:38:30.302Z", - "type": "POWER", - "value": "OFF" - } - }, - "sensorDataPoints": { - "insideTemperature": { - "celsius": 21.40, - "fahrenheit": 70.52, - "timestamp": "2020-03-06T18:06:09.546Z", - "type": "TEMPERATURE", - "precision": { - "celsius": 0.1, - "fahrenheit": 0.1 - } - }, - "humidity": { - "type": "PERCENTAGE", - "percentage": 50.40, - "timestamp": "2020-03-06T18:06:09.546Z" - } - } -} diff --git a/tests/fixtures/tado/michael_heat_mode.json b/tests/fixtures/tado/michael_heat_mode.json deleted file mode 100644 index 958ead7635a..00000000000 --- a/tests/fixtures/tado/michael_heat_mode.json +++ /dev/null @@ -1,58 +0,0 @@ -{ - "tadoMode": "HOME", - "geolocationOverride": false, - "geolocationOverrideDisableTime": null, - "preparation": null, - "setting": { - "type": "HEATING", - "power": "ON", - "temperature": { - "celsius": 20.0, - "fahrenheit": 68.0 - } - }, - "overlayType": null, - "overlay": null, - "openWindow": null, - "nextScheduleChange": { - "start": "2020-03-09T17:00:00Z", - "setting": { - "type": "HEATING", - "power": "ON", - "temperature": { - "celsius": 21.0, - "fahrenheit": 69.8 - } - } - }, - "nextTimeBlock": { - "start": "2020-03-09T17:00:00.000Z" - }, - "link": { - "state": "ONLINE" - }, - "activityDataPoints": { - "heatingPower": { - "type": "PERCENTAGE", - "percentage": 0.0, - "timestamp": "2020-03-09T08:20:47.299Z" - } - }, - "sensorDataPoints": { - "insideTemperature": { - "celsius": 20.06, - "fahrenheit": 68.11, - "timestamp": "2020-03-09T08:16:49.271Z", - "type": "TEMPERATURE", - "precision": { - "celsius": 0.1, - "fahrenheit": 0.1 - } - }, - "humidity": { - "type": "PERCENTAGE", - "percentage": 41.8, - "timestamp": "2020-03-09T08:16:49.271Z" - } - } -} diff --git a/tests/fixtures/tado/smartac3.auto_mode.json b/tests/fixtures/tado/smartac3.auto_mode.json deleted file mode 100644 index 254b409ddd9..00000000000 --- a/tests/fixtures/tado/smartac3.auto_mode.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "tadoMode": "HOME", - "sensorDataPoints": { - "insideTemperature": { - "fahrenheit": 76.64, - "timestamp": "2020-03-05T03:55:38.160Z", - "celsius": 24.8, - "type": "TEMPERATURE", - "precision": { - "fahrenheit": 0.1, - "celsius": 0.1 - } - }, - "humidity": { - "timestamp": "2020-03-05T03:55:38.160Z", - "percentage": 62.5, - "type": "PERCENTAGE" - } - }, - "link": { - "state": "ONLINE" - }, - "openWindow": null, - "geolocationOverride": false, - "geolocationOverrideDisableTime": null, - "overlay": { - "termination": { - "typeSkillBasedApp": "TADO_MODE", - "projectedExpiry": null, - "type": "TADO_MODE" - }, - "setting": { - "type": "AIR_CONDITIONING", - "mode": "AUTO", - "power": "ON" - }, - "type": "MANUAL" - }, - "activityDataPoints": { - "acPower": { - "timestamp": "2020-03-05T03:56:38.627Z", - "type": "POWER", - "value": "ON" - } - }, - "nextTimeBlock": { - "start": "2020-03-05T08:00:00.000Z" - }, - "preparation": null, - "overlayType": "MANUAL", - "nextScheduleChange": null, - "setting": { - "type": "AIR_CONDITIONING", - "mode": "AUTO", - "power": "ON" - } -} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.cool_mode.json b/tests/fixtures/tado/smartac3.cool_mode.json deleted file mode 100644 index a7db2cc75bc..00000000000 --- a/tests/fixtures/tado/smartac3.cool_mode.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "tadoMode": "HOME", - "sensorDataPoints": { - "insideTemperature": { - "fahrenheit": 76.57, - "timestamp": "2020-03-05T03:57:38.850Z", - "celsius": 24.76, - "type": "TEMPERATURE", - "precision": { - "fahrenheit": 0.1, - "celsius": 0.1 - } - }, - "humidity": { - "timestamp": "2020-03-05T03:57:38.850Z", - "percentage": 60.9, - "type": "PERCENTAGE" - } - }, - "link": { - "state": "ONLINE" - }, - "openWindow": null, - "geolocationOverride": false, - "geolocationOverrideDisableTime": null, - "overlay": { - "termination": { - "typeSkillBasedApp": "TADO_MODE", - "projectedExpiry": null, - "type": "TADO_MODE" - }, - "setting": { - "fanSpeed": "AUTO", - "type": "AIR_CONDITIONING", - "mode": "COOL", - "power": "ON", - "temperature": { - "fahrenheit": 64.0, - "celsius": 17.78 - } - }, - "type": "MANUAL" - }, - "activityDataPoints": { - "acPower": { - "timestamp": "2020-03-05T04:01:07.162Z", - "type": "POWER", - "value": "ON" - } - }, - "nextTimeBlock": { - "start": "2020-03-05T08:00:00.000Z" - }, - "preparation": null, - "overlayType": "MANUAL", - "nextScheduleChange": null, - "setting": { - "fanSpeed": "AUTO", - "type": "AIR_CONDITIONING", - "mode": "COOL", - "power": "ON", - "temperature": { - "fahrenheit": 64.0, - "celsius": 17.78 - } - } -} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.dry_mode.json b/tests/fixtures/tado/smartac3.dry_mode.json deleted file mode 100644 index d04612d1105..00000000000 --- a/tests/fixtures/tado/smartac3.dry_mode.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "tadoMode": "HOME", - "sensorDataPoints": { - "insideTemperature": { - "fahrenheit": 77.02, - "timestamp": "2020-03-05T04:02:07.396Z", - "celsius": 25.01, - "type": "TEMPERATURE", - "precision": { - "fahrenheit": 0.1, - "celsius": 0.1 - } - }, - "humidity": { - "timestamp": "2020-03-05T04:02:07.396Z", - "percentage": 62.0, - "type": "PERCENTAGE" - } - }, - "link": { - "state": "ONLINE" - }, - "openWindow": null, - "geolocationOverride": false, - "geolocationOverrideDisableTime": null, - "overlay": { - "termination": { - "typeSkillBasedApp": "TADO_MODE", - "projectedExpiry": null, - "type": "TADO_MODE" - }, - "setting": { - "type": "AIR_CONDITIONING", - "mode": "DRY", - "power": "ON" - }, - "type": "MANUAL" - }, - "activityDataPoints": { - "acPower": { - "timestamp": "2020-03-05T04:02:40.867Z", - "type": "POWER", - "value": "ON" - } - }, - "nextTimeBlock": { - "start": "2020-03-05T08:00:00.000Z" - }, - "preparation": null, - "overlayType": "MANUAL", - "nextScheduleChange": null, - "setting": { - "type": "AIR_CONDITIONING", - "mode": "DRY", - "power": "ON" - } -} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.fan_mode.json b/tests/fixtures/tado/smartac3.fan_mode.json deleted file mode 100644 index 6907c31c517..00000000000 --- a/tests/fixtures/tado/smartac3.fan_mode.json +++ /dev/null @@ -1,57 +0,0 @@ -{ - "tadoMode": "HOME", - "sensorDataPoints": { - "insideTemperature": { - "fahrenheit": 77.02, - "timestamp": "2020-03-05T04:02:07.396Z", - "celsius": 25.01, - "type": "TEMPERATURE", - "precision": { - "fahrenheit": 0.1, - "celsius": 0.1 - } - }, - "humidity": { - "timestamp": "2020-03-05T04:02:07.396Z", - "percentage": 62.0, - "type": "PERCENTAGE" - } - }, - "link": { - "state": "ONLINE" - }, - "openWindow": null, - "geolocationOverride": false, - "geolocationOverrideDisableTime": null, - "overlay": { - "termination": { - "typeSkillBasedApp": "TADO_MODE", - "projectedExpiry": null, - "type": "TADO_MODE" - }, - "setting": { - "type": "AIR_CONDITIONING", - "mode": "FAN", - "power": "ON" - }, - "type": "MANUAL" - }, - "activityDataPoints": { - "acPower": { - "timestamp": "2020-03-05T04:03:44.328Z", - "type": "POWER", - "value": "ON" - } - }, - "nextTimeBlock": { - "start": "2020-03-05T08:00:00.000Z" - }, - "preparation": null, - "overlayType": "MANUAL", - "nextScheduleChange": null, - "setting": { - "type": "AIR_CONDITIONING", - "mode": "FAN", - "power": "ON" - } -} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.heat_mode.json b/tests/fixtures/tado/smartac3.heat_mode.json deleted file mode 100644 index 06b5a350d83..00000000000 --- a/tests/fixtures/tado/smartac3.heat_mode.json +++ /dev/null @@ -1,67 +0,0 @@ -{ - "tadoMode": "HOME", - "sensorDataPoints": { - "insideTemperature": { - "fahrenheit": 76.57, - "timestamp": "2020-03-05T03:57:38.850Z", - "celsius": 24.76, - "type": "TEMPERATURE", - "precision": { - "fahrenheit": 0.1, - "celsius": 0.1 - } - }, - "humidity": { - "timestamp": "2020-03-05T03:57:38.850Z", - "percentage": 60.9, - "type": "PERCENTAGE" - } - }, - "link": { - "state": "ONLINE" - }, - "openWindow": null, - "geolocationOverride": false, - "geolocationOverrideDisableTime": null, - "overlay": { - "termination": { - "typeSkillBasedApp": "TADO_MODE", - "projectedExpiry": null, - "type": "TADO_MODE" - }, - "setting": { - "fanSpeed": "AUTO", - "type": "AIR_CONDITIONING", - "mode": "HEAT", - "power": "ON", - "temperature": { - "fahrenheit": 61.0, - "celsius": 16.11 - } - }, - "type": "MANUAL" - }, - "activityDataPoints": { - "acPower": { - "timestamp": "2020-03-05T03:59:36.390Z", - "type": "POWER", - "value": "ON" - } - }, - "nextTimeBlock": { - "start": "2020-03-05T08:00:00.000Z" - }, - "preparation": null, - "overlayType": "MANUAL", - "nextScheduleChange": null, - "setting": { - "fanSpeed": "AUTO", - "type": "AIR_CONDITIONING", - "mode": "HEAT", - "power": "ON", - "temperature": { - "fahrenheit": 61.0, - "celsius": 16.11 - } - } -} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.hvac_off.json b/tests/fixtures/tado/smartac3.hvac_off.json deleted file mode 100644 index 83e9d1a83d5..00000000000 --- a/tests/fixtures/tado/smartac3.hvac_off.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "tadoMode": "AWAY", - "sensorDataPoints": { - "insideTemperature": { - "fahrenheit": 70.59, - "timestamp": "2020-03-05T01:21:44.089Z", - "celsius": 21.44, - "type": "TEMPERATURE", - "precision": { - "fahrenheit": 0.1, - "celsius": 0.1 - } - }, - "humidity": { - "timestamp": "2020-03-05T01:21:44.089Z", - "percentage": 48.2, - "type": "PERCENTAGE" - } - }, - "link": { - "state": "ONLINE" - }, - "openWindow": null, - "geolocationOverride": false, - "geolocationOverrideDisableTime": null, - "overlay": { - "termination": { - "typeSkillBasedApp": "MANUAL", - "projectedExpiry": null, - "type": "MANUAL" - }, - "setting": { - "type": "AIR_CONDITIONING", - "power": "OFF" - }, - "type": "MANUAL" - }, - "activityDataPoints": { - "acPower": { - "timestamp": "2020-02-29T05:34:10.318Z", - "type": "POWER", - "value": "OFF" - } - }, - "nextTimeBlock": { - "start": "2020-03-05T04:00:00.000Z" - }, - "preparation": null, - "overlayType": "MANUAL", - "nextScheduleChange": null, - "setting": { - "type": "AIR_CONDITIONING", - "power": "OFF" - } -} diff --git a/tests/fixtures/tado/smartac3.manual_off.json b/tests/fixtures/tado/smartac3.manual_off.json deleted file mode 100644 index a9538f30dbe..00000000000 --- a/tests/fixtures/tado/smartac3.manual_off.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "tadoMode": "HOME", - "sensorDataPoints": { - "insideTemperature": { - "fahrenheit": 77.02, - "timestamp": "2020-03-05T04:02:07.396Z", - "celsius": 25.01, - "type": "TEMPERATURE", - "precision": { - "fahrenheit": 0.1, - "celsius": 0.1 - } - }, - "humidity": { - "timestamp": "2020-03-05T04:02:07.396Z", - "percentage": 62.0, - "type": "PERCENTAGE" - } - }, - "link": { - "state": "ONLINE" - }, - "openWindow": null, - "geolocationOverride": false, - "geolocationOverrideDisableTime": null, - "overlay": { - "termination": { - "typeSkillBasedApp": "MANUAL", - "projectedExpiry": null, - "type": "MANUAL" - }, - "setting": { - "type": "AIR_CONDITIONING", - "power": "OFF" - }, - "type": "MANUAL" - }, - "activityDataPoints": { - "acPower": { - "timestamp": "2020-03-05T04:05:08.804Z", - "type": "POWER", - "value": "OFF" - } - }, - "nextTimeBlock": { - "start": "2020-03-05T08:00:00.000Z" - }, - "preparation": null, - "overlayType": "MANUAL", - "nextScheduleChange": null, - "setting": { - "type": "AIR_CONDITIONING", - "power": "OFF" - } -} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.offline.json b/tests/fixtures/tado/smartac3.offline.json deleted file mode 100644 index fda1e6468eb..00000000000 --- a/tests/fixtures/tado/smartac3.offline.json +++ /dev/null @@ -1,71 +0,0 @@ -{ - "tadoMode": "HOME", - "sensorDataPoints": { - "insideTemperature": { - "fahrenheit": 77.09, - "timestamp": "2020-03-03T21:23:57.846Z", - "celsius": 25.05, - "type": "TEMPERATURE", - "precision": { - "fahrenheit": 0.1, - "celsius": 0.1 - } - }, - "humidity": { - "timestamp": "2020-03-03T21:23:57.846Z", - "percentage": 61.6, - "type": "PERCENTAGE" - } - }, - "link": { - "state": "OFFLINE", - "reason": { - "code": "disconnectedDevice", - "title": "There is a disconnected device." - } - }, - "openWindow": null, - "geolocationOverride": false, - "geolocationOverrideDisableTime": null, - "overlay": { - "termination": { - "typeSkillBasedApp": "TADO_MODE", - "projectedExpiry": null, - "type": "TADO_MODE" - }, - "setting": { - "fanSpeed": "AUTO", - "type": "AIR_CONDITIONING", - "mode": "COOL", - "power": "ON", - "temperature": { - "fahrenheit": 64.0, - "celsius": 17.78 - } - }, - "type": "MANUAL" - }, - "activityDataPoints": { - "acPower": { - "timestamp": "2020-02-29T18:42:26.683Z", - "type": "POWER", - "value": "OFF" - } - }, - "nextTimeBlock": { - "start": "2020-03-05T08:00:00.000Z" - }, - "preparation": null, - "overlayType": "MANUAL", - "nextScheduleChange": null, - "setting": { - "fanSpeed": "AUTO", - "type": "AIR_CONDITIONING", - "mode": "COOL", - "power": "ON", - "temperature": { - "fahrenheit": 64.0, - "celsius": 17.78 - } - } -} diff --git a/tests/fixtures/tado/smartac3.smart_mode.json b/tests/fixtures/tado/smartac3.smart_mode.json deleted file mode 100644 index 357a1a96658..00000000000 --- a/tests/fixtures/tado/smartac3.smart_mode.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "tadoMode": "HOME", - "sensorDataPoints": { - "insideTemperature": { - "fahrenheit": 75.97, - "timestamp": "2020-03-05T03:50:24.769Z", - "celsius": 24.43, - "type": "TEMPERATURE", - "precision": { - "fahrenheit": 0.1, - "celsius": 0.1 - } - }, - "humidity": { - "timestamp": "2020-03-05T03:50:24.769Z", - "percentage": 60.0, - "type": "PERCENTAGE" - } - }, - "link": { - "state": "ONLINE" - }, - "openWindow": null, - "geolocationOverride": false, - "geolocationOverrideDisableTime": null, - "overlay": null, - "activityDataPoints": { - "acPower": { - "timestamp": "2020-03-05T03:52:22.253Z", - "type": "POWER", - "value": "OFF" - } - }, - "nextTimeBlock": { - "start": "2020-03-05T08:00:00.000Z" - }, - "preparation": null, - "overlayType": null, - "nextScheduleChange": null, - "setting": { - "fanSpeed": "MIDDLE", - "type": "AIR_CONDITIONING", - "mode": "COOL", - "power": "ON", - "temperature": { - "fahrenheit": 68.0, - "celsius": 20.0 - } - } -} \ No newline at end of file diff --git a/tests/fixtures/tado/smartac3.turning_off.json b/tests/fixtures/tado/smartac3.turning_off.json deleted file mode 100644 index 0c16f85811a..00000000000 --- a/tests/fixtures/tado/smartac3.turning_off.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "tadoMode": "HOME", - "geolocationOverride": false, - "geolocationOverrideDisableTime": null, - "preparation": null, - "setting": { - "type": "AIR_CONDITIONING", - "power": "OFF" - }, - "overlayType": "MANUAL", - "overlay": { - "type": "MANUAL", - "setting": { - "type": "AIR_CONDITIONING", - "power": "OFF" - }, - "termination": { - "type": "MANUAL", - "typeSkillBasedApp": "MANUAL", - "projectedExpiry": null - } - }, - "openWindow": null, - "nextScheduleChange": null, - "nextTimeBlock": { - "start": "2020-03-07T04:00:00.000Z" - }, - "link": { - "state": "ONLINE" - }, - "activityDataPoints": { - "acPower": { - "timestamp": "2020-03-06T19:05:21.835Z", - "type": "POWER", - "value": "ON" - } - }, - "sensorDataPoints": { - "insideTemperature": { - "celsius": 21.40, - "fahrenheit": 70.52, - "timestamp": "2020-03-06T19:06:13.185Z", - "type": "TEMPERATURE", - "precision": { - "celsius": 0.1, - "fahrenheit": 0.1 - } - }, - "humidity": { - "type": "PERCENTAGE", - "percentage": 49.20, - "timestamp": "2020-03-06T19:06:13.185Z" - } - } -} From 2e802c88f88f3602ea8cd9bab784509dcbd192d8 Mon Sep 17 00:00:00 2001 From: Quentame Date: Tue, 10 Mar 2020 11:42:04 +0100 Subject: [PATCH 313/416] Add devices check to iCloud config flow (#31950) * Add devices check to iCloud config flow * Some test rename * Bump pyicloud to catch KeyError --- .../components/icloud/.translations/en.json | 3 +- homeassistant/components/icloud/account.py | 8 +++- .../components/icloud/config_flow.py | 18 +++++++- homeassistant/components/icloud/manifest.json | 2 +- homeassistant/components/icloud/strings.json | 3 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/icloud/test_config_flow.py | 41 +++++++++++++++---- 8 files changed, 64 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/icloud/.translations/en.json b/homeassistant/components/icloud/.translations/en.json index 3b7da70bcaf..73ca1b31256 100644 --- a/homeassistant/components/icloud/.translations/en.json +++ b/homeassistant/components/icloud/.translations/en.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Account already configured" + "already_configured": "Account already configured", + "no_device": "None of your devices have \"Find my iPhone\" activated" }, "error": { "login": "Login error: please check your email & password", diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index 789ae563482..bb3742174d7 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -5,7 +5,11 @@ import operator from typing import Dict from pyicloud import PyiCloudService -from pyicloud.exceptions import PyiCloudFailedLoginException, PyiCloudNoDevicesException +from pyicloud.exceptions import ( + PyiCloudFailedLoginException, + PyiCloudNoDevicesException, + PyiCloudServiceNotActivatedException, +) from pyicloud.services.findmyiphone import AppleDevice from homeassistant.components.zone import async_active_zone @@ -109,7 +113,7 @@ class IcloudAccount: api_devices = self.api.devices # Gets device owners infos user_info = api_devices.response["userInfo"] - except (KeyError, PyiCloudNoDevicesException): + except (PyiCloudServiceNotActivatedException, PyiCloudNoDevicesException): _LOGGER.error("No iCloud device found") raise ConfigEntryNotReady diff --git a/homeassistant/components/icloud/config_flow.py b/homeassistant/components/icloud/config_flow.py index b3cb9c28181..72ff6e6481d 100644 --- a/homeassistant/components/icloud/config_flow.py +++ b/homeassistant/components/icloud/config_flow.py @@ -3,7 +3,12 @@ import logging import os from pyicloud import PyiCloudService -from pyicloud.exceptions import PyiCloudException, PyiCloudFailedLoginException +from pyicloud.exceptions import ( + PyiCloudException, + PyiCloudFailedLoginException, + PyiCloudNoDevicesException, + PyiCloudServiceNotActivatedException, +) import voluptuous as vol from homeassistant import config_entries @@ -101,6 +106,17 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): if self.api.requires_2sa: return await self.async_step_trusted_device() + try: + devices = await self.hass.async_add_executor_job( + getattr, self.api, "devices" + ) + if not devices: + raise PyiCloudNoDevicesException() + except (PyiCloudServiceNotActivatedException, PyiCloudNoDevicesException): + _LOGGER.error("No device found in the iCloud account: %s", self._username) + self.api = None + return self.async_abort(reason="no_device") + return self.async_create_entry( title=self._username, data={ diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json index 5b232cf1e62..76b6b9b39ae 100644 --- a/homeassistant/components/icloud/manifest.json +++ b/homeassistant/components/icloud/manifest.json @@ -3,7 +3,7 @@ "name": "Apple iCloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/icloud", - "requirements": ["pyicloud==0.9.3"], + "requirements": ["pyicloud==0.9.4"], "dependencies": [], "codeowners": ["@Quentame"] } diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json index e0a7b7a32ce..f1931f7cb5c 100644 --- a/homeassistant/components/icloud/strings.json +++ b/homeassistant/components/icloud/strings.json @@ -31,7 +31,8 @@ "validate_verification_code": "Failed to verify your verification code, choose a trust device and start the verification again" }, "abort": { - "already_configured": "Account already configured" + "already_configured": "Account already configured", + "no_device": "None of your devices have \"Find my iPhone\" activated" } } } \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index acfebe8c4d0..022d81aff49 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1312,7 +1312,7 @@ pyhomeworks==0.0.6 pyialarm==0.3 # homeassistant.components.icloud -pyicloud==0.9.3 +pyicloud==0.9.4 # homeassistant.components.intesishome pyintesishome==1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 54ecf61d031..9bb76076b4a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -480,7 +480,7 @@ pyheos==0.6.0 pyhomematic==0.1.65 # homeassistant.components.icloud -pyicloud==0.9.3 +pyicloud==0.9.4 # homeassistant.components.ipma pyipma==2.0.5 diff --git a/tests/components/icloud/test_config_flow.py b/tests/components/icloud/test_config_flow.py index 6091d1cf1da..646d62a09b8 100644 --- a/tests/components/icloud/test_config_flow.py +++ b/tests/components/icloud/test_config_flow.py @@ -46,8 +46,8 @@ def mock_controller_service(): yield service_mock -@pytest.fixture(name="service_with_cookie") -def mock_controller_service_with_cookie(): +@pytest.fixture(name="service_authenticated") +def mock_controller_service_authenticated(): """Mock a successful service while already authenticate.""" with patch( "homeassistant.components.icloud.config_flow.PyiCloudService" @@ -59,6 +59,20 @@ def mock_controller_service_with_cookie(): yield service_mock +@pytest.fixture(name="service_authenticated_no_device") +def mock_controller_service_authenticated_no_device(): + """Mock a successful service while already authenticate, but without device.""" + with patch( + "homeassistant.components.icloud.config_flow.PyiCloudService" + ) as service_mock: + service_mock.return_value.requires_2sa = False + service_mock.return_value.trusted_devices = TRUSTED_DEVICES + service_mock.return_value.send_verification_code = Mock(return_value=True) + service_mock.return_value.validate_verification_code = Mock(return_value=True) + service_mock.return_value.devices = {} + yield service_mock + + @pytest.fixture(name="service_send_verification_code_failed") def mock_controller_service_send_verification_code_failed(): """Mock a failed service during sending verification code step.""" @@ -103,7 +117,7 @@ async def test_user(hass: HomeAssistantType, service: MagicMock): async def test_user_with_cookie( - hass: HomeAssistantType, service_with_cookie: MagicMock + hass: HomeAssistantType, service_authenticated: MagicMock ): """Test user config with presence of a cookie.""" # test with all provided @@ -148,7 +162,7 @@ async def test_import(hass: HomeAssistantType, service: MagicMock): async def test_import_with_cookie( - hass: HomeAssistantType, service_with_cookie: MagicMock + hass: HomeAssistantType, service_authenticated: MagicMock ): """Test import step with presence of a cookie.""" # import with username and password @@ -186,7 +200,7 @@ async def test_import_with_cookie( async def test_two_accounts_setup( - hass: HomeAssistantType, service_with_cookie: MagicMock + hass: HomeAssistantType, service_authenticated: MagicMock ): """Test to setup two accounts.""" MockConfigEntry( @@ -210,7 +224,7 @@ async def test_two_accounts_setup( assert result["data"][CONF_GPS_ACCURACY_THRESHOLD] == DEFAULT_GPS_ACCURACY_THRESHOLD -async def test_abort_if_already_setup(hass: HomeAssistantType): +async def test_already_setup(hass: HomeAssistantType): """Test we abort if the account is already setup.""" MockConfigEntry( domain=DOMAIN, @@ -240,7 +254,7 @@ async def test_abort_if_already_setup(hass: HomeAssistantType): async def test_login_failed(hass: HomeAssistantType): """Test when we have errors during login.""" with patch( - "pyicloud.base.PyiCloudService.authenticate", + "homeassistant.components.icloud.config_flow.PyiCloudService.authenticate", side_effect=PyiCloudFailedLoginException(), ): result = await hass.config_entries.flow.async_init( @@ -252,6 +266,19 @@ async def test_login_failed(hass: HomeAssistantType): assert result["errors"] == {CONF_USERNAME: "login"} +async def test_no_device( + hass: HomeAssistantType, service_authenticated_no_device: MagicMock +): + """Test when we have no devices.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "no_device" + + async def test_trusted_device(hass: HomeAssistantType, service: MagicMock): """Test trusted_device step.""" result = await hass.config_entries.flow.async_init( From a8758ed3a143d2ce9d2b7fa94b5837b418746f7b Mon Sep 17 00:00:00 2001 From: Paolo Tuninetto Date: Tue, 10 Mar 2020 11:48:09 +0100 Subject: [PATCH 314/416] Add support for newer SamsungTV models (#31537) * Added support for newer SamsungTV models * Fixed legacy port * store token in HA config directory * Change token name * rebasing and exception handling * implement update * fix error creating mediaplayer * Debug logging * Increase timeout * Restore update timeout * Store token_file path in config_entry * Introduction of samsung bridge class * Added bridge class functions * Code cleanup * more fixes * Begin testing * samsungtvws 1.2.0 * Config flow tests 0.1 * Fixed some mediaplayer tests * Fixed fixture in media player * use of constants and turn off * more media player tests * samsungtvws 1.3.1 and other fixes * WS tv update rewritten * more tests * test_init * fixed tests * removed reset mock * tests reset mock * close_remote and tests * deprecate port config * deprecate port config 2 * deprecate port config 3 * save token only if needed * cleanup * better websocket protocol detection * config removal --- .../components/samsungtv/__init__.py | 3 +- homeassistant/components/samsungtv/bridge.py | 254 ++++++++++++++++++ .../components/samsungtv/config_flow.py | 90 +++---- homeassistant/components/samsungtv/const.py | 12 + .../components/samsungtv/manifest.json | 5 +- .../components/samsungtv/media_player.py | 106 ++------ requirements_all.txt | 3 + requirements_test_all.txt | 3 + .../components/samsungtv/test_config_flow.py | 237 ++++++++++++---- tests/components/samsungtv/test_init.py | 53 ++-- .../components/samsungtv/test_media_player.py | 146 ++++++---- 11 files changed, 646 insertions(+), 266 deletions(-) create mode 100644 homeassistant/components/samsungtv/bridge.py diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index bc49dc3156d..8c17ff4794c 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -23,6 +23,7 @@ CONFIG_SCHEMA = vol.Schema( DOMAIN: vol.All( cv.ensure_list, [ + cv.deprecated(CONF_PORT), vol.Schema( { vol.Required(CONF_HOST): cv.string, @@ -30,7 +31,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional(CONF_PORT): cv.port, vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, } - ) + ), ], ensure_unique_hosts, ) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py new file mode 100644 index 00000000000..5203c61a978 --- /dev/null +++ b/homeassistant/components/samsungtv/bridge.py @@ -0,0 +1,254 @@ +"""samsungctl and samsungtvws bridge classes.""" +from abc import ABC, abstractmethod + +from samsungctl import Remote +from samsungctl.exceptions import AccessDenied, ConnectionClosed, UnhandledResponse +from samsungtvws import SamsungTVWS +from samsungtvws.exceptions import ConnectionFailure +from websocket import WebSocketException + +from homeassistant.const import ( + CONF_HOST, + CONF_ID, + CONF_METHOD, + CONF_NAME, + CONF_PORT, + CONF_TIMEOUT, + CONF_TOKEN, +) + +from .const import ( + CONF_DESCRIPTION, + LOGGER, + METHOD_LEGACY, + RESULT_AUTH_MISSING, + RESULT_NOT_SUCCESSFUL, + RESULT_NOT_SUPPORTED, + RESULT_SUCCESS, + VALUE_CONF_ID, + VALUE_CONF_NAME, +) + + +class SamsungTVBridge(ABC): + """The Base Bridge abstract class.""" + + @staticmethod + def get_bridge(method, host, port=None, token=None): + """Get Bridge instance.""" + if method == METHOD_LEGACY: + return SamsungTVLegacyBridge(method, host, port) + return SamsungTVWSBridge(method, host, port, token) + + def __init__(self, method, host, port): + """Initialize Bridge.""" + self.port = port + self.method = method + self.host = host + self.token = None + self._remote = None + self._callback = None + + def register_reauth_callback(self, func): + """Register a callback function.""" + self._callback = func + + @abstractmethod + def try_connect(self): + """Try to connect to the TV.""" + + def is_on(self): + """Tells if the TV is on.""" + self.close_remote() + + try: + return self._get_remote() is not None + except ( + UnhandledResponse, + AccessDenied, + ConnectionFailure, + ): + # We got a response so it's working. + return True + except OSError: + # Different reasons, e.g. hostname not resolveable + return False + + def send_key(self, key): + """Send a key to the tv and handles exceptions.""" + try: + # recreate connection if connection was dead + retry_count = 1 + for _ in range(retry_count + 1): + try: + self._send_key(key) + break + except ( + ConnectionClosed, + BrokenPipeError, + WebSocketException, + ): + # BrokenPipe can occur when the commands is sent to fast + # WebSocketException can occur when timed out + self._remote = None + except (UnhandledResponse, AccessDenied): + # We got a response so it's on. + LOGGER.debug("Failed sending command %s", key, exc_info=True) + except OSError: + # Different reasons, e.g. hostname not resolveable + pass + + @abstractmethod + def _send_key(self, key): + """Send the key.""" + + @abstractmethod + def _get_remote(self): + """Get Remote object.""" + + def close_remote(self): + """Close remote object.""" + try: + if self._remote is not None: + # Close the current remote connection + self._remote.close() + self._remote = None + except OSError: + LOGGER.debug("Could not establish connection") + + def _notify_callback(self): + """Notify access denied callback.""" + if self._callback: + self._callback() + + +class SamsungTVLegacyBridge(SamsungTVBridge): + """The Bridge for Legacy TVs.""" + + def __init__(self, method, host, port): + """Initialize Bridge.""" + super().__init__(method, host, None) + self.config = { + CONF_NAME: VALUE_CONF_NAME, + CONF_ID: VALUE_CONF_ID, + CONF_DESCRIPTION: VALUE_CONF_NAME, + CONF_METHOD: method, + CONF_HOST: host, + CONF_TIMEOUT: 1, + } + + def try_connect(self): + """Try to connect to the Legacy TV.""" + config = { + CONF_NAME: VALUE_CONF_NAME, + CONF_DESCRIPTION: VALUE_CONF_NAME, + CONF_ID: VALUE_CONF_ID, + CONF_HOST: self.host, + CONF_METHOD: self.method, + CONF_PORT: None, + # We need this high timeout because waiting for auth popup is just an open socket + CONF_TIMEOUT: 31, + } + try: + LOGGER.debug("Try config: %s", config) + with Remote(config.copy()): + LOGGER.debug("Working config: %s", config) + return RESULT_SUCCESS + except AccessDenied: + LOGGER.debug("Working but denied config: %s", config) + return RESULT_AUTH_MISSING + except UnhandledResponse: + LOGGER.debug("Working but unsupported config: %s", config) + return RESULT_NOT_SUPPORTED + except OSError as err: + LOGGER.debug("Failing config: %s, error: %s", config, err) + return RESULT_NOT_SUCCESSFUL + + def _get_remote(self): + """Create or return a remote control instance.""" + if self._remote is None: + # We need to create a new instance to reconnect. + try: + LOGGER.debug("Create SamsungRemote") + self._remote = Remote(self.config.copy()) + # This is only happening when the auth was switched to DENY + # A removed auth will lead to socket timeout because waiting for auth popup is just an open socket + except AccessDenied: + self._notify_callback() + raise + return self._remote + + def _send_key(self, key): + """Send the key using legacy protocol.""" + self._get_remote().control(key) + + +class SamsungTVWSBridge(SamsungTVBridge): + """The Bridge for WebSocket TVs.""" + + def __init__(self, method, host, port, token=None): + """Initialize Bridge.""" + super().__init__(method, host, port) + self.token = token + + def try_connect(self): + """Try to connect to the Websocket TV.""" + for self.port in (8001, 8002): + config = { + CONF_NAME: VALUE_CONF_NAME, + CONF_HOST: self.host, + CONF_METHOD: self.method, + CONF_PORT: self.port, + # We need this high timeout because waiting for auth popup is just an open socket + CONF_TIMEOUT: 31, + } + + try: + LOGGER.debug("Try config: %s", config) + with SamsungTVWS( + host=self.host, + port=self.port, + token=self.token, + timeout=config[CONF_TIMEOUT], + name=config[CONF_NAME], + ) as remote: + remote.open() + self.token = remote.token + if self.token: + config[CONF_TOKEN] = "*****" + LOGGER.debug("Working config: %s", config) + return RESULT_SUCCESS + except WebSocketException: + LOGGER.debug("Working but unsupported config: %s", config) + return RESULT_NOT_SUPPORTED + except (OSError, ConnectionFailure) as err: + LOGGER.debug("Failing config: %s, error: %s", config, err) + + return RESULT_NOT_SUCCESSFUL + + def _send_key(self, key): + """Send the key using websocket protocol.""" + if key == "KEY_POWEROFF": + key = "KEY_POWER" + self._get_remote().send_key(key) + + def _get_remote(self): + """Create or return a remote control instance.""" + if self._remote is None: + # We need to create a new instance to reconnect. + try: + LOGGER.debug("Create SamsungTVWS") + self._remote = SamsungTVWS( + host=self.host, + port=self.port, + token=self.token, + timeout=1, + name=VALUE_CONF_NAME, + ) + self._remote.open() + # This is only happening when the auth was switched to DENY + # A removed auth will lead to socket timeout because waiting for auth popup is just an open socket + except ConnectionFailure: + self._notify_callback() + raise + return self._remote diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index b3c5ecd1bf5..95283d9606c 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -2,10 +2,7 @@ import socket from urllib.parse import urlparse -from samsungctl import Remote -from samsungctl.exceptions import AccessDenied, UnhandledResponse import voluptuous as vol -from websocket import WebSocketException from homeassistant import config_entries from homeassistant.components.ssdp import ( @@ -21,23 +18,25 @@ from homeassistant.const import ( CONF_METHOD, CONF_NAME, CONF_PORT, + CONF_TOKEN, ) # pylint:disable=unused-import -from .const import CONF_MANUFACTURER, CONF_MODEL, DOMAIN, LOGGER +from .bridge import SamsungTVBridge +from .const import ( + CONF_MANUFACTURER, + CONF_MODEL, + DOMAIN, + LOGGER, + METHOD_LEGACY, + METHOD_WEBSOCKET, + RESULT_AUTH_MISSING, + RESULT_NOT_SUCCESSFUL, + RESULT_SUCCESS, +) DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str}) - -RESULT_AUTH_MISSING = "auth_missing" -RESULT_SUCCESS = "success" -RESULT_NOT_SUCCESSFUL = "not_successful" -RESULT_NOT_SUPPORTED = "not_supported" - -SUPPORTED_METHODS = ( - {"method": "websocket", "timeout": 1}, - # We need this high timeout because waiting for auth popup is just an open socket - {"method": "legacy", "timeout": 31}, -) +SUPPORTED_METHODS = [METHOD_LEGACY, METHOD_WEBSOCKET] def _get_ip(host): @@ -59,61 +58,39 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._host = None self._ip = None self._manufacturer = None - self._method = None self._model = None self._name = None - self._port = None self._title = None self._id = None + self._bridge = None def _get_entry(self): - return self.async_create_entry( - title=self._title, - data={ - CONF_HOST: self._host, - CONF_ID: self._id, - CONF_IP_ADDRESS: self._ip, - CONF_MANUFACTURER: self._manufacturer, - CONF_METHOD: self._method, - CONF_MODEL: self._model, - CONF_NAME: self._name, - CONF_PORT: self._port, - }, - ) + data = { + CONF_HOST: self._host, + CONF_ID: self._id, + CONF_IP_ADDRESS: self._ip, + CONF_MANUFACTURER: self._manufacturer, + CONF_METHOD: self._bridge.method, + CONF_MODEL: self._model, + CONF_NAME: self._name, + CONF_PORT: self._bridge.port, + } + if self._bridge.token: + data[CONF_TOKEN] = self._bridge.token + return self.async_create_entry(title=self._title, data=data,) def _try_connect(self): """Try to connect and check auth.""" - for cfg in SUPPORTED_METHODS: - config = { - "name": "HomeAssistant", - "description": "HomeAssistant", - "id": "ha.component.samsung", - "host": self._host, - "port": self._port, - } - config.update(cfg) - try: - LOGGER.debug("Try config: %s", config) - with Remote(config.copy()): - LOGGER.debug("Working config: %s", config) - self._method = cfg["method"] - return RESULT_SUCCESS - except AccessDenied: - LOGGER.debug("Working but denied config: %s", config) - return RESULT_AUTH_MISSING - except (UnhandledResponse, WebSocketException): - LOGGER.debug("Working but unsupported config: %s", config) - return RESULT_NOT_SUPPORTED - except OSError as err: - LOGGER.debug("Failing config: %s, error: %s", config, err) - + for method in SUPPORTED_METHODS: + self._bridge = SamsungTVBridge.get_bridge(method, self._host) + result = self._bridge.try_connect() + if result != RESULT_NOT_SUCCESSFUL: + return result LOGGER.debug("No working config found") return RESULT_NOT_SUCCESSFUL async def async_step_import(self, user_input=None): """Handle configuration by yaml file.""" - self._port = user_input.get(CONF_PORT) - return await self.async_step_user(user_input) async def async_step_user(self, user_input=None): @@ -191,7 +168,6 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._manufacturer = user_input.get(CONF_MANUFACTURER) self._model = user_input.get(CONF_MODEL) self._name = user_input.get(CONF_NAME) - self._port = user_input.get(CONF_PORT) self._title = self._model or self._name await self.async_set_unique_id(self._ip) diff --git a/homeassistant/components/samsungtv/const.py b/homeassistant/components/samsungtv/const.py index 46f6fb59a8c..c08f07e6379 100644 --- a/homeassistant/components/samsungtv/const.py +++ b/homeassistant/components/samsungtv/const.py @@ -6,6 +6,18 @@ DOMAIN = "samsungtv" DEFAULT_NAME = "Samsung TV" +VALUE_CONF_NAME = "HomeAssistant" +VALUE_CONF_ID = "ha.component.samsung" + +CONF_DESCRIPTION = "description" CONF_MANUFACTURER = "manufacturer" CONF_MODEL = "model" CONF_ON_ACTION = "turn_on_action" + +RESULT_AUTH_MISSING = "auth_missing" +RESULT_SUCCESS = "success" +RESULT_NOT_SUCCESSFUL = "not_successful" +RESULT_NOT_SUPPORTED = "not_supported" + +METHOD_LEGACY = "legacy" +METHOD_WEBSOCKET = "websocket" diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 3adc3b52eb3..66f71b5c5da 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -3,7 +3,8 @@ "name": "Samsung Smart TV", "documentation": "https://www.home-assistant.io/integrations/samsungtv", "requirements": [ - "samsungctl[websocket]==0.7.1" + "samsungctl[websocket]==0.7.1", + "samsungtvws[websocket]==1.4.0" ], "ssdp": [ { @@ -15,4 +16,4 @@ "@escoand" ], "config_flow": true -} +} \ No newline at end of file diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 8de42d157b7..8fa6a93088a 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -2,9 +2,7 @@ import asyncio from datetime import timedelta -from samsungctl import Remote as SamsungRemote, exceptions as samsung_exceptions import voluptuous as vol -from websocket import WebSocketException from homeassistant.components.media_player import DEVICE_CLASS_TV, MediaPlayerDevice from homeassistant.components.media_player.const import ( @@ -27,6 +25,7 @@ from homeassistant.const import ( CONF_METHOD, CONF_NAME, CONF_PORT, + CONF_TOKEN, STATE_OFF, STATE_ON, ) @@ -34,6 +33,7 @@ import homeassistant.helpers.config_validation as cv 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 KEY_PRESS_TIMEOUT = 1.2 @@ -90,91 +90,40 @@ class SamsungTVDevice(MediaPlayerDevice): # Assume that the TV is in Play mode self._playing = True self._state = None - self._remote = None # Mark the end of a shutdown command (need to wait 15 seconds before # sending the next command to avoid turning the TV back ON). self._end_of_power_off = None - # Generate a configuration for the Samsung library - self._config = { - "name": "HomeAssistant", - "description": "HomeAssistant", - "id": "ha.component.samsung", - "method": config_entry.data[CONF_METHOD], - "port": config_entry.data.get(CONF_PORT), - "host": config_entry.data[CONF_HOST], - "timeout": 1, - } + # Initialize bridge + self._bridge = SamsungTVBridge.get_bridge( + config_entry.data[CONF_METHOD], + config_entry.data[CONF_HOST], + config_entry.data[CONF_PORT], + config_entry.data.get(CONF_TOKEN), + ) + self._bridge.register_reauth_callback(self.access_denied) + + def access_denied(self): + """Access denied callbck.""" + 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, + ) + ) def update(self): """Update state of device.""" if self._power_off_in_progress(): self._state = STATE_OFF else: - if self._remote is not None: - # Close the current remote connection - self._remote.close() - self._remote = None - - try: - self.get_remote() - if self._remote: - self._state = STATE_ON - except ( - samsung_exceptions.UnhandledResponse, - samsung_exceptions.AccessDenied, - ): - # We got a response so it's working. - self._state = STATE_ON - except (OSError, WebSocketException): - # Different reasons, e.g. hostname not resolveable - self._state = STATE_OFF - - def get_remote(self): - """Create or return a remote control instance.""" - if self._remote is None: - # We need to create a new instance to reconnect. - try: - self._remote = SamsungRemote(self._config.copy()) - # This is only happening when the auth was switched to DENY - # A removed auth will lead to socket timeout because waiting for auth popup is just an open socket - except samsung_exceptions.AccessDenied: - self.hass.async_create_task( - self.hass.config_entries.flow.async_init( - DOMAIN, - context={"source": "reauth"}, - data=self._config_entry.data, - ) - ) - raise - - return self._remote + self._state = STATE_ON if self._bridge.is_on() else STATE_OFF def send_key(self, key): """Send a key to the tv and handles exceptions.""" - if self._power_off_in_progress() and key not in ("KEY_POWER", "KEY_POWEROFF"): + if self._power_off_in_progress() and key != "KEY_POWEROFF": LOGGER.info("TV is powering off, not sending command: %s", key) return - try: - # recreate connection if connection was dead - retry_count = 1 - for _ in range(retry_count + 1): - try: - self.get_remote().control(key) - break - except ( - samsung_exceptions.ConnectionClosed, - BrokenPipeError, - WebSocketException, - ): - # BrokenPipe can occur when the commands is sent to fast - # WebSocketException can occur when timed out - self._remote = None - except (samsung_exceptions.UnhandledResponse, samsung_exceptions.AccessDenied): - # We got a response so it's on. - LOGGER.debug("Failed sending command %s", key, exc_info=True) - except OSError: - # Different reasons, e.g. hostname not resolveable - pass + self._bridge.send_key(key) def _power_off_in_progress(self): return ( @@ -233,16 +182,9 @@ class SamsungTVDevice(MediaPlayerDevice): """Turn off media player.""" self._end_of_power_off = dt_util.utcnow() + timedelta(seconds=15) - if self._config["method"] == "websocket": - self.send_key("KEY_POWER") - else: - self.send_key("KEY_POWEROFF") + self.send_key("KEY_POWEROFF") # Force closing of remote session to provide instant UI feedback - try: - self.get_remote().close() - self._remote = None - except OSError: - LOGGER.debug("Could not establish connection.") + self._bridge.close_remote() def volume_up(self): """Volume up the media player.""" diff --git a/requirements_all.txt b/requirements_all.txt index 022d81aff49..63571a44625 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1815,6 +1815,9 @@ saltbox==0.1.3 # homeassistant.components.samsungtv samsungctl[websocket]==0.7.1 +# homeassistant.components.samsungtv +samsungtvws[websocket]==1.4.0 + # homeassistant.components.satel_integra satel_integra==0.3.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bb76076b4a..92c3487b995 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -625,6 +625,9 @@ rxv==0.6.0 # homeassistant.components.samsungtv samsungctl[websocket]==0.7.1 +# homeassistant.components.samsungtv +samsungtvws[websocket]==1.4.0 + # homeassistant.components.sense sense_energy==0.7.0 diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 8bca98f78f3..5485ee95827 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import call, patch from asynctest import mock import pytest from samsungctl.exceptions import AccessDenied, UnhandledResponse +from samsungtvws.exceptions import ConnectionFailure from websocket import WebSocketProtocolException from homeassistant.components.samsungtv.const import ( @@ -36,15 +37,6 @@ MOCK_SSDP_DATA_NOPREFIX = { ATTR_UPNP_UDN: "fake2_uuid", } -AUTODETECT_WEBSOCKET = { - "name": "HomeAssistant", - "description": "HomeAssistant", - "id": "ha.component.samsung", - "method": "websocket", - "port": None, - "host": "fake_host", - "timeout": 1, -} AUTODETECT_LEGACY = { "name": "HomeAssistant", "description": "HomeAssistant", @@ -59,7 +51,9 @@ AUTODETECT_LEGACY = { @pytest.fixture(name="remote") def remote_fixture(): """Patch the samsungctl Remote.""" - with patch("samsungctl.Remote") as remote_class, patch( + with patch( + "homeassistant.components.samsungtv.bridge.Remote" + ) as remote_class, patch( "homeassistant.components.samsungtv.config_flow.socket" ) as socket_class: remote = mock.Mock() @@ -71,9 +65,25 @@ def remote_fixture(): yield remote -async def test_user(hass, remote): - """Test starting a flow by user.""" +@pytest.fixture(name="remotews") +def remotews_fixture(): + """Patch the samsungtvws SamsungTVWS.""" + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWS" + ) as remotews_class, patch( + "homeassistant.components.samsungtv.config_flow.socket" + ) as socket_class: + remotews = mock.Mock() + remotews.__enter__ = mock.Mock() + remotews.__exit__ = mock.Mock() + remotews_class.return_value = remotews + socket = mock.Mock() + socket_class.return_value = socket + yield remotews + +async def test_user_legacy(hass, remote): + """Test starting a flow by user.""" # show form result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"} @@ -85,23 +95,51 @@ async def test_user(hass, remote): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input=MOCK_USER_DATA ) + # legacy tv entry created assert result["type"] == "create_entry" assert result["title"] == "fake_name" assert result["data"][CONF_HOST] == "fake_host" assert result["data"][CONF_NAME] == "fake_name" + assert result["data"][CONF_METHOD] == "legacy" assert result["data"][CONF_MANUFACTURER] is None assert result["data"][CONF_MODEL] is None assert result["data"][CONF_ID] is None -async def test_user_missing_auth(hass): +async def test_user_websocket(hass, remotews): + """Test starting a flow by user.""" + with patch( + "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom") + ): + # show form + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"} + ) + assert result["type"] == "form" + assert result["step_id"] == "user" + + # entry was added + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=MOCK_USER_DATA + ) + # legacy tv entry created + assert result["type"] == "create_entry" + assert result["title"] == "fake_name" + assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_NAME] == "fake_name" + assert result["data"][CONF_METHOD] == "websocket" + assert result["data"][CONF_MANUFACTURER] is None + assert result["data"][CONF_MODEL] is None + assert result["data"][CONF_ID] is None + + +async def test_user_legacy_missing_auth(hass): """Test starting a flow by user with authentication.""" with patch( - "homeassistant.components.samsungtv.config_flow.Remote", + "homeassistant.components.samsungtv.bridge.Remote", side_effect=AccessDenied("Boom"), ), patch("homeassistant.components.samsungtv.config_flow.socket"): - - # missing authentication + # legacy device missing authentication result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA ) @@ -109,14 +147,31 @@ async def test_user_missing_auth(hass): assert result["reason"] == "auth_missing" -async def test_user_not_supported(hass): +async def test_user_legacy_not_supported(hass): """Test starting a flow by user for not supported device.""" with patch( - "homeassistant.components.samsungtv.config_flow.Remote", + "homeassistant.components.samsungtv.bridge.Remote", side_effect=UnhandledResponse("Boom"), ), patch("homeassistant.components.samsungtv.config_flow.socket"): + # legacy device not supported + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA + ) + assert result["type"] == "abort" + assert result["reason"] == "not_supported" - # device not supported + +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"), + ), patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWS", + side_effect=WebSocketProtocolException("Boom"), + ), patch( + "homeassistant.components.samsungtv.config_flow.socket" + ): + # websocket device not supported result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA ) @@ -127,11 +182,30 @@ async def test_user_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.config_flow.Remote", + "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"), + ), patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWS", side_effect=OSError("Boom"), - ), patch("homeassistant.components.samsungtv.config_flow.socket"): + ), patch( + "homeassistant.components.samsungtv.config_flow.socket" + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA + ) + assert result["type"] == "abort" + assert result["reason"] == "not_successful" - # device not connectable + +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"), + ), patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWS", + side_effect=ConnectionFailure("Boom"), + ), patch( + "homeassistant.components.samsungtv.config_flow.socket" + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA ) @@ -202,10 +276,10 @@ async def test_ssdp_noprefix(hass, remote): assert result["data"][CONF_ID] == "fake2_uuid" -async def test_ssdp_missing_auth(hass): +async def test_ssdp_legacy_missing_auth(hass): """Test starting a flow from discovery with authentication.""" with patch( - "homeassistant.components.samsungtv.config_flow.Remote", + "homeassistant.components.samsungtv.bridge.Remote", side_effect=AccessDenied("Boom"), ), patch("homeassistant.components.samsungtv.config_flow.socket"): @@ -224,10 +298,10 @@ async def test_ssdp_missing_auth(hass): assert result["reason"] == "auth_missing" -async def test_ssdp_not_supported(hass): +async def test_ssdp_legacy_not_supported(hass): """Test starting a flow from discovery for not supported device.""" with patch( - "homeassistant.components.samsungtv.config_flow.Remote", + "homeassistant.components.samsungtv.bridge.Remote", side_effect=UnhandledResponse("Boom"), ), patch("homeassistant.components.samsungtv.config_flow.socket"): @@ -246,13 +320,16 @@ async def test_ssdp_not_supported(hass): assert result["reason"] == "not_supported" -async def test_ssdp_not_supported_2(hass): +async def test_ssdp_websocket_not_supported(hass): """Test starting a flow from discovery for not supported device.""" with patch( - "homeassistant.components.samsungtv.config_flow.Remote", + "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"), + ), patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWS", side_effect=WebSocketProtocolException("Boom"), - ), patch("homeassistant.components.samsungtv.config_flow.socket"): - + ), patch( + "homeassistant.components.samsungtv.config_flow.socket" + ): # confirm to add the entry result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA @@ -271,9 +348,39 @@ async def test_ssdp_not_supported_2(hass): async def test_ssdp_not_successful(hass): """Test starting a flow from discovery but no device found.""" with patch( - "homeassistant.components.samsungtv.config_flow.Remote", + "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"), + ), patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWS", side_effect=OSError("Boom"), - ), patch("homeassistant.components.samsungtv.config_flow.socket"): + ), patch( + "homeassistant.components.samsungtv.config_flow.socket" + ): + + # confirm to add the entry + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "ssdp"}, data=MOCK_SSDP_DATA + ) + assert result["type"] == "form" + assert result["step_id"] == "confirm" + + # device not found + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input="whatever" + ) + assert result["type"] == "abort" + assert result["reason"] == "not_successful" + + +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"), + ), patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWS", + side_effect=ConnectionFailure("Boom"), + ), patch( + "homeassistant.components.samsungtv.config_flow.socket" + ): # confirm to add the entry result = await hass.config_entries.flow.async_init( @@ -334,22 +441,32 @@ async def test_ssdp_already_configured(hass, remote): assert entry.data[CONF_ID] == "fake_uuid" -async def test_autodetect_websocket(hass, remote): +async def test_autodetect_websocket(hass, remote, remotews): """Test for send key with autodetection of protocol.""" - with patch("homeassistant.components.samsungtv.config_flow.Remote") as remote: + with patch( + "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"), + ), patch("homeassistant.components.samsungtv.bridge.SamsungTVWS") as remotews: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA ) assert result["type"] == "create_entry" assert result["data"][CONF_METHOD] == "websocket" - assert remote.call_count == 1 - assert remote.call_args_list == [call(AUTODETECT_WEBSOCKET)] + assert remotews.call_count == 1 + assert remotews.call_args_list == [ + call( + host="fake_host", + name="HomeAssistant", + port=8001, + timeout=31, + token=None, + ) + ] async def test_autodetect_auth_missing(hass, remote): """Test for send key with autodetection of protocol.""" with patch( - "homeassistant.components.samsungtv.config_flow.Remote", + "homeassistant.components.samsungtv.bridge.Remote", side_effect=[AccessDenied("Boom")], ) as remote: result = await hass.config_entries.flow.async_init( @@ -358,13 +475,13 @@ async def test_autodetect_auth_missing(hass, remote): assert result["type"] == "abort" assert result["reason"] == "auth_missing" assert remote.call_count == 1 - assert remote.call_args_list == [call(AUTODETECT_WEBSOCKET)] + assert remote.call_args_list == [call(AUTODETECT_LEGACY)] async def test_autodetect_not_supported(hass, remote): """Test for send key with autodetection of protocol.""" with patch( - "homeassistant.components.samsungtv.config_flow.Remote", + "homeassistant.components.samsungtv.bridge.Remote", side_effect=[UnhandledResponse("Boom")], ) as remote: result = await hass.config_entries.flow.async_init( @@ -373,40 +490,52 @@ async def test_autodetect_not_supported(hass, remote): assert result["type"] == "abort" assert result["reason"] == "not_supported" assert remote.call_count == 1 - assert remote.call_args_list == [call(AUTODETECT_WEBSOCKET)] + assert remote.call_args_list == [call(AUTODETECT_LEGACY)] async def test_autodetect_legacy(hass, remote): """Test for send key with autodetection of protocol.""" - with patch( - "homeassistant.components.samsungtv.config_flow.Remote", - side_effect=[OSError("Boom"), mock.DEFAULT], - ) as remote: + with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA ) assert result["type"] == "create_entry" assert result["data"][CONF_METHOD] == "legacy" - assert remote.call_count == 2 - assert remote.call_args_list == [ - call(AUTODETECT_WEBSOCKET), - call(AUTODETECT_LEGACY), - ] + assert remote.call_count == 1 + assert remote.call_args_list == [call(AUTODETECT_LEGACY)] -async def test_autodetect_none(hass, remote): +async def test_autodetect_none(hass, remote, remotews): """Test for send key with autodetection of protocol.""" with patch( - "homeassistant.components.samsungtv.config_flow.Remote", + "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError("Boom"), + ) as remote, patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWS", side_effect=OSError("Boom"), - ) as remote: + ) as remotews: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": "user"}, data=MOCK_USER_DATA ) assert result["type"] == "abort" assert result["reason"] == "not_successful" - assert remote.call_count == 2 + assert remote.call_count == 1 assert remote.call_args_list == [ - call(AUTODETECT_WEBSOCKET), call(AUTODETECT_LEGACY), ] + assert remotews.call_count == 2 + assert remotews.call_args_list == [ + call( + host="fake_host", + name="HomeAssistant", + port=8001, + timeout=31, + token=None, + ), + call( + host="fake_host", + name="HomeAssistant", + port=8002, + timeout=31, + token=None, + ), + ] diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index cd31434e6b0..064a870931f 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -1,6 +1,6 @@ """Tests for the Samsung TV Integration.""" -from unittest.mock import call, patch - +from asynctest import mock +from asynctest.mock import call, patch import pytest from homeassistant.components.media_player.const import DOMAIN, SUPPORT_TURN_ON @@ -14,7 +14,6 @@ from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, CONF_HOST, CONF_NAME, - CONF_PORT, SERVICE_VOLUME_UP, ) from homeassistant.setup import async_setup_component @@ -25,7 +24,6 @@ MOCK_CONFIG = { { CONF_HOST: "fake_host", CONF_NAME: "fake_name", - CONF_PORT: 1234, CONF_ON_ACTION: [{"delay": "00:00:01"}], } ] @@ -34,8 +32,7 @@ REMOTE_CALL = { "name": "HomeAssistant", "description": "HomeAssistant", "id": "ha.component.samsung", - "method": "websocket", - "port": MOCK_CONFIG[SAMSUNGTV_DOMAIN][0][CONF_PORT], + "method": "legacy", "host": MOCK_CONFIG[SAMSUNGTV_DOMAIN][0][CONF_HOST], "timeout": 1, } @@ -44,11 +41,17 @@ REMOTE_CALL = { @pytest.fixture(name="remote") def remote_fixture(): """Patch the samsungctl Remote.""" - with patch("homeassistant.components.samsungtv.socket") as socket1, patch( + with patch( + "homeassistant.components.samsungtv.bridge.Remote" + ) as remote_class, patch( "homeassistant.components.samsungtv.config_flow.socket" - ) as socket2, patch("homeassistant.components.samsungtv.config_flow.Remote"), patch( - "homeassistant.components.samsungtv.media_player.SamsungRemote" - ) as remote: + ) as socket1, patch( + "homeassistant.components.samsungtv.socket" + ) as socket2: + remote = mock.Mock() + remote.__enter__ = mock.Mock() + remote.__exit__ = mock.Mock() + remote_class.return_value = remote socket1.gethostbyname.return_value = "FAKE_IP_ADDRESS" socket2.gethostbyname.return_value = "FAKE_IP_ADDRESS" yield remote @@ -56,22 +59,24 @@ def remote_fixture(): async def test_setup(hass, remote): """Test Samsung TV integration is setup.""" - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID) + with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: + await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) + await hass.async_block_till_done() + state = hass.states.get(ENTITY_ID) - # test name and turn_on - assert state - assert state.name == "fake_name" - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_SAMSUNGTV | SUPPORT_TURN_ON - ) + # test name and turn_on + assert state + assert state.name == "fake_name" + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] + == SUPPORT_SAMSUNGTV | SUPPORT_TURN_ON + ) - # test host and port - assert await hass.services.async_call( - DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True - ) - assert remote.mock_calls[0] == call(REMOTE_CALL) + # test host and port + assert await hass.services.async_call( + DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True + ) + assert remote.call_args == call(REMOTE_CALL) async def test_setup_duplicate_config(hass, remote, caplog): diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index ba245ce7d6f..dff7525d980 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -7,6 +7,7 @@ from asynctest import mock from asynctest.mock import call, patch import pytest from samsungctl import exceptions +from samsungtvws.exceptions import ConnectionFailure from websocket import WebSocketException from homeassistant.components.media_player import DEVICE_CLASS_TV @@ -54,6 +55,17 @@ from tests.common import async_fire_time_changed ENTITY_ID = f"{DOMAIN}.fake" MOCK_CONFIG = { + SAMSUNGTV_DOMAIN: [ + { + CONF_HOST: "fake", + CONF_NAME: "fake", + CONF_PORT: 55000, + CONF_ON_ACTION: [{"delay": "00:00:01"}], + } + ] +} + +MOCK_CONFIGWS = { SAMSUNGTV_DOMAIN: [ { CONF_HOST: "fake", @@ -75,14 +87,35 @@ MOCK_CONFIG_NOTURNON = { @pytest.fixture(name="remote") def remote_fixture(): """Patch the samsungctl Remote.""" - with patch("homeassistant.components.samsungtv.config_flow.Remote"), patch( + with patch( + "homeassistant.components.samsungtv.bridge.Remote" + ) as remote_class, patch( "homeassistant.components.samsungtv.config_flow.socket" ) as socket1, patch( - "homeassistant.components.samsungtv.media_player.SamsungRemote" - ) as remote_class, patch( "homeassistant.components.samsungtv.socket" ) as socket2: remote = mock.Mock() + remote.__enter__ = mock.Mock() + remote.__exit__ = mock.Mock() + remote_class.return_value = remote + socket1.gethostbyname.return_value = "FAKE_IP_ADDRESS" + socket2.gethostbyname.return_value = "FAKE_IP_ADDRESS" + yield remote + + +@pytest.fixture(name="remotews") +def remotews_fixture(): + """Patch the samsungtvws SamsungTVWS.""" + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWS" + ) as remote_class, patch( + "homeassistant.components.samsungtv.config_flow.socket" + ) as socket1, patch( + "homeassistant.components.samsungtv.socket" + ) as socket2: + remote = mock.Mock() + remote.__enter__ = mock.Mock() + remote.__exit__ = mock.Mock() remote_class.return_value = remote socket1.gethostbyname.return_value = "FAKE_IP_ADDRESS" socket2.gethostbyname.return_value = "FAKE_IP_ADDRESS" @@ -140,7 +173,7 @@ async def test_update_off(hass, remote, mock_now): await setup_samsungtv(hass, MOCK_CONFIG) with patch( - "homeassistant.components.samsungtv.media_player.SamsungRemote", + "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), mock.DEFAULT], ): @@ -154,14 +187,13 @@ async def test_update_off(hass, remote, mock_now): async def test_update_access_denied(hass, remote, mock_now): - """Testing update tv unhandled response exception.""" + """Testing update tv access denied exception.""" await setup_samsungtv(hass, MOCK_CONFIG) with patch( - "homeassistant.components.samsungtv.media_player.SamsungRemote", + "homeassistant.components.samsungtv.bridge.Remote", side_effect=exceptions.AccessDenied("Boom"), ): - next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): async_fire_time_changed(hass, next_update) @@ -174,12 +206,36 @@ async def test_update_access_denied(hass, remote, mock_now): ] +async def test_update_connection_failure(hass, remotews, mock_now): + """Testing update tv connection failure exception.""" + with patch( + "homeassistant.components.samsungtv.bridge.Remote", + side_effect=[OSError("Boom"), mock.DEFAULT], + ): + await setup_samsungtv(hass, MOCK_CONFIGWS) + + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWS", + side_effect=ConnectionFailure("Boom"), + ): + next_update = mock_now + timedelta(minutes=5) + with patch("homeassistant.util.dt.utcnow", return_value=next_update): + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + assert [ + flow + for flow in hass.config_entries.flow.async_progress() + if flow["context"]["source"] == "reauth" + ] + + async def test_update_unhandled_response(hass, remote, mock_now): """Testing update tv unhandled response exception.""" await setup_samsungtv(hass, MOCK_CONFIG) with patch( - "homeassistant.components.samsungtv.media_player.SamsungRemote", + "homeassistant.components.samsungtv.bridge.Remote", side_effect=[exceptions.UnhandledResponse("Boom"), mock.DEFAULT], ): @@ -334,36 +390,30 @@ async def test_device_class(hass, remote): assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS_TV -async def test_turn_off_websocket(hass, remote): +async def test_turn_off_websocket(hass, remotews): """Test for turn_off.""" - await setup_samsungtv(hass, MOCK_CONFIG) + with patch( + "homeassistant.components.samsungtv.bridge.Remote", + side_effect=[OSError("Boom"), mock.DEFAULT], + ): + await setup_samsungtv(hass, MOCK_CONFIGWS) + assert await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True + ) + # key called + assert remotews.send_key.call_count == 1 + assert remotews.send_key.call_args_list == [call("KEY_POWER")] + + +async def test_turn_off_legacy(hass, remote): + """Test for turn_off.""" + await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True ) # key called assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_POWER")] - - -async def test_turn_off_legacy(hass): - """Test for turn_off.""" - with patch("homeassistant.components.samsungtv.config_flow.socket"), patch( - "homeassistant.components.samsungtv.config_flow.Remote", - side_effect=[OSError("Boom"), mock.DEFAULT], - ), patch( - "homeassistant.components.samsungtv.media_player.SamsungRemote" - ) as remote_class, patch( - "homeassistant.components.samsungtv.socket" - ): - remote = mock.Mock() - remote_class.return_value = remote - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) - assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True - ) - # key called - assert remote.control.call_count == 1 - assert remote.control.call_args_list == [call("KEY_POWEROFF")] + assert remote.control.call_args_list == [call("KEY_POWEROFF")] async def test_turn_off_os_error(hass, remote, caplog): @@ -374,7 +424,7 @@ async def test_turn_off_os_error(hass, remote, caplog): assert await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - assert "Could not establish connection." in caplog.text + assert "Could not establish connection" in caplog.text async def test_volume_up(hass, remote): @@ -526,11 +576,12 @@ async def test_play_media(hass, remote): async def test_play_media_invalid_type(hass, remote): """Test for play_media with invalid media type.""" - with patch( - "homeassistant.components.samsungtv.media_player.SamsungRemote" - ) as remote, patch("homeassistant.components.samsungtv.config_flow.socket"): + with patch("homeassistant.components.samsungtv.bridge.Remote") as remote, patch( + "homeassistant.components.samsungtv.config_flow.socket" + ): url = "https://example.com" await setup_samsungtv(hass, MOCK_CONFIG) + remote.reset_mock() assert await hass.services.async_call( DOMAIN, SERVICE_PLAY_MEDIA, @@ -549,11 +600,12 @@ async def test_play_media_invalid_type(hass, remote): async def test_play_media_channel_as_string(hass, remote): """Test for play_media with invalid channel as string.""" - with patch( - "homeassistant.components.samsungtv.media_player.SamsungRemote" - ) as remote, patch("homeassistant.components.samsungtv.config_flow.socket"): + with patch("homeassistant.components.samsungtv.bridge.Remote") as remote, patch( + "homeassistant.components.samsungtv.config_flow.socket" + ): url = "https://example.com" await setup_samsungtv(hass, MOCK_CONFIG) + remote.reset_mock() assert await hass.services.async_call( DOMAIN, SERVICE_PLAY_MEDIA, @@ -572,10 +624,11 @@ async def test_play_media_channel_as_string(hass, remote): async def test_play_media_channel_as_non_positive(hass, remote): """Test for play_media with invalid channel as non positive integer.""" - with patch( - "homeassistant.components.samsungtv.media_player.SamsungRemote" - ) as remote, patch("homeassistant.components.samsungtv.config_flow.socket"): + with patch("homeassistant.components.samsungtv.bridge.Remote") as remote, patch( + "homeassistant.components.samsungtv.config_flow.socket" + ): await setup_samsungtv(hass, MOCK_CONFIG) + remote.reset_mock() assert await hass.services.async_call( DOMAIN, SERVICE_PLAY_MEDIA, @@ -610,10 +663,11 @@ async def test_select_source(hass, remote): async def test_select_source_invalid_source(hass, remote): """Test for select_source with invalid source.""" - with patch( - "homeassistant.components.samsungtv.media_player.SamsungRemote" - ) as remote, patch("homeassistant.components.samsungtv.config_flow.socket"): + with patch("homeassistant.components.samsungtv.bridge.Remote") as remote, patch( + "homeassistant.components.samsungtv.config_flow.socket" + ): await setup_samsungtv(hass, MOCK_CONFIG) + remote.reset_mock() assert await hass.services.async_call( DOMAIN, SERVICE_SELECT_SOURCE, From c11a462f510b66a3f5bae2f801dee21f44180e48 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Tue, 10 Mar 2020 11:06:44 +0000 Subject: [PATCH 315/416] Clean up homekit_controller entity setup (#32628) --- .../components/homekit_controller/__init__.py | 74 ++++++++++--------- .../homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 42 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index b5dd848aa77..f697449bbdb 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -3,7 +3,9 @@ import logging import os import aiohomekit +from aiohomekit.model import Accessory from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.services import Service, ServicesTypes from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady @@ -11,7 +13,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity from .config_flow import normalize_hkid -from .connection import HKDevice, get_accessory_information +from .connection import HKDevice from .const import CONTROLLER, DOMAIN, ENTITY_MAP, KNOWN_DEVICES from .storage import EntityMapStorage @@ -37,6 +39,23 @@ class HomeKitEntity(Entity): self._signals = [] + @property + def accessory(self) -> Accessory: + """Return an Accessory model that this entity is attached to.""" + return self._accessory.entity_map.aid(self._aid) + + @property + def accessory_info(self) -> Service: + """Information about the make and model of an accessory.""" + return self.accessory.services.first( + service_type=ServicesTypes.ACCESSORY_INFORMATION + ) + + @property + def service(self) -> Service: + """Return a Service model that this entity is attached to.""" + return self.accessory.services.iid(self._iid) + async def async_added_to_hass(self): """Entity added to hass.""" self._signals.append( @@ -67,8 +86,6 @@ class HomeKitEntity(Entity): def setup(self): """Configure an entity baed on its HomeKit characteristics metadata.""" - accessories = self._accessory.accessories - get_uuid = CharacteristicsTypes.get_uuid characteristic_types = [get_uuid(c) for c in self.get_characteristic_types()] @@ -77,33 +94,19 @@ class HomeKitEntity(Entity): self._chars = {} self._char_names = {} - for accessory in accessories: - if accessory["aid"] != self._aid: + # Setup events and/or polling for characteristics directly attached to this entity + for char in self.service.characteristics: + if char.type not in characteristic_types: continue - self._accessory_info = get_accessory_information(accessory) - for service in accessory["services"]: - if service["iid"] != self._iid: - continue - for char in service["characteristics"]: - try: - uuid = CharacteristicsTypes.get_uuid(char["type"]) - except KeyError: - # If a KeyError is raised its a non-standard - # characteristic. We must ignore it in this case. - continue - if uuid not in characteristic_types: - continue - self._setup_characteristic(char) + self._setup_characteristic(char.to_accessory_and_service_list()) - accessory = self._accessory.entity_map.aid(self._aid) - this_service = accessory.services.iid(self._iid) - for child_service in accessory.services.filter( - parent_service=this_service - ): - for char in child_service.characteristics: - if char.type not in characteristic_types: - continue - self._setup_characteristic(char.to_accessory_and_service_list()) + # Setup events and/or polling for characteristics attached to sub-services of this + # entity (like an INPUT_SOURCE). + for service in self.accessory.services.filter(parent_service=self.service): + for char in service.characteristics: + if char.type not in characteristic_types: + continue + self._setup_characteristic(char.to_accessory_and_service_list()) def _setup_characteristic(self, char): """Configure an entity based on a HomeKit characteristics metadata.""" @@ -165,13 +168,13 @@ class HomeKitEntity(Entity): @property def unique_id(self): """Return the ID of this device.""" - serial = self._accessory_info["serial-number"] + serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) return f"homekit-{serial}-{self._iid}" @property def name(self): """Return the name of the device if any.""" - return self._accessory_info.get("name") + return self.accessory_info.value(CharacteristicsTypes.NAME) @property def available(self) -> bool: @@ -181,14 +184,15 @@ class HomeKitEntity(Entity): @property def device_info(self): """Return the device info.""" - accessory_serial = self._accessory_info["serial-number"] + info = self.accessory_info + accessory_serial = info.value(CharacteristicsTypes.SERIAL_NUMBER) device_info = { "identifiers": {(DOMAIN, "serial-number", accessory_serial)}, - "name": self._accessory_info["name"], - "manufacturer": self._accessory_info.get("manufacturer", ""), - "model": self._accessory_info.get("model", ""), - "sw_version": self._accessory_info.get("firmware.revision", ""), + "name": info.value(CharacteristicsTypes.NAME), + "manufacturer": info.value(CharacteristicsTypes.MANUFACTURER, ""), + "model": info.value(CharacteristicsTypes.MODEL, ""), + "sw_version": info.value(CharacteristicsTypes.FIRMWARE_REVISION, ""), } # Some devices only have a single accessory - we don't add a diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 7582bc10ae5..9207bb53b3e 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit[IP]==0.2.24"], + "requirements": ["aiohomekit[IP]==0.2.25"], "dependencies": [], "zeroconf": ["_hap._tcp.local."], "codeowners": ["@Jc2k"] diff --git a/requirements_all.txt b/requirements_all.txt index 63571a44625..dfbe9ef8c4e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -163,7 +163,7 @@ aioftp==0.12.0 aioharmony==0.1.13 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.24 +aiohomekit[IP]==0.2.25 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 92c3487b995..0580e954e10 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -62,7 +62,7 @@ aiobotocore==0.11.1 aioesphomeapi==2.6.1 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.24 +aiohomekit[IP]==0.2.25 # homeassistant.components.emulated_hue # homeassistant.components.http From 0a25e86fab78caeb28be6162255f5e3561527687 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 10 Mar 2020 05:01:52 -0700 Subject: [PATCH 316/416] Use callback instead of async methods in Timer (#32638) --- homeassistant/components/timer/__init__.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index abf3a6ab0f7..5172322a63d 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -239,7 +239,8 @@ class Timer(RestoreEntity): state = await self.async_get_last_state() self._state = state and state.state == state - async def async_start(self, duration): + @callback + def async_start(self, duration): """Start a timer.""" if self._listener: self._listener() @@ -267,11 +268,12 @@ class Timer(RestoreEntity): self.hass.bus.async_fire(event, {"entity_id": self.entity_id}) self._listener = async_track_point_in_utc_time( - self.hass, self.async_finished, self._end + self.hass, self._async_finished, self._end ) self.async_write_ha_state() - async def async_pause(self): + @callback + def async_pause(self): """Pause a timer.""" if self._listener is None: return @@ -284,7 +286,8 @@ class Timer(RestoreEntity): self.hass.bus.async_fire(EVENT_TIMER_PAUSED, {"entity_id": self.entity_id}) self.async_write_ha_state() - async def async_cancel(self): + @callback + def async_cancel(self): """Cancel a timer.""" if self._listener: self._listener() @@ -295,7 +298,8 @@ class Timer(RestoreEntity): self.hass.bus.async_fire(EVENT_TIMER_CANCELLED, {"entity_id": self.entity_id}) self.async_write_ha_state() - async def async_finish(self): + @callback + def async_finish(self): """Reset and updates the states, fire finished event.""" if self._state != STATUS_ACTIVE: return @@ -306,7 +310,8 @@ class Timer(RestoreEntity): self.hass.bus.async_fire(EVENT_TIMER_FINISHED, {"entity_id": self.entity_id}) self.async_write_ha_state() - async def async_finished(self, time): + @callback + def _async_finished(self, time): """Reset and updates the states, fire finished event.""" if self._state != STATUS_ACTIVE: return From 16d7f84be732a21fcf52f989f656b61b4d92ea03 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 10 Mar 2020 18:27:25 +0100 Subject: [PATCH 317/416] UniFi - Fix block functionality (#32625) * Fix block functionality * Remove unrelated changes * Bump dependency to v15 * Run requirement script --- homeassistant/components/unifi/controller.py | 2 +- homeassistant/components/unifi/manifest.json | 2 +- homeassistant/components/unifi/switch.py | 11 +++++++++-- .../components/unifi/unifi_client.py | 19 +++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 32 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index 7da36131058..a6981aeddee 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -162,7 +162,7 @@ class UniFiController: WIRELESS_GUEST_CONNECTED, ): self.update_wireless_clients() - elif data.get("clients") or data.get("devices"): + elif "clients" in data or "devices" in data: async_dispatcher_send(self.hass, self.signal_update) @property diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 85633ebf131..01aa245f608 100644 --- a/homeassistant/components/unifi/manifest.json +++ b/homeassistant/components/unifi/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/unifi", "requirements": [ - "aiounifi==14" + "aiounifi==15" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 84e85188ede..0df019de02c 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -262,7 +262,7 @@ class UniFiPOEClientSwitch(UniFiClient, SwitchDevice, RestoreEntity): """Shortcut to the switch port that client is connected to.""" try: return self.device.ports[self.client.sw_port] - except TypeError: + except (AttributeError, KeyError, TypeError): LOGGER.warning( "Entity %s reports faulty device %s or port %s", self.entity_id, @@ -282,7 +282,7 @@ class UniFiBlockClientSwitch(UniFiClient, SwitchDevice): @property def is_on(self): """Return true if client is allowed to connect.""" - return not self.client.blocked + return not self.is_blocked async def async_turn_on(self, **kwargs): """Turn on connectivity for client.""" @@ -291,3 +291,10 @@ class UniFiBlockClientSwitch(UniFiClient, SwitchDevice): async def async_turn_off(self, **kwargs): """Turn off connectivity for client.""" await self.controller.api.clients.async_block(self.client.mac) + + @property + def icon(self): + """Return the icon to use in the frontend.""" + if self.is_blocked: + return "mdi:network-off" + return "mdi:network" diff --git a/homeassistant/components/unifi/unifi_client.py b/homeassistant/components/unifi/unifi_client.py index f9e77d47c0e..4c1ce402e7f 100644 --- a/homeassistant/components/unifi/unifi_client.py +++ b/homeassistant/components/unifi/unifi_client.py @@ -2,6 +2,14 @@ import logging +from aiounifi.api import SOURCE_EVENT +from aiounifi.events import ( + WIRED_CLIENT_BLOCKED, + WIRED_CLIENT_UNBLOCKED, + WIRELESS_CLIENT_BLOCKED, + WIRELESS_CLIENT_UNBLOCKED, +) + from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -9,6 +17,9 @@ from homeassistant.helpers.entity import Entity LOGGER = logging.getLogger(__name__) +CLIENT_BLOCKED = (WIRED_CLIENT_BLOCKED, WIRELESS_CLIENT_BLOCKED) +CLIENT_UNBLOCKED = (WIRED_CLIENT_UNBLOCKED, WIRELESS_CLIENT_UNBLOCKED) + class UniFiClient(Entity): """Base class for UniFi clients.""" @@ -18,7 +29,9 @@ class UniFiClient(Entity): self.client = client self.controller = controller self.listeners = [] + self.is_wired = self.client.mac not in controller.wireless_clients + self.is_blocked = self.client.blocked async def async_added_to_hass(self) -> None: """Client entity created.""" @@ -41,6 +54,12 @@ class UniFiClient(Entity): """Update the clients state.""" if self.is_wired and self.client.mac in self.controller.wireless_clients: self.is_wired = False + + if self.client.last_updated == SOURCE_EVENT: + + if self.client.event.event in CLIENT_BLOCKED + CLIENT_UNBLOCKED: + self.is_blocked = self.client.event.event in CLIENT_BLOCKED + LOGGER.debug("Updating client %s %s", self.entity_id, self.client.mac) self.async_schedule_update_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index dfbe9ef8c4e..678bf3ba674 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -203,7 +203,7 @@ aiopylgtv==0.3.3 aioswitcher==2019.4.26 # homeassistant.components.unifi -aiounifi==14 +aiounifi==15 # homeassistant.components.wwlln aiowwlln==2.0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0580e954e10..00e04b8fd99 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ aiopylgtv==0.3.3 aioswitcher==2019.4.26 # homeassistant.components.unifi -aiounifi==14 +aiounifi==15 # homeassistant.components.wwlln aiowwlln==2.0.2 From b2bb9cf1347c5add9572654aea799d356725e03a Mon Sep 17 00:00:00 2001 From: Vilppu Vuorinen Date: Tue, 10 Mar 2020 20:00:07 +0200 Subject: [PATCH 318/416] Add support for MELCloud Air-to-Water devices (#32078) * Add support for melcloud Air-to-Water devices * Add water_heater entity for the water heater component. * Add individual climate entities for 0-2 supported radiator zones. * Add sensors for zone room temperatures, outdoor temperature and tank temperature. * Update .coveragerc * Use device_state_attributes Co-Authored-By: Martin Hjelmare * Apply suggestions from code review Co-Authored-By: Martin Hjelmare * Complete state_attributes -> device_state_attributes migration * Move constants to top of file * Remove async_turn_on/off * Drop mac from ATW unique_ids * Add MAC to device_info connections * Remove redundant ABC inheritance * Update homeassistant/components/melcloud/water_heater.py Co-Authored-By: Martin Hjelmare Co-authored-by: Martin Hjelmare --- .coveragerc | 2 + homeassistant/components/melcloud/__init__.py | 4 +- homeassistant/components/melcloud/climate.py | 217 +++++++++++++++--- homeassistant/components/melcloud/const.py | 17 +- .../components/melcloud/manifest.json | 2 +- homeassistant/components/melcloud/sensor.py | 75 +++++- .../components/melcloud/water_heater.py | 132 +++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 9 files changed, 392 insertions(+), 61 deletions(-) create mode 100644 homeassistant/components/melcloud/water_heater.py diff --git a/.coveragerc b/.coveragerc index 48701a8563a..89da763f3ca 100644 --- a/.coveragerc +++ b/.coveragerc @@ -414,7 +414,9 @@ omit = homeassistant/components/mediaroom/media_player.py homeassistant/components/melcloud/__init__.py homeassistant/components/melcloud/climate.py + homeassistant/components/melcloud/const.py homeassistant/components/melcloud/sensor.py + homeassistant/components/melcloud/water_heater.py homeassistant/components/message_bird/notify.py homeassistant/components/met/weather.py homeassistant/components/meteo_france/__init__.py diff --git a/homeassistant/components/melcloud/__init__.py b/homeassistant/components/melcloud/__init__.py index ef932f36aa4..0e81d6101b3 100644 --- a/homeassistant/components/melcloud/__init__.py +++ b/homeassistant/components/melcloud/__init__.py @@ -13,6 +13,7 @@ from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_TOKEN, CONF_USERNAME from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util import Throttle @@ -22,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -PLATFORMS = ["climate", "sensor"] +PLATFORMS = ["climate", "sensor", "water_heater"] CONF_LANGUAGE = "language" CONFIG_SCHEMA = vol.Schema( @@ -128,6 +129,7 @@ class MelCloudDevice: def device_info(self): """Return a device description for device registry.""" _device_info = { + "connections": {(CONNECTION_NETWORK_MAC, self.device.mac)}, "identifiers": {(DOMAIN, f"{self.device.mac}-{self.device.serial}")}, "manufacturer": "Mitsubishi Electric", "name": self.name, diff --git a/homeassistant/components/melcloud/climate.py b/homeassistant/components/melcloud/climate.py index 95cb1489f45..c661b1a59ad 100644 --- a/homeassistant/components/melcloud/climate.py +++ b/homeassistant/components/melcloud/climate.py @@ -1,14 +1,26 @@ """Platform for climate integration.""" from datetime import timedelta import logging -from typing import List, Optional +from typing import Any, Dict, List, Optional -from pymelcloud import DEVICE_TYPE_ATA +from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW, AtaDevice, AtwDevice +import pymelcloud.ata_device as ata +import pymelcloud.atw_device as atw +from pymelcloud.atw_device import ( + PROPERTY_ZONE_1_OPERATION_MODE, + PROPERTY_ZONE_2_OPERATION_MODE, + Zone, +) from homeassistant.components.climate import ClimateDevice from homeassistant.components.climate.const import ( DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, SUPPORT_FAN_MODE, SUPPORT_TARGET_TEMPERATURE, @@ -19,51 +31,90 @@ from homeassistant.helpers.typing import HomeAssistantType from homeassistant.util.temperature import convert as convert_temperature from . import MelCloudDevice -from .const import DOMAIN, HVAC_MODE_LOOKUP, HVAC_MODE_REVERSE_LOOKUP, TEMP_UNIT_LOOKUP +from .const import ATTR_STATUS, DOMAIN, TEMP_UNIT_LOOKUP SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) +ATA_HVAC_MODE_LOOKUP = { + ata.OPERATION_MODE_HEAT: HVAC_MODE_HEAT, + ata.OPERATION_MODE_DRY: HVAC_MODE_DRY, + ata.OPERATION_MODE_COOL: HVAC_MODE_COOL, + ata.OPERATION_MODE_FAN_ONLY: HVAC_MODE_FAN_ONLY, + ata.OPERATION_MODE_HEAT_COOL: HVAC_MODE_HEAT_COOL, +} +ATA_HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in ATA_HVAC_MODE_LOOKUP.items()} + + +ATW_ZONE_HVAC_MODE_LOOKUP = { + atw.ZONE_OPERATION_MODE_HEAT: HVAC_MODE_HEAT, + atw.ZONE_OPERATION_MODE_COOL: HVAC_MODE_COOL, +} +ATW_ZONE_HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in ATW_ZONE_HVAC_MODE_LOOKUP.items()} + + async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, async_add_entities ): """Set up MelCloud device climate based on config_entry.""" mel_devices = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [AtaDeviceClimate(mel_device) for mel_device in mel_devices[DEVICE_TYPE_ATA]], + [ + AtaDeviceClimate(mel_device, mel_device.device) + for mel_device in mel_devices[DEVICE_TYPE_ATA] + ] + + [ + AtwDeviceZoneClimate(mel_device, mel_device.device, zone) + for mel_device in mel_devices[DEVICE_TYPE_ATW] + for zone in mel_device.device.zones + ], True, ) -class AtaDeviceClimate(ClimateDevice): - """Air-to-Air climate device.""" +class MelCloudClimate(ClimateDevice): + """Base climate device.""" def __init__(self, device: MelCloudDevice): """Initialize the climate.""" - self._api = device - self._device = self._api.device + self.api = device + self._base_device = self.api.device self._name = device.name - @property - def unique_id(self) -> Optional[str]: - """Return a unique ID.""" - return f"{self._device.serial}-{self._device.mac}" - - @property - def name(self): - """Return the display name of this light.""" - return self._name - async def async_update(self): """Update state from MELCloud.""" - await self._api.async_update() + await self.api.async_update() @property def device_info(self): """Return a device description for device registry.""" - return self._api.device_info + return self.api.device_info + + @property + def target_temperature_step(self) -> Optional[float]: + """Return the supported step of target temperature.""" + return self._base_device.temperature_increment + + +class AtaDeviceClimate(MelCloudClimate): + """Air-to-Air climate device.""" + + def __init__(self, device: MelCloudDevice, ata_device: AtaDevice) -> None: + """Initialize the climate.""" + super().__init__(device) + self._device = ata_device + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return f"{self.api.device.serial}-{self.api.device.mac}" + + @property + def name(self): + """Return the display name of this entity.""" + return self._name @property def temperature_unit(self) -> str: @@ -76,7 +127,7 @@ class AtaDeviceClimate(ClimateDevice): mode = self._device.operation_mode if not self._device.power or mode is None: return HVAC_MODE_OFF - return HVAC_MODE_LOOKUP.get(mode) + return ATA_HVAC_MODE_LOOKUP.get(mode) async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" @@ -84,7 +135,7 @@ class AtaDeviceClimate(ClimateDevice): await self._device.set({"power": False}) return - operation_mode = HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode) + operation_mode = ATA_HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode) if operation_mode is None: raise ValueError(f"Invalid hvac_mode [{hvac_mode}]") @@ -97,7 +148,7 @@ class AtaDeviceClimate(ClimateDevice): def hvac_modes(self) -> List[str]: """Return the list of available hvac operation modes.""" return [HVAC_MODE_OFF] + [ - HVAC_MODE_LOOKUP.get(mode) for mode in self._device.operation_modes + ATA_HVAC_MODE_LOOKUP.get(mode) for mode in self._device.operation_modes ] @property @@ -116,11 +167,6 @@ class AtaDeviceClimate(ClimateDevice): {"target_temperature": kwargs.get("temperature", self.target_temperature)} ) - @property - def target_temperature_step(self) -> Optional[float]: - """Return the supported step of target temperature.""" - return self._device.target_temperature_step - @property def fan_mode(self) -> Optional[str]: """Return the fan setting.""" @@ -135,6 +181,11 @@ class AtaDeviceClimate(ClimateDevice): """Return the list of available fan modes.""" return self._device.fan_speeds + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_FAN_MODE | SUPPORT_TARGET_TEMPERATURE + async def async_turn_on(self) -> None: """Turn the entity on.""" await self._device.set({"power": True}) @@ -143,11 +194,6 @@ class AtaDeviceClimate(ClimateDevice): """Turn the entity off.""" await self._device.set({"power": False}) - @property - def supported_features(self) -> int: - """Return the list of supported features.""" - return SUPPORT_FAN_MODE | SUPPORT_TARGET_TEMPERATURE - @property def min_temp(self) -> float: """Return the minimum temperature.""" @@ -169,3 +215,108 @@ class AtaDeviceClimate(ClimateDevice): return convert_temperature( DEFAULT_MAX_TEMP, TEMP_CELSIUS, self.temperature_unit ) + + +class AtwDeviceZoneClimate(MelCloudClimate): + """Air-to-Water zone climate device.""" + + def __init__( + self, device: MelCloudDevice, atw_device: AtwDevice, atw_zone: Zone + ) -> None: + """Initialize the climate.""" + super().__init__(device) + self._device = atw_device + self._zone = atw_zone + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return f"{self.api.device.serial}-{self._zone.zone_index}" + + @property + def name(self) -> str: + """Return the display name of this entity.""" + return f"{self._name} {self._zone.name}" + + @property + def device_state_attributes(self) -> Dict[str, Any]: + """Return the optional state attributes with device specific additions.""" + data = { + ATTR_STATUS: ATW_ZONE_HVAC_MODE_LOOKUP.get( + self._zone.status, self._zone.status + ) + } + return data + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement used by the platform.""" + return TEMP_UNIT_LOOKUP.get(self._device.temp_unit, TEMP_CELSIUS) + + @property + def hvac_mode(self) -> str: + """Return hvac operation ie. heat, cool mode.""" + mode = self._zone.operation_mode + if not self._device.power or mode is None: + return HVAC_MODE_OFF + return ATW_ZONE_HVAC_MODE_LOOKUP.get(mode, HVAC_MODE_OFF) + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target hvac mode.""" + if hvac_mode == HVAC_MODE_OFF: + await self._device.set({"power": False}) + return + + operation_mode = ATW_ZONE_HVAC_MODE_REVERSE_LOOKUP.get(hvac_mode) + if operation_mode is None: + raise ValueError(f"Invalid hvac_mode [{hvac_mode}]") + + if self._zone.zone_index == 1: + props = {PROPERTY_ZONE_1_OPERATION_MODE: operation_mode} + else: + props = {PROPERTY_ZONE_2_OPERATION_MODE: operation_mode} + if self.hvac_mode == HVAC_MODE_OFF: + props["power"] = True + await self._device.set(props) + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available hvac operation modes.""" + return [self.hvac_mode] + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self._zone.room_temperature + + @property + def target_temperature(self) -> Optional[float]: + """Return the temperature we try to reach.""" + return self._zone.target_temperature + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + await self._zone.set_target_temperature( + kwargs.get("temperature", self.target_temperature) + ) + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE + + @property + def min_temp(self) -> float: + """Return the minimum temperature. + + MELCloud API does not expose radiator zone temperature limits. + """ + return convert_temperature(10, TEMP_CELSIUS, self.temperature_unit) + + @property + def max_temp(self) -> float: + """Return the maximum temperature. + + MELCloud API does not expose radiator zone temperature limits. + """ + return convert_temperature(30, TEMP_CELSIUS, self.temperature_unit) diff --git a/homeassistant/components/melcloud/const.py b/homeassistant/components/melcloud/const.py index e262be2c3fb..c6ce4391294 100644 --- a/homeassistant/components/melcloud/const.py +++ b/homeassistant/components/melcloud/const.py @@ -1,26 +1,11 @@ """Constants for the MELCloud Climate integration.""" -import pymelcloud.ata_device as ata_device from pymelcloud.const import UNIT_TEMP_CELSIUS, UNIT_TEMP_FAHRENHEIT -from homeassistant.components.climate.const import ( - HVAC_MODE_COOL, - HVAC_MODE_DRY, - HVAC_MODE_FAN_ONLY, - HVAC_MODE_HEAT, - HVAC_MODE_HEAT_COOL, -) from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT DOMAIN = "melcloud" -HVAC_MODE_LOOKUP = { - ata_device.OPERATION_MODE_HEAT: HVAC_MODE_HEAT, - ata_device.OPERATION_MODE_DRY: HVAC_MODE_DRY, - ata_device.OPERATION_MODE_COOL: HVAC_MODE_COOL, - ata_device.OPERATION_MODE_FAN_ONLY: HVAC_MODE_FAN_ONLY, - ata_device.OPERATION_MODE_HEAT_COOL: HVAC_MODE_HEAT_COOL, -} -HVAC_MODE_REVERSE_LOOKUP = {v: k for k, v in HVAC_MODE_LOOKUP.items()} +ATTR_STATUS = "status" TEMP_UNIT_LOOKUP = { UNIT_TEMP_CELSIUS: TEMP_CELSIUS, diff --git a/homeassistant/components/melcloud/manifest.json b/homeassistant/components/melcloud/manifest.json index 55edcdd0d9f..61fc9e1b730 100644 --- a/homeassistant/components/melcloud/manifest.json +++ b/homeassistant/components/melcloud/manifest.json @@ -3,7 +3,7 @@ "name": "MELCloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/melcloud", - "requirements": ["pymelcloud==2.1.0"], + "requirements": ["pymelcloud==2.4.0"], "dependencies": [], "codeowners": ["@vilppuvuorinen"] } diff --git a/homeassistant/components/melcloud/sensor.py b/homeassistant/components/melcloud/sensor.py index 8f55906443e..31bfd005ac1 100644 --- a/homeassistant/components/melcloud/sensor.py +++ b/homeassistant/components/melcloud/sensor.py @@ -1,7 +1,8 @@ """Support for MelCloud device sensors.""" import logging -from pymelcloud import DEVICE_TYPE_ATA +from pymelcloud import DEVICE_TYPE_ATA, DEVICE_TYPE_ATW +from pymelcloud.atw_device import Zone from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS from homeassistant.helpers.entity import Entity @@ -16,7 +17,7 @@ ATTR_DEVICE_CLASS = "device_class" ATTR_VALUE_FN = "value_fn" ATTR_ENABLED_FN = "enabled" -SENSORS = { +ATA_SENSORS = { "room_temperature": { ATTR_MEASUREMENT_NAME: "Room Temperature", ATTR_ICON: "mdi:thermometer", @@ -34,6 +35,34 @@ SENSORS = { ATTR_ENABLED_FN: lambda x: x.device.has_energy_consumed_meter, }, } +ATW_SENSORS = { + "outside_temperature": { + ATTR_MEASUREMENT_NAME: "Outside Temperature", + ATTR_ICON: "mdi:thermometer", + ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS), + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_VALUE_FN: lambda x: x.device.outside_temperature, + ATTR_ENABLED_FN: lambda x: True, + }, + "tank_temperature": { + ATTR_MEASUREMENT_NAME: "Tank Temperature", + ATTR_ICON: "mdi:thermometer", + ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS), + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_VALUE_FN: lambda x: x.device.tank_temperature, + ATTR_ENABLED_FN: lambda x: True, + }, +} +ATW_ZONE_SENSORS = { + "room_temperature": { + ATTR_MEASUREMENT_NAME: "Room Temperature", + ATTR_ICON: "mdi:thermometer", + ATTR_UNIT_FN: lambda x: TEMP_UNIT_LOOKUP.get(x.device.temp_unit, TEMP_CELSIUS), + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_VALUE_FN: lambda zone: zone.room_temperature, + ATTR_ENABLED_FN: lambda x: True, + } +} _LOGGER = logging.getLogger(__name__) @@ -43,22 +72,35 @@ async def async_setup_entry(hass, entry, async_add_entities): mel_devices = hass.data[DOMAIN].get(entry.entry_id) async_add_entities( [ - MelCloudSensor(mel_device, measurement, definition) - for measurement, definition in SENSORS.items() + MelDeviceSensor(mel_device, measurement, definition) + for measurement, definition in ATA_SENSORS.items() for mel_device in mel_devices[DEVICE_TYPE_ATA] if definition[ATTR_ENABLED_FN](mel_device) + ] + + [ + MelDeviceSensor(mel_device, measurement, definition) + for measurement, definition in ATW_SENSORS.items() + for mel_device in mel_devices[DEVICE_TYPE_ATW] + if definition[ATTR_ENABLED_FN](mel_device) + ] + + [ + AtwZoneSensor(mel_device, zone, measurement, definition) + for mel_device in mel_devices[DEVICE_TYPE_ATW] + for zone in mel_device.device.zones + for measurement, definition, in ATW_ZONE_SENSORS.items() + if definition[ATTR_ENABLED_FN](zone) ], True, ) -class MelCloudSensor(Entity): +class MelDeviceSensor(Entity): """Representation of a Sensor.""" - def __init__(self, device: MelCloudDevice, measurement, definition): + def __init__(self, api: MelCloudDevice, measurement, definition): """Initialize the sensor.""" - self._api = device - self._name_slug = device.name + self._api = api + self._name_slug = api.name self._measurement = measurement self._def = definition @@ -100,3 +142,20 @@ class MelCloudSensor(Entity): def device_info(self): """Return a device description for device registry.""" return self._api.device_info + + +class AtwZoneSensor(MelDeviceSensor): + """Air-to-Air device sensor.""" + + def __init__( + self, api: MelCloudDevice, zone: Zone, measurement, definition, + ): + """Initialize the sensor.""" + super().__init__(api, measurement, definition) + self._zone = zone + self._name_slug = f"{api.name} {zone.name}" + + @property + def state(self): + """Return zone based state.""" + return self._def[ATTR_VALUE_FN](self._zone) diff --git a/homeassistant/components/melcloud/water_heater.py b/homeassistant/components/melcloud/water_heater.py new file mode 100644 index 00000000000..fa7aff2b640 --- /dev/null +++ b/homeassistant/components/melcloud/water_heater.py @@ -0,0 +1,132 @@ +"""Platform for water_heater integration.""" +from typing import List, Optional + +from pymelcloud import DEVICE_TYPE_ATW, AtwDevice +from pymelcloud.atw_device import ( + PROPERTY_OPERATION_MODE, + PROPERTY_TARGET_TANK_TEMPERATURE, +) +from pymelcloud.device import PROPERTY_POWER + +from homeassistant.components.water_heater import ( + SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, + WaterHeaterDevice, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import TEMP_CELSIUS +from homeassistant.helpers.typing import HomeAssistantType + +from . import DOMAIN, MelCloudDevice +from .const import ATTR_STATUS, TEMP_UNIT_LOOKUP + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +): + """Set up MelCloud device climate based on config_entry.""" + mel_devices = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + AtwWaterHeater(mel_device, mel_device.device) + for mel_device in mel_devices[DEVICE_TYPE_ATW] + ], + True, + ) + + +class AtwWaterHeater(WaterHeaterDevice): + """Air-to-Water water heater.""" + + def __init__(self, api: MelCloudDevice, device: AtwDevice) -> None: + """Initialize water heater device.""" + self._api = api + self._device = device + self._name = device.name + + async def async_update(self): + """Update state from MELCloud.""" + await self._api.async_update() + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + return f"{self._api.device.serial}" + + @property + def name(self): + """Return the display name of this entity.""" + return self._name + + @property + def device_info(self): + """Return a device description for device registry.""" + return self._api.device_info + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + await self._device.set({PROPERTY_POWER: True}) + + async def async_turn_off(self) -> None: + """Turn the entity off.""" + await self._device.set({PROPERTY_POWER: False}) + + @property + def device_state_attributes(self): + """Return the optional state attributes with device specific additions.""" + data = {ATTR_STATUS: self._device.status} + return data + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement used by the platform.""" + return TEMP_UNIT_LOOKUP.get(self._device.temp_unit, TEMP_CELSIUS) + + @property + def current_operation(self) -> Optional[str]: + """Return current operation as reported by pymelcloud.""" + return self._device.operation_mode + + @property + def operation_list(self) -> List[str]: + """Return the list of available operation modes as reported by pymelcloud.""" + return self._device.operation_modes + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + return self._device.tank_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._device.target_tank_temperature + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + await self._device.set( + { + PROPERTY_TARGET_TANK_TEMPERATURE: kwargs.get( + "temperature", self.target_temperature + ) + } + ) + + async def async_set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + await self._device.set({PROPERTY_OPERATION_MODE: operation_mode}) + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE + + @property + def min_temp(self) -> Optional[float]: + """Return the minimum temperature.""" + return self._device.target_tank_temperature_min + + @property + def max_temp(self) -> Optional[float]: + """Return the maximum temperature.""" + return self._device.target_tank_temperature_max diff --git a/requirements_all.txt b/requirements_all.txt index 678bf3ba674..1146b6f16c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1372,7 +1372,7 @@ pymailgunner==1.4 pymediaroom==0.6.4 # homeassistant.components.melcloud -pymelcloud==2.1.0 +pymelcloud==2.4.0 # homeassistant.components.somfy pymfy==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 00e04b8fd99..ac561926564 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -501,7 +501,7 @@ pylitejet==0.1 pymailgunner==1.4 # homeassistant.components.melcloud -pymelcloud==2.1.0 +pymelcloud==2.4.0 # homeassistant.components.somfy pymfy==0.7.1 From 89fc430eec8464a6ced65ec3cb9b0ceda4b9b62f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 10 Mar 2020 11:28:11 -0700 Subject: [PATCH 319/416] Check against Switch attr not being None (#32647) --- homeassistant/components/switch/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index fb5c2969d7e..3884b90c464 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -103,7 +103,7 @@ class SwitchDevice(ToggleEntity): for prop, attr in PROP_TO_ATTR.items(): value = getattr(self, prop) - if value: + if value is not None: data[attr] = value return data From a3c55b5e96dc1045b85fba6a48aefb93a717031a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 10 Mar 2020 11:47:07 -0700 Subject: [PATCH 320/416] Upgrade to coronavirus 1.1.0 (#32648) --- homeassistant/components/coronavirus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/coronavirus/manifest.json b/homeassistant/components/coronavirus/manifest.json index d99a9b621a2..68e73525291 100644 --- a/homeassistant/components/coronavirus/manifest.json +++ b/homeassistant/components/coronavirus/manifest.json @@ -3,7 +3,7 @@ "name": "Coronavirus (COVID-19)", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/coronavirus", - "requirements": ["coronavirus==1.0.1"], + "requirements": ["coronavirus==1.1.0"], "ssdp": [], "zeroconf": [], "homekit": {}, diff --git a/requirements_all.txt b/requirements_all.txt index 1146b6f16c4..1a0a7c17855 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -406,7 +406,7 @@ connect-box==0.2.5 construct==2.9.45 # homeassistant.components.coronavirus -coronavirus==1.0.1 +coronavirus==1.1.0 # homeassistant.scripts.credstash # credstash==1.15.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac561926564..1ea989ce82c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -147,7 +147,7 @@ colorlog==4.1.0 construct==2.9.45 # homeassistant.components.coronavirus -coronavirus==1.0.1 +coronavirus==1.1.0 # homeassistant.scripts.credstash # credstash==1.15.0 From 21cff003f8602bfe4e2e88bb0a81c44737731064 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 10 Mar 2020 14:16:25 -0600 Subject: [PATCH 321/416] Add options flow for AirVisual (#32634) * Add options flow for AirVisual * Code review Co-Authored-By: Robert Svensson Co-authored-by: Robert Svensson --- .../airvisual/.translations/en.json | 14 +++++- .../components/airvisual/__init__.py | 16 ++++++- .../components/airvisual/config_flow.py | 40 +++++++++++++++- homeassistant/components/airvisual/sensor.py | 23 ++++++---- .../components/airvisual/strings.json | 14 +++++- .../components/airvisual/test_config_flow.py | 46 +++++++++++++++++-- 6 files changed, 132 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/airvisual/.translations/en.json b/homeassistant/components/airvisual/.translations/en.json index 844174220b1..2bcff29b770 100644 --- a/homeassistant/components/airvisual/.translations/en.json +++ b/homeassistant/components/airvisual/.translations/en.json @@ -11,13 +11,23 @@ "data": { "api_key": "API Key", "latitude": "Latitude", - "longitude": "Longitude", - "show_on_map": "Show monitored geography on the map" + "longitude": "Longitude" }, "description": "Monitor air quality in a geographical location.", "title": "Configure AirVisual" } }, "title": "AirVisual" + }, + "options": { + "step": { + "init": { + "data": { + "show_on_map": "Show monitored geography on the map" + }, + "description": "Set various options for the AirVisual integration.", + "title": "Configure AirVisual" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 24d8257d041..a48acf7bb34 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -139,6 +139,8 @@ async def async_setup_entry(hass, config_entry): hass, refresh, DEFAULT_SCAN_INTERVAL ) + config_entry.add_update_listener(async_update_options) + return True @@ -154,6 +156,12 @@ async def async_unload_entry(hass, config_entry): return True +async def async_update_options(hass, config_entry): + """Handle an options update.""" + airvisual = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] + airvisual.async_update_options(config_entry.options) + + class AirVisualData: """Define a class to manage data from the AirVisual cloud API.""" @@ -162,7 +170,7 @@ class AirVisualData: self._client = client self._hass = hass self.data = {} - self.show_on_map = config_entry.options[CONF_SHOW_ON_MAP] + self.options = config_entry.options self.geographies = { async_get_geography_id(geography): geography @@ -199,3 +207,9 @@ class AirVisualData: _LOGGER.debug("Received new data") async_dispatcher_send(self._hass, TOPIC_UPDATE) + + @callback + def async_update_options(self, options): + """Update the data manager's options.""" + self.options = options + async_dispatcher_send(self._hass, TOPIC_UPDATE) diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py index bdd1d9a7b70..2f961ccfb49 100644 --- a/homeassistant/components/airvisual/config_flow.py +++ b/homeassistant/components/airvisual/config_flow.py @@ -6,7 +6,12 @@ from pyairvisual.errors import InvalidKeyError import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_SHOW_ON_MAP, +) from homeassistant.core import callback from homeassistant.helpers import aiohttp_client, config_validation as cv @@ -16,7 +21,7 @@ _LOGGER = logging.getLogger("homeassistant.components.airvisual") class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a AirVisual config flow.""" + """Handle an AirVisual config flow.""" VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL @@ -48,6 +53,12 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", data_schema=self.cloud_api_schema, errors=errors or {}, ) + @staticmethod + @callback + def async_get_options_flow(config_entry): + """Define the config flow to handle options.""" + return AirVisualOptionsFlowHandler(config_entry) + async def async_step_import(self, import_config): """Import a config entry from configuration.yaml.""" return await self.async_step_user(import_config) @@ -85,3 +96,28 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry( title=f"Cloud API (API key: {user_input[CONF_API_KEY][:4]}...)", data=data ) + + +class AirVisualOptionsFlowHandler(config_entries.OptionsFlow): + """Handle an AirVisual options flow.""" + + def __init__(self, config_entry): + """Initialize.""" + self.config_entry = config_entry + + 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=user_input) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Required( + CONF_SHOW_ON_MAP, + default=self.config_entry.options.get(CONF_SHOW_ON_MAP), + ): bool + } + ), + ) diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index a25114b7f02..28d2b3f5f86 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -11,6 +11,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_MILLION, CONF_LATITUDE, CONF_LONGITUDE, + CONF_SHOW_ON_MAP, CONF_STATE, ) from homeassistant.core import callback @@ -110,15 +111,6 @@ class AirVisualSensor(Entity): ATTR_COUNTRY: airvisual.data[geography_id].get(CONF_COUNTRY), } - geography = airvisual.geographies[geography_id] - if geography.get(CONF_LATITUDE): - if airvisual.show_on_map: - self._attrs[ATTR_LATITUDE] = geography[CONF_LATITUDE] - self._attrs[ATTR_LONGITUDE] = geography[CONF_LONGITUDE] - else: - self._attrs["lati"] = geography[CONF_LATITUDE] - self._attrs["long"] = geography[CONF_LONGITUDE] - @property def available(self): """Return True if entity is available.""" @@ -199,6 +191,19 @@ class AirVisualSensor(Entity): } ) + geography = self._airvisual.geographies[self._geography_id] + if CONF_LATITUDE in geography: + if self._airvisual.options[CONF_SHOW_ON_MAP]: + self._attrs[ATTR_LATITUDE] = geography[CONF_LATITUDE] + self._attrs[ATTR_LONGITUDE] = geography[CONF_LONGITUDE] + self._attrs.pop("lati", None) + self._attrs.pop("long", None) + else: + self._attrs["lati"] = geography[CONF_LATITUDE] + self._attrs["long"] = geography[CONF_LONGITUDE] + self._attrs.pop(ATTR_LATITUDE, None) + self._attrs.pop(ATTR_LONGITUDE, None) + async def async_will_remove_from_hass(self) -> None: """Disconnect dispatcher listener when removed.""" for cancel in self._async_unsub_dispatcher_connects: diff --git a/homeassistant/components/airvisual/strings.json b/homeassistant/components/airvisual/strings.json index 6c3f17c92dd..6e94c393da6 100644 --- a/homeassistant/components/airvisual/strings.json +++ b/homeassistant/components/airvisual/strings.json @@ -8,8 +8,7 @@ "data": { "api_key": "API Key", "latitude": "Latitude", - "longitude": "Longitude", - "show_on_map": "Show monitored geography on the map" + "longitude": "Longitude" } } }, @@ -19,5 +18,16 @@ "abort": { "already_configured": "This API key is already in use." } + }, + "options": { + "step": { + "init": { + "title": "Configure AirVisual", + "description": "Set various options for the AirVisual integration.", + "data": { + "show_on_map": "Show monitored geography on the map" + } + } + } } } diff --git a/tests/components/airvisual/test_config_flow.py b/tests/components/airvisual/test_config_flow.py index 5057f1c3345..fb32a86a01a 100644 --- a/tests/components/airvisual/test_config_flow.py +++ b/tests/components/airvisual/test_config_flow.py @@ -5,7 +5,12 @@ from pyairvisual.errors import InvalidKeyError from homeassistant import data_entry_flow from homeassistant.components.airvisual import CONF_GEOGRAPHIES, DOMAIN from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_SHOW_ON_MAP, +) from tests.common import MockConfigEntry @@ -37,6 +42,34 @@ async def test_invalid_api_key(hass): assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} +async def test_options_flow(hass): + """Test config flow options.""" + conf = {CONF_API_KEY: "abcde12345"} + + 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.airvisual.async_setup_entry", return_value=True + ): + 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_SHOW_ON_MAP: False} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert config_entry.options == {CONF_SHOW_ON_MAP: False} + + async def test_show_form(hass): """Test that the form is served with no input.""" result = await hass.config_entries.flow.async_init( @@ -49,10 +82,13 @@ async def test_show_form(hass): async def test_step_import(hass): """Test that the import step works.""" - conf = {CONF_API_KEY: "abcde12345"} + conf = { + CONF_API_KEY: "abcde12345", + CONF_GEOGRAPHIES: [{CONF_LATITUDE: 51.528308, CONF_LONGITUDE: -0.3817765}], + } with patch( - "homeassistant.components.wwlln.async_setup_entry", return_value=True + "homeassistant.components.airvisual.async_setup_entry", return_value=True ), patch("pyairvisual.api.API.nearest_city"): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=conf @@ -62,7 +98,7 @@ async def test_step_import(hass): assert result["title"] == "Cloud API (API key: abcd...)" assert result["data"] == { CONF_API_KEY: "abcde12345", - CONF_GEOGRAPHIES: [{CONF_LATITUDE: 32.87336, CONF_LONGITUDE: -117.22743}], + CONF_GEOGRAPHIES: [{CONF_LATITUDE: 51.528308, CONF_LONGITUDE: -0.3817765}], } @@ -75,7 +111,7 @@ async def test_step_user(hass): } with patch( - "homeassistant.components.wwlln.async_setup_entry", return_value=True + "homeassistant.components.airvisual.async_setup_entry", return_value=True ), patch("pyairvisual.api.API.nearest_city"): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf From 908ae237385d773e3332d7da47ac7cf8f8088124 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2020 17:00:30 -0500 Subject: [PATCH 322/416] Add griddy integration (#32591) * Add griddy integration * Griddy is a wholesale power provider in Texas * Supports all four load zones in Texas * Provides real time power price which is useful for automations to handle demand response * Update homeassistant/components/griddy/sensor.py Co-Authored-By: Paulus Schoutsen * Update homeassistant/components/griddy/config_flow.py Co-Authored-By: Paulus Schoutsen * Add ability request updated via entity update service. * Improve error message about already configured * Remove DEVICE_CLASS_POWER since we do not have a device class for cost * remove setdefault that was left from previous refactor * More detail on data naming * Bump translation for testing * git add the config flow tests * s/PlatformNotReady/ConfigEntryNotReady/ * Review items * git add the other missing file * Patch griddypower * reduce * adjust target Co-authored-by: Paulus Schoutsen --- CODEOWNERS | 1 + .../components/griddy/.translations/en.json | 21 + homeassistant/components/griddy/__init__.py | 96 +++ .../components/griddy/config_flow.py | 75 +++ homeassistant/components/griddy/const.py | 7 + homeassistant/components/griddy/manifest.json | 14 + homeassistant/components/griddy/sensor.py | 76 +++ homeassistant/components/griddy/strings.json | 21 + homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/griddy/__init__.py | 1 + tests/components/griddy/test_config_flow.py | 54 ++ tests/components/griddy/test_sensor.py | 39 ++ tests/fixtures/griddy/getnow.json | 600 ++++++++++++++++++ 15 files changed, 1012 insertions(+) create mode 100644 homeassistant/components/griddy/.translations/en.json create mode 100644 homeassistant/components/griddy/__init__.py create mode 100644 homeassistant/components/griddy/config_flow.py create mode 100644 homeassistant/components/griddy/const.py create mode 100644 homeassistant/components/griddy/manifest.json create mode 100644 homeassistant/components/griddy/sensor.py create mode 100644 homeassistant/components/griddy/strings.json create mode 100644 tests/components/griddy/__init__.py create mode 100644 tests/components/griddy/test_config_flow.py create mode 100644 tests/components/griddy/test_sensor.py create mode 100644 tests/fixtures/griddy/getnow.json diff --git a/CODEOWNERS b/CODEOWNERS index 89417c4ca56..6714c948402 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -140,6 +140,7 @@ homeassistant/components/google_translate/* @awarecan homeassistant/components/google_travel_time/* @robbiet480 homeassistant/components/gpsd/* @fabaff homeassistant/components/greeneye_monitor/* @jkeljo +homeassistant/components/griddy/* @bdraco homeassistant/components/group/* @home-assistant/core homeassistant/components/growatt_server/* @indykoning homeassistant/components/gtfs/* @robbiet480 diff --git a/homeassistant/components/griddy/.translations/en.json b/homeassistant/components/griddy/.translations/en.json new file mode 100644 index 00000000000..bedd85e7508 --- /dev/null +++ b/homeassistant/components/griddy/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config" : { + "error" : { + "cannot_connect" : "Failed to connect, please try again", + "unknown" : "Unexpected error" + }, + "title" : "Griddy", + "step" : { + "user" : { + "description" : "Your Load Zone is in your Griddy account under “Account > Meter > Load Zone.”", + "data" : { + "loadzone" : "Load Zone (Settlement Point)" + }, + "title" : "Setup your Griddy Load Zone" + } + }, + "abort" : { + "already_configured" : "This Load Zone is already configured" + } + } +} diff --git a/homeassistant/components/griddy/__init__.py b/homeassistant/components/griddy/__init__.py new file mode 100644 index 00000000000..fb5079b00f8 --- /dev/null +++ b/homeassistant/components/griddy/__init__.py @@ -0,0 +1,96 @@ +"""The Griddy Power integration.""" +import asyncio +from datetime import timedelta +import logging + +from griddypower.async_api import LOAD_ZONES, AsyncGriddy +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_LOADZONE, DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + {DOMAIN: vol.Schema({vol.Required(CONF_LOADZONE): vol.In(LOAD_ZONES)})}, + extra=vol.ALLOW_EXTRA, +) + +PLATFORMS = ["sensor"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Griddy Power component.""" + + hass.data.setdefault(DOMAIN, {}) + conf = config.get(DOMAIN) + + if not conf: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_LOADZONE: conf.get(CONF_LOADZONE)}, + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Griddy Power from a config entry.""" + + entry_data = entry.data + + async_griddy = AsyncGriddy( + aiohttp_client.async_get_clientsession(hass), + settlement_point=entry_data[CONF_LOADZONE], + ) + + async def async_update_data(): + """Fetch data from API endpoint.""" + return await async_griddy.async_getnow() + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="Griddy getnow", + update_method=async_update_data, + update_interval=timedelta(seconds=UPDATE_INTERVAL), + ) + + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = coordinator + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """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/griddy/config_flow.py b/homeassistant/components/griddy/config_flow.py new file mode 100644 index 00000000000..56284384ee0 --- /dev/null +++ b/homeassistant/components/griddy/config_flow.py @@ -0,0 +1,75 @@ +"""Config flow for Griddy Power integration.""" +import asyncio +import logging + +from aiohttp import ClientError +from griddypower.async_api import LOAD_ZONES, AsyncGriddy +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.helpers import aiohttp_client + +from .const import CONF_LOADZONE +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_LOADZONE): vol.In(LOAD_ZONES)}) + + +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) + + try: + await AsyncGriddy( + client_session, settlement_point=data[CONF_LOADZONE] + ).async_getnow() + except (asyncio.TimeoutError, ClientError): + raise CannotConnect + + # Return info that you want to store in the config entry. + return {"title": f"Load Zone {data[CONF_LOADZONE]}"} + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Griddy Power.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + info = None + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if "base" not in errors: + await self.async_set_unique_id(user_input[CONF_LOADZONE]) + 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 + ) + + async def async_step_import(self, user_input): + """Handle import.""" + await self.async_set_unique_id(user_input[CONF_LOADZONE]) + self._abort_if_unique_id_configured() + + return await self.async_step_user(user_input) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/griddy/const.py b/homeassistant/components/griddy/const.py new file mode 100644 index 00000000000..034567a806e --- /dev/null +++ b/homeassistant/components/griddy/const.py @@ -0,0 +1,7 @@ +"""Constants for the Griddy Power integration.""" + +DOMAIN = "griddy" + +UPDATE_INTERVAL = 90 + +CONF_LOADZONE = "loadzone" diff --git a/homeassistant/components/griddy/manifest.json b/homeassistant/components/griddy/manifest.json new file mode 100644 index 00000000000..d17ed846fd9 --- /dev/null +++ b/homeassistant/components/griddy/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "griddy", + "name": "Griddy Power", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/griddy", + "requirements": ["griddypower==0.1.0"], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": [ + "@bdraco" + ] +} diff --git a/homeassistant/components/griddy/sensor.py b/homeassistant/components/griddy/sensor.py new file mode 100644 index 00000000000..31488650dc2 --- /dev/null +++ b/homeassistant/components/griddy/sensor.py @@ -0,0 +1,76 @@ +"""Support for August sensors.""" +import logging + +from homeassistant.helpers.entity import Entity + +from .const import CONF_LOADZONE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the August sensors.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + + settlement_point = config_entry.data[CONF_LOADZONE] + + async_add_entities([GriddyPriceSensor(settlement_point, coordinator)], True) + + +class GriddyPriceSensor(Entity): + """Representation of an August sensor.""" + + def __init__(self, settlement_point, coordinator): + """Initialize the sensor.""" + self._coordinator = coordinator + self._settlement_point = settlement_point + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "¢/kWh" + + @property + def name(self): + """Device Name.""" + return f"{self._settlement_point} Price Now" + + @property + def icon(self): + """Device Ice.""" + return "mdi:currency-usd" + + @property + def unique_id(self): + """Device Uniqueid.""" + return f"{self._settlement_point}_price_now" + + @property + def available(self): + """Return True if entity is available.""" + return self._coordinator.last_update_success + + @property + def state(self): + """Get the current price.""" + return round(float(self._coordinator.data.now.price_cents_kwh), 4) + + @property + def should_poll(self): + """Return False, updates are controlled via coordinator.""" + return False + + async def async_update(self): + """Update the entity. + + Only used by the generic entity update service. + """ + await self._coordinator.async_request_refresh() + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self._coordinator.async_add_listener(self.async_write_ha_state) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + self._coordinator.async_remove_listener(self.async_write_ha_state) diff --git a/homeassistant/components/griddy/strings.json b/homeassistant/components/griddy/strings.json new file mode 100644 index 00000000000..bedd85e7508 --- /dev/null +++ b/homeassistant/components/griddy/strings.json @@ -0,0 +1,21 @@ +{ + "config" : { + "error" : { + "cannot_connect" : "Failed to connect, please try again", + "unknown" : "Unexpected error" + }, + "title" : "Griddy", + "step" : { + "user" : { + "description" : "Your Load Zone is in your Griddy account under “Account > Meter > Load Zone.”", + "data" : { + "loadzone" : "Load Zone (Settlement Point)" + }, + "title" : "Setup your Griddy Load Zone" + } + }, + "abort" : { + "already_configured" : "This Load Zone is already configured" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3d752a955c5..91fda9f1c32 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -36,6 +36,7 @@ FLOWS = [ "gios", "glances", "gpslogger", + "griddy", "hangouts", "heos", "hisense_aehw4a1", diff --git a/requirements_all.txt b/requirements_all.txt index 1a0a7c17855..0dd4920e944 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -641,6 +641,9 @@ greeneye_monitor==2.0 # homeassistant.components.greenwave greenwavereality==0.5.1 +# homeassistant.components.griddy +griddypower==0.1.0 + # homeassistant.components.growatt_server growattServer==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1ea989ce82c..5ae7cb1c582 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -235,6 +235,9 @@ google-api-python-client==1.6.4 # homeassistant.components.google_pubsub google-cloud-pubsub==0.39.1 +# homeassistant.components.griddy +griddypower==0.1.0 + # homeassistant.components.ffmpeg ha-ffmpeg==2.0 diff --git a/tests/components/griddy/__init__.py b/tests/components/griddy/__init__.py new file mode 100644 index 00000000000..415ddc3ba5c --- /dev/null +++ b/tests/components/griddy/__init__.py @@ -0,0 +1 @@ +"""Tests for the Griddy Power integration.""" diff --git a/tests/components/griddy/test_config_flow.py b/tests/components/griddy/test_config_flow.py new file mode 100644 index 00000000000..1ab29aebece --- /dev/null +++ b/tests/components/griddy/test_config_flow.py @@ -0,0 +1,54 @@ +"""Test the Griddy Power config flow.""" +import asyncio + +from asynctest import MagicMock, patch + +from homeassistant import config_entries, setup +from homeassistant.components.griddy.const import DOMAIN + + +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( + "homeassistant.components.griddy.config_flow.AsyncGriddy.async_getnow", + return_value=MagicMock(), + ), patch( + "homeassistant.components.griddy.async_setup", return_value=True + ) as mock_setup, patch( + "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"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Load Zone LZ_HOUSTON" + assert result2["data"] == {"loadzone": "LZ_HOUSTON"} + 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): + """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.griddy.config_flow.AsyncGriddy.async_getnow", + side_effect=asyncio.TimeoutError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {"loadzone": "LZ_NORTH"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/griddy/test_sensor.py b/tests/components/griddy/test_sensor.py new file mode 100644 index 00000000000..995327a9b56 --- /dev/null +++ b/tests/components/griddy/test_sensor.py @@ -0,0 +1,39 @@ +"""The sensor tests for the griddy platform.""" +import json +import os + +from asynctest import patch +from griddypower.async_api import GriddyPriceData + +from homeassistant.components.griddy import CONF_LOADZONE, DOMAIN +from homeassistant.setup import async_setup_component + +from tests.common import load_fixture + + +async def _load_json_fixture(hass, path): + fixture = await hass.async_add_executor_job( + load_fixture, os.path.join("griddy", path) + ) + return json.loads(fixture) + + +def _mock_get_config(): + """Return a default griddy config.""" + return {DOMAIN: {CONF_LOADZONE: "LZ_HOUSTON"}} + + +async def test_houston_loadzone(hass): + """Test creation of the houston load zone.""" + + getnow_json = await _load_json_fixture(hass, "getnow.json") + griddy_price_data = GriddyPriceData(getnow_json) + with patch( + "homeassistant.components.griddy.AsyncGriddy.async_getnow", + return_value=griddy_price_data, + ): + assert await async_setup_component(hass, DOMAIN, _mock_get_config()) + await hass.async_block_till_done() + + sensor_lz_houston_price_now = hass.states.get("sensor.lz_houston_price_now") + assert sensor_lz_houston_price_now.state == "1.269" diff --git a/tests/fixtures/griddy/getnow.json b/tests/fixtures/griddy/getnow.json new file mode 100644 index 00000000000..2bf685dac44 --- /dev/null +++ b/tests/fixtures/griddy/getnow.json @@ -0,0 +1,600 @@ +{ + "now": { + "date": "2020-03-08T18:10:16Z", + "hour_num": "18", + "min_num": "10", + "settlement_point": "LZ_HOUSTON", + "price_type": "lmp", + "price_ckwh": "1.26900000000000000000", + "value_score": "11", + "mean_price_ckwh": "1.429706", + "diff_mean_ckwh": "-0.160706", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.544065", + "price_display": "1.3", + "price_display_sign": "¢", + "date_local_tz": "2020-03-08T13:10:16-05:00" + }, + "forecast": [ + { + "date": "2020-03-08T19:00:00Z", + "hour_num": "19", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.32000000", + "value_score": "12", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.113030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.3", + "price_display_sign": "¢", + "date_local_tz": "2020-03-08T14:00:00-05:00" + }, + { + "date": "2020-03-08T20:00:00Z", + "hour_num": "20", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.37400000", + "value_score": "12", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.059030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.4", + "price_display_sign": "¢", + "date_local_tz": "2020-03-08T15:00:00-05:00" + }, + { + "date": "2020-03-08T21:00:00Z", + "hour_num": "21", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.44700000", + "value_score": "13", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "0.013970", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.4", + "price_display_sign": "¢", + "date_local_tz": "2020-03-08T16:00:00-05:00" + }, + { + "date": "2020-03-08T22:00:00Z", + "hour_num": "22", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.52600000", + "value_score": "13", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "0.092970", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.5", + "price_display_sign": "¢", + "date_local_tz": "2020-03-08T17:00:00-05:00" + }, + { + "date": "2020-03-08T23:00:00Z", + "hour_num": "23", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "2.05100000", + "value_score": "17", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "0.617970", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "2.1", + "price_display_sign": "¢", + "date_local_tz": "2020-03-08T18:00:00-05:00" + }, + { + "date": "2020-03-09T00:00:00Z", + "hour_num": "0", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "2.07400000", + "value_score": "17", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "0.640970", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "2.1", + "price_display_sign": "¢", + "date_local_tz": "2020-03-08T19:00:00-05:00" + }, + { + "date": "2020-03-09T01:00:00Z", + "hour_num": "1", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.94400000", + "value_score": "16", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "0.510970", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.9", + "price_display_sign": "¢", + "date_local_tz": "2020-03-08T20:00:00-05:00" + }, + { + "date": "2020-03-09T02:00:00Z", + "hour_num": "2", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.57500000", + "value_score": "14", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "0.141970", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.6", + "price_display_sign": "¢", + "date_local_tz": "2020-03-08T21:00:00-05:00" + }, + { + "date": "2020-03-09T03:00:00Z", + "hour_num": "3", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.23700000", + "value_score": "11", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.196030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.2", + "price_display_sign": "¢", + "date_local_tz": "2020-03-08T22:00:00-05:00" + }, + { + "date": "2020-03-09T04:00:00Z", + "hour_num": "4", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "0.96200000", + "value_score": "9", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.471030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1", + "price_display_sign": "¢", + "date_local_tz": "2020-03-08T23:00:00-05:00" + }, + { + "date": "2020-03-09T05:00:00Z", + "hour_num": "5", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "0.80000000", + "value_score": "8", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.633030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "0.8", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T00:00:00-05:00" + }, + { + "date": "2020-03-09T06:00:00Z", + "hour_num": "6", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "0.70000000", + "value_score": "7", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.733030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "0.7", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T01:00:00-05:00" + }, + { + "date": "2020-03-09T07:00:00Z", + "hour_num": "7", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "0.70000000", + "value_score": "7", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.733030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "0.7", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T02:00:00-05:00" + }, + { + "date": "2020-03-09T08:00:00Z", + "hour_num": "8", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "0.70000000", + "value_score": "7", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.733030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "0.7", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T03:00:00-05:00" + }, + { + "date": "2020-03-09T09:00:00Z", + "hour_num": "9", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "0.70000000", + "value_score": "7", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.733030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "0.7", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T04:00:00-05:00" + }, + { + "date": "2020-03-09T10:00:00Z", + "hour_num": "10", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "0.90000000", + "value_score": "8", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.533030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "0.9", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T05:00:00-05:00" + }, + { + "date": "2020-03-09T11:00:00Z", + "hour_num": "11", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.00000000", + "value_score": "9", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.433030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T06:00:00-05:00" + }, + { + "date": "2020-03-09T12:00:00Z", + "hour_num": "12", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.10000000", + "value_score": "10", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.333030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.1", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T07:00:00-05:00" + }, + { + "date": "2020-03-09T13:00:00Z", + "hour_num": "13", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.10000000", + "value_score": "10", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.333030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.1", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T08:00:00-05:00" + }, + { + "date": "2020-03-09T14:00:00Z", + "hour_num": "14", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.20000000", + "value_score": "11", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.233030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.2", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T09:00:00-05:00" + }, + { + "date": "2020-03-09T15:00:00Z", + "hour_num": "15", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.20000000", + "value_score": "11", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.233030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.2", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T10:00:00-05:00" + }, + { + "date": "2020-03-09T16:00:00Z", + "hour_num": "16", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.30000000", + "value_score": "11", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.133030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.3", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T11:00:00-05:00" + }, + { + "date": "2020-03-09T17:00:00Z", + "hour_num": "17", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.40000000", + "value_score": "12", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.033030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.4", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T12:00:00-05:00" + }, + { + "date": "2020-03-09T18:00:00Z", + "hour_num": "18", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.50000000", + "value_score": "13", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "0.066970", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.5", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T13:00:00-05:00" + }, + { + "date": "2020-03-09T19:00:00Z", + "hour_num": "19", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.60000000", + "value_score": "14", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "0.166970", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.6", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T14:00:00-05:00" + }, + { + "date": "2020-03-09T20:00:00Z", + "hour_num": "20", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.60000000", + "value_score": "14", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "0.166970", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.6", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T15:00:00-05:00" + }, + { + "date": "2020-03-09T21:00:00Z", + "hour_num": "21", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.60000000", + "value_score": "14", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "0.166970", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.6", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T16:00:00-05:00" + }, + { + "date": "2020-03-09T22:00:00Z", + "hour_num": "22", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "2.10000000", + "value_score": "18", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "0.666970", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "2.1", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T17:00:00-05:00" + }, + { + "date": "2020-03-09T23:00:00Z", + "hour_num": "23", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "3.20000000", + "value_score": "27", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "1.766970", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "3.2", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T18:00:00-05:00" + }, + { + "date": "2020-03-10T00:00:00Z", + "hour_num": "0", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "2.40000000", + "value_score": "20", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "0.966970", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "2.4", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T19:00:00-05:00" + }, + { + "date": "2020-03-10T01:00:00Z", + "hour_num": "1", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "2.00000000", + "value_score": "17", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "0.566970", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "2", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T20:00:00-05:00" + }, + { + "date": "2020-03-10T02:00:00Z", + "hour_num": "2", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.70000000", + "value_score": "15", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "0.266970", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.7", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T21:00:00-05:00" + }, + { + "date": "2020-03-10T03:00:00Z", + "hour_num": "3", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.40000000", + "value_score": "12", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.033030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.4", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T22:00:00-05:00" + }, + { + "date": "2020-03-10T04:00:00Z", + "hour_num": "4", + "min_num": "0", + "settlement_point": "LZ_HOUSTON", + "price_type": "dam", + "price_ckwh": "1.20000000", + "value_score": "11", + "mean_price_ckwh": "1.433030", + "diff_mean_ckwh": "-0.233030", + "high_ckwh": "3.200000", + "low_ckwh": "0.700000", + "std_dev_ckwh": "0.552149", + "price_display": "1.2", + "price_display_sign": "¢", + "date_local_tz": "2020-03-09T23:00:00-05:00" + } + ], + "seconds_until_refresh": "26" +} From 765882fc4dad9ad4f3f11f5011f0ba49e61f15a5 Mon Sep 17 00:00:00 2001 From: Gerard Date: Tue, 10 Mar 2020 23:05:35 +0100 Subject: [PATCH 323/416] Fix bmw connected drive door_lock_state attribute error (#32074) * Fix for door_lock_state attribute error * Updates based on review comments * Remove update_time * Remove update time in lock * Remove update time in sensor * Remove unused variable * Change return for device_state_attributes --- .../components/bmw_connected_drive/__init__.py | 1 - .../bmw_connected_drive/binary_sensor.py | 11 +++++++---- .../components/bmw_connected_drive/const.py | 2 ++ .../components/bmw_connected_drive/lock.py | 16 ++++++++++++---- .../components/bmw_connected_drive/sensor.py | 8 ++++++-- 5 files changed, 27 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/bmw_connected_drive/const.py diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 6e7723b16ec..273bac8ef0e 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -120,7 +120,6 @@ class BMWConnectedDriveAccount: self, username: str, password: str, region_str: str, name: str, read_only ) -> None: """Initialize account.""" - region = get_region_from_name(region_str) self.read_only = read_only diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index 591cdadda35..fc3069f284c 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -4,9 +4,10 @@ import logging from bimmer_connected.state import ChargingState, LockState from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.const import LENGTH_KILOMETERS +from homeassistant.const import ATTR_ATTRIBUTION, LENGTH_KILOMETERS from . import DOMAIN as BMW_DOMAIN +from .const import ATTRIBUTION _LOGGER = logging.getLogger(__name__) @@ -107,7 +108,10 @@ class BMWConnectedDriveSensor(BinarySensorDevice): def device_state_attributes(self): """Return the state attributes of the binary sensor.""" vehicle_state = self._vehicle.state - result = {"car": self._vehicle.name} + result = { + "car": self._vehicle.name, + ATTR_ATTRIBUTION: ATTRIBUTION, + } if self._attribute == "lids": for lid in vehicle_state.lids: @@ -143,7 +147,6 @@ class BMWConnectedDriveSensor(BinarySensorDevice): def update(self): """Read new state data from the library.""" - vehicle_state = self._vehicle.state # device class opening: On means open, Off means closed @@ -152,7 +155,7 @@ class BMWConnectedDriveSensor(BinarySensorDevice): self._state = not vehicle_state.all_lids_closed if self._attribute == "windows": self._state = not vehicle_state.all_windows_closed - # device class safety: On means unsafe, Off means safe + # device class lock: On means unlocked, Off means locked if self._attribute == "door_lock_state": # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED self._state = vehicle_state.door_lock_state not in [ diff --git a/homeassistant/components/bmw_connected_drive/const.py b/homeassistant/components/bmw_connected_drive/const.py new file mode 100644 index 00000000000..d1a44b5e5c9 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/const.py @@ -0,0 +1,2 @@ +"""Const file for the BMW Connected Drive integration.""" +ATTRIBUTION = "Data provided by BMW Connected Drive" diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index 5323e94c1c3..7d4ad420af4 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -4,10 +4,12 @@ import logging from bimmer_connected.state import LockState from homeassistant.components.lock import LockDevice -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED +from homeassistant.const import ATTR_ATTRIBUTION, STATE_LOCKED, STATE_UNLOCKED from . import DOMAIN as BMW_DOMAIN +from .const import ATTRIBUTION +DOOR_LOCK_STATE = "door_lock_state" _LOGGER = logging.getLogger(__name__) @@ -36,6 +38,9 @@ class BMWLock(LockDevice): self._unique_id = f"{self._vehicle.vin}-{self._attribute}" self._sensor_name = sensor_name self._state = None + self.door_lock_state_available = ( + DOOR_LOCK_STATE in self._vehicle.available_attributes + ) @property def should_poll(self): @@ -59,10 +64,14 @@ class BMWLock(LockDevice): def device_state_attributes(self): """Return the state attributes of the lock.""" vehicle_state = self._vehicle.state - return { + result = { "car": self._vehicle.name, - "door_lock_state": vehicle_state.door_lock_state.value, + ATTR_ATTRIBUTION: ATTRIBUTION, } + if self.door_lock_state_available: + result["door_lock_state"] = vehicle_state.door_lock_state.value + result["last_update_reason"] = vehicle_state.last_update_reason + return result @property def is_locked(self): @@ -89,7 +98,6 @@ class BMWLock(LockDevice): def update(self): """Update state of the lock.""" - _LOGGER.debug("%s: updating data for %s", self._vehicle.name, self._attribute) vehicle_state = self._vehicle.state diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 30c12d7d7a0..d7eec8b9479 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -4,6 +4,7 @@ import logging from bimmer_connected.state import ChargingState from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_UNIT_SYSTEM_IMPERIAL, LENGTH_KILOMETERS, LENGTH_MILES, @@ -16,6 +17,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level from . import DOMAIN as BMW_DOMAIN +from .const import ATTRIBUTION _LOGGER = logging.getLogger(__name__) @@ -101,7 +103,6 @@ class BMWConnectedDriveSensor(Entity): @property def icon(self): """Icon to use in the frontend, if any.""" - vehicle_state = self._vehicle.state charging_state = vehicle_state.charging_status in [ChargingState.CHARGING] @@ -130,7 +131,10 @@ class BMWConnectedDriveSensor(Entity): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" - return {"car": self._vehicle.name} + return { + "car": self._vehicle.name, + ATTR_ATTRIBUTION: ATTRIBUTION, + } def update(self) -> None: """Read new state data from the library.""" From bbe0f7533675032660f5b68fbaf9499b6bdc5dc4 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 10 Mar 2020 23:15:11 +0100 Subject: [PATCH 324/416] input_datetime guard for unexpected state on restore (#32652) * input_datetime guard for unexpected state If state is a time and has_date = true, or the other way around, restore state would error * Update __init__.py * Add test --- homeassistant/components/input_datetime/__init__.py | 12 +++++++++++- tests/components/input_datetime/test_init.py | 11 +++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 575f607dadd..d000f606c58 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -237,12 +237,22 @@ class InputDatetime(RestoreEntity): return if self.has_date and self.has_time: - self._current_datetime = dt_util.parse_datetime(old_state.state) + date_time = dt_util.parse_datetime(old_state.state) + if date_time is None: + self._current_datetime = dt_util.parse_datetime(DEFAULT_VALUE) + return + self._current_datetime = date_time elif self.has_date: date = dt_util.parse_date(old_state.state) + if date is None: + self._current_datetime = dt_util.parse_datetime(DEFAULT_VALUE) + return self._current_datetime = datetime.datetime.combine(date, DEFAULT_TIME) else: time = dt_util.parse_time(old_state.state) + if time is None: + self._current_datetime = dt_util.parse_datetime(DEFAULT_VALUE) + return self._current_datetime = datetime.datetime.combine(DEFAULT_DATE, time) @property diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py index fa67bb2f8bf..afee32702c4 100644 --- a/tests/components/input_datetime/test_init.py +++ b/tests/components/input_datetime/test_init.py @@ -268,12 +268,15 @@ async def test_restore_state(hass): State("input_datetime.test_date", "2017-09-07"), State("input_datetime.test_datetime", "2017-09-07 19:46:00"), State("input_datetime.test_bogus_data", "this is not a date"), + State("input_datetime.test_was_time", "19:46:00"), + State("input_datetime.test_was_date", "2017-09-07"), ), ) hass.state = CoreState.starting initial = datetime.datetime(2017, 1, 1, 23, 42) + default = datetime.datetime(1970, 1, 1, 0, 0) await async_setup_component( hass, @@ -288,6 +291,8 @@ async def test_restore_state(hass): "has_date": True, "initial": str(initial), }, + "test_was_time": {"has_time": False, "has_date": True}, + "test_was_date": {"has_time": True, "has_date": False}, } }, ) @@ -305,6 +310,12 @@ async def test_restore_state(hass): state_bogus = hass.states.get("input_datetime.test_bogus_data") assert state_bogus.state == str(initial) + state_was_time = hass.states.get("input_datetime.test_was_time") + assert state_was_time.state == str(default.date()) + + state_was_date = hass.states.get("input_datetime.test_was_date") + assert state_was_date.state == str(default.time()) + async def test_default_value(hass): """Test default value if none has been set via initial or restore state.""" From ba0aaeeddb7873ad3cbfbccc5803dbce88b5e833 Mon Sep 17 00:00:00 2001 From: springstan <46536646+springstan@users.noreply.github.com> Date: Tue, 10 Mar 2020 23:34:54 +0100 Subject: [PATCH 325/416] Use f-strings in integrations starting with "M" (#32271) * Use f-strings in integrations starting with "M" * Format mqtt light init with black * Fix lint error * Fix pylint error * Restore constants * Update homeassistant/components/mqtt/discovery.py * Update homeassistant/components/mqtt/discovery.py * Update homeassistant/components/mqtt/discovery.py * Update homeassistant/components/mqtt/discovery.py * Format with Black --- homeassistant/components/magicseaweed/sensor.py | 10 ++-------- homeassistant/components/mailgun/notify.py | 3 +-- homeassistant/components/maxcube/__init__.py | 4 +--- .../components/maxcube/binary_sensor.py | 2 +- homeassistant/components/maxcube/climate.py | 2 +- .../components/media_player/__init__.py | 6 ++++-- .../components/mediaroom/media_player.py | 2 +- homeassistant/components/met/const.py | 3 +-- homeassistant/components/met/weather.py | 2 +- homeassistant/components/metoffice/sensor.py | 2 +- homeassistant/components/mhz19/sensor.py | 2 +- .../microsoft_face_detect/image_processing.py | 2 +- .../microsoft_face_identify/image_processing.py | 2 +- homeassistant/components/min_max/sensor.py | 4 +--- homeassistant/components/mobile_app/__init__.py | 2 +- homeassistant/components/mobile_app/notify.py | 4 ++-- homeassistant/components/mobile_app/webhook.py | 2 +- homeassistant/components/modbus/climate.py | 2 +- homeassistant/components/modbus/sensor.py | 4 ++-- .../components/mold_indicator/sensor.py | 2 +- homeassistant/components/mopar/__init__.py | 2 +- homeassistant/components/mopar/lock.py | 2 +- homeassistant/components/mopar/switch.py | 2 +- homeassistant/components/mqtt/__init__.py | 7 ++++--- homeassistant/components/mqtt/discovery.py | 16 +++++++--------- .../components/mqtt/light/schema_basic.py | 8 ++++---- homeassistant/components/mqtt/server.py | 4 +--- homeassistant/components/mqtt_room/sensor.py | 2 +- .../components/mychevy/binary_sensor.py | 11 +++++------ homeassistant/components/mychevy/sensor.py | 8 ++------ homeassistant/components/mysensors/__init__.py | 2 +- homeassistant/components/mysensors/gateway.py | 5 ++--- homeassistant/components/mysensors/helpers.py | 4 ++-- homeassistant/components/ps4/media_player.py | 7 ++++--- 34 files changed, 62 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/magicseaweed/sensor.py b/homeassistant/components/magicseaweed/sensor.py index 174ecf1882e..9364bee27b2 100644 --- a/homeassistant/components/magicseaweed/sensor.py +++ b/homeassistant/components/magicseaweed/sensor.py @@ -154,18 +154,12 @@ class MagicSeaweedSensor(Entity): elif self.type == "max_breaking_swell": self._state = forecast.swell_maxBreakingHeight elif self.type == "swell_forecast": - summary = "{} - {}".format( - forecast.swell_minBreakingHeight, forecast.swell_maxBreakingHeight - ) + summary = f"{forecast.swell_minBreakingHeight} - {forecast.swell_maxBreakingHeight}" self._state = summary if self.hour is None: for hour, data in self.data.hourly.items(): occurs = hour - hr_summary = "{} - {} {}".format( - data.swell_minBreakingHeight, - data.swell_maxBreakingHeight, - data.swell_unit, - ) + hr_summary = f"{data.swell_minBreakingHeight} - {data.swell_maxBreakingHeight} {data.swell_unit}" self._attrs[occurs] = hr_summary if self.type != "swell_forecast": diff --git a/homeassistant/components/mailgun/notify.py b/homeassistant/components/mailgun/notify.py index c2222cfd742..5ff57914be0 100644 --- a/homeassistant/components/mailgun/notify.py +++ b/homeassistant/components/mailgun/notify.py @@ -25,7 +25,6 @@ _LOGGER = logging.getLogger(__name__) # Images to attach to notification ATTR_IMAGES = "images" -DEFAULT_SENDER = "hass@{domain}" DEFAULT_SANDBOX = False # pylint: disable=no-value-for-parameter @@ -69,7 +68,7 @@ class MailgunNotificationService(BaseNotificationService): _LOGGER.debug("Mailgun domain: %s", self._client.domain) self._domain = self._client.domain if not self._sender: - self._sender = DEFAULT_SENDER.format(domain=self._domain) + self._sender = f"hass@{self._domain}" def connection_is_valid(self): """Check whether the provided credentials are valid.""" diff --git a/homeassistant/components/maxcube/__init__.py b/homeassistant/components/maxcube/__init__.py index 3e6ecbc948b..ffd156b5e00 100644 --- a/homeassistant/components/maxcube/__init__.py +++ b/homeassistant/components/maxcube/__init__.py @@ -65,9 +65,7 @@ def setup(hass, config): except timeout as ex: _LOGGER.error("Unable to connect to Max!Cube gateway: %s", str(ex)) hass.components.persistent_notification.create( - "Error: {}
" - "You will need to restart Home Assistant after fixing." - "".format(ex), + f"Error: {ex}
You will need to restart Home Assistant after fixing.", title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID, ) diff --git a/homeassistant/components/maxcube/binary_sensor.py b/homeassistant/components/maxcube/binary_sensor.py index 639b670baa8..2670b61b456 100644 --- a/homeassistant/components/maxcube/binary_sensor.py +++ b/homeassistant/components/maxcube/binary_sensor.py @@ -14,7 +14,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for handler in hass.data[DATA_KEY].values(): cube = handler.cube for device in cube.devices: - name = "{} {}".format(cube.room_by_id(device.room_id).name, device.name) + name = f"{cube.room_by_id(device.room_id).name} {device.name}" # Only add Window Shutters if cube.is_windowshutter(device): diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py index a6a63ddc0fd..e723853f629 100644 --- a/homeassistant/components/maxcube/climate.py +++ b/homeassistant/components/maxcube/climate.py @@ -34,7 +34,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for handler in hass.data[DATA_KEY].values(): cube = handler.cube for device in cube.devices: - name = "{} {}".format(cube.room_by_id(device.room_id).name, device.name) + name = f"{cube.room_by_id(device.room_id).name} {device.name}" if cube.is_thermostat(device) or cube.is_wallthermostat(device): devices.append(MaxCubeClimate(handler, name, device.rf_address)) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index a62b6bd7c2b..757dd00897d 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -104,7 +104,6 @@ _RND = SystemRandom() ENTITY_ID_FORMAT = DOMAIN + ".{}" -ENTITY_IMAGE_URL = "/api/media_player_proxy/{0}?token={1}&cache={2}" CACHE_IMAGES = "images" CACHE_MAXSIZE = "maxsize" CACHE_LOCK = "lock" @@ -767,7 +766,10 @@ class MediaPlayerDevice(Entity): if image_hash is None: return None - return ENTITY_IMAGE_URL.format(self.entity_id, self.access_token, image_hash) + return ( + f"/api/media_player_proxy/{self.entity_id}?" + f"token={self.access_token}&cache={image_hash}" + ) @property def capability_attributes(self): diff --git a/homeassistant/components/mediaroom/media_player.py b/homeassistant/components/mediaroom/media_player.py index 539138783ee..28d20d0db4b 100644 --- a/homeassistant/components/mediaroom/media_player.py +++ b/homeassistant/components/mediaroom/media_player.py @@ -147,7 +147,7 @@ class MediaroomDevice(MediaPlayerDevice): self._channel = None self._optimistic = optimistic self._state = STATE_PLAYING if optimistic else STATE_STANDBY - self._name = "Mediaroom {}".format(device_id if device_id else host) + self._name = f"Mediaroom {device_id if device_id else host}" self._available = True if device_id: self._unique_id = device_id diff --git a/homeassistant/components/met/const.py b/homeassistant/components/met/const.py index e60dab5072f..f29f034df68 100644 --- a/homeassistant/components/met/const.py +++ b/homeassistant/components/met/const.py @@ -9,7 +9,6 @@ HOME_LOCATION_NAME = "Home" CONF_TRACK_HOME = "track_home" -ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".met_{}" -ENTITY_ID_SENSOR_FORMAT_HOME = ENTITY_ID_SENSOR_FORMAT.format(HOME_LOCATION_NAME) +ENTITY_ID_SENSOR_FORMAT_HOME = f"{WEATHER_DOMAIN}.met_{HOME_LOCATION_NAME}" _LOGGER = logging.getLogger(".") diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index d99573a985e..13150098452 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -168,7 +168,7 @@ class MetWeather(WeatherEntity): if self.track_home: return "home" - return "{}-{}".format(self._config[CONF_LATITUDE], self._config[CONF_LONGITUDE]) + return f"{self._config[CONF_LATITUDE]}-{self._config[CONF_LONGITUDE]}" @property def name(self): diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py index 1c4ca83e5bd..d04f7c5f582 100644 --- a/homeassistant/components/metoffice/sensor.py +++ b/homeassistant/components/metoffice/sensor.py @@ -141,7 +141,7 @@ class MetOfficeCurrentSensor(Entity): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format(self._name, SENSOR_TYPES[self._condition][0]) + return f"{self._name} {SENSOR_TYPES[self._condition][0]}" @property def state(self): diff --git a/homeassistant/components/mhz19/sensor.py b/homeassistant/components/mhz19/sensor.py index 961d8646979..892895b9e02 100644 --- a/homeassistant/components/mhz19/sensor.py +++ b/homeassistant/components/mhz19/sensor.py @@ -85,7 +85,7 @@ class MHZ19Sensor(Entity): @property def name(self): """Return the name of the sensor.""" - return "{}: {}".format(self._name, SENSOR_TYPES[self._sensor_type][0]) + return f"{self._name}: {SENSOR_TYPES[self._sensor_type][0]}" @property def state(self): diff --git a/homeassistant/components/microsoft_face_detect/image_processing.py b/homeassistant/components/microsoft_face_detect/image_processing.py index c10f7edf9db..3b0580a3eeb 100644 --- a/homeassistant/components/microsoft_face_detect/image_processing.py +++ b/homeassistant/components/microsoft_face_detect/image_processing.py @@ -73,7 +73,7 @@ class MicrosoftFaceDetectEntity(ImageProcessingFaceEntity): if name: self._name = name else: - self._name = "MicrosoftFace {0}".format(split_entity_id(camera_entity)[1]) + self._name = f"MicrosoftFace {split_entity_id(camera_entity)[1]}" @property def camera_entity(self): diff --git a/homeassistant/components/microsoft_face_identify/image_processing.py b/homeassistant/components/microsoft_face_identify/image_processing.py index 820292eb365..1065e64a110 100644 --- a/homeassistant/components/microsoft_face_identify/image_processing.py +++ b/homeassistant/components/microsoft_face_identify/image_processing.py @@ -61,7 +61,7 @@ class MicrosoftFaceIdentifyEntity(ImageProcessingFaceEntity): if name: self._name = name else: - self._name = "MicrosoftFace {0}".format(split_entity_id(camera_entity)[1]) + self._name = f"MicrosoftFace {split_entity_id(camera_entity)[1]}" @property def confidence(self): diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py index 80beaf1f798..aa58cc0be21 100644 --- a/homeassistant/components/min_max/sensor.py +++ b/homeassistant/components/min_max/sensor.py @@ -115,9 +115,7 @@ class MinMaxSensor(Entity): if name: self._name = name else: - self._name = "{} sensor".format( - next(v for k, v in SENSOR_TYPES.items() if self._sensor_type == v) - ).capitalize() + self._name = f"{next(v for k, v in SENSOR_TYPES.items() if self._sensor_type == v)} sensor".capitalize() self._unit_of_measurement = None self._unit_of_measurement_mismatch = False self.min_value = self.max_value = self.mean = self.last = None diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index fcf95da586e..4396f8c8e0c 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -93,7 +93,7 @@ async def async_setup_entry(hass, entry): hass.data[DOMAIN][DATA_DEVICES][webhook_id] = device - registration_name = "Mobile App: {}".format(registration[ATTR_DEVICE_NAME]) + registration_name = f"Mobile App: {registration[ATTR_DEVICE_NAME]}" webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook) for domain in PLATFORMS: diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index b16c47e29c0..00f4577ad9e 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -139,8 +139,8 @@ class MobileAppNotificationService(BaseNotificationService): continue fallback_error = result.get("errorMessage", "Unknown error") - fallback_message = "Internal server error, please try again later: {}".format( - fallback_error + fallback_message = ( + f"Internal server error, please try again later: {fallback_error}" ) message = result.get("message", fallback_message) if response.status == 429: diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index c47f38986a1..adc90c15e98 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -381,7 +381,7 @@ async def webhook_register_sensor(hass, config_entry, data): _LOGGER.error("Error registering sensor: %s", ex) return empty_okay_response() - register_signal = "{}_{}_register".format(DOMAIN, data[ATTR_SENSOR_TYPE]) + register_signal = f"{DOMAIN}_{data[ATTR_SENSOR_TYPE]}_register" async_dispatcher_send(hass, register_signal, data) return webhook_response( diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index c0423849418..f83b7d7b901 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -162,7 +162,7 @@ class ModbusThermostat(ClimateDevice): DATA_TYPE_FLOAT: {1: "e", 2: "f", 4: "d"}, } - self._structure = ">{}".format(data_types[self._data_type][self._count]) + self._structure = f">{data_types[self._data_type][self._count]}" @property def supported_features(self): diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index 716cb5299b7..b586ad852df 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -99,8 +99,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): structure = ">i" if register[CONF_DATA_TYPE] != DATA_TYPE_CUSTOM: try: - structure = ">{}".format( - data_types[register[CONF_DATA_TYPE]][register[CONF_COUNT]] + structure = ( + f">{data_types[register[CONF_DATA_TYPE]][register[CONF_COUNT]]}" ) except KeyError: _LOGGER.error( diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py index b5c72fdce29..374866a6859 100644 --- a/homeassistant/components/mold_indicator/sensor.py +++ b/homeassistant/components/mold_indicator/sensor.py @@ -344,7 +344,7 @@ class MoldIndicator(Entity): elif crit_humidity < 0: self._state = "0" else: - self._state = "{0:d}".format(int(crit_humidity)) + self._state = f"{int(crit_humidity):d}" _LOGGER.debug("Mold indicator humidity: %s", self._state) diff --git a/homeassistant/components/mopar/__init__.py b/homeassistant/components/mopar/__init__.py index 21a3c3d16ea..4801a7c43d6 100644 --- a/homeassistant/components/mopar/__init__.py +++ b/homeassistant/components/mopar/__init__.py @@ -127,7 +127,7 @@ class MoparData: vehicle = self.vehicles[index] if not vehicle: return None - return "{} {} {}".format(vehicle["year"], vehicle["make"], vehicle["model"]) + return f"{vehicle['year']} {vehicle['make']} {vehicle['model']}" def actuate(self, command, index): """Run a command on the specified Mopar vehicle.""" diff --git a/homeassistant/components/mopar/lock.py b/homeassistant/components/mopar/lock.py index 49e25ad30c0..3933e567723 100644 --- a/homeassistant/components/mopar/lock.py +++ b/homeassistant/components/mopar/lock.py @@ -22,7 +22,7 @@ class MoparLock(LockDevice): def __init__(self, data, index): """Initialize the Mopar lock.""" self._index = index - self._name = "{} Lock".format(data.get_vehicle_name(self._index)) + self._name = f"{data.get_vehicle_name(self._index)} Lock" self._actuate = data.actuate self._state = None diff --git a/homeassistant/components/mopar/switch.py b/homeassistant/components/mopar/switch.py index 2dad56637ce..c7a8c762fbc 100644 --- a/homeassistant/components/mopar/switch.py +++ b/homeassistant/components/mopar/switch.py @@ -22,7 +22,7 @@ class MoparSwitch(SwitchDevice): def __init__(self, data, index): """Initialize the Switch.""" self._index = index - self._name = "{} Switch".format(data.get_vehicle_name(self._index)) + self._name = f"{data.get_vehicle_name(self._index)} Switch" self._actuate = data.actuate self._state = None diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index bb38c36090a..fbbf4f42d7a 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -437,8 +437,9 @@ async def async_subscribe( topic, catch_log_exception( wrapped_msg_callback, - lambda msg: "Exception in {} when handling msg on '{}': '{}'".format( - msg_callback.__name__, msg.topic, msg.payload + lambda msg: ( + f"Exception in {msg_callback.__name__} when handling msg on " + f"'{msg.topic}': '{msg.payload}'" ), ), qos, @@ -1014,7 +1015,7 @@ def _raise_on_error(result_code: int) -> None: if result_code != 0: raise HomeAssistantError( - "Error talking to MQTT: {}".format(mqtt.error_string(result_code)) + f"Error talking to MQTT: {mqtt.error_string(result_code)}" ) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index c54ab395c94..e6350179571 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -126,9 +126,9 @@ async def async_start( for key, value in payload.items(): if isinstance(value, str) and value: if value[0] == TOPIC_BASE and key.endswith("_topic"): - payload[key] = "{}{}".format(base, value[1:]) + payload[key] = f"{base}{value[1:]}" if value[-1] == TOPIC_BASE and key.endswith("_topic"): - payload[key] = "{}{}".format(value[:-1], base) + payload[key] = f"{value[:-1]}{base}" # If present, the node_id will be included in the discovered object id discovery_id = " ".join((node_id, object_id)) if node_id else object_id @@ -163,12 +163,10 @@ async def async_start( and component in IMPLICIT_STATE_TOPIC_COMPONENTS ): # state_topic not specified, infer from discovery topic - payload[CONF_STATE_TOPIC] = "{}/{}/{}{}/state".format( - discovery_topic, - component, - "%s/" % node_id if node_id else "", - object_id, - ) + fmt_node_id = f"{node_id}/" if node_id else "" + payload[ + CONF_STATE_TOPIC + ] = f"{discovery_topic}/{component}/{fmt_node_id}{object_id}/state" _LOGGER.warning( 'implicit %s is deprecated, add "%s":"%s" to ' "%s discovery message", @@ -199,7 +197,7 @@ async def async_start( await async_load_platform(hass, component, "mqtt", payload, hass_config) return - config_entries_key = "{}.{}".format(component, "mqtt") + config_entries_key = f"{component}.mqtt" async with hass.data[DATA_CONFIG_ENTRY_LOCK]: if config_entries_key not in hass.data[CONFIG_ENTRY_IS_SETUP]: if component == "device_automation": diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index a9ea21b4b0a..4b47014af48 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -675,7 +675,7 @@ class MqttLight( {"red": rgb[0], "green": rgb[1], "blue": rgb[2]} ) else: - rgb_color_str = "{},{},{}".format(*rgb) + rgb_color_str = f"{rgb[0]},{rgb[1]},{rgb[2]}" mqtt.async_publish( self.hass, @@ -695,7 +695,7 @@ class MqttLight( mqtt.async_publish( self.hass, self._topic[CONF_HS_COMMAND_TOPIC], - "{},{}".format(*hs_color), + f"{hs_color[0]},{hs_color[1]}", self._config[CONF_QOS], self._config[CONF_RETAIN], ) @@ -710,7 +710,7 @@ class MqttLight( mqtt.async_publish( self.hass, self._topic[CONF_XY_COMMAND_TOPIC], - "{},{}".format(*xy_color), + f"{xy_color[0]},{xy_color[1]}", self._config[CONF_QOS], self._config[CONF_RETAIN], ) @@ -753,7 +753,7 @@ class MqttLight( {"red": rgb[0], "green": rgb[1], "blue": rgb[2]} ) else: - rgb_color_str = "{},{},{}".format(*rgb) + rgb_color_str = f"{rgb[0]},{rgb[1]},{rgb[2]}" mqtt.async_publish( self.hass, diff --git a/homeassistant/components/mqtt/server.py b/homeassistant/components/mqtt/server.py index 61ba5c392b1..1b2a56a2195 100644 --- a/homeassistant/components/mqtt/server.py +++ b/homeassistant/components/mqtt/server.py @@ -85,9 +85,7 @@ def generate_config(hass, passwd, password): # Encrypt with what hbmqtt uses to verify passwd.write( - "homeassistant:{}\n".format(custom_app_context.encrypt(password)).encode( - "utf-8" - ) + f"homeassistant:{custom_app_context.encrypt(password)}\n".encode("utf-8") ) passwd.flush() diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index d8dfa65f799..580ffd606f3 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -73,7 +73,7 @@ class MQTTRoomSensor(Entity): """Initialize the sensor.""" self._state = STATE_NOT_HOME self._name = name - self._state_topic = "{}{}".format(state_topic, "/+") + self._state_topic = f"{state_topic}/+" self._device_id = slugify(device_id).upper() self._timeout = timeout self._consider_home = ( diff --git a/homeassistant/components/mychevy/binary_sensor.py b/homeassistant/components/mychevy/binary_sensor.py index e6d4d23c9b4..e5b0dc8b6ea 100644 --- a/homeassistant/components/mychevy/binary_sensor.py +++ b/homeassistant/components/mychevy/binary_sensor.py @@ -1,7 +1,10 @@ """Support for MyChevy binary sensors.""" import logging -from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorDevice +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorDevice, +) from homeassistant.core import callback from homeassistant.util import slugify @@ -42,11 +45,7 @@ class EVBinarySensor(BinarySensorDevice): self._type = config.device_class self._is_on = None self._car_vid = car_vid - self.entity_id = ENTITY_ID_FORMAT.format( - "{}_{}_{}".format( - MYCHEVY_DOMAIN, slugify(self._car.name), slugify(self._name) - ) - ) + self.entity_id = f"{BINARY_SENSOR_DOMAIN}.{MYCHEVY_DOMAIN}_{slugify(self._car.name)}_{slugify(self._name)}" @property def name(self): diff --git a/homeassistant/components/mychevy/sensor.py b/homeassistant/components/mychevy/sensor.py index 56335bb1650..f45c81a0007 100644 --- a/homeassistant/components/mychevy/sensor.py +++ b/homeassistant/components/mychevy/sensor.py @@ -1,7 +1,7 @@ """Support for MyChevy sensors.""" import logging -from homeassistant.components.sensor import ENTITY_ID_FORMAT +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import UNIT_PERCENTAGE from homeassistant.core import callback from homeassistant.helpers.entity import Entity @@ -123,11 +123,7 @@ class EVSensor(Entity): self._state_attributes = {} self._car_vid = car_vid - self.entity_id = ENTITY_ID_FORMAT.format( - "{}_{}_{}".format( - MYCHEVY_DOMAIN, slugify(self._car.name), slugify(self._name) - ) - ) + self.entity_id = f"{SENSOR_DOMAIN}.{MYCHEVY_DOMAIN}_{slugify(self._car.name)}_{slugify(self._name)}" async def async_added_to_hass(self): """Register callbacks.""" diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py index a528be15e14..43e398b142f 100644 --- a/homeassistant/components/mysensors/__init__.py +++ b/homeassistant/components/mysensors/__init__.py @@ -129,7 +129,7 @@ async def async_setup(hass, config): def _get_mysensors_name(gateway, node_id, child_id): """Return a name for a node child.""" - node_name = "{} {}".format(gateway.sensors[node_id].sketch_name, node_id) + node_name = f"{gateway.sensors[node_id].sketch_name} {node_id}" node_name = next( ( node[CONF_NODE_NAME] diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index 903ec069b51..d906c306dfc 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -42,7 +42,7 @@ MQTT_COMPONENT = "mqtt" def is_serial_port(value): """Validate that value is a windows serial port or a unix device.""" if sys.platform.startswith("win"): - ports = ("COM{}".format(idx + 1) for idx in range(256)) + ports = (f"COM{idx + 1}" for idx in range(256)) if value in ports: return value raise vol.Invalid(f"{value} is not a serial port") @@ -73,8 +73,7 @@ async def setup_gateways(hass, config): for index, gateway_conf in enumerate(conf[CONF_GATEWAYS]): persistence_file = gateway_conf.get( - CONF_PERSISTENCE_FILE, - hass.config.path("mysensors{}.pickle".format(index + 1)), + CONF_PERSISTENCE_FILE, hass.config.path(f"mysensors{index + 1}.pickle"), ) ready_gateway = await _get_gateway(hass, config, gateway_conf, persistence_file) if ready_gateway is not None: diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index f0e9b06b762..20b266e550e 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -92,8 +92,8 @@ def invalid_msg(gateway, child, value_type_name): """Return a message for an invalid child during schema validation.""" pres = gateway.const.Presentation set_req = gateway.const.SetReq - return "{} requires value_type {}".format( - pres(child.type).name, set_req[value_type_name].name + return ( + f"{pres(child.type).name} requires value_type {set_req[value_type_name].name}" ) diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index 28d201d78cd..3aa65734d34 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -5,7 +5,7 @@ import logging from pyps4_2ndscreen.errors import NotReady, PSDataIncomplete import pyps4_2ndscreen.ps4 as pyps4 -from homeassistant.components.media_player import ENTITY_IMAGE_URL, MediaPlayerDevice +from homeassistant.components.media_player import MediaPlayerDevice from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_TITLE, @@ -387,8 +387,9 @@ class PS4Device(MediaPlayerDevice): if self._state == STATE_PLAYING and self._media_content_id is not None: image_hash = self.media_image_hash if image_hash is not None: - return ENTITY_IMAGE_URL.format( - self.entity_id, self.access_token, image_hash + return ( + f"/api/media_player_proxy/{self.entity_id}?" + f"token={self.access_token}&cache={image_hash}" ) return MEDIA_IMAGE_DEFAULT From b9a9a92145250615b1b834dd497684a44e2e02d0 Mon Sep 17 00:00:00 2001 From: cgtobi Date: Wed, 11 Mar 2020 01:08:59 +0100 Subject: [PATCH 326/416] Refactor netatmo webhooks (#32195) * Start webhook implementation * Add webhook implementation * Bump pyatmo 3.2.5 * Fire event after data evaluation * Setup webhooks after components * Fix logging * Wrap non async call * Wrap non async call * Add smoke detector and door tag webhook support * Catch when webhook registration fails * Log to debug * Fix persons lookup * Add dependency * Remove false requirements * Fix requirements * Replace netatmo events by a single one * Slim down code * Clean up code * Address review vomments * Undo attribute removal * Bump pyatmo to v3.3.0 * Only create webhook id once and reuse * Store and reuse cloudhook url * Wait for hass core to be up and running * Register webhook once HA is ready * Delay webhook registration --- homeassistant/components/netatmo/__init__.py | 79 ++++++++++++++++++- homeassistant/components/netatmo/const.py | 20 +---- .../components/netatmo/manifest.json | 5 +- homeassistant/components/netatmo/webhook.py | 65 +++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 152 insertions(+), 21 deletions(-) create mode 100644 homeassistant/components/netatmo/webhook.py diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index bd79f597b5b..3c318c705bf 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -1,27 +1,47 @@ """The Netatmo integration.""" import asyncio import logging +import secrets +import pyatmo import voluptuous as vol +from homeassistant.components import cloud +from homeassistant.components.webhook import ( + async_register as webhook_register, + async_unregister as webhook_unregister, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISCOVERY, CONF_USERNAME, + CONF_WEBHOOK_ID, + EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv from . import api, config_flow -from .const import AUTH, DATA_PERSONS, DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN +from .const import ( + AUTH, + CONF_CLOUDHOOK_URL, + DATA_PERSONS, + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, +) +from .webhook import handle_webhook _LOGGER = logging.getLogger(__name__) CONF_SECRET_KEY = "secret_key" CONF_WEBHOOKS = "webhooks" +WAIT_FOR_CLOUD = 5 + CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( @@ -79,6 +99,46 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): hass.config_entries.async_forward_entry_setup(entry, component) ) + async def unregister_webhook(event): + _LOGGER.debug("Unregister Netatmo webhook (%s)", entry.data[CONF_WEBHOOK_ID]) + webhook_unregister(hass, entry.data[CONF_WEBHOOK_ID]) + + async def register_webhook(event): + # Wait for the could integration to be ready + await asyncio.sleep(WAIT_FOR_CLOUD) + + if CONF_WEBHOOK_ID not in entry.data: + data = {**entry.data, CONF_WEBHOOK_ID: secrets.token_hex()} + hass.config_entries.async_update_entry(entry, data=data) + + if hass.components.cloud.async_active_subscription(): + if CONF_CLOUDHOOK_URL not in entry.data: + webhook_url = await hass.components.cloud.async_create_cloudhook( + entry.data[CONF_WEBHOOK_ID] + ) + data = {**entry.data, CONF_CLOUDHOOK_URL: webhook_url} + hass.config_entries.async_update_entry(entry, data=data) + else: + webhook_url = entry.data[CONF_CLOUDHOOK_URL] + else: + webhook_url = hass.components.webhook.async_generate_url( + entry.data[CONF_WEBHOOK_ID] + ) + + try: + await hass.async_add_executor_job( + hass.data[DOMAIN][entry.entry_id][AUTH].addwebhook, webhook_url + ) + webhook_register( + hass, DOMAIN, "Netatmo", entry.data[CONF_WEBHOOK_ID], handle_webhook + ) + _LOGGER.info("Register Netatmo webhook: %s", webhook_url) + except pyatmo.ApiError as err: + _LOGGER.error("Error during webhook registration - %s", err) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, unregister_webhook) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, register_webhook) return True @@ -95,4 +155,21 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) + if CONF_WEBHOOK_ID in entry.data: + await hass.async_add_executor_job( + hass.data[DOMAIN][entry.entry_id][AUTH].dropwebhook() + ) + return unload_ok + + +async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry): + """Cleanup when entry is removed.""" + if CONF_WEBHOOK_ID in entry.data: + try: + _LOGGER.debug( + "Removing Netatmo cloudhook (%s)", entry.data[CONF_WEBHOOK_ID] + ) + await cloud.async_delete_cloudhook(hass, entry.data[CONF_WEBHOOK_ID]) + except cloud.CloudNotAvailable: + pass diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 5d981dc23b4..4443ef23032 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -11,43 +11,29 @@ CONF_PUBLIC = "public_sensor_config" CAMERA_DATA = "netatmo_camera" HOME_DATA = "netatmo_home_data" +CONF_CLOUDHOOK_URL = "cloudhook_url" + OAUTH2_AUTHORIZE = "https://api.netatmo.com/oauth2/authorize" OAUTH2_TOKEN = "https://api.netatmo.com/oauth2/token" DATA_PERSONS = "netatmo_persons" NETATMO_WEBHOOK_URL = None +NETATMO_EVENT = "netatmo_event" DEFAULT_PERSON = "Unknown" DEFAULT_DISCOVERY = True DEFAULT_WEBHOOKS = False -EVENT_PERSON = "person" -EVENT_MOVEMENT = "movement" -EVENT_HUMAN = "human" -EVENT_ANIMAL = "animal" -EVENT_VEHICLE = "vehicle" - -EVENT_BUS_PERSON = "netatmo_person" -EVENT_BUS_MOVEMENT = "netatmo_movement" -EVENT_BUS_HUMAN = "netatmo_human" -EVENT_BUS_ANIMAL = "netatmo_animal" -EVENT_BUS_VEHICLE = "netatmo_vehicle" -EVENT_BUS_OTHER = "netatmo_other" - ATTR_ID = "id" ATTR_PSEUDO = "pseudo" ATTR_NAME = "name" ATTR_EVENT_TYPE = "event_type" -ATTR_MESSAGE = "message" -ATTR_CAMERA_ID = "camera_id" ATTR_HOME_ID = "home_id" ATTR_HOME_NAME = "home_name" ATTR_PERSONS = "persons" ATTR_IS_KNOWN = "is_known" ATTR_FACE_URL = "face_url" -ATTR_SNAPSHOT_URL = "snapshot_url" -ATTR_VIGNETTE_URL = "vignette_url" ATTR_SCHEDULE_ID = "schedule_id" ATTR_SCHEDULE_NAME = "schedule_name" diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json index 6fe084cc885..6e1c3d9f8f4 100644 --- a/homeassistant/components/netatmo/manifest.json +++ b/homeassistant/components/netatmo/manifest.json @@ -3,7 +3,10 @@ "name": "Netatmo", "documentation": "https://www.home-assistant.io/integrations/netatmo", "requirements": [ - "pyatmo==3.2.4" + "pyatmo==3.3.0" + ], + "after_dependencies": [ + "cloud" ], "dependencies": [ "webhook" diff --git a/homeassistant/components/netatmo/webhook.py b/homeassistant/components/netatmo/webhook.py new file mode 100644 index 00000000000..8b6a4d3f1e1 --- /dev/null +++ b/homeassistant/components/netatmo/webhook.py @@ -0,0 +1,65 @@ +"""The Netatmo integration.""" +import logging + +from homeassistant.core import callback + +from .const import ( + ATTR_EVENT_TYPE, + ATTR_FACE_URL, + ATTR_ID, + ATTR_IS_KNOWN, + ATTR_NAME, + ATTR_PERSONS, + DATA_PERSONS, + DEFAULT_PERSON, + DOMAIN, + NETATMO_EVENT, +) + +_LOGGER = logging.getLogger(__name__) + + +async def handle_webhook(hass, webhook_id, request): + """Handle webhook callback.""" + try: + data = await request.json() + except ValueError: + return None + + _LOGGER.debug("Got webhook data: %s", data) + + event_type = data.get(ATTR_EVENT_TYPE) + + if event_type == "outdoor": + hass.bus.async_fire( + event_type=NETATMO_EVENT, event_data={"type": event_type, "data": data} + ) + for event_data in data.get("event_list"): + async_evaluate_event(hass, event_data) + else: + async_evaluate_event(hass, data) + + +@callback +def async_evaluate_event(hass, event_data): + """Evaluate events from webhook.""" + event_type = event_data.get(ATTR_EVENT_TYPE) + + if event_type == "person": + for person in event_data.get(ATTR_PERSONS): + person_event_data = dict(event_data) + person_event_data[ATTR_ID] = person.get(ATTR_ID) + person_event_data[ATTR_NAME] = hass.data[DOMAIN][DATA_PERSONS].get( + person_event_data[ATTR_ID], DEFAULT_PERSON + ) + person_event_data[ATTR_IS_KNOWN] = person.get(ATTR_IS_KNOWN) + person_event_data[ATTR_FACE_URL] = person.get(ATTR_FACE_URL) + hass.bus.async_fire( + event_type=NETATMO_EVENT, + event_data={"type": event_type, "data": person_event_data}, + ) + else: + hass.bus.async_fire( + event_type=NETATMO_EVENT, + event_data={"type": event_type, "data": event_data}, + ) diff --git a/requirements_all.txt b/requirements_all.txt index 0dd4920e944..554ba56d084 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1158,7 +1158,7 @@ pyalmond==0.0.2 pyarlo==0.2.3 # homeassistant.components.netatmo -pyatmo==3.2.4 +pyatmo==3.3.0 # homeassistant.components.atome pyatome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ae7cb1c582..e20a739ef22 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -434,7 +434,7 @@ pyalmond==0.0.2 pyarlo==0.2.3 # homeassistant.components.netatmo -pyatmo==3.2.4 +pyatmo==3.3.0 # homeassistant.components.blackbird pyblackbird==0.5 From ae147fd9c7413b32ac635358732e24a3801138c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2020 19:09:49 -0500 Subject: [PATCH 327/416] Lock operation sensors for August (#32593) * adkj * reduce * Convert august to async Async io was added to py-august 0.24 * Fix lint * Lock operation sensors for august * Tracking lock operation method allows user presence detection at the lock * revert lock changes * fix activity count merge conflict reversion * Fix revert that come back with the conflict --- homeassistant/components/august/activity.py | 2 +- homeassistant/components/august/const.py | 10 + homeassistant/components/august/sensor.py | 122 ++++++++++- tests/components/august/test_lock.py | 12 ++ tests/components/august/test_sensor.py | 191 ++++++++++++++++++ .../get_activity.lock_from_autorelock.json | 34 ++++ .../get_activity.lock_from_bluetooth.json | 34 ++++ .../august/get_activity.lock_from_keypad.json | 35 ++++ 8 files changed, 437 insertions(+), 3 deletions(-) create mode 100644 tests/fixtures/august/get_activity.lock_from_autorelock.json create mode 100644 tests/fixtures/august/get_activity.lock_from_bluetooth.json create mode 100644 tests/fixtures/august/get_activity.lock_from_keypad.json diff --git a/homeassistant/components/august/activity.py b/homeassistant/components/august/activity.py index 0b583d73886..c7a7d68d959 100644 --- a/homeassistant/components/august/activity.py +++ b/homeassistant/components/august/activity.py @@ -11,7 +11,7 @@ from .subscriber import AugustSubscriberMixin _LOGGER = logging.getLogger(__name__) ACTIVITY_STREAM_FETCH_LIMIT = 10 -ACTIVITY_CATCH_UP_FETCH_LIMIT = 200 +ACTIVITY_CATCH_UP_FETCH_LIMIT = 1000 class ActivityStream(AugustSubscriberMixin): diff --git a/homeassistant/components/august/const.py b/homeassistant/components/august/const.py index 923f90c331e..e8b8637b6cb 100644 --- a/homeassistant/components/august/const.py +++ b/homeassistant/components/august/const.py @@ -20,6 +20,16 @@ DATA_AUGUST = "data_august" DEFAULT_NAME = "August" DOMAIN = "august" +OPERATION_METHOD_AUTORELOCK = "autorelock" +OPERATION_METHOD_REMOTE = "remote" +OPERATION_METHOD_KEYPAD = "keypad" +OPERATION_METHOD_MOBILE_DEVICE = "mobile" + +ATTR_OPERATION_AUTORELOCK = "autorelock" +ATTR_OPERATION_METHOD = "method" +ATTR_OPERATION_REMOTE = "remote" +ATTR_OPERATION_KEYPAD = "keypad" + # Limit battery, online, and hardware updates to hourly # in order to reduce the number of api requests and # avoid hitting rate limits diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 6e8571c343a..018837a81dc 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -1,12 +1,26 @@ """Support for August sensors.""" import logging +from august.activity import ActivityType + from homeassistant.components.sensor import DEVICE_CLASS_BATTERY -from homeassistant.const import UNIT_PERCENTAGE +from homeassistant.const import ATTR_ENTITY_PICTURE, UNIT_PERCENTAGE from homeassistant.core import callback from homeassistant.helpers.entity import Entity +from homeassistant.helpers.restore_state import RestoreEntity -from .const import DATA_AUGUST, DOMAIN +from .const import ( + ATTR_OPERATION_AUTORELOCK, + ATTR_OPERATION_KEYPAD, + ATTR_OPERATION_METHOD, + ATTR_OPERATION_REMOTE, + DATA_AUGUST, + DOMAIN, + OPERATION_METHOD_AUTORELOCK, + OPERATION_METHOD_KEYPAD, + OPERATION_METHOD_MOBILE_DEVICE, + OPERATION_METHOD_REMOTE, +) from .entity import AugustEntityMixin _LOGGER = logging.getLogger(__name__) @@ -42,6 +56,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): data = hass.data[DOMAIN][config_entry.entry_id][DATA_AUGUST] devices = [] + operation_sensors = [] batteries = { "device_battery": [], "linked_keypad_battery": [], @@ -51,6 +66,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for device in data.locks: batteries["device_battery"].append(device) batteries["linked_keypad_battery"].append(device) + operation_sensors.append(device) for sensor_type in SENSOR_TYPES_BATTERY: for device in batteries[sensor_type]: @@ -70,9 +86,111 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ) devices.append(AugustBatterySensor(data, sensor_type, device)) + for device in operation_sensors: + devices.append(AugustOperatorSensor(data, device)) + async_add_entities(devices, True) +class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, Entity): + """Representation of an August lock operation sensor.""" + + def __init__(self, data, device): + """Initialize the sensor.""" + super().__init__(data, device) + self._data = data + self._device = device + self._state = None + self._operated_remote = None + self._operated_keypad = None + self._operated_autorelock = None + self._operated_time = None + self._available = False + self._entity_picture = None + self._update_from_data() + + @property + def available(self): + """Return the availability of this sensor.""" + return self._available + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._device.device_name} Operator" + + @callback + def _update_from_data(self): + """Get the latest state of the sensor and update activity.""" + lock_activity = self._data.activity_stream.get_latest_device_activity( + self._device_id, [ActivityType.LOCK_OPERATION] + ) + + if lock_activity is not None: + self._available = True + self._state = lock_activity.operated_by + self._operated_remote = lock_activity.operated_remote + self._operated_keypad = lock_activity.operated_keypad + self._operated_autorelock = lock_activity.operated_autorelock + self._entity_picture = lock_activity.operator_thumbnail_url + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + attributes = {} + + if self._operated_remote is not None: + attributes[ATTR_OPERATION_REMOTE] = self._operated_remote + if self._operated_keypad is not None: + attributes[ATTR_OPERATION_KEYPAD] = self._operated_keypad + if self._operated_autorelock is not None: + attributes[ATTR_OPERATION_AUTORELOCK] = self._operated_autorelock + + if self._operated_remote: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_REMOTE + elif self._operated_keypad: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_KEYPAD + elif self._operated_autorelock: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_AUTORELOCK + else: + attributes[ATTR_OPERATION_METHOD] = OPERATION_METHOD_MOBILE_DEVICE + + return attributes + + async def async_added_to_hass(self): + """Restore ATTR_CHANGED_BY on startup since it is likely no longer in the activity log.""" + await super().async_added_to_hass() + + last_state = await self.async_get_last_state() + if not last_state: + return + + self._state = last_state.state + if ATTR_ENTITY_PICTURE in last_state.attributes: + self._entity_picture = last_state.attributes[ATTR_ENTITY_PICTURE] + if ATTR_OPERATION_REMOTE in last_state.attributes: + self._operated_remote = last_state.attributes[ATTR_OPERATION_REMOTE] + if ATTR_OPERATION_KEYPAD in last_state.attributes: + self._operated_keypad = last_state.attributes[ATTR_OPERATION_KEYPAD] + if ATTR_OPERATION_AUTORELOCK in last_state.attributes: + self._operated_autorelock = last_state.attributes[ATTR_OPERATION_AUTORELOCK] + + @property + def entity_picture(self): + """Return the entity picture to use in the frontend, if any.""" + return self._entity_picture + + @property + def unique_id(self) -> str: + """Get the unique id of the device sensor.""" + return f"{self._device_id}_lock_operator" + + class AugustBatterySensor(AugustEntityMixin, Entity): """Representation of an August sensor.""" diff --git a/tests/components/august/test_lock.py b/tests/components/august/test_lock.py index ef8518e0bbc..4bd5509a216 100644 --- a/tests/components/august/test_lock.py +++ b/tests/components/august/test_lock.py @@ -6,6 +6,7 @@ from homeassistant.const import ( SERVICE_LOCK, SERVICE_UNLOCK, STATE_LOCKED, + STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNLOCKED, ) @@ -87,6 +88,17 @@ async def test_one_lock_operation(hass): lock_online_with_doorsense_name = hass.states.get("lock.online_with_doorsense_name") assert lock_online_with_doorsense_name.state == STATE_LOCKED + # No activity means it will be unavailable until the activity feed has data + entity_registry = await hass.helpers.entity_registry.async_get_registry() + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" + ) + assert lock_operator_sensor + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").state + == STATE_UNAVAILABLE + ) + async def test_one_lock_unknown_state(hass): """Test creation of a lock with doorsense and bridge.""" diff --git a/tests/components/august/test_sensor.py b/tests/components/august/test_sensor.py index dfcae6dd362..8c52d80c337 100644 --- a/tests/components/august/test_sensor.py +++ b/tests/components/august/test_sensor.py @@ -1,8 +1,12 @@ """The sensor tests for the august platform.""" +from homeassistant.const import STATE_UNAVAILABLE + from tests.components.august.mocks import ( _create_august_with_devices, + _mock_activities_from_fixture, _mock_doorbell_from_fixture, + _mock_doorsense_enabled_august_lock_detail, _mock_lock_from_fixture, ) @@ -121,3 +125,190 @@ async def test_create_lock_with_low_battery_linked_keypad(hass): ) assert entry assert entry.unique_id == "A6697750D607098BAE8D6BAA11EF8063_linked_keypad_battery" + + # No activity means it will be unavailable until someone unlocks/locks it + lock_operator_sensor = entity_registry.async_get( + "sensor.a6697750d607098bae8d6baa11ef8063_name_operator" + ) + assert ( + lock_operator_sensor.unique_id + == "A6697750D607098BAE8D6BAA11EF8063_lock_operator" + ) + assert ( + hass.states.get("sensor.a6697750d607098bae8d6baa11ef8063_name_operator").state + == STATE_UNAVAILABLE + ) + + +async def test_lock_operator_bluetooth(hass): + """Test operation of a lock with doorsense and bridge.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture( + hass, "get_activity.lock_from_bluetooth.json" + ) + await _create_august_with_devices(hass, [lock_one], activities=activities) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" + ) + assert lock_operator_sensor + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").state + == "Your favorite elven princess" + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "remote" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "keypad" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "autorelock" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "method" + ] + == "mobile" + ) + + +async def test_lock_operator_keypad(hass): + """Test operation of a lock with doorsense and bridge.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture( + hass, "get_activity.lock_from_keypad.json" + ) + await _create_august_with_devices(hass, [lock_one], activities=activities) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" + ) + assert lock_operator_sensor + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").state + == "Your favorite elven princess" + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "remote" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "keypad" + ] + is True + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "autorelock" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "method" + ] + == "keypad" + ) + + +async def test_lock_operator_remote(hass): + """Test operation of a lock with doorsense and bridge.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture(hass, "get_activity.lock.json") + await _create_august_with_devices(hass, [lock_one], activities=activities) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" + ) + assert lock_operator_sensor + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").state + == "Your favorite elven princess" + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "remote" + ] + is True + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "keypad" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "autorelock" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "method" + ] + == "remote" + ) + + +async def test_lock_operator_autorelock(hass): + """Test operation of a lock with doorsense and bridge.""" + lock_one = await _mock_doorsense_enabled_august_lock_detail(hass) + + activities = await _mock_activities_from_fixture( + hass, "get_activity.lock_from_autorelock.json" + ) + await _create_august_with_devices(hass, [lock_one], activities=activities) + + entity_registry = await hass.helpers.entity_registry.async_get_registry() + lock_operator_sensor = entity_registry.async_get( + "sensor.online_with_doorsense_name_operator" + ) + assert lock_operator_sensor + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").state + == "Auto Relock" + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "remote" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "keypad" + ] + is False + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "autorelock" + ] + is True + ) + assert ( + hass.states.get("sensor.online_with_doorsense_name_operator").attributes[ + "method" + ] + == "autorelock" + ) diff --git a/tests/fixtures/august/get_activity.lock_from_autorelock.json b/tests/fixtures/august/get_activity.lock_from_autorelock.json new file mode 100644 index 00000000000..1c5d19344dc --- /dev/null +++ b/tests/fixtures/august/get_activity.lock_from_autorelock.json @@ -0,0 +1,34 @@ +[{ + "entities" : { + "activity" : "mockActivity2", + "house" : "123", + "device" : "online_with_doorsense", + "callingUser" : "mockUserId2", + "otherUser" : "deleted" + }, + "callingUser" : { + "LastName" : "Relock", + "UserID" : "automaticrelock", + "FirstName" : "Auto" + }, + "otherUser" : { + "LastName" : "User", + "UserName" : "deleteduser", + "FirstName" : "Unknown", + "UserID" : "deleted", + "PhoneNo" : "deleted" + }, + "deviceType" : "lock", + "deviceName" : "MockHouseTDoor", + "action" : "lock", + "dateTime" : 1582007218000, + "info" : { + "remote" : false, + "DateLogActionID" : "ABC+Time" + }, + "deviceID" : "online_with_doorsense", + "house" : { + "houseName" : "MockHouse", + "houseID" : "123" + } +}] diff --git a/tests/fixtures/august/get_activity.lock_from_bluetooth.json b/tests/fixtures/august/get_activity.lock_from_bluetooth.json new file mode 100644 index 00000000000..f48d8da1319 --- /dev/null +++ b/tests/fixtures/august/get_activity.lock_from_bluetooth.json @@ -0,0 +1,34 @@ +[{ + "entities" : { + "activity" : "mockActivity2", + "house" : "123", + "device" : "online_with_doorsense", + "callingUser" : "mockUserId2", + "otherUser" : "deleted" + }, + "callingUser" : { + "LastName" : "elven princess", + "UserID" : "mockUserId2", + "FirstName" : "Your favorite" + }, + "otherUser" : { + "LastName" : "User", + "UserName" : "deleteduser", + "FirstName" : "Unknown", + "UserID" : "deleted", + "PhoneNo" : "deleted" + }, + "deviceType" : "lock", + "deviceName" : "MockHouseTDoor", + "action" : "lock", + "dateTime" : 1582007218000, + "info" : { + "remote" : false, + "DateLogActionID" : "ABC+Time" + }, + "deviceID" : "online_with_doorsense", + "house" : { + "houseName" : "MockHouse", + "houseID" : "123" + } +}] diff --git a/tests/fixtures/august/get_activity.lock_from_keypad.json b/tests/fixtures/august/get_activity.lock_from_keypad.json new file mode 100644 index 00000000000..4c76fc46cd8 --- /dev/null +++ b/tests/fixtures/august/get_activity.lock_from_keypad.json @@ -0,0 +1,35 @@ +[{ + "entities" : { + "activity" : "mockActivity2", + "house" : "123", + "device" : "online_with_doorsense", + "callingUser" : "mockUserId2", + "otherUser" : "deleted" + }, + "callingUser" : { + "LastName" : "elven princess", + "UserID" : "mockUserId2", + "FirstName" : "Your favorite" + }, + "otherUser" : { + "LastName" : "User", + "UserName" : "deleteduser", + "FirstName" : "Unknown", + "UserID" : "deleted", + "PhoneNo" : "deleted" + }, + "deviceType" : "lock", + "deviceName" : "MockHouseTDoor", + "action" : "lock", + "dateTime" : 1582007218000, + "info" : { + "remote" : false, + "keypad" : true, + "DateLogActionID" : "ABC+Time" + }, + "deviceID" : "online_with_doorsense", + "house" : { + "houseName" : "MockHouse", + "houseID" : "123" + } +}] From 048f9e7daa134b0a540af5ba2c5e0ab9146cee8f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 10 Mar 2020 19:10:00 -0500 Subject: [PATCH 328/416] =?UTF-8?q?Throw=20ConfigEntryNotReady=20when=20au?= =?UTF-8?q?gust=20servers=20are=20offline=20or=20u=E2=80=A6=20(#32635)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Throw ConfigEntryNotReady when august servers are offline * august has tests now and its nearing 100% * Adjust per review * define in init --- .coveragerc | 1 - homeassistant/components/august/__init__.py | 9 ++++--- homeassistant/components/august/gateway.py | 14 ++++++----- tests/components/august/test_init.py | 27 +++++++++++++++++++-- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/.coveragerc b/.coveragerc index 89da763f3ca..0e372cb9e1d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -61,7 +61,6 @@ omit = homeassistant/components/asterisk_mbox/* homeassistant/components/aten_pe/* homeassistant/components/atome/* - homeassistant/components/august/* homeassistant/components/aurora_abb_powerone/sensor.py homeassistant/components/automatic/device_tracker.py homeassistant/components/avea/light.py diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index e51d087d519..373fcae8d0c 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError import homeassistant.helpers.config_validation as cv from .activity import ActivityStream @@ -164,9 +164,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up August from a config entry.""" august_gateway = AugustGateway(hass) - await august_gateway.async_setup(entry.data) - return await async_setup_august(hass, entry, august_gateway) + try: + await august_gateway.async_setup(entry.data) + return await async_setup_august(hass, entry, august_gateway) + except asyncio.TimeoutError: + raise ConfigEntryNotReady async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py index 356414173ac..bb39523a984 100644 --- a/homeassistant/components/august/gateway.py +++ b/homeassistant/components/august/gateway.py @@ -29,6 +29,7 @@ class AugustGateway: """Init the connection.""" self._aiohttp_session = aiohttp_client.async_get_clientsession(hass) self._token_refresh_lock = asyncio.Lock() + self._access_token_cache_file = None self._hass = hass self._config = None self._api = None @@ -63,17 +64,18 @@ class AugustGateway: CONF_PASSWORD: self._config[CONF_PASSWORD], CONF_INSTALL_ID: self._config.get(CONF_INSTALL_ID), CONF_TIMEOUT: self._config.get(CONF_TIMEOUT), - CONF_ACCESS_TOKEN_CACHE_FILE: self._config[CONF_ACCESS_TOKEN_CACHE_FILE], + CONF_ACCESS_TOKEN_CACHE_FILE: self._access_token_cache_file, } async def async_setup(self, conf): """Create the api and authenticator objects.""" if conf.get(VERIFICATION_CODE_KEY): return - if conf.get(CONF_ACCESS_TOKEN_CACHE_FILE) is None: - conf[ - CONF_ACCESS_TOKEN_CACHE_FILE - ] = f".{conf[CONF_USERNAME]}{DEFAULT_AUGUST_CONFIG_FILE}" + + self._access_token_cache_file = conf.get( + CONF_ACCESS_TOKEN_CACHE_FILE, + f".{conf[CONF_USERNAME]}{DEFAULT_AUGUST_CONFIG_FILE}", + ) self._config = conf self._api = ApiAsync( @@ -87,7 +89,7 @@ class AugustGateway: self._config[CONF_PASSWORD], install_id=self._config.get(CONF_INSTALL_ID), access_token_cache_file=self._hass.config.path( - self._config[CONF_ACCESS_TOKEN_CACHE_FILE] + self._access_token_cache_file ), ) diff --git a/tests/components/august/test_init.py b/tests/components/august/test_init.py index 906eeff6213..c287a26b34f 100644 --- a/tests/components/august/test_init.py +++ b/tests/components/august/test_init.py @@ -1,4 +1,6 @@ """The tests for the august platform.""" +import asyncio + from asynctest import patch from august.exceptions import AugustApiAIOHTTPError @@ -8,8 +10,10 @@ from homeassistant.components.august.const import ( CONF_INSTALL_ID, CONF_LOGIN_METHOD, DEFAULT_AUGUST_CONFIG_FILE, + DOMAIN, ) from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN +from homeassistant.config_entries import ENTRY_STATE_SETUP_RETRY from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PASSWORD, @@ -23,6 +27,7 @@ from homeassistant.const import ( from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry from tests.components.august.mocks import ( _create_august_with_devices, _mock_doorsense_enabled_august_lock_detail, @@ -33,6 +38,25 @@ from tests.components.august.mocks import ( ) +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", + ) + config_entry.add_to_hass(hass) + + await setup.async_setup_component(hass, "persistent_notification", {}) + with patch( + "august.authenticator_async.AuthenticatorAsync.async_authenticate", + side_effect=asyncio.TimeoutError, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + + await hass.async_block_till_done() + assert config_entry.state == ENTRY_STATE_SETUP_RETRY + + async def test_unlock_throws_august_api_http_error(hass): """Test unlock throws correct error on http error.""" mocked_lock_detail = await _mock_operative_august_lock_detail(hass) @@ -127,8 +151,7 @@ async def test_set_up_from_yaml(hass): "homeassistant.components.august.config_flow.AugustGateway.async_authenticate", return_value=True, ): - mocked_config = _mock_get_config() - assert await async_setup_component(hass, "august", mocked_config) + assert await async_setup_component(hass, DOMAIN, _mock_get_config()) await hass.async_block_till_done() assert len(mock_setup_august.mock_calls) == 1 call = mock_setup_august.call_args From 1b5c9e922dd469c3efe9cff0e5f2480140f6c82b Mon Sep 17 00:00:00 2001 From: Jc2k Date: Wed, 11 Mar 2020 00:38:06 +0000 Subject: [PATCH 329/416] Bump aiohomekit for more reconnect fixes (#32657) --- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 9207bb53b3e..5ff719dde8c 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit[IP]==0.2.25"], + "requirements": ["aiohomekit[IP]==0.2.29"], "dependencies": [], "zeroconf": ["_hap._tcp.local."], "codeowners": ["@Jc2k"] diff --git a/requirements_all.txt b/requirements_all.txt index 554ba56d084..c2ec47addf3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -163,7 +163,7 @@ aioftp==0.12.0 aioharmony==0.1.13 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.25 +aiohomekit[IP]==0.2.29 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e20a739ef22..e834b37d912 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -62,7 +62,7 @@ aiobotocore==0.11.1 aioesphomeapi==2.6.1 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.25 +aiohomekit[IP]==0.2.29 # homeassistant.components.emulated_hue # homeassistant.components.http From 836b077bcc58f1c91e8ba305612cd3da906149bf Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 11 Mar 2020 01:26:50 -0500 Subject: [PATCH 330/416] Bump python-ecobee-api to 0.2.2 (#32667) --- homeassistant/components/ecobee/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index 8e21b9931cd..9f6b861c8fb 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -4,6 +4,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecobee", "dependencies": [], - "requirements": ["python-ecobee-api==0.2.1"], + "requirements": ["python-ecobee-api==0.2.2"], "codeowners": ["@marthoc"] } diff --git a/requirements_all.txt b/requirements_all.txt index c2ec47addf3..4c08f55405f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1584,7 +1584,7 @@ python-clementine-remote==1.0.1 python-digitalocean==1.13.2 # homeassistant.components.ecobee -python-ecobee-api==0.2.1 +python-ecobee-api==0.2.2 # homeassistant.components.eq3btsmart # python-eq3bt==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e834b37d912..d030d5dc61c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -569,7 +569,7 @@ pysonos==0.0.24 pyspcwebgw==0.4.0 # homeassistant.components.ecobee -python-ecobee-api==0.2.1 +python-ecobee-api==0.2.2 # homeassistant.components.darksky python-forecastio==1.4.0 From 61acf944c0c0698c13dd34b81c8e9fd3eead7d74 Mon Sep 17 00:00:00 2001 From: Paul Madden Date: Wed, 11 Mar 2020 00:27:07 -0600 Subject: [PATCH 331/416] Use bomradarloop v0.1.4 (#32660) --- CODEOWNERS | 1 + homeassistant/components/bom/manifest.json | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 6714c948402..d36843cce44 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -52,6 +52,7 @@ homeassistant/components/bitcoin/* @fabaff homeassistant/components/bizkaibus/* @UgaitzEtxebarria homeassistant/components/blink/* @fronzbot homeassistant/components/bmw_connected_drive/* @gerard33 +homeassistant/components/bom/* @maddenp homeassistant/components/braviatv/* @robbiet480 homeassistant/components/broadlink/* @danielhiversen @felipediel homeassistant/components/brother/* @bieniu diff --git a/homeassistant/components/bom/manifest.json b/homeassistant/components/bom/manifest.json index 211d1dafc7b..13537c53d0c 100644 --- a/homeassistant/components/bom/manifest.json +++ b/homeassistant/components/bom/manifest.json @@ -2,7 +2,7 @@ "domain": "bom", "name": "Australian Bureau of Meteorology (BOM)", "documentation": "https://www.home-assistant.io/integrations/bom", - "requirements": ["bomradarloop==0.1.3"], + "requirements": ["bomradarloop==0.1.4"], "dependencies": [], - "codeowners": [] + "codeowners": ["@maddenp"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c08f55405f..8cf07a45a1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -337,7 +337,7 @@ blockchain==1.4.4 # bme680==1.0.5 # homeassistant.components.bom -bomradarloop==0.1.3 +bomradarloop==0.1.4 # homeassistant.components.amazon_polly # homeassistant.components.route53 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d030d5dc61c..d6fae27ad5a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -121,7 +121,7 @@ axis==25 bellows-homeassistant==0.14.0 # homeassistant.components.bom -bomradarloop==0.1.3 +bomradarloop==0.1.4 # homeassistant.components.broadlink broadlink==0.12.0 From 69b19d54e861a6cf35e60cf6b772c80b2b2c3868 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Wed, 11 Mar 2020 01:43:44 -0500 Subject: [PATCH 332/416] Add codeowner for directv. (#32661) --- CODEOWNERS | 1 + homeassistant/components/directv/manifest.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index d36843cce44..e1868bd17a2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -83,6 +83,7 @@ homeassistant/components/demo/* @home-assistant/core homeassistant/components/derivative/* @afaucogney homeassistant/components/device_automation/* @home-assistant/core homeassistant/components/digital_ocean/* @fabaff +homeassistant/components/directv/* @ctalkington homeassistant/components/discogs/* @thibmaek homeassistant/components/doorbird/* @oblogic7 homeassistant/components/dsmr_reader/* @depl0y diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json index b0f0f8bb5eb..cfe74153f5c 100644 --- a/homeassistant/components/directv/manifest.json +++ b/homeassistant/components/directv/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/integrations/directv", "requirements": ["directpy==0.6"], "dependencies": [], - "codeowners": [] + "codeowners": ["@ctalkington"] } From 16336bf9021ac1687180c586319c9e908eee6ead Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 11 Mar 2020 08:42:22 +0000 Subject: [PATCH 333/416] Add entity_service calibrate_meter to utility_meter (#32658) * add calibrate service --- .../components/utility_meter/const.py | 2 ++ .../components/utility_meter/sensor.py | 19 +++++++++++++++++++ .../components/utility_meter/services.yaml | 10 ++++++++++ tests/components/utility_meter/test_sensor.py | 13 +++++++++++++ 4 files changed, 44 insertions(+) diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py index 23d39204f9c..5be7dcf9b69 100644 --- a/homeassistant/components/utility_meter/const.py +++ b/homeassistant/components/utility_meter/const.py @@ -23,6 +23,7 @@ CONF_TARIFF = "tariff" CONF_TARIFF_ENTITY = "tariff_entity" ATTR_TARIFF = "tariff" +ATTR_VALUE = "value" SIGNAL_START_PAUSE_METER = "utility_meter_start_pause" SIGNAL_RESET_METER = "utility_meter_reset" @@ -30,3 +31,4 @@ SIGNAL_RESET_METER = "utility_meter_reset" SERVICE_RESET = "reset" SERVICE_SELECT_TARIFF = "select_tariff" SERVICE_SELECT_NEXT_TARIFF = "next_tariff" +SERVICE_CALIBRATE_METER = "calibrate" diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 8c47e716b80..ad82cd9e79f 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -3,6 +3,8 @@ from datetime import date, timedelta from decimal import Decimal, DecimalException import logging +import voluptuous as vol + from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, @@ -11,6 +13,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import callback +from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import ( async_track_state_change, @@ -20,6 +23,7 @@ from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.util.dt as dt_util from .const import ( + ATTR_VALUE, CONF_METER, CONF_METER_NET_CONSUMPTION, CONF_METER_OFFSET, @@ -32,6 +36,7 @@ from .const import ( HOURLY, MONTHLY, QUARTERLY, + SERVICE_CALIBRATE_METER, SIGNAL_RESET_METER, WEEKLY, YEARLY, @@ -86,6 +91,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities(meters) + platform = entity_platform.current_platform.get() + + platform.async_register_entity_service( + SERVICE_CALIBRATE_METER, + {vol.Required(ATTR_VALUE): vol.Coerce(float)}, + "async_calibrate", + ) + class UtilityMeterSensor(RestoreEntity): """Representation of an utility meter sensor.""" @@ -206,6 +219,12 @@ class UtilityMeterSensor(RestoreEntity): self._state = 0 await self.async_update_ha_state() + async def async_calibrate(self, value): + """Calibrate the Utility Meter with a given value.""" + _LOGGER.debug("Calibrate %s = %s", self._name, value) + self._state = Decimal(value) + self.async_write_ha_state() + async def async_added_to_hass(self): """Handle entity which will be added.""" await super().async_added_to_hass() diff --git a/homeassistant/components/utility_meter/services.yaml b/homeassistant/components/utility_meter/services.yaml index 5437f4b83a6..42522f81876 100644 --- a/homeassistant/components/utility_meter/services.yaml +++ b/homeassistant/components/utility_meter/services.yaml @@ -23,3 +23,13 @@ select_tariff: tariff: description: Name of the tariff to switch to example: 'offpeak' + +calibrate: + description: calibrates an utility meter. + fields: + entity_id: + description: Name of the entity to calibrate + example: 'utility_meter.energy' + value: + description: Value to which set the meter + example: '100' \ No newline at end of file diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index fcfe97804e4..19742d74f21 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -7,7 +7,9 @@ from unittest.mock import patch from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.utility_meter.const import ( ATTR_TARIFF, + ATTR_VALUE, DOMAIN, + SERVICE_CALIBRATE_METER, SERVICE_SELECT_TARIFF, ) from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START @@ -96,6 +98,17 @@ async def test_state(hass): assert state is not None assert state.state == "3" + await hass.services.async_call( + DOMAIN, + SERVICE_CALIBRATE_METER, + {ATTR_ENTITY_ID: "sensor.energy_bill_midpeak", ATTR_VALUE: "100"}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.energy_bill_midpeak") + assert state is not None + assert state.state == "100" + async def test_net_consumption(hass): """Test utility sensor state.""" From 99109d162bfe8d9746a6d9b24282ec700c395ced Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 11 Mar 2020 12:10:00 +0100 Subject: [PATCH 334/416] Updated frontend to 20200311.0 (#32675) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 9925d26df98..c2ae9c0bcfe 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200306.0" + "home-assistant-frontend==20200311.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 523926ca22e..887fbc99f4c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.32.2 -home-assistant-frontend==20200306.0 +home-assistant-frontend==20200311.0 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8cf07a45a1b..2a0b30032c4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -696,7 +696,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200306.0 +home-assistant-frontend==20200311.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6fae27ad5a..81fed9172d2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -263,7 +263,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200306.0 +home-assistant-frontend==20200311.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 From 4248893007bf194e7df505524c6c7e1beb9ce78f Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Wed, 11 Mar 2020 07:17:53 -0400 Subject: [PATCH 335/416] Clean up custom polling in ZHA device and light (#32653) * cleanup timer handle when device is removed * separate unavailable times for mains vs battery * better name * remove light refresh handle when removing light * remove unused parallel updates * don't steal HA const for different purpose * don't flood network every hour for lights * update test to test both intervals * add test for light refresh interval * fix tests * update test * put parallel updates back for now... * fix async_schedule_update_ha_state usage * review comment * review comment * update test - review conversation * review comments * await count not call count * flip some state --- homeassistant/components/zha/binary_sensor.py | 2 +- homeassistant/components/zha/core/device.py | 16 +++++-- homeassistant/components/zha/core/gateway.py | 2 +- homeassistant/components/zha/cover.py | 6 +-- .../components/zha/device_tracker.py | 2 +- homeassistant/components/zha/entity.py | 4 +- homeassistant/components/zha/fan.py | 2 +- homeassistant/components/zha/light.py | 27 +++++++---- homeassistant/components/zha/lock.py | 6 +-- homeassistant/components/zha/sensor.py | 4 +- homeassistant/components/zha/switch.py | 6 +-- tests/components/zha/test_device.py | 38 +++++++++++---- tests/components/zha/test_light.py | 48 ++++++++++++++++++- 13 files changed, 122 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 5bcb0878a1a..a40bd62e83c 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -106,7 +106,7 @@ class BinarySensor(ZhaEntity, BinarySensorDevice): def async_set_state(self, attr_id, attr_name, value): """Set the state.""" self._state = bool(value) - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_update(self): """Attempt to retrieve on off state from the binary sensor.""" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 7297440624a..f2544b43882 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -61,7 +61,8 @@ from .const import ( from .helpers import LogMixin _LOGGER = logging.getLogger(__name__) -_KEEP_ALIVE_INTERVAL = 7200 +_CONSIDER_UNAVAILABLE_MAINS = 60 * 60 * 2 # 2 hours +_CONSIDER_UNAVAILABLE_BATTERY = 60 * 60 * 6 # 6 hours _UPDATE_ALIVE_INTERVAL = (60, 90) _CHECKIN_GRACE_PERIODS = 2 @@ -99,8 +100,12 @@ class ZHADevice(LogMixin): self._zigpy_device.__class__.__module__, self._zigpy_device.__class__.__name__, ) + if self.is_mains_powered: + self._consider_unavailable_time = _CONSIDER_UNAVAILABLE_MAINS + else: + self._consider_unavailable_time = _CONSIDER_UNAVAILABLE_BATTERY keep_alive_interval = random.randint(*_UPDATE_ALIVE_INTERVAL) - self._available_check = async_track_time_interval( + self._cancel_available_check = async_track_time_interval( self.hass, self._check_available, timedelta(seconds=keep_alive_interval) ) self._ha_device_id = None @@ -279,7 +284,7 @@ class ZHADevice(LogMixin): return difference = time.time() - self.last_seen - if difference < _KEEP_ALIVE_INTERVAL: + if difference < self._consider_unavailable_time: self.update_available(True) self._checkins_missed_count = 0 return @@ -363,9 +368,10 @@ class ZHADevice(LogMixin): self.debug("completed initialization") @callback - def async_unsub_dispatcher(self): - """Unsubscribe the dispatcher.""" + def async_cleanup_handles(self) -> None: + """Unsubscribe the dispatchers and timers.""" self._unsub() + self._cancel_available_check() @callback def async_update_last_seen(self, last_seen): diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 90d8165c640..e2831a55ad4 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -262,7 +262,7 @@ class ZHAGateway: entity_refs = self._device_registry.pop(device.ieee, None) if zha_device is not None: device_info = zha_device.async_get_info() - zha_device.async_unsub_dispatcher() + zha_device.async_cleanup_handles() async_dispatcher_send( self._hass, "{}_{}".format(SIGNAL_REMOVE, str(zha_device.ieee)) ) diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 46c97bc6b2b..46f7dd0e031 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -99,14 +99,14 @@ class ZhaCover(ZhaEntity, CoverDevice): self._state = STATE_CLOSED elif self._current_position == 100: self._state = STATE_OPEN - self.async_schedule_update_ha_state() + self.async_write_ha_state() @callback def async_update_state(self, state): """Handle state update from channel.""" _LOGGER.debug("state=%s", state) self._state = state - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_open_cover(self, **kwargs): """Open the window cover.""" @@ -134,7 +134,7 @@ class ZhaCover(ZhaEntity, CoverDevice): res = await self._cover_channel.stop() if isinstance(res, list) and res[1] is Status.SUCCESS: self._state = STATE_OPEN if self._current_position > 0 else STATE_CLOSED - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_update(self): """Attempt to retrieve the open/close state of the cover.""" diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index 2643642c47d..5fe1dbc0060 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -90,7 +90,7 @@ class ZHADeviceScannerEntity(ScannerEntity, ZhaEntity): self.debug("battery_percentage_remaining updated: %s", value) self._connected = True self._battery_level = Battery.formatter(value) - self.async_schedule_update_ha_state() + self.async_write_ha_state() @property def battery_level(self): diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 799e1239f61..4dd3fea016d 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -102,13 +102,13 @@ class ZhaEntity(RestoreEntity, LogMixin, entity.Entity): def async_set_available(self, available): """Set entity availability.""" self._available = available - self.async_schedule_update_ha_state() + self.async_write_ha_state() @callback def async_update_state_attribute(self, key, value): """Update a single device state attribute.""" self._device_state_attributes.update({key: value}) - self.async_schedule_update_ha_state() + self.async_write_ha_state() @callback def async_set_state(self, attr_id, attr_name, value): diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 79b3bc62960..234566267f6 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -117,7 +117,7 @@ class ZhaFan(ZhaEntity, FanEntity): def async_set_state(self, attr_id, attr_name, value): """Handle state update from channel.""" self._state = VALUE_TO_SPEED.get(value, self._state) - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_on(self, speed: str = None, **kwargs) -> None: """Turn the entity on.""" diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 18df380780d..3387abf9b87 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -2,6 +2,7 @@ from datetime import timedelta import functools import logging +import random from zigpy.zcl.foundation import Status @@ -44,9 +45,9 @@ UPDATE_COLORLOOP_HUE = 0x8 FLASH_EFFECTS = {light.FLASH_SHORT: EFFECT_BLINK, light.FLASH_LONG: EFFECT_BREATHE} UNSUPPORTED_ATTRIBUTE = 0x86 -SCAN_INTERVAL = timedelta(minutes=60) STRICT_MATCH = functools.partial(ZHA_ENTITIES.strict_match, light.DOMAIN) -PARALLEL_UPDATES = 5 +PARALLEL_UPDATES = 0 +_REFRESH_INTERVAL = (45, 75) async def async_setup_entry(hass, config_entry, async_add_entities): @@ -81,6 +82,7 @@ class Light(ZhaEntity, light.Light): self._level_channel = self.cluster_channels.get(CHANNEL_LEVEL) self._color_channel = self.cluster_channels.get(CHANNEL_COLOR) self._identify_channel = self.zha_device.channels.identify_ch + self._cancel_refresh_handle = None if self._level_channel: self._supported_features |= light.SUPPORT_BRIGHTNESS @@ -130,7 +132,7 @@ class Light(ZhaEntity, light.Light): """ value = max(0, min(254, value)) self._brightness = value - self.async_schedule_update_ha_state() + self.async_write_ha_state() @property def hs_color(self): @@ -163,7 +165,7 @@ class Light(ZhaEntity, light.Light): self._state = bool(value) if value: self._off_brightness = None - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_added_to_hass(self): """Run when about to be added to hass.""" @@ -175,7 +177,15 @@ class Light(ZhaEntity, light.Light): await self.async_accept_signal( self._level_channel, SIGNAL_SET_LEVEL, self.set_level ) - async_track_time_interval(self.hass, self.refresh, SCAN_INTERVAL) + refresh_interval = random.randint(*_REFRESH_INTERVAL) + self._cancel_refresh_handle = async_track_time_interval( + self.hass, self._refresh, timedelta(minutes=refresh_interval) + ) + + async def async_will_remove_from_hass(self) -> None: + """Disconnect entity object when removed.""" + self._cancel_refresh_handle() + await super().async_will_remove_from_hass() @callback def async_restore_last_state(self, last_state): @@ -296,7 +306,7 @@ class Light(ZhaEntity, light.Light): self._off_brightness = None self.debug("turned on: %s", t_log) - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the entity off.""" @@ -318,7 +328,7 @@ class Light(ZhaEntity, light.Light): # store current brightness so that the next turn_on uses it. self._off_brightness = self._brightness - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_update(self): """Attempt to retrieve on off state from the light.""" @@ -384,6 +394,7 @@ class Light(ZhaEntity, light.Light): if color_loop_active == 1: self._effect = light.EFFECT_COLORLOOP - async def refresh(self, time): + async def _refresh(self, time): """Call async_get_state at an interval.""" await self.async_get_state(from_cache=False) + self.async_write_ha_state() diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index a7f360b3424..5c0d54430e0 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -87,7 +87,7 @@ class ZhaDoorLock(ZhaEntity, LockDevice): if not isinstance(result, list) or result[0] is not Status.SUCCESS: self.error("Error with lock_door: %s", result) return - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_unlock(self, **kwargs): """Unlock the lock.""" @@ -95,7 +95,7 @@ class ZhaDoorLock(ZhaEntity, LockDevice): if not isinstance(result, list) or result[0] is not Status.SUCCESS: self.error("Error with unlock_door: %s", result) return - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_update(self): """Attempt to retrieve state from the lock.""" @@ -106,7 +106,7 @@ class ZhaDoorLock(ZhaEntity, LockDevice): def async_set_state(self, attr_id, attr_name, value): """Handle state update from channel.""" self._state = VALUE_TO_STATE.get(value, self._state) - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_get_state(self, from_cache=True): """Attempt to retrieve state from the lock.""" diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 9f18913c45a..b5ea7c54072 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -129,7 +129,7 @@ class Sensor(ZhaEntity): if value is not None: value = self.formatter(value) self._state = value - self.async_schedule_update_ha_state() + self.async_write_ha_state() @callback def async_restore_last_state(self, last_state): @@ -191,7 +191,7 @@ class Battery(Sensor): """Update a single device state attribute.""" if key == "battery_voltage": self._device_state_attributes[key] = round(value / 10, 1) - self.async_schedule_update_ha_state() + self.async_write_ha_state() @STRICT_MATCH(channel_names=CHANNEL_ELECTRICAL_MEASUREMENT) diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 298bcb9db77..156183ce95d 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -60,7 +60,7 @@ class Switch(ZhaEntity, SwitchDevice): if not isinstance(result, list) or result[1] is not Status.SUCCESS: return self._state = True - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the entity off.""" @@ -68,13 +68,13 @@ class Switch(ZhaEntity, SwitchDevice): if not isinstance(result, list) or result[1] is not Status.SUCCESS: return self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() @callback def async_set_state(self, attr_id, attr_name, value): """Handle state update from channel.""" self._state = bool(value) - self.async_schedule_update_ha_state() + self.async_write_ha_state() @property def device_state_attributes(self): diff --git a/tests/components/zha/test_device.py b/tests/components/zha/test_device.py index 0df975b732f..edfab1d11d1 100644 --- a/tests/components/zha/test_device.py +++ b/tests/components/zha/test_device.py @@ -8,11 +8,12 @@ import pytest import zigpy.zcl.clusters.general as general import homeassistant.components.zha.core.device as zha_core_device -import homeassistant.core as ha import homeassistant.util.dt as dt_util from .common import async_enable_traffic +from tests.common import async_fire_time_changed + @pytest.fixture def zigpy_device(zigpy_device_mock): @@ -32,9 +33,28 @@ def zigpy_device(zigpy_device_mock): @pytest.fixture -def device_with_basic_channel(zigpy_device): +def zigpy_device_mains(zigpy_device_mock): + """Device tracker zigpy device.""" + + def _dev(with_basic_channel: bool = True): + in_clusters = [general.OnOff.cluster_id] + if with_basic_channel: + in_clusters.append(general.Basic.cluster_id) + + endpoints = { + 3: {"in_clusters": in_clusters, "out_clusters": [], "device_type": 0} + } + return zigpy_device_mock( + endpoints, node_descriptor=b"\x02@\x84_\x11\x7fd\x00\x00,d\x00\x00" + ) + + return _dev + + +@pytest.fixture +def device_with_basic_channel(zigpy_device_mains): """Return a zha device with a basic channel present.""" - return zigpy_device(with_basic_channel=True) + return zigpy_device_mains(with_basic_channel=True) @pytest.fixture @@ -45,8 +65,8 @@ def device_without_basic_channel(zigpy_device): def _send_time_changed(hass, seconds): """Send a time changed event.""" - now = dt_util.utcnow() + timedelta(seconds) - hass.bus.async_fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now}) + now = dt_util.utcnow() + timedelta(seconds=seconds) + async_fire_time_changed(hass, now) @asynctest.patch( @@ -66,13 +86,13 @@ async def test_check_available_success( basic_ch.read_attributes.reset_mock() device_with_basic_channel.last_seen = None assert zha_device.available is True - _send_time_changed(hass, 61) + _send_time_changed(hass, zha_core_device._CONSIDER_UNAVAILABLE_MAINS + 2) await hass.async_block_till_done() assert zha_device.available is False assert basic_ch.read_attributes.await_count == 0 device_with_basic_channel.last_seen = ( - time.time() - zha_core_device._KEEP_ALIVE_INTERVAL - 2 + time.time() - zha_core_device._CONSIDER_UNAVAILABLE_MAINS - 2 ) _seens = [time.time(), device_with_basic_channel.last_seen] @@ -121,7 +141,7 @@ async def test_check_available_unsuccessful( assert basic_ch.read_attributes.await_count == 0 device_with_basic_channel.last_seen = ( - time.time() - zha_core_device._KEEP_ALIVE_INTERVAL - 2 + time.time() - zha_core_device._CONSIDER_UNAVAILABLE_MAINS - 2 ) # unsuccessfuly ping zigpy device, but zha_device is still available @@ -162,7 +182,7 @@ async def test_check_available_no_basic_channel( assert zha_device.available is True device_without_basic_channel.last_seen = ( - time.time() - zha_core_device._KEEP_ALIVE_INTERVAL - 2 + time.time() - zha_core_device._CONSIDER_UNAVAILABLE_BATTERY - 2 ) assert "does not have a mandatory basic cluster" not in caplog.text diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index fba57a0020e..3a3aff2c653 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -1,5 +1,6 @@ """Test zha light.""" -from unittest.mock import call, sentinel +from datetime import timedelta +from unittest.mock import MagicMock, call, sentinel import asynctest import pytest @@ -12,6 +13,7 @@ import zigpy.zcl.foundation as zcl_f from homeassistant.components.light import DOMAIN, FLASH_LONG, FLASH_SHORT from homeassistant.components.zha.light import FLASH_EFFECTS from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +import homeassistant.util.dt as dt_util from .common import ( async_enable_traffic, @@ -21,6 +23,8 @@ from .common import ( make_zcl_header, ) +from tests.common import async_fire_time_changed + ON = 1 OFF = 0 @@ -63,6 +67,46 @@ LIGHT_COLOR = { } +@asynctest.mock.patch( + "zigpy.zcl.clusters.general.OnOff.read_attributes", new=MagicMock() +) +async def test_light_refresh(hass, zigpy_device_mock, zha_device_joined_restored): + """Test zha light platform refresh.""" + + # create zigpy devices + zigpy_device = zigpy_device_mock(LIGHT_ON_OFF) + zha_device = await zha_device_joined_restored(zigpy_device) + on_off_cluster = zigpy_device.endpoints[1].on_off + entity_id = await find_entity_id(DOMAIN, zha_device, hass) + + # allow traffic to flow through the gateway and device + await async_enable_traffic(hass, [zha_device]) + on_off_cluster.read_attributes.reset_mock() + + # not enough time passed + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=20)) + await hass.async_block_till_done() + assert on_off_cluster.read_attributes.call_count == 0 + assert on_off_cluster.read_attributes.await_count == 0 + assert hass.states.get(entity_id).state == STATE_OFF + + # 1 interval - 1 call + on_off_cluster.read_attributes.return_value = [{"on_off": 1}, {}] + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=80)) + await hass.async_block_till_done() + assert on_off_cluster.read_attributes.call_count == 1 + assert on_off_cluster.read_attributes.await_count == 1 + assert hass.states.get(entity_id).state == STATE_ON + + # 2 intervals - 2 calls + on_off_cluster.read_attributes.return_value = [{"on_off": 0}, {}] + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=80)) + await hass.async_block_till_done() + assert on_off_cluster.read_attributes.call_count == 2 + assert on_off_cluster.read_attributes.await_count == 2 + assert hass.states.get(entity_id).state == STATE_OFF + + @asynctest.patch( "zigpy.zcl.clusters.lighting.Color.request", new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), @@ -84,7 +128,7 @@ LIGHT_COLOR = { [(LIGHT_ON_OFF, (1, 0, 0)), (LIGHT_LEVEL, (1, 1, 0)), (LIGHT_COLOR, (1, 1, 3))], ) async def test_light( - hass, zigpy_device_mock, zha_device_joined_restored, device, reporting, + hass, zigpy_device_mock, zha_device_joined_restored, device, reporting ): """Test zha light platform.""" From 647d137daaa2677ddec9c400a53f5d7bc3a53b0c Mon Sep 17 00:00:00 2001 From: Jc2k Date: Wed, 11 Mar 2020 11:40:47 +0000 Subject: [PATCH 336/416] Refactor homekit_controller entity update to work more like update coordinator (#32670) * Clean up use of get_characteristic_types * Get rid of get_hk_char_value helper * Get rid of _update_fn callbacks * Call async_write_has_state directly as async_state_changed doesnt do anything any more --- .../components/homekit_controller/__init__.py | 79 ++++----------- .../homekit_controller/air_quality.py | 14 +-- .../homekit_controller/alarm_control_panel.py | 25 ++--- .../homekit_controller/binary_sensor.py | 44 ++------- .../components/homekit_controller/climate.py | 53 +++------- .../components/homekit_controller/cover.py | 96 +++++++++---------- .../components/homekit_controller/fan.py | 49 ++++------ .../components/homekit_controller/light.py | 35 ++----- .../components/homekit_controller/lock.py | 24 ++--- .../homekit_controller/media_player.py | 8 +- .../components/homekit_controller/sensor.py | 70 ++++---------- .../components/homekit_controller/switch.py | 20 +--- 12 files changed, 159 insertions(+), 358 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index f697449bbdb..95224dfccbd 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -4,10 +4,9 @@ import os import aiohomekit from aiohomekit.model import Accessory -from aiohomekit.model.characteristics import CharacteristicsTypes +from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes from aiohomekit.model.services import Service, ServicesTypes -from homeassistant.core import callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity @@ -60,7 +59,7 @@ class HomeKitEntity(Entity): """Entity added to hass.""" self._signals.append( self.hass.helpers.dispatcher.async_dispatcher_connect( - self._accessory.signal_state_updated, self.async_state_changed + self._accessory.signal_state_updated, self.async_write_ha_state ) ) @@ -86,93 +85,53 @@ class HomeKitEntity(Entity): def setup(self): """Configure an entity baed on its HomeKit characteristics metadata.""" - get_uuid = CharacteristicsTypes.get_uuid - characteristic_types = [get_uuid(c) for c in self.get_characteristic_types()] - self.pollable_characteristics = [] self.watchable_characteristics = [] self._chars = {} self._char_names = {} + char_types = self.get_characteristic_types() + # Setup events and/or polling for characteristics directly attached to this entity - for char in self.service.characteristics: - if char.type not in characteristic_types: - continue - self._setup_characteristic(char.to_accessory_and_service_list()) + for char in self.service.characteristics.filter(char_types=char_types): + self._setup_characteristic(char) # Setup events and/or polling for characteristics attached to sub-services of this # entity (like an INPUT_SOURCE). for service in self.accessory.services.filter(parent_service=self.service): - for char in service.characteristics: - if char.type not in characteristic_types: - continue - self._setup_characteristic(char.to_accessory_and_service_list()) + for char in service.characteristics.filter(char_types=char_types): + self._setup_characteristic(char) - def _setup_characteristic(self, char): + def _setup_characteristic(self, char: Characteristic): """Configure an entity based on a HomeKit characteristics metadata.""" # Build up a list of (aid, iid) tuples to poll on update() - if "pr" in char["perms"]: - self.pollable_characteristics.append((self._aid, char["iid"])) + if "pr" in char.perms: + self.pollable_characteristics.append((self._aid, char.iid)) # Build up a list of (aid, iid) tuples to subscribe to - if "ev" in char["perms"]: - self.watchable_characteristics.append((self._aid, char["iid"])) + if "ev" in char.perms: + self.watchable_characteristics.append((self._aid, char.iid)) # Build a map of ctype -> iid - short_name = CharacteristicsTypes.get_short(char["type"]) - self._chars[short_name] = char["iid"] - self._char_names[char["iid"]] = short_name + self._chars[char.type_name] = char.iid + self._char_names[char.iid] = char.type_name # Callback to allow entity to configure itself based on this # characteristics metadata (valid values, value ranges, features, etc) - setup_fn_name = escape_characteristic_name(short_name) + setup_fn_name = escape_characteristic_name(char.type_name) setup_fn = getattr(self, f"_setup_{setup_fn_name}", None) if not setup_fn: return - setup_fn(char) - - def get_hk_char_value(self, characteristic_type): - """Return the value for a given characteristic type enum.""" - state = self._accessory.current_state.get(self._aid) - if not state: - return None - char = self._chars.get(CharacteristicsTypes.get_short(characteristic_type)) - if not char: - return None - return state.get(char, {}).get("value") - - @callback - def async_state_changed(self): - """Collect new data from bridge and update the entity state in hass.""" - accessory_state = self._accessory.current_state.get(self._aid, {}) - for iid, result in accessory_state.items(): - # No value so don't process this result - if "value" not in result: - continue - - # Unknown iid - this is probably for a sibling service that is part - # of the same physical accessory. Ignore it. - if iid not in self._char_names: - continue - - # Callback to update the entity with this characteristic value - char_name = escape_characteristic_name(self._char_names[iid]) - update_fn = getattr(self, f"_update_{char_name}", None) - if not update_fn: - continue - - update_fn(result["value"]) - - self.async_write_ha_state() + setup_fn(char.to_accessory_and_service_list()) @property - def unique_id(self): + def unique_id(self) -> str: """Return the ID of this device.""" serial = self.accessory_info.value(CharacteristicsTypes.SERIAL_NUMBER) return f"homekit-{serial}-{self._iid}" @property - def name(self): + def name(self) -> str: """Return the name of the device if any.""" return self.accessory_info.value(CharacteristicsTypes.NAME) diff --git a/homeassistant/components/homekit_controller/air_quality.py b/homeassistant/components/homekit_controller/air_quality.py index b2145887cef..999980ad60c 100644 --- a/homeassistant/components/homekit_controller/air_quality.py +++ b/homeassistant/components/homekit_controller/air_quality.py @@ -34,38 +34,38 @@ class HomeAirQualitySensor(HomeKitEntity, AirQualityEntity): @property def particulate_matter_2_5(self): """Return the particulate matter 2.5 level.""" - return self.get_hk_char_value(CharacteristicsTypes.DENSITY_PM25) + return self.service.value(CharacteristicsTypes.DENSITY_PM25) @property def particulate_matter_10(self): """Return the particulate matter 10 level.""" - return self.get_hk_char_value(CharacteristicsTypes.DENSITY_PM10) + return self.service.value(CharacteristicsTypes.DENSITY_PM10) @property def ozone(self): """Return the O3 (ozone) level.""" - return self.get_hk_char_value(CharacteristicsTypes.DENSITY_OZONE) + return self.service.value(CharacteristicsTypes.DENSITY_OZONE) @property def sulphur_dioxide(self): """Return the SO2 (sulphur dioxide) level.""" - return self.get_hk_char_value(CharacteristicsTypes.DENSITY_SO2) + return self.service.value(CharacteristicsTypes.DENSITY_SO2) @property def nitrogen_dioxide(self): """Return the NO2 (nitrogen dioxide) level.""" - return self.get_hk_char_value(CharacteristicsTypes.DENSITY_NO2) + return self.service.value(CharacteristicsTypes.DENSITY_NO2) @property def air_quality_text(self): """Return the Air Quality Index (AQI).""" - air_quality = self.get_hk_char_value(CharacteristicsTypes.AIR_QUALITY) + air_quality = self.service.value(CharacteristicsTypes.AIR_QUALITY) return AIR_QUALITY_TEXT.get(air_quality, "unknown") @property def volatile_organic_compounds(self): """Return the volatile organic compounds (VOC) level.""" - return self.get_hk_char_value(CharacteristicsTypes.DENSITY_VOC) + return self.service.value(CharacteristicsTypes.DENSITY_VOC) @property def device_state_attributes(self): diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index d0ddd8ae816..0a96d716c78 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -60,12 +60,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel): """Representation of a Homekit Alarm Control Panel.""" - def __init__(self, *args): - """Initialise the Alarm Control Panel.""" - super().__init__(*args) - self._state = None - self._battery_level = None - def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" return [ @@ -74,12 +68,6 @@ class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel): CharacteristicsTypes.BATTERY_LEVEL, ] - def _update_security_system_state_current(self, value): - self._state = CURRENT_STATE_MAP[value] - - def _update_battery_level(self, value): - self._battery_level = value - @property def icon(self): """Return icon.""" @@ -88,7 +76,9 @@ class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel): @property def state(self): """Return the state of the device.""" - return self._state + return CURRENT_STATE_MAP[ + self.service.value(CharacteristicsTypes.SECURITY_SYSTEM_STATE_CURRENT) + ] @property def supported_features(self) -> int: @@ -125,7 +115,10 @@ class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel): @property def device_state_attributes(self): """Return the optional state attributes.""" - if self._battery_level is None: - return None + attributes = {} - return {ATTR_BATTERY_LEVEL: self._battery_level} + battery_level = self.service.value(CharacteristicsTypes.BATTERY_LEVEL) + if battery_level: + attributes[ATTR_BATTERY_LEVEL] = battery_level + + return attributes diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py index 7ca7f7a5711..39d0e19ba40 100644 --- a/homeassistant/components/homekit_controller/binary_sensor.py +++ b/homeassistant/components/homekit_controller/binary_sensor.py @@ -20,18 +20,10 @@ _LOGGER = logging.getLogger(__name__) class HomeKitMotionSensor(HomeKitEntity, BinarySensorDevice): """Representation of a Homekit motion sensor.""" - def __init__(self, *args): - """Initialise the entity.""" - super().__init__(*args) - self._on = False - def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.MOTION_DETECTED] - def _update_motion_detected(self, value): - self._on = value - @property def device_class(self): """Define this binary_sensor as a motion sensor.""" @@ -40,24 +32,16 @@ class HomeKitMotionSensor(HomeKitEntity, BinarySensorDevice): @property def is_on(self): """Has motion been detected.""" - return self._on + return self.service.value(CharacteristicsTypes.MOTION_DETECTED) class HomeKitContactSensor(HomeKitEntity, BinarySensorDevice): """Representation of a Homekit contact sensor.""" - def __init__(self, *args): - """Initialise the entity.""" - super().__init__(*args) - self._state = None - def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.CONTACT_STATE] - def _update_contact_state(self, value): - self._state = value - @property def device_class(self): """Define this binary_sensor as a opening sensor.""" @@ -66,17 +50,12 @@ class HomeKitContactSensor(HomeKitEntity, BinarySensorDevice): @property def is_on(self): """Return true if the binary sensor is on/open.""" - return self._state == 1 + return self.service.value(CharacteristicsTypes.CONTACT_STATE) == 1 class HomeKitSmokeSensor(HomeKitEntity, BinarySensorDevice): """Representation of a Homekit smoke sensor.""" - def __init__(self, *args): - """Initialise the entity.""" - super().__init__(*args) - self._state = None - @property def device_class(self) -> str: """Return the class of this sensor.""" @@ -86,22 +65,14 @@ class HomeKitSmokeSensor(HomeKitEntity, BinarySensorDevice): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.SMOKE_DETECTED] - def _update_smoke_detected(self, value): - self._state = value - @property def is_on(self): """Return true if smoke is currently detected.""" - return self._state == 1 + return self.service.value(CharacteristicsTypes.SMOKE_DETECTED) == 1 class HomeKitOccupancySensor(HomeKitEntity, BinarySensorDevice): - """Representation of a Homekit smoke sensor.""" - - def __init__(self, *args): - """Initialise the entity.""" - super().__init__(*args) - self._state = None + """Representation of a Homekit occupancy sensor.""" @property def device_class(self) -> str: @@ -112,13 +83,10 @@ class HomeKitOccupancySensor(HomeKitEntity, BinarySensorDevice): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.OCCUPANCY_DETECTED] - def _update_occupancy_detected(self, value): - self._state = value - @property def is_on(self): - """Return true if smoke is currently detected.""" - return self._state == 1 + """Return true if occupancy is currently detected.""" + return self.service.value(CharacteristicsTypes.OCCUPANCY_DETECTED) == 1 ENTITY_TYPES = { diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index b294bb9bb71..e748ea430e5 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -67,14 +67,7 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): def __init__(self, *args): """Initialise the device.""" - self._state = None - self._target_mode = None - self._current_mode = None self._valid_modes = [] - self._current_temp = None - self._target_temp = None - self._current_humidity = None - self._target_humidity = None self._min_target_temp = None self._max_target_temp = None self._min_target_humidity = DEFAULT_MIN_HUMIDITY @@ -130,31 +123,6 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): if "maxValue" in characteristic: self._max_target_humidity = characteristic["maxValue"] - def _update_heating_cooling_current(self, value): - # This characteristic describes the current mode of a device, - # e.g. a thermostat is "heating" a room to 75 degrees Fahrenheit. - # Can be 0 - 2 (Off, Heat, Cool) - self._current_mode = CURRENT_MODE_HOMEKIT_TO_HASS.get(value) - - def _update_heating_cooling_target(self, value): - # This characteristic describes the target mode - # E.g. should the device start heating a room if the temperature - # falls below the target temperature. - # Can be 0 - 3 (Off, Heat, Cool, Auto) - self._target_mode = MODE_HOMEKIT_TO_HASS.get(value) - - def _update_temperature_current(self, value): - self._current_temp = value - - def _update_temperature_target(self, value): - self._target_temp = value - - def _update_relative_humidity_current(self, value): - self._current_humidity = value - - def _update_relative_humidity_target(self, value): - self._target_humidity = value - async def async_set_temperature(self, **kwargs): """Set new target temperature.""" temp = kwargs.get(ATTR_TEMPERATURE) @@ -189,12 +157,12 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): @property def current_temperature(self): """Return the current temperature.""" - return self._current_temp + return self.service.value(CharacteristicsTypes.TEMPERATURE_CURRENT) @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._target_temp + return self.service.value(CharacteristicsTypes.TEMPERATURE_TARGET) @property def min_temp(self): @@ -213,12 +181,12 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): @property def current_humidity(self): """Return the current humidity.""" - return self._current_humidity + return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) @property def target_humidity(self): """Return the humidity we try to reach.""" - return self._target_humidity + return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET) @property def min_humidity(self): @@ -233,12 +201,21 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): @property def hvac_action(self): """Return the current running hvac operation.""" - return self._current_mode + # This characteristic describes the current mode of a device, + # e.g. a thermostat is "heating" a room to 75 degrees Fahrenheit. + # Can be 0 - 2 (Off, Heat, Cool) + value = self.service.value(CharacteristicsTypes.HEATING_COOLING_CURRENT) + return CURRENT_MODE_HOMEKIT_TO_HASS.get(value) @property def hvac_mode(self): """Return hvac operation ie. heat, cool mode.""" - return self._target_mode + # This characteristic describes the target mode + # E.g. should the device start heating a room if the temperature + # falls below the target temperature. + # Can be 0 - 3 (Off, Heat, Cool, Auto) + value = self.service.value(CharacteristicsTypes.HEATING_COOLING_TARGET) + return MODE_HOMEKIT_TO_HASS.get(value) @property def hvac_modes(self): diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 2799d1d76a6..79682970496 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -61,13 +61,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice): """Representation of a HomeKit Garage Door.""" - def __init__(self, accessory, discovery_info): - """Initialise the Cover.""" - super().__init__(accessory, discovery_info) - self._state = None - self._obstruction_detected = None - self.lock_state = None - @property def device_class(self): """Define this cover as a garage door.""" @@ -81,31 +74,31 @@ class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice): CharacteristicsTypes.OBSTRUCTION_DETECTED, ] - def _update_door_state_current(self, value): - self._state = CURRENT_GARAGE_STATE_MAP[value] - - def _update_obstruction_detected(self, value): - self._obstruction_detected = value - @property def supported_features(self): """Flag supported features.""" return SUPPORT_OPEN | SUPPORT_CLOSE + @property + def state(self): + """Return the current state of the garage door.""" + value = self.service.value(CharacteristicsTypes.DOOR_STATE_CURRENT) + return CURRENT_GARAGE_STATE_MAP[value] + @property def is_closed(self): """Return true if cover is closed, else False.""" - return self._state == STATE_CLOSED + return self.state == STATE_CLOSED @property def is_closing(self): """Return if the cover is closing or not.""" - return self._state == STATE_CLOSING + return self.state == STATE_CLOSING @property def is_opening(self): """Return if the cover is opening or not.""" - return self._state == STATE_OPENING + return self.state == STATE_OPENING async def async_open_cover(self, **kwargs): """Send open command.""" @@ -129,10 +122,15 @@ class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice): @property def device_state_attributes(self): """Return the optional state attributes.""" - if self._obstruction_detected is None: - return None + attributes = {} - return {"obstruction-detected": self._obstruction_detected} + obstruction_detected = self.service.value( + CharacteristicsTypes.OBSTRUCTION_DETECTED + ) + if obstruction_detected: + attributes["obstruction-detected"] = obstruction_detected + + return attributes class HomeKitWindowCover(HomeKitEntity, CoverDevice): @@ -141,11 +139,7 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice): def __init__(self, accessory, discovery_info): """Initialise the Cover.""" super().__init__(accessory, discovery_info) - self._state = None - self._position = None - self._tilt_position = None - self._obstruction_detected = None - self.lock_state = None + self._features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION def get_characteristic_types(self): @@ -175,21 +169,6 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice): SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_SET_TILT_POSITION ) - def _update_position_state(self, value): - self._state = CURRENT_WINDOW_STATE_MAP[value] - - def _update_position_current(self, value): - self._position = value - - def _update_vertical_tilt_current(self, value): - self._tilt_position = value - - def _update_horizontal_tilt_current(self, value): - self._tilt_position = value - - def _update_obstruction_detected(self, value): - self._obstruction_detected = value - @property def supported_features(self): """Flag supported features.""" @@ -198,22 +177,36 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice): @property def current_cover_position(self): """Return the current position of cover.""" - return self._position + return self.service.value(CharacteristicsTypes.POSITION_CURRENT) @property def is_closed(self): """Return true if cover is closed, else False.""" - return self._position == 0 + return self.current_cover_position == 0 @property def is_closing(self): """Return if the cover is closing or not.""" - return self._state == STATE_CLOSING + value = self.service.value(CharacteristicsTypes.POSITION_STATE) + state = CURRENT_WINDOW_STATE_MAP[value] + return state == STATE_CLOSING @property def is_opening(self): """Return if the cover is opening or not.""" - return self._state == STATE_OPENING + value = self.service.value(CharacteristicsTypes.POSITION_STATE) + state = CURRENT_WINDOW_STATE_MAP[value] + return state == STATE_OPENING + + @property + def current_cover_tilt_position(self): + """Return current position of cover tilt.""" + tilt_position = self.service.value(CharacteristicsTypes.VERTICAL_TILT_CURRENT) + if not tilt_position: + tilt_position = self.service.value( + CharacteristicsTypes.HORIZONTAL_TILT_CURRENT + ) + return tilt_position async def async_stop_cover(self, **kwargs): """Send hold command.""" @@ -238,11 +231,6 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice): ] await self._accessory.put_characteristics(characteristics) - @property - def current_cover_tilt_position(self): - """Return current position of cover tilt.""" - return self._tilt_position - async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" tilt_position = kwargs[ATTR_TILT_POSITION] @@ -268,8 +256,12 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice): @property def device_state_attributes(self): """Return the optional state attributes.""" - state_attributes = {} - if self._obstruction_detected is not None: - state_attributes["obstruction-detected"] = self._obstruction_detected + attributes = {} - return state_attributes + obstruction_detected = self.service.value( + CharacteristicsTypes.OBSTRUCTION_DETECTED + ) + if obstruction_detected: + attributes["obstruction-detected"] = obstruction_detected + + return attributes diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 24bb5b96503..25cfee73fb8 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -46,12 +46,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): def __init__(self, *args): """Initialise the fan.""" - self._on = None self._features = 0 - self._rotation_direction = 0 - self._rotation_speed = 0 - self._swing_mode = 0 - super().__init__(*args) def get_characteristic_types(self): @@ -71,31 +66,23 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): def _setup_swing_mode(self, char): self._features |= SUPPORT_OSCILLATE - def _update_rotation_direction(self, value): - self._rotation_direction = value - - def _update_rotation_speed(self, value): - self._rotation_speed = value - - def _update_swing_mode(self, value): - self._swing_mode = value - - @property - def is_on(self): - """Return true if device is on.""" - return self._on - @property def speed(self): """Return the current speed.""" if not self.is_on: return SPEED_OFF - if self._rotation_speed > SPEED_TO_PCNT[SPEED_MEDIUM]: + + rotation_speed = self.service.value(CharacteristicsTypes.ROTATION_SPEED) + + if rotation_speed > SPEED_TO_PCNT[SPEED_MEDIUM]: return SPEED_HIGH - if self._rotation_speed > SPEED_TO_PCNT[SPEED_LOW]: + + if rotation_speed > SPEED_TO_PCNT[SPEED_LOW]: return SPEED_MEDIUM - if self._rotation_speed > SPEED_TO_PCNT[SPEED_OFF]: + + if rotation_speed > SPEED_TO_PCNT[SPEED_OFF]: return SPEED_LOW + return SPEED_OFF @property @@ -108,12 +95,14 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): @property def current_direction(self): """Return the current direction of the fan.""" - return HK_DIRECTION_TO_HA[self._rotation_direction] + direction = self.service.value(CharacteristicsTypes.ROTATION_DIRECTION) + return HK_DIRECTION_TO_HA[direction] @property def oscillating(self): """Return whether or not the fan is currently oscillating.""" - return self._swing_mode == 1 + oscillating = self.service.value(CharacteristicsTypes.SWING_MODE) + return oscillating == 1 @property def supported_features(self): @@ -208,8 +197,10 @@ class HomeKitFanV1(BaseHomeKitFan): """Define the homekit characteristics the entity cares about.""" return [CharacteristicsTypes.ON] + super().get_characteristic_types() - def _update_on(self, value): - self._on = value == 1 + @property + def is_on(self): + """Return true if device is on.""" + return self.service.value(CharacteristicsTypes.ON) == 1 class HomeKitFanV2(BaseHomeKitFan): @@ -221,8 +212,10 @@ class HomeKitFanV2(BaseHomeKitFan): """Define the homekit characteristics the entity cares about.""" return [CharacteristicsTypes.ACTIVE] + super().get_characteristic_types() - def _update_active(self, value): - self._on = value == 1 + @property + def is_on(self): + """Return true if device is on.""" + return self.service.value(CharacteristicsTypes.ACTIVE) == 1 ENTITY_TYPES = { diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index 5978455cf6f..962cfcc466c 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -38,15 +38,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class HomeKitLight(HomeKitEntity, Light): """Representation of a Homekit light.""" - def __init__(self, *args): - """Initialise the light.""" - super().__init__(*args) - self._on = False - self._brightness = 0 - self._color_temperature = 0 - self._hue = 0 - self._saturation = 0 - def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" return [ @@ -69,40 +60,28 @@ class HomeKitLight(HomeKitEntity, Light): def _setup_saturation(self, char): self._features |= SUPPORT_COLOR - def _update_on(self, value): - self._on = value - - def _update_brightness(self, value): - self._brightness = value - - def _update_color_temperature(self, value): - self._color_temperature = value - - def _update_hue(self, value): - self._hue = value - - def _update_saturation(self, value): - self._saturation = value - @property def is_on(self): """Return true if device is on.""" - return self._on + return self.service.value(CharacteristicsTypes.ON) @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self._brightness * 255 / 100 + return self.service.value(CharacteristicsTypes.BRIGHTNESS) * 255 / 100 @property def hs_color(self): """Return the color property.""" - return (self._hue, self._saturation) + return ( + self.service.value(CharacteristicsTypes.HUE), + self.service.value(CharacteristicsTypes.SATURATION), + ) @property def color_temp(self): """Return the color temperature.""" - return self._color_temperature + return self.service.value(CharacteristicsTypes.COLOR_TEMPERATURE) @property def supported_features(self): diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index fc046c704b9..b79c10e2ae0 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -37,12 +37,6 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class HomeKitLock(HomeKitEntity, LockDevice): """Representation of a HomeKit Controller Lock.""" - def __init__(self, accessory, discovery_info): - """Initialise the Lock.""" - super().__init__(accessory, discovery_info) - self._state = None - self._battery_level = None - def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" return [ @@ -51,16 +45,11 @@ class HomeKitLock(HomeKitEntity, LockDevice): CharacteristicsTypes.BATTERY_LEVEL, ] - def _update_lock_mechanism_current_state(self, value): - self._state = CURRENT_STATE_MAP[value] - - def _update_battery_level(self, value): - self._battery_level = value - @property def is_locked(self): """Return true if device is locked.""" - return self._state == STATE_LOCKED + value = self.service.value(CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE) + return CURRENT_STATE_MAP[value] == STATE_LOCKED async def async_lock(self, **kwargs): """Lock the device.""" @@ -84,7 +73,10 @@ class HomeKitLock(HomeKitEntity, LockDevice): @property def device_state_attributes(self): """Return the optional state attributes.""" - if self._battery_level is None: - return None + attributes = {} - return {ATTR_BATTERY_LEVEL: self._battery_level} + battery_level = self.service.value(CharacteristicsTypes.BATTERY_LEVEL) + if battery_level: + attributes[ATTR_BATTERY_LEVEL] = battery_level + + return attributes diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py index 09693f3e8a8..798931e2cb4 100644 --- a/homeassistant/components/homekit_controller/media_player.py +++ b/homeassistant/components/homekit_controller/media_player.py @@ -130,9 +130,7 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice): @property def source(self): """Name of the current input source.""" - active_identifier = self.get_hk_char_value( - CharacteristicsTypes.ACTIVE_IDENTIFIER - ) + active_identifier = self.service.value(CharacteristicsTypes.ACTIVE_IDENTIFIER) if not active_identifier: return None @@ -150,11 +148,11 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice): @property def state(self): """State of the tv.""" - active = self.get_hk_char_value(CharacteristicsTypes.ACTIVE) + active = self.service.value(CharacteristicsTypes.ACTIVE) if not active: return STATE_PROBLEM - homekit_state = self.get_hk_char_value(CharacteristicsTypes.CURRENT_MEDIA_STATE) + homekit_state = self.service.value(CharacteristicsTypes.CURRENT_MEDIA_STATE) if homekit_state is not None: return HK_TO_HA_STATE[homekit_state] diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index 636c10dbe79..87f47e72023 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -25,11 +25,6 @@ UNIT_LUX = "lux" class HomeKitHumiditySensor(HomeKitEntity): """Representation of a Homekit humidity sensor.""" - def __init__(self, *args): - """Initialise the entity.""" - super().__init__(*args) - self._state = None - def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT] @@ -54,23 +49,15 @@ class HomeKitHumiditySensor(HomeKitEntity): """Return units for the sensor.""" return UNIT_PERCENTAGE - def _update_relative_humidity_current(self, value): - self._state = value - @property def state(self): """Return the current humidity.""" - return self._state + return self.service.value(CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT) class HomeKitTemperatureSensor(HomeKitEntity): """Representation of a Homekit temperature sensor.""" - def __init__(self, *args): - """Initialise the entity.""" - super().__init__(*args) - self._state = None - def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.TEMPERATURE_CURRENT] @@ -95,23 +82,15 @@ class HomeKitTemperatureSensor(HomeKitEntity): """Return units for the sensor.""" return TEMP_CELSIUS - def _update_temperature_current(self, value): - self._state = value - @property def state(self): """Return the current temperature in Celsius.""" - return self._state + return self.service.value(CharacteristicsTypes.TEMPERATURE_CURRENT) class HomeKitLightSensor(HomeKitEntity): """Representation of a Homekit light level sensor.""" - def __init__(self, *args): - """Initialise the entity.""" - super().__init__(*args) - self._state = None - def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.LIGHT_LEVEL_CURRENT] @@ -136,23 +115,15 @@ class HomeKitLightSensor(HomeKitEntity): """Return units for the sensor.""" return UNIT_LUX - def _update_light_level_current(self, value): - self._state = value - @property def state(self): """Return the current light level in lux.""" - return self._state + return self.service.value(CharacteristicsTypes.LIGHT_LEVEL_CURRENT) class HomeKitCarbonDioxideSensor(HomeKitEntity): """Representation of a Homekit Carbon Dioxide sensor.""" - def __init__(self, *args): - """Initialise the entity.""" - super().__init__(*args) - self._state = None - def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [CharacteristicsTypes.CARBON_DIOXIDE_LEVEL] @@ -172,25 +143,15 @@ class HomeKitCarbonDioxideSensor(HomeKitEntity): """Return units for the sensor.""" return CONCENTRATION_PARTS_PER_MILLION - def _update_carbon_dioxide_level(self, value): - self._state = value - @property def state(self): """Return the current CO2 level in ppm.""" - return self._state + return self.service.value(CharacteristicsTypes.CARBON_DIOXIDE_LEVEL) class HomeKitBatterySensor(HomeKitEntity): """Representation of a Homekit battery sensor.""" - def __init__(self, *args): - """Initialise the entity.""" - super().__init__(*args) - self._state = None - self._low_battery = False - self._charging = False - def get_characteristic_types(self): """Define the homekit characteristics the entity is tracking.""" return [ @@ -218,12 +179,12 @@ class HomeKitBatterySensor(HomeKitEntity): # This is similar to the logic in helpers.icon, but we have delegated the # decision about what mdi:battery-alert is to the device. icon = "mdi:battery" - if self._charging and self.state > 10: + if self.is_charging and self.state > 10: percentage = int(round(self.state / 20 - 0.01)) * 20 icon += f"-charging-{percentage}" - elif self._charging: + elif self.is_charging: icon += "-outline" - elif self._low_battery: + elif self.is_low_battery: icon += "-alert" elif self.state < 95: percentage = max(int(round(self.state / 10 - 0.01)) * 10, 10) @@ -236,22 +197,23 @@ class HomeKitBatterySensor(HomeKitEntity): """Return units for the sensor.""" return UNIT_PERCENTAGE - def _update_battery_level(self, value): - self._state = value + @property + def is_low_battery(self): + """Return true if battery level is low.""" + return self.service.value(CharacteristicsTypes.STATUS_LO_BATT) == 1 - def _update_status_lo_batt(self, value): - self._low_battery = value == 1 - - def _update_charging_state(self, value): + @property + def is_charging(self): + """Return true if currently charing.""" # 0 = not charging # 1 = charging # 2 = not chargeable - self._charging = value == 1 + return self.service.value(CharacteristicsTypes.CHARGING_STATE) == 1 @property def state(self): """Return the current battery level percentage.""" - return self._state + return self.service.value(CharacteristicsTypes.BATTERY_LEVEL) ENTITY_TYPES = { diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index 9f12d59204d..2251062d99c 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -32,30 +32,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class HomeKitSwitch(HomeKitEntity, SwitchDevice): """Representation of a Homekit switch.""" - def __init__(self, *args): - """Initialise the switch.""" - super().__init__(*args) - self._on = None - self._outlet_in_use = None - def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" return [CharacteristicsTypes.ON, CharacteristicsTypes.OUTLET_IN_USE] - def _update_on(self, value): - self._on = value - - def _update_outlet_in_use(self, value): - self._outlet_in_use = value - @property def is_on(self): """Return true if device is on.""" - return self._on + return self.service.value(CharacteristicsTypes.ON) async def async_turn_on(self, **kwargs): """Turn the specified switch on.""" - self._on = True characteristics = [{"aid": self._aid, "iid": self._chars["on"], "value": True}] await self._accessory.put_characteristics(characteristics) @@ -67,5 +54,6 @@ class HomeKitSwitch(HomeKitEntity, SwitchDevice): @property def device_state_attributes(self): """Return the optional state attributes.""" - if self._outlet_in_use is not None: - return {OUTLET_IN_USE: self._outlet_in_use} + outlet_in_use = self.service.value(CharacteristicsTypes.OUTLET_IN_USE) + if outlet_in_use is not None: + return {OUTLET_IN_USE: outlet_in_use} From 365578d053780efd3b6aa6c941729b8a1e499208 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Wed, 11 Mar 2020 12:34:19 +0000 Subject: [PATCH 337/416] Update homekit_controller to use CharacteristicPermissions constants (#32679) --- .../components/homekit_controller/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 95224dfccbd..572c2a047f3 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -4,7 +4,11 @@ import os import aiohomekit from aiohomekit.model import Accessory -from aiohomekit.model.characteristics import Characteristic, CharacteristicsTypes +from aiohomekit.model.characteristics import ( + Characteristic, + CharacteristicPermissions, + CharacteristicsTypes, +) from aiohomekit.model.services import Service, ServicesTypes from homeassistant.exceptions import ConfigEntryNotReady @@ -105,11 +109,11 @@ class HomeKitEntity(Entity): def _setup_characteristic(self, char: Characteristic): """Configure an entity based on a HomeKit characteristics metadata.""" # Build up a list of (aid, iid) tuples to poll on update() - if "pr" in char.perms: + if CharacteristicPermissions.paired_read in char.perms: self.pollable_characteristics.append((self._aid, char.iid)) # Build up a list of (aid, iid) tuples to subscribe to - if "ev" in char.perms: + if CharacteristicPermissions.events in char.perms: self.watchable_characteristics.append((self._aid, char.iid)) # Build a map of ctype -> iid From 7127492767744799431d47c0f4a9186239310cd9 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Wed, 11 Mar 2020 08:37:28 -0400 Subject: [PATCH 338/416] Additional ZHA cleanup (#32678) * fix double device loading in tests * change imports * None is default --- homeassistant/components/zha/core/gateway.py | 2 +- homeassistant/components/zha/light.py | 4 ++-- homeassistant/components/zha/sensor.py | 4 ++-- tests/components/zha/conftest.py | 1 - tests/components/zha/test_light.py | 22 +++++++++----------- 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index e2831a55ad4..6c0681d9eca 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -235,7 +235,7 @@ class ZHAGateway: def _send_group_gateway_message(self, zigpy_group, gateway_message_type): """Send the gareway event for a zigpy group event.""" - zha_group = self._groups.get(zigpy_group.group_id, None) + zha_group = self._groups.get(zigpy_group.group_id) if zha_group is not None: async_dispatcher_send( self._hass, diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 3387abf9b87..435f8940032 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -380,8 +380,8 @@ class Light(ZhaEntity, light.Light): ): self._color_temp = results["color_temperature"] - color_x = results.get("color_x", None) - color_y = results.get("color_y", None) + color_x = results.get("color_x") + color_y = results.get("color_y") if color_x is not None and color_y is not None: self._hs_color = color_util.color_xy_to_hs( float(color_x / 65535), float(color_y / 65535) diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index b5ea7c54072..3953db27f20 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -178,10 +178,10 @@ class Battery(Sensor): state_attrs = {} attributes = ["battery_size", "battery_quantity"] results = await self._channel.get_attributes(attributes) - battery_size = results.get("battery_size", None) + battery_size = results.get("battery_size") if battery_size is not None: state_attrs["battery_size"] = BATTERY_SIZES.get(battery_size, "Unknown") - battery_quantity = results.get("battery_quantity", None) + battery_quantity = results.get("battery_quantity") if battery_quantity is not None: state_attrs["battery_quantity"] = battery_quantity return state_attrs diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 53ffb121291..e6056428db6 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -157,7 +157,6 @@ def zha_device_restored(hass, zigpy_app_controller, setup_zha): zigpy_app_controller.devices[zigpy_dev.ieee] = zigpy_dev await setup_zha() zha_gateway = hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] - await zha_gateway.async_load_devices() return zha_gateway.get_device(zigpy_dev.ieee) return _zha_device diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 3a3aff2c653..6f5bd23e297 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -2,7 +2,7 @@ from datetime import timedelta from unittest.mock import MagicMock, call, sentinel -import asynctest +from asynctest import CoroutineMock, patch import pytest import zigpy.profiles.zha import zigpy.types @@ -67,9 +67,7 @@ LIGHT_COLOR = { } -@asynctest.mock.patch( - "zigpy.zcl.clusters.general.OnOff.read_attributes", new=MagicMock() -) +@patch("zigpy.zcl.clusters.general.OnOff.read_attributes", new=MagicMock()) async def test_light_refresh(hass, zigpy_device_mock, zha_device_joined_restored): """Test zha light platform refresh.""" @@ -107,21 +105,21 @@ async def test_light_refresh(hass, zigpy_device_mock, zha_device_joined_restored assert hass.states.get(entity_id).state == STATE_OFF -@asynctest.patch( +@patch( "zigpy.zcl.clusters.lighting.Color.request", - new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), + new=CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), ) -@asynctest.patch( +@patch( "zigpy.zcl.clusters.general.Identify.request", - new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), + new=CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), ) -@asynctest.patch( +@patch( "zigpy.zcl.clusters.general.LevelControl.request", - new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), + new=CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), ) -@asynctest.patch( +@patch( "zigpy.zcl.clusters.general.OnOff.request", - new=asynctest.CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), + new=CoroutineMock(return_value=[sentinel.data, zcl_f.Status.SUCCESS]), ) @pytest.mark.parametrize( "device, reporting", From 4c4e5f3fa91daa92654d78c04fcdfb36fe4118b7 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 11 Mar 2020 16:16:22 +0100 Subject: [PATCH 339/416] Fix demos (#32086) * Fixes for demos * Update vacuum.py * Comment * Fix tests --- homeassistant/components/demo/__init__.py | 1 + homeassistant/components/demo/light.py | 18 +++-- homeassistant/components/demo/switch.py | 9 ++- homeassistant/components/demo/vacuum.py | 6 +- tests/components/google_assistant/__init__.py | 2 +- .../google_assistant/test_google_assistant.py | 3 +- .../google_assistant/test_smart_home.py | 78 +++++++++++++++++-- tests/components/group/test_light.py | 2 +- 8 files changed, 98 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index f1e6d3df74f..344ffbd9fd3 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -23,6 +23,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ "media_player", "sensor", "switch", + "vacuum", "water_heater", ] diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py index a2c06b72986..ac7e53826b9 100644 --- a/homeassistant/components/demo/light.py +++ b/homeassistant/components/demo/light.py @@ -44,9 +44,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= effect_list=LIGHT_EFFECT_LIST, effect=LIGHT_EFFECT_LIST[0], ), - DemoLight( - "light_2", "Ceiling Lights", True, True, LIGHT_COLORS[0], LIGHT_TEMPS[1] - ), + DemoLight("light_2", "Ceiling Lights", True, True, ct=LIGHT_TEMPS[1]), DemoLight( "light_3", "Kitchen Lights", True, True, LIGHT_COLORS[1], LIGHT_TEMPS[0] ), @@ -86,6 +84,10 @@ class DemoLight(Light): self._effect_list = effect_list self._effect = effect self._available = True + if ct is not None and hs_color is None: + self._color_mode = "ct" + else: + self._color_mode = "hs" @property def device_info(self): @@ -128,12 +130,16 @@ class DemoLight(Light): @property def hs_color(self) -> tuple: """Return the hs color value.""" - return self._hs_color + if self._color_mode == "hs": + return self._hs_color + return None @property def color_temp(self) -> int: """Return the CT color temperature.""" - return self._ct + if self._color_mode == "ct": + return self._ct + return None @property def white_value(self) -> int: @@ -165,9 +171,11 @@ class DemoLight(Light): self._state = True if ATTR_HS_COLOR in kwargs: + self._color_mode = "hs" self._hs_color = kwargs[ATTR_HS_COLOR] if ATTR_COLOR_TEMP in kwargs: + self._color_mode = "ct" self._ct = kwargs[ATTR_COLOR_TEMP] if ATTR_BRIGHTNESS in kwargs: diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py index 5c651198f5c..5050b2283b4 100644 --- a/homeassistant/components/demo/switch.py +++ b/homeassistant/components/demo/switch.py @@ -10,7 +10,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities( [ DemoSwitch("swith1", "Decorative Lights", True, None, True), - DemoSwitch("swith2", "AC", False, "mdi:air-conditioner", False), + DemoSwitch( + "swith2", + "AC", + False, + "mdi:air-conditioner", + False, + device_class="outlet", + ), ] ) diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py index fb64f17a452..0bdf3ed48f1 100644 --- a/homeassistant/components/demo/vacuum.py +++ b/homeassistant/components/demo/vacuum.py @@ -78,12 +78,12 @@ DEMO_VACUUM_STATE = "5_Fifth_floor" async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Demo config entry.""" - setup_platform(hass, {}, async_add_entities) + await async_setup_platform(hass, {}, async_add_entities) -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 Demo vacuums.""" - add_entities( + async_add_entities( [ DemoVacuum(DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES), DemoVacuum(DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES), diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py index c0b5aa7b193..802b7968ee6 100644 --- a/tests/components/google_assistant/__init__.py +++ b/tests/components/google_assistant/__init__.py @@ -92,7 +92,7 @@ DEMO_DEVICES = [ "id": "switch.ac", "name": {"name": "AC"}, "traits": ["action.devices.traits.OnOff"], - "type": "action.devices.types.SWITCH", + "type": "action.devices.types.OUTLET", "willReportState": False, }, { diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py index f2f43b6dabd..c806e656762 100644 --- a/tests/components/google_assistant/test_google_assistant.py +++ b/tests/components/google_assistant/test_google_assistant.py @@ -175,12 +175,12 @@ async def test_query_request(hass_fixture, assistant_client, auth_header): assert devices["light.bed_light"]["on"] is False assert devices["light.ceiling_lights"]["on"] is True assert devices["light.ceiling_lights"]["brightness"] == 70 + assert devices["light.ceiling_lights"]["color"]["temperatureK"] == 2631 assert devices["light.kitchen_lights"]["color"]["spectrumHsv"] == { "hue": 345, "saturation": 0.75, "value": 0.7058823529411765, } - assert devices["light.kitchen_lights"]["color"]["temperatureK"] == 4166 assert devices["media_player.lounge_room"]["on"] is True @@ -372,7 +372,6 @@ async def test_execute_request(hass_fixture, assistant_client, auth_header): bed = hass_fixture.states.get("light.bed_light") assert bed.attributes.get(light.ATTR_COLOR_TEMP) == 212 - assert bed.attributes.get(light.ATTR_RGB_COLOR) == (0, 255, 0) assert hass_fixture.states.get("switch.decorative_lights").state == "off" diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 7e98f162f22..c08c15a02f4 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -203,6 +203,11 @@ async def test_query_message(hass): light2.entity_id = "light.another_light" await light2.async_update_ha_state() + light3 = DemoLight(None, "Color temp Light", state=True, ct=400, brightness=200) + light3.hass = hass + light3.entity_id = "light.color_temp_light" + await light3.async_update_ha_state() + events = [] hass.bus.async_listen(EVENT_QUERY_RECEIVED, events.append) @@ -219,6 +224,7 @@ async def test_query_message(hass): "devices": [ {"id": "light.demo_light"}, {"id": "light.another_light"}, + {"id": "light.color_temp_light"}, {"id": "light.non_existing"}, ] }, @@ -244,14 +250,19 @@ async def test_query_message(hass): "saturation": 0.75, "value": 0.3058823529411765, }, - "temperatureK": 2500, }, }, + "light.color_temp_light": { + "on": True, + "online": True, + "brightness": 78, + "color": {"temperatureK": 2500}, + }, } }, } - assert len(events) == 3 + assert len(events) == 4 assert events[0].event_type == EVENT_QUERY_RECEIVED assert events[0].data == { "request_id": REQ_ID, @@ -266,6 +277,12 @@ async def test_query_message(hass): } assert events[2].event_type == EVENT_QUERY_RECEIVED assert events[2].data == { + "request_id": REQ_ID, + "entity_id": "light.color_temp_light", + "source": "cloud", + } + assert events[3].event_type == EVENT_QUERY_RECEIVED + assert events[3].data == { "request_id": REQ_ID, "entity_id": "light.non_existing", "source": "cloud", @@ -301,6 +318,7 @@ async def test_execute(hass): "devices": [ {"id": "light.non_existing"}, {"id": "light.ceiling_lights"}, + {"id": "light.kitchen_lights"}, ], "execution": [ { @@ -321,6 +339,8 @@ async def test_execute(hass): const.SOURCE_CLOUD, ) + print(result) + assert result == { "requestId": REQ_ID, "payload": { @@ -333,17 +353,26 @@ async def test_execute(hass): { "ids": ["light.ceiling_lights"], "status": "SUCCESS", + "states": { + "on": True, + "online": True, + "brightness": 20, + "color": {"temperatureK": 2631}, + }, + }, + { + "ids": ["light.kitchen_lights"], + "status": "SUCCESS", "states": { "on": True, "online": True, "brightness": 20, "color": { "spectrumHsv": { - "hue": 56, - "saturation": 0.86, + "hue": 345, + "saturation": 0.75, "value": 0.2, }, - "temperatureK": 2631, }, }, }, @@ -351,7 +380,7 @@ async def test_execute(hass): }, } - assert len(events) == 4 + assert len(events) == 6 assert events[0].event_type == EVENT_COMMAND_RECEIVED assert events[0].data == { "request_id": REQ_ID, @@ -392,21 +421,54 @@ async def test_execute(hass): }, "source": "cloud", } + assert events[4].event_type == EVENT_COMMAND_RECEIVED + assert events[4].data == { + "request_id": REQ_ID, + "entity_id": "light.kitchen_lights", + "execution": { + "command": "action.devices.commands.OnOff", + "params": {"on": True}, + }, + "source": "cloud", + } + assert events[5].event_type == EVENT_COMMAND_RECEIVED + assert events[5].data == { + "request_id": REQ_ID, + "entity_id": "light.kitchen_lights", + "execution": { + "command": "action.devices.commands.BrightnessAbsolute", + "params": {"brightness": 20}, + }, + "source": "cloud", + } - assert len(service_events) == 2 + assert len(service_events) == 4 assert service_events[0].data == { "domain": "light", "service": "turn_on", "service_data": {"entity_id": "light.ceiling_lights"}, } - assert service_events[0].context == events[2].context assert service_events[1].data == { "domain": "light", "service": "turn_on", "service_data": {"brightness_pct": 20, "entity_id": "light.ceiling_lights"}, } + assert service_events[0].context == events[2].context assert service_events[1].context == events[2].context assert service_events[1].context == events[3].context + assert service_events[2].data == { + "domain": "light", + "service": "turn_on", + "service_data": {"entity_id": "light.kitchen_lights"}, + } + assert service_events[3].data == { + "domain": "light", + "service": "turn_on", + "service_data": {"brightness_pct": 20, "entity_id": "light.kitchen_lights"}, + } + assert service_events[2].context == events[4].context + assert service_events[3].context == events[4].context + assert service_events[3].context == events[5].context async def test_raising_error_trait(hass): diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index 87898e42d59..1f1466981b6 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -229,7 +229,7 @@ async def test_emulated_color_temp_group(hass): state = hass.states.get("light.ceiling_lights") assert state.state == "on" assert state.attributes["color_temp"] == 200 - assert "hs_color" in state.attributes.keys() + assert "hs_color" not in state.attributes.keys() state = hass.states.get("light.kitchen_lights") assert state.state == "on" From 015e779d56f6ad13d5528dff948ed7f498348dc5 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 11 Mar 2020 17:24:52 +0100 Subject: [PATCH 340/416] UniFi - Client tracker schedules update on disconnect event (#32655) --- .../components/unifi/device_tracker.py | 33 +++++++++++++++++++ .../components/unifi/unifi_client.py | 20 ++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index b398dad488b..e5d3bcfa82b 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -8,6 +8,7 @@ from homeassistant.components.unifi.config_flow import get_controller_from_confi from homeassistant.core import callback from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.event import async_track_point_in_utc_time import homeassistant.util.dt as dt_util from .const import ATTR_MANUFACTURER @@ -175,6 +176,8 @@ class UniFiClientTracker(UniFiClient, ScannerEntity): """Set up tracked client.""" super().__init__(client, controller) + self.cancel_scheduled_update = None + self.is_disconnected = None self.wired_bug = None if self.is_wired != self.client.is_wired: self.wired_bug = dt_util.utcnow() - self.controller.option_detection_time @@ -186,6 +189,14 @@ class UniFiClientTracker(UniFiClient, ScannerEntity): If connected to unwanted ssid return False. If is_wired and client.is_wired differ it means that the device is offline and UniFi bug shows device as wired. """ + + @callback + def _scheduled_update(now): + """Scheduled callback for update.""" + self.is_disconnected = True + self.cancel_scheduled_update = None + self.async_schedule_update_ha_state() + if ( not self.is_wired and self.controller.option_ssid_filter @@ -193,6 +204,28 @@ class UniFiClientTracker(UniFiClient, ScannerEntity): ): return False + if (self.is_wired and self.wired_connection) or ( + not self.is_wired and self.wireless_connection + ): + if self.cancel_scheduled_update: + self.cancel_scheduled_update() + self.cancel_scheduled_update = None + + self.is_disconnected = False + + if (self.is_wired and self.wired_connection is False) or ( + not self.is_wired and self.wireless_connection is False + ): + if not self.is_disconnected and not self.cancel_scheduled_update: + self.cancel_scheduled_update = async_track_point_in_utc_time( + self.hass, + _scheduled_update, + dt_util.utcnow() + self.controller.option_detection_time, + ) + + if self.is_disconnected is not None: + return not self.is_disconnected + if self.is_wired != self.client.is_wired: if not self.wired_bug: self.wired_bug = dt_util.utcnow() diff --git a/homeassistant/components/unifi/unifi_client.py b/homeassistant/components/unifi/unifi_client.py index 4c1ce402e7f..b46771e574b 100644 --- a/homeassistant/components/unifi/unifi_client.py +++ b/homeassistant/components/unifi/unifi_client.py @@ -5,8 +5,12 @@ import logging from aiounifi.api import SOURCE_EVENT from aiounifi.events import ( WIRED_CLIENT_BLOCKED, + WIRED_CLIENT_CONNECTED, + WIRED_CLIENT_DISCONNECTED, WIRED_CLIENT_UNBLOCKED, WIRELESS_CLIENT_BLOCKED, + WIRELESS_CLIENT_CONNECTED, + WIRELESS_CLIENT_DISCONNECTED, WIRELESS_CLIENT_UNBLOCKED, ) @@ -19,6 +23,8 @@ LOGGER = logging.getLogger(__name__) CLIENT_BLOCKED = (WIRED_CLIENT_BLOCKED, WIRELESS_CLIENT_BLOCKED) CLIENT_UNBLOCKED = (WIRED_CLIENT_UNBLOCKED, WIRELESS_CLIENT_UNBLOCKED) +WIRED_CLIENT = (WIRED_CLIENT_CONNECTED, WIRED_CLIENT_DISCONNECTED) +WIRELESS_CLIENT = (WIRELESS_CLIENT_CONNECTED, WIRELESS_CLIENT_DISCONNECTED) class UniFiClient(Entity): @@ -32,6 +38,8 @@ class UniFiClient(Entity): self.is_wired = self.client.mac not in controller.wireless_clients self.is_blocked = self.client.blocked + self.wired_connection = None + self.wireless_connection = None async def async_added_to_hass(self) -> None: """Client entity created.""" @@ -57,7 +65,17 @@ class UniFiClient(Entity): if self.client.last_updated == SOURCE_EVENT: - if self.client.event.event in CLIENT_BLOCKED + CLIENT_UNBLOCKED: + if self.client.event.event in WIRELESS_CLIENT: + self.wireless_connection = ( + self.client.event.event == WIRELESS_CLIENT_CONNECTED + ) + + elif self.client.event.event in WIRED_CLIENT: + self.wired_connection = ( + self.client.event.event == WIRED_CLIENT_CONNECTED + ) + + elif self.client.event.event in CLIENT_BLOCKED + CLIENT_UNBLOCKED: self.is_blocked = self.client.event.event in CLIENT_BLOCKED LOGGER.debug("Updating client %s %s", self.entity_id, self.client.mac) From 50c32d57f5d498081a6015e6045a5304f88642bd Mon Sep 17 00:00:00 2001 From: cgtobi Date: Wed, 11 Mar 2020 17:25:19 +0100 Subject: [PATCH 341/416] Remove Netatmo binary sensor (#32673) * Clean up for access scope review * Remove deleted from coverage omit list --- .coveragerc | 1 - homeassistant/components/netatmo/__init__.py | 2 +- .../components/netatmo/binary_sensor.py | 203 ------------------ homeassistant/components/netatmo/camera.py | 13 +- homeassistant/components/netatmo/climate.py | 6 +- homeassistant/components/netatmo/const.py | 15 ++ homeassistant/components/netatmo/sensor.py | 8 +- 7 files changed, 28 insertions(+), 220 deletions(-) delete mode 100644 homeassistant/components/netatmo/binary_sensor.py diff --git a/.coveragerc b/.coveragerc index 0e372cb9e1d..f1f1f0e6222 100644 --- a/.coveragerc +++ b/.coveragerc @@ -467,7 +467,6 @@ omit = homeassistant/components/nello/lock.py homeassistant/components/nest/* homeassistant/components/netatmo/__init__.py - homeassistant/components/netatmo/binary_sensor.py homeassistant/components/netatmo/api.py homeassistant/components/netatmo/camera.py homeassistant/components/netatmo/climate.py diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py index 3c318c705bf..776e63bef7d 100644 --- a/homeassistant/components/netatmo/__init__.py +++ b/homeassistant/components/netatmo/__init__.py @@ -58,7 +58,7 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PLATFORMS = ["binary_sensor", "camera", "climate", "sensor"] +PLATFORMS = ["camera", "climate", "sensor"] async def async_setup(hass: HomeAssistant, config: dict): diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py deleted file mode 100644 index 5f419bda2c2..00000000000 --- a/homeassistant/components/netatmo/binary_sensor.py +++ /dev/null @@ -1,203 +0,0 @@ -"""Support for the Netatmo binary sensors.""" -import logging - -import pyatmo - -from homeassistant.components.binary_sensor import BinarySensorDevice - -from .camera import CameraData -from .const import AUTH, DOMAIN, MANUFACTURER - -_LOGGER = logging.getLogger(__name__) - -# These are the available sensors mapped to binary_sensor class -WELCOME_SENSOR_TYPES = { - "Someone known": "motion", - "Someone unknown": "motion", - "Motion": "motion", -} -PRESENCE_SENSOR_TYPES = { - "Outdoor motion": "motion", - "Outdoor human": "motion", - "Outdoor animal": "motion", - "Outdoor vehicle": "motion", -} -TAG_SENSOR_TYPES = {"Tag Vibration": "vibration", "Tag Open": "opening"} - -SENSOR_TYPES = { - "NACamera": WELCOME_SENSOR_TYPES, - "NOC": PRESENCE_SENSOR_TYPES, -} - -CONF_HOME = "home" -CONF_CAMERAS = "cameras" -CONF_WELCOME_SENSORS = "welcome_sensors" -CONF_PRESENCE_SENSORS = "presence_sensors" -CONF_TAG_SENSORS = "tag_sensors" - -DEFAULT_TIMEOUT = 90 - - -async def async_setup_entry(hass, entry, async_add_entities): - """Set up the access to Netatmo binary sensor.""" - auth = hass.data[DOMAIN][entry.entry_id][AUTH] - - def get_entities(): - """Retrieve Netatmo entities.""" - entities = [] - - def get_camera_home_id(data, camera_id): - """Return the home id for a given camera id.""" - for home_id in data.camera_data.cameras: - for camera in data.camera_data.cameras[home_id].values(): - if camera["id"] == camera_id: - return home_id - return None - - try: - data = CameraData(hass, auth) - - for camera in data.get_all_cameras(): - home_id = get_camera_home_id(data, camera_id=camera["id"]) - - sensor_types = {} - sensor_types.update(SENSOR_TYPES[camera["type"]]) - - # Tags are only supported with Netatmo Welcome indoor cameras - modules = data.get_modules(camera["id"]) - if camera["type"] == "NACamera" and modules: - for module in modules: - for sensor_type in TAG_SENSOR_TYPES: - _LOGGER.debug( - "Adding camera tag %s (%s)", - module["name"], - module["id"], - ) - entities.append( - NetatmoBinarySensor( - data, - camera["id"], - home_id, - sensor_type, - module["id"], - ) - ) - - for sensor_type in sensor_types: - entities.append( - NetatmoBinarySensor(data, camera["id"], home_id, sensor_type) - ) - except pyatmo.NoDevice: - _LOGGER.debug("No camera entities to add") - - return entities - - async_add_entities(await hass.async_add_executor_job(get_entities), True) - - -class NetatmoBinarySensor(BinarySensorDevice): - """Represent a single binary sensor in a Netatmo Camera device.""" - - def __init__(self, data, camera_id, home_id, sensor_type, module_id=None): - """Set up for access to the Netatmo camera events.""" - self._data = data - self._camera_id = camera_id - self._module_id = module_id - self._sensor_type = sensor_type - camera_info = data.camera_data.cameraById(cid=camera_id) - self._camera_name = camera_info["name"] - self._camera_type = camera_info["type"] - self._home_id = home_id - self._home_name = self._data.camera_data.getHomeName(home_id=home_id) - self._timeout = DEFAULT_TIMEOUT - if module_id: - self._module_name = data.camera_data.moduleById(mid=module_id)["name"] - self._name = ( - f"{MANUFACTURER} {self._camera_name} {self._module_name} {sensor_type}" - ) - self._unique_id = ( - f"{self._camera_id}-{self._module_id}-" - f"{self._camera_type}-{sensor_type}" - ) - else: - self._name = f"{MANUFACTURER} {self._camera_name} {sensor_type}" - self._unique_id = f"{self._camera_id}-{self._camera_type}-{sensor_type}" - self._state = None - - @property - def name(self): - """Return the name of the Netatmo device and this sensor.""" - return self._name - - @property - def unique_id(self): - """Return the unique ID for this sensor.""" - return self._unique_id - - @property - def device_class(self): - """Return the class of this sensor.""" - if self._camera_type == "NACamera": - return WELCOME_SENSOR_TYPES.get(self._sensor_type) - if self._camera_type == "NOC": - return PRESENCE_SENSOR_TYPES.get(self._sensor_type) - return TAG_SENSOR_TYPES.get(self._sensor_type) - - @property - def device_info(self): - """Return the device info for the sensor.""" - return { - "identifiers": {(DOMAIN, self._camera_id)}, - "name": self._camera_name, - "manufacturer": MANUFACTURER, - "model": self._camera_type, - } - - @property - def is_on(self): - """Return true if binary sensor is on.""" - return self._state - - def update(self): - """Request an update from the Netatmo API.""" - self._data.update() - self._data.update_event(camera_type=self._camera_type) - - if self._camera_type == "NACamera": - if self._sensor_type == "Someone known": - self._state = self._data.camera_data.someone_known_seen( - cid=self._camera_id, exclude=self._timeout - ) - elif self._sensor_type == "Someone unknown": - self._state = self._data.camera_data.someone_unknown_seen( - cid=self._camera_id, exclude=self._timeout - ) - elif self._sensor_type == "Motion": - self._state = self._data.camera_data.motion_detected( - cid=self._camera_id, exclude=self._timeout - ) - elif self._camera_type == "NOC": - if self._sensor_type == "Outdoor motion": - self._state = self._data.camera_data.outdoor_motion_detected( - cid=self._camera_id, offset=self._timeout - ) - elif self._sensor_type == "Outdoor human": - self._state = self._data.camera_data.human_detected( - cid=self._camera_id, offset=self._timeout - ) - elif self._sensor_type == "Outdoor animal": - self._state = self._data.camera_data.animal_detected( - cid=self._camera_id, offset=self._timeout - ) - elif self._sensor_type == "Outdoor vehicle": - self._state = self._data.camera_data.car_detected( - cid=self._camera_id, offset=self._timeout - ) - if self._sensor_type == "Tag Vibration": - self._state = self._data.camera_data.module_motion_detected( - mid=self._module_id, cid=self._camera_id, exclude=self._timeout - ) - elif self._sensor_type == "Tag Open": - self._state = self._data.camera_data.module_opened( - mid=self._module_id, cid=self._camera_id, exclude=self._timeout - ) diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py index 08a3847c0b7..616d2a620c5 100644 --- a/homeassistant/components/netatmo/camera.py +++ b/homeassistant/components/netatmo/camera.py @@ -22,6 +22,7 @@ from .const import ( MANUFACTURER, MIN_TIME_BETWEEN_EVENT_UPDATES, MIN_TIME_BETWEEN_UPDATES, + MODELS, ) _LOGGER = logging.getLogger(__name__) @@ -149,7 +150,7 @@ class NetatmoCamera(Camera): "identifiers": {(DOMAIN, self._camera_id)}, "name": self._camera_name, "manufacturer": MANUFACTURER, - "model": self._camera_type, + "model": MODELS[self._camera_type], } @property @@ -224,23 +225,13 @@ class NetatmoCamera(Camera): camera = self._data.camera_data.get_camera(cid=self._camera_id) - # URLs self._vpnurl, self._localurl = self._data.camera_data.camera_urls( cid=self._camera_id ) - - # Monitoring status self._status = camera.get("status") - - # SD Card status self._sd_status = camera.get("sd_status") - - # Power status self._alim_status = camera.get("alim_status") - - # Is local self._is_local = camera.get("is_local") - self.is_streaming = self._alim_status == "on" diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index f36328a5887..aa269cfeb49 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -36,6 +36,7 @@ from .const import ( AUTH, DOMAIN, MANUFACTURER, + MODELS, SERVICE_SETSCHEDULE, ) @@ -187,7 +188,7 @@ class NetatmoThermostat(ClimateDevice): "identifiers": {(DOMAIN, self._room_id)}, "name": self._room_name, "manufacturer": MANUFACTURER, - "model": self._module_type, + "model": MODELS[self._module_type], } @property @@ -447,6 +448,9 @@ class ThermostatData: """Call the NetAtmo API to update the data.""" try: self.homestatus = pyatmo.HomeStatus(self.auth, home_id=self.home_id) + except pyatmo.exceptions.NoDevice: + _LOGGER.error("No device found") + return except TypeError: _LOGGER.error("Error when getting homestatus") return diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 4443ef23032..9216a678e68 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -6,6 +6,21 @@ API = "api" DOMAIN = "netatmo" MANUFACTURER = "Netatmo" +MODELS = { + "NAPlug": "Relay", + "NATherm1": "Smart Thermostat", + "NRV": "Smart Radiator Valves", + "NACamera": "Smart Indoor Camera", + "NOC": "Smart Outdoor Camera", + "NSD": "Smart Smoke Alarm", + "NACamDoorTag": "Smart Door and Window Sensors", + "NAMain": "Smart Home Weather station – indoor module", + "NAModule1": "Smart Home Weather station – outdoor module", + "NAModule4": "Smart Additional Indoor module", + "NAModule3": "Smart Rain Gauge", + "NAModule2": "Smart Anemometer", +} + AUTH = "netatmo_auth" CONF_PUBLIC = "public_sensor_config" CAMERA_DATA = "netatmo_camera" diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py index f52b6797a7a..fcddf58daaa 100644 --- a/homeassistant/components/netatmo/sensor.py +++ b/homeassistant/components/netatmo/sensor.py @@ -16,7 +16,7 @@ from homeassistant.const import ( from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -from .const import AUTH, DOMAIN, MANUFACTURER +from .const import AUTH, DOMAIN, MANUFACTURER, MODELS _LOGGER = logging.getLogger(__name__) @@ -202,7 +202,7 @@ class NetatmoSensor(Entity): "identifiers": {(DOMAIN, self._module_id)}, "name": self.module_name, "manufacturer": MANUFACTURER, - "model": self._module_type, + "model": MODELS[self._module_type], } @property @@ -233,7 +233,9 @@ class NetatmoSensor(Entity): data = self.netatmo_data.data.get(self._module_id) if data is None: - _LOGGER.info("No data found for %s (%s)", self.module_name, self._module_id) + _LOGGER.debug( + "No data found for %s (%s)", self.module_name, self._module_id + ) _LOGGER.debug("data: %s", self.netatmo_data.data) self._state = None return From c56530a71256cf8e7e9a894d525094999409af78 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Thu, 12 Mar 2020 00:26:16 +0800 Subject: [PATCH 342/416] Connect to more recent versions of IZone (#32552) * Update to new version of python-izone * Improve disconnection handling. * Update requirements-all * Lint fix --- homeassistant/components/izone/climate.py | 36 ++++++++++++++++---- homeassistant/components/izone/const.py | 2 +- homeassistant/components/izone/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 20673312fa7..dde71d14a9c 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -85,6 +85,21 @@ async def async_setup_entry( return True +def _return_on_connection_error(ret=None): + def wrap(func): + def wrapped_f(*args, **kwargs): + if not args[0].available: + return ret + try: + return func(*args, **kwargs) + except ConnectionError: + return ret + + return wrapped_f + + return wrap + + class ControllerDevice(ClimateDevice): """Representation of iZone Controller.""" @@ -161,6 +176,8 @@ class ControllerDevice(ClimateDevice): if ctrl is not self._controller: return self.async_schedule_update_ha_state() + for zone in self.zones.values(): + zone.async_schedule_update_ha_state() self.async_on_remove( async_dispatcher_connect( @@ -259,12 +276,15 @@ class ControllerDevice(ClimateDevice): if not self._controller.is_on: return HVAC_MODE_OFF mode = self._controller.mode + if mode == Controller.Mode.FREE_AIR: + return HVAC_MODE_FAN_ONLY for (key, value) in self._state_to_pizone.items(): if value == mode: return key assert False, "Should be unreachable" @property + @_return_on_connection_error([]) def hvac_modes(self) -> List[str]: """Return the list of available operation modes.""" if self._controller.free_air: @@ -272,11 +292,13 @@ class ControllerDevice(ClimateDevice): return [HVAC_MODE_OFF, *self._state_to_pizone] @property + @_return_on_connection_error(PRESET_NONE) def preset_mode(self): """Eco mode is external air.""" return PRESET_ECO if self._controller.free_air else PRESET_NONE @property + @_return_on_connection_error([PRESET_NONE]) def preset_modes(self): """Available preset modes, normal or eco.""" if self._controller.free_air_enabled: @@ -284,6 +306,7 @@ class ControllerDevice(ClimateDevice): return [PRESET_NONE] @property + @_return_on_connection_error() def current_temperature(self) -> Optional[float]: """Return the current temperature.""" if self._controller.mode == Controller.Mode.FREE_AIR: @@ -291,6 +314,7 @@ class ControllerDevice(ClimateDevice): return self._controller.temp_return @property + @_return_on_connection_error() def target_temperature(self) -> Optional[float]: """Return the temperature we try to reach.""" if not self._supported_features & SUPPORT_TARGET_TEMPERATURE: @@ -318,11 +342,13 @@ class ControllerDevice(ClimateDevice): return list(self._fan_to_pizone) @property + @_return_on_connection_error(0.0) def min_temp(self) -> float: """Return the minimum temperature.""" return self._controller.temp_min @property + @_return_on_connection_error(50.0) def max_temp(self) -> float: """Return the maximum temperature.""" return self._controller.temp_max @@ -453,14 +479,12 @@ class ZoneDevice(ClimateDevice): return False @property + @_return_on_connection_error(0) def supported_features(self): """Return the list of supported features.""" - try: - if self._zone.mode == Zone.Mode.AUTO: - return self._supported_features - return self._supported_features & ~SUPPORT_TARGET_TEMPERATURE - except ConnectionError: - return None + if self._zone.mode == Zone.Mode.AUTO: + return self._supported_features + return self._supported_features & ~SUPPORT_TARGET_TEMPERATURE @property def temperature_unit(self): diff --git a/homeassistant/components/izone/const.py b/homeassistant/components/izone/const.py index 4da7bc9e4af..fdee8dc7228 100644 --- a/homeassistant/components/izone/const.py +++ b/homeassistant/components/izone/const.py @@ -7,7 +7,7 @@ DATA_CONFIG = "izone_config" DISPATCH_CONTROLLER_DISCOVERED = "izone_controller_discovered" DISPATCH_CONTROLLER_DISCONNECTED = "izone_controller_disconnected" -DISPATCH_CONTROLLER_RECONNECTED = "izone_controller_disconnected" +DISPATCH_CONTROLLER_RECONNECTED = "izone_controller_reconnected" DISPATCH_CONTROLLER_UPDATE = "izone_controller_update" DISPATCH_ZONE_UPDATE = "izone_zone_update" diff --git a/homeassistant/components/izone/manifest.json b/homeassistant/components/izone/manifest.json index b8bb5b55b79..9e982b19cf8 100644 --- a/homeassistant/components/izone/manifest.json +++ b/homeassistant/components/izone/manifest.json @@ -2,7 +2,7 @@ "domain": "izone", "name": "iZone", "documentation": "https://www.home-assistant.io/integrations/izone", - "requirements": ["python-izone==1.1.1"], + "requirements": ["python-izone==1.1.2"], "dependencies": [], "codeowners": ["@Swamp-Ig"], "config_flow": true diff --git a/requirements_all.txt b/requirements_all.txt index 2a0b30032c4..07635c17435 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1611,7 +1611,7 @@ python-gitlab==1.6.0 python-hpilo==4.3 # homeassistant.components.izone -python-izone==1.1.1 +python-izone==1.1.2 # homeassistant.components.joaoapps_join python-join-api==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 81fed9172d2..90c8acb9df0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -575,7 +575,7 @@ python-ecobee-api==0.2.2 python-forecastio==1.4.0 # homeassistant.components.izone -python-izone==1.1.1 +python-izone==1.1.2 # homeassistant.components.xiaomi_miio python-miio==0.4.8 From ffe8b94d75a8e4385fee9dddaca6d40bf01e0d0d Mon Sep 17 00:00:00 2001 From: Jc2k Date: Wed, 11 Mar 2020 16:27:20 +0000 Subject: [PATCH 343/416] Simplfy homekit_controller characteristic writes (#32683) --- .../components/homekit_controller/__init__.py | 26 ++++-- .../homekit_controller/alarm_control_panel.py | 11 +-- .../components/homekit_controller/climate.py | 29 +++--- .../homekit_controller/connection.py | 12 +-- .../components/homekit_controller/cover.py | 63 ++++++------- .../components/homekit_controller/fan.py | 91 ++++--------------- .../components/homekit_controller/light.py | 41 +++------ .../components/homekit_controller/lock.py | 11 +-- .../homekit_controller/media_player.py | 66 ++++---------- .../components/homekit_controller/switch.py | 6 +- 10 files changed, 123 insertions(+), 233 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 572c2a047f3..2089471f288 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -1,6 +1,7 @@ """Support for Homekit device discovery.""" import logging import os +from typing import Any, Dict import aiohomekit from aiohomekit.model import Accessory @@ -37,7 +38,6 @@ class HomeKitEntity(Entity): self._aid = devinfo["aid"] self._iid = devinfo["iid"] self._features = 0 - self._chars = {} self.setup() self._signals = [] @@ -79,6 +79,24 @@ class HomeKitEntity(Entity): signal_remove() self._signals.clear() + async def async_put_characteristics(self, characteristics: Dict[str, Any]): + """ + Write characteristics to the device. + + A characteristic type is unique within a service, but in order to write + to a named characteristic on a bridge we need to turn its type into + an aid and iid, and send it as a list of tuples, which is what this + helper does. + + E.g. you can do: + + await entity.async_put_characteristics({ + CharacteristicsTypes.ON: True + }) + """ + payload = self.service.build_update(characteristics) + return await self._accessory.put_characteristics(payload) + @property def should_poll(self) -> bool: """Return False. @@ -91,8 +109,6 @@ class HomeKitEntity(Entity): """Configure an entity baed on its HomeKit characteristics metadata.""" self.pollable_characteristics = [] self.watchable_characteristics = [] - self._chars = {} - self._char_names = {} char_types = self.get_characteristic_types() @@ -116,10 +132,6 @@ class HomeKitEntity(Entity): if CharacteristicPermissions.events in char.perms: self.watchable_characteristics.append((self._aid, char.iid)) - # Build a map of ctype -> iid - self._chars[char.type_name] = char.iid - self._char_names[char.iid] = char.type_name - # Callback to allow entity to configure itself based on this # characteristics metadata (valid values, value ranges, features, etc) setup_fn_name = escape_characteristic_name(char.type_name) diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index 0a96d716c78..9e712b4127f 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -103,14 +103,9 @@ class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel): async def set_alarm_state(self, state, code=None): """Send state command.""" - characteristics = [ - { - "aid": self._aid, - "iid": self._chars["security-system-state.target"], - "value": TARGET_STATE_MAP[state], - } - ] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics( + {CharacteristicsTypes.SECURITY_SYSTEM_STATE_TARGET: TARGET_STATE_MAP[state]} + ) @property def device_state_attributes(self): diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index e748ea430e5..133c100b125 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -127,32 +127,25 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): """Set new target temperature.""" temp = kwargs.get(ATTR_TEMPERATURE) - characteristics = [ - {"aid": self._aid, "iid": self._chars["temperature.target"], "value": temp} - ] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics( + {CharacteristicsTypes.TEMPERATURE_TARGET: temp} + ) async def async_set_humidity(self, humidity): """Set new target humidity.""" - characteristics = [ - { - "aid": self._aid, - "iid": self._chars["relative-humidity.target"], - "value": humidity, - } - ] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics( + {CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET: humidity} + ) async def async_set_hvac_mode(self, hvac_mode): """Set new target operation mode.""" - characteristics = [ + await self.async_put_characteristics( { - "aid": self._aid, - "iid": self._chars["heating-cooling.target"], - "value": MODE_HASS_TO_HOMEKIT[hvac_mode], + CharacteristicsTypes.HEATING_COOLING_TARGET: MODE_HASS_TO_HOMEKIT[ + hvac_mode + ], } - ] - await self._accessory.put_characteristics(characteristics) + ) @property def current_temperature(self): diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 06f2830d5f8..605253e6235 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -338,12 +338,8 @@ class HKDevice: async def put_characteristics(self, characteristics): """Control a HomeKit device state from Home Assistant.""" - chars = [] - for row in characteristics: - chars.append((row["aid"], row["iid"], row["value"])) - async with self.pairing_lock: - results = await self.pairing.put_characteristics(chars) + results = await self.pairing.put_characteristics(characteristics) # Feed characteristics back into HA and update the current state # results will only contain failures, so anythin in characteristics @@ -351,8 +347,8 @@ class HKDevice: # reflect the change immediately. new_entity_state = {} - for row in characteristics: - key = (row["aid"], row["iid"]) + for aid, iid, value in characteristics: + key = (aid, iid) # If the key was returned by put_characteristics() then the # change didn't work @@ -361,7 +357,7 @@ class HKDevice: # Otherwise it was accepted and we can apply the change to # our state - new_entity_state[key] = {"value": row["value"]} + new_entity_state[key] = {"value": value} self.process_new_events(new_entity_state) diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 79682970496..9b73846d6a7 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -110,14 +110,9 @@ class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice): async def set_door_state(self, state): """Send state command.""" - characteristics = [ - { - "aid": self._aid, - "iid": self._chars["door-state.target"], - "value": TARGET_GARAGE_STATE_MAP[state], - } - ] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics( + {CharacteristicsTypes.DOOR_STATE_TARGET: TARGET_GARAGE_STATE_MAP[state]} + ) @property def device_state_attributes(self): @@ -198,6 +193,20 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice): state = CURRENT_WINDOW_STATE_MAP[value] return state == STATE_OPENING + @property + def is_horizontal_tilt(self): + """Return True if the service has a horizontal tilt characteristic.""" + return ( + self.service.value(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT) is not None + ) + + @property + def is_vertical_tilt(self): + """Return True if the service has a vertical tilt characteristic.""" + return ( + self.service.value(CharacteristicsTypes.VERTICAL_TILT_CURRENT) is not None + ) + @property def current_cover_tilt_position(self): """Return current position of cover tilt.""" @@ -210,10 +219,7 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice): async def async_stop_cover(self, **kwargs): """Send hold command.""" - characteristics = [ - {"aid": self._aid, "iid": self._chars["position.hold"], "value": 1} - ] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics({CharacteristicsTypes.POSITION_HOLD: 1}) async def async_open_cover(self, **kwargs): """Send open command.""" @@ -226,32 +232,21 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice): async def async_set_cover_position(self, **kwargs): """Send position command.""" position = kwargs[ATTR_POSITION] - characteristics = [ - {"aid": self._aid, "iid": self._chars["position.target"], "value": position} - ] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics( + {CharacteristicsTypes.POSITION_TARGET: position} + ) async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" tilt_position = kwargs[ATTR_TILT_POSITION] - if "vertical-tilt.target" in self._chars: - characteristics = [ - { - "aid": self._aid, - "iid": self._chars["vertical-tilt.target"], - "value": tilt_position, - } - ] - await self._accessory.put_characteristics(characteristics) - elif "horizontal-tilt.target" in self._chars: - characteristics = [ - { - "aid": self._aid, - "iid": self._chars["horizontal-tilt.target"], - "value": tilt_position, - } - ] - await self._accessory.put_characteristics(characteristics) + if self.is_vertical_tilt: + await self.async_put_characteristics( + {CharacteristicsTypes.VERTICAL_TILT_TARGET: tilt_position} + ) + elif self.is_horizontal_tilt: + await self.async_put_characteristics( + {CharacteristicsTypes.HORIZONTAL_TILT_TARGET: tilt_position} + ) @property def device_state_attributes(self): diff --git a/homeassistant/components/homekit_controller/fan.py b/homeassistant/components/homekit_controller/fan.py index 25cfee73fb8..f0d6967684c 100644 --- a/homeassistant/components/homekit_controller/fan.py +++ b/homeassistant/components/homekit_controller/fan.py @@ -55,6 +55,7 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): CharacteristicsTypes.SWING_MODE, CharacteristicsTypes.ROTATION_DIRECTION, CharacteristicsTypes.ROTATION_SPEED, + self.on_characteristic, ] def _setup_rotation_direction(self, char): @@ -66,6 +67,11 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): def _setup_swing_mode(self, char): self._features |= SUPPORT_OSCILLATE + @property + def is_on(self): + """Return true if device is on.""" + return self.service.value(self.on_characteristic) == 1 + @property def speed(self): """Return the current speed.""" @@ -111,14 +117,8 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): async def async_set_direction(self, direction): """Set the direction of the fan.""" - await self._accessory.put_characteristics( - [ - { - "aid": self._aid, - "iid": self._chars["rotation.direction"], - "value": DIRECTION_TO_HK[direction], - } - ] + await self.async_put_characteristics( + {CharacteristicsTypes.ROTATION_DIRECTION: DIRECTION_TO_HK[direction]} ) async def async_set_speed(self, speed): @@ -126,96 +126,45 @@ class BaseHomeKitFan(HomeKitEntity, FanEntity): if speed == SPEED_OFF: return await self.async_turn_off() - await self._accessory.put_characteristics( - [ - { - "aid": self._aid, - "iid": self._chars["rotation.speed"], - "value": SPEED_TO_PCNT[speed], - } - ] + await self.async_put_characteristics( + {CharacteristicsTypes.ROTATION_SPEED: SPEED_TO_PCNT[speed]} ) async def async_oscillate(self, oscillating: bool): """Oscillate the fan.""" - await self._accessory.put_characteristics( - [ - { - "aid": self._aid, - "iid": self._chars["swing-mode"], - "value": 1 if oscillating else 0, - } - ] + await self.async_put_characteristics( + {CharacteristicsTypes.SWING_MODE: 1 if oscillating else 0} ) async def async_turn_on(self, speed=None, **kwargs): """Turn the specified fan on.""" - characteristics = [] + characteristics = {} if not self.is_on: - characteristics.append( - { - "aid": self._aid, - "iid": self._chars[self.on_characteristic], - "value": True, - } - ) + characteristics[self.on_characteristic] = True if self.supported_features & SUPPORT_SET_SPEED and speed: - characteristics.append( - { - "aid": self._aid, - "iid": self._chars["rotation.speed"], - "value": SPEED_TO_PCNT[speed], - }, - ) + characteristics[CharacteristicsTypes.ROTATION_SPEED] = SPEED_TO_PCNT[speed] - if not characteristics: - return - - await self._accessory.put_characteristics(characteristics) + if characteristics: + await self.async_put_characteristics(characteristics) async def async_turn_off(self, **kwargs): """Turn the specified fan off.""" - characteristics = [ - { - "aid": self._aid, - "iid": self._chars[self.on_characteristic], - "value": False, - } - ] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics({self.on_characteristic: False}) class HomeKitFanV1(BaseHomeKitFan): """Implement fan support for public.hap.service.fan.""" - on_characteristic = "on" - - def get_characteristic_types(self): - """Define the homekit characteristics the entity cares about.""" - return [CharacteristicsTypes.ON] + super().get_characteristic_types() - - @property - def is_on(self): - """Return true if device is on.""" - return self.service.value(CharacteristicsTypes.ON) == 1 + on_characteristic = CharacteristicsTypes.ON class HomeKitFanV2(BaseHomeKitFan): """Implement fan support for public.hap.service.fanv2.""" - on_characteristic = "active" - - def get_characteristic_types(self): - """Define the homekit characteristics the entity cares about.""" - return [CharacteristicsTypes.ACTIVE] + super().get_characteristic_types() - - @property - def is_on(self): - """Return true if device is on.""" - return self.service.value(CharacteristicsTypes.ACTIVE) == 1 + on_characteristic = CharacteristicsTypes.ACTIVE ENTITY_TYPES = { diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index 962cfcc466c..14ed74cc085 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -94,41 +94,28 @@ class HomeKitLight(HomeKitEntity, Light): temperature = kwargs.get(ATTR_COLOR_TEMP) brightness = kwargs.get(ATTR_BRIGHTNESS) - characteristics = [] + characteristics = {} + if hs_color is not None: - characteristics.append( - {"aid": self._aid, "iid": self._chars["hue"], "value": hs_color[0]} - ) - characteristics.append( + characteristics.update( { - "aid": self._aid, - "iid": self._chars["saturation"], - "value": hs_color[1], + CharacteristicsTypes.HUE: hs_color[0], + CharacteristicsTypes.SATURATION: hs_color[1], } ) + if brightness is not None: - characteristics.append( - { - "aid": self._aid, - "iid": self._chars["brightness"], - "value": int(brightness * 100 / 255), - } + characteristics[CharacteristicsTypes.BRIGHTNESS] = int( + brightness * 100 / 255 ) if temperature is not None: - characteristics.append( - { - "aid": self._aid, - "iid": self._chars["color-temperature"], - "value": int(temperature), - } - ) - characteristics.append( - {"aid": self._aid, "iid": self._chars["on"], "value": True} - ) - await self._accessory.put_characteristics(characteristics) + characteristics[CharacteristicsTypes.COLOR_TEMPERATURE] = int(temperature) + + characteristics[CharacteristicsTypes.ON] = True + + await self.async_put_characteristics(characteristics) async def async_turn_off(self, **kwargs): """Turn the specified light off.""" - characteristics = [{"aid": self._aid, "iid": self._chars["on"], "value": False}] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics({CharacteristicsTypes.ON: False}) diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index b79c10e2ae0..c07f85fb50f 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -61,14 +61,9 @@ class HomeKitLock(HomeKitEntity, LockDevice): async def _set_lock_state(self, state): """Send state command.""" - characteristics = [ - { - "aid": self._aid, - "iid": self._chars["lock-mechanism.target-state"], - "value": TARGET_STATE_MAP[state], - } - ] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics( + {CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE: TARGET_STATE_MAP[state]} + ) @property def device_state_attributes(self): diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py index 798931e2cb4..2e4b05817bb 100644 --- a/homeassistant/components/homekit_controller/media_player.py +++ b/homeassistant/components/homekit_controller/media_player.py @@ -165,23 +165,13 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice): return if TargetMediaStateValues.PLAY in self._supported_target_media_state: - characteristics = [ - { - "aid": self._aid, - "iid": self._chars["target-media-state"], - "value": TargetMediaStateValues.PLAY, - } - ] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics( + {CharacteristicsTypes.TARGET_MEDIA_STATE: TargetMediaStateValues.PLAY} + ) elif RemoteKeyValues.PLAY_PAUSE in self._supported_remote_key: - characteristics = [ - { - "aid": self._aid, - "iid": self._chars["remote-key"], - "value": RemoteKeyValues.PLAY_PAUSE, - } - ] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics( + {CharacteristicsTypes.REMOTE_KEY: RemoteKeyValues.PLAY_PAUSE} + ) async def async_media_pause(self): """Send pause command.""" @@ -190,23 +180,13 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice): return if TargetMediaStateValues.PAUSE in self._supported_target_media_state: - characteristics = [ - { - "aid": self._aid, - "iid": self._chars["target-media-state"], - "value": TargetMediaStateValues.PAUSE, - } - ] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics( + {CharacteristicsTypes.TARGET_MEDIA_STATE: TargetMediaStateValues.PAUSE} + ) elif RemoteKeyValues.PLAY_PAUSE in self._supported_remote_key: - characteristics = [ - { - "aid": self._aid, - "iid": self._chars["remote-key"], - "value": RemoteKeyValues.PLAY_PAUSE, - } - ] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics( + {CharacteristicsTypes.REMOTE_KEY: RemoteKeyValues.PLAY_PAUSE} + ) async def async_media_stop(self): """Send stop command.""" @@ -215,14 +195,9 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice): return if TargetMediaStateValues.STOP in self._supported_target_media_state: - characteristics = [ - { - "aid": self._aid, - "iid": self._chars["target-media-state"], - "value": TargetMediaStateValues.STOP, - } - ] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics( + {CharacteristicsTypes.TARGET_MEDIA_STATE: TargetMediaStateValues.STOP} + ) async def async_select_source(self, source): """Switch to a different media source.""" @@ -240,11 +215,6 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice): identifier = input_source[CharacteristicsTypes.IDENTIFIER] - characteristics = [ - { - "aid": self._aid, - "iid": self._chars["active-identifier"], - "value": identifier.value, - } - ] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics( + {CharacteristicsTypes.ACTIVE_IDENTIFIER: identifier.value} + ) diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index 2251062d99c..5897bbb7b3f 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -43,13 +43,11 @@ class HomeKitSwitch(HomeKitEntity, SwitchDevice): async def async_turn_on(self, **kwargs): """Turn the specified switch on.""" - characteristics = [{"aid": self._aid, "iid": self._chars["on"], "value": True}] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics({CharacteristicsTypes.ON: True}) async def async_turn_off(self, **kwargs): """Turn the specified switch off.""" - characteristics = [{"aid": self._aid, "iid": self._chars["on"], "value": False}] - await self._accessory.put_characteristics(characteristics) + await self.async_put_characteristics({CharacteristicsTypes.ON: False}) @property def device_state_attributes(self): From 440c837eb6548b30ee94a9584a394fbd3b9c8f13 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 11 Mar 2020 12:31:02 -0400 Subject: [PATCH 344/416] Allow sw_version update of a device registry entry. (#32630) --- homeassistant/helpers/device_registry.py | 2 ++ tests/helpers/test_device_registry.py | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 0821b909dc7..6d9574c3bbd 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -155,6 +155,7 @@ class DeviceRegistry: name=_UNDEF, name_by_user=_UNDEF, new_identifiers=_UNDEF, + sw_version=_UNDEF, via_device_id=_UNDEF, remove_config_entry_id=_UNDEF, ): @@ -165,6 +166,7 @@ class DeviceRegistry: name=name, name_by_user=name_by_user, new_identifiers=new_identifiers, + sw_version=sw_version, via_device_id=via_device_id, remove_config_entry_id=remove_config_entry_id, ) diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index 7f31c32cde3..13bb61253bc 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -480,3 +480,21 @@ async def test_loading_race_condition(hass): mock_load.assert_called_once_with() assert results[0] == results[1] + + +async def test_update_sw_version(registry): + """Verify that we can update software version of a device.""" + entry = registry.async_get_or_create( + config_entry_id="1234", + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + identifiers={("bla", "123")}, + ) + assert not entry.sw_version + sw_version = "0x20020263" + + with patch.object(registry, "async_schedule_save") as mock_save: + updated_entry = registry.async_update_device(entry.id, sw_version=sw_version) + + assert mock_save.call_count == 1 + assert updated_entry != entry + assert updated_entry.sw_version == sw_version From 5216dc0ae1ac4ecc0f2f1d9fe11c6d43d7a045f5 Mon Sep 17 00:00:00 2001 From: Florian Werner Date: Wed, 11 Mar 2020 17:33:00 +0100 Subject: [PATCH 345/416] Fix rate of change calculation of statistics sensor (#32597) * Fix rate of change of statistics sensor * Fix test --- homeassistant/components/statistics/sensor.py | 2 +- tests/components/statistics/test_sensor.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index d85b6b079ae..0fb08a0fecb 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -267,7 +267,7 @@ class StatisticsSensor(Entity): time_diff = (self.max_age - self.min_age).total_seconds() if time_diff > 0: - self.change_rate = self.average_change / time_diff + self.change_rate = self.change / time_diff self.change = round(self.change, self._precision) self.average_change = round(self.average_change, self._precision) diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index cec669da134..df79d0750b4 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -36,7 +36,7 @@ class TestStatisticsSensor(unittest.TestCase): self.variance = round(statistics.variance(self.values), 2) self.change = round(self.values[-1] - self.values[0], 2) self.average_change = round(self.change / (len(self.values) - 1), 2) - self.change_rate = round(self.average_change / (60 * (self.count - 1)), 2) + self.change_rate = round(self.change / (60 * (self.count - 1)), 2) def teardown_method(self, method): """Stop everything that was started.""" From f7ddbc7e1e1505653c0e987dbb817f855b1debd2 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 11 Mar 2020 17:34:19 +0100 Subject: [PATCH 346/416] Remove duplicated config from MQTT tests (#32689) --- tests/components/mqtt/common.py | 57 ++- .../mqtt/test_alarm_control_panel.py | 411 +++++------------- tests/components/mqtt/test_binary_sensor.py | 137 +++--- tests/components/mqtt/test_climate.py | 116 ++--- tests/components/mqtt/test_cover.py | 102 ++--- tests/components/mqtt/test_fan.py | 101 ++--- tests/components/mqtt/test_legacy_vacuum.py | 92 ++-- tests/components/mqtt/test_light.py | 100 ++--- tests/components/mqtt/test_light_json.py | 106 ++--- tests/components/mqtt/test_light_template.py | 121 ++---- tests/components/mqtt/test_lock.py | 101 ++--- tests/components/mqtt/test_sensor.py | 86 ++-- tests/components/mqtt/test_state_vacuum.py | 99 ++--- tests/components/mqtt/test_switch.py | 101 ++--- 14 files changed, 599 insertions(+), 1131 deletions(-) diff --git a/tests/components/mqtt/common.py b/tests/components/mqtt/common.py index f8c57101445..f8de0faf82f 100644 --- a/tests/components/mqtt/common.py +++ b/tests/components/mqtt/common.py @@ -17,7 +17,10 @@ from tests.common import ( async def help_test_setting_attribute_via_mqtt_json_message( hass, mqtt_mock, domain, config ): - """Test the setting of attribute via MQTT with JSON payload.""" + """Test the setting of attribute via MQTT with JSON payload. + + This is a test helper for the MqttAttributes mixin. + """ assert await async_setup_component(hass, domain, config,) async_fire_mqtt_message(hass, "attr-topic", '{ "val": "100" }') @@ -29,7 +32,10 @@ async def help_test_setting_attribute_via_mqtt_json_message( async def help_test_update_with_json_attrs_not_dict( hass, mqtt_mock, caplog, domain, config ): - """Test attributes get extracted from a JSON result.""" + """Test attributes get extracted from a JSON result. + + This is a test helper for the MqttAttributes mixin. + """ assert await async_setup_component(hass, domain, config,) async_fire_mqtt_message(hass, "attr-topic", '[ "list", "of", "things"]') @@ -42,7 +48,10 @@ async def help_test_update_with_json_attrs_not_dict( async def help_test_update_with_json_attrs_bad_JSON( hass, mqtt_mock, caplog, domain, config ): - """Test attributes get extracted from a JSON result.""" + """Test JSON validation of attributes. + + This is a test helper for the MqttAttributes mixin. + """ assert await async_setup_component(hass, domain, config,) async_fire_mqtt_message(hass, "attr-topic", "This is not JSON") @@ -55,13 +64,16 @@ async def help_test_update_with_json_attrs_bad_JSON( async def help_test_discovery_update_attr( hass, mqtt_mock, caplog, domain, data1, data2 ): - """Test update of discovered MQTTAttributes.""" + """Test update of discovered MQTTAttributes. + + This is a test helper for the MqttAttributes mixin. + """ entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, "homeassistant", {}, entry) async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data1) await hass.async_block_till_done() async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "100" }') - state = hass.states.get(f"{domain}.beer") + state = hass.states.get(f"{domain}.test") assert state.attributes.get("val") == "100" # Change json_attributes_topic @@ -70,17 +82,17 @@ async def help_test_discovery_update_attr( # Verify we are no longer subscribing to the old topic async_fire_mqtt_message(hass, "attr-topic1", '{ "val": "50" }') - state = hass.states.get(f"{domain}.beer") + state = hass.states.get(f"{domain}.test") assert state.attributes.get("val") == "100" # Verify we are subscribing to the new topic async_fire_mqtt_message(hass, "attr-topic2", '{ "val": "75" }') - state = hass.states.get(f"{domain}.beer") + state = hass.states.get(f"{domain}.test") assert state.attributes.get("val") == "75" async def help_test_unique_id(hass, domain, config): - """Test unique id option only creates one alarm per unique_id.""" + """Test unique id option only creates one entity per unique_id.""" await async_mock_mqtt_component(hass) assert await async_setup_component(hass, domain, config,) async_fire_mqtt_message(hass, "test-topic", "payload") @@ -88,26 +100,32 @@ async def help_test_unique_id(hass, domain, config): async def help_test_discovery_removal(hass, mqtt_mock, caplog, domain, data): - """Test removal of discovered component.""" + """Test removal of discovered component. + + This is a test helper for the MqttDiscoveryUpdate mixin. + """ entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, "homeassistant", {}, entry) async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() - state = hass.states.get(f"{domain}.beer") + state = hass.states.get(f"{domain}.test") assert state is not None - assert state.name == "Beer" + assert state.name == "test" async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", "") await hass.async_block_till_done() - state = hass.states.get(f"{domain}.beer") + state = hass.states.get(f"{domain}.test") assert state is None async def help_test_discovery_update(hass, mqtt_mock, caplog, domain, data1, data2): - """Test update of discovered component.""" + """Test update of discovered component. + + This is a test helper for the MqttDiscoveryUpdate mixin. + """ entry = MockConfigEntry(domain=mqtt.DOMAIN) await async_start(hass, "homeassistant", {}, entry) @@ -150,13 +168,17 @@ async def help_test_discovery_broken(hass, mqtt_mock, caplog, domain, data1, dat assert state is None -async def help_test_entity_device_info_with_identifier(hass, mqtt_mock, domain, data): - """Test MQTT alarm control panel device registry integration.""" +async def help_test_entity_device_info_with_identifier(hass, mqtt_mock, domain, config): + """Test device registry integration. + + This is a test helper for the MqttDiscoveryUpdate mixin. + """ entry = MockConfigEntry(domain=mqtt.DOMAIN) entry.add_to_hass(hass) await async_start(hass, "homeassistant", {}, entry) registry = await hass.helpers.device_registry.async_get_registry() + data = json.dumps(config) async_fire_mqtt_message(hass, f"homeassistant/{domain}/bla/config", data) await hass.async_block_till_done() @@ -171,7 +193,10 @@ async def help_test_entity_device_info_with_identifier(hass, mqtt_mock, domain, async def help_test_entity_device_info_update(hass, mqtt_mock, domain, config): - """Test device registry update.""" + """Test device registry update. + + This is a test helper for the MqttDiscoveryUpdate mixin. + """ entry = MockConfigEntry(domain=mqtt.DOMAIN) entry.add_to_hass(hass) await async_start(hass, "homeassistant", {}, entry) diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 6a14f2ebbda..9a0df0bcd8d 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -1,4 +1,5 @@ """The tests the MQTT alarm control panel component.""" +import copy import json from homeassistant.components import alarm_control_panel @@ -36,6 +37,52 @@ from tests.components.alarm_control_panel import common CODE = "HELLO_CODE" +DEFAULT_CONFIG = { + alarm_control_panel.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "alarm/state", + "command_topic": "alarm/command", + } +} + +DEFAULT_CONFIG_ATTR = { + alarm_control_panel.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "alarm/state", + "command_topic": "alarm/command", + "json_attributes_topic": "attr-topic", + } +} + +DEFAULT_CONFIG_CODE = { + alarm_control_panel.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "alarm/state", + "command_topic": "alarm/command", + "code": "1234", + "code_arm_required": True, + } +} + +DEFAULT_CONFIG_DEVICE_INFO = { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "command_topic": "test-command-topic", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + "unique_id": "veryunique", +} + async def test_fail_setup_without_state_topic(hass, mqtt_mock): """Test for failing with no state topic.""" @@ -71,16 +118,7 @@ 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, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - } - }, + hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) entity_id = "alarm_control_panel.test" @@ -102,16 +140,7 @@ 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, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - } - }, + hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) entity_id = "alarm_control_panel.test" @@ -125,16 +154,7 @@ 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, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - } - }, + hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) await common.async_alarm_arm_home(hass) @@ -149,18 +169,7 @@ 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, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - "code": "1234", - "code_arm_required": True, - } - }, + hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) call_count = mqtt_mock.async_publish.call_count @@ -173,20 +182,9 @@ async def test_arm_home_publishes_mqtt_when_code_not_req(hass, mqtt_mock): When code_arm_required = False """ - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - "code": "1234", - "code_arm_required": False, - } - }, - ) + 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,) await common.async_alarm_arm_home(hass) mqtt_mock.async_publish.assert_called_once_with( @@ -197,16 +195,7 @@ 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, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - } - }, + hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) await common.async_alarm_arm_away(hass) @@ -221,18 +210,7 @@ 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, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - "code": "1234", - "code_arm_required": True, - } - }, + hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) call_count = mqtt_mock.async_publish.call_count @@ -245,20 +223,9 @@ async def test_arm_away_publishes_mqtt_when_code_not_req(hass, mqtt_mock): When code_arm_required = False """ - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - "code": "1234", - "code_arm_required": False, - } - }, - ) + 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,) await common.async_alarm_arm_away(hass) mqtt_mock.async_publish.assert_called_once_with( @@ -269,16 +236,7 @@ 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, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - } - }, + hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) await common.async_alarm_arm_night(hass) @@ -293,18 +251,7 @@ 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, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - "code": "1234", - "code_arm_required": True, - } - }, + hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) call_count = mqtt_mock.async_publish.call_count @@ -317,20 +264,9 @@ async def test_arm_night_publishes_mqtt_when_code_not_req(hass, mqtt_mock): When code_arm_required = False """ - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - "code": "1234", - "code_arm_required": False, - } - }, - ) + 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,) await common.async_alarm_arm_night(hass) mqtt_mock.async_publish.assert_called_once_with( @@ -341,16 +277,7 @@ async def test_arm_night_publishes_mqtt_when_code_not_req(hass, mqtt_mock): 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, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - } - }, + hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG, ) await common.async_alarm_disarm(hass) @@ -362,20 +289,12 @@ async def test_disarm_publishes_mqtt_with_template(hass, mqtt_mock): When command_template set to output json """ - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - "code": "1234", - "command_template": '{"action":"{{ action }}",' '"code":"{{ code }}"}', - } - }, + config = copy.deepcopy(DEFAULT_CONFIG_CODE) + config[alarm_control_panel.DOMAIN]["code"] = "1234" + config[alarm_control_panel.DOMAIN]["command_template"] = ( + '{"action":"{{ action }}",' '"code":"{{ code }}"}' ) + assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config,) await common.async_alarm_disarm(hass, 1234) mqtt_mock.async_publish.assert_called_once_with( @@ -388,20 +307,10 @@ async def test_disarm_publishes_mqtt_when_code_not_req(hass, mqtt_mock): When code_disarm_required = False """ - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - "code": "1234", - "code_disarm_required": False, - } - }, - ) + 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,) await common.async_alarm_disarm(hass) mqtt_mock.async_publish.assert_called_once_with("alarm/command", "DISARM", 0, False) @@ -413,18 +322,7 @@ 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, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - "code": "1234", - "code_disarm_required": True, - } - }, + hass, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_CODE, ) call_count = mqtt_mock.async_publish.call_count @@ -434,20 +332,9 @@ async def test_disarm_not_publishes_mqtt_with_invalid_code_when_req(hass, mqtt_m async def test_default_availability_payload(hass, mqtt_mock): """Test availability by default payload with defined topic.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - "code": "1234", - "availability_topic": "availability-topic", - } - }, - ) + config = copy.deepcopy(DEFAULT_CONFIG_CODE) + config[alarm_control_panel.DOMAIN]["availability_topic"] = "availability-topic" + assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config,) state = hass.states.get("alarm_control_panel.test") assert state.state == STATE_UNAVAILABLE @@ -465,22 +352,11 @@ async def test_default_availability_payload(hass, mqtt_mock): async def test_custom_availability_payload(hass, mqtt_mock): """Test availability by custom payload with defined topic.""" - assert await async_setup_component( - hass, - alarm_control_panel.DOMAIN, - { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "alarm/state", - "command_topic": "alarm/command", - "code": "1234", - "availability_topic": "availability-topic", - "payload_available": "good", - "payload_not_available": "nogood", - } - }, - ) + config = copy.deepcopy(DEFAULT_CONFIG) + config[alarm_control_panel.DOMAIN]["availability_topic"] = "availability-topic" + config[alarm_control_panel.DOMAIN]["payload_available"] = "good" + config[alarm_control_panel.DOMAIN]["payload_not_available"] = "nogood" + assert await async_setup_component(hass, alarm_control_panel.DOMAIN, config,) state = hass.states.get("alarm_control_panel.test") assert state.state == STATE_UNAVAILABLE @@ -496,22 +372,6 @@ async def test_custom_availability_payload(hass, mqtt_mock): assert state.state == STATE_UNAVAILABLE -async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): - """Test the setting of attribute via MQTT with JSON payload.""" - config = { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } - await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, alarm_control_panel.DOMAIN, config - ) - - async def test_update_state_via_state_topic_template(hass, mqtt_mock): """Test updating with template_value via state topic.""" assert await async_setup_component( @@ -542,50 +402,36 @@ async def test_update_state_via_state_topic_template(hass, mqtt_mock): assert state.state == STATE_ALARM_ARMED_AWAY +async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): + """Test the setting of attribute via MQTT with JSON payload.""" + await help_test_setting_attribute_via_mqtt_json_message( + hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_ATTR + ) + + async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, config + hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - alarm_control_panel.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, config + hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - data1 = ( - '{ "name": "Beer",' - ' "command_topic": "test_topic",' - ' "json_attributes_topic": "attr-topic1" }' - ) - data2 = ( - '{ "name": "Beer",' - ' "command_topic": "test_topic",' - ' "json_attributes_topic": "attr-topic2" }' - ) + config1 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN]) + config2 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN]) + config1["json_attributes_topic"] = "attr-topic1" + config2["json_attributes_topic"] = "attr-topic2" + data1 = json.dumps(config1) + data2 = json.dumps(config2) + await help_test_discovery_update_attr( hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data1, data2 ) @@ -616,11 +462,7 @@ async def test_unique_id(hass): async def test_discovery_removal_alarm(hass, mqtt_mock, caplog): """Test removal of discovered alarm_control_panel.""" - data = ( - '{ "name": "Beer",' - ' "state_topic": "test_topic",' - ' "command_topic": "test_topic" }' - ) + data = json.dumps(DEFAULT_CONFIG[alarm_control_panel.DOMAIN]) await help_test_discovery_removal( hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data ) @@ -628,16 +470,13 @@ async def test_discovery_removal_alarm(hass, mqtt_mock, caplog): async def test_discovery_update_alarm(hass, mqtt_mock, caplog): """Test update of discovered alarm_control_panel.""" - 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[alarm_control_panel.DOMAIN]) + config2 = copy.deepcopy(DEFAULT_CONFIG[alarm_control_panel.DOMAIN]) + config1["name"] = "Beer" + config2["name"] = "Milk" + + data1 = json.dumps(config1) + data2 = json.dumps(config2) await help_test_discovery_update( hass, mqtt_mock, caplog, alarm_control_panel.DOMAIN, data1, data2 ) @@ -658,47 +497,15 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT alarm control panel device registry integration.""" - data = json.dumps( - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - ) await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, alarm_control_panel.DOMAIN, data + hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - config = { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } await help_test_entity_device_info_update( - hass, mqtt_mock, alarm_control_panel.DOMAIN, config + hass, mqtt_mock, alarm_control_panel.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO ) diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index e77cddda76d..2cc917c527b 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -1,4 +1,5 @@ """The tests for the MQTT binary sensor platform.""" +import copy from datetime import datetime, timedelta import json from unittest.mock import patch @@ -30,6 +31,38 @@ from .common import ( from tests.common import async_fire_mqtt_message, async_fire_time_changed +DEFAULT_CONFIG = { + binary_sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + } +} + +DEFAULT_CONFIG_ATTR = { + binary_sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } +} + +DEFAULT_CONFIG_DEVICE_INFO = { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + "unique_id": "veryunique", +} + async def test_setting_sensor_value_expires_availability_topic(hass, mqtt_mock, caplog): """Test the expiration of the value.""" @@ -424,61 +457,34 @@ async def test_off_delay(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - config = { - binary_sensor.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, binary_sensor.DOMAIN, config + hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - binary_sensor.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, binary_sensor.DOMAIN, config + hass, mqtt_mock, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - binary_sensor.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, binary_sensor.DOMAIN, config + hass, mqtt_mock, caplog, binary_sensor.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - data1 = ( - '{ "name": "Beer",' - ' "state_topic": "test_topic",' - ' "json_attributes_topic": "attr-topic1" }' - ) - data2 = ( - '{ "name": "Beer",' - ' "state_topic": "test_topic",' - ' "json_attributes_topic": "attr-topic2" }' - ) + config1 = copy.deepcopy(DEFAULT_CONFIG_ATTR[binary_sensor.DOMAIN]) + config2 = copy.deepcopy(DEFAULT_CONFIG_ATTR[binary_sensor.DOMAIN]) + config1["json_attributes_topic"] = "attr-topic1" + config2["json_attributes_topic"] = "attr-topic2" + data1 = json.dumps(config1) + data2 = json.dumps(config2) + await help_test_discovery_update_attr( hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data1, data2 ) @@ -507,11 +513,7 @@ async def test_unique_id(hass): async def test_discovery_removal_binary_sensor(hass, mqtt_mock, caplog): """Test removal of discovered binary_sensor.""" - data = ( - '{ "name": "Beer",' - ' "state_topic": "test_topic",' - ' "availability_topic": "availability_topic" }' - ) + data = json.dumps(DEFAULT_CONFIG[binary_sensor.DOMAIN]) await help_test_discovery_removal( hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data ) @@ -519,16 +521,13 @@ async def test_discovery_removal_binary_sensor(hass, mqtt_mock, caplog): async def test_discovery_update_binary_sensor(hass, mqtt_mock, caplog): """Test update of discovered binary_sensor.""" - data1 = ( - '{ "name": "Beer",' - ' "state_topic": "test_topic",' - ' "availability_topic": "availability_topic1" }' - ) - data2 = ( - '{ "name": "Milk",' - ' "state_topic": "test_topic2",' - ' "availability_topic": "availability_topic2" }' - ) + config1 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN]) + config2 = copy.deepcopy(DEFAULT_CONFIG[binary_sensor.DOMAIN]) + config1["name"] = "Beer" + config2["name"] = "Milk" + + data1 = json.dumps(config1) + data2 = json.dumps(config2) await help_test_discovery_update( hass, mqtt_mock, caplog, binary_sensor.DOMAIN, data1, data2 ) @@ -545,45 +544,15 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT binary sensor device registry integration.""" - data = json.dumps( - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - ) await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, binary_sensor.DOMAIN, data + hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - config = { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } await help_test_entity_device_info_update( - hass, mqtt_mock, binary_sensor.DOMAIN, config + hass, mqtt_mock, binary_sensor.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO ) diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py index 677fef06f22..a6fb5f2cc66 100644 --- a/tests/components/mqtt/test_climate.py +++ b/tests/components/mqtt/test_climate.py @@ -60,6 +60,32 @@ DEFAULT_CONFIG = { } } +DEFAULT_CONFIG_ATTR = { + CLIMATE_DOMAIN: { + "platform": "mqtt", + "name": "test", + "power_state_topic": "test-topic", + "power_command_topic": "test_topic", + "json_attributes_topic": "attr-topic", + } +} + +DEFAULT_CONFIG_DEVICE_INFO = { + "platform": "mqtt", + "name": "Test 1", + "power_state_topic": "test-topic", + "power_command_topic": "test-command-topic", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + "unique_id": "veryunique", +} + async def test_setup_params(hass, mqtt_mock): """Test the initial parameters.""" @@ -773,66 +799,34 @@ async def test_temp_step_custom(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - config = { - CLIMATE_DOMAIN: { - "platform": "mqtt", - "name": "test", - "power_state_topic": "test-topic", - "power_command_topic": "test_topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, CLIMATE_DOMAIN, config + hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - CLIMATE_DOMAIN: { - "platform": "mqtt", - "name": "test", - "power_state_topic": "test-topic", - "power_command_topic": "test_topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, CLIMATE_DOMAIN, config + hass, mqtt_mock, caplog, CLIMATE_DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - CLIMATE_DOMAIN: { - "platform": "mqtt", - "name": "test", - "power_state_topic": "test-topic", - "power_command_topic": "test_topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, CLIMATE_DOMAIN, config + hass, mqtt_mock, caplog, CLIMATE_DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" - data1 = ( - '{ "name": "Beer",' - ' "power_state_topic": "test-topic",' - ' "power_command_topic": "test_topic",' - ' "json_attributes_topic": "attr-topic1" }' - ) - data2 = ( - '{ "name": "Beer",' - ' "power_state_topic": "test-topic",' - ' "power_command_topic": "test_topic",' - ' "json_attributes_topic": "attr-topic2" }' - ) + config1 = copy.deepcopy(DEFAULT_CONFIG[CLIMATE_DOMAIN]) + config2 = copy.deepcopy(DEFAULT_CONFIG[CLIMATE_DOMAIN]) + config1["json_attributes_topic"] = "attr-topic1" + config2["json_attributes_topic"] = "attr-topic2" + data1 = json.dumps(config1) + data2 = json.dumps(config2) + await help_test_discovery_update_attr( hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data1, data2 ) @@ -863,7 +857,7 @@ async def test_unique_id(hass): async def test_discovery_removal_climate(hass, mqtt_mock, caplog): """Test removal of discovered climate.""" - data = '{ "name": "Beer" }' + data = json.dumps(DEFAULT_CONFIG[CLIMATE_DOMAIN]) await help_test_discovery_removal(hass, mqtt_mock, caplog, CLIMATE_DOMAIN, data) @@ -887,44 +881,16 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT climate device registry integration.""" - data = json.dumps( - { - "platform": "mqtt", - "name": "Test 1", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - ) await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, CLIMATE_DOMAIN, data + hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG_DEVICE_INFO ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - config = { - "platform": "mqtt", - "name": "Test 1", - "power_state_topic": "test-topic", - "power_command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - await help_test_entity_device_info_update(hass, mqtt_mock, CLIMATE_DOMAIN, config) + await help_test_entity_device_info_update( + hass, mqtt_mock, CLIMATE_DOMAIN, DEFAULT_CONFIG_DEVICE_INFO + ) async def test_entity_id_update(hass, mqtt_mock): diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 0bf8aa92150..78f7dc72a24 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -1,6 +1,4 @@ """The tests for the MQTT cover platform.""" -import json - from homeassistant.components import cover from homeassistant.components.cover import ATTR_POSITION, ATTR_TILT_POSITION from homeassistant.components.mqtt.cover import MqttCover @@ -41,6 +39,31 @@ from .common import ( from tests.common import async_fire_mqtt_message +DEFAULT_CONFIG_ATTR = { + cover.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } +} + +DEFAULT_CONFIG_DEVICE_INFO = { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test-command-topic", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + "unique_id": "veryunique", +} + async def test_state_via_state_topic(hass, mqtt_mock): """Test the controlling state via topic.""" @@ -1700,61 +1723,38 @@ async def test_invalid_device_class(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - config = { - cover.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, cover.DOMAIN, config + hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - cover.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, cover.DOMAIN, config + hass, mqtt_mock, caplog, cover.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - cover.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, cover.DOMAIN, config + hass, mqtt_mock, caplog, cover.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" data1 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic1" }' ) data2 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) + await help_test_discovery_update_attr( hass, mqtt_mock, caplog, cover.DOMAIN, data1, data2 ) @@ -1783,7 +1783,7 @@ async def test_unique_id(hass): async def test_discovery_removal_cover(hass, mqtt_mock, caplog): """Test removal of discovered cover.""" - data = '{ "name": "Beer",' ' "command_topic": "test_topic" }' + data = '{ "name": "test",' ' "command_topic": "test_topic" }' await help_test_discovery_removal(hass, mqtt_mock, caplog, cover.DOMAIN, data) @@ -1807,46 +1807,16 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT cover device registry integration.""" - data = json.dumps( - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - ) await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, cover.DOMAIN, data + hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - config = { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - await help_test_entity_device_info_update(hass, mqtt_mock, cover.DOMAIN, config) + await help_test_entity_device_info_update( + hass, mqtt_mock, cover.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO + ) async def test_entity_id_update(hass, mqtt_mock): diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 556dbbe5528..512dddd4fc6 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -1,6 +1,4 @@ """Test MQTT fans.""" -import json - from homeassistant.components import fan from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -27,6 +25,31 @@ from .common import ( from tests.common import async_fire_mqtt_message from tests.components.fan import common +DEFAULT_CONFIG_ATTR = { + fan.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } +} + +DEFAULT_CONFIG_DEVICE_INFO = { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test-command-topic", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + "unique_id": "veryunique", +} + async def test_fail_setup_if_no_command_topic(hass, mqtt_mock): """Test if command fails with command topic.""" @@ -447,58 +470,34 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - config = { - fan.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, fan.DOMAIN, config + hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - fan.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, fan.DOMAIN, config + hass, mqtt_mock, caplog, fan.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - fan.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, fan.DOMAIN, config + hass, mqtt_mock, caplog, fan.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" data1 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic1" }' ) data2 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) @@ -532,7 +531,7 @@ async def test_unique_id(hass): async def test_discovery_removal_fan(hass, mqtt_mock, caplog): """Test removal of discovered fan.""" - data = '{ "name": "Beer",' ' "command_topic": "test_topic" }' + data = '{ "name": "test",' ' "command_topic": "test_topic" }' await help_test_discovery_removal(hass, mqtt_mock, caplog, fan.DOMAIN, data) @@ -552,46 +551,16 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT fan device registry integration.""" - data = json.dumps( - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - ) await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, fan.DOMAIN, data + hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - config = { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - await help_test_entity_device_info_update(hass, mqtt_mock, fan.DOMAIN, config) + await help_test_entity_device_info_update( + hass, mqtt_mock, fan.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO + ) async def test_entity_id_update(hass, mqtt_mock): diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py index 558a185dbb0..c3500e6ac6a 100644 --- a/tests/components/mqtt/test_legacy_vacuum.py +++ b/tests/components/mqtt/test_legacy_vacuum.py @@ -63,6 +63,29 @@ DEFAULT_CONFIG = { mqttvacuum.CONF_FAN_SPEED_LIST: ["min", "medium", "high", "max"], } +DEFAULT_CONFIG_ATTR = { + vacuum.DOMAIN: { + "platform": "mqtt", + "name": "test", + "json_attributes_topic": "attr-topic", + } +} + +DEFAULT_CONFIG_DEVICE_INFO = { + "platform": "mqtt", + "name": "Test 1", + "command_topic": "test-command-topic", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + "unique_id": "veryunique", +} + async def test_default_supported_features(hass, mqtt_mock): """Test that the correct supported features.""" @@ -525,55 +548,34 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - config = { - vacuum.DOMAIN: { - "platform": "mqtt", - "name": "test", - "json_attributes_topic": "attr-topic", - } - } await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, vacuum.DOMAIN, config + hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - vacuum.DOMAIN: { - "platform": "mqtt", - "name": "test", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, vacuum.DOMAIN, config + hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - vacuum.DOMAIN: { - "platform": "mqtt", - "name": "test", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, vacuum.DOMAIN, config + hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" data1 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic1" }' ) data2 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) @@ -605,7 +607,7 @@ async def test_unique_id(hass, mqtt_mock): async def test_discovery_removal_vacuum(hass, mqtt_mock, caplog): """Test removal of discovered vacuum.""" - data = '{ "name": "Beer",' ' "command_topic": "test_topic" }' + data = json.dumps(DEFAULT_CONFIG_ATTR[vacuum.DOMAIN]) await help_test_discovery_removal(hass, mqtt_mock, caplog, vacuum.DOMAIN, data) @@ -629,44 +631,16 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT vacuum device registry integration.""" - data = json.dumps( - { - "platform": "mqtt", - "name": "Test 1", - "command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - ) await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, vacuum.DOMAIN, data + hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - config = { - "platform": "mqtt", - "name": "Test 1", - "command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - await help_test_entity_device_info_update(hass, mqtt_mock, vacuum.DOMAIN, config) + await help_test_entity_device_info_update( + hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO + ) async def test_entity_id_update(hass, mqtt_mock): diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py index 389806a2b20..f2bde3d3b43 100644 --- a/tests/components/mqtt/test_light.py +++ b/tests/components/mqtt/test_light.py @@ -153,7 +153,6 @@ light: payload_off: "off" """ -import json from unittest import mock from unittest.mock import patch @@ -190,6 +189,31 @@ from tests.common import ( ) from tests.components.light import common +DEFAULT_CONFIG_ATTR = { + light.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } +} + +DEFAULT_CONFIG_DEVICE_INFO = { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test-command-topic", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + "unique_id": "veryunique", +} + async def test_fail_setup_if_no_command_topic(hass, mqtt_mock): """Test if command fails with command topic.""" @@ -1075,58 +1099,34 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - config = { - light.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, light.DOMAIN, config + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - light.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, light.DOMAIN, config + hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - light.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, light.DOMAIN, config + hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" data1 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic1" }' ) data2 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) @@ -1161,7 +1161,7 @@ async def test_unique_id(hass): async def test_discovery_removal_light(hass, mqtt_mock, caplog): """Test removal of discovered light.""" data = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) @@ -1214,46 +1214,16 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT light device registry integration.""" - data = json.dumps( - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - ) await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, light.DOMAIN, data + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - config = { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - await help_test_entity_device_info_update(hass, mqtt_mock, light.DOMAIN, config) + await help_test_entity_device_info_update( + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO + ) async def test_entity_id_update(hass, mqtt_mock): diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index 8783f16c9af..71ced8f1db2 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -120,6 +120,33 @@ from .common import ( from tests.common import MockConfigEntry, async_fire_mqtt_message, mock_coro from tests.components.light import common +DEFAULT_CONFIG_ATTR = { + light.DOMAIN: { + "platform": "mqtt", + "schema": "json", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } +} + +DEFAULT_CONFIG_DEVICE_INFO = { + "platform": "mqtt", + "name": "Test 1", + "schema": "json", + "state_topic": "test-topic", + "command_topic": "test-command-topic", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + "unique_id": "veryunique", +} + class JsonValidator(object): """Helper to compare JSON.""" @@ -921,62 +948,35 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - config = { - light.DOMAIN: { - "platform": "mqtt", - "schema": "json", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, light.DOMAIN, config + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - light.DOMAIN: { - "platform": "mqtt", - "schema": "json", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, light.DOMAIN, config + hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - light.DOMAIN: { - "platform": "mqtt", - "schema": "json", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, light.DOMAIN, config + hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" data1 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "schema": "json",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic1" }' ) data2 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "schema": "json",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' @@ -1013,7 +1013,7 @@ async def test_unique_id(hass): async def test_discovery_removal(hass, mqtt_mock, caplog): """Test removal of discovered mqtt_json lights.""" - data = '{ "name": "Beer",' ' "schema": "json",' ' "command_topic": "test_topic" }' + data = '{ "name": "test",' ' "schema": "json",' ' "command_topic": "test_topic" }' await help_test_discovery_removal(hass, mqtt_mock, caplog, light.DOMAIN, data) @@ -1068,48 +1068,16 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT light device registry integration.""" - data = json.dumps( - { - "platform": "mqtt", - "name": "Test 1", - "schema": "json", - "state_topic": "test-topic", - "command_topic": "test-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - ) await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, light.DOMAIN, data + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - config = { - "platform": "mqtt", - "name": "Test 1", - "schema": "json", - "state_topic": "test-topic", - "command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - await help_test_entity_device_info_update(hass, mqtt_mock, light.DOMAIN, config) + await help_test_entity_device_info_update( + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO + ) async def test_entity_id_update(hass, mqtt_mock): diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py index 26c58b7522c..9d4d3fcba25 100644 --- a/tests/components/mqtt/test_light_template.py +++ b/tests/components/mqtt/test_light_template.py @@ -26,7 +26,6 @@ If your light doesn't support white value feature, omit `white_value_template`. If your light doesn't support RGB feature, omit `(red|green|blue)_template`. """ -import json from unittest.mock import patch from homeassistant.components import light, mqtt @@ -61,6 +60,37 @@ from tests.common import ( mock_coro, ) +DEFAULT_CONFIG_ATTR = { + light.DOMAIN: { + "platform": "mqtt", + "schema": "template", + "name": "test", + "command_topic": "test-topic", + "command_on_template": "on,{{ transition }}", + "command_off_template": "off,{{ transition|d }}", + "json_attributes_topic": "attr-topic", + } +} + +DEFAULT_CONFIG_DEVICE_INFO = { + "platform": "mqtt", + "name": "Test 1", + "schema": "template", + "state_topic": "test-topic", + "command_topic": "test-command-topic", + "command_on_template": "on,{{ transition }}", + "command_off_template": "off,{{ transition|d }}", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + "unique_id": "veryunique", +} + async def test_setup_fails(hass, mqtt_mock): """Test that setup fails with missing required configuration items.""" @@ -522,62 +552,29 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - config = { - light.DOMAIN: { - "platform": "mqtt", - "schema": "template", - "name": "test", - "command_topic": "test-topic", - "command_on_template": "on,{{ transition }}", - "command_off_template": "off,{{ transition|d }}", - "json_attributes_topic": "attr-topic", - } - } await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, light.DOMAIN, config + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - light.DOMAIN: { - "platform": "mqtt", - "schema": "template", - "name": "test", - "command_topic": "test-topic", - "command_on_template": "on,{{ transition }}", - "command_off_template": "off,{{ transition|d }}", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, light.DOMAIN, config + hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - light.DOMAIN: { - "platform": "mqtt", - "schema": "template", - "name": "test", - "command_topic": "test-topic", - "command_on_template": "on,{{ transition }}", - "command_off_template": "off,{{ transition|d }}", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, light.DOMAIN, config + hass, mqtt_mock, caplog, light.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" data1 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "schema": "template",' ' "command_topic": "test_topic",' ' "command_on_template": "on",' @@ -585,7 +582,7 @@ async def test_discovery_update_attr(hass, mqtt_mock, caplog): ' "json_attributes_topic": "attr-topic1" }' ) data2 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "schema": "template",' ' "command_topic": "test_topic",' ' "command_on_template": "on",' @@ -627,7 +624,7 @@ async def test_unique_id(hass): async def test_discovery_removal(hass, mqtt_mock, caplog): """Test removal of discovered mqtt_json lights.""" data = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "schema": "template",' ' "command_topic": "test_topic",' ' "command_on_template": "on",' @@ -695,52 +692,16 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT light device registry integration.""" - data = json.dumps( - { - "platform": "mqtt", - "name": "Test 1", - "schema": "template", - "state_topic": "test-topic", - "command_topic": "test-topic", - "command_on_template": "on,{{ transition }}", - "command_off_template": "off,{{ transition|d }}", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - ) await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, light.DOMAIN, data + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - config = { - "platform": "mqtt", - "name": "Test 1", - "schema": "template", - "state_topic": "test-topic", - "command_topic": "test-command-topic", - "command_on_template": "on,{{ transition }}", - "command_off_template": "off,{{ transition|d }}", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - await help_test_entity_device_info_update(hass, mqtt_mock, light.DOMAIN, config) + await help_test_entity_device_info_update( + hass, mqtt_mock, light.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO + ) async def test_entity_id_update(hass, mqtt_mock): diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py index a9c1fe73952..d636eb1534d 100644 --- a/tests/components/mqtt/test_lock.py +++ b/tests/components/mqtt/test_lock.py @@ -1,6 +1,4 @@ """The tests for the MQTT lock platform.""" -import json - from homeassistant.components import lock from homeassistant.const import ( ATTR_ASSUMED_STATE, @@ -27,6 +25,31 @@ from .common import ( from tests.common import async_fire_mqtt_message from tests.components.lock import common +DEFAULT_CONFIG_ATTR = { + lock.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } +} + +DEFAULT_CONFIG_DEVICE_INFO = { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test-command-topic", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + "unique_id": "veryunique", +} + async def test_controlling_state_via_topic(hass, mqtt_mock): """Test the controlling state via topic.""" @@ -316,58 +339,34 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - config = { - lock.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, lock.DOMAIN, config + hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - lock.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, lock.DOMAIN, config + hass, mqtt_mock, caplog, lock.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - lock.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, lock.DOMAIN, config + hass, mqtt_mock, caplog, lock.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" data1 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic1" }' ) data2 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) @@ -401,7 +400,7 @@ async def test_unique_id(hass): async def test_discovery_removal_lock(hass, mqtt_mock, caplog): """Test removal of discovered lock.""" - data = '{ "name": "Beer",' ' "command_topic": "test_topic" }' + data = '{ "name": "test",' ' "command_topic": "test_topic" }' await help_test_discovery_removal(hass, mqtt_mock, caplog, lock.DOMAIN, data) @@ -431,46 +430,16 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT lock device registry integration.""" - data = json.dumps( - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - ) await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, lock.DOMAIN, data + hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - config = { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - await help_test_entity_device_info_update(hass, mqtt_mock, lock.DOMAIN, config) + await help_test_entity_device_info_update( + hass, mqtt_mock, lock.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO + ) async def test_entity_id_update(hass, mqtt_mock): diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index be29a297d3d..0cf24894bcb 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -31,6 +31,30 @@ from tests.common import ( async_fire_time_changed, ) +DEFAULT_CONFIG_ATTR = { + sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } +} + +DEFAULT_CONFIG_DEVICE_INFO = { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + "unique_id": "veryunique", +} + async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock): """Test the setting of the value via MQTT.""" @@ -447,43 +471,27 @@ async def test_setting_attribute_with_template(hass, mqtt_mock): async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - sensor.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, sensor.DOMAIN, config + hass, mqtt_mock, caplog, sensor.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - sensor.DOMAIN: { - "platform": "mqtt", - "name": "test", - "state_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, sensor.DOMAIN, config + hass, mqtt_mock, caplog, sensor.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" data1 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "state_topic": "test_topic",' ' "json_attributes_topic": "attr-topic1" }' ) data2 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "state_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) @@ -515,7 +523,7 @@ async def test_unique_id(hass): async def test_discovery_removal_sensor(hass, mqtt_mock, caplog): """Test removal of discovered sensor.""" - data = '{ "name": "Beer",' ' "state_topic": "test_topic" }' + data = '{ "name": "test",' ' "state_topic": "test_topic" }' await help_test_discovery_removal(hass, mqtt_mock, caplog, sensor.DOMAIN, data) @@ -539,44 +547,16 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT sensor device registry integration.""" - data = json.dumps( - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - ) await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, sensor.DOMAIN, data + hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - config = { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - await help_test_entity_device_info_update(hass, mqtt_mock, sensor.DOMAIN, config) + await help_test_entity_device_info_update( + hass, mqtt_mock, sensor.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO + ) async def test_entity_id_update(hass, mqtt_mock): diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py index 146f69b30d7..52c101d138c 100644 --- a/tests/components/mqtt/test_state_vacuum.py +++ b/tests/components/mqtt/test_state_vacuum.py @@ -63,6 +63,31 @@ DEFAULT_CONFIG = { mqttvacuum.CONF_FAN_SPEED_LIST: ["min", "medium", "high", "max"], } +DEFAULT_CONFIG_ATTR = { + vacuum.DOMAIN: { + "platform": "mqtt", + "schema": "state", + "name": "test", + "json_attributes_topic": "attr-topic", + } +} + +DEFAULT_CONFIG_DEVICE_INFO = { + "platform": "mqtt", + "schema": "state", + "name": "Test 1", + "command_topic": "test-command-topic", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + "unique_id": "veryunique", +} + async def test_default_supported_features(hass, mqtt_mock): """Test that the correct supported features.""" @@ -351,59 +376,35 @@ async def test_custom_availability_payload(hass, mqtt_mock): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - config = { - vacuum.DOMAIN: { - "platform": "mqtt", - "schema": "state", - "name": "test", - "json_attributes_topic": "attr-topic", - } - } await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, vacuum.DOMAIN, config + hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - vacuum.DOMAIN: { - "platform": "mqtt", - "schema": "state", - "name": "test", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, vacuum.DOMAIN, config + hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - vacuum.DOMAIN: { - "platform": "mqtt", - "schema": "state", - "name": "test", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, vacuum.DOMAIN, config + hass, mqtt_mock, caplog, vacuum.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" data1 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "schema": "state",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic1" }' ) data2 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "schema": "state",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' @@ -438,7 +439,7 @@ async def test_unique_id(hass, mqtt_mock): async def test_discovery_removal_vacuum(hass, mqtt_mock, caplog): """Test removal of discovered vacuum.""" - data = '{ "schema": "state", "name": "Beer",' ' "command_topic": "test_topic"}' + data = '{ "schema": "state", "name": "test",' ' "command_topic": "test_topic"}' await help_test_discovery_removal(hass, mqtt_mock, caplog, vacuum.DOMAIN, data) @@ -462,46 +463,16 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT vacuum device registry integration.""" - data = json.dumps( - { - "platform": "mqtt", - "schema": "state", - "name": "Test 1", - "command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - ) await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, vacuum.DOMAIN, data + hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - config = { - "platform": "mqtt", - "schema": "state", - "name": "Test 1", - "command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - await help_test_entity_device_info_update(hass, mqtt_mock, vacuum.DOMAIN, config) + await help_test_entity_device_info_update( + hass, mqtt_mock, vacuum.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO + ) async def test_entity_id_update(hass, mqtt_mock): diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py index cfb5c3598b2..983d91f08a2 100644 --- a/tests/components/mqtt/test_switch.py +++ b/tests/components/mqtt/test_switch.py @@ -1,6 +1,4 @@ """The tests for the MQTT switch platform.""" -import json - from asynctest import patch import pytest @@ -31,6 +29,31 @@ from .common import ( from tests.common import async_fire_mqtt_message, async_mock_mqtt_component, mock_coro from tests.components.switch import common +DEFAULT_CONFIG_ATTR = { + switch.DOMAIN: { + "platform": "mqtt", + "name": "test", + "command_topic": "test-topic", + "json_attributes_topic": "attr-topic", + } +} + +DEFAULT_CONFIG_DEVICE_INFO = { + "platform": "mqtt", + "name": "Test 1", + "state_topic": "test-topic", + "command_topic": "test-command-topic", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + "unique_id": "veryunique", +} + @pytest.fixture def mock_publish(hass): @@ -271,58 +294,34 @@ async def test_custom_state_payload(hass, mock_publish): async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock): """Test the setting of attribute via MQTT with JSON payload.""" - config = { - switch.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_setting_attribute_via_mqtt_json_message( - hass, mqtt_mock, switch.DOMAIN, config + hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - switch.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_not_dict( - hass, mqtt_mock, caplog, switch.DOMAIN, config + hass, mqtt_mock, caplog, switch.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog): """Test attributes get extracted from a JSON result.""" - config = { - switch.DOMAIN: { - "platform": "mqtt", - "name": "test", - "command_topic": "test-topic", - "json_attributes_topic": "attr-topic", - } - } await help_test_update_with_json_attrs_bad_JSON( - hass, mqtt_mock, caplog, switch.DOMAIN, config + hass, mqtt_mock, caplog, switch.DOMAIN, DEFAULT_CONFIG_ATTR ) async def test_discovery_update_attr(hass, mqtt_mock, caplog): """Test update of discovered MQTTAttributes.""" data1 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic1" }' ) data2 = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "command_topic": "test_topic",' ' "json_attributes_topic": "attr-topic2" }' ) @@ -357,7 +356,7 @@ async def test_unique_id(hass): async def test_discovery_removal_switch(hass, mqtt_mock, caplog): """Test removal of discovered switch.""" data = ( - '{ "name": "Beer",' + '{ "name": "test",' ' "state_topic": "test_topic",' ' "command_topic": "test_topic" }' ) @@ -396,46 +395,16 @@ async def test_discovery_broken(hass, mqtt_mock, caplog): async def test_entity_device_info_with_identifier(hass, mqtt_mock): """Test MQTT switch device registry integration.""" - data = json.dumps( - { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - ) await help_test_entity_device_info_with_identifier( - hass, mqtt_mock, switch.DOMAIN, data + hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO ) async def test_entity_device_info_update(hass, mqtt_mock): """Test device registry update.""" - config = { - "platform": "mqtt", - "name": "Test 1", - "state_topic": "test-topic", - "command_topic": "test-command-topic", - "device": { - "identifiers": ["helloworld"], - "connections": [["mac", "02:5b:26:a8:dc:12"]], - "manufacturer": "Whatever", - "name": "Beer", - "model": "Glass", - "sw_version": "0.1-beta", - }, - "unique_id": "veryunique", - } - await help_test_entity_device_info_update(hass, mqtt_mock, switch.DOMAIN, config) + await help_test_entity_device_info_update( + hass, mqtt_mock, switch.DOMAIN, DEFAULT_CONFIG_DEVICE_INFO + ) async def test_entity_id_update(hass, mqtt_mock): From 44c774335197d63a752ba08dbdf713741310b1aa Mon Sep 17 00:00:00 2001 From: jjlawren Date: Wed, 11 Mar 2020 11:37:02 -0500 Subject: [PATCH 347/416] Rewrite and add Plex tests (#32633) * Rewrite and add Plex tests * Remove unnecessary mocks * Explicitly import constants for readability --- .coveragerc | 2 - homeassistant/components/plex/media_player.py | 1 + tests/components/plex/const.py | 52 +++ tests/components/plex/mock_classes.py | 235 ++++++++---- tests/components/plex/test_config_flow.py | 351 +++++++----------- tests/components/plex/test_init.py | 302 +++++++++++++++ tests/components/plex/test_server.py | 134 +++++++ 7 files changed, 788 insertions(+), 289 deletions(-) create mode 100644 tests/components/plex/const.py create mode 100644 tests/components/plex/test_init.py create mode 100644 tests/components/plex/test_server.py diff --git a/.coveragerc b/.coveragerc index f1f1f0e6222..dd89a4dfd26 100644 --- a/.coveragerc +++ b/.coveragerc @@ -542,10 +542,8 @@ omit = homeassistant/components/pioneer/media_player.py homeassistant/components/pjlink/media_player.py homeassistant/components/plaato/* - homeassistant/components/plex/__init__.py homeassistant/components/plex/media_player.py homeassistant/components/plex/sensor.py - homeassistant/components/plex/server.py homeassistant/components/plugwise/* homeassistant/components/plum_lightpad/* homeassistant/components/pocketcasts/sensor.py diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 0599837aa80..1be06876baf 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -333,6 +333,7 @@ class PlexMediaPlayer(MediaPlayerDevice): def force_idle(self): """Force client to idle.""" + self._player_state = STATE_IDLE self._state = STATE_IDLE self.session = None self._clear_media_details() diff --git a/tests/components/plex/const.py b/tests/components/plex/const.py new file mode 100644 index 00000000000..0f91a9da23f --- /dev/null +++ b/tests/components/plex/const.py @@ -0,0 +1,52 @@ +"""Constants used by Plex tests.""" +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.components.plex import const +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + CONF_TOKEN, + CONF_URL, + CONF_VERIFY_SSL, +) + +MOCK_SERVERS = [ + { + CONF_HOST: "1.2.3.4", + CONF_PORT: 32400, + const.CONF_SERVER: "Plex Server 1", + const.CONF_SERVER_IDENTIFIER: "unique_id_123", + }, + { + CONF_HOST: "4.3.2.1", + CONF_PORT: 32400, + const.CONF_SERVER: "Plex Server 2", + const.CONF_SERVER_IDENTIFIER: "unique_id_456", + }, +] + +MOCK_USERS = { + "Owner": {"enabled": True}, + "b": {"enabled": True}, + "c": {"enabled": True}, +} + +MOCK_TOKEN = "secret_token" + +DEFAULT_DATA = { + const.CONF_SERVER: MOCK_SERVERS[0][const.CONF_SERVER], + const.PLEX_SERVER_CONFIG: { + const.CONF_CLIENT_IDENTIFIER: "00000000-0000-0000-0000-000000000000", + CONF_TOKEN: MOCK_TOKEN, + CONF_URL: f"https://{MOCK_SERVERS[0][CONF_HOST]}:{MOCK_SERVERS[0][CONF_PORT]}", + CONF_VERIFY_SSL: True, + }, + const.CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][const.CONF_SERVER_IDENTIFIER], +} + +DEFAULT_OPTIONS = { + MP_DOMAIN: { + const.CONF_IGNORE_NEW_SHARED_USERS: False, + const.CONF_MONITORED_USERS: MOCK_USERS, + const.CONF_USE_EPISODE_ART: False, + } +} diff --git a/tests/components/plex/mock_classes.py b/tests/components/plex/mock_classes.py index 6e61dfac3ab..9b59190173f 100644 --- a/tests/components/plex/mock_classes.py +++ b/tests/components/plex/mock_classes.py @@ -1,29 +1,12 @@ """Mock classes used in tests.""" -import itertools +from homeassistant.components.plex.const import ( + CONF_SERVER, + CONF_SERVER_IDENTIFIER, + PLEX_SERVER_CONFIG, +) +from homeassistant.const import CONF_URL -from homeassistant.components.plex.const import CONF_SERVER, CONF_SERVER_IDENTIFIER -from homeassistant.const import CONF_HOST, CONF_PORT - -MOCK_SERVERS = [ - { - CONF_HOST: "1.2.3.4", - CONF_PORT: 32400, - CONF_SERVER: "Plex Server 1", - CONF_SERVER_IDENTIFIER: "unique_id_123", - }, - { - CONF_HOST: "4.3.2.1", - CONF_PORT: 32400, - CONF_SERVER: "Plex Server 2", - CONF_SERVER_IDENTIFIER: "unique_id_456", - }, -] - -MOCK_MONITORED_USERS = { - "a": {"enabled": True}, - "b": {"enabled": False}, - "c": {"enabled": True}, -} +from .const import DEFAULT_DATA, MOCK_SERVERS, MOCK_USERS class MockResource: @@ -64,10 +47,11 @@ class MockPlexAccount: class MockPlexSystemAccount: """Mock a PlexSystemAccount instance.""" - def __init__(self): + def __init__(self, index): """Initialize the object.""" - self.name = "Dummy" - self.accountID = 1 + # Start accountIDs at 1 to set proper owner. + self.name = list(MOCK_USERS)[index] + self.accountID = index + 1 class MockPlexServer: @@ -76,68 +60,179 @@ class MockPlexServer: def __init__( self, index=0, - ssl=True, - load_users=True, - num_users=len(MOCK_MONITORED_USERS), - ignore_new_users=False, + config_entry=None, + num_users=len(MOCK_USERS), + session_type="video", ): """Initialize the object.""" - host = MOCK_SERVERS[index][CONF_HOST] - port = MOCK_SERVERS[index][CONF_PORT] - self.friendlyName = MOCK_SERVERS[index][ # pylint: disable=invalid-name - CONF_SERVER + if config_entry: + self._data = config_entry.data + else: + self._data = DEFAULT_DATA + + self._baseurl = self._data[PLEX_SERVER_CONFIG][CONF_URL] + self.friendlyName = self._data[CONF_SERVER] + self.machineIdentifier = self._data[CONF_SERVER_IDENTIFIER] + + self._systemAccounts = list(map(MockPlexSystemAccount, range(num_users))) + + self._clients = [] + self._sessions = [] + self.set_clients(num_users) + self.set_sessions(num_users, session_type) + + 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)] + + def set_sessions(self, num_sessions, session_type): + """Set up mock PlexSessions for this PlexServer.""" + self._sessions = [ + MockPlexSession(self._clients[x], mediatype=session_type, index=x) + for x in range(num_sessions) ] - self.machineIdentifier = MOCK_SERVERS[index][ # pylint: disable=invalid-name - CONF_SERVER_IDENTIFIER - ] - prefix = "https" if ssl else "http" - self._baseurl = f"{prefix}://{host}:{port}" - self._systemAccount = MockPlexSystemAccount() - self._ignore_new_users = ignore_new_users - self._load_users = load_users - self._num_users = num_users + + def clear_clients(self): + """Clear all active PlexClients.""" + self._clients = [] + + def clear_sessions(self): + """Clear all active PlexSessions.""" + self._sessions = [] + + def clients(self): + """Mock the clients method.""" + return self._clients + + def sessions(self): + """Mock the sessions method.""" + return self._sessions def systemAccounts(self): """Mock the systemAccounts lookup method.""" - return [self._systemAccount] + return self._systemAccounts + + def url(self, path, includeToken=False): + """Mock method to generate a server URL.""" + return f"{self._baseurl}{path}" @property def accounts(self): """Mock the accounts property.""" - return set(["a", "b", "c"]) - - @property - def owner(self): - """Mock the owner property.""" - return "a" - - @property - def url_in_use(self): - """Return URL used by PlexServer.""" - return self._baseurl + return set(MOCK_USERS) @property def version(self): """Mock version of PlexServer.""" return "1.0" - @property - def option_monitored_users(self): - """Mock loaded config option for monitored users.""" - userdict = dict(itertools.islice(MOCK_MONITORED_USERS.items(), self._num_users)) - return userdict if self._load_users else {} + +class MockPlexClient: + """Mock a PlexClient instance.""" + + def __init__(self, url, index=0): + """Initialize the object.""" + self.machineIdentifier = f"client-{index+1}" + self._baseurl = url + + def url(self, key): + """Mock the url method.""" + return f"{self._baseurl}{key}" @property - def option_ignore_new_shared_users(self): - """Mock loaded config option for ignoring new users.""" - return self._ignore_new_users + def device(self): + """Mock the device attribute.""" + return "DEVICE" @property - def option_show_all_controls(self): - """Mock loaded config option for showing all controls.""" - return False + def platform(self): + """Mock the platform attribute.""" + return "PLATFORM" @property - def option_use_episode_art(self): - """Mock loaded config option for using episode art.""" - return False + def product(self): + """Mock the product attribute.""" + return "PRODUCT" + + @property + def protocolCapabilities(self): + """Mock the protocolCapabilities attribute.""" + return ["player"] + + @property + def state(self): + """Mock the state attribute.""" + return "playing" + + @property + def title(self): + """Mock the title attribute.""" + return "TITLE" + + @property + def version(self): + """Mock the version attribute.""" + return "1.0" + + +class MockPlexSession: + """Mock a PlexServer.sessions() instance.""" + + def __init__(self, player, mediatype, index=0): + """Initialize the object.""" + self.TYPE = mediatype + self.usernames = [list(MOCK_USERS)[index]] + self.players = [player] + self._section = MockPlexLibrarySection() + + @property + def duration(self): + """Mock the duration attribute.""" + return 10000000 + + @property + def ratingKey(self): + """Mock the ratingKey attribute.""" + return 123 + + def section(self): + """Mock the section method.""" + return self._section + + @property + def summary(self): + """Mock the summary attribute.""" + return "SUMMARY" + + @property + def thumbUrl(self): + """Mock the thumbUrl attribute.""" + return "http://1.2.3.4/thumb" + + @property + def title(self): + """Mock the title attribute.""" + return "TITLE" + + @property + def type(self): + """Mock the type attribute.""" + return "movie" + + @property + def viewOffset(self): + """Mock the viewOffset attribute.""" + return 0 + + @property + def year(self): + """Mock the year attribute.""" + return 2020 + + +class MockPlexLibrarySection: + """Mock a Plex LibrarySection instance.""" + + def __init__(self, library="Movies"): + """Initialize the object.""" + self.title = library diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py index c131a123dc9..d839ccc674b 100644 --- a/tests/components/plex/test_config_flow.py +++ b/tests/components/plex/test_config_flow.py @@ -1,55 +1,46 @@ """Tests for Plex config flow.""" import copy -from unittest.mock import patch -import asynctest +from asynctest import patch import plexapi.exceptions import requests.exceptions +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.plex import config_flow +from homeassistant.components.plex.const import ( + CONF_IGNORE_NEW_SHARED_USERS, + CONF_MONITORED_USERS, + CONF_SERVER, + CONF_SERVER_IDENTIFIER, + CONF_USE_EPISODE_ART, + DOMAIN, + PLEX_SERVER_CONFIG, + PLEX_UPDATE_PLATFORMS_SIGNAL, + SERVERS, +) +from homeassistant.config_entries import ENTRY_STATE_LOADED from homeassistant.const import CONF_HOST, CONF_PORT, CONF_TOKEN, CONF_URL +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component -from .mock_classes import MOCK_SERVERS, MockPlexAccount, MockPlexServer +from .const import DEFAULT_DATA, DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN +from .mock_classes import MockPlexAccount, MockPlexServer from tests.common import MockConfigEntry -MOCK_TOKEN = "secret_token" -MOCK_FILE_CONTENTS = { - f"{MOCK_SERVERS[0][CONF_HOST]}:{MOCK_SERVERS[0][CONF_PORT]}": { - "ssl": False, - "token": MOCK_TOKEN, - "verify": True, - } -} - -DEFAULT_OPTIONS = { - config_flow.MP_DOMAIN: { - config_flow.CONF_USE_EPISODE_ART: False, - config_flow.CONF_IGNORE_NEW_SHARED_USERS: False, - } -} - - -def init_config_flow(hass): - """Init a configuration flow.""" - flow = config_flow.PlexFlowHandler() - flow.hass = hass - return flow - async def test_bad_credentials(hass): """Test when provided credentials are rejected.""" result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) assert result["type"] == "form" assert result["step_id"] == "start_website_auth" with patch( "plexapi.myplex.MyPlexAccount", side_effect=plexapi.exceptions.Unauthorized - ), asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch( + ), patch("plexauth.PlexAuth.initiate_auth"), patch( "plexauth.PlexAuth.token", return_value="BAD TOKEN" ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -72,7 +63,7 @@ async def test_import_success(hass): with patch("plexapi.server.PlexServer", return_value=mock_plex_server): result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, context={"source": "import"}, data={ CONF_TOKEN: MOCK_TOKEN, @@ -82,16 +73,10 @@ async def test_import_success(hass): assert result["type"] == "create_entry" assert result["title"] == mock_plex_server.friendlyName - assert result["data"][config_flow.CONF_SERVER] == mock_plex_server.friendlyName - assert ( - result["data"][config_flow.CONF_SERVER_IDENTIFIER] - == mock_plex_server.machineIdentifier - ) - assert ( - result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL] - == mock_plex_server.url_in_use - ) - assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + assert result["data"][CONF_SERVER] == mock_plex_server.friendlyName + assert result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machineIdentifier + assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server._baseurl + assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN async def test_import_bad_hostname(hass): @@ -101,7 +86,7 @@ async def test_import_bad_hostname(hass): "plexapi.server.PlexServer", side_effect=requests.exceptions.ConnectionError ): result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, context={"source": "import"}, data={ CONF_TOKEN: MOCK_TOKEN, @@ -116,14 +101,14 @@ async def test_unknown_exception(hass): """Test when an unknown exception is encountered.""" result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) assert result["type"] == "form" assert result["step_id"] == "start_website_auth" - with patch("plexapi.myplex.MyPlexAccount", side_effect=Exception), asynctest.patch( + with patch("plexapi.myplex.MyPlexAccount", side_effect=Exception), patch( "plexauth.PlexAuth.initiate_auth" - ), asynctest.patch("plexauth.PlexAuth.token", return_value="MOCK_TOKEN"): + ), patch("plexauth.PlexAuth.token", return_value="MOCK_TOKEN"): result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == "external" @@ -141,14 +126,14 @@ async def test_no_servers_found(hass): await async_setup_component(hass, "http", {"http": {}}) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) assert result["type"] == "form" assert result["step_id"] == "start_website_auth" with patch( "plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount(servers=0) - ), asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch( + ), patch("plexauth.PlexAuth.initiate_auth"), patch( "plexauth.PlexAuth.token", return_value=MOCK_TOKEN ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -171,14 +156,14 @@ async def test_single_available_server(hass): await async_setup_component(hass, "http", {"http": {}}) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) assert result["type"] == "form" assert result["step_id"] == "start_website_auth" with patch("plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount()), patch( "plexapi.server.PlexServer", return_value=mock_plex_server - ), asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch( + ), patch("plexauth.PlexAuth.initiate_auth"), patch( "plexauth.PlexAuth.token", return_value=MOCK_TOKEN ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -190,16 +175,12 @@ async def test_single_available_server(hass): result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == "create_entry" assert result["title"] == mock_plex_server.friendlyName - assert result["data"][config_flow.CONF_SERVER] == mock_plex_server.friendlyName + assert result["data"][CONF_SERVER] == mock_plex_server.friendlyName assert ( - result["data"][config_flow.CONF_SERVER_IDENTIFIER] - == mock_plex_server.machineIdentifier + result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machineIdentifier ) - assert ( - result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL] - == mock_plex_server.url_in_use - ) - assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server._baseurl + assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN async def test_multiple_servers_with_selection(hass): @@ -210,18 +191,16 @@ async def test_multiple_servers_with_selection(hass): await async_setup_component(hass, "http", {"http": {}}) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) assert result["type"] == "form" assert result["step_id"] == "start_website_auth" with patch( "plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount(servers=2) - ), patch( - "plexapi.server.PlexServer", return_value=mock_plex_server - ), asynctest.patch( + ), patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( "plexauth.PlexAuth.initiate_auth" - ), asynctest.patch( + ), patch( "plexauth.PlexAuth.token", return_value=MOCK_TOKEN ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -235,23 +214,16 @@ 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={ - config_flow.CONF_SERVER: MOCK_SERVERS[0][config_flow.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 - assert result["data"][config_flow.CONF_SERVER] == mock_plex_server.friendlyName + assert result["data"][CONF_SERVER] == mock_plex_server.friendlyName assert ( - result["data"][config_flow.CONF_SERVER_IDENTIFIER] - == mock_plex_server.machineIdentifier + result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machineIdentifier ) - assert ( - result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL] - == mock_plex_server.url_in_use - ) - assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server._baseurl + assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN async def test_adding_last_unconfigured_server(hass): @@ -262,28 +234,24 @@ async def test_adding_last_unconfigured_server(hass): await async_setup_component(hass, "http", {"http": {}}) MockConfigEntry( - domain=config_flow.DOMAIN, + domain=DOMAIN, data={ - config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVERS[1][ - config_flow.CONF_SERVER_IDENTIFIER - ], - config_flow.CONF_SERVER: MOCK_SERVERS[1][config_flow.CONF_SERVER], + CONF_SERVER_IDENTIFIER: MOCK_SERVERS[1][CONF_SERVER_IDENTIFIER], + CONF_SERVER: MOCK_SERVERS[1][CONF_SERVER], }, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) assert result["type"] == "form" assert result["step_id"] == "start_website_auth" with patch( "plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount(servers=2) - ), patch( - "plexapi.server.PlexServer", return_value=mock_plex_server - ), asynctest.patch( + ), patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( "plexauth.PlexAuth.initiate_auth" - ), asynctest.patch( + ), patch( "plexauth.PlexAuth.token", return_value=MOCK_TOKEN ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -295,16 +263,12 @@ async def test_adding_last_unconfigured_server(hass): result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["type"] == "create_entry" assert result["title"] == mock_plex_server.friendlyName - assert result["data"][config_flow.CONF_SERVER] == mock_plex_server.friendlyName + assert result["data"][CONF_SERVER] == mock_plex_server.friendlyName assert ( - result["data"][config_flow.CONF_SERVER_IDENTIFIER] - == mock_plex_server.machineIdentifier + result["data"][CONF_SERVER_IDENTIFIER] == mock_plex_server.machineIdentifier ) - assert ( - result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL] - == mock_plex_server.url_in_use - ) - assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + assert result["data"][PLEX_SERVER_CONFIG][CONF_URL] == mock_plex_server._baseurl + assert result["data"][PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN async def test_already_configured(hass): @@ -313,23 +277,19 @@ async def test_already_configured(hass): mock_plex_server = MockPlexServer() MockConfigEntry( - domain=config_flow.DOMAIN, + domain=DOMAIN, data={ - config_flow.CONF_SERVER: MOCK_SERVERS[0][config_flow.CONF_SERVER], - config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][ - config_flow.CONF_SERVER_IDENTIFIER - ], + CONF_SERVER: MOCK_SERVERS[0][CONF_SERVER], + CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][CONF_SERVER_IDENTIFIER], }, - unique_id=MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER], + unique_id=MOCK_SERVERS[0][CONF_SERVER_IDENTIFIER], ).add_to_hass(hass) - with patch( - "plexapi.server.PlexServer", return_value=mock_plex_server - ), asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch( - "plexauth.PlexAuth.token", return_value=MOCK_TOKEN - ): + with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( + "plexauth.PlexAuth.initiate_auth" + ), patch("plexauth.PlexAuth.token", return_value=MOCK_TOKEN): result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, + DOMAIN, context={"source": "import"}, data={ CONF_TOKEN: MOCK_TOKEN, @@ -346,34 +306,30 @@ async def test_all_available_servers_configured(hass): await async_setup_component(hass, "http", {"http": {}}) MockConfigEntry( - domain=config_flow.DOMAIN, + domain=DOMAIN, data={ - config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][ - config_flow.CONF_SERVER_IDENTIFIER - ], - config_flow.CONF_SERVER: MOCK_SERVERS[0][config_flow.CONF_SERVER], + CONF_SERVER_IDENTIFIER: MOCK_SERVERS[0][CONF_SERVER_IDENTIFIER], + CONF_SERVER: MOCK_SERVERS[0][CONF_SERVER], }, ).add_to_hass(hass) MockConfigEntry( - domain=config_flow.DOMAIN, + domain=DOMAIN, data={ - config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVERS[1][ - config_flow.CONF_SERVER_IDENTIFIER - ], - config_flow.CONF_SERVER: MOCK_SERVERS[1][config_flow.CONF_SERVER], + CONF_SERVER_IDENTIFIER: MOCK_SERVERS[1][CONF_SERVER_IDENTIFIER], + CONF_SERVER: MOCK_SERVERS[1][CONF_SERVER], }, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) assert result["type"] == "form" assert result["step_id"] == "start_website_auth" with patch( "plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount(servers=2) - ), asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch( + ), patch("plexauth.PlexAuth.initiate_auth"), patch( "plexauth.PlexAuth.token", return_value=MOCK_TOKEN ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -389,20 +345,26 @@ async def test_all_available_servers_configured(hass): async def test_option_flow(hass): """Test config options flow selection.""" - - mock_plex_server = MockPlexServer(load_users=False) - - MOCK_SERVER_ID = MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER] - hass.data[config_flow.DOMAIN] = { - config_flow.SERVERS: {MOCK_SERVER_ID: mock_plex_server} - } + mock_plex_server = MockPlexServer() entry = MockConfigEntry( - domain=config_flow.DOMAIN, - data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVER_ID}, + domain=DOMAIN, + data=DEFAULT_DATA, options=DEFAULT_OPTIONS, + unique_id=DEFAULT_DATA["server_id"], ) - entry.add_to_hass(hass) + + with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( + "homeassistant.components.plex.PlexWebsocket.listen" + ) as mock_listen: + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert mock_listen.called + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_LOADED result = await hass.config_entries.options.async_init( entry.entry_id, context={"source": "test"}, data=None @@ -413,112 +375,69 @@ async def test_option_flow(hass): result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={ - config_flow.CONF_USE_EPISODE_ART: True, - config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, - config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts), + CONF_USE_EPISODE_ART: True, + CONF_IGNORE_NEW_SHARED_USERS: True, + CONF_MONITORED_USERS: list(mock_plex_server.accounts), }, ) assert result["type"] == "create_entry" assert result["data"] == { - config_flow.MP_DOMAIN: { - config_flow.CONF_USE_EPISODE_ART: True, - config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, - config_flow.CONF_MONITORED_USERS: { + MP_DOMAIN: { + CONF_USE_EPISODE_ART: True, + CONF_IGNORE_NEW_SHARED_USERS: True, + CONF_MONITORED_USERS: { user: {"enabled": True} for user in mock_plex_server.accounts }, } } -async def test_option_flow_loading_saved_users(hass): - """Test config options flow selection when loading existing user config.""" +async def test_option_flow_new_users_available(hass, caplog): + """Test config options multiselect defaults when new Plex users are seen.""" - mock_plex_server = MockPlexServer(load_users=True) - - MOCK_SERVER_ID = MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER] - hass.data[config_flow.DOMAIN] = { - config_flow.SERVERS: {MOCK_SERVER_ID: mock_plex_server} - } + OPTIONS_OWNER_ONLY = copy.deepcopy(DEFAULT_OPTIONS) + OPTIONS_OWNER_ONLY[MP_DOMAIN][CONF_MONITORED_USERS] = {"Owner": {"enabled": True}} entry = MockConfigEntry( - domain=config_flow.DOMAIN, - data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVER_ID}, - options=DEFAULT_OPTIONS, + domain=DOMAIN, + data=DEFAULT_DATA, + options=OPTIONS_OWNER_ONLY, + unique_id=DEFAULT_DATA["server_id"], ) - entry.add_to_hass(hass) + + mock_plex_server = MockPlexServer(config_entry=entry) + + with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( + "homeassistant.components.plex.PlexWebsocket.listen" + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + server_id = mock_plex_server.machineIdentifier + + async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) + await hass.async_block_till_done() + + monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users + + new_users = [x for x in mock_plex_server.accounts if x not in monitored_users] + assert len(monitored_users) == 1 + assert len(new_users) == 2 + + sensor = hass.states.get("sensor.plex_plex_server_1") + assert sensor.state == str(len(mock_plex_server.accounts)) result = await hass.config_entries.options.async_init( entry.entry_id, context={"source": "test"}, data=None ) assert result["type"] == "form" assert result["step_id"] == "plex_mp_settings" + multiselect_defaults = result["data_schema"].schema["monitored_users"].options - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - config_flow.CONF_USE_EPISODE_ART: True, - config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, - config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts), - }, - ) - assert result["type"] == "create_entry" - assert result["data"] == { - config_flow.MP_DOMAIN: { - config_flow.CONF_USE_EPISODE_ART: True, - config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, - config_flow.CONF_MONITORED_USERS: { - user: {"enabled": True} for user in mock_plex_server.accounts - }, - } - } - - -async def test_option_flow_new_users_available(hass): - """Test config options flow selection when new Plex accounts available.""" - - mock_plex_server = MockPlexServer(load_users=True, num_users=2) - - MOCK_SERVER_ID = MOCK_SERVERS[0][config_flow.CONF_SERVER_IDENTIFIER] - hass.data[config_flow.DOMAIN] = { - config_flow.SERVERS: {MOCK_SERVER_ID: mock_plex_server} - } - - OPTIONS_WITH_USERS = copy.deepcopy(DEFAULT_OPTIONS) - OPTIONS_WITH_USERS[config_flow.MP_DOMAIN][config_flow.CONF_MONITORED_USERS] = { - "a": {"enabled": True} - } - - entry = MockConfigEntry( - domain=config_flow.DOMAIN, - data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_SERVER_ID}, - options=OPTIONS_WITH_USERS, - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.options.async_init( - entry.entry_id, context={"source": "test"}, data=None - ) - assert result["type"] == "form" - assert result["step_id"] == "plex_mp_settings" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - config_flow.CONF_USE_EPISODE_ART: True, - config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, - config_flow.CONF_MONITORED_USERS: list(mock_plex_server.accounts), - }, - ) - assert result["type"] == "create_entry" - assert result["data"] == { - config_flow.MP_DOMAIN: { - config_flow.CONF_USE_EPISODE_ART: True, - config_flow.CONF_IGNORE_NEW_SHARED_USERS: True, - config_flow.CONF_MONITORED_USERS: { - user: {"enabled": True} for user in mock_plex_server.accounts - }, - } - } + assert "[Owner]" in multiselect_defaults["Owner"] + for user in new_users: + assert "[New]" in multiselect_defaults[user] async def test_external_timed_out(hass): @@ -527,12 +446,12 @@ async def test_external_timed_out(hass): await async_setup_component(hass, "http", {"http": {}}) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) assert result["type"] == "form" assert result["step_id"] == "start_website_auth" - with asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch( + with patch("plexauth.PlexAuth.initiate_auth"), patch( "plexauth.PlexAuth.token", return_value=None ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -552,12 +471,12 @@ async def test_callback_view(hass, aiohttp_client): await async_setup_component(hass, "http", {"http": {}}) result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, context={"source": "user"} + DOMAIN, context={"source": "user"} ) assert result["type"] == "form" assert result["step_id"] == "start_website_auth" - with asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch( + with patch("plexauth.PlexAuth.initiate_auth"), patch( "plexauth.PlexAuth.token", return_value=MOCK_TOKEN ): result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -575,13 +494,11 @@ async def test_multiple_servers_with_import(hass): with patch( "plexapi.myplex.MyPlexAccount", return_value=MockPlexAccount(servers=2) - ), asynctest.patch("plexauth.PlexAuth.initiate_auth"), asynctest.patch( + ), patch("plexauth.PlexAuth.initiate_auth"), patch( "plexauth.PlexAuth.token", return_value=MOCK_TOKEN ): result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - context={"source": "import"}, - data={CONF_TOKEN: MOCK_TOKEN}, + DOMAIN, context={"source": "import"}, data={CONF_TOKEN: MOCK_TOKEN}, ) assert result["type"] == "abort" assert result["reason"] == "non-interactive" diff --git a/tests/components/plex/test_init.py b/tests/components/plex/test_init.py new file mode 100644 index 00000000000..3358ac1c2cb --- /dev/null +++ b/tests/components/plex/test_init.py @@ -0,0 +1,302 @@ +"""Tests for Plex setup.""" +import copy +from datetime import timedelta + +from asynctest import patch +import plexapi +import requests + +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +import homeassistant.components.plex.const as const +from homeassistant.config_entries import ( + ENTRY_STATE_LOADED, + ENTRY_STATE_NOT_LOADED, + ENTRY_STATE_SETUP_ERROR, + ENTRY_STATE_SETUP_RETRY, +) +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_TOKEN, + CONF_VERIFY_SSL, +) +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from .const import DEFAULT_DATA, DEFAULT_OPTIONS, MOCK_SERVERS, MOCK_TOKEN +from .mock_classes import MockPlexServer + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_setup_with_config(hass): + """Test setup component with config.""" + config = { + const.DOMAIN: { + CONF_HOST: MOCK_SERVERS[0][CONF_HOST], + CONF_PORT: MOCK_SERVERS[0][CONF_PORT], + CONF_TOKEN: MOCK_TOKEN, + CONF_SSL: True, + CONF_VERIFY_SSL: True, + MP_DOMAIN: { + const.CONF_IGNORE_NEW_SHARED_USERS: False, + const.CONF_USE_EPISODE_ART: False, + }, + }, + } + + mock_plex_server = MockPlexServer() + + with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( + "homeassistant.components.plex.PlexWebsocket.listen" + ) as mock_listen: + assert await async_setup_component(hass, const.DOMAIN, config) is True + await hass.async_block_till_done() + + assert mock_listen.called + assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1 + entry = hass.config_entries.async_entries(const.DOMAIN)[0] + assert entry.state == ENTRY_STATE_LOADED + + server_id = mock_plex_server.machineIdentifier + loaded_server = hass.data[const.DOMAIN][const.SERVERS][server_id] + + assert loaded_server.plex_server == mock_plex_server + + assert server_id in hass.data[const.DOMAIN][const.DISPATCHERS] + assert server_id in hass.data[const.DOMAIN][const.WEBSOCKETS] + assert ( + hass.data[const.DOMAIN][const.PLATFORMS_COMPLETED][server_id] == const.PLATFORMS + ) + + +async def test_setup_with_config_entry(hass): + """Test setup component with config.""" + + mock_plex_server = MockPlexServer() + + entry = MockConfigEntry( + domain=const.DOMAIN, + data=DEFAULT_DATA, + options=DEFAULT_OPTIONS, + unique_id=DEFAULT_DATA["server_id"], + ) + + with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( + "homeassistant.components.plex.PlexWebsocket.listen" + ) as mock_listen: + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert mock_listen.called + + assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_LOADED + + server_id = mock_plex_server.machineIdentifier + loaded_server = hass.data[const.DOMAIN][const.SERVERS][server_id] + + assert loaded_server.plex_server == mock_plex_server + + assert server_id in hass.data[const.DOMAIN][const.DISPATCHERS] + assert server_id in hass.data[const.DOMAIN][const.WEBSOCKETS] + assert ( + hass.data[const.DOMAIN][const.PLATFORMS_COMPLETED][server_id] == const.PLATFORMS + ) + + async_dispatcher_send(hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.plex_plex_server_1") + assert sensor.state == str(len(mock_plex_server.accounts)) + + async_dispatcher_send(hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) + await hass.async_block_till_done() + + with patch.object( + mock_plex_server, "clients", side_effect=plexapi.exceptions.BadRequest + ): + async_dispatcher_send( + hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id) + ) + await hass.async_block_till_done() + + with patch.object( + mock_plex_server, "clients", side_effect=requests.exceptions.RequestException + ): + async_dispatcher_send( + hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id) + ) + await hass.async_block_till_done() + + +async def test_set_config_entry_unique_id(hass): + """Test updating missing unique_id from config entry.""" + + mock_plex_server = MockPlexServer() + + entry = MockConfigEntry( + domain=const.DOMAIN, data=DEFAULT_DATA, options=DEFAULT_OPTIONS, unique_id=None, + ) + + with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( + "homeassistant.components.plex.PlexWebsocket.listen" + ) as mock_listen: + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert mock_listen.called + + assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_LOADED + + assert ( + hass.config_entries.async_entries(const.DOMAIN)[0].unique_id + == mock_plex_server.machineIdentifier + ) + + +async def test_setup_config_entry_with_error(hass): + """Test setup component from config entry with errors.""" + + entry = MockConfigEntry( + domain=const.DOMAIN, + data=DEFAULT_DATA, + options=DEFAULT_OPTIONS, + unique_id=DEFAULT_DATA["server_id"], + ) + + with patch( + "homeassistant.components.plex.PlexServer.connect", + side_effect=requests.exceptions.ConnectionError, + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) is False + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_SETUP_RETRY + + with patch( + "homeassistant.components.plex.PlexServer.connect", + side_effect=plexapi.exceptions.BadRequest, + ): + next_update = dt_util.utcnow() + timedelta(seconds=30) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_SETUP_ERROR + + +async def test_setup_with_insecure_config_entry(hass): + """Test setup component with config.""" + + mock_plex_server = MockPlexServer() + + INSECURE_DATA = copy.deepcopy(DEFAULT_DATA) + INSECURE_DATA[const.PLEX_SERVER_CONFIG][CONF_VERIFY_SSL] = False + + entry = MockConfigEntry( + domain=const.DOMAIN, + data=INSECURE_DATA, + options=DEFAULT_OPTIONS, + unique_id=DEFAULT_DATA["server_id"], + ) + + with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( + "homeassistant.components.plex.PlexWebsocket.listen" + ) as mock_listen: + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert mock_listen.called + + assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_LOADED + + +async def test_unload_config_entry(hass): + """Test unloading a config entry.""" + mock_plex_server = MockPlexServer() + + entry = MockConfigEntry( + domain=const.DOMAIN, + data=DEFAULT_DATA, + options=DEFAULT_OPTIONS, + unique_id=DEFAULT_DATA["server_id"], + ) + entry.add_to_hass(hass) + + config_entries = hass.config_entries.async_entries(const.DOMAIN) + assert len(config_entries) == 1 + assert entry is config_entries[0] + + with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( + "homeassistant.components.plex.PlexWebsocket.listen" + ) as mock_listen: + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert mock_listen.called + + assert entry.state == ENTRY_STATE_LOADED + + server_id = mock_plex_server.machineIdentifier + loaded_server = hass.data[const.DOMAIN][const.SERVERS][server_id] + + assert loaded_server.plex_server == mock_plex_server + + assert server_id in hass.data[const.DOMAIN][const.DISPATCHERS] + assert server_id in hass.data[const.DOMAIN][const.WEBSOCKETS] + assert ( + hass.data[const.DOMAIN][const.PLATFORMS_COMPLETED][server_id] == const.PLATFORMS + ) + + with patch("homeassistant.components.plex.PlexWebsocket.close") as mock_close: + await hass.config_entries.async_unload(entry.entry_id) + assert mock_close.called + + assert entry.state == ENTRY_STATE_NOT_LOADED + + assert server_id not in hass.data[const.DOMAIN][const.SERVERS] + assert server_id not in hass.data[const.DOMAIN][const.DISPATCHERS] + assert server_id not in hass.data[const.DOMAIN][const.WEBSOCKETS] + + +async def test_setup_with_photo_session(hass): + """Test setup component with config.""" + + mock_plex_server = MockPlexServer(session_type="photo") + + entry = MockConfigEntry( + domain=const.DOMAIN, + data=DEFAULT_DATA, + options=DEFAULT_OPTIONS, + unique_id=DEFAULT_DATA["server_id"], + ) + + with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( + "homeassistant.components.plex.PlexWebsocket.listen" + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(const.DOMAIN)) == 1 + assert entry.state == ENTRY_STATE_LOADED + + server_id = mock_plex_server.machineIdentifier + + async_dispatcher_send(hass, const.PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) + await hass.async_block_till_done() + + media_player = hass.states.get("media_player.plex_product_title") + assert media_player.state == "idle" + + sensor = hass.states.get("sensor.plex_plex_server_1") + assert sensor.state == str(len(mock_plex_server.accounts)) diff --git a/tests/components/plex/test_server.py b/tests/components/plex/test_server.py new file mode 100644 index 00000000000..646a6ded32e --- /dev/null +++ b/tests/components/plex/test_server.py @@ -0,0 +1,134 @@ +"""Tests for Plex server.""" +import copy + +from asynctest import patch + +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.components.plex.const import ( + CONF_IGNORE_NEW_SHARED_USERS, + CONF_MONITORED_USERS, + DOMAIN, + PLEX_UPDATE_PLATFORMS_SIGNAL, + SERVERS, +) +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import DEFAULT_DATA, DEFAULT_OPTIONS +from .mock_classes import MockPlexServer + +from tests.common import MockConfigEntry + + +async def test_new_users_available(hass): + """Test setting up when new users available on Plex server.""" + + MONITORED_USERS = {"Owner": {"enabled": True}} + OPTIONS_WITH_USERS = copy.deepcopy(DEFAULT_OPTIONS) + OPTIONS_WITH_USERS[MP_DOMAIN][CONF_MONITORED_USERS] = MONITORED_USERS + + entry = MockConfigEntry( + domain=DOMAIN, + data=DEFAULT_DATA, + options=OPTIONS_WITH_USERS, + unique_id=DEFAULT_DATA["server_id"], + ) + + mock_plex_server = MockPlexServer(config_entry=entry) + + with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( + "homeassistant.components.plex.PlexWebsocket.listen" + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + server_id = mock_plex_server.machineIdentifier + + async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) + await hass.async_block_till_done() + + monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users + + ignored_users = [x for x in monitored_users if not monitored_users[x]["enabled"]] + assert len(monitored_users) == 1 + assert len(ignored_users) == 0 + + sensor = hass.states.get("sensor.plex_plex_server_1") + assert sensor.state == str(len(mock_plex_server.accounts)) + + +async def test_new_ignored_users_available(hass, caplog): + """Test setting up when new users available on Plex server but are ignored.""" + + MONITORED_USERS = {"Owner": {"enabled": True}} + OPTIONS_WITH_USERS = copy.deepcopy(DEFAULT_OPTIONS) + OPTIONS_WITH_USERS[MP_DOMAIN][CONF_MONITORED_USERS] = MONITORED_USERS + OPTIONS_WITH_USERS[MP_DOMAIN][CONF_IGNORE_NEW_SHARED_USERS] = True + + entry = MockConfigEntry( + domain=DOMAIN, + data=DEFAULT_DATA, + options=OPTIONS_WITH_USERS, + unique_id=DEFAULT_DATA["server_id"], + ) + + mock_plex_server = MockPlexServer(config_entry=entry) + + with patch("plexapi.server.PlexServer", return_value=mock_plex_server), patch( + "homeassistant.components.plex.PlexWebsocket.listen" + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + server_id = mock_plex_server.machineIdentifier + + async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) + await hass.async_block_till_done() + + monitored_users = hass.data[DOMAIN][SERVERS][server_id].option_monitored_users + + ignored_users = [x for x in mock_plex_server.accounts if x not in monitored_users] + assert len(monitored_users) == 1 + assert len(ignored_users) == 2 + for ignored_user in ignored_users: + assert f"Ignoring Plex client owned by {ignored_user}" in caplog.text + + sensor = hass.states.get("sensor.plex_plex_server_1") + assert sensor.state == str(len(mock_plex_server.accounts)) + + +async def test_mark_sessions_idle(hass): + """Test marking media_players as idle when sessions end.""" + 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( + "homeassistant.components.plex.PlexWebsocket.listen" + ): + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + server_id = mock_plex_server.machineIdentifier + + async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.plex_plex_server_1") + assert sensor.state == str(len(mock_plex_server.accounts)) + + mock_plex_server.clear_clients() + mock_plex_server.clear_sessions() + + async_dispatcher_send(hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(server_id)) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.plex_plex_server_1") + assert sensor.state == "0" From d28d1ff6572bc9a8b85317b5e9f7f0a3e12cb6f2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 11 Mar 2020 11:50:16 -0700 Subject: [PATCH 348/416] Add JSON benchmark (#32690) * Add JSON benchmark * Fix logbook benchmarks * Move logbook import back --- homeassistant/scripts/benchmark/__init__.py | 37 +++++++++++++-------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index d28b8ab08f7..2c885dd1713 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -8,6 +8,7 @@ from timeit import default_timer as timer from typing import Callable, Dict from homeassistant import core +from homeassistant.components.websocket_api.const import JSON_DUMP from homeassistant.const import ATTR_NOW, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED from homeassistant.util import dt as dt_util @@ -50,8 +51,8 @@ def benchmark(func): @benchmark -async def async_million_events(hass): - """Run a million events.""" +async def fire_events(hass): + """Fire a million events.""" count = 0 event_name = "benchmark_event" event = asyncio.Event() @@ -78,7 +79,7 @@ async def async_million_events(hass): @benchmark -async def async_million_time_changed_helper(hass): +async def time_changed_helper(hass): """Run a million events through time changed helper.""" count = 0 event = asyncio.Event() @@ -106,7 +107,7 @@ async def async_million_time_changed_helper(hass): @benchmark -async def async_million_state_changed_helper(hass): +async def state_changed_helper(hass): """Run a million events through state changed helper.""" count = 0 entity_id = "light.kitchen" @@ -139,22 +140,19 @@ async def async_million_state_changed_helper(hass): @benchmark -@asyncio.coroutine -def logbook_filtering_state(hass): +async def logbook_filtering_state(hass): """Filter state changes.""" - return _logbook_filtering(hass, 1, 1) + return await _logbook_filtering(hass, 1, 1) @benchmark -@asyncio.coroutine -def logbook_filtering_attributes(hass): +async def logbook_filtering_attributes(hass): """Filter attribute changes.""" - return _logbook_filtering(hass, 1, 2) + return await _logbook_filtering(hass, 1, 2) @benchmark -@asyncio.coroutine -def _logbook_filtering(hass, last_changed, last_updated): +async def _logbook_filtering(hass, last_changed, last_updated): from homeassistant.components import logbook entity_id = "test.entity" @@ -182,7 +180,7 @@ def _logbook_filtering(hass, last_changed, last_updated): start = timer() - list(logbook.humanify(None, yield_events(event))) + list(logbook.humanify(hass, yield_events(event))) return timer() - start @@ -194,3 +192,16 @@ async def valid_entity_id(hass): for _ in range(10 ** 6): core.valid_entity_id("light.kitchen") return timer() - start + + +@benchmark +async def json_serialize_states(hass): + """Serialize million states with websocket default encoder.""" + states = [ + core.State("light.kitchen", "on", {"friendly_name": "Kitchen Lights"}) + for _ in range(10 ** 6) + ] + + start = timer() + JSON_DUMP(states) + return timer() - start From 3e8728ad5f055a7f8cbc03d4d7b5ad3a93e2f16d Mon Sep 17 00:00:00 2001 From: chiefdragon Date: Wed, 11 Mar 2020 18:58:42 +0000 Subject: [PATCH 349/416] =?UTF-8?q?Fix=20issue=20#23758=20-=20Restore=20Ca?= =?UTF-8?q?nary=20sensors=20and=20ensure=20alarm=20con=E2=80=A6=20(#32627)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fixed missing Canary sensors and Canary alarm control panel that was not updating correctly * Resolved pylinting warnings in original tests --- .../components/canary/alarm_control_panel.py | 8 +- homeassistant/components/canary/sensor.py | 17 ++-- tests/components/canary/test_sensor.py | 78 +++++++++---------- 3 files changed, 55 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py index cceb78743d3..35fff8accbd 100644 --- a/homeassistant/components/canary/alarm_control_panel.py +++ b/homeassistant/components/canary/alarm_control_panel.py @@ -49,7 +49,6 @@ class CanaryAlarm(AlarmControlPanel): @property def state(self): """Return the state of the device.""" - location = self._data.get_location(self._location_id) if location.is_private: @@ -82,15 +81,16 @@ class CanaryAlarm(AlarmControlPanel): def alarm_arm_home(self, code=None): """Send arm home command.""" - self._data.set_location_mode(self._location_id, LOCATION_MODE_HOME) def alarm_arm_away(self, code=None): """Send arm away command.""" - self._data.set_location_mode(self._location_id, LOCATION_MODE_AWAY) def alarm_arm_night(self, code=None): """Send arm night command.""" - self._data.set_location_mode(self._location_id, LOCATION_MODE_NIGHT) + + def update(self): + """Get the latest state of the sensor.""" + self._data.update() diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py index 09413f9cb61..88b42d296ed 100644 --- a/homeassistant/components/canary/sensor.py +++ b/homeassistant/components/canary/sensor.py @@ -10,14 +10,21 @@ from . import DATA_CANARY SENSOR_VALUE_PRECISION = 2 ATTR_AIR_QUALITY = "air_quality" +# Define variables to store the device names, as referred to by the Canary API. +# Note: If Canary change the name of any of their devices (which they have done), +# then these variables will need updating, otherwise the sensors will stop working +# and disappear in Home Assistant. +CANARY_PRO = "Canary Pro" +CANARY_FLEX = "Canary Flex" + # Sensor types are defined like so: # sensor type name, unit_of_measurement, icon SENSOR_TYPES = [ - ["temperature", TEMP_CELSIUS, "mdi:thermometer", ["Canary"]], - ["humidity", UNIT_PERCENTAGE, "mdi:water-percent", ["Canary"]], - ["air_quality", None, "mdi:weather-windy", ["Canary"]], - ["wifi", "dBm", "mdi:wifi", ["Canary Flex"]], - ["battery", UNIT_PERCENTAGE, "mdi:battery-50", ["Canary Flex"]], + ["temperature", TEMP_CELSIUS, "mdi:thermometer", [CANARY_PRO]], + ["humidity", UNIT_PERCENTAGE, "mdi:water-percent", [CANARY_PRO]], + ["air_quality", None, "mdi:weather-windy", [CANARY_PRO]], + ["wifi", "dBm", "mdi:wifi", [CANARY_FLEX]], + ["battery", UNIT_PERCENTAGE, "mdi:battery-50", [CANARY_FLEX]], ] STATE_AIR_QUALITY_NORMAL = "normal" diff --git a/tests/components/canary/test_sensor.py b/tests/components/canary/test_sensor.py index b9d8be50b90..6cc33ddf610 100644 --- a/tests/components/canary/test_sensor.py +++ b/tests/components/canary/test_sensor.py @@ -41,9 +41,9 @@ class TestCanarySensorSetup(unittest.TestCase): def test_setup_sensors(self): """Test the sensor setup.""" - online_device_at_home = mock_device(20, "Dining Room", True, "Canary") - offline_device_at_home = mock_device(21, "Front Yard", False, "Canary") - online_device_at_work = mock_device(22, "Office", True, "Canary") + online_device_at_home = mock_device(20, "Dining Room", True, "Canary Pro") + offline_device_at_home = mock_device(21, "Front Yard", False, "Canary Pro") + online_device_at_work = mock_device(22, "Office", True, "Canary Pro") self.hass.data[DATA_CANARY] = Mock() self.hass.data[DATA_CANARY].locations = [ @@ -55,11 +55,11 @@ class TestCanarySensorSetup(unittest.TestCase): canary.setup_platform(self.hass, self.config, self.add_entities, None) - assert 6 == len(self.DEVICES) + assert len(self.DEVICES) == 6 def test_temperature_sensor(self): """Test temperature sensor with fahrenheit.""" - device = mock_device(10, "Family Room", "Canary") + device = mock_device(10, "Family Room", "Canary Pro") location = mock_location("Home", False) data = Mock() @@ -68,14 +68,14 @@ class TestCanarySensorSetup(unittest.TestCase): sensor = CanarySensor(data, SENSOR_TYPES[0], location, device) sensor.update() - assert "Home Family Room Temperature" == sensor.name - assert "°C" == sensor.unit_of_measurement - assert 21.12 == sensor.state - assert "mdi:thermometer" == sensor.icon + assert sensor.name == "Home Family Room Temperature" + assert sensor.unit_of_measurement == "°C" + assert sensor.state == 21.12 + assert sensor.icon == "mdi:thermometer" def test_temperature_sensor_with_none_sensor_value(self): """Test temperature sensor with fahrenheit.""" - device = mock_device(10, "Family Room", "Canary") + device = mock_device(10, "Family Room", "Canary Pro") location = mock_location("Home", False) data = Mock() @@ -88,7 +88,7 @@ class TestCanarySensorSetup(unittest.TestCase): def test_humidity_sensor(self): """Test humidity sensor.""" - device = mock_device(10, "Family Room", "Canary") + device = mock_device(10, "Family Room", "Canary Pro") location = mock_location("Home") data = Mock() @@ -97,14 +97,14 @@ class TestCanarySensorSetup(unittest.TestCase): sensor = CanarySensor(data, SENSOR_TYPES[1], location, device) sensor.update() - assert "Home Family Room Humidity" == sensor.name - assert UNIT_PERCENTAGE == sensor.unit_of_measurement - assert 50.46 == sensor.state - assert "mdi:water-percent" == sensor.icon + assert sensor.name == "Home Family Room Humidity" + assert sensor.unit_of_measurement == UNIT_PERCENTAGE + assert sensor.state == 50.46 + assert sensor.icon == "mdi:water-percent" def test_air_quality_sensor_with_very_abnormal_reading(self): """Test air quality sensor.""" - device = mock_device(10, "Family Room", "Canary") + device = mock_device(10, "Family Room", "Canary Pro") location = mock_location("Home") data = Mock() @@ -113,17 +113,17 @@ class TestCanarySensorSetup(unittest.TestCase): sensor = CanarySensor(data, SENSOR_TYPES[2], location, device) sensor.update() - assert "Home Family Room Air Quality" == sensor.name + assert sensor.name == "Home Family Room Air Quality" assert sensor.unit_of_measurement is None - assert 0.4 == sensor.state - assert "mdi:weather-windy" == sensor.icon + assert sensor.state == 0.4 + assert sensor.icon == "mdi:weather-windy" air_quality = sensor.device_state_attributes[ATTR_AIR_QUALITY] - assert STATE_AIR_QUALITY_VERY_ABNORMAL == air_quality + assert air_quality == STATE_AIR_QUALITY_VERY_ABNORMAL def test_air_quality_sensor_with_abnormal_reading(self): """Test air quality sensor.""" - device = mock_device(10, "Family Room", "Canary") + device = mock_device(10, "Family Room", "Canary Pro") location = mock_location("Home") data = Mock() @@ -132,17 +132,17 @@ class TestCanarySensorSetup(unittest.TestCase): sensor = CanarySensor(data, SENSOR_TYPES[2], location, device) sensor.update() - assert "Home Family Room Air Quality" == sensor.name + assert sensor.name == "Home Family Room Air Quality" assert sensor.unit_of_measurement is None - assert 0.59 == sensor.state - assert "mdi:weather-windy" == sensor.icon + assert sensor.state == 0.59 + assert sensor.icon == "mdi:weather-windy" air_quality = sensor.device_state_attributes[ATTR_AIR_QUALITY] - assert STATE_AIR_QUALITY_ABNORMAL == air_quality + assert air_quality == STATE_AIR_QUALITY_ABNORMAL def test_air_quality_sensor_with_normal_reading(self): """Test air quality sensor.""" - device = mock_device(10, "Family Room", "Canary") + device = mock_device(10, "Family Room", "Canary Pro") location = mock_location("Home") data = Mock() @@ -151,17 +151,17 @@ class TestCanarySensorSetup(unittest.TestCase): sensor = CanarySensor(data, SENSOR_TYPES[2], location, device) sensor.update() - assert "Home Family Room Air Quality" == sensor.name + assert sensor.name == "Home Family Room Air Quality" assert sensor.unit_of_measurement is None - assert 1.0 == sensor.state - assert "mdi:weather-windy" == sensor.icon + assert sensor.state == 1.0 + assert sensor.icon == "mdi:weather-windy" air_quality = sensor.device_state_attributes[ATTR_AIR_QUALITY] - assert STATE_AIR_QUALITY_NORMAL == air_quality + assert air_quality == STATE_AIR_QUALITY_NORMAL def test_air_quality_sensor_with_none_sensor_value(self): """Test air quality sensor.""" - device = mock_device(10, "Family Room", "Canary") + device = mock_device(10, "Family Room", "Canary Pro") location = mock_location("Home") data = Mock() @@ -184,10 +184,10 @@ class TestCanarySensorSetup(unittest.TestCase): sensor = CanarySensor(data, SENSOR_TYPES[4], location, device) sensor.update() - assert "Home Family Room Battery" == sensor.name - assert UNIT_PERCENTAGE == sensor.unit_of_measurement - assert 70.46 == sensor.state - assert "mdi:battery-70" == sensor.icon + assert sensor.name == "Home Family Room Battery" + assert sensor.unit_of_measurement == UNIT_PERCENTAGE + assert sensor.state == 70.46 + assert sensor.icon == "mdi:battery-70" def test_wifi_sensor(self): """Test battery sensor.""" @@ -200,7 +200,7 @@ class TestCanarySensorSetup(unittest.TestCase): sensor = CanarySensor(data, SENSOR_TYPES[3], location, device) sensor.update() - assert "Home Family Room Wifi" == sensor.name - assert "dBm" == sensor.unit_of_measurement - assert -57 == sensor.state - assert "mdi:wifi" == sensor.icon + assert sensor.name == "Home Family Room Wifi" + assert sensor.unit_of_measurement == "dBm" + assert sensor.state == -57 + assert sensor.icon == "mdi:wifi" From c9a9bd16fe90922d1b6fe75fc5daee463bfab9b6 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 11 Mar 2020 20:18:11 +0100 Subject: [PATCH 350/416] Updated frontend to 20200311.1 (#32691) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index c2ae9c0bcfe..ad34c0c112c 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200311.0" + "home-assistant-frontend==20200311.1" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 887fbc99f4c..39c1d76b999 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.32.2 -home-assistant-frontend==20200311.0 +home-assistant-frontend==20200311.1 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 07635c17435..54cbf09e31c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -696,7 +696,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200311.0 +home-assistant-frontend==20200311.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 90c8acb9df0..1c380f93679 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -263,7 +263,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200311.0 +home-assistant-frontend==20200311.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 From bb666b9ac6f9649e5f61c0d82edc593b9cec08c6 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Wed, 11 Mar 2020 14:28:38 -0500 Subject: [PATCH 351/416] Add config flow to directv (#32162) * initial work on config flow. * more work on config flow. * work on config flow and add tests. other cleanup. * cleanup tests. * fix test. * isort * Update .coveragerc * Update test_init.py * Update test_init.py * Update test_init.py * Update test_config_flow.py * Update test_config_flow.py * Update test_config_flow.py * correct upnp serial format. * improve config flow coverage. * review tweaks. * further review tweaks * simplify dtv data gathering job * lint * black * Update test_init.py * Update test_init.py * Simplify exception handling. * Simplify exception handling. * Update media_player.py * Update test_media_player.py * Update test_media_player.py * Update test_media_player.py * Update test_media_player.py * Update test_media_player.py * fix failing test. * restore change made during debug. * isort. Co-authored-by: Paulus Schoutsen --- .coveragerc | 1 - .../components/directv/.translations/en.json | 26 + homeassistant/components/directv/__init__.py | 95 ++- .../components/directv/config_flow.py | 130 ++++ homeassistant/components/directv/const.py | 10 +- .../components/directv/manifest.json | 9 +- .../components/directv/media_player.py | 205 ++++--- homeassistant/components/directv/strings.json | 26 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/ssdp.py | 6 + tests/components/directv/__init__.py | 183 +++++- tests/components/directv/test_config_flow.py | 240 ++++++++ tests/components/directv/test_init.py | 48 ++ tests/components/directv/test_media_player.py | 574 ++++++------------ 14 files changed, 1062 insertions(+), 492 deletions(-) create mode 100644 homeassistant/components/directv/.translations/en.json create mode 100644 homeassistant/components/directv/config_flow.py create mode 100644 homeassistant/components/directv/strings.json create mode 100644 tests/components/directv/test_config_flow.py create mode 100644 tests/components/directv/test_init.py diff --git a/.coveragerc b/.coveragerc index dd89a4dfd26..2716a1fed44 100644 --- a/.coveragerc +++ b/.coveragerc @@ -149,7 +149,6 @@ omit = homeassistant/components/dht/sensor.py homeassistant/components/digital_ocean/* homeassistant/components/digitalloggers/switch.py - homeassistant/components/directv/media_player.py homeassistant/components/discogs/sensor.py homeassistant/components/discord/notify.py homeassistant/components/dlib_face_detect/image_processing.py diff --git a/homeassistant/components/directv/.translations/en.json b/homeassistant/components/directv/.translations/en.json new file mode 100644 index 00000000000..e2a8eff5783 --- /dev/null +++ b/homeassistant/components/directv/.translations/en.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "DirecTV receiver is already configured" + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "unknown": "Unexpected error" + }, + "flow_title": "DirecTV: {name}", + "step": { + "ssdp_confirm": { + "data": {}, + "description": "Do you want to set up {name}?", + "title": "Connect to the DirecTV receiver" + }, + "user": { + "data": { + "host": "Host or IP address" + }, + "title": "Connect to the DirecTV receiver" + } + }, + "title": "DirecTV" + } +} \ No newline at end of file diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py index 5934e1b6c51..d9f3f171992 100644 --- a/homeassistant/components/directv/__init__.py +++ b/homeassistant/components/directv/__init__.py @@ -1 +1,94 @@ -"""The directv component.""" +"""The DirecTV integration.""" +import asyncio +from datetime import timedelta +from typing import Dict + +from DirectPy import DIRECTV +from requests.exceptions import RequestException +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import config_validation as cv + +from .const import DATA_CLIENT, DATA_LOCATIONS, DATA_VERSION_INFO, DEFAULT_PORT, DOMAIN + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.All( + cv.ensure_list, [vol.Schema({vol.Required(CONF_HOST): cv.string})] + ) + }, + extra=vol.ALLOW_EXTRA, +) + +PLATFORMS = ["media_player"] +SCAN_INTERVAL = timedelta(seconds=30) + + +def get_dtv_data( + hass: HomeAssistant, host: str, port: int = DEFAULT_PORT, client_addr: str = "0" +) -> dict: + """Retrieve a DIRECTV instance, locations list, and version info for the receiver device.""" + dtv = DIRECTV(host, port, client_addr) + locations = dtv.get_locations() + version_info = dtv.get_version() + + return { + DATA_CLIENT: dtv, + DATA_LOCATIONS: locations, + DATA_VERSION_INFO: version_info, + } + + +async def async_setup(hass: HomeAssistant, config: Dict) -> bool: + """Set up the DirecTV component.""" + hass.data.setdefault(DOMAIN, {}) + + if DOMAIN in config: + for entry_config in config[DOMAIN]: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=entry_config, + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up DirecTV from a config entry.""" + try: + dtv_data = await hass.async_add_executor_job( + get_dtv_data, hass, entry.data[CONF_HOST] + ) + except RequestException: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = dtv_data + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/directv/config_flow.py b/homeassistant/components/directv/config_flow.py new file mode 100644 index 00000000000..27ddf2cda7b --- /dev/null +++ b/homeassistant/components/directv/config_flow.py @@ -0,0 +1,130 @@ +"""Config flow for DirecTV.""" +import logging +from typing import Any, Dict, Optional +from urllib.parse import urlparse + +from DirectPy import DIRECTV +from requests.exceptions import RequestException +import voluptuous as vol + +from homeassistant.components.ssdp import ATTR_SSDP_LOCATION, ATTR_UPNP_SERIAL +from homeassistant.config_entries import CONN_CLASS_LOCAL_POLL, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_NAME +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError + +from .const import DEFAULT_PORT +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +ERROR_CANNOT_CONNECT = "cannot_connect" +ERROR_UNKNOWN = "unknown" + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str}) + + +def validate_input(data: Dict) -> Dict: + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + # directpy does IO in constructor. + dtv = DIRECTV(data["host"], DEFAULT_PORT) + version_info = dtv.get_version() + + return { + "title": data["host"], + "host": data["host"], + "receiver_id": "".join(version_info["receiverId"].split()), + } + + +class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for DirecTV.""" + + VERSION = 1 + CONNECTION_CLASS = CONN_CLASS_LOCAL_POLL + + @callback + def _show_form(self, errors: Optional[Dict] = None) -> Dict[str, Any]: + """Show the form to the user.""" + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors or {}, + ) + + async def async_step_import( + self, user_input: Optional[Dict] = None + ) -> Dict[str, Any]: + """Handle a flow initialized by yaml file.""" + return await self.async_step_user(user_input) + + async def async_step_user( + self, user_input: Optional[Dict] = None + ) -> Dict[str, Any]: + """Handle a flow initialized by user.""" + if not user_input: + return self._show_form() + + errors = {} + + try: + info = await self.hass.async_add_executor_job(validate_input, user_input) + user_input[CONF_HOST] = info[CONF_HOST] + except RequestException: + errors["base"] = ERROR_CANNOT_CONNECT + return self._show_form(errors) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = ERROR_UNKNOWN + return self._show_form(errors) + + await self.async_set_unique_id(info["receiver_id"]) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=info["title"], data=user_input) + + async def async_step_ssdp( + self, discovery_info: Optional[Dict] = None + ) -> Dict[str, Any]: + """Handle a flow initialized by discovery.""" + host = urlparse(discovery_info[ATTR_SSDP_LOCATION]).hostname + receiver_id = discovery_info[ATTR_UPNP_SERIAL][4:] # strips off RID- + + await self.async_set_unique_id(receiver_id) + self._abort_if_unique_id_configured(updates={CONF_HOST: host}) + + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + self.context.update( + {CONF_HOST: host, CONF_NAME: host, "title_placeholders": {"name": host}} + ) + + return await self.async_step_ssdp_confirm() + + async def async_step_ssdp_confirm( + self, user_input: Optional[Dict] = None + ) -> Dict[str, Any]: + """Handle user-confirmation of discovered device.""" + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + name = self.context.get(CONF_NAME) + + if user_input is not None: + # pylint: disable=no-member # https://github.com/PyCQA/pylint/issues/3167 + user_input[CONF_HOST] = self.context.get(CONF_HOST) + + try: + await self.hass.async_add_executor_job(validate_input, user_input) + return self.async_create_entry(title=name, data=user_input) + except (OSError, RequestException): + return self.async_abort(reason=ERROR_CANNOT_CONNECT) + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return self.async_abort(reason=ERROR_UNKNOWN) + + return self.async_show_form( + step_id="ssdp_confirm", description_placeholders={"name": name}, + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" diff --git a/homeassistant/components/directv/const.py b/homeassistant/components/directv/const.py index 8b3ae08c526..e5b04ce34f6 100644 --- a/homeassistant/components/directv/const.py +++ b/homeassistant/components/directv/const.py @@ -1,12 +1,20 @@ """Constants for the DirecTV integration.""" +DOMAIN = "directv" + ATTR_MEDIA_CURRENTLY_RECORDING = "media_currently_recording" ATTR_MEDIA_RATING = "media_rating" ATTR_MEDIA_RECORDED = "media_recorded" ATTR_MEDIA_START_TIME = "media_start_time" -DATA_DIRECTV = "data_directv" +DATA_CLIENT = "client" +DATA_LOCATIONS = "locations" +DATA_VERSION_INFO = "version_info" DEFAULT_DEVICE = "0" +DEFAULT_MANUFACTURER = "DirecTV" DEFAULT_NAME = "DirecTV Receiver" DEFAULT_PORT = 8080 + +MODEL_HOST = "DirecTV Host" +MODEL_CLIENT = "DirecTV Client" diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json index cfe74153f5c..7e1dffd7435 100644 --- a/homeassistant/components/directv/manifest.json +++ b/homeassistant/components/directv/manifest.json @@ -4,5 +4,12 @@ "documentation": "https://www.home-assistant.io/integrations/directv", "requirements": ["directpy==0.6"], "dependencies": [], - "codeowners": ["@ctalkington"] + "codeowners": ["@ctalkington"], + "config_flow": true, + "ssdp": [ + { + "manufacturer": "DIRECTV", + "deviceType": "urn:schemas-upnp-org:device:MediaServer:1" + } + ] } diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index 673e97a18af..c1c227d319d 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -1,8 +1,9 @@ """Support for the DirecTV receivers.""" import logging +from typing import Callable, Dict, List, Optional from DirectPy import DIRECTV -import requests +from requests.exceptions import RequestException import voluptuous as vol from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice @@ -19,6 +20,7 @@ from homeassistant.components.media_player.const import ( SUPPORT_TURN_OFF, SUPPORT_TURN_ON, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_DEVICE, CONF_HOST, @@ -28,18 +30,25 @@ from homeassistant.const import ( STATE_PAUSED, STATE_PLAYING, ) -import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import dt as dt_util from .const import ( ATTR_MEDIA_CURRENTLY_RECORDING, ATTR_MEDIA_RATING, ATTR_MEDIA_RECORDED, ATTR_MEDIA_START_TIME, - DATA_DIRECTV, + DATA_CLIENT, + DATA_LOCATIONS, + DATA_VERSION_INFO, DEFAULT_DEVICE, + DEFAULT_MANUFACTURER, DEFAULT_NAME, DEFAULT_PORT, + DOMAIN, + MODEL_CLIENT, + MODEL_HOST, ) _LOGGER = logging.getLogger(__name__) @@ -74,97 +83,67 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the DirecTV platform.""" - known_devices = hass.data.get(DATA_DIRECTV, set()) +def get_dtv_instance( + host: str, port: int = DEFAULT_PORT, client_addr: str = "0" +) -> DIRECTV: + """Retrieve a DIRECTV instance for the receiver or client device.""" + try: + return DIRECTV(host, port, client_addr) + except RequestException as exception: + _LOGGER.debug( + "Request exception %s trying to retrieve DIRECTV instance for client address %s on device %s", + exception, + client_addr, + host, + ) + return None + + +async def async_setup_entry( + hass: HomeAssistantType, + entry: ConfigEntry, + async_add_entities: Callable[[List, bool], None], +) -> bool: + """Set up the DirecTV config entry.""" + locations = hass.data[DOMAIN][entry.entry_id][DATA_LOCATIONS] + version_info = hass.data[DOMAIN][entry.entry_id][DATA_VERSION_INFO] entities = [] - if CONF_HOST in config: - name = config[CONF_NAME] - host = config[CONF_HOST] - port = config[CONF_PORT] - device = config[CONF_DEVICE] + for loc in locations["locations"]: + if "locationName" not in loc or "clientAddr" not in loc: + continue - _LOGGER.debug( - "Adding configured device %s with client address %s", name, device, + if loc["clientAddr"] != "0": + # directpy does IO in constructor. + dtv = await hass.async_add_executor_job( + get_dtv_instance, entry.data[CONF_HOST], DEFAULT_PORT, loc["clientAddr"] + ) + else: + dtv = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + + if not dtv: + continue + + entities.append( + DirecTvDevice( + str.title(loc["locationName"]), loc["clientAddr"], dtv, version_info, + ) ) - dtv = DIRECTV(host, port, device) - dtv_version = _get_receiver_version(dtv) - - entities.append(DirecTvDevice(name, device, dtv, dtv_version,)) - known_devices.add((host, device)) - - elif discovery_info: - host = discovery_info.get("host") - name = f"DirecTV_{discovery_info.get('serial', '')}" - - # Attempt to discover additional RVU units - _LOGGER.debug("Doing discovery of DirecTV devices on %s", host) - - dtv = DIRECTV(host, DEFAULT_PORT) - - try: - dtv_version = _get_receiver_version(dtv) - resp = dtv.get_locations() - except requests.exceptions.RequestException as ex: - # Bail out and just go forward with uPnP data - # Make sure that this device is not already configured - # Comparing based on host (IP) and clientAddr. - _LOGGER.debug("Request exception %s trying to get locations", ex) - resp = {"locations": [{"locationName": name, "clientAddr": DEFAULT_DEVICE}]} - - _LOGGER.debug("Known devices: %s", known_devices) - for loc in resp.get("locations") or []: - if "locationName" not in loc or "clientAddr" not in loc: - continue - - loc_name = str.title(loc["locationName"]) - - # Make sure that this device is not already configured - # Comparing based on host (IP) and clientAddr. - if (host, loc["clientAddr"]) in known_devices: - _LOGGER.debug( - "Discovered device %s on host %s with " - "client address %s is already " - "configured", - loc_name, - host, - loc["clientAddr"], - ) - else: - _LOGGER.debug( - "Adding discovered device %s with client address %s", - loc_name, - loc["clientAddr"], - ) - - entities.append( - DirecTvDevice( - loc_name, - loc["clientAddr"], - DIRECTV(host, DEFAULT_PORT, loc["clientAddr"]), - dtv_version, - ) - ) - known_devices.add((host, loc["clientAddr"])) - - add_entities(entities) - - -def _get_receiver_version(client): - """Return the version of the DirectTV receiver.""" - try: - return client.get_version() - except requests.exceptions.RequestException as ex: - _LOGGER.debug("Request exception %s trying to get receiver version", ex) - return None + async_add_entities(entities, True) class DirecTvDevice(MediaPlayerDevice): """Representation of a DirecTV receiver on the network.""" - def __init__(self, name, device, dtv, version_info=None): + def __init__( + self, + name: str, + device: str, + dtv: DIRECTV, + version_info: Optional[Dict] = None, + enabled_default: bool = True, + ): """Initialize the device.""" self.dtv = dtv self._name = name @@ -178,17 +157,32 @@ class DirecTvDevice(MediaPlayerDevice): self._is_client = device != "0" self._assumed_state = None self._available = False + self._enabled_default = enabled_default self._first_error_timestamp = None - - if device != "0": - self._unique_id = device - elif version_info: - self._unique_id = "".join(version_info.get("receiverId").split()) + self._model = None + self._receiver_id = None + self._software_version = None if self._is_client: - _LOGGER.debug("Created DirecTV client %s for device %s", self._name, device) + self._model = MODEL_CLIENT + self._unique_id = device + + if version_info: + self._receiver_id = "".join(version_info["receiverId"].split()) + + if not self._is_client: + self._unique_id = self._receiver_id + self._model = MODEL_HOST + self._software_version = version_info["stbSoftwareVersion"] + + if self._is_client: + _LOGGER.debug( + "Created DirecTV media player for client %s on device %s", + self._name, + device, + ) else: - _LOGGER.debug("Created DirecTV device for %s", self._name) + _LOGGER.debug("Created DirecTV media player for device %s", self._name) def update(self): """Retrieve latest state.""" @@ -225,17 +219,19 @@ class DirecTvDevice(MediaPlayerDevice): else: _LOGGER.error(log_message) - except requests.RequestException as ex: + except RequestException as exception: _LOGGER.error( "%s: Request error trying to update current status: %s", self.entity_id, - ex, + exception, ) self._check_state_available() - except Exception as ex: + except Exception as exception: _LOGGER.error( - "%s: Exception trying to update current status: %s", self.entity_id, ex + "%s: Exception trying to update current status: %s", + self.entity_id, + exception, ) self._available = False if not self._first_error_timestamp: @@ -275,6 +271,23 @@ class DirecTvDevice(MediaPlayerDevice): """Return a unique ID to use for this media player.""" return self._unique_id + @property + def device_info(self): + """Return device specific attributes.""" + return { + "name": self.name, + "identifiers": {(DOMAIN, self.unique_id)}, + "manufacturer": DEFAULT_MANUFACTURER, + "model": self._model, + "sw_version": self._software_version, + "via_device": (DOMAIN, self._receiver_id), + } + + @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 + # MediaPlayerDevice properties and methods @property def state(self): diff --git a/homeassistant/components/directv/strings.json b/homeassistant/components/directv/strings.json new file mode 100644 index 00000000000..78316d663bd --- /dev/null +++ b/homeassistant/components/directv/strings.json @@ -0,0 +1,26 @@ +{ + "config": { + "title": "DirecTV", + "flow_title": "DirecTV: {name}", + "step": { + "ssdp_confirm": { + "data": {}, + "description": "Do you want to set up {name}?", + "title": "Connect to the DirecTV receiver" + }, + "user": { + "title": "Connect to the DirecTV receiver", + "data": { + "host": "Host or IP address" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "DirecTV receiver is already configured" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 91fda9f1c32..b281a322b23 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -23,6 +23,7 @@ FLOWS = [ "daikin", "deconz", "dialogflow", + "directv", "dynalite", "ecobee", "elgato", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 0eb9af0231d..3bf54b1d9f7 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -11,6 +11,12 @@ SSDP = { "manufacturer": "Royal Philips Electronics" } ], + "directv": [ + { + "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", + "manufacturer": "DIRECTV" + } + ], "heos": [ { "st": "urn:schemas-denon-com:device:ACT-Denon:1" diff --git a/tests/components/directv/__init__.py b/tests/components/directv/__init__.py index 9a32215e53d..d7f79c76be5 100644 --- a/tests/components/directv/__init__.py +++ b/tests/components/directv/__init__.py @@ -1 +1,182 @@ -"""Tests for the directv component.""" +"""Tests for the DirecTV component.""" +from homeassistant.components.directv.const import DOMAIN +from homeassistant.const import CONF_HOST +from homeassistant.helpers.typing import HomeAssistantType + +from tests.common import MockConfigEntry + +CLIENT_NAME = "Bedroom Client" +CLIENT_ADDRESS = "2CA17D1CD30X" +DEFAULT_DEVICE = "0" +HOST = "127.0.0.1" +MAIN_NAME = "Main DVR" +RECEIVER_ID = "028877455858" +SSDP_LOCATION = "http://127.0.0.1/" +UPNP_SERIAL = "RID-028877455858" + +LIVE = { + "callsign": "HASSTV", + "date": "20181110", + "duration": 3600, + "isOffAir": False, + "isPclocked": 1, + "isPpv": False, + "isRecording": False, + "isVod": False, + "major": 202, + "minor": 65535, + "offset": 1, + "programId": "102454523", + "rating": "No Rating", + "startTime": 1541876400, + "stationId": 3900947, + "title": "Using Home Assistant to automate your home", +} + +RECORDING = { + "callsign": "HASSTV", + "date": "20181110", + "duration": 3600, + "isOffAir": False, + "isPclocked": 1, + "isPpv": False, + "isRecording": True, + "isVod": False, + "major": 202, + "minor": 65535, + "offset": 1, + "programId": "102454523", + "rating": "No Rating", + "startTime": 1541876400, + "stationId": 3900947, + "title": "Using Home Assistant to automate your home", + "uniqueId": "12345", + "episodeTitle": "Configure DirecTV platform.", +} + +MOCK_CONFIG = {DOMAIN: [{CONF_HOST: HOST}]} + +MOCK_GET_LOCATIONS = { + "locations": [{"locationName": MAIN_NAME, "clientAddr": DEFAULT_DEVICE}], + "status": { + "code": 200, + "commandResult": 0, + "msg": "OK.", + "query": "/info/getLocations", + }, +} + +MOCK_GET_LOCATIONS_MULTIPLE = { + "locations": [ + {"locationName": MAIN_NAME, "clientAddr": DEFAULT_DEVICE}, + {"locationName": CLIENT_NAME, "clientAddr": CLIENT_ADDRESS}, + ], + "status": { + "code": 200, + "commandResult": 0, + "msg": "OK.", + "query": "/info/getLocations", + }, +} + +MOCK_GET_VERSION = { + "accessCardId": "0021-1495-6572", + "receiverId": "0288 7745 5858", + "status": { + "code": 200, + "commandResult": 0, + "msg": "OK.", + "query": "/info/getVersion", + }, + "stbSoftwareVersion": "0x4ed7", + "systemTime": 1281625203, + "version": "1.2", +} + + +class MockDirectvClass: + """A fake DirecTV DVR device.""" + + def __init__(self, ip, port=8080, clientAddr="0"): + """Initialize the fake DirecTV device.""" + self._host = ip + self._port = port + self._device = clientAddr + self._standby = True + self._play = False + + self.attributes = LIVE + + def get_locations(self): + """Mock for get_locations method.""" + return MOCK_GET_LOCATIONS + + def get_serial_num(self): + """Mock for get_serial_num method.""" + test_serial_num = { + "serialNum": "9999999999", + "status": { + "code": 200, + "commandResult": 0, + "msg": "OK.", + "query": "/info/getSerialNum", + }, + } + + return test_serial_num + + def get_standby(self): + """Mock for get_standby method.""" + return self._standby + + def get_tuned(self): + """Mock for get_tuned method.""" + if self._play: + self.attributes["offset"] = self.attributes["offset"] + 1 + + test_attributes = self.attributes + test_attributes["status"] = { + "code": 200, + "commandResult": 0, + "msg": "OK.", + "query": "/tv/getTuned", + } + return test_attributes + + def get_version(self): + """Mock for get_version method.""" + return MOCK_GET_VERSION + + def key_press(self, keypress): + """Mock for key_press method.""" + if keypress == "poweron": + self._standby = False + self._play = True + elif keypress == "poweroff": + self._standby = True + self._play = False + elif keypress == "play": + self._play = True + elif keypress == "pause" or keypress == "stop": + self._play = False + + def tune_channel(self, source): + """Mock for tune_channel method.""" + self.attributes["major"] = int(source) + + +async def setup_integration( + hass: HomeAssistantType, skip_entry_setup: bool = False +) -> MockConfigEntry: + """Set up the DirecTV integration in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, unique_id=RECEIVER_ID, data={CONF_HOST: HOST} + ) + + entry.add_to_hass(hass) + + if not skip_entry_setup: + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/directv/test_config_flow.py b/tests/components/directv/test_config_flow.py new file mode 100644 index 00000000000..5516b61cd46 --- /dev/null +++ b/tests/components/directv/test_config_flow.py @@ -0,0 +1,240 @@ +"""Test the DirecTV config flow.""" +from typing import Any, Dict, Optional + +from asynctest import patch +from requests.exceptions import RequestException + +from homeassistant.components.directv.const import DOMAIN +from homeassistant.components.ssdp import ATTR_SSDP_LOCATION, ATTR_UPNP_SERIAL +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_SSDP, SOURCE_USER +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 homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.directv import ( + HOST, + RECEIVER_ID, + SSDP_LOCATION, + UPNP_SERIAL, + MockDirectvClass, +) + + +async def async_configure_flow( + hass: HomeAssistantType, flow_id: str, user_input: Optional[Dict] = None +) -> Any: + """Set up mock DirecTV integration flow.""" + with patch( + "homeassistant.components.directv.config_flow.DIRECTV", new=MockDirectvClass, + ): + return await hass.config_entries.flow.async_configure( + flow_id=flow_id, user_input=user_input + ) + + +async def async_init_flow( + hass: HomeAssistantType, + handler: str = DOMAIN, + context: Optional[Dict] = None, + data: Any = None, +) -> Any: + """Set up mock DirecTV integration flow.""" + with patch( + "homeassistant.components.directv.config_flow.DIRECTV", new=MockDirectvClass, + ): + return await hass.config_entries.flow.async_init( + handler=handler, context=context, data=data + ) + + +async def test_duplicate_error(hass: HomeAssistantType) -> None: + """Test that errors are shown when duplicates are added.""" + MockConfigEntry( + domain=DOMAIN, unique_id=RECEIVER_ID, data={CONF_HOST: HOST} + ).add_to_hass(hass) + + result = await async_init_flow( + hass, context={CONF_SOURCE: SOURCE_IMPORT}, data={CONF_HOST: HOST} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + result = await async_init_flow( + hass, context={CONF_SOURCE: SOURCE_USER}, data={CONF_HOST: HOST} + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + result = await async_init_flow( + hass, + context={CONF_SOURCE: SOURCE_SSDP}, + data={ATTR_SSDP_LOCATION: SSDP_LOCATION, ATTR_UPNP_SERIAL: UPNP_SERIAL}, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_form(hass: HomeAssistantType) -> None: + """Test we get the form.""" + await async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {} + + with patch( + "homeassistant.components.directv.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.directv.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result = await async_configure_flow(hass, result["flow_id"], {CONF_HOST: HOST}) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == {CONF_HOST: HOST} + 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: HomeAssistantType) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + with patch( + "homeassistant.components.directv.config_flow.DIRECTV", new=MockDirectvClass, + ), patch( + "homeassistant.components.directv.config_flow.DIRECTV.get_version", + side_effect=RequestException, + ) as mock_validate_input: + result = await async_configure_flow(hass, result["flow_id"], {CONF_HOST: HOST},) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + await hass.async_block_till_done() + assert len(mock_validate_input.mock_calls) == 1 + + +async def test_form_unknown_error(hass: HomeAssistantType) -> None: + """Test we handle unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER} + ) + + with patch( + "homeassistant.components.directv.config_flow.DIRECTV", new=MockDirectvClass, + ), patch( + "homeassistant.components.directv.config_flow.DIRECTV.get_version", + side_effect=Exception, + ) as mock_validate_input: + result = await async_configure_flow(hass, result["flow_id"], {CONF_HOST: HOST},) + + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"} + + await hass.async_block_till_done() + assert len(mock_validate_input.mock_calls) == 1 + + +async def test_import(hass: HomeAssistantType) -> None: + """Test the import step.""" + with patch( + "homeassistant.components.directv.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.directv.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result = await async_init_flow( + hass, context={CONF_SOURCE: SOURCE_IMPORT}, data={CONF_HOST: HOST}, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == {CONF_HOST: HOST} + + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_ssdp_discovery(hass: HomeAssistantType) -> None: + """Test the ssdp discovery step.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data={ATTR_SSDP_LOCATION: SSDP_LOCATION, ATTR_UPNP_SERIAL: UPNP_SERIAL}, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "ssdp_confirm" + assert result["description_placeholders"] == {CONF_NAME: HOST} + + with patch( + "homeassistant.components.directv.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.directv.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result = await async_configure_flow(hass, result["flow_id"], {}) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == HOST + assert result["data"] == {CONF_HOST: HOST} + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_ssdp_discovery_confirm_abort(hass: HomeAssistantType) -> None: + """Test we handle SSDP confirm cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data={ATTR_SSDP_LOCATION: SSDP_LOCATION, ATTR_UPNP_SERIAL: UPNP_SERIAL}, + ) + + with patch( + "homeassistant.components.directv.config_flow.DIRECTV", new=MockDirectvClass, + ), patch( + "homeassistant.components.directv.config_flow.DIRECTV.get_version", + side_effect=RequestException, + ) as mock_validate_input: + result = await async_configure_flow(hass, result["flow_id"], {}) + + assert result["type"] == RESULT_TYPE_ABORT + + await hass.async_block_till_done() + assert len(mock_validate_input.mock_calls) == 1 + + +async def test_ssdp_discovery_confirm_unknown_error(hass: HomeAssistantType) -> None: + """Test we handle SSDP confirm unknown error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_SSDP}, + data={ATTR_SSDP_LOCATION: SSDP_LOCATION, ATTR_UPNP_SERIAL: UPNP_SERIAL}, + ) + + with patch( + "homeassistant.components.directv.config_flow.DIRECTV", new=MockDirectvClass, + ), patch( + "homeassistant.components.directv.config_flow.DIRECTV.get_version", + side_effect=Exception, + ) as mock_validate_input: + result = await async_configure_flow(hass, result["flow_id"], {}) + + assert result["type"] == RESULT_TYPE_ABORT + + await hass.async_block_till_done() + assert len(mock_validate_input.mock_calls) == 1 diff --git a/tests/components/directv/test_init.py b/tests/components/directv/test_init.py new file mode 100644 index 00000000000..02e97b9b015 --- /dev/null +++ b/tests/components/directv/test_init.py @@ -0,0 +1,48 @@ +"""Tests for the Roku integration.""" +from asynctest import patch +from requests.exceptions import RequestException + +from homeassistant.components.directv.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.directv import MockDirectvClass, setup_integration + +# pylint: disable=redefined-outer-name + + +async def test_config_entry_not_ready(hass: HomeAssistantType) -> None: + """Test the DirecTV configuration entry not ready.""" + with patch( + "homeassistant.components.directv.DIRECTV", new=MockDirectvClass, + ), patch( + "homeassistant.components.directv.DIRECTV.get_locations", + side_effect=RequestException, + ): + entry = await setup_integration(hass) + + assert entry.state == ENTRY_STATE_SETUP_RETRY + + +async def test_unload_config_entry(hass: HomeAssistantType) -> None: + """Test the DirecTV configuration entry unloading.""" + with patch( + "homeassistant.components.directv.DIRECTV", new=MockDirectvClass, + ), patch( + "homeassistant.components.directv.media_player.async_setup_entry", + return_value=True, + ): + 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() + + assert entry.entry_id not in hass.data[DOMAIN] + assert entry.state == ENTRY_STATE_NOT_LOADED diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py index be805d837f5..9c06164c309 100644 --- a/tests/components/directv/test_media_player.py +++ b/tests/components/directv/test_media_player.py @@ -1,17 +1,16 @@ """The tests for the DirecTV Media player platform.""" from datetime import datetime, timedelta -from unittest.mock import call, patch +from typing import Optional -import pytest -import requests +from asynctest import patch +from pytest import fixture +from requests import RequestException from homeassistant.components.directv.media_player import ( ATTR_MEDIA_CURRENTLY_RECORDING, ATTR_MEDIA_RATING, ATTR_MEDIA_RECORDED, ATTR_MEDIA_START_TIME, - DEFAULT_DEVICE, - DEFAULT_PORT, ) from homeassistant.components.media_player.const import ( ATTR_INPUT_SOURCE, @@ -24,7 +23,7 @@ from homeassistant.components.media_player.const import ( ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_SERIES_TITLE, ATTR_MEDIA_TITLE, - DOMAIN, + DOMAIN as MP_DOMAIN, MEDIA_TYPE_TVSHOW, SERVICE_PLAY_MEDIA, SUPPORT_NEXT_TRACK, @@ -38,10 +37,6 @@ from homeassistant.components.media_player.const import ( ) from homeassistant.const import ( ATTR_ENTITY_ID, - CONF_DEVICE, - CONF_HOST, - CONF_NAME, - CONF_PORT, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, @@ -54,184 +49,143 @@ from homeassistant.const import ( STATE_PLAYING, STATE_UNAVAILABLE, ) -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.directv import ( + CLIENT_ADDRESS, + DOMAIN, + HOST, + MOCK_GET_LOCATIONS_MULTIPLE, + RECORDING, + MockDirectvClass, + setup_integration, +) ATTR_UNIQUE_ID = "unique_id" -CLIENT_ENTITY_ID = "media_player.client_dvr" -MAIN_ENTITY_ID = "media_player.main_dvr" -IP_ADDRESS = "127.0.0.1" +CLIENT_ENTITY_ID = f"{MP_DOMAIN}.bedroom_client" +MAIN_ENTITY_ID = f"{MP_DOMAIN}.main_dvr" -DISCOVERY_INFO = {"host": IP_ADDRESS, "serial": 1234} - -LIVE = { - "callsign": "HASSTV", - "date": "20181110", - "duration": 3600, - "isOffAir": False, - "isPclocked": 1, - "isPpv": False, - "isRecording": False, - "isVod": False, - "major": 202, - "minor": 65535, - "offset": 1, - "programId": "102454523", - "rating": "No Rating", - "startTime": 1541876400, - "stationId": 3900947, - "title": "Using Home Assistant to automate your home", -} - -LOCATIONS = [{"locationName": "Main DVR", "clientAddr": DEFAULT_DEVICE}] - -RECORDING = { - "callsign": "HASSTV", - "date": "20181110", - "duration": 3600, - "isOffAir": False, - "isPclocked": 1, - "isPpv": False, - "isRecording": True, - "isVod": False, - "major": 202, - "minor": 65535, - "offset": 1, - "programId": "102454523", - "rating": "No Rating", - "startTime": 1541876400, - "stationId": 3900947, - "title": "Using Home Assistant to automate your home", - "uniqueId": "12345", - "episodeTitle": "Configure DirecTV platform.", -} - -WORKING_CONFIG = { - "media_player": { - "platform": "directv", - CONF_HOST: IP_ADDRESS, - CONF_NAME: "Main DVR", - CONF_PORT: DEFAULT_PORT, - CONF_DEVICE: DEFAULT_DEVICE, - } -} +# pylint: disable=redefined-outer-name -@pytest.fixture -def client_dtv(): +@fixture +def client_dtv() -> MockDirectvClass: """Fixture for a client device.""" - mocked_dtv = MockDirectvClass("mock_ip") + mocked_dtv = MockDirectvClass(HOST, clientAddr=CLIENT_ADDRESS) mocked_dtv.attributes = RECORDING - mocked_dtv._standby = False + mocked_dtv._standby = False # pylint: disable=protected-access return mocked_dtv -@pytest.fixture -def main_dtv(): - """Fixture for main DVR.""" - return MockDirectvClass("mock_ip") - - -@pytest.fixture -def dtv_side_effect(client_dtv, main_dtv): - """Fixture to create DIRECTV instance for main and client.""" - - def mock_dtv(ip, port, client_addr="0"): - if client_addr != "0": - mocked_dtv = client_dtv - else: - mocked_dtv = main_dtv - mocked_dtv._host = ip - mocked_dtv._port = port - mocked_dtv._device = client_addr - return mocked_dtv - - return mock_dtv - - -@pytest.fixture -def mock_now(): +@fixture +def mock_now() -> datetime: """Fixture for dtutil.now.""" return dt_util.utcnow() -@pytest.fixture -def platforms(hass, dtv_side_effect, mock_now): - """Fixture for setting up test platforms.""" - config = { - "media_player": [ - { - "platform": "directv", - "name": "Main DVR", - "host": IP_ADDRESS, - "port": DEFAULT_PORT, - "device": DEFAULT_DEVICE, - }, - { - "platform": "directv", - "name": "Client DVR", - "host": IP_ADDRESS, - "port": DEFAULT_PORT, - "device": "2CA17D1CD30X", - }, - ] - } - +async def setup_directv(hass: HomeAssistantType) -> MockConfigEntry: + """Set up mock DirecTV integration.""" with patch( - "homeassistant.components.directv.media_player.DIRECTV", - side_effect=dtv_side_effect, - ), patch("homeassistant.util.dt.utcnow", return_value=mock_now): - hass.loop.run_until_complete(async_setup_component(hass, DOMAIN, config)) - hass.loop.run_until_complete(hass.async_block_till_done()) - yield + "homeassistant.components.directv.DIRECTV", new=MockDirectvClass, + ): + return await setup_integration(hass) -async def async_turn_on(hass, entity_id=None): +async def setup_directv_with_instance_error(hass: HomeAssistantType) -> MockConfigEntry: + """Set up mock DirecTV integration.""" + with patch( + "homeassistant.components.directv.DIRECTV", new=MockDirectvClass, + ), patch( + "homeassistant.components.directv.DIRECTV.get_locations", + return_value=MOCK_GET_LOCATIONS_MULTIPLE, + ), patch( + "homeassistant.components.directv.media_player.get_dtv_instance", + return_value=None, + ): + return await setup_integration(hass) + + +async def setup_directv_with_locations( + hass: HomeAssistantType, client_dtv: MockDirectvClass, +) -> MockConfigEntry: + """Set up mock DirecTV integration.""" + with patch( + "homeassistant.components.directv.DIRECTV", new=MockDirectvClass, + ), patch( + "homeassistant.components.directv.DIRECTV.get_locations", + return_value=MOCK_GET_LOCATIONS_MULTIPLE, + ), patch( + "homeassistant.components.directv.media_player.get_dtv_instance", + return_value=client_dtv, + ): + return await setup_integration(hass) + + +async def async_turn_on( + hass: HomeAssistantType, entity_id: Optional[str] = None +) -> None: """Turn on specified media player or all.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data) + await hass.services.async_call(MP_DOMAIN, SERVICE_TURN_ON, data) -async def async_turn_off(hass, entity_id=None): +async def async_turn_off( + hass: HomeAssistantType, entity_id: Optional[str] = None +) -> None: """Turn off specified media player or all.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data) + await hass.services.async_call(MP_DOMAIN, SERVICE_TURN_OFF, data) -async def async_media_pause(hass, entity_id=None): +async def async_media_pause( + hass: HomeAssistantType, entity_id: Optional[str] = None +) -> None: """Send the media player the command for pause.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - await hass.services.async_call(DOMAIN, SERVICE_MEDIA_PAUSE, data) + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PAUSE, data) -async def async_media_play(hass, entity_id=None): +async def async_media_play( + hass: HomeAssistantType, entity_id: Optional[str] = None +) -> None: """Send the media player the command for play/pause.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - await hass.services.async_call(DOMAIN, SERVICE_MEDIA_PLAY, data) + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PLAY, data) -async def async_media_stop(hass, entity_id=None): +async def async_media_stop( + hass: HomeAssistantType, entity_id: Optional[str] = None +) -> None: """Send the media player the command for stop.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - await hass.services.async_call(DOMAIN, SERVICE_MEDIA_STOP, data) + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_STOP, data) -async def async_media_next_track(hass, entity_id=None): +async def async_media_next_track( + hass: HomeAssistantType, entity_id: Optional[str] = None +) -> None: """Send the media player the command for next track.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - await hass.services.async_call(DOMAIN, SERVICE_MEDIA_NEXT_TRACK, data) + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_NEXT_TRACK, data) -async def async_media_previous_track(hass, entity_id=None): +async def async_media_previous_track( + hass: HomeAssistantType, entity_id: Optional[str] = None +) -> None: """Send the media player the command for prev track.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} - await hass.services.async_call(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data) + await hass.services.async_call(MP_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data) -async def async_play_media(hass, media_type, media_id, entity_id=None, enqueue=None): +async def async_play_media( + hass: HomeAssistantType, + media_type: str, + media_id: str, + entity_id: Optional[str] = None, + enqueue: Optional[str] = None, +) -> None: """Send the media player the command for playing media.""" data = {ATTR_MEDIA_CONTENT_TYPE: media_type, ATTR_MEDIA_CONTENT_ID: media_id} @@ -241,191 +195,37 @@ async def async_play_media(hass, media_type, media_id, entity_id=None, enqueue=N if enqueue: data[ATTR_MEDIA_ENQUEUE] = enqueue - await hass.services.async_call(DOMAIN, SERVICE_PLAY_MEDIA, data) + await hass.services.async_call(MP_DOMAIN, SERVICE_PLAY_MEDIA, data) -class MockDirectvClass: - """A fake DirecTV DVR device.""" - - def __init__(self, ip, port=8080, clientAddr="0"): - """Initialize the fake DirecTV device.""" - self._host = ip - self._port = port - self._device = clientAddr - self._standby = True - self._play = False - - self._locations = LOCATIONS - - self.attributes = LIVE - - def get_locations(self): - """Mock for get_locations method.""" - test_locations = { - "locations": self._locations, - "status": { - "code": 200, - "commandResult": 0, - "msg": "OK.", - "query": "/info/getLocations", - }, - } - - return test_locations - - def get_serial_num(self): - """Mock for get_serial_num method.""" - test_serial_num = { - "serialNum": "9999999999", - "status": { - "code": 200, - "commandResult": 0, - "msg": "OK.", - "query": "/info/getSerialNum", - }, - } - - return test_serial_num - - def get_standby(self): - """Mock for get_standby method.""" - return self._standby - - def get_tuned(self): - """Mock for get_tuned method.""" - if self._play: - self.attributes["offset"] = self.attributes["offset"] + 1 - - test_attributes = self.attributes - test_attributes["status"] = { - "code": 200, - "commandResult": 0, - "msg": "OK.", - "query": "/tv/getTuned", - } - return test_attributes - - def get_version(self): - """Mock for get_version method.""" - test_version = { - "accessCardId": "0021-1495-6572", - "receiverId": "0288 7745 5858", - "status": { - "code": 200, - "commandResult": 0, - "msg": "OK.", - "query": "/info/getVersion", - }, - "stbSoftwareVersion": "0x4ed7", - "systemTime": 1281625203, - "version": "1.2", - } - - return test_version - - def key_press(self, keypress): - """Mock for key_press method.""" - if keypress == "poweron": - self._standby = False - self._play = True - elif keypress == "poweroff": - self._standby = True - self._play = False - elif keypress == "play": - self._play = True - elif keypress == "pause" or keypress == "stop": - self._play = False - - def tune_channel(self, source): - """Mock for tune_channel method.""" - self.attributes["major"] = int(source) +async def test_setup(hass: HomeAssistantType) -> None: + """Test setup with basic config.""" + await setup_directv(hass) + assert hass.states.get(MAIN_ENTITY_ID) -async def test_setup_platform_config(hass): - """Test setting up the platform from configuration.""" - with patch( - "homeassistant.components.directv.media_player.DIRECTV", new=MockDirectvClass - ): +async def test_setup_with_multiple_locations( + hass: HomeAssistantType, client_dtv: MockDirectvClass +) -> None: + """Test setup with basic config with client location.""" + await setup_directv_with_locations(hass, client_dtv) - await async_setup_component(hass, DOMAIN, WORKING_CONFIG) - await hass.async_block_till_done() - - state = hass.states.get(MAIN_ENTITY_ID) - assert state - assert len(hass.states.async_entity_ids("media_player")) == 1 + assert hass.states.get(MAIN_ENTITY_ID) + assert hass.states.get(CLIENT_ENTITY_ID) -async def test_setup_platform_discover(hass): - """Test setting up the platform from discovery.""" - with patch( - "homeassistant.components.directv.media_player.DIRECTV", new=MockDirectvClass - ): +async def test_setup_with_instance_error(hass: HomeAssistantType) -> None: + """Test setup with basic config with client location that results in instance error.""" + await setup_directv_with_instance_error(hass) - hass.async_create_task( - async_load_platform( - hass, DOMAIN, "directv", DISCOVERY_INFO, {"media_player": {}} - ) - ) - await hass.async_block_till_done() - - state = hass.states.get(MAIN_ENTITY_ID) - assert state - assert len(hass.states.async_entity_ids("media_player")) == 1 + assert hass.states.get(MAIN_ENTITY_ID) + assert hass.states.async_entity_ids(MP_DOMAIN) == [MAIN_ENTITY_ID] -async def test_setup_platform_discover_duplicate(hass): - """Test setting up the platform from discovery.""" - with patch( - "homeassistant.components.directv.media_player.DIRECTV", new=MockDirectvClass - ): - - await async_setup_component(hass, DOMAIN, WORKING_CONFIG) - await hass.async_block_till_done() - hass.async_create_task( - async_load_platform( - hass, DOMAIN, "directv", DISCOVERY_INFO, {"media_player": {}} - ) - ) - await hass.async_block_till_done() - - state = hass.states.get(MAIN_ENTITY_ID) - assert state - assert len(hass.states.async_entity_ids("media_player")) == 1 - - -async def test_setup_platform_discover_client(hass): - """Test setting up the platform from discovery.""" - LOCATIONS.append({"locationName": "Client 1", "clientAddr": "1"}) - LOCATIONS.append({"locationName": "Client 2", "clientAddr": "2"}) - - with patch( - "homeassistant.components.directv.media_player.DIRECTV", new=MockDirectvClass - ): - - await async_setup_component(hass, DOMAIN, WORKING_CONFIG) - await hass.async_block_till_done() - - hass.async_create_task( - async_load_platform( - hass, DOMAIN, "directv", DISCOVERY_INFO, {"media_player": {}} - ) - ) - await hass.async_block_till_done() - - del LOCATIONS[-1] - del LOCATIONS[-1] - state = hass.states.get(MAIN_ENTITY_ID) - assert state - state = hass.states.get("media_player.client_1") - assert state - state = hass.states.get("media_player.client_2") - assert state - - assert len(hass.states.async_entity_ids("media_player")) == 3 - - -async def test_unique_id(hass, platforms): +async def test_unique_id(hass: HomeAssistantType, client_dtv: MockDirectvClass) -> None: """Test unique id.""" + await setup_directv_with_locations(hass, client_dtv) + entity_registry = await hass.helpers.entity_registry.async_get_registry() main = entity_registry.async_get(MAIN_ENTITY_ID) @@ -435,8 +235,12 @@ async def test_unique_id(hass, platforms): assert client.unique_id == "2CA17D1CD30X" -async def test_supported_features(hass, platforms): +async def test_supported_features( + hass: HomeAssistantType, client_dtv: MockDirectvClass +) -> None: """Test supported features.""" + await setup_directv_with_locations(hass, client_dtv) + # Features supported for main DVR state = hass.states.get(MAIN_ENTITY_ID) assert ( @@ -464,8 +268,12 @@ async def test_supported_features(hass, platforms): ) -async def test_check_attributes(hass, platforms, mock_now): +async def test_check_attributes( + hass: HomeAssistantType, mock_now: dt_util.dt.datetime, client_dtv: MockDirectvClass +) -> None: """Test attributes.""" + await setup_directv_with_locations(hass, client_dtv) + next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): async_fire_time_changed(hass, next_update) @@ -512,8 +320,12 @@ async def test_check_attributes(hass, platforms, mock_now): assert state.attributes.get(ATTR_MEDIA_POSITION_UPDATED_AT) == next_update -async def test_main_services(hass, platforms, main_dtv, mock_now): +async def test_main_services( + hass: HomeAssistantType, mock_now: dt_util.dt.datetime, client_dtv: MockDirectvClass +) -> None: """Test the different services.""" + await setup_directv_with_locations(hass, client_dtv) + next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): async_fire_time_changed(hass, next_update) @@ -522,77 +334,50 @@ async def test_main_services(hass, platforms, main_dtv, mock_now): state = hass.states.get(MAIN_ENTITY_ID) assert state.state == STATE_OFF - # All these should call key_press in our class. - with patch.object( - main_dtv, "key_press", wraps=main_dtv.key_press - ) as mock_key_press, patch.object( - main_dtv, "tune_channel", wraps=main_dtv.tune_channel - ) as mock_tune_channel, patch.object( - main_dtv, "get_tuned", wraps=main_dtv.get_tuned - ) as mock_get_tuned, patch.object( - main_dtv, "get_standby", wraps=main_dtv.get_standby - ) as mock_get_standby: + # Turn main DVR on. When turning on DVR is playing. + await async_turn_on(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_PLAYING - # Turn main DVR on. When turning on DVR is playing. - await async_turn_on(hass, MAIN_ENTITY_ID) - await hass.async_block_till_done() - assert mock_key_press.called - assert mock_key_press.call_args == call("poweron") - state = hass.states.get(MAIN_ENTITY_ID) - assert state.state == STATE_PLAYING + # Pause live TV. + await async_media_pause(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_PAUSED - # Pause live TV. - await async_media_pause(hass, MAIN_ENTITY_ID) - await hass.async_block_till_done() - assert mock_key_press.called - assert mock_key_press.call_args == call("pause") - state = hass.states.get(MAIN_ENTITY_ID) - assert state.state == STATE_PAUSED + # Start play again for live TV. + await async_media_play(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_PLAYING - # Start play again for live TV. - await async_media_play(hass, MAIN_ENTITY_ID) - await hass.async_block_till_done() - assert mock_key_press.called - assert mock_key_press.call_args == call("play") - state = hass.states.get(MAIN_ENTITY_ID) - assert state.state == STATE_PLAYING + # Change channel, currently it should be 202 + assert state.attributes.get("source") == 202 + await async_play_media(hass, "channel", 7, MAIN_ENTITY_ID) + await hass.async_block_till_done() + state = hass.states.get(MAIN_ENTITY_ID) + assert state.attributes.get("source") == 7 - # Change channel, currently it should be 202 - assert state.attributes.get("source") == 202 - await async_play_media(hass, "channel", 7, MAIN_ENTITY_ID) - await hass.async_block_till_done() - assert mock_tune_channel.called - assert mock_tune_channel.call_args == call("7") - state = hass.states.get(MAIN_ENTITY_ID) - assert state.attributes.get("source") == 7 + # Stop live TV. + await async_media_stop(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_PAUSED - # Stop live TV. - await async_media_stop(hass, MAIN_ENTITY_ID) - await hass.async_block_till_done() - assert mock_key_press.called - assert mock_key_press.call_args == call("stop") - state = hass.states.get(MAIN_ENTITY_ID) - assert state.state == STATE_PAUSED - - # Turn main DVR off. - await async_turn_off(hass, MAIN_ENTITY_ID) - await hass.async_block_till_done() - assert mock_key_press.called - assert mock_key_press.call_args == call("poweroff") - state = hass.states.get(MAIN_ENTITY_ID) - assert state.state == STATE_OFF - - # There should have been 6 calls to check if DVR is in standby - assert main_dtv.get_standby.call_count == 6 - assert mock_get_standby.call_count == 6 - # There should be 5 calls to get current info (only 1 time it will - # not be called as DVR is in standby.) - assert main_dtv.get_tuned.call_count == 5 - assert mock_get_tuned.call_count == 5 + # Turn main DVR off. + await async_turn_off(hass, MAIN_ENTITY_ID) + await hass.async_block_till_done() + state = hass.states.get(MAIN_ENTITY_ID) + assert state.state == STATE_OFF -async def test_available(hass, platforms, main_dtv, mock_now): +async def test_available( + hass: HomeAssistantType, mock_now: dt_util.dt.datetime, client_dtv: MockDirectvClass +) -> None: """Test available status.""" + entry = await setup_directv_with_locations(hass, client_dtv) + next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): async_fire_time_changed(hass, next_update) @@ -602,11 +387,17 @@ async def test_available(hass, platforms, main_dtv, mock_now): state = hass.states.get(MAIN_ENTITY_ID) assert state.state != STATE_UNAVAILABLE + assert hass.data[DOMAIN] + assert hass.data[DOMAIN][entry.entry_id] + assert hass.data[DOMAIN][entry.entry_id]["client"] + + main_dtv = hass.data[DOMAIN][entry.entry_id]["client"] + # Make update fail 1st time next_update = next_update + timedelta(minutes=5) - with patch.object( - main_dtv, "get_standby", side_effect=requests.RequestException - ), patch("homeassistant.util.dt.utcnow", return_value=next_update): + with patch.object(main_dtv, "get_standby", side_effect=RequestException), patch( + "homeassistant.util.dt.utcnow", return_value=next_update + ): async_fire_time_changed(hass, next_update) await hass.async_block_till_done() @@ -615,9 +406,9 @@ async def test_available(hass, platforms, main_dtv, mock_now): # Make update fail 2nd time within 1 minute next_update = next_update + timedelta(seconds=30) - with patch.object( - main_dtv, "get_standby", side_effect=requests.RequestException - ), patch("homeassistant.util.dt.utcnow", return_value=next_update): + with patch.object(main_dtv, "get_standby", side_effect=RequestException), patch( + "homeassistant.util.dt.utcnow", return_value=next_update + ): async_fire_time_changed(hass, next_update) await hass.async_block_till_done() @@ -626,9 +417,9 @@ async def test_available(hass, platforms, main_dtv, mock_now): # Make update fail 3rd time more then a minute after 1st failure next_update = next_update + timedelta(minutes=1) - with patch.object( - main_dtv, "get_standby", side_effect=requests.RequestException - ), patch("homeassistant.util.dt.utcnow", return_value=next_update): + with patch.object(main_dtv, "get_standby", side_effect=RequestException), patch( + "homeassistant.util.dt.utcnow", return_value=next_update + ): async_fire_time_changed(hass, next_update) await hass.async_block_till_done() @@ -640,5 +431,6 @@ async def test_available(hass, platforms, main_dtv, mock_now): with patch("homeassistant.util.dt.utcnow", return_value=next_update): async_fire_time_changed(hass, next_update) await hass.async_block_till_done() + state = hass.states.get(MAIN_ENTITY_ID) assert state.state != STATE_UNAVAILABLE From cea5cac6e2e56cc393b432a0b2f8ce4b628a6ee4 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Wed, 11 Mar 2020 14:33:02 -0500 Subject: [PATCH 352/416] Update directpy==0.7 for directv. (#32694) --- homeassistant/components/directv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json index 7e1dffd7435..cb8ed68b304 100644 --- a/homeassistant/components/directv/manifest.json +++ b/homeassistant/components/directv/manifest.json @@ -2,7 +2,7 @@ "domain": "directv", "name": "DirecTV", "documentation": "https://www.home-assistant.io/integrations/directv", - "requirements": ["directpy==0.6"], + "requirements": ["directpy==0.7"], "dependencies": [], "codeowners": ["@ctalkington"], "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 54cbf09e31c..2d7434f6293 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -439,7 +439,7 @@ deluge-client==1.7.1 denonavr==0.8.0 # homeassistant.components.directv -directpy==0.6 +directpy==0.7 # homeassistant.components.discogs discogs_client==2.2.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c380f93679..71aa2004eef 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -165,7 +165,7 @@ defusedxml==0.6.0 denonavr==0.8.0 # homeassistant.components.directv -directpy==0.6 +directpy==0.7 # homeassistant.components.updater distro==1.4.0 From 01dc81d8fb5666b17bfdd3b573786c40c2a681a7 Mon Sep 17 00:00:00 2001 From: Quentame Date: Wed, 11 Mar 2020 20:43:37 +0100 Subject: [PATCH 353/416] Fetch iCloud family devices only when wanted (#32644) * Fetch iCloud family devices only when wanted * Review: form data_schema as init schema --- .../components/icloud/.translations/en.json | 3 ++- homeassistant/components/icloud/__init__.py | 12 +++++++++- homeassistant/components/icloud/account.py | 7 +++++- .../components/icloud/config_flow.py | 19 +++++++++++++-- homeassistant/components/icloud/const.py | 2 ++ homeassistant/components/icloud/strings.json | 3 ++- tests/components/icloud/test_config_flow.py | 24 +++++++++++++++---- 7 files changed, 59 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/icloud/.translations/en.json b/homeassistant/components/icloud/.translations/en.json index 73ca1b31256..19a07a19c68 100644 --- a/homeassistant/components/icloud/.translations/en.json +++ b/homeassistant/components/icloud/.translations/en.json @@ -20,7 +20,8 @@ "user": { "data": { "password": "Password", - "username": "Email" + "username": "Email", + "with_family": "With family" }, "description": "Enter your credentials", "title": "iCloud credentials" diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 1131a4eecc9..ba0f42432cc 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -14,8 +14,10 @@ from .account import IcloudAccount from .const import ( CONF_GPS_ACCURACY_THRESHOLD, CONF_MAX_INTERVAL, + CONF_WITH_FAMILY, DEFAULT_GPS_ACCURACY_THRESHOLD, DEFAULT_MAX_INTERVAL, + DEFAULT_WITH_FAMILY, DOMAIN, PLATFORMS, STORAGE_KEY, @@ -71,6 +73,7 @@ ACCOUNT_SCHEMA = vol.Schema( { vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_WITH_FAMILY, default=DEFAULT_WITH_FAMILY): cv.boolean, vol.Optional(CONF_MAX_INTERVAL, default=DEFAULT_MAX_INTERVAL): cv.positive_int, vol.Optional( CONF_GPS_ACCURACY_THRESHOLD, default=DEFAULT_GPS_ACCURACY_THRESHOLD @@ -110,6 +113,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool username = entry.data[CONF_USERNAME] password = entry.data[CONF_PASSWORD] + with_family = entry.data[CONF_WITH_FAMILY] max_interval = entry.data[CONF_MAX_INTERVAL] gps_accuracy_threshold = entry.data[CONF_GPS_ACCURACY_THRESHOLD] @@ -120,7 +124,13 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool icloud_dir = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) account = IcloudAccount( - hass, username, password, icloud_dir, max_interval, gps_accuracy_threshold, + hass, + username, + password, + icloud_dir, + with_family, + max_interval, + gps_accuracy_threshold, ) await hass.async_add_executor_job(account.setup) diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index bb3742174d7..6c4d9c5c25f 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -78,6 +78,7 @@ class IcloudAccount: username: str, password: str, icloud_dir: Store, + with_family: bool, max_interval: int, gps_accuracy_threshold: int, ): @@ -85,6 +86,7 @@ class IcloudAccount: self.hass = hass self._username = username self._password = password + self._with_family = with_family self._fetch_interval = max_interval self._max_interval = max_interval self._gps_accuracy_threshold = gps_accuracy_threshold @@ -102,7 +104,10 @@ class IcloudAccount: """Set up an iCloud account.""" try: self.api = PyiCloudService( - self._username, self._password, self._icloud_dir.path + self._username, + self._password, + self._icloud_dir.path, + with_family=self._with_family, ) except PyiCloudFailedLoginException as error: self.api = None diff --git a/homeassistant/components/icloud/config_flow.py b/homeassistant/components/icloud/config_flow.py index 72ff6e6481d..052e5b98379 100644 --- a/homeassistant/components/icloud/config_flow.py +++ b/homeassistant/components/icloud/config_flow.py @@ -17,8 +17,10 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from .const import ( CONF_GPS_ACCURACY_THRESHOLD, CONF_MAX_INTERVAL, + CONF_WITH_FAMILY, DEFAULT_GPS_ACCURACY_THRESHOLD, DEFAULT_MAX_INTERVAL, + DEFAULT_WITH_FAMILY, STORAGE_KEY, STORAGE_VERSION, ) @@ -41,7 +43,7 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self.api = None self._username = None self._password = None - self._account_name = None + self._with_family = None self._max_interval = None self._gps_accuracy_threshold = None @@ -64,6 +66,10 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): vol.Required( CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "") ): str, + vol.Optional( + CONF_WITH_FAMILY, + default=user_input.get(CONF_WITH_FAMILY, DEFAULT_WITH_FAMILY), + ): bool, } ), errors=errors or {}, @@ -83,6 +89,7 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): self._username = user_input[CONF_USERNAME] self._password = user_input[CONF_PASSWORD] + self._with_family = user_input.get(CONF_WITH_FAMILY, DEFAULT_WITH_FAMILY) self._max_interval = user_input.get(CONF_MAX_INTERVAL, DEFAULT_MAX_INTERVAL) self._gps_accuracy_threshold = user_input.get( CONF_GPS_ACCURACY_THRESHOLD, DEFAULT_GPS_ACCURACY_THRESHOLD @@ -95,7 +102,13 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): try: self.api = await self.hass.async_add_executor_job( - PyiCloudService, self._username, self._password, icloud_dir.path + PyiCloudService, + self._username, + self._password, + icloud_dir.path, + True, + None, + self._with_family, ) except PyiCloudFailedLoginException as error: _LOGGER.error("Error logging into iCloud service: %s", error) @@ -122,6 +135,7 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): data={ CONF_USERNAME: self._username, CONF_PASSWORD: self._password, + CONF_WITH_FAMILY: self._with_family, CONF_MAX_INTERVAL: self._max_interval, CONF_GPS_ACCURACY_THRESHOLD: self._gps_accuracy_threshold, }, @@ -211,6 +225,7 @@ class IcloudFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): { CONF_USERNAME: self._username, CONF_PASSWORD: self._password, + CONF_WITH_FAMILY: self._with_family, CONF_MAX_INTERVAL: self._max_interval, CONF_GPS_ACCURACY_THRESHOLD: self._gps_accuracy_threshold, } diff --git a/homeassistant/components/icloud/const.py b/homeassistant/components/icloud/const.py index 14bd4e498bd..d62bacf1212 100644 --- a/homeassistant/components/icloud/const.py +++ b/homeassistant/components/icloud/const.py @@ -2,9 +2,11 @@ DOMAIN = "icloud" +CONF_WITH_FAMILY = "with_family" CONF_MAX_INTERVAL = "max_interval" CONF_GPS_ACCURACY_THRESHOLD = "gps_accuracy_threshold" +DEFAULT_WITH_FAMILY = False DEFAULT_MAX_INTERVAL = 30 # min DEFAULT_GPS_ACCURACY_THRESHOLD = 500 # meters diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json index f1931f7cb5c..6cea7dc1175 100644 --- a/homeassistant/components/icloud/strings.json +++ b/homeassistant/components/icloud/strings.json @@ -7,7 +7,8 @@ "description": "Enter your credentials", "data": { "username": "Email", - "password": "Password" + "password": "Password", + "with_family": "With family" } }, "trusted_device": { diff --git a/tests/components/icloud/test_config_flow.py b/tests/components/icloud/test_config_flow.py index 646d62a09b8..4bce35d0a63 100644 --- a/tests/components/icloud/test_config_flow.py +++ b/tests/components/icloud/test_config_flow.py @@ -12,8 +12,10 @@ from homeassistant.components.icloud.config_flow import ( from homeassistant.components.icloud.const import ( CONF_GPS_ACCURACY_THRESHOLD, CONF_MAX_INTERVAL, + CONF_WITH_FAMILY, DEFAULT_GPS_ACCURACY_THRESHOLD, DEFAULT_MAX_INTERVAL, + DEFAULT_WITH_FAMILY, DOMAIN, ) from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER @@ -25,6 +27,7 @@ from tests.common import MockConfigEntry USERNAME = "username@me.com" USERNAME_2 = "second_username@icloud.com" PASSWORD = "password" +WITH_FAMILY = True MAX_INTERVAL = 15 GPS_ACCURACY_THRESHOLD = 250 @@ -106,7 +109,7 @@ async def test_user(hass: HomeAssistantType, service: MagicMock): assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "user" - # test with all provided + # test with required result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -124,20 +127,25 @@ async def test_user_with_cookie( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, - data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}, + data={ + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + CONF_WITH_FAMILY: WITH_FAMILY, + }, ) assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["result"].unique_id == USERNAME assert result["title"] == USERNAME assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_WITH_FAMILY] == WITH_FAMILY assert result["data"][CONF_MAX_INTERVAL] == DEFAULT_MAX_INTERVAL assert result["data"][CONF_GPS_ACCURACY_THRESHOLD] == DEFAULT_GPS_ACCURACY_THRESHOLD async def test_import(hass: HomeAssistantType, service: MagicMock): """Test import step.""" - # import with username and password + # import with required result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, @@ -153,6 +161,7 @@ async def test_import(hass: HomeAssistantType, service: MagicMock): data={ CONF_USERNAME: USERNAME_2, CONF_PASSWORD: PASSWORD, + CONF_WITH_FAMILY: WITH_FAMILY, CONF_MAX_INTERVAL: MAX_INTERVAL, CONF_GPS_ACCURACY_THRESHOLD: GPS_ACCURACY_THRESHOLD, }, @@ -165,7 +174,7 @@ async def test_import_with_cookie( hass: HomeAssistantType, service_authenticated: MagicMock ): """Test import step with presence of a cookie.""" - # import with username and password + # import with required result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, @@ -176,6 +185,7 @@ async def test_import_with_cookie( assert result["title"] == USERNAME assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_WITH_FAMILY] == DEFAULT_WITH_FAMILY assert result["data"][CONF_MAX_INTERVAL] == DEFAULT_MAX_INTERVAL assert result["data"][CONF_GPS_ACCURACY_THRESHOLD] == DEFAULT_GPS_ACCURACY_THRESHOLD @@ -186,6 +196,7 @@ async def test_import_with_cookie( data={ CONF_USERNAME: USERNAME_2, CONF_PASSWORD: PASSWORD, + CONF_WITH_FAMILY: WITH_FAMILY, CONF_MAX_INTERVAL: MAX_INTERVAL, CONF_GPS_ACCURACY_THRESHOLD: GPS_ACCURACY_THRESHOLD, }, @@ -195,6 +206,7 @@ async def test_import_with_cookie( assert result["title"] == USERNAME_2 assert result["data"][CONF_USERNAME] == USERNAME_2 assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_WITH_FAMILY] == WITH_FAMILY assert result["data"][CONF_MAX_INTERVAL] == MAX_INTERVAL assert result["data"][CONF_GPS_ACCURACY_THRESHOLD] == GPS_ACCURACY_THRESHOLD @@ -209,7 +221,7 @@ async def test_two_accounts_setup( unique_id=USERNAME, ).add_to_hass(hass) - # import with username and password + # import with required result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, @@ -220,6 +232,7 @@ async def test_two_accounts_setup( assert result["title"] == USERNAME_2 assert result["data"][CONF_USERNAME] == USERNAME_2 assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_WITH_FAMILY] == DEFAULT_WITH_FAMILY assert result["data"][CONF_MAX_INTERVAL] == DEFAULT_MAX_INTERVAL assert result["data"][CONF_GPS_ACCURACY_THRESHOLD] == DEFAULT_GPS_ACCURACY_THRESHOLD @@ -361,6 +374,7 @@ async def test_verification_code_success(hass: HomeAssistantType, service: Magic assert result["title"] == USERNAME assert result["data"][CONF_USERNAME] == USERNAME assert result["data"][CONF_PASSWORD] == PASSWORD + assert result["data"][CONF_WITH_FAMILY] == DEFAULT_WITH_FAMILY assert result["data"][CONF_MAX_INTERVAL] == DEFAULT_MAX_INTERVAL assert result["data"][CONF_GPS_ACCURACY_THRESHOLD] == DEFAULT_GPS_ACCURACY_THRESHOLD From 180bcad47796c7a80fbc23962dce508ddaa9f3c8 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Wed, 11 Mar 2020 14:52:27 -0500 Subject: [PATCH 354/416] Add codeowner for roku. (#32695) --- CODEOWNERS | 1 + homeassistant/components/roku/manifest.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index e1868bd17a2..97b347b8415 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -295,6 +295,7 @@ homeassistant/components/repetier/* @MTrab homeassistant/components/rfxtrx/* @danielhiversen homeassistant/components/ring/* @balloob homeassistant/components/rmvtransport/* @cgtobi +homeassistant/components/roku/* @ctalkington homeassistant/components/roomba/* @pschmitt homeassistant/components/safe_mode/* @home-assistant/core homeassistant/components/saj/* @fredericvl diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index ba67f61b2ee..20461c789e2 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -5,5 +5,5 @@ "requirements": ["roku==4.0.0"], "dependencies": [], "after_dependencies": ["discovery"], - "codeowners": [] + "codeowners": ["@ctalkington"] } From 71155f548f28c2414bb676131fe717fdd613bad0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 11 Mar 2020 20:54:27 +0100 Subject: [PATCH 355/416] Bumped version to 0.107.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 66db936669b..e3a4ea8e45c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 107 -PATCH_VERSION = "0.dev0" +PATCH_VERSION = "0b0" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 943c7ee11a8011b0d31e91a3e3e0f977a2baa583 Mon Sep 17 00:00:00 2001 From: Barry Williams Date: Thu, 12 Mar 2020 08:54:25 +0000 Subject: [PATCH 356/416] If device has volume disabled, the volume will be `None`. However in these (#32702) instances whenever the volume was requested a division calculation was made resulting in a TypeError. The volume adjustment from `0-100` to `0-1` is now calculated during the `update()` method. --- homeassistant/components/openhome/media_player.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index 5d6ee47c3eb..967bce6007e 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -85,7 +85,7 @@ class OpenhomeDevice(MediaPlayerDevice): self._supported_features |= ( SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET ) - self._volume_level = self._device.VolumeLevel() + self._volume_level = self._device.VolumeLevel() / 100.0 self._volume_muted = self._device.IsMuted() for source in self._device.Sources(): @@ -222,7 +222,7 @@ class OpenhomeDevice(MediaPlayerDevice): @property def volume_level(self): """Volume level of the media player (0..1).""" - return self._volume_level / 100.0 + return self._volume_level @property def is_volume_muted(self): From bfacd9a1c394b6ff5d518344703c8a377bb85ff7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 12 Mar 2020 11:55:18 +0100 Subject: [PATCH 357/416] Remove deprecated hide_if_away from device trackers (#32705) --- .../components/device_tracker/__init__.py | 12 +------ .../components/device_tracker/const.py | 3 -- .../components/device_tracker/legacy.py | 14 +------- .../device_sun_light_trigger/test_init.py | 2 -- tests/components/device_tracker/test_init.py | 34 ------------------- .../unifi_direct/test_device_tracker.py | 3 +- 6 files changed, 3 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index c66bb621ad4..6d8e2307145 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -25,12 +25,10 @@ from .const import ( ATTR_LOCATION_NAME, ATTR_MAC, ATTR_SOURCE_TYPE, - CONF_AWAY_HIDE, CONF_CONSIDER_HOME, CONF_NEW_DEVICE_DEFAULTS, CONF_SCAN_INTERVAL, CONF_TRACK_NEW, - DEFAULT_AWAY_HIDE, DEFAULT_CONSIDER_HOME, DEFAULT_TRACK_NEW, DOMAIN, @@ -53,15 +51,7 @@ SOURCE_TYPES = ( NEW_DEVICE_DEFAULTS_SCHEMA = vol.Any( None, - vol.All( - cv.deprecated(CONF_AWAY_HIDE, invalidation_version="0.107.0"), - vol.Schema( - { - vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean, - vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean, - } - ), - ), + vol.Schema({vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean}), ) PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( { diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py index 06313deccb6..c9ce9f2024a 100644 --- a/homeassistant/components/device_tracker/const.py +++ b/homeassistant/components/device_tracker/const.py @@ -20,9 +20,6 @@ SCAN_INTERVAL = timedelta(seconds=12) CONF_TRACK_NEW = "track_new_devices" DEFAULT_TRACK_NEW = True -CONF_AWAY_HIDE = "hide_if_away" -DEFAULT_AWAY_HIDE = False - CONF_CONSIDER_HOME = "consider_home" DEFAULT_CONSIDER_HOME = timedelta(seconds=180) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 68908f8c79f..515b7cbc614 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -37,11 +37,9 @@ from .const import ( ATTR_HOST_NAME, ATTR_MAC, ATTR_SOURCE_TYPE, - CONF_AWAY_HIDE, CONF_CONSIDER_HOME, CONF_NEW_DEVICE_DEFAULTS, CONF_TRACK_NEW, - DEFAULT_AWAY_HIDE, DEFAULT_CONSIDER_HOME, DEFAULT_TRACK_NEW, DOMAIN, @@ -198,7 +196,6 @@ class DeviceTracker: mac, picture=picture, icon=icon, - hide_if_away=self.defaults.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE), ) self.devices[dev_id] = device if mac is not None: @@ -303,7 +300,6 @@ class Device(RestoreEntity): picture: str = None, gravatar: str = None, icon: str = None, - hide_if_away: bool = False, ) -> None: """Initialize a device.""" self.hass = hass @@ -331,8 +327,6 @@ class Device(RestoreEntity): self.icon = icon - self.away_hide = hide_if_away - self.source_type = None self._attributes = {} @@ -372,11 +366,6 @@ class Device(RestoreEntity): """Return device state attributes.""" return self._attributes - @property - def hidden(self): - """If device should be hidden.""" - return self.away_hide and self.state != STATE_HOME - async def async_seen( self, host_name: str = None, @@ -524,7 +513,6 @@ async def async_load_config( vol.Optional(CONF_MAC, default=None): vol.Any( None, vol.All(cv.string, vol.Upper) ), - vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean, vol.Optional("gravatar", default=None): vol.Any(None, cv.string), vol.Optional("picture", default=None): vol.Any(None, cv.string), vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All( @@ -544,6 +532,7 @@ async def async_load_config( for dev_id, device in devices.items(): # Deprecated option. We just ignore it to avoid breaking change device.pop("vendor", None) + device.pop("hide_if_away", None) try: device = dev_schema(device) device["dev_id"] = cv.slugify(dev_id) @@ -564,7 +553,6 @@ def update_config(path: str, dev_id: str, device: Device): ATTR_ICON: device.icon, "picture": device.config_picture, "track": device.track, - CONF_AWAY_HIDE: device.away_hide, } } out.write("\n") diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index bc4d44e1b42..9cc794380f9 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -34,7 +34,6 @@ def scanner(hass): "homeassistant.components.device_tracker.legacy.load_yaml_config_file", return_value={ "device_1": { - "hide_if_away": False, "mac": "DEV1", "name": "Unnamed Device", "picture": "http://example.com/dev1.jpg", @@ -42,7 +41,6 @@ def scanner(hass): "vendor": None, }, "device_2": { - "hide_if_away": False, "mac": "DEV2", "name": "Unnamed Device", "picture": "http://example.com/dev2.jpg", diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py index 3ad9e741aae..1a21ad4a7a4 100644 --- a/tests/components/device_tracker/test_init.py +++ b/tests/components/device_tracker/test_init.py @@ -15,7 +15,6 @@ from homeassistant.const import ( ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_GPS_ACCURACY, - ATTR_HIDDEN, ATTR_ICON, ATTR_LATITUDE, ATTR_LONGITUDE, @@ -107,7 +106,6 @@ async def test_reading_yaml_config(hass, yaml_devices): "AB:CD:EF:GH:IJ", "Test name", picture="http://test.picture", - hide_if_away=True, icon="mdi:kettle", ) await hass.async_add_executor_job( @@ -121,7 +119,6 @@ async def test_reading_yaml_config(hass, yaml_devices): assert device.track == config.track assert device.mac == config.mac assert device.config_picture == config.config_picture - assert device.away_hide == config.away_hide assert device.consider_home == config.consider_home assert device.icon == config.icon @@ -284,7 +281,6 @@ async def test_entity_attributes(hass, mock_device_tracker_conf): None, friendly_name, picture, - hide_if_away=True, icon=icon, ) devices.append(device) @@ -299,25 +295,6 @@ async def test_entity_attributes(hass, mock_device_tracker_conf): assert picture == attrs.get(ATTR_ENTITY_PICTURE) -async def test_device_hidden(hass, mock_device_tracker_conf): - """Test hidden devices.""" - devices = mock_device_tracker_conf - dev_id = "test_entity" - entity_id = f"{const.DOMAIN}.{dev_id}" - device = legacy.Device( - hass, timedelta(seconds=180), True, dev_id, None, hide_if_away=True - ) - devices.append(device) - - scanner = getattr(hass.components, "test.device_tracker").SCANNER - scanner.reset() - - with assert_setup_component(1, device_tracker.DOMAIN): - assert await async_setup_component(hass, device_tracker.DOMAIN, TEST_PLATFORM) - - assert hass.states.get(entity_id).attributes.get(ATTR_HIDDEN) - - @patch("homeassistant.components.device_tracker.legacy." "DeviceTracker.async_see") async def test_see_service(mock_see, hass): """Test the see service with a unicode dev_id and NO MAC.""" @@ -609,17 +586,6 @@ async def test_picture_and_icon_on_see_discovery(mock_device_tracker_conf, hass) assert mock_device_tracker_conf[0].entity_picture == "pic_url" -async def test_default_hide_if_away_is_used(mock_device_tracker_conf, hass): - """Test that default track_new is used.""" - tracker = legacy.DeviceTracker( - hass, timedelta(seconds=60), False, {device_tracker.CONF_AWAY_HIDE: True}, [] - ) - await tracker.async_see(dev_id=12) - await hass.async_block_till_done() - assert len(mock_device_tracker_conf) == 1 - assert mock_device_tracker_conf[0].away_hide - - async def test_backward_compatibility_for_track_new(mock_device_tracker_conf, hass): """Test backward compatibility for track new.""" tracker = legacy.DeviceTracker( diff --git a/tests/components/unifi_direct/test_device_tracker.py b/tests/components/unifi_direct/test_device_tracker.py index 84602f438f4..297bf917dbf 100644 --- a/tests/components/unifi_direct/test_device_tracker.py +++ b/tests/components/unifi_direct/test_device_tracker.py @@ -7,7 +7,6 @@ import pytest import voluptuous as vol from homeassistant.components.device_tracker import ( - CONF_AWAY_HIDE, CONF_CONSIDER_HOME, CONF_NEW_DEVICE_DEFAULTS, CONF_TRACK_NEW, @@ -49,7 +48,7 @@ async def test_get_scanner(unifi_mock, hass): CONF_PASSWORD: "fake_pass", CONF_TRACK_NEW: True, CONF_CONSIDER_HOME: timedelta(seconds=180), - CONF_NEW_DEVICE_DEFAULTS: {CONF_TRACK_NEW: True, CONF_AWAY_HIDE: False}, + CONF_NEW_DEVICE_DEFAULTS: {CONF_TRACK_NEW: True}, } } From 8db426e5da1a03ddf98e70ba5400b7a8cba2b24d Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 11 Mar 2020 19:34:54 -0600 Subject: [PATCH 358/416] Broaden exception handling for IQVIA (#32708) --- homeassistant/components/iqvia/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py index 3e62eb9b1ee..a33dabeadeb 100644 --- a/homeassistant/components/iqvia/__init__.py +++ b/homeassistant/components/iqvia/__init__.py @@ -4,7 +4,7 @@ from datetime import timedelta import logging from pyiqvia import Client -from pyiqvia.errors import InvalidZipError, IQVIAError +from pyiqvia.errors import InvalidZipError import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT @@ -172,7 +172,7 @@ class IQVIAData: results = await asyncio.gather(*tasks.values(), return_exceptions=True) for key, result in zip(tasks, results): - if isinstance(result, IQVIAError): + if isinstance(result, Exception): _LOGGER.error("Unable to get %s data: %s", key, result) self.data[key] = {} continue From c46d0e4a49c1bc94712afda6d5f92f0d8744587a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 12 Mar 2020 14:47:57 -0700 Subject: [PATCH 359/416] Sonos idle (#32712) * Sonos idle * F-string * Add to properties * Fixes --- .../components/sonos/media_player.py | 44 ++++++++++--------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 0ab78195cb2..8828c27e9c7 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -93,6 +93,8 @@ ATTR_NIGHT_SOUND = "night_sound" ATTR_SPEECH_ENHANCE = "speech_enhance" ATTR_QUEUE_POSITION = "queue_position" +UNAVAILABLE_VALUES = {"", "NOT_IMPLEMENTED", None} + class SonosData: """Storage class for platform global data.""" @@ -330,7 +332,7 @@ def soco_coordinator(funct): def _timespan_secs(timespan): """Parse a time-span into number of seconds.""" - if timespan in ("", "NOT_IMPLEMENTED", None): + if timespan in UNAVAILABLE_VALUES: return None return sum(60 ** x[0] * int(x[1]) for x in enumerate(reversed(timespan.split(":")))) @@ -427,7 +429,11 @@ class SonosEntity(MediaPlayerDevice): @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: + return STATE_IDLE return STATE_PAUSED if self._status in ("PLAYING", "TRANSITIONING"): return STATE_PLAYING @@ -511,16 +517,14 @@ class SonosEntity(MediaPlayerDevice): def _radio_artwork(self, url): """Return the private URL with artwork for a radio stream.""" - if url not in ("", "NOT_IMPLEMENTED", None): - if url.find("tts_proxy") > 0: - # If the content is a tts don't try to fetch an image from it. - return None - url = "http://{host}:{port}/getaa?s=1&u={uri}".format( - host=self.soco.ip_address, - port=1400, - uri=urllib.parse.quote(url, safe=""), - ) - return url + if url in UNAVAILABLE_VALUES: + return None + + if url.find("tts_proxy") > 0: + # If the content is a tts don't try to fetch an image from it. + return None + + return f"http://{self.soco.ip_address}:1400/getaa?s=1&u={urllib.parse.quote(url, safe='')}" def _attach_player(self): """Get basic information and add event subscriptions.""" @@ -606,9 +610,9 @@ class SonosEntity(MediaPlayerDevice): self._media_image_url = None - self._media_artist = source + self._media_artist = None self._media_album_name = None - self._media_title = None + self._media_title = source self._source_name = source @@ -640,7 +644,7 @@ class SonosEntity(MediaPlayerDevice): # For radio streams we set the radio station name as the title. current_uri_metadata = media_info["CurrentURIMetaData"] - if current_uri_metadata not in ("", "NOT_IMPLEMENTED", None): + if current_uri_metadata not in UNAVAILABLE_VALUES: # currently soco does not have an API for this current_uri_metadata = pysonos.xml.XML.fromstring( pysonos.utils.really_utf8(current_uri_metadata) @@ -650,7 +654,7 @@ class SonosEntity(MediaPlayerDevice): ".//{http://purl.org/dc/elements/1.1/}title" ) - if md_title not in ("", "NOT_IMPLEMENTED", None): + if md_title not in UNAVAILABLE_VALUES: self._media_title = md_title if self._media_artist and self._media_title: @@ -867,25 +871,25 @@ class SonosEntity(MediaPlayerDevice): @soco_coordinator def media_artist(self): """Artist of current playing media, music track only.""" - return self._media_artist + return self._media_artist or None @property @soco_coordinator def media_album_name(self): """Album name of current playing media, music track only.""" - return self._media_album_name + return self._media_album_name or None @property @soco_coordinator def media_title(self): """Title of current playing media.""" - return self._media_title + return self._media_title or None @property @soco_coordinator def source(self): """Name of the current input source.""" - return self._source_name + return self._source_name or None @property @soco_coordinator From fe6ca522e8fed2b93282d1dbeb97fe32e40ec2dd Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 12 Mar 2020 06:01:05 -0400 Subject: [PATCH 360/416] =?UTF-8?q?Update=20Vizio=20`source`=20property=20?= =?UTF-8?q?to=20only=20return=20current=20app=20if=20i=E2=80=A6=20(#32713)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * only return current app for source if current app is set * check for None specifically * make sure current app isn't called for speaker --- homeassistant/components/vizio/media_player.py | 2 +- tests/components/vizio/test_media_player.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 63918737411..69a430bb997 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -304,7 +304,7 @@ class VizioDevice(MediaPlayerDevice): @property def source(self) -> str: """Return current input of the device.""" - if self._current_input in INPUT_APPS: + if self._current_app is not None and self._current_input in INPUT_APPS: return self._current_app return self._current_input diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 19696af73a2..68366e8e98b 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -112,7 +112,9 @@ async def _test_setup( ), patch( "homeassistant.components.vizio.media_player.VizioAsync.get_power_state", return_value=vizio_power_state, - ): + ), patch( + "homeassistant.components.vizio.media_player.VizioAsync.get_current_app", + ) as service_call: config_entry.add_to_hass(hass) assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() @@ -125,6 +127,8 @@ async def _test_setup( if ha_power_state == STATE_ON: assert attr["source_list"] == INPUT_LIST assert attr["source"] == CURRENT_INPUT + if ha_device_class == DEVICE_CLASS_SPEAKER: + assert not service_call.called assert ( attr["volume_level"] == float(int(MAX_VOLUME[vizio_device_class] / 2)) From 9f76a8c12de9a4c01c6c7c81abd3574006d6d85a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 12 Mar 2020 04:31:55 -0500 Subject: [PATCH 361/416] =?UTF-8?q?Resolve=20Home=20Assistant=20fails=20to?= =?UTF-8?q?=20start=20when=20Sense=20integration=20i=E2=80=A6=20(#32716)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Bump sense_energy 0.7.1 which also fixes throwing ConfigEntryNotReady --- homeassistant/components/sense/manifest.json | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 61f09fb444b..c07e1e4f5c3 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -3,11 +3,11 @@ "name": "Sense", "documentation": "https://www.home-assistant.io/integrations/sense", "requirements": [ - "sense_energy==0.7.0" + "sense_energy==0.7.1" ], "dependencies": [], "codeowners": [ "@kbickar" ], "config_flow": true -} \ No newline at end of file +} diff --git a/requirements_all.txt b/requirements_all.txt index 2d7434f6293..8c5439e9273 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1837,7 +1837,7 @@ sendgrid==6.1.1 sense-hat==2.2.0 # homeassistant.components.sense -sense_energy==0.7.0 +sense_energy==0.7.1 # homeassistant.components.sentry sentry-sdk==0.13.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 71aa2004eef..56370e493ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -632,7 +632,7 @@ samsungctl[websocket]==0.7.1 samsungtvws[websocket]==1.4.0 # homeassistant.components.sense -sense_energy==0.7.0 +sense_energy==0.7.1 # homeassistant.components.sentry sentry-sdk==0.13.5 From 2f2a9085730b89adadc12947a88c3a2ccba60b7e Mon Sep 17 00:00:00 2001 From: escoand Date: Thu, 12 Mar 2020 10:29:11 +0100 Subject: [PATCH 362/416] Fix legacy Samsung TV (#32719) * Update bridge.py * Update test_init.py --- homeassistant/components/samsungtv/bridge.py | 5 +++-- tests/components/samsungtv/test_init.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 5203c61a978..31f102a62a4 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -130,10 +130,11 @@ class SamsungTVLegacyBridge(SamsungTVBridge): super().__init__(method, host, None) self.config = { CONF_NAME: VALUE_CONF_NAME, - CONF_ID: VALUE_CONF_ID, CONF_DESCRIPTION: VALUE_CONF_NAME, - CONF_METHOD: method, + CONF_ID: VALUE_CONF_ID, CONF_HOST: host, + CONF_METHOD: method, + CONF_PORT: None, CONF_TIMEOUT: 1, } diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 064a870931f..232a04416d5 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -34,6 +34,7 @@ REMOTE_CALL = { "id": "ha.component.samsung", "method": "legacy", "host": MOCK_CONFIG[SAMSUNGTV_DOMAIN][0][CONF_HOST], + "port": None, "timeout": 1, } From 9ad776e55d66e839a80a203b2d9e8c7404e58272 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 12 Mar 2020 10:04:34 -0400 Subject: [PATCH 363/416] Set self._current_app to None when vizio device is off (#32725) --- homeassistant/components/vizio/media_player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py index 69a430bb997..d013f41403a 100644 --- a/homeassistant/components/vizio/media_player.py +++ b/homeassistant/components/vizio/media_player.py @@ -190,6 +190,7 @@ class VizioDevice(MediaPlayerDevice): self._is_muted = None self._current_input = None self._available_inputs = None + self._current_app = None self._available_apps = None return From 3345d85dcaf2c93deff95ea8fcf94bf3cd644eb0 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Thu, 12 Mar 2020 22:41:04 +0100 Subject: [PATCH 364/416] Updated frontend to 20200312.0 (#32741) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index ad34c0c112c..c3cf353dba1 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200311.1" + "home-assistant-frontend==20200312.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 39c1d76b999..56ab609c5af 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.32.2 -home-assistant-frontend==20200311.1 +home-assistant-frontend==20200312.0 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8c5439e9273..6ea136b003c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -696,7 +696,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200311.1 +home-assistant-frontend==20200312.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 56370e493ab..adaeaa76198 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -263,7 +263,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200311.1 +home-assistant-frontend==20200312.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 From a66f4ca4ec5e27e1bb39098cdc90e3b6f5047611 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 12 Mar 2020 17:02:38 -0700 Subject: [PATCH 365/416] Bumped version to 0.107.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index e3a4ea8e45c..b0d64876dd5 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 107 -PATCH_VERSION = "0b0" +PATCH_VERSION = "0b1" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 7bdac8ef2ed51601e4a78547f05a1b935087be2f Mon Sep 17 00:00:00 2001 From: Austin Mroczek Date: Thu, 12 Mar 2020 23:34:09 -0700 Subject: [PATCH 366/416] Bump total-connect-client to 0.54.1 #32758) --- homeassistant/components/totalconnect/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json index 3ebb319ad07..4675ef0ffaf 100644 --- a/homeassistant/components/totalconnect/manifest.json +++ b/homeassistant/components/totalconnect/manifest.json @@ -2,7 +2,7 @@ "domain": "totalconnect", "name": "Honeywell Total Connect Alarm", "documentation": "https://www.home-assistant.io/integrations/totalconnect", - "requirements": ["total_connect_client==0.53"], + "requirements": ["total_connect_client==0.54.1"], "dependencies": [], "codeowners": ["@austinmroczek"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6ea136b003c..0fb4804f8cd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2017,7 +2017,7 @@ todoist-python==8.0.0 toonapilib==3.2.4 # homeassistant.components.totalconnect -total_connect_client==0.53 +total_connect_client==0.54.1 # homeassistant.components.tplink_lte tp-connected==0.0.4 From 4f78e0431555320ffc70400a4b305146eaa0c2a2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 13 Mar 2020 12:07:53 -0500 Subject: [PATCH 367/416] Bump py-august to 0.25.0 (#32769) Fixes a bug in the conversion to async where code validation failed. --- homeassistant/components/august/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index ca757ae5ad3..f1085b81554 100644 --- a/homeassistant/components/august/manifest.json +++ b/homeassistant/components/august/manifest.json @@ -3,7 +3,7 @@ "name": "August", "documentation": "https://www.home-assistant.io/integrations/august", "requirements": [ - "py-august==0.24.0" + "py-august==0.25.0" ], "dependencies": [ "configurator" diff --git a/requirements_all.txt b/requirements_all.txt index 0fb4804f8cd..34cf1661b3c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1088,7 +1088,7 @@ pushover_complete==1.1.1 pwmled==1.5.0 # homeassistant.components.august -py-august==0.24.0 +py-august==0.25.0 # homeassistant.components.canary py-canary==0.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index adaeaa76198..ba75636010b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -397,7 +397,7 @@ pure-python-adb==0.2.2.dev0 pushbullet.py==0.11.0 # homeassistant.components.august -py-august==0.24.0 +py-august==0.25.0 # homeassistant.components.canary py-canary==0.5.0 From 7268bcd9be413360a077966e982a1c39c36291af Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 13 Mar 2020 19:55:53 +0100 Subject: [PATCH 368/416] Check if panel url used and delay dashboard reg till start (#32771) * Check if panel url used and delay dashboard reg till start * move storage_dashboard_changed * fix tests --- homeassistant/components/frontend/__init__.py | 3 +- homeassistant/components/lovelace/__init__.py | 53 ++++++++++--------- .../components/lovelace/dashboard.py | 5 +- tests/components/frontend/test_init.py | 11 +--- tests/components/lovelace/test_dashboard.py | 8 +++ 5 files changed, 42 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 1e3dea98619..d9a39ce5726 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -276,8 +276,7 @@ async def async_setup(hass, config): hass.http.app.router.register_resource(IndexView(repo_path, hass)) - for panel in ("kiosk", "states", "profile"): - async_register_built_in_panel(hass, panel) + async_register_built_in_panel(hass, "profile") # To smooth transition to new urls, add redirects to new urls of dev tools # Added June 27, 2019. Can be removed in 2021. diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 23e8a14e511..8ed5e1abfbb 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -4,7 +4,7 @@ import logging import voluptuous as vol from homeassistant.components import frontend -from homeassistant.const import CONF_FILENAME +from homeassistant.const import CONF_FILENAME, EVENT_HOMEASSISTANT_START from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, config_validation as cv @@ -127,25 +127,12 @@ async def async_setup(hass, config): # We store a dictionary mapping url_path: config. None is the default. "dashboards": {None: default_config}, "resources": resource_collection, + "yaml_dashboards": config[DOMAIN].get(CONF_DASHBOARDS, {}), } if hass.config.safe_mode: return True - # Process YAML dashboards - for url_path, dashboard_conf in config[DOMAIN].get(CONF_DASHBOARDS, {}).items(): - # For now always mode=yaml - config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf) - hass.data[DOMAIN]["dashboards"][url_path] = config - - try: - _register_panel(hass, url_path, MODE_YAML, dashboard_conf, False) - except ValueError: - _LOGGER.warning("Panel url path %s is not unique", url_path) - - # Process storage dashboards - dashboards_collection = dashboard.DashboardsCollection(hass) - async def storage_dashboard_changed(change_type, item_id, item): """Handle a storage dashboard change.""" url_path = item[CONF_URL_PATH] @@ -180,16 +167,34 @@ async def async_setup(hass, config): except ValueError: _LOGGER.warning("Failed to %s panel %s from storage", change_type, url_path) - dashboards_collection.async_add_listener(storage_dashboard_changed) - await dashboards_collection.async_load() + async def async_setup_dashboards(event): + """Register dashboards on startup.""" + # Process YAML dashboards + for url_path, dashboard_conf in hass.data[DOMAIN]["yaml_dashboards"].items(): + # For now always mode=yaml + config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf) + hass.data[DOMAIN]["dashboards"][url_path] = config - collection.StorageCollectionWebsocket( - dashboards_collection, - "lovelace/dashboards", - "dashboard", - STORAGE_DASHBOARD_CREATE_FIELDS, - STORAGE_DASHBOARD_UPDATE_FIELDS, - ).async_setup(hass, create_list=False) + try: + _register_panel(hass, url_path, MODE_YAML, dashboard_conf, False) + except ValueError: + _LOGGER.warning("Panel url path %s is not unique", url_path) + + # Process storage dashboards + dashboards_collection = dashboard.DashboardsCollection(hass) + + dashboards_collection.async_add_listener(storage_dashboard_changed) + await dashboards_collection.async_load() + + collection.StorageCollectionWebsocket( + dashboards_collection, + "lovelace/dashboards", + "dashboard", + STORAGE_DASHBOARD_CREATE_FIELDS, + STORAGE_DASHBOARD_UPDATE_FIELDS, + ).async_setup(hass, create_list=False) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, async_setup_dashboards) return True diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 514f1eb87b6..f32ac2ed1ff 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -6,6 +6,7 @@ import time import voluptuous as vol +from homeassistant.components.frontend import DATA_PANELS from homeassistant.const import CONF_FILENAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError @@ -231,8 +232,8 @@ class DashboardsCollection(collection.StorageCollection): async def _process_create_data(self, data: dict) -> dict: """Validate the config is valid.""" - if data[CONF_URL_PATH] in self.hass.data[DOMAIN]["dashboards"]: - raise vol.Invalid("Dashboard url path needs to be unique") + if data[CONF_URL_PATH] in self.hass.data[DATA_PANELS]: + raise vol.Invalid("Panel url path needs to be unique") return self.CREATE_SCHEMA(data) diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index 627bf23341d..36243972fb6 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -106,15 +106,6 @@ async def test_we_cannot_POST_to_root(mock_http_client): assert resp.status == 405 -async def test_states_routes(mock_http_client): - """All served by index.""" - resp = await mock_http_client.get("/states") - assert resp.status == 200 - - resp = await mock_http_client.get("/states/group.existing") - assert resp.status == 200 - - async def test_themes_api(hass, hass_ws_client): """Test that /api/themes returns correct data.""" assert await async_setup_component(hass, "frontend", CONFIG_THEMES) @@ -217,7 +208,7 @@ async def test_missing_themes(hass, hass_ws_client): 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("/states?latest") + 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 diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 21a44bc771d..9bfe3da38c9 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -5,6 +5,7 @@ import pytest from homeassistant.components import frontend from homeassistant.components.lovelace import const, dashboard +from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.setup import async_setup_component from tests.common import async_capture_events, get_system_health_info @@ -223,6 +224,8 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path): } }, ) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() assert hass.data[frontend.DATA_PANELS]["test-panel"].config == {"mode": "yaml"} assert hass.data[frontend.DATA_PANELS]["test-panel-no-sidebar"].config == { "mode": "yaml" @@ -306,6 +309,8 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path): async def test_storage_dashboards(hass, hass_ws_client, hass_storage): """Test we load lovelace config from storage.""" assert await async_setup_component(hass, "lovelace", {}) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "storage"} client = await hass_ws_client(hass) @@ -450,6 +455,9 @@ async def test_websocket_list_dashboards(hass, hass_ws_client): }, ) + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + client = await hass_ws_client(hass) # Create a storage dashboard From 56686dd14c85e10213939f5e1bdc222076881900 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 13 Mar 2020 12:16:55 -0700 Subject: [PATCH 369/416] Bumped version to 0.107.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b0d64876dd5..17cbe0fd239 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 107 -PATCH_VERSION = "0b1" +PATCH_VERSION = "0b2" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 6780bded7eb1a3645881eac8cc945836ba03213a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 13 Mar 2020 21:43:39 +0100 Subject: [PATCH 370/416] Updated frontend to 20200313.0 (#32777) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index c3cf353dba1..2817b744d72 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200312.0" + "home-assistant-frontend==20200313.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 56ab609c5af..49c9d017e3a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.32.2 -home-assistant-frontend==20200312.0 +home-assistant-frontend==20200313.0 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 34cf1661b3c..f1cdb9851c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -696,7 +696,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200312.0 +home-assistant-frontend==20200313.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba75636010b..f9226696907 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -263,7 +263,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200312.0 +home-assistant-frontend==20200313.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.8 From dfd29e6d739d5b9274d3129e4c80036a52d4c675 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 13 Mar 2020 22:23:34 +0100 Subject: [PATCH 371/416] Bumped version to 0.107.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 17cbe0fd239..d4a6dd1484f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 107 -PATCH_VERSION = "0b2" +PATCH_VERSION = "0b3" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From e365dc398b5cced35307cafde9dd4fb5eb1b07c7 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Sat, 14 Mar 2020 19:43:09 +0000 Subject: [PATCH 372/416] Fix homekit_controller beta connectivity issues (#32810) --- homeassistant/components/homekit_controller/manifest.json | 2 +- homeassistant/components/homekit_controller/media_player.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 5ff719dde8c..a73d68227c7 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit[IP]==0.2.29"], + "requirements": ["aiohomekit[IP]==0.2.29.1"], "dependencies": [], "zeroconf": ["_hap._tcp.local."], "codeowners": ["@Jc2k"] diff --git a/homeassistant/components/homekit_controller/media_player.py b/homeassistant/components/homekit_controller/media_player.py index 2e4b05817bb..3a1a7359e13 100644 --- a/homeassistant/components/homekit_controller/media_player.py +++ b/homeassistant/components/homekit_controller/media_player.py @@ -154,7 +154,7 @@ class HomeKitTelevision(HomeKitEntity, MediaPlayerDevice): homekit_state = self.service.value(CharacteristicsTypes.CURRENT_MEDIA_STATE) if homekit_state is not None: - return HK_TO_HA_STATE[homekit_state] + return HK_TO_HA_STATE.get(homekit_state, STATE_OK) return STATE_OK diff --git a/requirements_all.txt b/requirements_all.txt index f1cdb9851c6..44367ce8f11 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -163,7 +163,7 @@ aioftp==0.12.0 aioharmony==0.1.13 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.29 +aiohomekit[IP]==0.2.29.1 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f9226696907..45c1de0dadf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -62,7 +62,7 @@ aiobotocore==0.11.1 aioesphomeapi==2.6.1 # homeassistant.components.homekit_controller -aiohomekit[IP]==0.2.29 +aiohomekit[IP]==0.2.29.1 # homeassistant.components.emulated_hue # homeassistant.components.http From 86c4fa0fc5e18d3615db1925aadac9ff1ed7f7d0 Mon Sep 17 00:00:00 2001 From: Slava Date: Fri, 13 Mar 2020 23:42:47 +0100 Subject: [PATCH 373/416] Add brightness state to emulated hue when devices support only color temp and brightness (#31834) --- homeassistant/components/emulated_hue/hue_api.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 9a2d624a55f..06a57960898 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -677,7 +677,11 @@ def entity_to_json(config, entity): retval["type"] = "Color temperature light" retval["modelid"] = "HASS312" retval["state"].update( - {HUE_API_STATE_COLORMODE: "ct", HUE_API_STATE_CT: state[STATE_COLOR_TEMP]} + { + HUE_API_STATE_COLORMODE: "ct", + HUE_API_STATE_CT: state[STATE_COLOR_TEMP], + HUE_API_STATE_BRI: state[STATE_BRIGHTNESS], + } ) elif entity_features & ( SUPPORT_BRIGHTNESS From 1b622925a131d1bfaa9eb6ce4bec65742b9a127c Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Fri, 13 Mar 2020 23:55:21 -0500 Subject: [PATCH 374/416] Optimize directv client initialization (#32706) * Optimize directv client initialization. * Update config_flow.py * Update media_player.py * Update media_player.py * Update __init__.py * Update __init__.py * Update __init__.py * Update __init__.py * Update test_media_player.py * Update __init__.py * Update media_player.py * Update test_media_player.py * Update media_player.py * Update test_media_player.py * Update config_flow.py * Update test_config_flow.py * Update test_config_flow.py * Update test_config_flow.py * Update test_config_flow.py * Update test_config_flow.py * Update test_config_flow.py * Update __init__.py * Update test_config_flow.py * Update test_config_flow.py * Update test_media_player.py * Update test_media_player.py * Update __init__.py * Update __init__.py * Update __init__.py --- homeassistant/components/directv/__init__.py | 2 +- .../components/directv/config_flow.py | 6 +- .../components/directv/media_player.py | 27 ++----- tests/components/directv/__init__.py | 23 ++++-- tests/components/directv/test_config_flow.py | 20 ++--- tests/components/directv/test_media_player.py | 80 +++++-------------- 6 files changed, 50 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py index d9f3f171992..fc7bb78989a 100644 --- a/homeassistant/components/directv/__init__.py +++ b/homeassistant/components/directv/__init__.py @@ -32,7 +32,7 @@ def get_dtv_data( hass: HomeAssistant, host: str, port: int = DEFAULT_PORT, client_addr: str = "0" ) -> dict: """Retrieve a DIRECTV instance, locations list, and version info for the receiver device.""" - dtv = DIRECTV(host, port, client_addr) + dtv = DIRECTV(host, port, client_addr, determine_state=False) locations = dtv.get_locations() version_info = dtv.get_version() diff --git a/homeassistant/components/directv/config_flow.py b/homeassistant/components/directv/config_flow.py index 27ddf2cda7b..d1b3a6cbe62 100644 --- a/homeassistant/components/directv/config_flow.py +++ b/homeassistant/components/directv/config_flow.py @@ -29,8 +29,7 @@ def validate_input(data: Dict) -> Dict: Data has the keys from DATA_SCHEMA with values provided by the user. """ - # directpy does IO in constructor. - dtv = DIRECTV(data["host"], DEFAULT_PORT) + dtv = DIRECTV(data["host"], DEFAULT_PORT, determine_state=False) version_info = dtv.get_version() return { @@ -76,8 +75,7 @@ class DirecTVConfigFlow(ConfigFlow, domain=DOMAIN): return self._show_form(errors) except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected exception") - errors["base"] = ERROR_UNKNOWN - return self._show_form(errors) + return self.async_abort(reason=ERROR_UNKNOWN) await self.async_set_unique_id(info["receiver_id"]) self._abort_if_unique_id_configured() diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index c1c227d319d..b04ef9fed68 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -83,22 +83,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def get_dtv_instance( - host: str, port: int = DEFAULT_PORT, client_addr: str = "0" -) -> DIRECTV: - """Retrieve a DIRECTV instance for the receiver or client device.""" - try: - return DIRECTV(host, port, client_addr) - except RequestException as exception: - _LOGGER.debug( - "Request exception %s trying to retrieve DIRECTV instance for client address %s on device %s", - exception, - client_addr, - host, - ) - return None - - async def async_setup_entry( hass: HomeAssistantType, entry: ConfigEntry, @@ -114,16 +98,15 @@ async def async_setup_entry( continue if loc["clientAddr"] != "0": - # directpy does IO in constructor. - dtv = await hass.async_add_executor_job( - get_dtv_instance, entry.data[CONF_HOST], DEFAULT_PORT, loc["clientAddr"] + dtv = DIRECTV( + entry.data[CONF_HOST], + DEFAULT_PORT, + loc["clientAddr"], + determine_state=False, ) else: dtv = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] - if not dtv: - continue - entities.append( DirecTvDevice( str.title(loc["locationName"]), loc["clientAddr"], dtv, version_info, diff --git a/tests/components/directv/__init__.py b/tests/components/directv/__init__.py index d7f79c76be5..876b1e311ab 100644 --- a/tests/components/directv/__init__.py +++ b/tests/components/directv/__init__.py @@ -1,4 +1,6 @@ """Tests for the DirecTV component.""" +from DirectPy import DIRECTV + from homeassistant.components.directv.const import DOMAIN from homeassistant.const import CONF_HOST from homeassistant.helpers.typing import HomeAssistantType @@ -94,18 +96,23 @@ MOCK_GET_VERSION = { } -class MockDirectvClass: +class MockDirectvClass(DIRECTV): """A fake DirecTV DVR device.""" - def __init__(self, ip, port=8080, clientAddr="0"): + def __init__(self, ip, port=8080, clientAddr="0", determine_state=False): """Initialize the fake DirecTV device.""" - self._host = ip - self._port = port - self._device = clientAddr - self._standby = True - self._play = False + super().__init__( + ip=ip, port=port, clientAddr=clientAddr, determine_state=determine_state, + ) - self.attributes = LIVE + self._play = False + self._standby = True + + if self.clientAddr == CLIENT_ADDRESS: + self.attributes = RECORDING + self._standby = False + else: + self.attributes = LIVE def get_locations(self): """Mock for get_locations method.""" diff --git a/tests/components/directv/test_config_flow.py b/tests/components/directv/test_config_flow.py index 5516b61cd46..bd5d8b83419 100644 --- a/tests/components/directv/test_config_flow.py +++ b/tests/components/directv/test_config_flow.py @@ -114,9 +114,7 @@ async def test_form_cannot_connect(hass: HomeAssistantType) -> None: ) with patch( - "homeassistant.components.directv.config_flow.DIRECTV", new=MockDirectvClass, - ), patch( - "homeassistant.components.directv.config_flow.DIRECTV.get_version", + "tests.components.directv.test_config_flow.MockDirectvClass.get_version", side_effect=RequestException, ) as mock_validate_input: result = await async_configure_flow(hass, result["flow_id"], {CONF_HOST: HOST},) @@ -135,15 +133,13 @@ async def test_form_unknown_error(hass: HomeAssistantType) -> None: ) with patch( - "homeassistant.components.directv.config_flow.DIRECTV", new=MockDirectvClass, - ), patch( - "homeassistant.components.directv.config_flow.DIRECTV.get_version", + "tests.components.directv.test_config_flow.MockDirectvClass.get_version", side_effect=Exception, ) as mock_validate_input: result = await async_configure_flow(hass, result["flow_id"], {CONF_HOST: HOST},) - assert result["type"] == RESULT_TYPE_FORM - assert result["errors"] == {"base": "unknown"} + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "unknown" await hass.async_block_till_done() assert len(mock_validate_input.mock_calls) == 1 @@ -205,9 +201,7 @@ async def test_ssdp_discovery_confirm_abort(hass: HomeAssistantType) -> None: ) with patch( - "homeassistant.components.directv.config_flow.DIRECTV", new=MockDirectvClass, - ), patch( - "homeassistant.components.directv.config_flow.DIRECTV.get_version", + "tests.components.directv.test_config_flow.MockDirectvClass.get_version", side_effect=RequestException, ) as mock_validate_input: result = await async_configure_flow(hass, result["flow_id"], {}) @@ -227,9 +221,7 @@ async def test_ssdp_discovery_confirm_unknown_error(hass: HomeAssistantType) -> ) with patch( - "homeassistant.components.directv.config_flow.DIRECTV", new=MockDirectvClass, - ), patch( - "homeassistant.components.directv.config_flow.DIRECTV.get_version", + "tests.components.directv.test_config_flow.MockDirectvClass.get_version", side_effect=Exception, ) as mock_validate_input: result = await async_configure_flow(hass, result["flow_id"], {}) diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py index 9c06164c309..f7cf63355a8 100644 --- a/tests/components/directv/test_media_player.py +++ b/tests/components/directv/test_media_player.py @@ -54,9 +54,7 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.directv import ( - CLIENT_ADDRESS, DOMAIN, - HOST, MOCK_GET_LOCATIONS_MULTIPLE, RECORDING, MockDirectvClass, @@ -70,15 +68,6 @@ MAIN_ENTITY_ID = f"{MP_DOMAIN}.main_dvr" # pylint: disable=redefined-outer-name -@fixture -def client_dtv() -> MockDirectvClass: - """Fixture for a client device.""" - mocked_dtv = MockDirectvClass(HOST, clientAddr=CLIENT_ADDRESS) - mocked_dtv.attributes = RECORDING - mocked_dtv._standby = False # pylint: disable=protected-access - return mocked_dtv - - @fixture def mock_now() -> datetime: """Fixture for dtutil.now.""" @@ -93,34 +82,19 @@ async def setup_directv(hass: HomeAssistantType) -> MockConfigEntry: return await setup_integration(hass) -async def setup_directv_with_instance_error(hass: HomeAssistantType) -> MockConfigEntry: +async def setup_directv_with_locations(hass: HomeAssistantType) -> MockConfigEntry: """Set up mock DirecTV integration.""" with patch( - "homeassistant.components.directv.DIRECTV", new=MockDirectvClass, - ), patch( - "homeassistant.components.directv.DIRECTV.get_locations", + "tests.components.directv.test_media_player.MockDirectvClass.get_locations", return_value=MOCK_GET_LOCATIONS_MULTIPLE, - ), patch( - "homeassistant.components.directv.media_player.get_dtv_instance", - return_value=None, ): - return await setup_integration(hass) - - -async def setup_directv_with_locations( - hass: HomeAssistantType, client_dtv: MockDirectvClass, -) -> MockConfigEntry: - """Set up mock DirecTV integration.""" - with patch( - "homeassistant.components.directv.DIRECTV", new=MockDirectvClass, - ), patch( - "homeassistant.components.directv.DIRECTV.get_locations", - return_value=MOCK_GET_LOCATIONS_MULTIPLE, - ), patch( - "homeassistant.components.directv.media_player.get_dtv_instance", - return_value=client_dtv, - ): - return await setup_integration(hass) + with patch( + "homeassistant.components.directv.DIRECTV", new=MockDirectvClass, + ), patch( + "homeassistant.components.directv.media_player.DIRECTV", + new=MockDirectvClass, + ): + return await setup_integration(hass) async def async_turn_on( @@ -204,27 +178,17 @@ async def test_setup(hass: HomeAssistantType) -> None: assert hass.states.get(MAIN_ENTITY_ID) -async def test_setup_with_multiple_locations( - hass: HomeAssistantType, client_dtv: MockDirectvClass -) -> None: +async def test_setup_with_multiple_locations(hass: HomeAssistantType) -> None: """Test setup with basic config with client location.""" - await setup_directv_with_locations(hass, client_dtv) + await setup_directv_with_locations(hass) assert hass.states.get(MAIN_ENTITY_ID) assert hass.states.get(CLIENT_ENTITY_ID) -async def test_setup_with_instance_error(hass: HomeAssistantType) -> None: - """Test setup with basic config with client location that results in instance error.""" - await setup_directv_with_instance_error(hass) - - assert hass.states.get(MAIN_ENTITY_ID) - assert hass.states.async_entity_ids(MP_DOMAIN) == [MAIN_ENTITY_ID] - - -async def test_unique_id(hass: HomeAssistantType, client_dtv: MockDirectvClass) -> None: +async def test_unique_id(hass: HomeAssistantType) -> None: """Test unique id.""" - await setup_directv_with_locations(hass, client_dtv) + await setup_directv_with_locations(hass) entity_registry = await hass.helpers.entity_registry.async_get_registry() @@ -235,11 +199,9 @@ async def test_unique_id(hass: HomeAssistantType, client_dtv: MockDirectvClass) assert client.unique_id == "2CA17D1CD30X" -async def test_supported_features( - hass: HomeAssistantType, client_dtv: MockDirectvClass -) -> None: +async def test_supported_features(hass: HomeAssistantType) -> None: """Test supported features.""" - await setup_directv_with_locations(hass, client_dtv) + await setup_directv_with_locations(hass) # Features supported for main DVR state = hass.states.get(MAIN_ENTITY_ID) @@ -269,10 +231,10 @@ async def test_supported_features( async def test_check_attributes( - hass: HomeAssistantType, mock_now: dt_util.dt.datetime, client_dtv: MockDirectvClass + hass: HomeAssistantType, mock_now: dt_util.dt.datetime ) -> None: """Test attributes.""" - await setup_directv_with_locations(hass, client_dtv) + await setup_directv_with_locations(hass) next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): @@ -321,10 +283,10 @@ async def test_check_attributes( async def test_main_services( - hass: HomeAssistantType, mock_now: dt_util.dt.datetime, client_dtv: MockDirectvClass + hass: HomeAssistantType, mock_now: dt_util.dt.datetime ) -> None: """Test the different services.""" - await setup_directv_with_locations(hass, client_dtv) + await setup_directv(hass) next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): @@ -373,10 +335,10 @@ async def test_main_services( async def test_available( - hass: HomeAssistantType, mock_now: dt_util.dt.datetime, client_dtv: MockDirectvClass + hass: HomeAssistantType, mock_now: dt_util.dt.datetime ) -> None: """Test available status.""" - entry = await setup_directv_with_locations(hass, client_dtv) + entry = await setup_directv(hass) next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): From 0788bbd62991b989328d4b52c7c0d262fa37fb3a Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Sun, 15 Mar 2020 18:18:15 +0100 Subject: [PATCH 375/416] Add log message on timeout and update less often for upnp devices (#32740) * Catch asyncio.TimeoutError, show a proper message instead * Throttle updates to max once per 30s * Change code owner * Fix CODEOWNERS + linting * Warn on connection timeout --- CODEOWNERS | 2 +- homeassistant/components/upnp/device.py | 20 ++++++++++++++++---- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/components/upnp/sensor.py | 5 +++++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 97b347b8415..19bb89ff51e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -386,7 +386,7 @@ homeassistant/components/unifiled/* @florisvdk homeassistant/components/upc_connect/* @pvizeli homeassistant/components/upcloud/* @scop homeassistant/components/updater/* @home-assistant/core -homeassistant/components/upnp/* @robbiet480 +homeassistant/components/upnp/* @StevenLooman homeassistant/components/uptimerobot/* @ludeeus homeassistant/components/usgs_earthquakes_feed/* @exxamalte homeassistant/components/utility_meter/* @dgomes diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py index b144d2b96ed..474170050c3 100644 --- a/homeassistant/components/upnp/device.py +++ b/homeassistant/components/upnp/device.py @@ -142,16 +142,28 @@ class Device: async def async_get_total_bytes_received(self): """Get total bytes received.""" - return await self._igd_device.async_get_total_bytes_received() + try: + return await self._igd_device.async_get_total_bytes_received() + except asyncio.TimeoutError: + _LOGGER.warning("Timeout during get_total_bytes_received") async def async_get_total_bytes_sent(self): """Get total bytes sent.""" - return await self._igd_device.async_get_total_bytes_sent() + try: + return await self._igd_device.async_get_total_bytes_sent() + except asyncio.TimeoutError: + _LOGGER.warning("Timeout during get_total_bytes_sent") async def async_get_total_packets_received(self): """Get total packets received.""" - return await self._igd_device.async_get_total_packets_received() + try: + return await self._igd_device.async_get_total_packets_received() + except asyncio.TimeoutError: + _LOGGER.warning("Timeout during get_total_packets_received") async def async_get_total_packets_sent(self): """Get total packets sent.""" - return await self._igd_device.async_get_total_packets_sent() + try: + return await self._igd_device.async_get_total_packets_sent() + except asyncio.TimeoutError: + _LOGGER.warning("Timeout during get_total_packets_sent") diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 1e55d60f95e..47ad465eb36 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/upnp", "requirements": ["async-upnp-client==0.14.12"], "dependencies": [], - "codeowners": ["@robbiet480"] + "codeowners": ["@StevenLooman"] } diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index c77a1b6279f..9632997ac1b 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -1,4 +1,5 @@ """Support for UPnP/IGD Sensors.""" +from datetime import timedelta import logging from homeassistant.const import DATA_BYTES, DATA_KIBIBYTES, TIME_SECONDS @@ -7,6 +8,7 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import Throttle import homeassistant.util.dt as dt_util from .const import DOMAIN as DOMAIN_UPNP, SIGNAL_REMOVE_SENSOR @@ -29,6 +31,8 @@ IN = "received" OUT = "sent" KIBIBYTE = 1024 +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) + async def async_setup_platform( hass: HomeAssistantType, config, async_add_entities, discovery_info=None @@ -142,6 +146,7 @@ class RawUPnPIGDSensor(UpnpSensor): """Return the unit of measurement of this entity, if any.""" return self._type["unit"] + @Throttle(MIN_TIME_BETWEEN_UPDATES) async def async_update(self): """Get the latest information from the IGD.""" if self._type_name == BYTES_RECEIVED: From 706607f1d21a82f9a0ad40e0fa19a3fff5504965 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Fri, 13 Mar 2020 19:17:50 -0400 Subject: [PATCH 376/416] Fix handling of attribute reports in ZHA sensors and binary sensors (#32776) * Update sensor tests. * Update light tests. * Update binary_sensor tests. * Update cover tests. * Update device tracker tests. * Update fan tests. * Update lock tests. * Update switch tests. * add sensor attr to sensors * add sensor attr to binary sensors * cleanup extra var Co-authored-by: Alexei Chetroi --- homeassistant/components/zha/binary_sensor.py | 8 ++++ homeassistant/components/zha/sensor.py | 11 ++++++ tests/components/zha/common.py | 17 +++++++++ tests/components/zha/test_binary_sensor.py | 14 ++----- tests/components/zha/test_cover.py | 14 ++----- tests/components/zha/test_device_tracker.py | 13 ++----- tests/components/zha/test_fan.py | 13 ++----- tests/components/zha/test_light.py | 25 ++++-------- tests/components/zha/test_lock.py | 16 ++------ tests/components/zha/test_sensor.py | 38 +++++++------------ tests/components/zha/test_switch.py | 12 ++---- 11 files changed, 77 insertions(+), 104 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index a40bd62e83c..6c88f3e1013 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -64,6 +64,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class BinarySensor(ZhaEntity, BinarySensorDevice): """ZHA BinarySensor.""" + SENSOR_ATTR = None DEVICE_CLASS = None def __init__(self, unique_id, zha_device, channels, **kwargs): @@ -105,6 +106,8 @@ class BinarySensor(ZhaEntity, BinarySensorDevice): @callback def async_set_state(self, attr_id, attr_name, value): """Set the state.""" + if self.SENSOR_ATTR is None or self.SENSOR_ATTR != attr_name: + return self._state = bool(value) self.async_write_ha_state() @@ -121,6 +124,7 @@ class BinarySensor(ZhaEntity, BinarySensorDevice): class Accelerometer(BinarySensor): """ZHA BinarySensor.""" + SENSOR_ATTR = "acceleration" DEVICE_CLASS = DEVICE_CLASS_MOVING @@ -128,6 +132,7 @@ class Accelerometer(BinarySensor): class Occupancy(BinarySensor): """ZHA BinarySensor.""" + SENSOR_ATTR = "occupancy" DEVICE_CLASS = DEVICE_CLASS_OCCUPANCY @@ -135,6 +140,7 @@ class Occupancy(BinarySensor): class Opening(BinarySensor): """ZHA BinarySensor.""" + SENSOR_ATTR = "on_off" DEVICE_CLASS = DEVICE_CLASS_OPENING @@ -142,6 +148,8 @@ class Opening(BinarySensor): class IASZone(BinarySensor): """ZHA IAS BinarySensor.""" + SENSOR_ATTR = "zone_status" + async def get_device_class(self) -> None: """Get the HA device class from the channel.""" zone_type = await self._channel.get_attribute_value("zone_type") diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 3953db27f20..8182fdcabcf 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -83,6 +83,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class Sensor(ZhaEntity): """Base ZHA sensor.""" + SENSOR_ATTR = None _decimals = 1 _device_class = None _divisor = 1 @@ -126,6 +127,8 @@ class Sensor(ZhaEntity): @callback def async_set_state(self, attr_id, attr_name, value): """Handle state update from channel.""" + if self.SENSOR_ATTR is None or self.SENSOR_ATTR != attr_name: + return if value is not None: value = self.formatter(value) self._state = value @@ -154,6 +157,7 @@ class Sensor(ZhaEntity): class AnalogInput(Sensor): """Sensor that displays analog input values.""" + SENSOR_ATTR = "present_value" pass @@ -161,6 +165,7 @@ class AnalogInput(Sensor): class Battery(Sensor): """Battery sensor of power configuration cluster.""" + SENSOR_ATTR = "battery_percentage_remaining" _device_class = DEVICE_CLASS_BATTERY _unit = UNIT_PERCENTAGE @@ -198,6 +203,7 @@ class Battery(Sensor): class ElectricalMeasurement(Sensor): """Active power measurement.""" + SENSOR_ATTR = "active_power" _device_class = DEVICE_CLASS_POWER _divisor = 10 _unit = POWER_WATT @@ -232,6 +238,7 @@ class Text(Sensor): class Humidity(Sensor): """Humidity sensor.""" + SENSOR_ATTR = "measured_value" _device_class = DEVICE_CLASS_HUMIDITY _divisor = 100 _unit = UNIT_PERCENTAGE @@ -241,6 +248,7 @@ class Humidity(Sensor): class Illuminance(Sensor): """Illuminance Sensor.""" + SENSOR_ATTR = "measured_value" _device_class = DEVICE_CLASS_ILLUMINANCE _unit = "lx" @@ -254,6 +262,7 @@ class Illuminance(Sensor): class SmartEnergyMetering(Sensor): """Metering sensor.""" + SENSOR_ATTR = "instantaneous_demand" _device_class = DEVICE_CLASS_POWER def formatter(self, value): @@ -270,6 +279,7 @@ class SmartEnergyMetering(Sensor): class Pressure(Sensor): """Pressure sensor.""" + SENSOR_ATTR = "measured_value" _device_class = DEVICE_CLASS_PRESSURE _decimals = 0 _unit = "hPa" @@ -279,6 +289,7 @@ class Pressure(Sensor): class Temperature(Sensor): """Temperature Sensor.""" + SENSOR_ATTR = "measured_value" _device_class = DEVICE_CLASS_TEMPERATURE _divisor = 100 _unit = TEMP_CELSIUS diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 3eb6f407f32..3753136d59d 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -102,6 +102,23 @@ def make_attribute(attrid, value, status=0): return attr +def send_attribute_report(hass, cluster, attrid, value): + """Send a single attribute report.""" + return send_attributes_report(hass, cluster, {attrid: value}) + + +async def send_attributes_report(hass, cluster: int, attributes: dict): + """Cause the sensor to receive an attribute report from the network. + + This is to simulate the normal device communication that happens when a + device is paired to the zigbee network. + """ + attrs = [make_attribute(attrid, value) for attrid, value in attributes.items()] + hdr = make_zcl_header(zcl_f.Command.Report_Attributes) + cluster.handle_message(hdr, [attrs]) + await hass.async_block_till_done() + + async def find_entity_id(domain, zha_device, hass): """Find the entity id under the testing. diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index a22bfa54dae..730c7c844f2 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -2,7 +2,6 @@ import pytest import zigpy.zcl.clusters.measurement as measurement import zigpy.zcl.clusters.security as security -import zigpy.zcl.foundation as zcl_f from homeassistant.components.binary_sensor import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE @@ -11,8 +10,7 @@ from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, - make_attribute, - make_zcl_header, + send_attributes_report, ) DEVICE_IAS = { @@ -36,17 +34,11 @@ DEVICE_OCCUPANCY = { async def async_test_binary_sensor_on_off(hass, cluster, entity_id): """Test getting on and off messages for binary sensors.""" # binary sensor on - attr = make_attribute(0, 1) - hdr = make_zcl_header(zcl_f.Command.Report_Attributes) - - cluster.handle_message(hdr, [[attr]]) - await hass.async_block_till_done() + await send_attributes_report(hass, cluster, {1: 0, 0: 1, 2: 2}) assert hass.states.get(entity_id).state == STATE_ON # binary sensor off - attr.value.value = 0 - cluster.handle_message(hdr, [[attr]]) - await hass.async_block_till_done() + await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 2}) assert hass.states.get(entity_id).state == STATE_OFF diff --git a/tests/components/zha/test_cover.py b/tests/components/zha/test_cover.py index 3ece16d8116..188ddf69a23 100644 --- a/tests/components/zha/test_cover.py +++ b/tests/components/zha/test_cover.py @@ -14,8 +14,7 @@ from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, - make_attribute, - make_zcl_header, + send_attributes_report, ) from tests.common import mock_coro @@ -64,19 +63,12 @@ async def test_cover(m1, hass, zha_device_joined_restored, zigpy_cover_device): await async_enable_traffic(hass, [zha_device]) await hass.async_block_till_done() - attr = make_attribute(8, 100) - hdr = make_zcl_header(zcl_f.Command.Report_Attributes) - cluster.handle_message(hdr, [[attr]]) - await hass.async_block_till_done() - # test that the state has changed from unavailable to off + await send_attributes_report(hass, cluster, {0: 0, 8: 100, 1: 1}) assert hass.states.get(entity_id).state == STATE_CLOSED # test to see if it opens - attr = make_attribute(8, 0) - hdr = make_zcl_header(zcl_f.Command.Report_Attributes) - cluster.handle_message(hdr, [[attr]]) - await hass.async_block_till_done() + await send_attributes_report(hass, cluster, {0: 1, 8: 0, 1: 100}) assert hass.states.get(entity_id).state == STATE_OPEN # close from UI diff --git a/tests/components/zha/test_device_tracker.py b/tests/components/zha/test_device_tracker.py index 3782cdc09a7..330153e5f8c 100644 --- a/tests/components/zha/test_device_tracker.py +++ b/tests/components/zha/test_device_tracker.py @@ -4,7 +4,6 @@ import time import pytest import zigpy.zcl.clusters.general as general -import zigpy.zcl.foundation as zcl_f from homeassistant.components.device_tracker import DOMAIN, SOURCE_TYPE_ROUTER from homeassistant.components.zha.core.registries import ( @@ -17,8 +16,7 @@ from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, - make_attribute, - make_zcl_header, + send_attributes_report, ) from tests.common import async_fire_time_changed @@ -66,12 +64,9 @@ async def test_device_tracker(hass, zha_device_joined_restored, zigpy_device_dt) assert hass.states.get(entity_id).state == STATE_NOT_HOME # turn state flip - attr = make_attribute(0x0020, 23) - hdr = make_zcl_header(zcl_f.Command.Report_Attributes) - cluster.handle_message(hdr, [[attr]]) - - attr = make_attribute(0x0021, 200) - cluster.handle_message(hdr, [[attr]]) + await send_attributes_report( + hass, cluster, {0x0000: 0, 0x0020: 23, 0x0021: 200, 0x0001: 2} + ) zigpy_device_dt.last_seen = time.time() + 10 next_update = dt_util.utcnow() + timedelta(seconds=30) diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 0cf3e3e954d..5011a847a4e 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -3,7 +3,6 @@ from unittest.mock import call import pytest import zigpy.zcl.clusters.hvac as hvac -import zigpy.zcl.foundation as zcl_f from homeassistant.components import fan from homeassistant.components.fan import ATTR_SPEED, DOMAIN, SERVICE_SET_SPEED @@ -20,8 +19,7 @@ from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, - make_attribute, - make_zcl_header, + send_attributes_report, ) @@ -52,16 +50,11 @@ async def test_fan(hass, zha_device_joined_restored, zigpy_device): assert hass.states.get(entity_id).state == STATE_OFF # turn on at fan - attr = make_attribute(0, 1) - hdr = make_zcl_header(zcl_f.Command.Report_Attributes) - cluster.handle_message(hdr, [[attr]]) - await hass.async_block_till_done() + await send_attributes_report(hass, cluster, {1: 2, 0: 1, 2: 3}) assert hass.states.get(entity_id).state == STATE_ON # turn off at fan - attr.value.value = 0 - cluster.handle_message(hdr, [[attr]]) - await hass.async_block_till_done() + await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 2}) assert hass.states.get(entity_id).state == STATE_OFF # turn on from HA diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 6f5bd23e297..f27bd329bdb 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -19,8 +19,7 @@ from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, - make_attribute, - make_zcl_header, + send_attributes_report, ) from tests.common import async_fire_time_changed @@ -190,26 +189,18 @@ async def test_light( async def async_test_on_off_from_light(hass, cluster, entity_id): """Test on off functionality from the light.""" # turn on at light - attr = make_attribute(0, 1) - hdr = make_zcl_header(zcl_f.Command.Report_Attributes) - cluster.handle_message(hdr, [[attr]]) - await hass.async_block_till_done() + await send_attributes_report(hass, cluster, {1: 0, 0: 1, 2: 3}) assert hass.states.get(entity_id).state == STATE_ON # turn off at light - attr.value.value = 0 - cluster.handle_message(hdr, [[attr]]) - await hass.async_block_till_done() + await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 3}) assert hass.states.get(entity_id).state == STATE_OFF async def async_test_on_from_light(hass, cluster, entity_id): """Test on off functionality from the light.""" # turn on at light - attr = make_attribute(0, 1) - hdr = make_zcl_header(zcl_f.Command.Report_Attributes) - cluster.handle_message(hdr, [[attr]]) - await hass.async_block_till_done() + await send_attributes_report(hass, cluster, {1: -1, 0: 1, 2: 2}) assert hass.states.get(entity_id).state == STATE_ON @@ -316,10 +307,10 @@ async def async_test_level_on_off_from_hass( async def async_test_dimmer_from_light(hass, cluster, entity_id, level, expected_state): """Test dimmer functionality from the light.""" - attr = make_attribute(0, level) - hdr = make_zcl_header(zcl_f.Command.Report_Attributes) - cluster.handle_message(hdr, [[attr]]) - await hass.async_block_till_done() + + await send_attributes_report( + hass, cluster, {1: level + 10, 0: level, 2: level - 10 or 22} + ) assert hass.states.get(entity_id).state == expected_state # hass uses None for brightness of 0 in state attributes if level == 0: diff --git a/tests/components/zha/test_lock.py b/tests/components/zha/test_lock.py index 0442ea497d7..86ec266ffa2 100644 --- a/tests/components/zha/test_lock.py +++ b/tests/components/zha/test_lock.py @@ -10,12 +10,7 @@ import zigpy.zcl.foundation as zcl_f from homeassistant.components.lock import DOMAIN from homeassistant.const import STATE_LOCKED, STATE_UNAVAILABLE, STATE_UNLOCKED -from .common import ( - async_enable_traffic, - find_entity_id, - make_attribute, - make_zcl_header, -) +from .common import async_enable_traffic, find_entity_id, send_attributes_report from tests.common import mock_coro @@ -58,16 +53,11 @@ async def test_lock(hass, lock): assert hass.states.get(entity_id).state == STATE_UNLOCKED # set state to locked - attr = make_attribute(0, 1) - hdr = make_zcl_header(zcl_f.Command.Report_Attributes) - cluster.handle_message(hdr, [[attr]]) - await hass.async_block_till_done() + await send_attributes_report(hass, cluster, {1: 0, 0: 1, 2: 2}) assert hass.states.get(entity_id).state == STATE_LOCKED # set state to unlocked - attr.value.value = 2 - cluster.handle_message(hdr, [[attr]]) - await hass.async_block_till_done() + await send_attributes_report(hass, cluster, {1: 0, 0: 2, 2: 3}) assert hass.states.get(entity_id).state == STATE_UNLOCKED # lock from HA diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index fce882c6949..50b85f5720f 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -6,7 +6,6 @@ import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.homeautomation as homeautomation import zigpy.zcl.clusters.measurement as measurement import zigpy.zcl.clusters.smartenergy as smartenergy -import zigpy.zcl.foundation as zcl_f from homeassistant.components.sensor import DOMAIN import homeassistant.config as config_util @@ -28,38 +27,41 @@ from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, - make_attribute, - make_zcl_header, + send_attribute_report, + send_attributes_report, ) async def async_test_humidity(hass, cluster, entity_id): """Test humidity sensor.""" - await send_attribute_report(hass, cluster, 0, 1000) + await send_attributes_report(hass, cluster, {1: 1, 0: 1000, 2: 100}) assert_state(hass, entity_id, "10.0", UNIT_PERCENTAGE) async def async_test_temperature(hass, cluster, entity_id): """Test temperature sensor.""" - await send_attribute_report(hass, cluster, 0, 2900) + await send_attributes_report(hass, cluster, {1: 1, 0: 2900, 2: 100}) assert_state(hass, entity_id, "29.0", "°C") async def async_test_pressure(hass, cluster, entity_id): """Test pressure sensor.""" - await send_attribute_report(hass, cluster, 0, 1000) + await send_attributes_report(hass, cluster, {1: 1, 0: 1000, 2: 10000}) + assert_state(hass, entity_id, "1000", "hPa") + + await send_attributes_report(hass, cluster, {0: 1000, 20: -1, 16: 10000}) assert_state(hass, entity_id, "1000", "hPa") async def async_test_illuminance(hass, cluster, entity_id): """Test illuminance sensor.""" - await send_attribute_report(hass, cluster, 0, 10) + await send_attributes_report(hass, cluster, {1: 1, 0: 10, 2: 20}) assert_state(hass, entity_id, "1.0", "lx") async def async_test_metering(hass, cluster, entity_id): """Test metering sensor.""" - await send_attribute_report(hass, cluster, 1024, 12345) + await send_attributes_report(hass, cluster, {1025: 1, 1024: 12345, 1026: 100}) assert_state(hass, entity_id, "12345.0", "unknown") @@ -73,17 +75,17 @@ async def async_test_electrical_measurement(hass, cluster, entity_id): new_callable=mock.PropertyMock, ) as divisor_mock: divisor_mock.return_value = 1 - await send_attribute_report(hass, cluster, 1291, 100) + await send_attributes_report(hass, cluster, {0: 1, 1291: 100, 10: 1000}) assert_state(hass, entity_id, "100", "W") - await send_attribute_report(hass, cluster, 1291, 99) + await send_attributes_report(hass, cluster, {0: 1, 1291: 99, 10: 1000}) assert_state(hass, entity_id, "99", "W") divisor_mock.return_value = 10 - await send_attribute_report(hass, cluster, 1291, 1000) + await send_attributes_report(hass, cluster, {0: 1, 1291: 1000, 10: 5000}) assert_state(hass, entity_id, "100", "W") - await send_attribute_report(hass, cluster, 1291, 99) + await send_attributes_report(hass, cluster, {0: 1, 1291: 99, 10: 5000}) assert_state(hass, entity_id, "9.9", "W") @@ -141,18 +143,6 @@ async def test_sensor( await async_test_rejoin(hass, zigpy_device, [cluster], (report_count,)) -async def send_attribute_report(hass, cluster, attrid, value): - """Cause the sensor to receive an attribute report from the network. - - This is to simulate the normal device communication that happens when a - device is paired to the zigbee network. - """ - attr = make_attribute(attrid, value) - hdr = make_zcl_header(zcl_f.Command.Report_Attributes) - cluster.handle_message(hdr, [[attr]]) - await hass.async_block_till_done() - - def assert_state(hass, entity_id, state, unit_of_measurement): """Check that the state is what is expected. diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index 22ceb629009..98f661cc1ab 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -12,8 +12,7 @@ from .common import ( async_enable_traffic, async_test_rejoin, find_entity_id, - make_attribute, - make_zcl_header, + send_attributes_report, ) from tests.common import mock_coro @@ -53,16 +52,11 @@ async def test_switch(hass, zha_device_joined_restored, zigpy_device): assert hass.states.get(entity_id).state == STATE_OFF # turn on at switch - attr = make_attribute(0, 1) - hdr = make_zcl_header(zcl_f.Command.Report_Attributes) - cluster.handle_message(hdr, [[attr]]) - await hass.async_block_till_done() + await send_attributes_report(hass, cluster, {1: 0, 0: 1, 2: 2}) assert hass.states.get(entity_id).state == STATE_ON # turn off at switch - attr.value.value = 0 - cluster.handle_message(hdr, [[attr]]) - await hass.async_block_till_done() + await send_attributes_report(hass, cluster, {1: 1, 0: 0, 2: 2}) assert hass.states.get(entity_id).state == STATE_OFF # turn on from HA From b5c8b5b91f763545f79feb41ed383667f0401084 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Fri, 13 Mar 2020 22:58:14 +0000 Subject: [PATCH 377/416] Fix onvif error with non ptz cameras (#32783) --- homeassistant/components/onvif/camera.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 614eb4e6556..ce241f779b1 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -375,7 +375,7 @@ class ONVIFHassCamera(Camera): def setup_ptz(self): """Set up PTZ if available.""" _LOGGER.debug("Setting up the ONVIF PTZ service") - if self._camera.get_service("ptz") is None: + if self._camera.get_service("ptz", create=False) is None: _LOGGER.debug("PTZ is not available") else: self._ptz_service = self._camera.create_ptz_service() From e666485ea9ffd391541ac9d6975a6610b21c8ae9 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Sat, 14 Mar 2020 05:58:32 +0100 Subject: [PATCH 378/416] Fix brightness_pct in light device turn_on action (#32787) --- homeassistant/components/light/device_action.py | 10 +++++----- tests/components/light/test_device_action.py | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/light/device_action.py b/homeassistant/components/light/device_action.py index 5ee2785a700..5c534cc4150 100644 --- a/homeassistant/components/light/device_action.py +++ b/homeassistant/components/light/device_action.py @@ -15,7 +15,7 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry from homeassistant.helpers.typing import ConfigType, TemplateVarsType -from . import ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_STEP_PCT, DOMAIN, SUPPORT_BRIGHTNESS +from . import ATTR_BRIGHTNESS_PCT, ATTR_BRIGHTNESS_STEP_PCT, DOMAIN, SUPPORT_BRIGHTNESS TYPE_BRIGHTNESS_INCREASE = "brightness_increase" TYPE_BRIGHTNESS_DECREASE = "brightness_decrease" @@ -28,7 +28,7 @@ ACTION_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( toggle_entity.DEVICE_ACTION_TYPES + [TYPE_BRIGHTNESS_INCREASE, TYPE_BRIGHTNESS_DECREASE] ), - vol.Optional(ATTR_BRIGHTNESS): vol.All( + vol.Optional(ATTR_BRIGHTNESS_PCT): vol.All( vol.Coerce(int), vol.Range(min=0, max=100) ), } @@ -57,8 +57,8 @@ async def async_call_action_from_config( data[ATTR_BRIGHTNESS_STEP_PCT] = 10 elif config[CONF_TYPE] == TYPE_BRIGHTNESS_DECREASE: data[ATTR_BRIGHTNESS_STEP_PCT] = -10 - elif ATTR_BRIGHTNESS in config: - data[ATTR_BRIGHTNESS] = config[ATTR_BRIGHTNESS] + elif ATTR_BRIGHTNESS_PCT in config: + data[ATTR_BRIGHTNESS_PCT] = config[ATTR_BRIGHTNESS_PCT] await hass.services.async_call( DOMAIN, SERVICE_TURN_ON, data, blocking=True, context=context @@ -125,7 +125,7 @@ async def async_get_action_capabilities(hass: HomeAssistant, config: dict) -> di return { "extra_fields": vol.Schema( { - vol.Optional(ATTR_BRIGHTNESS): vol.All( + vol.Optional(ATTR_BRIGHTNESS_PCT): vol.All( vol.Coerce(int), vol.Range(min=0, max=100) ) } diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index 610f61dea52..6cddfc15744 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -126,7 +126,7 @@ async def test_get_action_capabilities_brightness(hass, device_reg, entity_reg): expected_capabilities = { "extra_fields": [ { - "name": "brightness", + "name": "brightness_pct", "optional": True, "type": "integer", "valueMax": 100, @@ -218,7 +218,7 @@ async def test_action(hass, calls): "device_id": "", "entity_id": ent1.entity_id, "type": "turn_on", - "brightness": 75, + "brightness_pct": 75, }, }, ] @@ -273,11 +273,11 @@ async def test_action(hass, calls): assert len(turn_on_calls) == 3 assert turn_on_calls[2].data["entity_id"] == ent1.entity_id - assert turn_on_calls[2].data["brightness"] == 75 + assert turn_on_calls[2].data["brightness_pct"] == 75 hass.bus.async_fire("test_on") await hass.async_block_till_done() assert len(turn_on_calls) == 4 assert turn_on_calls[3].data["entity_id"] == ent1.entity_id - assert "brightness" not in turn_on_calls[3].data + assert "brightness_pct" not in turn_on_calls[3].data From 57dd45318ddc7a2bb2afb78861992cb9af0d220d Mon Sep 17 00:00:00 2001 From: Greg <34967045+gtdiehl@users.noreply.github.com> Date: Sat, 14 Mar 2020 14:27:28 -0700 Subject: [PATCH 379/416] Bump eagle_reader API version to v0.2.4 (#32789) --- homeassistant/components/rainforest_eagle/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rainforest_eagle/manifest.json b/homeassistant/components/rainforest_eagle/manifest.json index cb8e95df42f..0649dfded99 100644 --- a/homeassistant/components/rainforest_eagle/manifest.json +++ b/homeassistant/components/rainforest_eagle/manifest.json @@ -3,7 +3,7 @@ "name": "Rainforest Eagle-200", "documentation": "https://www.home-assistant.io/integrations/rainforest_eagle", "requirements": [ - "eagle200_reader==0.2.1", + "eagle200_reader==0.2.4", "uEagle==0.0.1" ], "dependencies": [], diff --git a/requirements_all.txt b/requirements_all.txt index 44367ce8f11..fd3cedcd1a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -469,7 +469,7 @@ dweepy==0.3.0 dynalite_devices==0.1.32 # homeassistant.components.rainforest_eagle -eagle200_reader==0.2.1 +eagle200_reader==0.2.4 # homeassistant.components.ebusd ebusdpy==0.0.16 From 226a0bcaad96b3e73e540f38ada0d8409dc2289e Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sat, 14 Mar 2020 13:12:01 -0500 Subject: [PATCH 380/416] Fix directv location of unknown error string (#32807) * Update strings.json * Update en.json --- homeassistant/components/directv/.translations/en.json | 8 ++++---- homeassistant/components/directv/strings.json | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/directv/.translations/en.json b/homeassistant/components/directv/.translations/en.json index e2a8eff5783..667d5168f8d 100644 --- a/homeassistant/components/directv/.translations/en.json +++ b/homeassistant/components/directv/.translations/en.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "already_configured": "DirecTV receiver is already configured" + "already_configured": "DirecTV receiver is already configured", + "unknown": "Unexpected error" }, "error": { - "cannot_connect": "Failed to connect, please try again", - "unknown": "Unexpected error" + "cannot_connect": "Failed to connect, please try again" }, "flow_title": "DirecTV: {name}", "step": { @@ -23,4 +23,4 @@ }, "title": "DirecTV" } -} \ No newline at end of file +} diff --git a/homeassistant/components/directv/strings.json b/homeassistant/components/directv/strings.json index 78316d663bd..e0a5a477ad2 100644 --- a/homeassistant/components/directv/strings.json +++ b/homeassistant/components/directv/strings.json @@ -16,11 +16,11 @@ } }, "error": { - "cannot_connect": "Failed to connect, please try again", - "unknown": "Unexpected error" + "cannot_connect": "Failed to connect, please try again" }, "abort": { - "already_configured": "DirecTV receiver is already configured" + "already_configured": "DirecTV receiver is already configured", + "unknown": "Unexpected error" } } } From 3b1fb2f416b74e54f13093845f9baba252cf0b20 Mon Sep 17 00:00:00 2001 From: Chris Talkington Date: Sat, 14 Mar 2020 13:32:38 -0500 Subject: [PATCH 381/416] Remove extra logging from directv init. (#32809) --- homeassistant/components/directv/media_player.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index b04ef9fed68..f487e72f694 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -158,15 +158,6 @@ class DirecTvDevice(MediaPlayerDevice): self._model = MODEL_HOST self._software_version = version_info["stbSoftwareVersion"] - if self._is_client: - _LOGGER.debug( - "Created DirecTV media player for client %s on device %s", - self._name, - device, - ) - else: - _LOGGER.debug("Created DirecTV media player for device %s", self._name) - def update(self): """Retrieve latest state.""" _LOGGER.debug("%s: Updating status", self.entity_id) From 3b84b6e6d578dbf582392a3f164d23435d48e608 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Sun, 15 Mar 2020 19:50:23 +0100 Subject: [PATCH 382/416] Require a hyphen in lovelace dashboard url (#32816) * Require a hyphen in lovelace dashboard url * Keep storage dashboards working * register during startup again * Update homeassistant/components/lovelace/dashboard.py Co-Authored-By: Paulus Schoutsen * Comments Co-authored-by: Paulus Schoutsen --- homeassistant/components/lovelace/__init__.py | 47 +++--- homeassistant/components/lovelace/const.py | 2 + .../components/lovelace/dashboard.py | 23 +++ tests/components/lovelace/test_dashboard.py | 147 ++++++++++++++---- 4 files changed, 163 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 8ed5e1abfbb..220161fb649 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -4,7 +4,7 @@ import logging import voluptuous as vol from homeassistant.components import frontend -from homeassistant.const import CONF_FILENAME, EVENT_HOMEASSISTANT_START +from homeassistant.const import CONF_FILENAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, config_validation as cv @@ -143,6 +143,7 @@ async def async_setup(hass, config): return if change_type == collection.CHANGE_ADDED: + existing = hass.data[DOMAIN]["dashboards"].get(url_path) if existing: @@ -167,34 +168,30 @@ async def async_setup(hass, config): except ValueError: _LOGGER.warning("Failed to %s panel %s from storage", change_type, url_path) - async def async_setup_dashboards(event): - """Register dashboards on startup.""" - # Process YAML dashboards - for url_path, dashboard_conf in hass.data[DOMAIN]["yaml_dashboards"].items(): - # For now always mode=yaml - config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf) - hass.data[DOMAIN]["dashboards"][url_path] = config + # Process YAML dashboards + for url_path, dashboard_conf in hass.data[DOMAIN]["yaml_dashboards"].items(): + # For now always mode=yaml + config = dashboard.LovelaceYAML(hass, url_path, dashboard_conf) + hass.data[DOMAIN]["dashboards"][url_path] = config - try: - _register_panel(hass, url_path, MODE_YAML, dashboard_conf, False) - except ValueError: - _LOGGER.warning("Panel url path %s is not unique", url_path) + try: + _register_panel(hass, url_path, MODE_YAML, dashboard_conf, False) + except ValueError: + _LOGGER.warning("Panel url path %s is not unique", url_path) - # Process storage dashboards - dashboards_collection = dashboard.DashboardsCollection(hass) + # Process storage dashboards + dashboards_collection = dashboard.DashboardsCollection(hass) - dashboards_collection.async_add_listener(storage_dashboard_changed) - await dashboards_collection.async_load() + dashboards_collection.async_add_listener(storage_dashboard_changed) + await dashboards_collection.async_load() - collection.StorageCollectionWebsocket( - dashboards_collection, - "lovelace/dashboards", - "dashboard", - STORAGE_DASHBOARD_CREATE_FIELDS, - STORAGE_DASHBOARD_UPDATE_FIELDS, - ).async_setup(hass, create_list=False) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, async_setup_dashboards) + collection.StorageCollectionWebsocket( + dashboards_collection, + "lovelace/dashboards", + "dashboard", + STORAGE_DASHBOARD_CREATE_FIELDS, + STORAGE_DASHBOARD_UPDATE_FIELDS, + ).async_setup(hass, create_list=False) return True diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index 7205ae21cbe..8d7ee092cbe 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -76,6 +76,8 @@ def url_slug(value: Any) -> str: """Validate value is a valid url slug.""" if value is None: raise vol.Invalid("Slug should not be None") + if "-" not in value: + raise vol.Invalid("Url path needs to contain a hyphen (-)") str_value = str(value) slg = slugify(str_value, separator="-") if str_value == slg: diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index f32ac2ed1ff..38740672914 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -3,6 +3,7 @@ from abc import ABC, abstractmethod import logging import os import time +from typing import Optional, cast import voluptuous as vol @@ -230,8 +231,30 @@ class DashboardsCollection(collection.StorageCollection): _LOGGER, ) + async def _async_load_data(self) -> Optional[dict]: + """Load the data.""" + data = await self.store.async_load() + + if data is None: + return cast(Optional[dict], data) + + updated = False + + for item in data["items"] or []: + if "-" not in item[CONF_URL_PATH]: + updated = True + item[CONF_URL_PATH] = f"lovelace-{item[CONF_URL_PATH]}" + + if updated: + await self.store.async_save(data) + + return cast(Optional[dict], data) + async def _process_create_data(self, data: dict) -> dict: """Validate the config is valid.""" + if "-" not in data[CONF_URL_PATH]: + raise vol.Invalid("Url path needs to contain a hyphen (-)") + if data[CONF_URL_PATH] in self.hass.data[DATA_PANELS]: raise vol.Invalid("Panel url path needs to be unique") diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 9bfe3da38c9..1effb10be27 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -5,10 +5,13 @@ import pytest from homeassistant.components import frontend from homeassistant.components.lovelace import const, dashboard -from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.setup import async_setup_component -from tests.common import async_capture_events, get_system_health_info +from tests.common import ( + assert_setup_component, + async_capture_events, + get_system_health_info, +) async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage): @@ -224,8 +227,6 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path): } }, ) - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() assert hass.data[frontend.DATA_PANELS]["test-panel"].config == {"mode": "yaml"} assert hass.data[frontend.DATA_PANELS]["test-panel-no-sidebar"].config == { "mode": "yaml" @@ -306,11 +307,32 @@ async def test_dashboard_from_yaml(hass, hass_ws_client, url_path): assert len(events) == 1 +async def test_wrong_key_dashboard_from_yaml(hass): + """Test we don't load lovelace dashboard without hyphen config from yaml.""" + with assert_setup_component(0): + assert not await async_setup_component( + hass, + "lovelace", + { + "lovelace": { + "dashboards": { + "testpanel": { + "mode": "yaml", + "filename": "bla.yaml", + "title": "Test Panel", + "icon": "mdi:test-icon", + "show_in_sidebar": False, + "require_admin": True, + } + } + } + }, + ) + + async def test_storage_dashboards(hass, hass_ws_client, hass_storage): """Test we load lovelace config from storage.""" assert await async_setup_component(hass, "lovelace", {}) - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() assert hass.data[frontend.DATA_PANELS]["lovelace"].config == {"mode": "storage"} client = await hass_ws_client(hass) @@ -321,12 +343,24 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): assert response["success"] assert response["result"] == [] - # Add a dashboard + # Add a wrong dashboard await client.send_json( { "id": 6, "type": "lovelace/dashboards/create", - "url_path": "created_url_path", + "url_path": "path", + "title": "Test path without hyphen", + } + ) + response = await client.receive_json() + assert not response["success"] + + # Add a dashboard + await client.send_json( + { + "id": 7, + "type": "lovelace/dashboards/create", + "url_path": "created-url-path", "require_admin": True, "title": "New Title", "icon": "mdi:map", @@ -339,10 +373,11 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): assert response["result"]["icon"] == "mdi:map" dashboard_id = response["result"]["id"] + dashboard_path = response["result"]["url_path"] - assert "created_url_path" in hass.data[frontend.DATA_PANELS] + assert "created-url-path" in hass.data[frontend.DATA_PANELS] - await client.send_json({"id": 7, "type": "lovelace/dashboards/list"}) + await client.send_json({"id": 8, "type": "lovelace/dashboards/list"}) response = await client.receive_json() assert response["success"] assert len(response["result"]) == 1 @@ -354,7 +389,7 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): # Fetch config await client.send_json( - {"id": 8, "type": "lovelace/config", "url_path": "created_url_path"} + {"id": 9, "type": "lovelace/config", "url_path": "created-url-path"} ) response = await client.receive_json() assert not response["success"] @@ -365,22 +400,22 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): await client.send_json( { - "id": 9, + "id": 10, "type": "lovelace/config/save", - "url_path": "created_url_path", + "url_path": "created-url-path", "config": {"yo": "hello"}, } ) response = await client.receive_json() assert response["success"] - assert hass_storage[dashboard.CONFIG_STORAGE_KEY.format(dashboard_id)]["data"] == { - "config": {"yo": "hello"} - } + assert hass_storage[dashboard.CONFIG_STORAGE_KEY.format(dashboard_path)][ + "data" + ] == {"config": {"yo": "hello"}} assert len(events) == 1 - assert events[0].data["url_path"] == "created_url_path" + assert events[0].data["url_path"] == "created-url-path" await client.send_json( - {"id": 10, "type": "lovelace/config", "url_path": "created_url_path"} + {"id": 11, "type": "lovelace/config", "url_path": "created-url-path"} ) response = await client.receive_json() assert response["success"] @@ -389,7 +424,7 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): # Update a dashboard await client.send_json( { - "id": 11, + "id": 12, "type": "lovelace/dashboards/update", "dashboard_id": dashboard_id, "require_admin": False, @@ -401,19 +436,19 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): response = await client.receive_json() assert response["success"] assert response["result"]["mode"] == "storage" - assert response["result"]["url_path"] == "created_url_path" + assert response["result"]["url_path"] == "created-url-path" assert response["result"]["title"] == "Updated Title" assert response["result"]["icon"] == "mdi:updated" assert response["result"]["show_in_sidebar"] is False assert response["result"]["require_admin"] is False # List dashboards again and make sure we see latest config - await client.send_json({"id": 12, "type": "lovelace/dashboards/list"}) + await client.send_json({"id": 13, "type": "lovelace/dashboards/list"}) response = await client.receive_json() assert response["success"] assert len(response["result"]) == 1 assert response["result"][0]["mode"] == "storage" - assert response["result"][0]["url_path"] == "created_url_path" + assert response["result"][0]["url_path"] == "created-url-path" assert response["result"][0]["title"] == "Updated Title" assert response["result"][0]["icon"] == "mdi:updated" assert response["result"][0]["show_in_sidebar"] is False @@ -421,22 +456,75 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): # Add dashboard with existing url path await client.send_json( - {"id": 13, "type": "lovelace/dashboards/create", "url_path": "created_url_path"} + {"id": 14, "type": "lovelace/dashboards/create", "url_path": "created-url-path"} ) response = await client.receive_json() assert not response["success"] # Delete dashboards await client.send_json( - {"id": 14, "type": "lovelace/dashboards/delete", "dashboard_id": dashboard_id} + {"id": 15, "type": "lovelace/dashboards/delete", "dashboard_id": dashboard_id} ) response = await client.receive_json() assert response["success"] - assert "created_url_path" not in hass.data[frontend.DATA_PANELS] + assert "created-url-path" not in hass.data[frontend.DATA_PANELS] assert dashboard.CONFIG_STORAGE_KEY.format(dashboard_id) not in hass_storage +async def test_storage_dashboard_migrate(hass, hass_ws_client, hass_storage): + """Test changing url path from storage config.""" + hass_storage[dashboard.DASHBOARDS_STORAGE_KEY] = { + "key": "lovelace_dashboards", + "version": 1, + "data": { + "items": [ + { + "icon": "mdi:tools", + "id": "tools", + "mode": "storage", + "require_admin": True, + "show_in_sidebar": True, + "title": "Tools", + "url_path": "tools", + }, + { + "icon": "mdi:tools", + "id": "tools2", + "mode": "storage", + "require_admin": True, + "show_in_sidebar": True, + "title": "Tools", + "url_path": "dashboard-tools", + }, + ] + }, + } + + assert await async_setup_component(hass, "lovelace", {}) + + client = await hass_ws_client(hass) + + # Fetch data + await client.send_json({"id": 5, "type": "lovelace/dashboards/list"}) + response = await client.receive_json() + assert response["success"] + without_hyphen, with_hyphen = response["result"] + + assert without_hyphen["icon"] == "mdi:tools" + assert without_hyphen["id"] == "tools" + assert without_hyphen["mode"] == "storage" + assert without_hyphen["require_admin"] + assert without_hyphen["show_in_sidebar"] + assert without_hyphen["title"] == "Tools" + assert without_hyphen["url_path"] == "lovelace-tools" + + assert ( + with_hyphen + == hass_storage[dashboard.DASHBOARDS_STORAGE_KEY]["data"]["items"][1] + ) + + async def test_websocket_list_dashboards(hass, hass_ws_client): """Test listing dashboards both storage + YAML.""" assert await async_setup_component( @@ -455,9 +543,6 @@ async def test_websocket_list_dashboards(hass, hass_ws_client): }, ) - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - await hass.async_block_till_done() - client = await hass_ws_client(hass) # Create a storage dashboard @@ -465,7 +550,7 @@ async def test_websocket_list_dashboards(hass, hass_ws_client): { "id": 6, "type": "lovelace/dashboards/create", - "url_path": "created_url_path", + "url_path": "created-url-path", "title": "Test Storage", } ) @@ -473,7 +558,7 @@ async def test_websocket_list_dashboards(hass, hass_ws_client): assert response["success"] # List dashboards - await client.send_json({"id": 7, "type": "lovelace/dashboards/list"}) + await client.send_json({"id": 8, "type": "lovelace/dashboards/list"}) response = await client.receive_json() assert response["success"] assert len(response["result"]) == 2 @@ -486,4 +571,4 @@ async def test_websocket_list_dashboards(hass, hass_ws_client): assert without_sb["mode"] == "storage" assert without_sb["title"] == "Test Storage" - assert without_sb["url_path"] == "created_url_path" + assert without_sb["url_path"] == "created-url-path" From 875671cc2bff3888019c69ad885efe09d3ae4c41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sun, 15 Mar 2020 12:41:19 +0100 Subject: [PATCH 383/416] Add Netatmo Home Coach as model (#32829) --- homeassistant/components/netatmo/const.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py index 9216a678e68..0a0c9575600 100644 --- a/homeassistant/components/netatmo/const.py +++ b/homeassistant/components/netatmo/const.py @@ -19,6 +19,7 @@ MODELS = { "NAModule4": "Smart Additional Indoor module", "NAModule3": "Smart Rain Gauge", "NAModule2": "Smart Anemometer", + "NHC": "Home Coach", } AUTH = "netatmo_auth" From 42998f898b1c167bf47ecac94c4cfc986f83abfc Mon Sep 17 00:00:00 2001 From: SukramJ Date: Sun, 15 Mar 2020 19:01:50 +0100 Subject: [PATCH 384/416] Add SF transition to HmIP-BSL and remove obsolete code in HMIPC (#32833) --- homeassistant/components/homematicip_cloud/hap.py | 5 ----- homeassistant/components/homematicip_cloud/light.py | 3 ++- .../components/homematicip_cloud/test_alarm_control_panel.py | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py index 0d6fc726050..dd85827f1ae 100644 --- a/homeassistant/components/homematicip_cloud/hap.py +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -137,11 +137,6 @@ class HomematicipHAP: job = self.hass.async_create_task(self.get_state()) job.add_done_callback(self.get_state_finished) self._accesspoint_connected = True - else: - # Update home with the given json from arg[0], - # without devices and groups. - - self.home.update_home_only(args[0]) @callback def async_create_entity(self, *args, **kwargs) -> None: diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index 4e081f4d8fa..cead186db95 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -20,6 +20,7 @@ from homeassistant.components.light import ( ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, + SUPPORT_TRANSITION, Light, ) from homeassistant.config_entries import ConfigEntry @@ -197,7 +198,7 @@ class HomematicipNotificationLight(HomematicipGenericDevice, Light): @property def supported_features(self) -> int: """Flag supported features.""" - return SUPPORT_BRIGHTNESS | SUPPORT_COLOR + return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_TRANSITION @property def unique_id(self) -> str: diff --git a/tests/components/homematicip_cloud/test_alarm_control_panel.py b/tests/components/homematicip_cloud/test_alarm_control_panel.py index 23e5beb40eb..92782f2cbb2 100644 --- a/tests/components/homematicip_cloud/test_alarm_control_panel.py +++ b/tests/components/homematicip_cloud/test_alarm_control_panel.py @@ -31,9 +31,7 @@ async def _async_manipulate_security_zones( internal_zone = home.search_group_by_id(internal_zone_id) internal_zone.active = internal_active - home.from_json(json) - home._get_functionalHomes(json) - home._load_functionalChannels() + home.update_home_only(json) home.fire_update_event(json) await hass.async_block_till_done() From d88275d6d2d8dfefdb63db49b0029d51085f53cb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 15 Mar 2020 11:51:02 -0700 Subject: [PATCH 385/416] Make sure panel_custom won't crash on invalid data (#32835) * Make sure panel_custom won't crash on invalid data * Add a test --- homeassistant/components/hassio/manifest.json | 3 ++- homeassistant/components/panel_custom/__init__.py | 15 +++++++++------ tests/components/panel_custom/test_init.py | 14 ++++++++++++++ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json index d3dd7dc9c94..cd004db4c93 100644 --- a/homeassistant/components/hassio/manifest.json +++ b/homeassistant/components/hassio/manifest.json @@ -3,6 +3,7 @@ "name": "Hass.io", "documentation": "https://www.home-assistant.io/hassio", "requirements": [], - "dependencies": ["http", "panel_custom"], + "dependencies": ["http"], + "after_dependencies": ["panel_custom"], "codeowners": ["@home-assistant/hass-io"] } diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py index cf861992bd6..82572d7396c 100644 --- a/homeassistant/components/panel_custom/__init__.py +++ b/homeassistant/components/panel_custom/__init__.py @@ -146,8 +146,6 @@ async def async_setup(hass, config): if DOMAIN not in config: return True - success = False - for panel in config[DOMAIN]: name = panel[CONF_COMPONENT_NAME] @@ -182,8 +180,13 @@ async def async_setup(hass, config): hass.http.register_static_path(url, panel_path) kwargs["html_url"] = url - await async_register_panel(hass, **kwargs) + try: + await async_register_panel(hass, **kwargs) + except ValueError as err: + _LOGGER.error( + "Unable to register panel %s: %s", + panel.get(CONF_SIDEBAR_TITLE, name), + err, + ) - success = True - - return success + return True diff --git a/tests/components/panel_custom/test_init.py b/tests/components/panel_custom/test_init.py index e6bc56d080e..5f7161089f6 100644 --- a/tests/components/panel_custom/test_init.py +++ b/tests/components/panel_custom/test_init.py @@ -181,3 +181,17 @@ async def test_url_option_conflict(hass): 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( + hass, + "panel_custom", + { + "panel_custom": [ + {"name": "todo-mvc", "js_url": "/local/bla.js"}, + {"name": "todo-mvc", "js_url": "/local/bla.js"}, + ] + }, + ) From a3d74651a89d817e8c140e97695b40d5d9928511 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 15 Mar 2020 11:56:56 -0700 Subject: [PATCH 386/416] Bumped version to 0.107.0b4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d4a6dd1484f..c8817e4c344 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 107 -PATCH_VERSION = "0b3" +PATCH_VERSION = "0b4" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From cee72724b6a3248d7ce44f912ce601350f27a110 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 16 Mar 2020 10:04:12 +0000 Subject: [PATCH 387/416] Ensure unique_ids for all evohome thermostats (#32604) * initial commit * small tweak --- homeassistant/components/evohome/climate.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py index 8b65d837171..b7899afdd7b 100644 --- a/homeassistant/components/evohome/climate.py +++ b/homeassistant/components/evohome/climate.py @@ -149,7 +149,12 @@ class EvoZone(EvoChild, EvoClimateDevice): """Initialize a Honeywell TCC Zone.""" super().__init__(evo_broker, evo_device) - self._unique_id = evo_device.zoneId + if evo_device.modelType.startswith("VisionProWifi"): + # this system does not have a distinct ID for the zone + self._unique_id = f"{evo_device.zoneId}z" + else: + self._unique_id = evo_device.zoneId + self._name = evo_device.name self._icon = "mdi:radiator" From fb1ba86b0866ee32c8bfa9edd4c395c8fbc43cb8 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Sun, 15 Mar 2020 20:42:07 -0700 Subject: [PATCH 388/416] Bump teslajsonpy to 0.5.1 (#32827) --- homeassistant/components/tesla/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tesla/manifest.json b/homeassistant/components/tesla/manifest.json index 21605d16579..950a860b308 100644 --- a/homeassistant/components/tesla/manifest.json +++ b/homeassistant/components/tesla/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/tesla", "requirements": [ - "teslajsonpy==0.4.0" + "teslajsonpy==0.5.1" ], "dependencies": [], "codeowners": [ diff --git a/requirements_all.txt b/requirements_all.txt index fd3cedcd1a1..ade07e108d1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1996,7 +1996,7 @@ temperusb==1.5.3 # tensorflow==1.13.2 # homeassistant.components.tesla -teslajsonpy==0.4.0 +teslajsonpy==0.5.1 # homeassistant.components.thermoworks_smoke thermoworks_smoke==0.1.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 45c1de0dadf..6ab064e6c3b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -684,7 +684,7 @@ sunwatcher==0.2.1 tellduslive==0.10.10 # homeassistant.components.tesla -teslajsonpy==0.4.0 +teslajsonpy==0.5.1 # homeassistant.components.toon toonapilib==3.2.4 From 104665d8499bd64b263575a895c257e0f08af3a2 Mon Sep 17 00:00:00 2001 From: Kit Klein <33464407+kit-klein@users.noreply.github.com> Date: Sun, 15 Mar 2020 20:11:26 -0400 Subject: [PATCH 389/416] Ignore the ignored konnected config entries (#32845) * ignore the ignored konnected config entries * key off data instead of source --- homeassistant/components/konnected/__init__.py | 1 + tests/components/konnected/test_init.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index 94508b01483..72d82fd31be 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -306,6 +306,7 @@ class KonnectedView(HomeAssistantView): [ entry.data[CONF_ACCESS_TOKEN] for entry in hass.config_entries.async_entries(DOMAIN) + if entry.data.get(CONF_ACCESS_TOKEN) ] ) if auth is None or not next( diff --git a/tests/components/konnected/test_init.py b/tests/components/konnected/test_init.py index 907f83cd981..e410aa9d60a 100644 --- a/tests/components/konnected/test_init.py +++ b/tests/components/konnected/test_init.py @@ -582,6 +582,10 @@ async def test_state_updates(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.add_to_hass(hass) + assert ( await async_setup_component( hass, From 65423bb62b7aa32987837d63d547148e9b362637 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Sun, 15 Mar 2020 16:50:23 -0400 Subject: [PATCH 390/416] Bump insteonplm to 0.16.8 (#32847) --- homeassistant/components/insteon/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 69c35477b8d..64c4b6a67be 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -2,7 +2,7 @@ "domain": "insteon", "name": "Insteon", "documentation": "https://www.home-assistant.io/integrations/insteon", - "requirements": ["insteonplm==0.16.7"], + "requirements": ["insteonplm==0.16.8"], "dependencies": [], "codeowners": [] } diff --git a/requirements_all.txt b/requirements_all.txt index ade07e108d1..b4f00a10ccf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -747,7 +747,7 @@ incomfort-client==0.4.0 influxdb==5.2.3 # homeassistant.components.insteon -insteonplm==0.16.7 +insteonplm==0.16.8 # homeassistant.components.iperf3 iperf3==0.1.11 From b6a3bcf87f0b775bd503e863d87c3bf1808a650b Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 16 Mar 2020 11:40:21 +0100 Subject: [PATCH 391/416] Update pyozw 0.1.9 (#32864) --- homeassistant/components/zwave/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave/manifest.json b/homeassistant/components/zwave/manifest.json index 1fc6401f25b..81978aa96cd 100644 --- a/homeassistant/components/zwave/manifest.json +++ b/homeassistant/components/zwave/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave", - "requirements": ["homeassistant-pyozw==0.1.8", "pydispatcher==2.0.5"], + "requirements": ["homeassistant-pyozw==0.1.9", "pydispatcher==2.0.5"], "dependencies": [], "codeowners": ["@home-assistant/z-wave"] } diff --git a/requirements_all.txt b/requirements_all.txt index b4f00a10ccf..85bd69463af 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -699,7 +699,7 @@ holidays==0.10.1 home-assistant-frontend==20200313.0 # homeassistant.components.zwave -homeassistant-pyozw==0.1.8 +homeassistant-pyozw==0.1.9 # homeassistant.components.homematicip_cloud homematicip==0.10.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ab064e6c3b..4ded6f8f917 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -266,7 +266,7 @@ holidays==0.10.1 home-assistant-frontend==20200313.0 # homeassistant.components.zwave -homeassistant-pyozw==0.1.8 +homeassistant-pyozw==0.1.9 # homeassistant.components.homematicip_cloud homematicip==0.10.17 From f2c3f76b8ee904cf47115e217503e683c4874c9c Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 16 Mar 2020 13:30:59 +0100 Subject: [PATCH 392/416] Updated frontend to 20200316.0 (#32866) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2817b744d72..211e5bc7e84 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200313.0" + "home-assistant-frontend==20200316.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 49c9d017e3a..96cf257e61e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.32.2 -home-assistant-frontend==20200313.0 +home-assistant-frontend==20200316.0 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 85bd69463af..f51cef5125f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -696,7 +696,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200313.0 +home-assistant-frontend==20200316.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4ded6f8f917..2ba38990639 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -263,7 +263,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200313.0 +home-assistant-frontend==20200316.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.9 From 7f6b3c11308a214d522f9e24635e89643c2e97cc Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 16 Mar 2020 13:59:27 +0100 Subject: [PATCH 393/416] Bumped version to 0.107.0b5 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c8817e4c344..7ff286fd460 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 107 -PATCH_VERSION = "0b4" +PATCH_VERSION = "0b5" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From ccb34083fe518a8635f10066a122497cdc17c866 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 16 Mar 2020 20:25:23 +0100 Subject: [PATCH 394/416] Add lovelace reload service for yaml resources (#32865) * Lovelace add reload service for yaml resources * Clean up imports * Comments --- homeassistant/components/lovelace/__init__.py | 64 +++++++++++++++---- homeassistant/components/lovelace/const.py | 3 + .../components/lovelace/services.yaml | 4 ++ 3 files changed, 57 insertions(+), 14 deletions(-) create mode 100644 homeassistant/components/lovelace/services.yaml diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py index 220161fb649..95508c2f8f3 100644 --- a/homeassistant/components/lovelace/__init__.py +++ b/homeassistant/components/lovelace/__init__.py @@ -4,10 +4,14 @@ import logging import voluptuous as vol from homeassistant.components import frontend +from homeassistant.config import async_hass_config_yaml, async_process_component_config from homeassistant.const import CONF_FILENAME from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection, config_validation as cv +from homeassistant.helpers.service import async_register_admin_service +from homeassistant.helpers.typing import ConfigType, HomeAssistantType, ServiceCallType +from homeassistant.loader import async_get_integration from homeassistant.util import sanitize_filename from . import dashboard, resources, websocket @@ -25,8 +29,10 @@ from .const import ( MODE_STORAGE, MODE_YAML, RESOURCE_CREATE_FIELDS, + RESOURCE_RELOAD_SERVICE_SCHEMA, RESOURCE_SCHEMA, RESOURCE_UPDATE_FIELDS, + SERVICE_RELOAD_RESOURCES, STORAGE_DASHBOARD_CREATE_FIELDS, STORAGE_DASHBOARD_UPDATE_FIELDS, url_slug, @@ -62,29 +68,41 @@ CONFIG_SCHEMA = vol.Schema( ) -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistantType, config: ConfigType): """Set up the Lovelace commands.""" mode = config[DOMAIN][CONF_MODE] yaml_resources = config[DOMAIN].get(CONF_RESOURCES) frontend.async_register_built_in_panel(hass, DOMAIN, config={"mode": mode}) + async def reload_resources_service_handler(service_call: ServiceCallType) -> None: + """Reload yaml resources.""" + try: + conf = await async_hass_config_yaml(hass) + except HomeAssistantError as err: + _LOGGER.error(err) + return + + integration = await async_get_integration(hass, DOMAIN) + + config = await async_process_component_config(hass, conf, integration) + + resource_collection = await create_yaml_resource_col( + hass, config[DOMAIN].get(CONF_RESOURCES) + ) + hass.data[DOMAIN]["resources"] = resource_collection + if mode == MODE_YAML: default_config = dashboard.LovelaceYAML(hass, None, None) + resource_collection = await create_yaml_resource_col(hass, yaml_resources) - if yaml_resources is None: - try: - ll_conf = await default_config.async_load(False) - except HomeAssistantError: - pass - else: - if CONF_RESOURCES in ll_conf: - _LOGGER.warning( - "Resources need to be specified in your configuration.yaml. Please see the docs." - ) - yaml_resources = ll_conf[CONF_RESOURCES] - - resource_collection = resources.ResourceYAMLCollection(yaml_resources or []) + async_register_admin_service( + hass, + DOMAIN, + SERVICE_RELOAD_RESOURCES, + reload_resources_service_handler, + schema=RESOURCE_RELOAD_SERVICE_SCHEMA, + ) else: default_config = dashboard.LovelaceStorage(hass, None) @@ -196,6 +214,24 @@ async def async_setup(hass, config): return True +async def create_yaml_resource_col(hass, yaml_resources): + """Create yaml resources collection.""" + if yaml_resources is None: + default_config = dashboard.LovelaceYAML(hass, None, None) + try: + ll_conf = await default_config.async_load(False) + except HomeAssistantError: + pass + else: + if CONF_RESOURCES in ll_conf: + _LOGGER.warning( + "Resources need to be specified in your configuration.yaml. Please see the docs." + ) + yaml_resources = ll_conf[CONF_RESOURCES] + + return resources.ResourceYAMLCollection(yaml_resources or []) + + async def system_health_info(hass): """Get info for the info page.""" return await hass.data[DOMAIN]["dashboards"][None].async_get_info() diff --git a/homeassistant/components/lovelace/const.py b/homeassistant/components/lovelace/const.py index 8d7ee092cbe..a093c672dd6 100644 --- a/homeassistant/components/lovelace/const.py +++ b/homeassistant/components/lovelace/const.py @@ -41,6 +41,9 @@ RESOURCE_UPDATE_FIELDS = { vol.Optional(CONF_URL): cv.string, } +SERVICE_RELOAD_RESOURCES = "reload_resources" +RESOURCE_RELOAD_SERVICE_SCHEMA = vol.Schema({}) + CONF_TITLE = "title" CONF_REQUIRE_ADMIN = "require_admin" CONF_SHOW_IN_SIDEBAR = "show_in_sidebar" diff --git a/homeassistant/components/lovelace/services.yaml b/homeassistant/components/lovelace/services.yaml new file mode 100644 index 00000000000..1147f287e59 --- /dev/null +++ b/homeassistant/components/lovelace/services.yaml @@ -0,0 +1,4 @@ +# Describes the format for available lovelace services + +reload_resources: + description: Reload Lovelace resources from yaml configuration. From 661f1b69f2e903fdb8ba1ea205b5ba100b60d811 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 16 Mar 2020 15:29:14 -0400 Subject: [PATCH 395/416] Bump ZHA quirks to 0.0.37 (#32867) --- homeassistant/components/zha/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index fec85625ee4..19940eaea00 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/zha", "requirements": [ "bellows-homeassistant==0.14.0", - "zha-quirks==0.0.36", + "zha-quirks==0.0.37", "zigpy-cc==0.1.0", "zigpy-deconz==0.7.0", "zigpy-homeassistant==0.16.0", diff --git a/requirements_all.txt b/requirements_all.txt index f51cef5125f..c11c4e76749 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2158,7 +2158,7 @@ zengge==0.2 zeroconf==0.24.5 # homeassistant.components.zha -zha-quirks==0.0.36 +zha-quirks==0.0.37 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ba38990639..97dc27163e9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -747,7 +747,7 @@ yahooweather==0.10 zeroconf==0.24.5 # homeassistant.components.zha -zha-quirks==0.0.36 +zha-quirks==0.0.37 # homeassistant.components.zha zigpy-cc==0.1.0 From 03b1c6ddee35f682a03e79219f0766972a5076be Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 16 Mar 2020 14:47:44 -0700 Subject: [PATCH 396/416] Remove group as a dependency from entity integrations (#32870) * remove group dependency * Update device sun light trigger * Add zone dep back to device tracker --- homeassistant/components/automation/manifest.json | 3 ++- homeassistant/components/cover/manifest.json | 2 +- .../components/device_sun_light_trigger/manifest.json | 3 ++- homeassistant/components/device_tracker/manifest.json | 3 ++- homeassistant/components/fan/manifest.json | 2 +- homeassistant/components/light/manifest.json | 2 +- homeassistant/components/lock/manifest.json | 2 +- homeassistant/components/plant/manifest.json | 2 +- homeassistant/components/remote/manifest.json | 2 +- homeassistant/components/script/manifest.json | 2 +- homeassistant/components/switch/manifest.json | 2 +- homeassistant/components/vacuum/manifest.json | 2 +- script/hassfest/dependencies.py | 4 ++-- script/hassfest/manifest.py | 4 ++-- 14 files changed, 19 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/automation/manifest.json b/homeassistant/components/automation/manifest.json index 34261cba5a9..48d8c58dfe1 100644 --- a/homeassistant/components/automation/manifest.json +++ b/homeassistant/components/automation/manifest.json @@ -3,7 +3,8 @@ "name": "Automation", "documentation": "https://www.home-assistant.io/integrations/automation", "requirements": [], - "dependencies": ["device_automation", "group", "webhook"], + "dependencies": [], + "after_dependencies": ["device_automation", "webhook"], "codeowners": ["@home-assistant/core"], "quality_scale": "internal" } diff --git a/homeassistant/components/cover/manifest.json b/homeassistant/components/cover/manifest.json index aa43e934dc9..788d72b707f 100644 --- a/homeassistant/components/cover/manifest.json +++ b/homeassistant/components/cover/manifest.json @@ -3,7 +3,7 @@ "name": "Cover", "documentation": "https://www.home-assistant.io/integrations/cover", "requirements": [], - "dependencies": ["group"], + "dependencies": [], "codeowners": ["@home-assistant/core"], "quality_scale": "internal" } diff --git a/homeassistant/components/device_sun_light_trigger/manifest.json b/homeassistant/components/device_sun_light_trigger/manifest.json index 702f8704564..edeb10dcec2 100644 --- a/homeassistant/components/device_sun_light_trigger/manifest.json +++ b/homeassistant/components/device_sun_light_trigger/manifest.json @@ -3,7 +3,8 @@ "name": "Presence-based Lights", "documentation": "https://www.home-assistant.io/integrations/device_sun_light_trigger", "requirements": [], - "dependencies": ["device_tracker", "group", "light", "person"], + "dependencies": [], + "after_dependencies": ["device_tracker", "group", "light", "person"], "codeowners": [], "quality_scale": "internal" } diff --git a/homeassistant/components/device_tracker/manifest.json b/homeassistant/components/device_tracker/manifest.json index 35b9a4a3fdb..4bd9846f76d 100644 --- a/homeassistant/components/device_tracker/manifest.json +++ b/homeassistant/components/device_tracker/manifest.json @@ -3,7 +3,8 @@ "name": "Device Tracker", "documentation": "https://www.home-assistant.io/integrations/device_tracker", "requirements": [], - "dependencies": ["group", "zone"], + "dependencies": ["zone"], + "after_dependencies": [], "codeowners": [], "quality_scale": "internal" } diff --git a/homeassistant/components/fan/manifest.json b/homeassistant/components/fan/manifest.json index 02ed368feac..53b7873612c 100644 --- a/homeassistant/components/fan/manifest.json +++ b/homeassistant/components/fan/manifest.json @@ -3,7 +3,7 @@ "name": "Fan", "documentation": "https://www.home-assistant.io/integrations/fan", "requirements": [], - "dependencies": ["group"], + "dependencies": [], "codeowners": [], "quality_scale": "internal" } diff --git a/homeassistant/components/light/manifest.json b/homeassistant/components/light/manifest.json index e0a9652a10c..64e21654afd 100644 --- a/homeassistant/components/light/manifest.json +++ b/homeassistant/components/light/manifest.json @@ -3,7 +3,7 @@ "name": "Light", "documentation": "https://www.home-assistant.io/integrations/light", "requirements": [], - "dependencies": ["group"], + "dependencies": [], "codeowners": [], "quality_scale": "internal" } diff --git a/homeassistant/components/lock/manifest.json b/homeassistant/components/lock/manifest.json index ab05166d15f..cd2fdf27f2d 100644 --- a/homeassistant/components/lock/manifest.json +++ b/homeassistant/components/lock/manifest.json @@ -3,7 +3,7 @@ "name": "Lock", "documentation": "https://www.home-assistant.io/integrations/lock", "requirements": [], - "dependencies": ["group"], + "dependencies": [], "codeowners": [], "quality_scale": "internal" } diff --git a/homeassistant/components/plant/manifest.json b/homeassistant/components/plant/manifest.json index de5f0c1f880..f0ff20f3759 100644 --- a/homeassistant/components/plant/manifest.json +++ b/homeassistant/components/plant/manifest.json @@ -3,7 +3,7 @@ "name": "Plant Monitor", "documentation": "https://www.home-assistant.io/integrations/plant", "requirements": [], - "dependencies": ["group", "zone"], + "dependencies": [], "after_dependencies": ["recorder"], "codeowners": ["@ChristianKuehnel"], "quality_scale": "internal" diff --git a/homeassistant/components/remote/manifest.json b/homeassistant/components/remote/manifest.json index 24616bc5947..8f559b758d6 100644 --- a/homeassistant/components/remote/manifest.json +++ b/homeassistant/components/remote/manifest.json @@ -3,6 +3,6 @@ "name": "Remote", "documentation": "https://www.home-assistant.io/integrations/remote", "requirements": [], - "dependencies": ["group"], + "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/script/manifest.json b/homeassistant/components/script/manifest.json index dac37110172..ce9899f021c 100644 --- a/homeassistant/components/script/manifest.json +++ b/homeassistant/components/script/manifest.json @@ -3,7 +3,7 @@ "name": "Scripts", "documentation": "https://www.home-assistant.io/integrations/script", "requirements": [], - "dependencies": ["group"], + "dependencies": [], "codeowners": ["@home-assistant/core"], "quality_scale": "internal" } diff --git a/homeassistant/components/switch/manifest.json b/homeassistant/components/switch/manifest.json index b14c8ca48d5..37cdf77172c 100644 --- a/homeassistant/components/switch/manifest.json +++ b/homeassistant/components/switch/manifest.json @@ -3,7 +3,7 @@ "name": "Switch", "documentation": "https://www.home-assistant.io/integrations/switch", "requirements": [], - "dependencies": ["group"], + "dependencies": [], "codeowners": [], "quality_scale": "internal" } diff --git a/homeassistant/components/vacuum/manifest.json b/homeassistant/components/vacuum/manifest.json index 895311ae5b6..a6f7ddb2bda 100644 --- a/homeassistant/components/vacuum/manifest.json +++ b/homeassistant/components/vacuum/manifest.json @@ -3,6 +3,6 @@ "name": "Vacuum", "documentation": "https://www.home-assistant.io/integrations/vacuum", "requirements": [], - "dependencies": ["group"], + "dependencies": [], "codeowners": [] } diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 934400533e1..660e8065966 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -156,7 +156,7 @@ def calc_allowed_references(integration: Integration) -> Set[str]: """Return a set of allowed references.""" allowed_references = ( ALLOWED_USED_COMPONENTS - | set(integration.manifest["dependencies"]) + | set(integration.manifest.get("dependencies", [])) | set(integration.manifest.get("after_dependencies", [])) ) @@ -250,7 +250,7 @@ def validate(integrations: Dict[str, Integration], config): validate_dependencies(integrations, integration) # check that all referenced dependencies exist - for dep in integration.manifest["dependencies"]: + for dep in integration.manifest.get("dependencies", []): if dep not in integrations: integration.add_error( "dependencies", f"Dependency {dep} does not exist" diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 7852953dc92..758279cabf8 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -52,8 +52,8 @@ MANIFEST_SCHEMA = vol.Schema( vol.Url(), documentation_url # pylint: disable=no-value-for-parameter ), vol.Optional("quality_scale"): vol.In(SUPPORTED_QUALITY_SCALES), - vol.Required("requirements"): [str], - vol.Required("dependencies"): [str], + vol.Optional("requirements"): [str], + vol.Optional("dependencies"): [str], vol.Optional("after_dependencies"): [str], vol.Required("codeowners"): [str], vol.Optional("logo"): vol.Url(), # pylint: disable=no-value-for-parameter From a7aca106681cd340fd2c9a25b3756dd7f52c21eb Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 16 Mar 2020 20:08:00 +0100 Subject: [PATCH 397/416] Lovelace: storage key based on id instead of url_path (#32873) * Fix storage key based on url_path * Fix test --- homeassistant/components/lovelace/dashboard.py | 2 +- tests/components/lovelace/test_dashboard.py | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index 38740672914..cdb104a150b 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -88,7 +88,7 @@ class LovelaceStorage(LovelaceConfig): storage_key = CONFIG_STORAGE_KEY_DEFAULT else: url_path = config[CONF_URL_PATH] - storage_key = CONFIG_STORAGE_KEY.format(url_path) + storage_key = CONFIG_STORAGE_KEY.format(config["id"]) super().__init__(hass, url_path, config) diff --git a/tests/components/lovelace/test_dashboard.py b/tests/components/lovelace/test_dashboard.py index 1effb10be27..775b2760c96 100644 --- a/tests/components/lovelace/test_dashboard.py +++ b/tests/components/lovelace/test_dashboard.py @@ -373,7 +373,6 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): assert response["result"]["icon"] == "mdi:map" dashboard_id = response["result"]["id"] - dashboard_path = response["result"]["url_path"] assert "created-url-path" in hass.data[frontend.DATA_PANELS] @@ -408,9 +407,9 @@ async def test_storage_dashboards(hass, hass_ws_client, hass_storage): ) response = await client.receive_json() assert response["success"] - assert hass_storage[dashboard.CONFIG_STORAGE_KEY.format(dashboard_path)][ - "data" - ] == {"config": {"yo": "hello"}} + assert hass_storage[dashboard.CONFIG_STORAGE_KEY.format(dashboard_id)]["data"] == { + "config": {"yo": "hello"} + } assert len(events) == 1 assert events[0].data["url_path"] == "created-url-path" From 4f78674a4c46d0829365a2947eefbf227c2347a9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 16 Mar 2020 22:27:20 +0100 Subject: [PATCH 398/416] Updated frontend to 20200316.1 (#32878) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 211e5bc7e84..174bab5a189 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200316.0" + "home-assistant-frontend==20200316.1" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 96cf257e61e..37a0a8e357e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.32.2 -home-assistant-frontend==20200316.0 +home-assistant-frontend==20200316.1 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index c11c4e76749..3db1a513ed9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -696,7 +696,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200316.0 +home-assistant-frontend==20200316.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 97dc27163e9..b361464e823 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -263,7 +263,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200316.0 +home-assistant-frontend==20200316.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.9 From d196fd136d246ed640040960f14dc04489f0923b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 16 Mar 2020 14:53:13 -0700 Subject: [PATCH 399/416] Bumped version to 0.107.0b6 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 7ff286fd460..6ca593de34e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 107 -PATCH_VERSION = "0b5" +PATCH_VERSION = "0b6" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 67a721d39b17c9960de3a22326400234f5514ad1 Mon Sep 17 00:00:00 2001 From: Quentame Date: Tue, 17 Mar 2020 18:16:58 +0100 Subject: [PATCH 400/416] Fix iCloud init while pending (#32750) * Fix iCloud init while pending Continue if device is pending while setup Create devices and fetch 15s if pending, otherwise determine interval to fetch. * Add retried_fetch guard --- homeassistant/components/icloud/account.py | 31 +++++++++------------- 1 file changed, 12 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/icloud/account.py b/homeassistant/components/icloud/account.py index 6c4d9c5c25f..50a3e74f78f 100644 --- a/homeassistant/components/icloud/account.py +++ b/homeassistant/components/icloud/account.py @@ -97,6 +97,7 @@ class IcloudAccount: self._owner_fullname = None self._family_members_fullname = {} self._devices = {} + self._retried_fetch = False self.listeners = [] @@ -122,10 +123,6 @@ class IcloudAccount: _LOGGER.error("No iCloud device found") raise ConfigEntryNotReady - if DEVICE_STATUS_CODES.get(list(api_devices)[0][DEVICE_STATUS]) == "pending": - _LOGGER.warning("Pending devices, trying again ...") - raise ConfigEntryNotReady - self._owner_fullname = f"{user_info['firstName']} {user_info['lastName']}" self._family_members_fullname = {} @@ -157,28 +154,15 @@ class IcloudAccount: ) return - if DEVICE_STATUS_CODES.get(list(api_devices)[0][DEVICE_STATUS]) == "pending": - _LOGGER.warning("Pending devices, trying again in 15s") - self._fetch_interval = 0.25 - dispatcher_send(self.hass, self.signal_device_update) - track_point_in_utc_time( - self.hass, - self.keep_alive, - utcnow() + timedelta(minutes=self._fetch_interval), - ) - return - # Gets devices infos new_device = False for device in api_devices: status = device.status(DEVICE_STATUS_SET) device_id = status[DEVICE_ID] device_name = status[DEVICE_NAME] - device_status = DEVICE_STATUS_CODES.get(status[DEVICE_STATUS], "error") if ( - device_status == "pending" - or status[DEVICE_BATTERY_STATUS] == "Unknown" + status[DEVICE_BATTERY_STATUS] == "Unknown" or status.get(DEVICE_BATTERY_LEVEL) is None ): continue @@ -198,7 +182,16 @@ class IcloudAccount: self._devices[device_id].update(status) new_device = True - self._fetch_interval = self._determine_interval() + if ( + DEVICE_STATUS_CODES.get(list(api_devices)[0][DEVICE_STATUS]) == "pending" + and not self._retried_fetch + ): + _LOGGER.warning("Pending devices, trying again in 15s") + self._fetch_interval = 0.25 + self._retried_fetch = True + else: + self._fetch_interval = self._determine_interval() + self._retried_fetch = False dispatcher_send(self.hass, self.signal_device_update) if new_device: From ac8c889b0fe99d1cf91b91beb76eb35d7dc06841 Mon Sep 17 00:00:00 2001 From: Paolo Tuninetto Date: Tue, 17 Mar 2020 00:37:10 +0100 Subject: [PATCH 401/416] Add default port to samsung tv (#32820) * Default port for websocket tv * Update config entry * move bridge creation * fix indent * remove loop --- homeassistant/components/samsungtv/bridge.py | 2 ++ .../components/samsungtv/media_player.py | 26 ++++++++++++------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index 31f102a62a4..b582f6269e4 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -46,6 +46,7 @@ class SamsungTVBridge(ABC): self.method = method self.host = host self.token = None + self.default_port = None self._remote = None self._callback = None @@ -191,6 +192,7 @@ class SamsungTVWSBridge(SamsungTVBridge): """Initialize Bridge.""" super().__init__(method, host, port) self.token = token + self.default_port = 8001 def try_connect(self): """Try to connect to the Websocket TV.""" diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 8fa6a93088a..8f12341ee4a 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -71,13 +71,27 @@ async def async_setup_entry(hass, config_entry, async_add_entities): ): turn_on_action = hass.data[DOMAIN][ip_address][CONF_ON_ACTION] on_script = Script(hass, turn_on_action) - async_add_entities([SamsungTVDevice(config_entry, on_script)]) + + # Initialize bridge + data = config_entry.data.copy() + bridge = SamsungTVBridge.get_bridge( + 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), + ) + + async_add_entities([SamsungTVDevice(bridge, config_entry, on_script)]) class SamsungTVDevice(MediaPlayerDevice): """Representation of a Samsung TV.""" - def __init__(self, config_entry, on_script): + def __init__(self, bridge, config_entry, on_script): """Initialize the Samsung device.""" self._config_entry = config_entry self._manufacturer = config_entry.data.get(CONF_MANUFACTURER) @@ -93,13 +107,7 @@ class SamsungTVDevice(MediaPlayerDevice): # Mark the end of a shutdown command (need to wait 15 seconds before # sending the next command to avoid turning the TV back ON). self._end_of_power_off = None - # Initialize bridge - self._bridge = SamsungTVBridge.get_bridge( - config_entry.data[CONF_METHOD], - config_entry.data[CONF_HOST], - config_entry.data[CONF_PORT], - config_entry.data.get(CONF_TOKEN), - ) + self._bridge = bridge self._bridge.register_reauth_callback(self.access_denied) def access_denied(self): From 912409ed0c8384b654db4b26c4e6472c32d066bd Mon Sep 17 00:00:00 2001 From: Jason Lachowsky Date: Mon, 16 Mar 2020 05:58:12 -0500 Subject: [PATCH 402/416] Corrected minor misspellings (#32857) --- .../components/homekit_controller/.translations/en.json | 2 +- homeassistant/components/homekit_controller/strings.json | 2 +- homeassistant/components/system_log/__init__.py | 4 ++-- homeassistant/components/toon/.translations/en.json | 2 +- homeassistant/components/toon/strings.json | 2 +- tests/components/system_log/test_init.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homekit_controller/.translations/en.json b/homeassistant/components/homekit_controller/.translations/en.json index 72aa720b449..eb994289a62 100644 --- a/homeassistant/components/homekit_controller/.translations/en.json +++ b/homeassistant/components/homekit_controller/.translations/en.json @@ -14,7 +14,7 @@ "busy_error": "Device refused to add pairing as it is already pairing with another controller.", "max_peers_error": "Device refused to add pairing as it has no free pairing storage.", "max_tries_error": "Device refused to add pairing as it has received more than 100 unsuccessful authentication attempts.", - "pairing_failed": "An unhandled error occured while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently.", + "pairing_failed": "An unhandled error occurred while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently.", "unable_to_pair": "Unable to pair, please try again.", "unknown_error": "Device reported an unknown error. Pairing failed." }, diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index 55718e35b59..80370717183 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -25,7 +25,7 @@ "max_peers_error": "Device refused to add pairing as it has no free pairing storage.", "busy_error": "Device refused to add pairing as it is already pairing with another controller.", "max_tries_error": "Device refused to add pairing as it has received more than 100 unsuccessful authentication attempts.", - "pairing_failed": "An unhandled error occured while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently." + "pairing_failed": "An unhandled error occurred while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently." }, "abort": { "no_devices": "No unpaired devices could be found", diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 2ddf02f76ed..bf49de5a731 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -91,7 +91,7 @@ class LogEntry: def __init__(self, record, stack, source): """Initialize a log entry.""" - self.first_occured = self.timestamp = record.created + self.first_occurred = self.timestamp = record.created self.name = record.name self.level = record.levelname self.message = deque([record.getMessage()], maxlen=5) @@ -117,7 +117,7 @@ class LogEntry: "timestamp": self.timestamp, "exception": self.exception, "count": self.count, - "first_occured": self.first_occured, + "first_occurred": self.first_occurred, } diff --git a/homeassistant/components/toon/.translations/en.json b/homeassistant/components/toon/.translations/en.json index cea3146a3a5..7d7d6c73e16 100644 --- a/homeassistant/components/toon/.translations/en.json +++ b/homeassistant/components/toon/.translations/en.json @@ -5,7 +5,7 @@ "client_secret": "The client secret from the configuration is invalid.", "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 occured, while authenticating." + "unknown_auth_fail": "Unexpected error occurred, while authenticating." }, "error": { "credentials": "The provided credentials are invalid.", diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json index 80d71d4e421..20d6ba3d72c 100644 --- a/homeassistant/components/toon/strings.json +++ b/homeassistant/components/toon/strings.json @@ -26,7 +26,7 @@ "abort": { "client_id": "The client ID from the configuration is invalid.", "client_secret": "The client secret from the configuration is invalid.", - "unknown_auth_fail": "Unexpected error occured, while authenticating.", + "unknown_auth_fail": "Unexpected error occurred, while authenticating.", "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/)." } diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py index 9862260c5f8..92f0ed9fd16 100644 --- a/tests/components/system_log/test_init.py +++ b/tests/components/system_log/test_init.py @@ -157,7 +157,7 @@ async def test_dedup_logs(hass, hass_client): log_msg() log = await get_error_log(hass, hass_client, 3) assert_log(log[0], "", ["error message 2", "error message 2-2"], "ERROR") - assert log[0]["timestamp"] > log[0]["first_occured"] + assert log[0]["timestamp"] > log[0]["first_occurred"] log_msg("2-3") log_msg("2-4") From 67d728fc500cda8d93dd47252b8b5d1348d22971 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 Mar 2020 03:59:39 -0700 Subject: [PATCH 403/416] Make zone dependency of device tracker an after dep (#32880) * Make zone dependency of device tracker an after dep * Fix test --- .../components/device_tracker/manifest.json | 4 +-- tests/components/mobile_app/test_webhook.py | 6 ++-- tests/components/unifi/test_device_tracker.py | 24 +++++++-------- tests/components/unifi/test_sensor.py | 4 +-- tests/components/unifi/test_switch.py | 30 +++++++++---------- 5 files changed, 35 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/device_tracker/manifest.json b/homeassistant/components/device_tracker/manifest.json index 4bd9846f76d..2d0e9a82a53 100644 --- a/homeassistant/components/device_tracker/manifest.json +++ b/homeassistant/components/device_tracker/manifest.json @@ -3,8 +3,8 @@ "name": "Device Tracker", "documentation": "https://www.home-assistant.io/integrations/device_tracker", "requirements": [], - "dependencies": ["zone"], - "after_dependencies": [], + "dependencies": [], + "after_dependencies": ["zone"], "codeowners": [], "quality_scale": "internal" } diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 39837543a47..974fb577606 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -160,8 +160,10 @@ async def test_webhook_handle_get_zones(hass, create_registrations, webhook_clie assert resp.status == 200 json = await resp.json() - assert len(json) == 1 - assert json[0]["entity_id"] == "zone.home" + assert len(json) == 2 + zones = sorted(json, key=lambda entry: entry["entity_id"]) + assert zones[0]["entity_id"] == "zone.home" + assert zones[1]["entity_id"] == "zone.test" async def test_webhook_handle_get_config(hass, create_registrations, webhook_client): diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index cbef7c31922..f225ddb44a9 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -108,7 +108,7 @@ async def test_no_clients(hass): """Test the update_clients function when no clients are found.""" await setup_unifi_integration(hass) - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 0 async def test_tracked_devices(hass): @@ -123,7 +123,7 @@ async def test_tracked_devices(hass): devices_response=[DEVICE_1, DEVICE_2], known_wireless_clients=(CLIENT_4["mac"],), ) - assert len(hass.states.async_all()) == 7 + assert len(hass.states.async_all()) == 6 client_1 = hass.states.get("device_tracker.client_1") assert client_1 is not None @@ -184,7 +184,7 @@ async def test_controller_state_change(hass): controller = await setup_unifi_integration( hass, clients_response=[CLIENT_1], devices_response=[DEVICE_1], ) - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 2 # Controller unavailable controller.async_unifi_signalling_callback( @@ -214,7 +214,7 @@ async def test_option_track_clients(hass): controller = await setup_unifi_integration( hass, clients_response=[CLIENT_1, CLIENT_2], devices_response=[DEVICE_1], ) - assert len(hass.states.async_all()) == 4 + assert len(hass.states.async_all()) == 3 client_1 = hass.states.get("device_tracker.client_1") assert client_1 is not None @@ -259,7 +259,7 @@ async def test_option_track_wired_clients(hass): controller = await setup_unifi_integration( hass, clients_response=[CLIENT_1, CLIENT_2], devices_response=[DEVICE_1], ) - assert len(hass.states.async_all()) == 4 + assert len(hass.states.async_all()) == 3 client_1 = hass.states.get("device_tracker.client_1") assert client_1 is not None @@ -304,7 +304,7 @@ async def test_option_track_devices(hass): controller = await setup_unifi_integration( hass, clients_response=[CLIENT_1, CLIENT_2], devices_response=[DEVICE_1], ) - assert len(hass.states.async_all()) == 4 + assert len(hass.states.async_all()) == 3 client_1 = hass.states.get("device_tracker.client_1") assert client_1 is not None @@ -349,7 +349,7 @@ async def test_option_ssid_filter(hass): controller = await setup_unifi_integration( hass, options={CONF_SSID_FILTER: ["ssid"]}, clients_response=[CLIENT_3], ) - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 1 # SSID filter active client_3 = hass.states.get("device_tracker.client_3") @@ -387,7 +387,7 @@ async def test_wireless_client_go_wired_issue(hass): client_1_client["last_seen"] = dt_util.as_timestamp(dt_util.utcnow()) controller = await setup_unifi_integration(hass, clients_response=[client_1_client]) - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 1 client_1 = hass.states.get("device_tracker.client_1") assert client_1 is not None @@ -460,7 +460,7 @@ async def test_restoring_client(hass): clients_response=[CLIENT_2], clients_all_response=[CLIENT_1], ) - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 2 device_1 = hass.states.get("device_tracker.client_1") assert device_1 is not None @@ -474,7 +474,7 @@ async def test_dont_track_clients(hass): clients_response=[CLIENT_1], devices_response=[DEVICE_1], ) - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 1 client_1 = hass.states.get("device_tracker.client_1") assert client_1 is None @@ -492,7 +492,7 @@ async def test_dont_track_devices(hass): clients_response=[CLIENT_1], devices_response=[DEVICE_1], ) - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 1 client_1 = hass.states.get("device_tracker.client_1") assert client_1 is not None @@ -509,7 +509,7 @@ async def test_dont_track_wired_clients(hass): options={unifi.controller.CONF_TRACK_WIRED_CLIENTS: False}, clients_response=[CLIENT_1, CLIENT_2], ) - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 1 client_1 = hass.states.get("device_tracker.client_1") assert client_1 is not None diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 7d0600f5885..a858bc9a649 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -55,7 +55,7 @@ async def test_no_clients(hass): ) assert len(controller.mock_requests) == 4 - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 0 async def test_sensors(hass): @@ -71,7 +71,7 @@ async def test_sensors(hass): ) assert len(controller.mock_requests) == 4 - assert len(hass.states.async_all()) == 5 + assert len(hass.states.async_all()) == 4 wired_client_rx = hass.states.get("sensor.wired_client_name_rx") assert wired_client_rx.state == "1234.0" diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index bc30161b77f..a06be14024b 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -209,7 +209,7 @@ async def test_no_clients(hass): ) assert len(controller.mock_requests) == 4 - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 0 async def test_controller_not_client(hass): @@ -222,7 +222,7 @@ async def test_controller_not_client(hass): ) assert len(controller.mock_requests) == 4 - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 0 cloudkey = hass.states.get("switch.cloud_key") assert cloudkey is None @@ -240,7 +240,7 @@ async def test_not_admin(hass): ) assert len(controller.mock_requests) == 4 - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 0 async def test_switches(hass): @@ -258,7 +258,7 @@ async def test_switches(hass): ) assert len(controller.mock_requests) == 4 - assert len(hass.states.async_all()) == 4 + assert len(hass.states.async_all()) == 3 switch_1 = hass.states.get("switch.poe_client_1") assert switch_1 is not None @@ -312,7 +312,7 @@ async def test_new_client_discovered_on_block_control(hass): ) assert len(controller.mock_requests) == 4 - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 0 blocked = hass.states.get("switch.block_client_1") assert blocked is None @@ -324,7 +324,7 @@ async def test_new_client_discovered_on_block_control(hass): controller.api.session_handler("data") await hass.async_block_till_done() - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 1 blocked = hass.states.get("switch.block_client_1") assert blocked is not None @@ -336,7 +336,7 @@ async def test_option_block_clients(hass): options={CONF_BLOCK_CLIENT: [BLOCKED["mac"]]}, clients_all_response=[BLOCKED, UNBLOCKED], ) - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 1 # Add a second switch hass.config_entries.async_update_entry( @@ -344,28 +344,28 @@ async def test_option_block_clients(hass): options={CONF_BLOCK_CLIENT: [BLOCKED["mac"], UNBLOCKED["mac"]]}, ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 2 # Remove the second switch again hass.config_entries.async_update_entry( controller.config_entry, options={CONF_BLOCK_CLIENT: [BLOCKED["mac"]]}, ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 1 # Enable one and remove another one hass.config_entries.async_update_entry( controller.config_entry, options={CONF_BLOCK_CLIENT: [UNBLOCKED["mac"]]}, ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 1 # Remove one hass.config_entries.async_update_entry( controller.config_entry, options={CONF_BLOCK_CLIENT: []}, ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 0 async def test_new_client_discovered_on_poe_control(hass): @@ -378,7 +378,7 @@ async def test_new_client_discovered_on_poe_control(hass): ) assert len(controller.mock_requests) == 4 - assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_all()) == 1 controller.api.websocket._data = { "meta": {"message": "sta:sync"}, @@ -391,7 +391,7 @@ async def test_new_client_discovered_on_poe_control(hass): "switch", "turn_off", {"entity_id": "switch.poe_client_1"}, blocking=True ) assert len(controller.mock_requests) == 5 - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 2 assert controller.mock_requests[4] == { "json": { "port_overrides": [{"port_idx": 1, "portconf_id": "1a1", "poe_mode": "off"}] @@ -430,7 +430,7 @@ async def test_ignore_multiple_poe_clients_on_same_port(hass): ) assert len(controller.mock_requests) == 4 - assert len(hass.states.async_all()) == 4 + assert len(hass.states.async_all()) == 3 switch_1 = hass.states.get("switch.poe_client_1") switch_2 = hass.states.get("switch.poe_client_2") @@ -481,7 +481,7 @@ async def test_restoring_client(hass): ) assert len(controller.mock_requests) == 4 - assert len(hass.states.async_all()) == 3 + assert len(hass.states.async_all()) == 2 device_1 = hass.states.get("switch.client_1") assert device_1 is not None From 4908d4358cf3db658abcd276309b16d8ba174fa6 Mon Sep 17 00:00:00 2001 From: Quentame Date: Tue, 17 Mar 2020 17:46:30 +0100 Subject: [PATCH 404/416] Bump iCloud to 0.9.5 (#32901) --- homeassistant/components/icloud/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json index 76b6b9b39ae..b4ef46cfbaf 100644 --- a/homeassistant/components/icloud/manifest.json +++ b/homeassistant/components/icloud/manifest.json @@ -3,7 +3,7 @@ "name": "Apple iCloud", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/icloud", - "requirements": ["pyicloud==0.9.4"], + "requirements": ["pyicloud==0.9.5"], "dependencies": [], "codeowners": ["@Quentame"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3db1a513ed9..c608c6f0596 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1315,7 +1315,7 @@ pyhomeworks==0.0.6 pyialarm==0.3 # homeassistant.components.icloud -pyicloud==0.9.4 +pyicloud==0.9.5 # homeassistant.components.intesishome pyintesishome==1.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b361464e823..f531ccf6726 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -483,7 +483,7 @@ pyheos==0.6.0 pyhomematic==0.1.65 # homeassistant.components.icloud -pyicloud==0.9.4 +pyicloud==0.9.5 # homeassistant.components.ipma pyipma==2.0.5 From 9797b09d4476163efdee20dddf8a11059cbdc688 Mon Sep 17 00:00:00 2001 From: brubaked <37672083+brubaked@users.noreply.github.com> Date: Tue, 17 Mar 2020 10:17:18 -0700 Subject: [PATCH 405/416] Changed Sensor icons to be more emotionally sensitive (#32904) The existing sensor icons, while descriptive - dead = dead - are perhaps too matter of fact and don't accurately convey the tragedy. I changed emoticon-dead-outline to emoticon-cry-outline, as I think it better conveys the reality of the situation along with the emotions tied to the statistic. --- homeassistant/components/coronavirus/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/coronavirus/sensor.py b/homeassistant/components/coronavirus/sensor.py index 3885dbebf24..2887427ec6b 100644 --- a/homeassistant/components/coronavirus/sensor.py +++ b/homeassistant/components/coronavirus/sensor.py @@ -7,9 +7,9 @@ from .const import ATTRIBUTION, OPTION_WORLDWIDE SENSORS = { "confirmed": "mdi:emoticon-neutral-outline", - "current": "mdi:emoticon-frown-outline", + "current": "mdi:emoticon-sad-outline", "recovered": "mdi:emoticon-happy-outline", - "deaths": "mdi:emoticon-dead-outline", + "deaths": "mdi:emoticon-cry-outline", } From ab38e7d98aa3fcd1c803e21612f982212ea966f4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 Mar 2020 10:30:56 -0700 Subject: [PATCH 406/416] Bump cast to 4.2.0 (#32906) --- homeassistant/components/cast/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index 51558e78266..be0b64dc0b1 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -3,7 +3,7 @@ "name": "Google Cast", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/cast", - "requirements": ["pychromecast==4.1.1"], + "requirements": ["pychromecast==4.2.0"], "dependencies": [], "after_dependencies": ["cloud"], "zeroconf": ["_googlecast._tcp.local."], diff --git a/requirements_all.txt b/requirements_all.txt index c608c6f0596..1664f72f5b9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1188,7 +1188,7 @@ pycfdns==0.0.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==4.1.1 +pychromecast==4.2.0 # homeassistant.components.cmus pycmus==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f531ccf6726..8aa311a1de6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -443,7 +443,7 @@ pyblackbird==0.5 pybotvac==0.0.17 # homeassistant.components.cast -pychromecast==4.1.1 +pychromecast==4.2.0 # homeassistant.components.coolmaster pycoolmasternet==0.0.4 From ae98f13181fe1452ba04882ee8a1754d2f3faff3 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 Mar 2020 10:36:59 -0700 Subject: [PATCH 407/416] Bumped version to 0.107.0b7 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 6ca593de34e..9b6f3aaca6a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 107 -PATCH_VERSION = "0b6" +PATCH_VERSION = "0b7" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From f3e682004231abd693d3e0233089cf9839a58394 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Tue, 17 Mar 2020 22:19:42 +0200 Subject: [PATCH 408/416] Fix setting up options due to config data freeze (#32872) --- homeassistant/components/mikrotik/hub.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/mikrotik/hub.py b/homeassistant/components/mikrotik/hub.py index 300d73b6b11..023bdc74a7e 100644 --- a/homeassistant/components/mikrotik/hub.py +++ b/homeassistant/components/mikrotik/hub.py @@ -332,16 +332,17 @@ class MikrotikHub: async def async_add_options(self): """Populate default options for Mikrotik.""" if not self.config_entry.options: + data = dict(self.config_entry.data) options = { - CONF_ARP_PING: self.config_entry.data.pop(CONF_ARP_PING, False), - CONF_FORCE_DHCP: self.config_entry.data.pop(CONF_FORCE_DHCP, False), - CONF_DETECTION_TIME: self.config_entry.data.pop( + CONF_ARP_PING: data.pop(CONF_ARP_PING, False), + CONF_FORCE_DHCP: data.pop(CONF_FORCE_DHCP, False), + CONF_DETECTION_TIME: data.pop( CONF_DETECTION_TIME, DEFAULT_DETECTION_TIME ), } self.hass.config_entries.async_update_entry( - self.config_entry, options=options + self.config_entry, data=data, options=options ) async def request_update(self): From fddb565e4cc2dea9b7e3278ab608a7d50f7ea7e2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 17 Mar 2020 20:42:55 +0100 Subject: [PATCH 409/416] Fix input text reload (#32911) * Fix input text reload * FIx schema instead --- .../components/input_text/__init__.py | 41 ++++++++----------- 1 file changed, 16 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 692a0101249..c512bc221db 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -88,24 +88,22 @@ def _cv_input_text(cfg): CONFIG_SCHEMA = vol.Schema( { DOMAIN: cv.schema_with_slug_keys( - vol.Any( - vol.All( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.Coerce(int), - vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int), - vol.Optional(CONF_INITIAL, ""): cv.string, - vol.Optional(CONF_ICON): cv.icon, - vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_PATTERN): cv.string, - vol.Optional(CONF_MODE, default=MODE_TEXT): vol.In( - [MODE_TEXT, MODE_PASSWORD] - ), - }, - _cv_input_text, - ), - None, - ) + vol.All( + lambda value: value or {}, + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MIN, default=CONF_MIN_VALUE): vol.Coerce(int), + vol.Optional(CONF_MAX, default=CONF_MAX_VALUE): vol.Coerce(int), + vol.Optional(CONF_INITIAL, ""): cv.string, + vol.Optional(CONF_ICON): cv.icon, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_PATTERN): cv.string, + vol.Optional(CONF_MODE, default=MODE_TEXT): vol.In( + [MODE_TEXT, MODE_PASSWORD] + ), + }, + _cv_input_text, + ), ) }, extra=vol.ALLOW_EXTRA, @@ -203,13 +201,6 @@ class InputText(RestoreEntity): @classmethod def from_yaml(cls, config: typing.Dict) -> "InputText": """Return entity instance initialized from yaml storage.""" - # set defaults for empty config - config = { - CONF_MAX: CONF_MAX_VALUE, - CONF_MIN: CONF_MIN_VALUE, - CONF_MODE: MODE_TEXT, - **config, - } input_text = cls(config) input_text.entity_id = f"{DOMAIN}.{config[CONF_ID]}" input_text.editable = False From b70be5f2f232b18bb4db2e4041f5aa96385c121e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 Mar 2020 12:14:17 -0700 Subject: [PATCH 410/416] Blacklist auto_backup (#32912) * Blacklist auto_backup * Mock with a set --- homeassistant/setup.py | 8 ++++++++ tests/test_setup.py | 13 +++++++++++++ 2 files changed, 21 insertions(+) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index f62228b28f5..e00c5fac03f 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -19,6 +19,8 @@ DATA_DEPS_REQS = "deps_reqs_processed" SLOW_SETUP_WARNING = 10 +BLACKLIST = set(["auto_backup"]) + def setup_component(hass: core.HomeAssistant, domain: str, config: Dict) -> bool: """Set up a component and all its dependencies.""" @@ -37,6 +39,12 @@ async def async_setup_component( if domain in hass.config.components: return True + if domain in BLACKLIST: + _LOGGER.error( + "Integration %s is blacklisted because it is causing issues.", domain + ) + return False + setup_tasks = hass.data.setdefault(DATA_SETUP, {}) if domain in setup_tasks: diff --git a/tests/test_setup.py b/tests/test_setup.py index f90a7269752..95fd1e0a15d 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -6,6 +6,7 @@ import os import threading from unittest import mock +from asynctest import patch import voluptuous as vol from homeassistant import setup @@ -535,3 +536,15 @@ async def test_setup_import_blows_up(hass): "homeassistant.loader.Integration.get_component", side_effect=ValueError ): assert not await setup.async_setup_component(hass, "sun", {}) + + +async def test_blacklist(caplog): + """Test setup blacklist.""" + with patch("homeassistant.setup.BLACKLIST", {"bad_integration"}): + assert not await setup.async_setup_component( + mock.Mock(config=mock.Mock(components=[])), "bad_integration", {} + ) + assert ( + "Integration bad_integration is blacklisted because it is causing issues." + in caplog.text + ) From 8348878e7e2534e8d3c8105a648509fc3026283e Mon Sep 17 00:00:00 2001 From: Hans Oischinger Date: Tue, 17 Mar 2020 21:19:57 +0100 Subject: [PATCH 411/416] Introduce safe scan_interval for vicare (#32915) --- homeassistant/components/vicare/climate.py | 4 ++++ homeassistant/components/vicare/water_heater.py | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index 1b101cc7612..ef5533523f8 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -1,4 +1,5 @@ """Viessmann ViCare climate device.""" +from datetime import timedelta import logging import requests @@ -79,6 +80,9 @@ HA_TO_VICARE_PRESET_HEATING = { PYVICARE_ERROR = "error" +# Scan interval of 15 minutes seems to be safe to not hit the ViCare server rate limit +SCAN_INTERVAL = timedelta(seconds=900) + def setup_platform(hass, config, add_entities, discovery_info=None): """Create the ViCare climate devices.""" diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index eea3d81faf6..fdac2962739 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -1,4 +1,5 @@ """Viessmann ViCare water_heater device.""" +from datetime import timedelta import logging import requests @@ -42,6 +43,9 @@ HA_TO_VICARE_HVAC_DHW = { PYVICARE_ERROR = "error" +# Scan interval of 15 minutes seems to be safe to not hit the ViCare server rate limit +SCAN_INTERVAL = timedelta(seconds=900) + def setup_platform(hass, config, add_entities, discovery_info=None): """Create the ViCare water_heater devices.""" From d0d9d853f21e17d699af58dfef32b404e6193f14 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 Mar 2020 17:54:57 -0700 Subject: [PATCH 412/416] Fix hassio panel load (#32922) * Fix loading hassio panel * Remove blacklist --- homeassistant/components/hassio/__init__.py | 19 +++++++++---------- homeassistant/setup.py | 8 -------- tests/test_setup.py | 13 ------------- 3 files changed, 9 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index cc03f26085c..bcb751faa64 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -190,16 +190,15 @@ async def async_setup(hass, config): hass.http.register_view(HassIOView(host, websession)) - if "frontend" in hass.config.components: - await hass.components.panel_custom.async_register_panel( - frontend_url_path="hassio", - webcomponent_name="hassio-main", - sidebar_title="Supervisor", - sidebar_icon="hass:home-assistant", - js_url="/api/hassio/app/entrypoint.js", - embed_iframe=True, - require_admin=True, - ) + await hass.components.panel_custom.async_register_panel( + frontend_url_path="hassio", + webcomponent_name="hassio-main", + sidebar_title="Supervisor", + sidebar_icon="hass:home-assistant", + js_url="/api/hassio/app/entrypoint.js", + embed_iframe=True, + require_admin=True, + ) await hassio.update_hass_api(config.get("http", {}), refresh_token) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index e00c5fac03f..f62228b28f5 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -19,8 +19,6 @@ DATA_DEPS_REQS = "deps_reqs_processed" SLOW_SETUP_WARNING = 10 -BLACKLIST = set(["auto_backup"]) - def setup_component(hass: core.HomeAssistant, domain: str, config: Dict) -> bool: """Set up a component and all its dependencies.""" @@ -39,12 +37,6 @@ async def async_setup_component( if domain in hass.config.components: return True - if domain in BLACKLIST: - _LOGGER.error( - "Integration %s is blacklisted because it is causing issues.", domain - ) - return False - setup_tasks = hass.data.setdefault(DATA_SETUP, {}) if domain in setup_tasks: diff --git a/tests/test_setup.py b/tests/test_setup.py index 95fd1e0a15d..f90a7269752 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -6,7 +6,6 @@ import os import threading from unittest import mock -from asynctest import patch import voluptuous as vol from homeassistant import setup @@ -536,15 +535,3 @@ async def test_setup_import_blows_up(hass): "homeassistant.loader.Integration.get_component", side_effect=ValueError ): assert not await setup.async_setup_component(hass, "sun", {}) - - -async def test_blacklist(caplog): - """Test setup blacklist.""" - with patch("homeassistant.setup.BLACKLIST", {"bad_integration"}): - assert not await setup.async_setup_component( - mock.Mock(config=mock.Mock(components=[])), "bad_integration", {} - ) - assert ( - "Integration bad_integration is blacklisted because it is causing issues." - in caplog.text - ) From 0ca87007fdfcd510ca1e5bd5c2cdfae870569995 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 17 Mar 2020 17:56:18 -0700 Subject: [PATCH 413/416] Bumped version to 0.107.0b8 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 9b6f3aaca6a..fac3badfa53 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 107 -PATCH_VERSION = "0b7" +PATCH_VERSION = "0b8" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 657bf33e326a744db3b2481490d55ad4a04e666a Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 18 Mar 2020 13:02:27 +0100 Subject: [PATCH 414/416] Updated frontend to 20200318.0 (#32931) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 174bab5a189..fc9cd188565 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -3,7 +3,7 @@ "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/integrations/frontend", "requirements": [ - "home-assistant-frontend==20200316.1" + "home-assistant-frontend==20200318.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 37a0a8e357e..36499440c19 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ cryptography==2.8 defusedxml==0.6.0 distro==1.4.0 hass-nabucasa==0.32.2 -home-assistant-frontend==20200316.1 +home-assistant-frontend==20200318.0 importlib-metadata==1.5.0 jinja2>=2.10.3 netdisco==2.6.0 diff --git a/requirements_all.txt b/requirements_all.txt index 1664f72f5b9..afe893dbc56 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -696,7 +696,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200316.1 +home-assistant-frontend==20200318.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8aa311a1de6..4234621441f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -263,7 +263,7 @@ hole==0.5.0 holidays==0.10.1 # homeassistant.components.frontend -home-assistant-frontend==20200316.1 +home-assistant-frontend==20200318.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.9 From c2f615839d22ab58e260508ea46defe094826efb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 18 Mar 2020 13:31:02 +0100 Subject: [PATCH 415/416] Bumped version to 0.107.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index fac3badfa53..a8a8b898ebb 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 107 -PATCH_VERSION = "0b8" +PATCH_VERSION = "0" __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER = (3, 7, 0) From 1e469b39ad159b9a34210c800322861c48eeee4c Mon Sep 17 00:00:00 2001 From: SukramJ Date: Sat, 14 Mar 2020 19:35:15 +0100 Subject: [PATCH 416/416] Fix flaky tests for HMIPC (#32806) --- .../components/homematicip_cloud/conftest.py | 30 ++++++++++++++++--- .../homematicip_cloud/test_config_flow.py | 7 +++-- .../components/homematicip_cloud/test_hap.py | 7 ++--- .../components/homematicip_cloud/test_init.py | 16 +++++++--- 4 files changed, 45 insertions(+), 15 deletions(-) diff --git a/tests/components/homematicip_cloud/conftest.py b/tests/components/homematicip_cloud/conftest.py index 502e9d1b73e..927690d881f 100644 --- a/tests/components/homematicip_cloud/conftest.py +++ b/tests/components/homematicip_cloud/conftest.py @@ -1,5 +1,5 @@ """Initializer helpers for HomematicIP fake server.""" -from asynctest import CoroutineMock, MagicMock, Mock +from asynctest import CoroutineMock, MagicMock, Mock, patch from homematicip.aio.auth import AsyncAuth from homematicip.aio.connection import AsyncConnection from homematicip.aio.home import AsyncHome @@ -106,9 +106,10 @@ async def mock_hap_with_service_fixture( @pytest.fixture(name="simple_mock_home") -def simple_mock_home_fixture() -> AsyncHome: - """Return a simple AsyncHome Mock.""" - return Mock( +def simple_mock_home_fixture(): + """Return a simple mocked connection.""" + + mock_home = Mock( spec=AsyncHome, name="Demo", devices=[], @@ -120,6 +121,27 @@ def simple_mock_home_fixture() -> AsyncHome: connected=True, ) + with patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome", + autospec=True, + return_value=mock_home, + ): + yield + + +@pytest.fixture(name="mock_connection_init") +def mock_connection_init_fixture(): + """Return a simple mocked connection.""" + + with patch( + "homeassistant.components.homematicip_cloud.hap.AsyncHome.init", + return_value=None, + ), patch( + "homeassistant.components.homematicip_cloud.hap.AsyncAuth.init", + return_value=None, + ): + yield + @pytest.fixture(name="simple_mock_auth") def simple_mock_auth_fixture() -> AsyncAuth: diff --git a/tests/components/homematicip_cloud/test_config_flow.py b/tests/components/homematicip_cloud/test_config_flow.py index 01e820e7565..6436433a147 100644 --- a/tests/components/homematicip_cloud/test_config_flow.py +++ b/tests/components/homematicip_cloud/test_config_flow.py @@ -16,12 +16,15 @@ DEFAULT_CONFIG = {HMIPC_HAPID: "ABC123", HMIPC_PIN: "123", HMIPC_NAME: "hmip"} IMPORT_CONFIG = {HMIPC_HAPID: "ABC123", HMIPC_AUTHTOKEN: "123", HMIPC_NAME: "hmip"} -async def test_flow_works(hass): +async def test_flow_works(hass, simple_mock_home): """Test config flow.""" with patch( "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_checkbutton", return_value=False, + ), patch( + "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.get_auth", + return_value=True, ): result = await hass.config_entries.flow.async_init( HMIPC_DOMAIN, context={"source": "user"}, data=DEFAULT_CONFIG @@ -137,7 +140,7 @@ async def test_init_already_configured(hass): assert result["reason"] == "already_configured" -async def test_import_config(hass): +async def test_import_config(hass, simple_mock_home): """Test importing a host with an existing config file.""" with patch( "homeassistant.components.homematicip_cloud.hap.HomematicipAuth.async_checkbutton", diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index 1dd5b2fc789..e6e143973f3 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -125,14 +125,11 @@ async def test_hap_create(hass, hmip_config_entry, simple_mock_home): hass.config.components.add(HMIPC_DOMAIN) hap = HomematicipHAP(hass, hmip_config_entry) assert hap - with patch( - "homeassistant.components.homematicip_cloud.hap.AsyncHome", - return_value=simple_mock_home, - ), patch.object(hap, "async_connect"): + with patch.object(hap, "async_connect"): assert await hap.async_setup() -async def test_hap_create_exception(hass, hmip_config_entry): +async def test_hap_create_exception(hass, hmip_config_entry, mock_connection_init): """Mock AsyncHome to execute get_hap.""" hass.config.components.add(HMIPC_DOMAIN) diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py index ef7f5fa24ae..f97e7114b94 100644 --- a/tests/components/homematicip_cloud/test_init.py +++ b/tests/components/homematicip_cloud/test_init.py @@ -24,7 +24,9 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -async def test_config_with_accesspoint_passed_to_config_entry(hass): +async def test_config_with_accesspoint_passed_to_config_entry( + hass, mock_connection, simple_mock_home +): """Test that config for a accesspoint are loaded via config entry.""" entry_config = { @@ -51,7 +53,9 @@ async def test_config_with_accesspoint_passed_to_config_entry(hass): assert isinstance(hass.data[HMIPC_DOMAIN]["ABC123"], HomematicipHAP) -async def test_config_already_registered_not_passed_to_config_entry(hass): +async def test_config_already_registered_not_passed_to_config_entry( + hass, simple_mock_home +): """Test that an already registered accesspoint does not get imported.""" mock_config = {HMIPC_AUTHTOKEN: "123", HMIPC_HAPID: "ABC123", HMIPC_NAME: "name"} @@ -87,7 +91,9 @@ async def test_config_already_registered_not_passed_to_config_entry(hass): assert config_entries[0].unique_id == "ABC123" -async def test_load_entry_fails_due_to_connection_error(hass, hmip_config_entry): +async def test_load_entry_fails_due_to_connection_error( + hass, hmip_config_entry, mock_connection_init +): """Test load entry fails due to connection error.""" hmip_config_entry.add_to_hass(hass) @@ -101,7 +107,9 @@ async def test_load_entry_fails_due_to_connection_error(hass, hmip_config_entry) assert hmip_config_entry.state == ENTRY_STATE_SETUP_RETRY -async def test_load_entry_fails_due_to_generic_exception(hass, hmip_config_entry): +async def test_load_entry_fails_due_to_generic_exception( + hass, hmip_config_entry, simple_mock_home +): """Test load entry fails due to generic exception.""" hmip_config_entry.add_to_hass(hass)