From 355e557c24077fe1810a0db6188ba646735dbef6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 30 Jun 2021 16:55:41 +0200 Subject: [PATCH 001/134] Bumped version to 2021.7.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 250a50bfada..89fccd434ed 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0.dev0" +PATCH_VERSION: Final = "0b0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From b565dcf3b0903d5fddb03fec59bc7a5c0d2a5b60 Mon Sep 17 00:00:00 2001 From: Bruce Sheplan Date: Thu, 1 Jul 2021 03:11:01 -0500 Subject: [PATCH 002/134] Add screenlogic reconnect (#52022) Co-authored-by: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> --- .../components/screenlogic/__init__.py | 73 ++++++++++++++----- 1 file changed, 54 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 521a1ea798c..223ca9262ee 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -40,25 +40,8 @@ async def async_setup(hass: HomeAssistant, config: dict): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Screenlogic from a config entry.""" - mac = entry.unique_id - # Attempt to re-discover named gateway to follow IP changes - discovered_gateways = hass.data[DOMAIN][DISCOVERED_GATEWAYS] - if mac in discovered_gateways: - connect_info = discovered_gateways[mac] - else: - _LOGGER.warning("Gateway rediscovery failed") - # Static connection defined or fallback from discovery - connect_info = { - SL_GATEWAY_NAME: name_for_mac(mac), - SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], - SL_GATEWAY_PORT: entry.data[CONF_PORT], - } - try: - gateway = ScreenLogicGateway(**connect_info) - except ScreenLogicError as ex: - _LOGGER.error("Error while connecting to the gateway %s: %s", connect_info, ex) - raise ConfigEntryNotReady from ex + gateway = await hass.async_add_executor_job(get_new_gateway, hass, entry) # The api library uses a shared socket connection and does not handle concurrent # requests very well. @@ -99,6 +82,39 @@ async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry): await hass.config_entries.async_reload(entry.entry_id) +def get_connect_info(hass: HomeAssistant, entry: ConfigEntry): + """Construct connect_info from configuration entry and returns it to caller.""" + mac = entry.unique_id + # Attempt to re-discover named gateway to follow IP changes + discovered_gateways = hass.data[DOMAIN][DISCOVERED_GATEWAYS] + if mac in discovered_gateways: + connect_info = discovered_gateways[mac] + else: + _LOGGER.warning("Gateway rediscovery failed") + # Static connection defined or fallback from discovery + connect_info = { + SL_GATEWAY_NAME: name_for_mac(mac), + SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], + SL_GATEWAY_PORT: entry.data[CONF_PORT], + } + + return connect_info + + +def get_new_gateway(hass: HomeAssistant, entry: ConfigEntry): + """Instantiate a new ScreenLogicGateway, connect to it and return it to caller.""" + + connect_info = get_connect_info(hass, entry) + + try: + gateway = ScreenLogicGateway(**connect_info) + except ScreenLogicError as ex: + _LOGGER.error("Error while connecting to the gateway %s: %s", connect_info, ex) + raise ConfigEntryNotReady from ex + + return gateway + + class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): """Class to manage the data update for the Screenlogic component.""" @@ -119,13 +135,32 @@ class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator): update_interval=interval, ) + def reconnect_gateway(self): + """Instantiate a new ScreenLogicGateway, connect to it and update. Return new gateway to caller.""" + + connect_info = get_connect_info(self.hass, self.config_entry) + + try: + gateway = ScreenLogicGateway(**connect_info) + gateway.update() + except ScreenLogicError as error: + raise UpdateFailed(error) from error + + return gateway + async def _async_update_data(self): """Fetch data from the Screenlogic gateway.""" try: async with self.api_lock: await self.hass.async_add_executor_job(self.gateway.update) except ScreenLogicError as error: - raise UpdateFailed(error) from error + _LOGGER.warning("ScreenLogicError - attempting reconnect: %s", error) + + async with self.api_lock: + self.gateway = await self.hass.async_add_executor_job( + self.reconnect_gateway + ) + return self.gateway.get_data() From 96998aafe388d094df861a2dc80ff13cb476f575 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 30 Jun 2021 11:09:19 -0500 Subject: [PATCH 003/134] Update homekit_controller to use async zeroconf (#52330) --- homeassistant/components/homekit_controller/__init__.py | 6 ++++-- homeassistant/components/homekit_controller/config_flow.py | 6 ++++-- homeassistant/components/homekit_controller/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 11 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 44d8286984c..f7507d09837 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -219,8 +219,10 @@ async def async_setup(hass, config): map_storage = hass.data[ENTITY_MAP] = EntityMapStorage(hass) await map_storage.async_initialize() - zeroconf_instance = await zeroconf.async_get_instance(hass) - hass.data[CONTROLLER] = aiohomekit.Controller(zeroconf_instance=zeroconf_instance) + async_zeroconf_instance = await zeroconf.async_get_async_instance(hass) + hass.data[CONTROLLER] = aiohomekit.Controller( + async_zeroconf_instance=async_zeroconf_instance + ) hass.data[KNOWN_DEVICES] = {} hass.data[TRIGGERS] = {} diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 6ae66d362c9..ebb14e43378 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -99,8 +99,10 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def _async_setup_controller(self): """Create the controller.""" - zeroconf_instance = await zeroconf.async_get_instance(self.hass) - self.controller = aiohomekit.Controller(zeroconf_instance=zeroconf_instance) + async_zeroconf_instance = await zeroconf.async_get_async_instance(self.hass) + self.controller = aiohomekit.Controller( + async_zeroconf_instance=async_zeroconf_instance + ) async def async_step_user(self, user_input=None): """Handle a flow start.""" diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 7ff32e402fe..155f3a4f5f6 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.2.67"], + "requirements": ["aiohomekit==0.4.0"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/requirements_all.txt b/requirements_all.txt index a0e10cdf991..13fc6ace1d2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -175,7 +175,7 @@ aioguardian==1.0.4 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.2.67 +aiohomekit==0.4.0 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 70585ece828..8e76f627281 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -112,7 +112,7 @@ aioguardian==1.0.4 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.2.67 +aiohomekit==0.4.0 # homeassistant.components.emulated_hue # homeassistant.components.http From 6d346a59c262b32d852d1c991eb09091cc8cc750 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Thu, 1 Jul 2021 07:48:29 +0200 Subject: [PATCH 004/134] Bump bt_proximity (#52364) --- homeassistant/components/bluetooth_tracker/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bluetooth_tracker/manifest.json b/homeassistant/components/bluetooth_tracker/manifest.json index a41720c2c4f..ccf48a9b8c3 100644 --- a/homeassistant/components/bluetooth_tracker/manifest.json +++ b/homeassistant/components/bluetooth_tracker/manifest.json @@ -2,7 +2,7 @@ "domain": "bluetooth_tracker", "name": "Bluetooth Tracker", "documentation": "https://www.home-assistant.io/integrations/bluetooth_tracker", - "requirements": ["bt_proximity==0.2", "pybluez==0.22"], + "requirements": ["bt_proximity==0.2.1", "pybluez==0.22"], "codeowners": [], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 13fc6ace1d2..0c41ce28a7b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -417,7 +417,7 @@ brunt==0.1.3 bsblan==0.4.0 # homeassistant.components.bluetooth_tracker -bt_proximity==0.2 +bt_proximity==0.2.1 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 From c783ef49c72d558172f8bb27aba5078df0ff2be0 Mon Sep 17 00:00:00 2001 From: Tobias Sauerwein Date: Thu, 1 Jul 2021 00:19:22 +0200 Subject: [PATCH 005/134] Bump pyatmo to v5.2.0 (#52365) * Bump pyatmo to v5.2.0 * Revert formatting changes --- 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 a6630a00f50..de7fbc36038 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==5.1.0" + "pyatmo==5.2.0" ], "after_dependencies": [ "cloud", diff --git a/requirements_all.txt b/requirements_all.txt index 0c41ce28a7b..674956249fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1319,7 +1319,7 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==5.1.0 +pyatmo==5.2.0 # homeassistant.components.atome pyatome==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8e76f627281..6ca5677143e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -747,7 +747,7 @@ pyarlo==0.2.4 pyatag==0.3.5.3 # homeassistant.components.netatmo -pyatmo==5.1.0 +pyatmo==5.2.0 # homeassistant.components.apple_tv pyatv==0.7.7 From 8de7312c92469f73fc30a10e5396c60a921bd44a Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Thu, 1 Jul 2021 14:05:55 -0400 Subject: [PATCH 006/134] Bump up ZHA dependencies (#52374) * Bump up ZHA dependencies * Fix broken tests * Update tests/components/zha/common.py Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- homeassistant/components/zha/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- tests/components/zha/common.py | 5 +++-- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index d1e79d1b67b..b366b73d6c8 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -7,10 +7,10 @@ "bellows==0.25.0", "pyserial==3.5", "pyserial-asyncio==0.5", - "zha-quirks==0.0.57", + "zha-quirks==0.0.58", "zigpy-cc==0.5.2", "zigpy-deconz==0.12.0", - "zigpy==0.34.0", + "zigpy==0.35.0", "zigpy-xbee==0.13.0", "zigpy-zigate==0.7.3", "zigpy-znp==0.5.1" diff --git a/requirements_all.txt b/requirements_all.txt index 674956249fb..2ea8a88b303 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2431,7 +2431,7 @@ zengge==0.2 zeroconf==0.32.0 # homeassistant.components.zha -zha-quirks==0.0.57 +zha-quirks==0.0.58 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2455,7 +2455,7 @@ zigpy-zigate==0.7.3 zigpy-znp==0.5.1 # homeassistant.components.zha -zigpy==0.34.0 +zigpy==0.35.0 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ca5677143e..aa044b66df5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1334,7 +1334,7 @@ zeep[async]==4.0.0 zeroconf==0.32.0 # homeassistant.components.zha -zha-quirks==0.0.57 +zha-quirks==0.0.58 # homeassistant.components.zha zigpy-cc==0.5.2 @@ -1352,7 +1352,7 @@ zigpy-zigate==0.7.3 zigpy-znp==0.5.1 # homeassistant.components.zha -zigpy==0.34.0 +zigpy==0.35.0 # homeassistant.components.zwave_js zwave-js-server-python==0.27.0 diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 45caed95ae6..eb65cc4fd2e 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -40,8 +40,9 @@ class FakeEndpoint: if _patch_cluster: patch_cluster(cluster) self.in_clusters[cluster_id] = cluster - if hasattr(cluster, "ep_attribute"): - setattr(self, cluster.ep_attribute, cluster) + ep_attribute = cluster.ep_attribute + if ep_attribute: + setattr(self, ep_attribute, cluster) def add_output_cluster(self, cluster_id, _patch_cluster=True): """Add an output cluster.""" From 5cc878fc793ce33302c139b6c83f872bc2b6c698 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 1 Jul 2021 03:35:14 -0500 Subject: [PATCH 007/134] Fix missing default latitude/longitude/elevation in OpenUV config flow (#52380) --- .../components/openuv/config_flow.py | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/openuv/config_flow.py b/homeassistant/components/openuv/config_flow.py index 2ed6b56d914..e31cef9ee0a 100644 --- a/homeassistant/components/openuv/config_flow.py +++ b/homeassistant/components/openuv/config_flow.py @@ -14,26 +14,35 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from .const import DOMAIN -CONFIG_SCHEMA = vol.Schema( - { - vol.Required(CONF_API_KEY): str, - vol.Inclusive(CONF_LATITUDE, "coords"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "coords"): cv.longitude, - vol.Optional(CONF_ELEVATION): vol.Coerce(float), - } -) - class OpenUvFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Handle an OpenUV config flow.""" VERSION = 2 + @property + def config_schema(self): + """Return the config schema.""" + return vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Inclusive( + CONF_LATITUDE, "coords", default=self.hass.config.latitude + ): cv.latitude, + vol.Inclusive( + CONF_LONGITUDE, "coords", default=self.hass.config.longitude + ): cv.longitude, + vol.Optional( + CONF_ELEVATION, default=self.hass.config.elevation + ): vol.Coerce(float), + } + ) + async def _show_form(self, errors=None): """Show the form to the user.""" return self.async_show_form( step_id="user", - data_schema=CONFIG_SCHEMA, + data_schema=self.config_schema, errors=errors if errors else {}, ) From 3dcad64d53ad1a79504cf1ce1e75e3dd35f4f589 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 2 Jul 2021 09:51:47 +0200 Subject: [PATCH 008/134] Improve sensor statistics tests (#52386) --- .../components/recorder/statistics.py | 20 +- homeassistant/components/sensor/recorder.py | 8 + tests/components/sensor/test_recorder.py | 532 +++++++++++++----- 3 files changed, 403 insertions(+), 157 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 753a66926ad..0e01005c13a 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -45,6 +45,8 @@ QUERY_STATISTIC_META = [ STATISTICS_BAKERY = "recorder_statistics_bakery" STATISTICS_META_BAKERY = "recorder_statistics_bakery" +# Convert pressure and temperature statistics from the native unit used for statistics +# to the units configured by the user UNIT_CONVERSIONS = { PRESSURE_PA: lambda x, units: pressure_util.convert( x, PRESSURE_PA, units.pressure_unit @@ -137,7 +139,8 @@ def _get_meta_data(hass, session, statistic_ids): return {id: _meta(result, id) for id in statistic_ids} -def _unit_system_unit(unit: str, units) -> str: +def _configured_unit(unit: str, units) -> str: + """Return the pressure and temperature units configured by the user.""" if unit == PRESSURE_PA: return units.pressure_unit if unit == TEMP_CELSIUS: @@ -146,7 +149,7 @@ def _unit_system_unit(unit: str, units) -> str: def list_statistic_ids(hass, statistic_type=None): - """Return statistic_ids.""" + """Return statistic_ids and meta data.""" units = hass.config.units with session_scope(hass=hass) as session: baked_query = hass.data[STATISTICS_BAKERY]( @@ -161,13 +164,14 @@ def list_statistic_ids(hass, statistic_type=None): baked_query += lambda q: q.order_by(Statistics.statistic_id) result = execute(baked_query(session)) - statistic_ids_list = [statistic_id[0] for statistic_id in result] - statistic_ids = _get_meta_data(hass, session, statistic_ids_list) - for statistic_id in statistic_ids.values(): - unit = _unit_system_unit(statistic_id["unit_of_measurement"], units) - statistic_id["unit_of_measurement"] = unit - return list(statistic_ids.values()) + statistic_ids = [statistic_id[0] for statistic_id in result] + meta_data = _get_meta_data(hass, session, statistic_ids) + for item in meta_data.values(): + unit = _configured_unit(item["unit_of_measurement"], units) + item["unit_of_measurement"] = unit + + return list(meta_data.values()) def statistics_during_period(hass, start_time, end_time=None, statistic_ids=None): diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index ac26e06e07d..f88b91d1b70 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -54,6 +54,7 @@ DEVICE_CLASS_STATISTICS = { DEVICE_CLASS_TEMPERATURE: {"mean", "min", "max"}, } +# Normalized units which will be stored in the statistics table DEVICE_CLASS_UNITS = { DEVICE_CLASS_ENERGY: ENERGY_KILO_WATT_HOUR, DEVICE_CLASS_POWER: POWER_WATT, @@ -62,14 +63,18 @@ DEVICE_CLASS_UNITS = { } UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { + # Convert energy to kWh DEVICE_CLASS_ENERGY: { ENERGY_KILO_WATT_HOUR: lambda x: x, ENERGY_WATT_HOUR: lambda x: x / 1000, }, + # Convert power W DEVICE_CLASS_POWER: { POWER_WATT: lambda x: x, POWER_KILO_WATT: lambda x: x * 1000, }, + # Convert pressure to Pa + # Note: pressure_util.convert is bypassed to avoid redundant error checking DEVICE_CLASS_PRESSURE: { PRESSURE_BAR: lambda x: x / pressure_util.UNIT_CONVERSION[PRESSURE_BAR], PRESSURE_HPA: lambda x: x / pressure_util.UNIT_CONVERSION[PRESSURE_HPA], @@ -78,6 +83,8 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { PRESSURE_PA: lambda x: x / pressure_util.UNIT_CONVERSION[PRESSURE_PA], PRESSURE_PSI: lambda x: x / pressure_util.UNIT_CONVERSION[PRESSURE_PSI], }, + # Convert temperature to °C + # Note: temperature_util.convert is bypassed to avoid redundant error checking DEVICE_CLASS_TEMPERATURE: { TEMP_CELSIUS: lambda x: x, TEMP_FAHRENHEIT: temperature_util.fahrenheit_to_celsius, @@ -85,6 +92,7 @@ UNIT_CONVERSIONS: dict[str, dict[str, Callable]] = { }, } +# Keep track of entities for which a warning about unsupported unit has been logged WARN_UNSUPPORTED_UNIT = set() diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 65346f1feba..99ede396381 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1,32 +1,141 @@ """The tests for sensor recorder platform.""" # pylint: disable=protected-access,invalid-name from datetime import timedelta -from unittest.mock import patch, sentinel +from unittest.mock import patch +import pytest from pytest import approx from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat -from homeassistant.components.recorder.statistics import statistics_during_period -from homeassistant.const import STATE_UNAVAILABLE, TEMP_CELSIUS +from homeassistant.components.recorder.statistics import ( + list_statistic_ids, + statistics_during_period, +) +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.setup import setup_component import homeassistant.util.dt as dt_util from tests.components.recorder.common import wait_recording_done +ENERGY_SENSOR_ATTRIBUTES = { + "device_class": "energy", + "state_class": "measurement", + "unit_of_measurement": "kWh", +} +POWER_SENSOR_ATTRIBUTES = { + "device_class": "power", + "state_class": "measurement", + "unit_of_measurement": "kW", +} +PRESSURE_SENSOR_ATTRIBUTES = { + "device_class": "pressure", + "state_class": "measurement", + "unit_of_measurement": "hPa", +} +TEMPERATURE_SENSOR_ATTRIBUTES = { + "device_class": "temperature", + "state_class": "measurement", + "unit_of_measurement": "°C", +} -def test_compile_hourly_statistics(hass_recorder): + +@pytest.mark.parametrize( + "device_class,unit,native_unit,mean,min,max", + [ + ("battery", "%", "%", 16.440677, 10, 30), + ("battery", None, None, 16.440677, 10, 30), + ("humidity", "%", "%", 16.440677, 10, 30), + ("humidity", None, None, 16.440677, 10, 30), + ("pressure", "Pa", "Pa", 16.440677, 10, 30), + ("pressure", "hPa", "Pa", 1644.0677, 1000, 3000), + ("pressure", "mbar", "Pa", 1644.0677, 1000, 3000), + ("pressure", "inHg", "Pa", 55674.53, 33863.89, 101591.67), + ("pressure", "psi", "Pa", 113354.48, 68947.57, 206842.71), + ("temperature", "°C", "°C", 16.440677, 10, 30), + ("temperature", "°F", "°C", -8.644068, -12.22222, -1.111111), + ], +) +def test_compile_hourly_statistics( + hass_recorder, caplog, device_class, unit, native_unit, mean, min, max +): """Test compiling hourly statistics.""" + zero = dt_util.utcnow() hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) - zero, four, states = record_states(hass) + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes) hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) recorder.do_adhoc_statistics(period="hourly", start=zero) wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(mean), + "min": approx(min), + "max": approx(max), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +@pytest.mark.parametrize("attributes", [TEMPERATURE_SENSOR_ATTRIBUTES]) +def test_compile_hourly_statistics_unsupported(hass_recorder, caplog, attributes): + """Test compiling hourly statistics for unsupported sensor.""" + attributes = dict(attributes) + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + four, states = record_states(hass, zero, "sensor.test1", attributes) + if "unit_of_measurement" in attributes: + attributes["unit_of_measurement"] = "invalid" + _, _states = record_states(hass, zero, "sensor.test2", attributes) + states = {**states, **_states} + attributes.pop("unit_of_measurement") + _, _states = record_states(hass, zero, "sensor.test3", attributes) + states = {**states, **_states} + attributes["state_class"] = "invalid" + _, _states = record_states(hass, zero, "sensor.test4", attributes) + states = {**states, **_states} + attributes.pop("state_class") + _, _states = record_states(hass, zero, "sensor.test5", attributes) + states = {**states, **_states} + attributes["state_class"] = "measurement" + _, _states = record_states(hass, zero, "sensor.test6", attributes) + states = {**states, **_states} + attributes["state_class"] = "unsupported" + _, _states = record_states(hass, zero, "sensor.test7", attributes) + states = {**states, **_states} + + hist = history.get_significant_states(hass, zero, four) + assert dict(states) == dict(hist) + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": "°C"} + ] stats = statistics_during_period(hass, zero) assert stats == { "sensor.test1": [ @@ -42,23 +151,36 @@ def test_compile_hourly_statistics(hass_recorder): } ] } + assert "Error while processing event StatisticsTask" not in caplog.text -def test_compile_hourly_energy_statistics(hass_recorder): +@pytest.mark.parametrize( + "device_class,unit,native_unit,factor", + [ + ("energy", "kWh", "kWh", 1), + ("energy", "Wh", "kWh", 1 / 1000), + ("monetary", "€", "€", 1), + ("monetary", "SEK", "SEK", 1), + ], +) +def test_compile_hourly_energy_statistics( + hass_recorder, caplog, device_class, unit, native_unit, factor +): """Test compiling hourly statistics.""" + zero = dt_util.utcnow() hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) - sns1_attr = { - "device_class": "energy", + attributes = { + "device_class": device_class, "state_class": "measurement", - "unit_of_measurement": "kWh", + "unit_of_measurement": unit, + "last_reset": None, } - sns2_attr = {"device_class": "energy"} - sns3_attr = {} + seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] - zero, four, eight, states = record_energy_states( - hass, sns1_attr, sns2_attr, sns3_attr + four, eight, states = record_energy_states( + hass, zero, "sensor.test1", attributes, seq ) hist = history.get_significant_states( hass, zero - timedelta.resolution, eight + timedelta.resolution @@ -71,6 +193,97 @@ def test_compile_hourly_energy_statistics(hass_recorder): wait_recording_done(hass) recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": native_unit} + ] + stats = statistics_during_period(hass, zero) + assert stats == { + "sensor.test1": [ + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(zero), + "state": approx(factor * seq[2]), + "sum": approx(factor * 10.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=1)), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(four), + "state": approx(factor * seq[5]), + "sum": approx(factor * 10.0), + }, + { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero + timedelta(hours=2)), + "max": None, + "mean": None, + "min": None, + "last_reset": process_timestamp_to_utc_isoformat(four), + "state": approx(factor * seq[8]), + "sum": approx(factor * 40.0), + }, + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text + + +def test_compile_hourly_energy_statistics_unsupported(hass_recorder, caplog): + """Test compiling hourly statistics.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + sns1_attr = { + "device_class": "energy", + "state_class": "measurement", + "unit_of_measurement": "kWh", + "last_reset": None, + } + sns2_attr = {"device_class": "energy"} + sns3_attr = {} + sns4_attr = { + "device_class": "energy", + "state_class": "measurement", + "unit_of_measurement": "kWh", + } + seq1 = [10, 15, 20, 10, 30, 40, 50, 60, 70] + seq2 = [110, 120, 130, 0, 30, 45, 55, 65, 75] + seq3 = [0, 0, 5, 10, 30, 50, 60, 80, 90] + seq4 = [0, 0, 5, 10, 30, 50, 60, 80, 90] + + four, eight, states = record_energy_states( + hass, zero, "sensor.test1", sns1_attr, seq1 + ) + _, _, _states = record_energy_states(hass, zero, "sensor.test2", sns2_attr, seq2) + states = {**states, **_states} + _, _, _states = record_energy_states(hass, zero, "sensor.test3", sns3_attr, seq3) + states = {**states, **_states} + _, _, _states = record_energy_states(hass, zero, "sensor.test4", sns4_attr, seq4) + states = {**states, **_states} + + hist = history.get_significant_states( + hass, zero - timedelta.resolution, eight + timedelta.resolution + ) + assert dict(states)["sensor.test1"] == dict(hist)["sensor.test1"] + + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=1)) + wait_recording_done(hass) + recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) + wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": "kWh"} + ] stats = statistics_during_period(hass, zero) assert stats == { "sensor.test1": [ @@ -106,32 +319,37 @@ def test_compile_hourly_energy_statistics(hass_recorder): }, ] } + assert "Error while processing event StatisticsTask" not in caplog.text -def test_compile_hourly_energy_statistics2(hass_recorder): - """Test compiling hourly statistics.""" +def test_compile_hourly_energy_statistics_multiple(hass_recorder, caplog): + """Test compiling multiple hourly statistics.""" + zero = dt_util.utcnow() hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) - sns1_attr = { - "device_class": "energy", - "state_class": "measurement", - "unit_of_measurement": "kWh", - } - sns2_attr = { - "device_class": "energy", - "state_class": "measurement", - "unit_of_measurement": "kWh", - } + sns1_attr = {**ENERGY_SENSOR_ATTRIBUTES, "last_reset": None} + sns2_attr = {**ENERGY_SENSOR_ATTRIBUTES, "last_reset": None} sns3_attr = { - "device_class": "energy", - "state_class": "measurement", + **ENERGY_SENSOR_ATTRIBUTES, "unit_of_measurement": "Wh", + "last_reset": None, } + sns4_attr = {**ENERGY_SENSOR_ATTRIBUTES} + seq1 = [10, 15, 20, 10, 30, 40, 50, 60, 70] + seq2 = [110, 120, 130, 0, 30, 45, 55, 65, 75] + seq3 = [0, 0, 5, 10, 30, 50, 60, 80, 90] + seq4 = [0, 0, 5, 10, 30, 50, 60, 80, 90] - zero, four, eight, states = record_energy_states( - hass, sns1_attr, sns2_attr, sns3_attr + four, eight, states = record_energy_states( + hass, zero, "sensor.test1", sns1_attr, seq1 ) + _, _, _states = record_energy_states(hass, zero, "sensor.test2", sns2_attr, seq2) + states = {**states, **_states} + _, _, _states = record_energy_states(hass, zero, "sensor.test3", sns3_attr, seq3) + states = {**states, **_states} + _, _, _states = record_energy_states(hass, zero, "sensor.test4", sns4_attr, seq4) + states = {**states, **_states} hist = history.get_significant_states( hass, zero - timedelta.resolution, eight + timedelta.resolution ) @@ -143,6 +361,12 @@ def test_compile_hourly_energy_statistics2(hass_recorder): wait_recording_done(hass) recorder.do_adhoc_statistics(period="hourly", start=zero + timedelta(hours=2)) wait_recording_done(hass) + statistic_ids = list_statistic_ids(hass) + assert statistic_ids == [ + {"statistic_id": "sensor.test1", "unit_of_measurement": "kWh"}, + {"statistic_id": "sensor.test2", "unit_of_measurement": "kWh"}, + {"statistic_id": "sensor.test3", "unit_of_measurement": "kWh"}, + ] stats = statistics_during_period(hass, zero) assert stats == { "sensor.test1": [ @@ -242,14 +466,39 @@ def test_compile_hourly_energy_statistics2(hass_recorder): }, ], } + assert "Error while processing event StatisticsTask" not in caplog.text -def test_compile_hourly_statistics_unchanged(hass_recorder): +@pytest.mark.parametrize( + "device_class,unit,value", + [ + ("battery", "%", 30), + ("battery", None, 30), + ("humidity", "%", 30), + ("humidity", None, 30), + ("pressure", "Pa", 30), + ("pressure", "hPa", 3000), + ("pressure", "mbar", 3000), + ("pressure", "inHg", 101591.67), + ("pressure", "psi", 206842.71), + ("temperature", "°C", 30), + ("temperature", "°F", -1.111111), + ], +) +def test_compile_hourly_statistics_unchanged( + hass_recorder, caplog, device_class, unit, value +): """Test compiling hourly statistics, with no changes during the hour.""" + zero = dt_util.utcnow() hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) - zero, four, states = record_states(hass) + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": unit, + } + four, states = record_states(hass, zero, "sensor.test1", attributes) hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) @@ -261,23 +510,27 @@ def test_compile_hourly_statistics_unchanged(hass_recorder): { "statistic_id": "sensor.test1", "start": process_timestamp_to_utc_isoformat(four), - "mean": approx(30.0), - "min": approx(30.0), - "max": approx(30.0), + "mean": approx(value), + "min": approx(value), + "max": approx(value), "last_reset": None, "state": None, "sum": None, } ] } + assert "Error while processing event StatisticsTask" not in caplog.text -def test_compile_hourly_statistics_partially_unavailable(hass_recorder): +def test_compile_hourly_statistics_partially_unavailable(hass_recorder, caplog): """Test compiling hourly statistics, with the sensor being partially unavailable.""" + zero = dt_util.utcnow() hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) - zero, four, states = record_states_partially_unavailable(hass) + four, states = record_states_partially_unavailable( + hass, zero, "sensor.test1", TEMPERATURE_SENSOR_ATTRIBUTES + ) hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) @@ -298,39 +551,87 @@ def test_compile_hourly_statistics_partially_unavailable(hass_recorder): } ] } + assert "Error while processing event StatisticsTask" not in caplog.text -def test_compile_hourly_statistics_unavailable(hass_recorder): +@pytest.mark.parametrize( + "device_class,unit,value", + [ + ("battery", "%", 30), + ("battery", None, 30), + ("humidity", "%", 30), + ("humidity", None, 30), + ("pressure", "Pa", 30), + ("pressure", "hPa", 3000), + ("pressure", "mbar", 3000), + ("pressure", "inHg", 101591.67), + ("pressure", "psi", 206842.71), + ("temperature", "°C", 30), + ("temperature", "°F", -1.111111), + ], +) +def test_compile_hourly_statistics_unavailable( + hass_recorder, caplog, device_class, unit, value +): """Test compiling hourly statistics, with the sensor being unavailable.""" + zero = dt_util.utcnow() hass = hass_recorder() recorder = hass.data[DATA_INSTANCE] setup_component(hass, "sensor", {}) - zero, four, states = record_states_partially_unavailable(hass) + attributes = { + "device_class": device_class, + "state_class": "measurement", + "unit_of_measurement": unit, + } + four, states = record_states_partially_unavailable( + hass, zero, "sensor.test1", attributes + ) + _, _states = record_states(hass, zero, "sensor.test2", attributes) + states = {**states, **_states} hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) recorder.do_adhoc_statistics(period="hourly", start=four) wait_recording_done(hass) stats = statistics_during_period(hass, four) - assert stats == {} + assert stats == { + "sensor.test2": [ + { + "statistic_id": "sensor.test2", + "start": process_timestamp_to_utc_isoformat(four), + "mean": approx(value), + "min": approx(value), + "max": approx(value), + "last_reset": None, + "state": None, + "sum": None, + } + ] + } + assert "Error while processing event StatisticsTask" not in caplog.text -def record_states(hass): +def test_compile_hourly_statistics_fails(hass_recorder, caplog): + """Test compiling hourly statistics throws.""" + zero = dt_util.utcnow() + hass = hass_recorder() + recorder = hass.data[DATA_INSTANCE] + setup_component(hass, "sensor", {}) + with patch( + "homeassistant.components.sensor.recorder.compile_statistics", + side_effect=Exception, + ): + recorder.do_adhoc_statistics(period="hourly", start=zero) + wait_recording_done(hass) + assert "Error while processing event StatisticsTask" in caplog.text + + +def record_states(hass, zero, entity_id, attributes): """Record some test states. We inject a bunch of state updates for temperature sensors. """ - mp = "media_player.test" - sns1 = "sensor.test1" - sns2 = "sensor.test2" - sns3 = "sensor.test3" - sns1_attr = { - "device_class": "temperature", - "state_class": "measurement", - "unit_of_measurement": TEMP_CELSIUS, - } - sns2_attr = {"device_class": "temperature"} - sns3_attr = {} + attributes = dict(attributes) def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -338,46 +639,29 @@ def record_states(hass): wait_recording_done(hass) return hass.states.get(entity_id) - zero = dt_util.utcnow() one = zero + timedelta(minutes=1) two = one + timedelta(minutes=10) three = two + timedelta(minutes=40) four = three + timedelta(minutes=10) - states = {mp: [], sns1: [], sns2: [], sns3: []} + states = {entity_id: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=one): - states[mp].append( - set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) - ) - states[mp].append( - set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) - ) - states[sns1].append(set_state(sns1, "10", attributes=sns1_attr)) - states[sns2].append(set_state(sns2, "10", attributes=sns2_attr)) - states[sns3].append(set_state(sns3, "10", attributes=sns3_attr)) + states[entity_id].append(set_state(entity_id, "10", attributes=attributes)) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=two): - states[sns1].append(set_state(sns1, "15", attributes=sns1_attr)) - states[sns2].append(set_state(sns2, "15", attributes=sns2_attr)) - states[sns3].append(set_state(sns3, "15", attributes=sns3_attr)) + states[entity_id].append(set_state(entity_id, "15", attributes=attributes)) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=three): - states[sns1].append(set_state(sns1, "30", attributes=sns1_attr)) - states[sns2].append(set_state(sns2, "30", attributes=sns2_attr)) - states[sns3].append(set_state(sns3, "30", attributes=sns3_attr)) + states[entity_id].append(set_state(entity_id, "30", attributes=attributes)) - return zero, four, states + return four, states -def record_energy_states(hass, _sns1_attr, _sns2_attr, _sns3_attr): +def record_energy_states(hass, zero, entity_id, _attributes, seq): """Record some test states. We inject a bunch of state updates for energy sensors. """ - sns1 = "sensor.test1" - sns2 = "sensor.test2" - sns3 = "sensor.test3" - sns4 = "sensor.test4" def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -385,7 +669,6 @@ def record_energy_states(hass, _sns1_attr, _sns2_attr, _sns3_attr): wait_recording_done(hass) return hass.states.get(entity_id) - zero = dt_util.utcnow() one = zero + timedelta(minutes=15) two = one + timedelta(minutes=30) three = two + timedelta(minutes=15) @@ -395,88 +678,50 @@ def record_energy_states(hass, _sns1_attr, _sns2_attr, _sns3_attr): seven = six + timedelta(minutes=15) eight = seven + timedelta(minutes=30) - sns1_attr = {**_sns1_attr, "last_reset": zero.isoformat()} - sns2_attr = {**_sns2_attr, "last_reset": zero.isoformat()} - sns3_attr = {**_sns3_attr, "last_reset": zero.isoformat()} - sns4_attr = {**_sns3_attr} + attributes = dict(_attributes) + if "last_reset" in _attributes: + attributes["last_reset"] = zero.isoformat() - states = {sns1: [], sns2: [], sns3: [], sns4: []} + states = {entity_id: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=zero): - states[sns1].append(set_state(sns1, "10", attributes=sns1_attr)) # Sum 0 - states[sns2].append(set_state(sns2, "110", attributes=sns2_attr)) # Sum 0 - states[sns3].append(set_state(sns3, "0", attributes=sns3_attr)) # Sum 0 - states[sns4].append(set_state(sns4, "0", attributes=sns4_attr)) # - + states[entity_id].append(set_state(entity_id, seq[0], attributes=attributes)) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=one): - states[sns1].append(set_state(sns1, "15", attributes=sns1_attr)) # Sum 5 - states[sns2].append(set_state(sns2, "120", attributes=sns2_attr)) # Sum 10 - states[sns3].append(set_state(sns3, "0", attributes=sns3_attr)) # Sum 0 - states[sns4].append(set_state(sns4, "0", attributes=sns4_attr)) # - + states[entity_id].append(set_state(entity_id, seq[1], attributes=attributes)) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=two): - states[sns1].append(set_state(sns1, "20", attributes=sns1_attr)) # Sum 10 - states[sns2].append(set_state(sns2, "130", attributes=sns2_attr)) # Sum 20 - states[sns3].append(set_state(sns3, "5", attributes=sns3_attr)) # Sum 5 - states[sns4].append(set_state(sns4, "5", attributes=sns4_attr)) # - + states[entity_id].append(set_state(entity_id, seq[2], attributes=attributes)) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=three): - states[sns1].append(set_state(sns1, "10", attributes=sns1_attr)) # Sum 0 - states[sns2].append(set_state(sns2, "0", attributes=sns2_attr)) # Sum -110 - states[sns3].append(set_state(sns3, "10", attributes=sns3_attr)) # Sum 10 - states[sns4].append(set_state(sns4, "10", attributes=sns4_attr)) # - + states[entity_id].append(set_state(entity_id, seq[3], attributes=attributes)) - sns1_attr = {**_sns1_attr, "last_reset": four.isoformat()} - sns2_attr = {**_sns2_attr, "last_reset": four.isoformat()} - sns3_attr = {**_sns3_attr, "last_reset": four.isoformat()} + attributes = dict(_attributes) + if "last_reset" in _attributes: + attributes["last_reset"] = four.isoformat() with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=four): - states[sns1].append(set_state(sns1, "30", attributes=sns1_attr)) # Sum 0 - states[sns2].append(set_state(sns2, "30", attributes=sns2_attr)) # Sum -110 - states[sns3].append(set_state(sns3, "30", attributes=sns3_attr)) # Sum 10 - states[sns4].append(set_state(sns4, "30", attributes=sns4_attr)) # - + states[entity_id].append(set_state(entity_id, seq[4], attributes=attributes)) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=five): - states[sns1].append(set_state(sns1, "40", attributes=sns1_attr)) # Sum 10 - states[sns2].append(set_state(sns2, "45", attributes=sns2_attr)) # Sum -95 - states[sns3].append(set_state(sns3, "50", attributes=sns3_attr)) # Sum 30 - states[sns4].append(set_state(sns4, "50", attributes=sns4_attr)) # - + states[entity_id].append(set_state(entity_id, seq[5], attributes=attributes)) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=six): - states[sns1].append(set_state(sns1, "50", attributes=sns1_attr)) # Sum 20 - states[sns2].append(set_state(sns2, "55", attributes=sns2_attr)) # Sum -85 - states[sns3].append(set_state(sns3, "60", attributes=sns3_attr)) # Sum 40 - states[sns4].append(set_state(sns4, "60", attributes=sns4_attr)) # - + states[entity_id].append(set_state(entity_id, seq[6], attributes=attributes)) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=seven): - states[sns1].append(set_state(sns1, "60", attributes=sns1_attr)) # Sum 30 - states[sns2].append(set_state(sns2, "65", attributes=sns2_attr)) # Sum -75 - states[sns3].append(set_state(sns3, "80", attributes=sns3_attr)) # Sum 60 - states[sns4].append(set_state(sns4, "80", attributes=sns4_attr)) # - + states[entity_id].append(set_state(entity_id, seq[7], attributes=attributes)) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=eight): - states[sns1].append(set_state(sns1, "70", attributes=sns1_attr)) # Sum 40 - states[sns2].append(set_state(sns2, "75", attributes=sns2_attr)) # Sum -65 - states[sns3].append(set_state(sns3, "90", attributes=sns3_attr)) # Sum 70 + states[entity_id].append(set_state(entity_id, seq[8], attributes=attributes)) - return zero, four, eight, states + return four, eight, states -def record_states_partially_unavailable(hass): +def record_states_partially_unavailable(hass, zero, entity_id, attributes): """Record some test states. We inject a bunch of state updates temperature sensors. """ - mp = "media_player.test" - sns1 = "sensor.test1" - sns2 = "sensor.test2" - sns3 = "sensor.test3" - sns1_attr = { - "device_class": "temperature", - "state_class": "measurement", - "unit_of_measurement": TEMP_CELSIUS, - } - sns2_attr = {"device_class": "temperature"} - sns3_attr = {} def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -484,32 +729,21 @@ def record_states_partially_unavailable(hass): wait_recording_done(hass) return hass.states.get(entity_id) - zero = dt_util.utcnow() one = zero + timedelta(minutes=1) two = one + timedelta(minutes=15) three = two + timedelta(minutes=30) four = three + timedelta(minutes=15) - states = {mp: [], sns1: [], sns2: [], sns3: []} + states = {entity_id: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=one): - states[mp].append( - set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) - ) - states[mp].append( - set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) - ) - states[sns1].append(set_state(sns1, "10", attributes=sns1_attr)) - states[sns2].append(set_state(sns2, "10", attributes=sns2_attr)) - states[sns3].append(set_state(sns3, "10", attributes=sns3_attr)) + states[entity_id].append(set_state(entity_id, "10", attributes=attributes)) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=two): - states[sns1].append(set_state(sns1, "25", attributes=sns1_attr)) - states[sns2].append(set_state(sns2, "25", attributes=sns2_attr)) - states[sns3].append(set_state(sns3, "25", attributes=sns3_attr)) + states[entity_id].append(set_state(entity_id, "25", attributes=attributes)) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=three): - states[sns1].append(set_state(sns1, STATE_UNAVAILABLE, attributes=sns1_attr)) - states[sns2].append(set_state(sns2, STATE_UNAVAILABLE, attributes=sns2_attr)) - states[sns3].append(set_state(sns3, STATE_UNAVAILABLE, attributes=sns3_attr)) + states[entity_id].append( + set_state(entity_id, STATE_UNAVAILABLE, attributes=attributes) + ) - return zero, four, states + return four, states From b8b0bc9392bd5c27935ccc4144d331c3ebedb3bf Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 2 Jul 2021 11:49:42 +0200 Subject: [PATCH 009/134] Reject trusted network access from proxies (#52388) --- .../auth/providers/trusted_networks.py | 14 ++++++++ homeassistant/components/http/forwarded.py | 8 ++--- tests/auth/providers/test_trusted_networks.py | 25 ++++++++++++++ tests/components/http/test_forwarded.py | 33 ++++--------------- 4 files changed, 48 insertions(+), 32 deletions(-) diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index fd2014667f8..7b609f371ef 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -81,6 +81,17 @@ class TrustedNetworksAuthProvider(AuthProvider): """Return trusted users per network.""" return cast(Dict[IPNetwork, Any], self.config[CONF_TRUSTED_USERS]) + @property + def trusted_proxies(self) -> list[IPNetwork]: + """Return trusted proxies in the system.""" + if not self.hass.http: + return [] + + return [ + ip_network(trusted_proxy) + for trusted_proxy in self.hass.http.trusted_proxies + ] + @property def support_mfa(self) -> bool: """Trusted Networks auth provider does not support MFA.""" @@ -178,6 +189,9 @@ class TrustedNetworksAuthProvider(AuthProvider): ): raise InvalidAuthError("Not in trusted_networks") + if any(ip_addr in trusted_proxy for trusted_proxy in self.trusted_proxies): + raise InvalidAuthError("Can't allow access from a proxy server") + @callback def async_validate_refresh_token( self, refresh_token: RefreshToken, remote_ip: str | None = None diff --git a/homeassistant/components/http/forwarded.py b/homeassistant/components/http/forwarded.py index 684dbbb9e2b..18bc51af1d1 100644 --- a/homeassistant/components/http/forwarded.py +++ b/homeassistant/components/http/forwarded.py @@ -129,11 +129,9 @@ def async_setup_forwarded( overrides["remote"] = str(forwarded_ip) break else: - _LOGGER.warning( - "Request originated directly from a trusted proxy included in X-Forwarded-For: %s, this is likely a miss configuration and will be rejected", - forwarded_for_headers, - ) - raise HTTPBadRequest() + # If all the IP addresses are from trusted networks, take the left-most. + forwarded_for_index = -1 + overrides["remote"] = str(forwarded_for[-1]) # Handle X-Forwarded-Proto forwarded_proto_headers: list[str] = request.headers.getall( diff --git a/tests/auth/providers/test_trusted_networks.py b/tests/auth/providers/test_trusted_networks.py index 412f660adc3..d7574bf0da1 100644 --- a/tests/auth/providers/test_trusted_networks.py +++ b/tests/auth/providers/test_trusted_networks.py @@ -8,7 +8,9 @@ import voluptuous as vol from homeassistant import auth from homeassistant.auth import auth_store from homeassistant.auth.providers import trusted_networks as tn_auth +from homeassistant.components.http import CONF_TRUSTED_PROXIES, CONF_USE_X_FORWARDED_FOR from homeassistant.data_entry_flow import RESULT_TYPE_ABORT, RESULT_TYPE_CREATE_ENTRY +from homeassistant.setup import async_setup_component @pytest.fixture @@ -144,6 +146,29 @@ async def test_validate_access(provider): provider.async_validate_access(ip_address("2001:db8::ff00:42:8329")) +async def test_validate_access_proxy(hass, provider): + """Test validate access from trusted networks are blocked from proxy.""" + + await async_setup_component( + hass, + "http", + { + "http": { + CONF_TRUSTED_PROXIES: ["192.168.128.0/31", "fd00::1"], + CONF_USE_X_FORWARDED_FOR: True, + } + }, + ) + provider.async_validate_access(ip_address("192.168.128.2")) + provider.async_validate_access(ip_address("fd00::2")) + with pytest.raises(tn_auth.InvalidAuthError): + provider.async_validate_access(ip_address("192.168.128.0")) + with pytest.raises(tn_auth.InvalidAuthError): + provider.async_validate_access(ip_address("192.168.128.1")) + with pytest.raises(tn_auth.InvalidAuthError): + provider.async_validate_access(ip_address("fd00::1")) + + async def test_validate_refresh_token(provider): """Verify re-validation of refresh token.""" with patch.object(provider, "async_validate_access") as mock: diff --git a/tests/components/http/test_forwarded.py b/tests/components/http/test_forwarded.py index 8d8467b699f..400a1f32729 100644 --- a/tests/components/http/test_forwarded.py +++ b/tests/components/http/test_forwarded.py @@ -43,9 +43,15 @@ async def test_x_forwarded_for_without_trusted_proxy(aiohttp_client, caplog): @pytest.mark.parametrize( "trusted_proxies,x_forwarded_for,remote", [ + ( + ["127.0.0.0/24", "1.1.1.1", "10.10.10.0/24"], + "10.10.10.10, 1.1.1.1", + "10.10.10.10", + ), (["127.0.0.0/24", "1.1.1.1"], "123.123.123.123, 2.2.2.2, 1.1.1.1", "2.2.2.2"), (["127.0.0.0/24", "1.1.1.1"], "123.123.123.123,2.2.2.2,1.1.1.1", "2.2.2.2"), (["127.0.0.0/24"], "123.123.123.123, 2.2.2.2, 1.1.1.1", "1.1.1.1"), + (["127.0.0.0/24"], "127.0.0.1", "127.0.0.1"), (["127.0.0.1", "1.1.1.1"], "123.123.123.123, 1.1.1.1", "123.123.123.123"), (["127.0.0.1", "1.1.1.1"], "123.123.123.123, 2.2.2.2, 1.1.1.1", "2.2.2.2"), (["127.0.0.1"], "255.255.255.255", "255.255.255.255"), @@ -77,33 +83,6 @@ async def test_x_forwarded_for_with_trusted_proxy( assert resp.status == 200 -@pytest.mark.parametrize( - "trusted_proxies,x_forwarded_for", - [ - ( - ["127.0.0.0/24", "1.1.1.1", "10.10.10.0/24"], - "10.10.10.10, 1.1.1.1", - ), - (["127.0.0.0/24"], "127.0.0.1"), - ], -) -async def test_x_forwarded_for_from_trusted_proxy_rejected( - trusted_proxies, x_forwarded_for, aiohttp_client -): - """Test that we reject forwarded requests from proxy server itself.""" - - app = web.Application() - app.router.add_get("/", mock_handler) - async_setup_forwarded( - app, True, [ip_network(trusted_proxy) for trusted_proxy in trusted_proxies] - ) - - mock_api_client = await aiohttp_client(app) - resp = await mock_api_client.get("/", headers={X_FORWARDED_FOR: x_forwarded_for}) - - assert resp.status == 400 - - async def test_x_forwarded_for_disabled_with_proxy(aiohttp_client, caplog): """Test that we warn when processing is disabled, but proxy has been detected.""" From eea544d2d23a51bf91e907feb135ae617bb3876f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jul 2021 17:34:59 +0200 Subject: [PATCH 010/134] Fix MQTT cover optimistic mode (#52392) * Fix MQTT cover optimistic mode * Add test --- homeassistant/components/mqtt/cover.py | 52 +++++++++++++++++++------- tests/components/mqtt/test_cover.py | 28 ++++++++++++++ 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index fd3e36c04e1..d920d12662f 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -245,11 +245,45 @@ class MqttCover(MqttEntity, CoverEntity): return PLATFORM_SCHEMA def _setup_from_config(self, config): - self._optimistic = config[CONF_OPTIMISTIC] or ( - config.get(CONF_STATE_TOPIC) is None + no_position = ( + config.get(CONF_SET_POSITION_TOPIC) is None and config.get(CONF_GET_POSITION_TOPIC) is None ) - self._tilt_optimistic = config[CONF_TILT_STATE_OPTIMISTIC] + no_state = ( + config.get(CONF_COMMAND_TOPIC) is None + and config.get(CONF_STATE_TOPIC) is None + ) + no_tilt = ( + config.get(CONF_TILT_COMMAND_TOPIC) is None + and config.get(CONF_TILT_STATUS_TOPIC) is None + ) + optimistic_position = ( + config.get(CONF_SET_POSITION_TOPIC) is not None + and config.get(CONF_GET_POSITION_TOPIC) is None + ) + optimistic_state = ( + config.get(CONF_COMMAND_TOPIC) is not None + and config.get(CONF_STATE_TOPIC) is None + ) + optimistic_tilt = ( + config.get(CONF_TILT_COMMAND_TOPIC) is not None + and config.get(CONF_TILT_STATUS_TOPIC) is None + ) + + if config[CONF_OPTIMISTIC] or ( + (no_position or optimistic_position) + and (no_state or optimistic_state) + and (no_tilt or optimistic_tilt) + ): + # Force into optimistic mode. + self._optimistic = True + + if ( + config[CONF_TILT_STATE_OPTIMISTIC] + or config.get(CONF_TILT_STATUS_TOPIC) is None + ): + # Force into optimistic tilt mode. + self._tilt_optimistic = True value_template = self._config.get(CONF_VALUE_TEMPLATE) if value_template is not None: @@ -418,17 +452,7 @@ class MqttCover(MqttEntity, CoverEntity): "qos": self._config[CONF_QOS], } - if ( - self._config.get(CONF_GET_POSITION_TOPIC) is None - and self._config.get(CONF_STATE_TOPIC) is None - ): - # Force into optimistic mode. - self._optimistic = True - - if self._config.get(CONF_TILT_STATUS_TOPIC) is None: - # Force into optimistic tilt mode. - self._tilt_optimistic = True - else: + if self._config.get(CONF_TILT_STATUS_TOPIC) is not None: self._tilt_value = STATE_UNKNOWN topics["tilt_status_topic"] = { "topic": self._config.get(CONF_TILT_STATUS_TOPIC), diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py index 7d763895428..0ee2557fbd6 100644 --- a/tests/components/mqtt/test_cover.py +++ b/tests/components/mqtt/test_cover.py @@ -393,6 +393,34 @@ async def test_position_via_template_and_entity_id(hass, mqtt_mock): assert current_cover_position == 20 +@pytest.mark.parametrize( + "config, assumed_state", + [ + ({"command_topic": "abc"}, True), + ({"command_topic": "abc", "state_topic": "abc"}, False), + # ({"set_position_topic": "abc"}, True), - not a valid configuration + ({"set_position_topic": "abc", "position_topic": "abc"}, False), + ({"tilt_command_topic": "abc"}, True), + ({"tilt_command_topic": "abc", "tilt_status_topic": "abc"}, False), + ], +) +async def test_optimistic_flag(hass, mqtt_mock, config, assumed_state): + """Test assumed_state is set correctly.""" + assert await async_setup_component( + hass, + cover.DOMAIN, + {cover.DOMAIN: {**config, "platform": "mqtt", "name": "test", "qos": 0}}, + ) + await hass.async_block_till_done() + + state = hass.states.get("cover.test") + assert state.state == STATE_UNKNOWN + if assumed_state: + assert ATTR_ASSUMED_STATE in state.attributes + else: + assert ATTR_ASSUMED_STATE not in state.attributes + + async def test_optimistic_state_change(hass, mqtt_mock): """Test changing state optimistically.""" assert await async_setup_component( From 4959561bde56d836a6f2c00d7974c696f44d26d7 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Jul 2021 14:53:03 +0200 Subject: [PATCH 011/134] Fix sensor statistics collection with empty states (#52393) --- homeassistant/components/sensor/recorder.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index f88b91d1b70..d9200bdf797 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -161,13 +161,15 @@ def _normalize_states( entity_history: list[State], device_class: str, entity_id: str ) -> tuple[str | None, list[tuple[float, State]]]: """Normalize units.""" + unit = None if device_class not in UNIT_CONVERSIONS: # We're not normalizing this device class, return the state as they are fstates = [ (float(el.state), el) for el in entity_history if _is_number(el.state) ] - unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) + if fstates: + unit = fstates[0][1].attributes.get(ATTR_UNIT_OF_MEASUREMENT) return unit, fstates fstates = [] From 61bc95d70469f4ab4a1f883e02a160e4380fab90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Klomp?= Date: Thu, 1 Jul 2021 21:32:46 +0200 Subject: [PATCH 012/134] Bump pysma to 0.6.1 (#52401) --- homeassistant/components/sma/__init__.py | 2 +- homeassistant/components/sma/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sma/__init__.py b/homeassistant/components/sma/__init__.py index 0df3ef8cb7c..2eb0e6760ed 100644 --- a/homeassistant/components/sma/__init__.py +++ b/homeassistant/components/sma/__init__.py @@ -167,7 +167,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: pysma.exceptions.SmaReadException, pysma.exceptions.SmaConnectionException, ) as exc: - raise UpdateFailed from exc + raise UpdateFailed(exc) from exc interval = timedelta( seconds=entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 721431b89a7..85f6de7cb7c 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -3,7 +3,7 @@ "name": "SMA Solar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sma", - "requirements": ["pysma==0.6.0"], + "requirements": ["pysma==0.6.1"], "codeowners": ["@kellerza", "@rklomp"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 2ea8a88b303..08fafae4050 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1756,7 +1756,7 @@ pysignalclirestapi==0.3.4 pyskyqhub==0.1.3 # homeassistant.components.sma -pysma==0.6.0 +pysma==0.6.1 # homeassistant.components.smappee pysmappee==0.2.25 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa044b66df5..c825c050adf 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -998,7 +998,7 @@ pysiaalarm==3.0.0 pysignalclirestapi==0.3.4 # homeassistant.components.sma -pysma==0.6.0 +pysma==0.6.1 # homeassistant.components.smappee pysmappee==0.2.25 From 527af96ad965a2f6869389bcb84243882c13a930 Mon Sep 17 00:00:00 2001 From: Tom Brien Date: Fri, 2 Jul 2021 10:15:05 +0100 Subject: [PATCH 013/134] Add update listener to Coinbase (#52404) Co-authored-by: Franck Nijhof --- homeassistant/components/coinbase/__init__.py | 26 +++++++++++++++++ homeassistant/components/coinbase/sensor.py | 16 +++++++---- tests/components/coinbase/const.py | 4 +-- tests/components/coinbase/test_config_flow.py | 28 ++++++++++++++++--- 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py index eb4370a9534..08b97756dff 100644 --- a/homeassistant/components/coinbase/__init__.py +++ b/homeassistant/components/coinbase/__init__.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import Throttle @@ -70,6 +71,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: create_and_update_instance, entry.data[CONF_API_KEY], entry.data[CONF_API_TOKEN] ) + entry.async_on_unload(entry.add_update_listener(update_listener)) + hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = instance @@ -96,6 +99,29 @@ def create_and_update_instance(api_key, api_token): return instance +async def update_listener(hass, config_entry): + """Handle options update.""" + + await hass.config_entries.async_reload(config_entry.entry_id) + + registry = entity_registry.async_get(hass) + entities = entity_registry.async_entries_for_config_entry( + registry, config_entry.entry_id + ) + + # Remove orphaned entities + for entity in entities: + currency = entity.unique_id.split("-")[-1] + if "xe" in entity.unique_id and currency not in config_entry.options.get( + CONF_EXCHANGE_RATES + ): + registry.async_remove(entity.entity_id) + elif "wallet" in entity.unique_id and currency not in config_entry.options.get( + CONF_CURRENCIES + ): + registry.async_remove(entity.entity_id) + + def get_accounts(client): """Handle paginated accounts.""" response = client.get_accounts() diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py index 5febfe8a978..13981619051 100644 --- a/homeassistant/components/coinbase/sensor.py +++ b/homeassistant/components/coinbase/sensor.py @@ -11,6 +11,7 @@ from .const import ( API_ACCOUNT_ID, API_ACCOUNT_NAME, API_ACCOUNT_NATIVE_BALANCE, + API_RATES, CONF_CURRENCIES, CONF_EXCHANGE_RATES, DOMAIN, @@ -48,7 +49,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): if CONF_CURRENCIES in config_entry.options: desired_currencies = config_entry.options[CONF_CURRENCIES] - exchange_native_currency = instance.exchange_rates.currency + exchange_native_currency = instance.exchange_rates[API_ACCOUNT_CURRENCY] for currency in desired_currencies: if currency not in provided_currencies: @@ -81,9 +82,12 @@ class AccountSensor(SensorEntity): self._coinbase_data = coinbase_data self._currency = currency for account in coinbase_data.accounts: - if account.currency == currency: + if account[API_ACCOUNT_CURRENCY] == currency: self._name = f"Coinbase {account[API_ACCOUNT_NAME]}" - self._id = f"coinbase-{account[API_ACCOUNT_ID]}" + self._id = ( + f"coinbase-{account[API_ACCOUNT_ID]}-wallet-" + f"{account[API_ACCOUNT_CURRENCY]}" + ) self._state = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] self._unit_of_measurement = account[API_ACCOUNT_CURRENCY] self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ @@ -131,7 +135,7 @@ class AccountSensor(SensorEntity): """Get the latest state of the sensor.""" self._coinbase_data.update() for account in self._coinbase_data.accounts: - if account.currency == self._currency: + if account[API_ACCOUNT_CURRENCY] == self._currency: self._state = account[API_ACCOUNT_BALANCE][API_ACCOUNT_AMOUNT] self._native_balance = account[API_ACCOUNT_NATIVE_BALANCE][ API_ACCOUNT_AMOUNT @@ -150,9 +154,9 @@ class ExchangeRateSensor(SensorEntity): self._coinbase_data = coinbase_data self.currency = exchange_currency self._name = f"{exchange_currency} Exchange Rate" - self._id = f"{coinbase_data.user_id}-xe-{exchange_currency}" + self._id = f"coinbase-{coinbase_data.user_id}-xe-{exchange_currency}" self._state = round( - 1 / float(self._coinbase_data.exchange_rates.rates[self.currency]), 2 + 1 / float(self._coinbase_data.exchange_rates[API_RATES][self.currency]), 2 ) self._unit_of_measurement = native_currency diff --git a/tests/components/coinbase/const.py b/tests/components/coinbase/const.py index 52505be514e..864ebc18701 100644 --- a/tests/components/coinbase/const.py +++ b/tests/components/coinbase/const.py @@ -11,14 +11,14 @@ BAD_EXCHANGE_RATE = "ETH" MOCK_ACCOUNTS_RESPONSE = [ { "balance": {"amount": "13.38", "currency": GOOD_CURRENCY_3}, - "currency": "BTC", + "currency": GOOD_CURRENCY_3, "id": "ABCDEF", "name": "BTC Wallet", "native_balance": {"amount": "15.02", "currency": GOOD_CURRENCY_2}, }, { "balance": {"amount": "0.00001", "currency": GOOD_CURRENCY}, - "currency": "BTC", + "currency": GOOD_CURRENCY, "id": "123456789", "name": "BTC Wallet", "native_balance": {"amount": "100.12", "currency": GOOD_CURRENCY_2}, diff --git a/tests/components/coinbase/test_config_flow.py b/tests/components/coinbase/test_config_flow.py index dc036d23a6f..4c7b6c13333 100644 --- a/tests/components/coinbase/test_config_flow.py +++ b/tests/components/coinbase/test_config_flow.py @@ -19,7 +19,14 @@ from .common import ( mock_get_exchange_rates, mocked_get_accounts, ) -from .const import BAD_CURRENCY, BAD_EXCHANGE_RATE, GOOD_CURRENCY, GOOD_EXCHNAGE_RATE +from .const import ( + BAD_CURRENCY, + BAD_EXCHANGE_RATE, + GOOD_CURRENCY, + GOOD_CURRENCY_2, + GOOD_EXCHNAGE_RATE, + GOOD_EXCHNAGE_RATE_2, +) from tests.common import MockConfigEntry @@ -139,6 +146,18 @@ async def test_form_catch_all_exception(hass): async def test_option_good_account_currency(hass): """Test we handle a good wallet currency option.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="abcde12345", + title="Test User", + data={CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"}, + options={ + CONF_CURRENCIES: [GOOD_CURRENCY_2], + CONF_EXCHANGE_RATES: [], + }, + ) + config_entry.add_to_hass(hass) + with patch( "coinbase.wallet.client.Client.get_current_user", return_value=mock_get_current_user(), @@ -148,7 +167,8 @@ async def test_option_good_account_currency(hass): "coinbase.wallet.client.Client.get_exchange_rates", return_value=mock_get_exchange_rates(), ): - config_entry = await init_mock_coinbase(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() result = await hass.config_entries.options.async_init(config_entry.entry_id) await hass.async_block_till_done() result2 = await hass.config_entries.options.async_configure( @@ -191,12 +211,12 @@ async def test_option_good_exchange_rate(hass): """Test we handle a good exchange rate option.""" config_entry = MockConfigEntry( domain=DOMAIN, - unique_id="abcde12345", + entry_id="abcde12345", title="Test User", data={CONF_API_KEY: "123456", CONF_API_TOKEN: "AbCDeF"}, options={ CONF_CURRENCIES: [], - CONF_EXCHANGE_RATES: [], + CONF_EXCHANGE_RATES: [GOOD_EXCHNAGE_RATE_2], }, ) config_entry.add_to_hass(hass) From e8ed4979500a28a0a4d5459be2d813e42f1f0ddc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 2 Jul 2021 00:30:33 +0200 Subject: [PATCH 014/134] Upgrade wled to 0.7.1 (#52405) --- homeassistant/components/wled/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wled/manifest.json b/homeassistant/components/wled/manifest.json index 237f6850b66..348109f6b87 100644 --- a/homeassistant/components/wled/manifest.json +++ b/homeassistant/components/wled/manifest.json @@ -3,7 +3,7 @@ "name": "WLED", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wled", - "requirements": ["wled==0.7.0"], + "requirements": ["wled==0.7.1"], "zeroconf": ["_wled._tcp.local."], "codeowners": ["@frenck"], "quality_scale": "platinum", diff --git a/requirements_all.txt b/requirements_all.txt index 08fafae4050..87486dceef3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2378,7 +2378,7 @@ wirelesstagpy==0.4.1 withings-api==2.3.2 # homeassistant.components.wled -wled==0.7.0 +wled==0.7.1 # homeassistant.components.wolflink wolf_smartset==0.1.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c825c050adf..f79b8764e58 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1302,7 +1302,7 @@ wiffi==1.0.1 withings-api==2.3.2 # homeassistant.components.wled -wled==0.7.0 +wled==0.7.1 # homeassistant.components.wolflink wolf_smartset==0.1.11 From e2e72851d79db535c2392347bc2133286d8414ab Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Fri, 2 Jul 2021 01:36:48 -0400 Subject: [PATCH 015/134] Bump eight sleep dependency to fix bug (#52408) --- .../components/eight_sleep/__init__.py | 27 ++++++++----------- .../components/eight_sleep/binary_sensor.py | 14 +++++----- .../components/eight_sleep/manifest.json | 2 +- requirements_all.txt | 2 +- 4 files changed, 20 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index 67c195da3e6..4e16cd1087f 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__init__.py @@ -11,10 +11,10 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_SENSORS, CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, ) from homeassistant.core import callback from homeassistant.helpers import discovery +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, @@ -29,7 +29,6 @@ _LOGGER = logging.getLogger(__name__) CONF_PARTNER = "partner" DATA_EIGHT = "eight_sleep" -DEFAULT_PARTNER = False DOMAIN = "eight_sleep" HEAT_ENTITY = "heat" @@ -86,12 +85,15 @@ SERVICE_EIGHT_SCHEMA = vol.Schema( CONFIG_SCHEMA = vol.Schema( { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PARTNER, default=DEFAULT_PARTNER): cv.boolean, - } + DOMAIN: vol.All( + cv.deprecated(CONF_PARTNER), + vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PARTNER): cv.boolean, + } + ), ) }, extra=vol.ALLOW_EXTRA, @@ -104,7 +106,6 @@ async def async_setup(hass, config): conf = config.get(DOMAIN) user = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) - partner = conf.get(CONF_PARTNER) if hass.config.time_zone is None: _LOGGER.error("Timezone is not set in Home Assistant") @@ -112,7 +113,7 @@ async def async_setup(hass, config): timezone = str(hass.config.time_zone) - eight = EightSleep(user, password, timezone, partner, None, hass.loop) + eight = EightSleep(user, password, timezone, async_get_clientsession(hass)) hass.data[DATA_EIGHT] = eight @@ -190,12 +191,6 @@ async def async_setup(hass, config): DOMAIN, SERVICE_HEAT_SET, async_service_handler, schema=SERVICE_EIGHT_SCHEMA ) - async def stop_eight(event): - """Handle stopping eight api session.""" - await eight.stop() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_eight) - return True diff --git a/homeassistant/components/eight_sleep/binary_sensor.py b/homeassistant/components/eight_sleep/binary_sensor.py index 803b20383b6..d8a763c2e54 100644 --- a/homeassistant/components/eight_sleep/binary_sensor.py +++ b/homeassistant/components/eight_sleep/binary_sensor.py @@ -1,7 +1,10 @@ """Support for Eight Sleep binary sensors.""" import logging -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_OCCUPANCY, + BinarySensorEntity, +) from . import CONF_BINARY_SENSORS, DATA_EIGHT, NAME_MAP, EightSleepHeatEntity @@ -34,13 +37,15 @@ class EightHeatSensor(EightSleepHeatEntity, BinarySensorEntity): self._sensor = sensor self._mapped_name = NAME_MAP.get(self._sensor, self._sensor) - self._name = f"{name} {self._mapped_name}" self._state = None self._side = self._sensor.split("_")[0] self._userid = self._eight.fetch_userid(self._side) self._usrobj = self._eight.users[self._userid] + self._attr_name = f"{name} {self._mapped_name}" + self._attr_device_class = DEVICE_CLASS_OCCUPANCY + _LOGGER.debug( "Presence Sensor: %s, Side: %s, User: %s", self._sensor, @@ -48,11 +53,6 @@ class EightHeatSensor(EightSleepHeatEntity, BinarySensorEntity): self._userid, ) - @property - def name(self): - """Return the name of the sensor, if any.""" - return self._name - @property def is_on(self): """Return true if the binary sensor is on.""" diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json index d0f86d5a5e4..fb3762cf738 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.5"], + "requirements": ["pyeight==0.1.8"], "codeowners": ["@mezz64"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 87486dceef3..05cea4ea541 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1409,7 +1409,7 @@ pyeconet==0.1.14 pyedimax==0.2.1 # homeassistant.components.eight_sleep -pyeight==0.1.5 +pyeight==0.1.8 # homeassistant.components.emby pyemby==1.7 From e4a7347e7d59f65253641a7c635d16ab417e4f50 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 2 Jul 2021 10:24:43 -0500 Subject: [PATCH 016/134] Import track_new_devices and scan_interval from yaml for nmap_tracker (#52409) * Import track_new_devices and scan_interval from yaml for nmap_tracker * Import track_new_devices and scan_interval from yaml for nmap_tracker * Import track_new_devices and scan_interval from yaml for nmap_tracker * tests * translate * tweak * adjust * save indent * pylint * There are two CONF_SCAN_INTERVAL constants * adjust name -- there are TWO CONF_SCAN_INTERVAL constants * remove CONF_SCAN_INTERVAL/CONF_TRACK_NEW from user flow * assert it does not appear in the user step --- .../components/nmap_tracker/__init__.py | 65 ++++++++++++------- .../components/nmap_tracker/config_flow.py | 55 +++++++++++----- .../components/nmap_tracker/const.py | 2 + .../components/nmap_tracker/device_tracker.py | 37 +++++++++-- .../components/nmap_tracker/strings.json | 6 +- .../nmap_tracker/translations/en.json | 4 +- .../nmap_tracker/test_config_flow.py | 25 +++++++ 7 files changed, 146 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 381813a3b49..399121e4e00 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -12,6 +12,10 @@ from getmac import get_mac_address from mac_vendor_lookup import AsyncMacLookup from nmap import PortScanner, PortScannerError +from homeassistant.components.device_tracker.const import ( + CONF_SCAN_INTERVAL, + CONF_TRACK_NEW, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED from homeassistant.core import CoreState, HomeAssistant, callback @@ -25,6 +29,7 @@ import homeassistant.util.dt as dt_util from .const import ( CONF_HOME_INTERVAL, CONF_OPTIONS, + DEFAULT_TRACK_NEW_DEVICES, DOMAIN, NMAP_TRACKED_DEVICES, PLATFORMS, @@ -146,7 +151,10 @@ class NmapDeviceScanner: self._hosts = None self._options = None self._exclude = None + self._scan_interval = None + self._track_new_devices = None + self._known_mac_addresses = {} self._finished_first_scan = False self._last_results = [] self._mac_vendor_lookup = None @@ -154,6 +162,10 @@ class NmapDeviceScanner: async def async_setup(self): """Set up the tracker.""" config = self._entry.options + self._track_new_devices = config.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW_DEVICES) + self._scan_interval = timedelta( + seconds=config.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL) + ) self._hosts = cv.ensure_list_csv(config[CONF_HOSTS]) self._exclude = cv.ensure_list_csv(config[CONF_EXCLUDE]) self._options = config[CONF_OPTIONS] @@ -170,6 +182,12 @@ class NmapDeviceScanner: EVENT_HOMEASSISTANT_STARTED, self._async_start_scanner ) ) + registry = er.async_get(self._hass) + self._known_mac_addresses = { + entry.unique_id: entry.original_name + for entry in registry.entities.values() + if entry.config_entry_id == self._entry_id + } @property def signal_device_new(self) -> str: @@ -199,7 +217,7 @@ class NmapDeviceScanner: async_track_time_interval( self._hass, self._async_scan_devices, - timedelta(seconds=TRACKER_SCAN_INTERVAL), + self._scan_interval, ) ) self._mac_vendor_lookup = AsyncMacLookup() @@ -258,26 +276,22 @@ class NmapDeviceScanner: # After all config entries have finished their first # scan we mark devices that were not found as not_home # from unavailable - registry = er.async_get(self._hass) now = dt_util.now() - for entry in registry.entities.values(): - if entry.config_entry_id != self._entry_id: + for mac_address, original_name in self._known_mac_addresses.items(): + if mac_address in self.devices.tracked: continue - if entry.unique_id not in self.devices.tracked: - self.devices.config_entry_owner[entry.unique_id] = self._entry_id - self.devices.tracked[entry.unique_id] = NmapDevice( - entry.unique_id, - None, - entry.original_name, - None, - self._async_get_vendor(entry.unique_id), - "Device not found in initial scan", - now, - 1, - ) - async_dispatcher_send( - self._hass, self.signal_device_missing, entry.unique_id - ) + self.devices.config_entry_owner[mac_address] = self._entry_id + self.devices.tracked[mac_address] = NmapDevice( + mac_address, + None, + original_name, + None, + self._async_get_vendor(mac_address), + "Device not found in initial scan", + now, + 1, + ) + async_dispatcher_send(self._hass, self.signal_device_missing, mac_address) def _run_nmap_scan(self): """Run nmap and return the result.""" @@ -344,21 +358,28 @@ class NmapDeviceScanner: _LOGGER.info("No MAC address found for %s", ipv4) continue - hostname = info["hostnames"][0]["name"] if info["hostnames"] else ipv4 - formatted_mac = format_mac(mac) + new = formatted_mac not in devices.tracked + if ( + new + and not self._track_new_devices + and formatted_mac not in devices.tracked + and formatted_mac not in self._known_mac_addresses + ): + continue + if ( devices.config_entry_owner.setdefault(formatted_mac, entry_id) != entry_id ): continue + hostname = info["hostnames"][0]["name"] if info["hostnames"] else ipv4 vendor = info.get("vendor", {}).get(mac) or self._async_get_vendor(mac) name = human_readable_name(hostname, vendor, mac) device = NmapDevice( formatted_mac, hostname, name, ipv4, vendor, reason, now, 0 ) - new = formatted_mac not in devices.tracked devices.tracked[formatted_mac] = device devices.ipv4_last_mac[ipv4] = formatted_mac diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py index 942689ad575..68e61745b63 100644 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ b/homeassistant/components/nmap_tracker/config_flow.py @@ -8,13 +8,24 @@ import ifaddr import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.device_tracker.const import ( + CONF_SCAN_INTERVAL, + CONF_TRACK_NEW, +) from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.util import get_local_ip -from .const import CONF_HOME_INTERVAL, CONF_OPTIONS, DEFAULT_OPTIONS, DOMAIN +from .const import ( + CONF_HOME_INTERVAL, + CONF_OPTIONS, + DEFAULT_OPTIONS, + DEFAULT_TRACK_NEW_DEVICES, + DOMAIN, + TRACKER_SCAN_INTERVAL, +) DEFAULT_NETWORK_PREFIX = 24 @@ -92,23 +103,35 @@ def normalize_input(user_input): return errors -async def _async_build_schema_with_user_input(hass, user_input): +async def _async_build_schema_with_user_input(hass, user_input, include_options): hosts = user_input.get(CONF_HOSTS, await hass.async_add_executor_job(get_network)) exclude = user_input.get( CONF_EXCLUDE, await hass.async_add_executor_job(get_local_ip) ) - return vol.Schema( - { - vol.Required(CONF_HOSTS, default=hosts): str, - vol.Required( - CONF_HOME_INTERVAL, default=user_input.get(CONF_HOME_INTERVAL, 0) - ): int, - vol.Optional(CONF_EXCLUDE, default=exclude): str, - vol.Optional( - CONF_OPTIONS, default=user_input.get(CONF_OPTIONS, DEFAULT_OPTIONS) - ): str, - } - ) + schema = { + vol.Required(CONF_HOSTS, default=hosts): str, + vol.Required( + CONF_HOME_INTERVAL, default=user_input.get(CONF_HOME_INTERVAL, 0) + ): int, + vol.Optional(CONF_EXCLUDE, default=exclude): str, + vol.Optional( + CONF_OPTIONS, default=user_input.get(CONF_OPTIONS, DEFAULT_OPTIONS) + ): str, + } + if include_options: + schema.update( + { + vol.Optional( + CONF_TRACK_NEW, + default=user_input.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW_DEVICES), + ): bool, + vol.Optional( + CONF_SCAN_INTERVAL, + default=user_input.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL), + ): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)), + } + ) + return vol.Schema(schema) class OptionsFlowHandler(config_entries.OptionsFlow): @@ -133,7 +156,7 @@ class OptionsFlowHandler(config_entries.OptionsFlow): return self.async_show_form( step_id="init", data_schema=await _async_build_schema_with_user_input( - self.hass, self.options + self.hass, self.options, True ), errors=errors, ) @@ -170,7 +193,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form( step_id="user", data_schema=await _async_build_schema_with_user_input( - self.hass, self.options + self.hass, self.options, False ), errors=errors, ) diff --git a/homeassistant/components/nmap_tracker/const.py b/homeassistant/components/nmap_tracker/const.py index e71c2d58bbb..88118a81811 100644 --- a/homeassistant/components/nmap_tracker/const.py +++ b/homeassistant/components/nmap_tracker/const.py @@ -12,3 +12,5 @@ CONF_OPTIONS = "scan_options" DEFAULT_OPTIONS = "-F --host-timeout 5s" TRACKER_SCAN_INTERVAL = 120 + +DEFAULT_TRACK_NEW_DEVICES = True diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index 24e0d3d8e26..350e75adf48 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -11,6 +11,11 @@ from homeassistant.components.device_tracker import ( SOURCE_TYPE_ROUTER, ) from homeassistant.components.device_tracker.config_entry import ScannerEntity +from homeassistant.components.device_tracker.const import ( + CONF_NEW_DEVICE_DEFAULTS, + CONF_SCAN_INTERVAL, + CONF_TRACK_NEW, +) from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS from homeassistant.core import HomeAssistant, callback @@ -19,7 +24,14 @@ from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect from . import NmapDeviceScanner, short_hostname, signal_device_update -from .const import CONF_HOME_INTERVAL, CONF_OPTIONS, DEFAULT_OPTIONS, DOMAIN +from .const import ( + CONF_HOME_INTERVAL, + CONF_OPTIONS, + DEFAULT_OPTIONS, + DEFAULT_TRACK_NEW_DEVICES, + DOMAIN, + TRACKER_SCAN_INTERVAL, +) _LOGGER = logging.getLogger(__name__) @@ -37,16 +49,27 @@ async def async_get_scanner(hass, config): """Validate the configuration and return a Nmap scanner.""" validated_config = config[DEVICE_TRACKER_DOMAIN] + if CONF_SCAN_INTERVAL in validated_config: + scan_interval = validated_config[CONF_SCAN_INTERVAL].total_seconds() + else: + scan_interval = TRACKER_SCAN_INTERVAL + + import_config = { + CONF_HOSTS: ",".join(validated_config[CONF_HOSTS]), + CONF_HOME_INTERVAL: validated_config[CONF_HOME_INTERVAL], + CONF_EXCLUDE: ",".join(validated_config[CONF_EXCLUDE]), + CONF_OPTIONS: validated_config[CONF_OPTIONS], + CONF_SCAN_INTERVAL: scan_interval, + CONF_TRACK_NEW: validated_config.get(CONF_NEW_DEVICE_DEFAULTS, {}).get( + CONF_TRACK_NEW, DEFAULT_TRACK_NEW_DEVICES + ), + } + hass.async_create_task( hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, - data={ - CONF_HOSTS: ",".join(validated_config[CONF_HOSTS]), - CONF_HOME_INTERVAL: validated_config[CONF_HOME_INTERVAL], - CONF_EXCLUDE: ",".join(validated_config[CONF_EXCLUDE]), - CONF_OPTIONS: validated_config[CONF_OPTIONS], - }, + data=import_config, ) ) diff --git a/homeassistant/components/nmap_tracker/strings.json b/homeassistant/components/nmap_tracker/strings.json index a1e04b681cd..ecb470a6f0d 100644 --- a/homeassistant/components/nmap_tracker/strings.json +++ b/homeassistant/components/nmap_tracker/strings.json @@ -8,8 +8,10 @@ "hosts": "[%key:component::nmap_tracker::config::step::user::data::hosts%]", "home_interval": "[%key:component::nmap_tracker::config::step::user::data::home_interval%]", "exclude": "[%key:component::nmap_tracker::config::step::user::data::exclude%]", - "scan_options": "[%key:component::nmap_tracker::config::step::user::data::scan_options%]" - } + "scan_options": "[%key:component::nmap_tracker::config::step::user::data::scan_options%]", + "track_new_devices": "Track new devices", + "interval_seconds": "Scan interval" + } } }, "error": { diff --git a/homeassistant/components/nmap_tracker/translations/en.json b/homeassistant/components/nmap_tracker/translations/en.json index ed37a6a5410..6b83532a0e2 100644 --- a/homeassistant/components/nmap_tracker/translations/en.json +++ b/homeassistant/components/nmap_tracker/translations/en.json @@ -28,7 +28,9 @@ "exclude": "Network addresses (comma seperated) to exclude from scanning", "home_interval": "Minimum number of minutes between scans of active devices (preserve battery)", "hosts": "Network addresses (comma seperated) to scan", - "scan_options": "Raw configurable scan options for Nmap" + "interval_seconds": "Scan interval", + "scan_options": "Raw configurable scan options for Nmap", + "track_new_devices": "Track new devices" }, "description": "Configure hosts to be scanned by Nmap. Network address and excludes can be IP Addresses (192.168.1.1), IP Networks (192.168.0.0/24) or IP Ranges (192.168.1.0-32)." } diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py index 1556dee58d9..c4e82936b88 100644 --- a/tests/components/nmap_tracker/test_config_flow.py +++ b/tests/components/nmap_tracker/test_config_flow.py @@ -4,6 +4,10 @@ from unittest.mock import patch import pytest from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.device_tracker.const import ( + CONF_SCAN_INTERVAL, + CONF_TRACK_NEW, +) from homeassistant.components.nmap_tracker.const import ( CONF_HOME_INTERVAL, CONF_OPTIONS, @@ -28,6 +32,10 @@ async def test_form(hass: HomeAssistant, hosts: str) -> None: assert result["type"] == "form" assert result["errors"] == {} + schema_defaults = result["data_schema"]({}) + assert CONF_TRACK_NEW not in schema_defaults + assert CONF_SCAN_INTERVAL not in schema_defaults + with patch( "homeassistant.components.nmap_tracker.async_setup_entry", return_value=True, @@ -198,6 +206,15 @@ async def test_options_flow(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.RESULT_TYPE_FORM assert result["step_id"] == "init" + assert result["data_schema"]({}) == { + CONF_EXCLUDE: "4.4.4.4", + CONF_HOME_INTERVAL: 3, + CONF_HOSTS: "192.168.1.0/24", + CONF_SCAN_INTERVAL: 120, + CONF_OPTIONS: "-F --host-timeout 5s", + CONF_TRACK_NEW: True, + } + with patch( "homeassistant.components.nmap_tracker.async_setup_entry", return_value=True, @@ -209,6 +226,8 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_HOME_INTERVAL: 5, CONF_OPTIONS: "-sn", CONF_EXCLUDE: "4.4.4.4, 5.5.5.5", + CONF_SCAN_INTERVAL: 10, + CONF_TRACK_NEW: False, }, ) await hass.async_block_till_done() @@ -219,6 +238,8 @@ async def test_options_flow(hass: HomeAssistant) -> None: CONF_HOME_INTERVAL: 5, CONF_OPTIONS: "-sn", CONF_EXCLUDE: "4.4.4.4,5.5.5.5", + CONF_SCAN_INTERVAL: 10, + CONF_TRACK_NEW: False, } assert len(mock_setup_entry.mock_calls) == 1 @@ -238,6 +259,8 @@ async def test_import(hass: HomeAssistant) -> None: CONF_HOME_INTERVAL: 3, CONF_OPTIONS: DEFAULT_OPTIONS, CONF_EXCLUDE: "4.4.4.4, 6.4.3.2", + CONF_SCAN_INTERVAL: 2000, + CONF_TRACK_NEW: False, }, ) await hass.async_block_till_done() @@ -250,6 +273,8 @@ async def test_import(hass: HomeAssistant) -> None: CONF_HOME_INTERVAL: 3, CONF_OPTIONS: DEFAULT_OPTIONS, CONF_EXCLUDE: "4.4.4.4,6.4.3.2", + CONF_SCAN_INTERVAL: 2000, + CONF_TRACK_NEW: False, } assert len(mock_setup_entry.mock_calls) == 1 From 94638d316f571e220dcfe0e67c7be744dd710d78 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 2 Jul 2021 13:17:00 +0200 Subject: [PATCH 017/134] Drop statistic_id and source columns from statistics table (#52417) * Drop statistic_id and source columns from statistics table * Remove useless double drop of statistics table * Update homeassistant/components/recorder/models.py Co-authored-by: Franck Nijhof * black Co-authored-by: Franck Nijhof --- .../components/recorder/migration.py | 16 ++- homeassistant/components/recorder/models.py | 25 ++-- .../components/recorder/statistics.py | 133 ++++++++++-------- homeassistant/components/sensor/recorder.py | 6 +- 4 files changed, 107 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index b219209b386..30a5162e947 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -11,7 +11,14 @@ from sqlalchemy.exc import ( ) from sqlalchemy.schema import AddConstraint, DropConstraint -from .models import SCHEMA_VERSION, TABLE_STATES, Base, SchemaChanges, Statistics +from .models import ( + SCHEMA_VERSION, + TABLE_STATES, + Base, + SchemaChanges, + Statistics, + StatisticsMeta, +) from .util import session_scope _LOGGER = logging.getLogger(__name__) @@ -453,10 +460,15 @@ def _apply_update(engine, session, new_version, old_version): connection, engine, TABLE_STATES, ["old_state_id"] ) elif new_version == 17: + # This dropped the statistics table, done again in version 18. + pass + elif new_version == 18: if sqlalchemy.inspect(engine).has_table(Statistics.__tablename__): - # Recreate the statistics table + # Recreate the statistics and statisticsmeta tables Statistics.__table__.drop(engine) Statistics.__table__.create(engine) + StatisticsMeta.__table__.drop(engine) + StatisticsMeta.__table__.create(engine) else: raise ValueError(f"No schema migration defined for version {new_version}") diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 5a96cbf4f0b..50052c1f722 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -36,7 +36,7 @@ import homeassistant.util.dt as dt_util # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 17 +SCHEMA_VERSION = 18 _LOGGER = logging.getLogger(__name__) @@ -224,8 +224,11 @@ class Statistics(Base): # type: ignore __tablename__ = TABLE_STATISTICS id = Column(Integer, primary_key=True) created = Column(DATETIME_TYPE, default=dt_util.utcnow) - source = Column(String(32)) - statistic_id = Column(String(255)) + metadata_id = Column( + Integer, + ForeignKey(f"{TABLE_STATISTICS_META}.id", ondelete="CASCADE"), + index=True, + ) start = Column(DATETIME_TYPE, index=True) mean = Column(Float()) min = Column(Float()) @@ -236,15 +239,14 @@ class Statistics(Base): # type: ignore __table_args__ = ( # Used for fetching statistics for a certain entity at a specific time - Index("ix_statistics_statistic_id_start", "statistic_id", "start"), + Index("ix_statistics_statistic_id_start", "metadata_id", "start"), ) @staticmethod - def from_stats(source, statistic_id, start, stats): + def from_stats(metadata_id, start, stats): """Create object from a statistics.""" return Statistics( - source=source, - statistic_id=statistic_id, + metadata_id=metadata_id, start=start, **stats, ) @@ -258,17 +260,22 @@ class StatisticsMeta(Base): # type: ignore "mysql_collate": "utf8mb4_unicode_ci", } __tablename__ = TABLE_STATISTICS_META - statistic_id = Column(String(255), primary_key=True) + id = Column(Integer, primary_key=True) + statistic_id = Column(String(255), index=True) source = Column(String(32)) unit_of_measurement = Column(String(255)) + has_mean = Column(Boolean) + has_sum = Column(Boolean) @staticmethod - def from_meta(source, statistic_id, unit_of_measurement): + def from_meta(source, statistic_id, unit_of_measurement, has_mean, has_sum): """Create object from meta data.""" return StatisticsMeta( source=source, statistic_id=statistic_id, unit_of_measurement=unit_of_measurement, + has_mean=has_mean, + has_sum=has_sum, ) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 0e01005c13a..e1dd0fb986a 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -23,7 +23,7 @@ if TYPE_CHECKING: from . import Recorder QUERY_STATISTICS = [ - Statistics.statistic_id, + Statistics.metadata_id, Statistics.start, Statistics.mean, Statistics.min, @@ -33,11 +33,8 @@ QUERY_STATISTICS = [ Statistics.sum, ] -QUERY_STATISTIC_IDS = [ - Statistics.statistic_id, -] - QUERY_STATISTIC_META = [ + StatisticsMeta.id, StatisticsMeta.statistic_id, StatisticsMeta.unit_of_measurement, ] @@ -76,16 +73,39 @@ def get_start_time() -> datetime.datetime: return start +def _get_metadata_ids(hass, session, statistic_ids): + """Resolve metadata_id for a list of statistic_ids.""" + baked_query = hass.data[STATISTICS_META_BAKERY]( + lambda session: session.query(*QUERY_STATISTIC_META) + ) + baked_query += lambda q: q.filter( + StatisticsMeta.statistic_id.in_(bindparam("statistic_ids")) + ) + result = execute(baked_query(session).params(statistic_ids=statistic_ids)) + + return [id for id, _, _ in result] + + +def _get_or_add_metadata_id(hass, session, statistic_id, metadata): + """Get metadata_id for a statistic_id, add if it doesn't exist.""" + metadata_id = _get_metadata_ids(hass, session, [statistic_id]) + if not metadata_id: + unit = metadata["unit_of_measurement"] + has_mean = metadata["has_mean"] + has_sum = metadata["has_sum"] + session.add( + StatisticsMeta.from_meta(DOMAIN, statistic_id, unit, has_mean, has_sum) + ) + metadata_id = _get_metadata_ids(hass, session, [statistic_id]) + return metadata_id[0] + + @retryable_database_job("statistics") def compile_statistics(instance: Recorder, start: datetime.datetime) -> bool: """Compile statistics.""" start = dt_util.as_utc(start) end = start + timedelta(hours=1) - _LOGGER.debug( - "Compiling statistics for %s-%s", - start, - end, - ) + _LOGGER.debug("Compiling statistics for %s-%s", start, end) platform_stats = [] for domain, platform in instance.hass.data[DOMAIN].items(): if not hasattr(platform, "compile_statistics"): @@ -98,29 +118,22 @@ def compile_statistics(instance: Recorder, start: datetime.datetime) -> bool: with session_scope(session=instance.get_session()) as session: # type: ignore for stats in platform_stats: for entity_id, stat in stats.items(): - session.add( - Statistics.from_stats(DOMAIN, entity_id, start, stat["stat"]) + metadata_id = _get_or_add_metadata_id( + instance.hass, session, entity_id, stat["meta"] ) - exists = session.query( - session.query(StatisticsMeta) - .filter_by(statistic_id=entity_id) - .exists() - ).scalar() - if not exists: - unit = stat["meta"]["unit_of_measurement"] - session.add(StatisticsMeta.from_meta(DOMAIN, entity_id, unit)) + session.add(Statistics.from_stats(metadata_id, start, stat["stat"])) return True -def _get_meta_data(hass, session, statistic_ids): +def _get_meta_data(hass, session, statistic_ids, statistic_type): """Fetch meta data.""" - def _meta(metas, wanted_statistic_id): - meta = {"statistic_id": wanted_statistic_id, "unit_of_measurement": None} - for statistic_id, unit in metas: - if statistic_id == wanted_statistic_id: - meta["unit_of_measurement"] = unit + def _meta(metas, wanted_metadata_id): + meta = None + for metadata_id, statistic_id, unit in metas: + if metadata_id == wanted_metadata_id: + meta = {"unit_of_measurement": unit, "statistic_id": statistic_id} return meta baked_query = hass.data[STATISTICS_META_BAKERY]( @@ -130,13 +143,14 @@ def _get_meta_data(hass, session, statistic_ids): baked_query += lambda q: q.filter( StatisticsMeta.statistic_id.in_(bindparam("statistic_ids")) ) - + if statistic_type == "mean": + baked_query += lambda q: q.filter(StatisticsMeta.has_mean.isnot(False)) + if statistic_type == "sum": + baked_query += lambda q: q.filter(StatisticsMeta.has_sum.isnot(False)) result = execute(baked_query(session).params(statistic_ids=statistic_ids)) - if statistic_ids is None: - statistic_ids = [statistic_id[0] for statistic_id in result] - - return {id: _meta(result, id) for id in statistic_ids} + metadata_ids = [metadata[0] for metadata in result] + return {id: _meta(result, id) for id in metadata_ids} def _configured_unit(unit: str, units) -> str: @@ -152,24 +166,11 @@ def list_statistic_ids(hass, statistic_type=None): """Return statistic_ids and meta data.""" units = hass.config.units with session_scope(hass=hass) as session: - baked_query = hass.data[STATISTICS_BAKERY]( - lambda session: session.query(*QUERY_STATISTIC_IDS).distinct() - ) + meta_data = _get_meta_data(hass, session, None, statistic_type) - if statistic_type == "mean": - baked_query += lambda q: q.filter(Statistics.mean.isnot(None)) - if statistic_type == "sum": - baked_query += lambda q: q.filter(Statistics.sum.isnot(None)) - - baked_query += lambda q: q.order_by(Statistics.statistic_id) - - result = execute(baked_query(session)) - - statistic_ids = [statistic_id[0] for statistic_id in result] - meta_data = _get_meta_data(hass, session, statistic_ids) - for item in meta_data.values(): - unit = _configured_unit(item["unit_of_measurement"], units) - item["unit_of_measurement"] = unit + for meta in meta_data.values(): + unit = _configured_unit(meta["unit_of_measurement"], units) + meta["unit_of_measurement"] = unit return list(meta_data.values()) @@ -186,20 +187,24 @@ def statistics_during_period(hass, start_time, end_time=None, statistic_ids=None if end_time is not None: baked_query += lambda q: q.filter(Statistics.start < bindparam("end_time")) + metadata_ids = None if statistic_ids is not None: baked_query += lambda q: q.filter( - Statistics.statistic_id.in_(bindparam("statistic_ids")) + Statistics.metadata_id.in_(bindparam("metadata_ids")) ) statistic_ids = [statistic_id.lower() for statistic_id in statistic_ids] + metadata_ids = _get_metadata_ids(hass, session, statistic_ids) + if not metadata_ids: + return {} - baked_query += lambda q: q.order_by(Statistics.statistic_id, Statistics.start) + baked_query += lambda q: q.order_by(Statistics.metadata_id, Statistics.start) stats = execute( baked_query(session).params( - start_time=start_time, end_time=end_time, statistic_ids=statistic_ids + start_time=start_time, end_time=end_time, metadata_ids=metadata_ids ) ) - meta_data = _get_meta_data(hass, session, statistic_ids) + meta_data = _get_meta_data(hass, session, statistic_ids, None) return _sorted_statistics_to_dict(hass, stats, statistic_ids, meta_data) @@ -210,23 +215,28 @@ def get_last_statistics(hass, number_of_stats, statistic_id=None): lambda session: session.query(*QUERY_STATISTICS) ) + metadata_id = None if statistic_id is not None: - baked_query += lambda q: q.filter_by(statistic_id=bindparam("statistic_id")) + baked_query += lambda q: q.filter_by(metadata_id=bindparam("metadata_id")) + metadata_ids = _get_metadata_ids(hass, session, [statistic_id]) + if not metadata_ids: + return {} + metadata_id = metadata_ids[0] baked_query += lambda q: q.order_by( - Statistics.statistic_id, Statistics.start.desc() + Statistics.metadata_id, Statistics.start.desc() ) baked_query += lambda q: q.limit(bindparam("number_of_stats")) stats = execute( baked_query(session).params( - number_of_stats=number_of_stats, statistic_id=statistic_id + number_of_stats=number_of_stats, metadata_id=metadata_id ) ) statistic_ids = [statistic_id] if statistic_id is not None else None - meta_data = _get_meta_data(hass, session, statistic_ids) + meta_data = _get_meta_data(hass, session, statistic_ids, None) return _sorted_statistics_to_dict(hass, stats, statistic_ids, meta_data) @@ -249,13 +259,14 @@ def _sorted_statistics_to_dict( _process_timestamp_to_utc_isoformat = process_timestamp_to_utc_isoformat # Append all statistic entries, and do unit conversion - for ent_id, group in groupby(stats, lambda state: state.statistic_id): - unit = meta_data[ent_id]["unit_of_measurement"] + for meta_id, group in groupby(stats, lambda state: state.metadata_id): + unit = meta_data[meta_id]["unit_of_measurement"] + statistic_id = meta_data[meta_id]["statistic_id"] convert = UNIT_CONVERSIONS.get(unit, lambda x, units: x) - ent_results = result[ent_id] + ent_results = result[meta_id] ent_results.extend( { - "statistic_id": db_state.statistic_id, + "statistic_id": statistic_id, "start": _process_timestamp_to_utc_isoformat(db_state.start), "mean": convert(db_state.mean, units), "min": convert(db_state.min, units), @@ -268,4 +279,4 @@ def _sorted_statistics_to_dict( ) # Filter out the empty lists if some states had 0 results. - return {key: val for key, val in result.items() if val} + return {meta_data[key]["statistic_id"]: val for key, val in result.items() if val} diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index d9200bdf797..bbd49814076 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -224,7 +224,11 @@ def compile_statistics( result[entity_id] = {} # Set meta data - result[entity_id]["meta"] = {"unit_of_measurement": unit} + result[entity_id]["meta"] = { + "unit_of_measurement": unit, + "has_mean": "mean" in wanted_statistics, + "has_sum": "sum" in wanted_statistics, + } # Make calculations stat: dict = {} From 66680e44e45d288979e38aef469994bfca4ee8e5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 2 Jul 2021 14:10:32 +0200 Subject: [PATCH 018/134] Upgrade aioimaplib to 0.9.0 (#52422) --- homeassistant/components/imap/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json index 5bb1efa0ca1..c1823459745 100644 --- a/homeassistant/components/imap/manifest.json +++ b/homeassistant/components/imap/manifest.json @@ -2,7 +2,7 @@ "domain": "imap", "name": "IMAP", "documentation": "https://www.home-assistant.io/integrations/imap", - "requirements": ["aioimaplib==0.7.15"], + "requirements": ["aioimaplib==0.9.0"], "codeowners": [], "iot_class": "cloud_push" } diff --git a/requirements_all.txt b/requirements_all.txt index 05cea4ea541..d8cd25d074c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -185,7 +185,7 @@ aiohttp_cors==0.7.0 aiohue==2.5.1 # homeassistant.components.imap -aioimaplib==0.7.15 +aioimaplib==0.9.0 # homeassistant.components.apache_kafka aiokafka==0.6.0 From d04b0978dfd555b5abdc948576c1c2b66eabb0f1 Mon Sep 17 00:00:00 2001 From: Milan Meulemans Date: Fri, 2 Jul 2021 15:09:36 +0200 Subject: [PATCH 019/134] Fix typo in forecast_solar strings (#52430) --- homeassistant/components/forecast_solar/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/forecast_solar/strings.json b/homeassistant/components/forecast_solar/strings.json index eb98fc79297..e1ae451a04f 100644 --- a/homeassistant/components/forecast_solar/strings.json +++ b/homeassistant/components/forecast_solar/strings.json @@ -17,7 +17,7 @@ "options": { "step": { "init": { - "description": "These values allow tweaking the Solar.Forecast result. Please refer to the documentation is a field is unclear.", + "description": "These values allow tweaking the Solar.Forecast result. Please refer to the documentation if a field is unclear.", "data": { "api_key": "Forecast.Solar API Key (optional)", "azimuth": "Azimuth (360 degrees, 0 = North, 90 = East, 180 = South, 270 = West)", From 729f3dc6b8f0056eabab93c83f3995a601c772cf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 2 Jul 2021 15:40:54 +0200 Subject: [PATCH 020/134] Avoid duplicated database queries when fetching statistics (#52433) --- .../components/recorder/statistics.py | 51 +++++----- tests/components/recorder/test_statistics.py | 97 +++++++++++++++---- 2 files changed, 102 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index e1dd0fb986a..2ef49df7ded 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -126,7 +126,7 @@ def compile_statistics(instance: Recorder, start: datetime.datetime) -> bool: return True -def _get_meta_data(hass, session, statistic_ids, statistic_type): +def _get_metadata(hass, session, statistic_ids, statistic_type): """Fetch meta data.""" def _meta(metas, wanted_metadata_id): @@ -166,18 +166,23 @@ def list_statistic_ids(hass, statistic_type=None): """Return statistic_ids and meta data.""" units = hass.config.units with session_scope(hass=hass) as session: - meta_data = _get_meta_data(hass, session, None, statistic_type) + metadata = _get_metadata(hass, session, None, statistic_type) - for meta in meta_data.values(): + for meta in metadata.values(): unit = _configured_unit(meta["unit_of_measurement"], units) meta["unit_of_measurement"] = unit - return list(meta_data.values()) + return list(metadata.values()) def statistics_during_period(hass, start_time, end_time=None, statistic_ids=None): """Return states changes during UTC period start_time - end_time.""" + metadata = None with session_scope(hass=hass) as session: + metadata = _get_metadata(hass, session, statistic_ids, None) + if not metadata: + return {} + baked_query = hass.data[STATISTICS_BAKERY]( lambda session: session.query(*QUERY_STATISTICS) ) @@ -192,10 +197,7 @@ def statistics_during_period(hass, start_time, end_time=None, statistic_ids=None baked_query += lambda q: q.filter( Statistics.metadata_id.in_(bindparam("metadata_ids")) ) - statistic_ids = [statistic_id.lower() for statistic_id in statistic_ids] - metadata_ids = _get_metadata_ids(hass, session, statistic_ids) - if not metadata_ids: - return {} + metadata_ids = list(metadata.keys()) baked_query += lambda q: q.order_by(Statistics.metadata_id, Statistics.start) @@ -204,24 +206,23 @@ def statistics_during_period(hass, start_time, end_time=None, statistic_ids=None start_time=start_time, end_time=end_time, metadata_ids=metadata_ids ) ) - meta_data = _get_meta_data(hass, session, statistic_ids, None) - return _sorted_statistics_to_dict(hass, stats, statistic_ids, meta_data) + return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata) -def get_last_statistics(hass, number_of_stats, statistic_id=None): - """Return the last number_of_stats statistics.""" +def get_last_statistics(hass, number_of_stats, statistic_id): + """Return the last number_of_stats statistics for a statistic_id.""" + statistic_ids = [statistic_id] with session_scope(hass=hass) as session: + metadata = _get_metadata(hass, session, statistic_ids, None) + if not metadata: + return {} + baked_query = hass.data[STATISTICS_BAKERY]( lambda session: session.query(*QUERY_STATISTICS) ) - metadata_id = None - if statistic_id is not None: - baked_query += lambda q: q.filter_by(metadata_id=bindparam("metadata_id")) - metadata_ids = _get_metadata_ids(hass, session, [statistic_id]) - if not metadata_ids: - return {} - metadata_id = metadata_ids[0] + baked_query += lambda q: q.filter_by(metadata_id=bindparam("metadata_id")) + metadata_id = next(iter(metadata.keys())) baked_query += lambda q: q.order_by( Statistics.metadata_id, Statistics.start.desc() @@ -235,16 +236,14 @@ def get_last_statistics(hass, number_of_stats, statistic_id=None): ) ) - statistic_ids = [statistic_id] if statistic_id is not None else None - meta_data = _get_meta_data(hass, session, statistic_ids, None) - return _sorted_statistics_to_dict(hass, stats, statistic_ids, meta_data) + return _sorted_statistics_to_dict(hass, stats, statistic_ids, metadata) def _sorted_statistics_to_dict( hass, stats, statistic_ids, - meta_data, + metadata, ): """Convert SQL results into JSON friendly data structure.""" result = defaultdict(list) @@ -260,8 +259,8 @@ def _sorted_statistics_to_dict( # Append all statistic entries, and do unit conversion for meta_id, group in groupby(stats, lambda state: state.metadata_id): - unit = meta_data[meta_id]["unit_of_measurement"] - statistic_id = meta_data[meta_id]["statistic_id"] + unit = metadata[meta_id]["unit_of_measurement"] + statistic_id = metadata[meta_id]["statistic_id"] convert = UNIT_CONVERSIONS.get(unit, lambda x, units: x) ent_results = result[meta_id] ent_results.extend( @@ -279,4 +278,4 @@ def _sorted_statistics_to_dict( ) # Filter out the empty lists if some states had 0 results. - return {meta_data[key]["statistic_id"]: val for key, val in result.items() if val} + return {metadata[key]["statistic_id"]: val for key, val in result.items() if val} diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 104617aee2c..32eaaaab842 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -8,7 +8,10 @@ from pytest import approx from homeassistant.components.recorder import history from homeassistant.components.recorder.const import DATA_INSTANCE from homeassistant.components.recorder.models import process_timestamp_to_utc_isoformat -from homeassistant.components.recorder.statistics import statistics_during_period +from homeassistant.components.recorder.statistics import ( + get_last_statistics, + statistics_during_period, +) from homeassistant.const import TEMP_CELSIUS from homeassistant.setup import setup_component import homeassistant.util.dt as dt_util @@ -25,24 +28,69 @@ def test_compile_hourly_statistics(hass_recorder): hist = history.get_significant_states(hass, zero, four) assert dict(states) == dict(hist) - recorder.do_adhoc_statistics(period="hourly", start=zero) - wait_recording_done(hass) for kwargs in ({}, {"statistic_ids": ["sensor.test1"]}): stats = statistics_during_period(hass, zero, **kwargs) - assert stats == { - "sensor.test1": [ - { - "statistic_id": "sensor.test1", - "start": process_timestamp_to_utc_isoformat(zero), - "mean": approx(14.915254237288135), - "min": approx(10.0), - "max": approx(20.0), - "last_reset": None, - "state": None, - "sum": None, - } - ] - } + assert stats == {} + stats = get_last_statistics(hass, 0, "sensor.test1") + assert stats == {} + + recorder.do_adhoc_statistics(period="hourly", start=zero) + recorder.do_adhoc_statistics(period="hourly", start=four) + wait_recording_done(hass) + expected_1 = { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(zero), + "mean": approx(14.915254237288135), + "min": approx(10.0), + "max": approx(20.0), + "last_reset": None, + "state": None, + "sum": None, + } + expected_2 = { + "statistic_id": "sensor.test1", + "start": process_timestamp_to_utc_isoformat(four), + "mean": approx(20.0), + "min": approx(20.0), + "max": approx(20.0), + "last_reset": None, + "state": None, + "sum": None, + } + expected_stats1 = [ + {**expected_1, "statistic_id": "sensor.test1"}, + {**expected_2, "statistic_id": "sensor.test1"}, + ] + expected_stats2 = [ + {**expected_1, "statistic_id": "sensor.test2"}, + {**expected_2, "statistic_id": "sensor.test2"}, + ] + + # Test statistics_during_period + stats = statistics_during_period(hass, zero) + assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} + + stats = statistics_during_period(hass, zero, statistic_ids=["sensor.test2"]) + assert stats == {"sensor.test2": expected_stats2} + + stats = statistics_during_period(hass, zero, statistic_ids=["sensor.test3"]) + assert stats == {} + + # Test get_last_statistics + stats = get_last_statistics(hass, 0, "sensor.test1") + assert stats == {} + + stats = get_last_statistics(hass, 1, "sensor.test1") + assert stats == {"sensor.test1": [{**expected_2, "statistic_id": "sensor.test1"}]} + + stats = get_last_statistics(hass, 2, "sensor.test1") + assert stats == {"sensor.test1": expected_stats1[::-1]} + + stats = get_last_statistics(hass, 3, "sensor.test1") + assert stats == {"sensor.test1": expected_stats1[::-1]} + + stats = get_last_statistics(hass, 1, "sensor.test3") + assert stats == {} def record_states(hass): @@ -54,13 +102,19 @@ def record_states(hass): sns1 = "sensor.test1" sns2 = "sensor.test2" sns3 = "sensor.test3" + sns4 = "sensor.test4" sns1_attr = { "device_class": "temperature", "state_class": "measurement", "unit_of_measurement": TEMP_CELSIUS, } - sns2_attr = {"device_class": "temperature"} - sns3_attr = {} + sns2_attr = { + "device_class": "humidity", + "state_class": "measurement", + "unit_of_measurement": "%", + } + sns3_attr = {"device_class": "temperature"} + sns4_attr = {} def set_state(entity_id, state, **kwargs): """Set the state.""" @@ -74,7 +128,7 @@ def record_states(hass): three = two + timedelta(minutes=30) four = three + timedelta(minutes=15) - states = {mp: [], sns1: [], sns2: [], sns3: []} + states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: []} with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=one): states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) @@ -85,15 +139,18 @@ def record_states(hass): states[sns1].append(set_state(sns1, "10", attributes=sns1_attr)) states[sns2].append(set_state(sns2, "10", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "10", attributes=sns3_attr)) + states[sns4].append(set_state(sns4, "10", attributes=sns4_attr)) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=two): states[sns1].append(set_state(sns1, "15", attributes=sns1_attr)) states[sns2].append(set_state(sns2, "15", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "15", attributes=sns3_attr)) + states[sns4].append(set_state(sns4, "15", attributes=sns4_attr)) with patch("homeassistant.components.recorder.dt_util.utcnow", return_value=three): states[sns1].append(set_state(sns1, "20", attributes=sns1_attr)) states[sns2].append(set_state(sns2, "20", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "20", attributes=sns3_attr)) + states[sns4].append(set_state(sns4, "20", attributes=sns4_attr)) return zero, four, states From 730c8cbcc4ff415fcc45fed56676ee1bf8b440d5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 2 Jul 2021 16:28:16 +0200 Subject: [PATCH 021/134] Correct recorder table arguments (#52436) --- homeassistant/components/recorder/models.py | 52 +++++++-------------- 1 file changed, 17 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 50052c1f722..c77d824c64f 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -66,10 +66,12 @@ DATETIME_TYPE = DateTime(timezone=True).with_variant( class Events(Base): # type: ignore """Event history data.""" - __table_args__ = { - "mysql_default_charset": "utf8mb4", - "mysql_collate": "utf8mb4_unicode_ci", - } + __table_args__ = ( + # Used for fetching events at a specific time + # see logbook + Index("ix_events_event_type_time_fired", "event_type", "time_fired"), + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) __tablename__ = TABLE_EVENTS event_id = Column(Integer, Identity(), primary_key=True) event_type = Column(String(MAX_LENGTH_EVENT_EVENT_TYPE)) @@ -81,12 +83,6 @@ class Events(Base): # type: ignore context_user_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) context_parent_id = Column(String(MAX_LENGTH_EVENT_CONTEXT_ID), index=True) - __table_args__ = ( - # Used for fetching events at a specific time - # see logbook - Index("ix_events_event_type_time_fired", "event_type", "time_fired"), - ) - def __repr__(self) -> str: """Return string representation of instance for debugging.""" return ( @@ -133,10 +129,12 @@ class Events(Base): # type: ignore class States(Base): # type: ignore """State change history.""" - __table_args__ = { - "mysql_default_charset": "utf8mb4", - "mysql_collate": "utf8mb4_unicode_ci", - } + __table_args__ = ( + # Used for fetching the state of entities at a specific time + # (get_states in history.py) + Index("ix_states_entity_id_last_updated", "entity_id", "last_updated"), + {"mysql_default_charset": "utf8mb4", "mysql_collate": "utf8mb4_unicode_ci"}, + ) __tablename__ = TABLE_STATES state_id = Column(Integer, Identity(), primary_key=True) domain = Column(String(MAX_LENGTH_STATE_DOMAIN)) @@ -153,12 +151,6 @@ class States(Base): # type: ignore event = relationship("Events", uselist=False) old_state = relationship("States", remote_side=[state_id]) - __table_args__ = ( - # Used for fetching the state of entities at a specific time - # (get_states in history.py) - Index("ix_states_entity_id_last_updated", "entity_id", "last_updated"), - ) - def __repr__(self) -> str: """Return string representation of instance for debugging.""" return ( @@ -217,10 +209,10 @@ class States(Base): # type: ignore class Statistics(Base): # type: ignore """Statistics.""" - __table_args__ = { - "mysql_default_charset": "utf8mb4", - "mysql_collate": "utf8mb4_unicode_ci", - } + __table_args__ = ( + # Used for fetching statistics for a certain entity at a specific time + Index("ix_statistics_statistic_id_start", "metadata_id", "start"), + ) __tablename__ = TABLE_STATISTICS id = Column(Integer, primary_key=True) created = Column(DATETIME_TYPE, default=dt_util.utcnow) @@ -237,11 +229,6 @@ class Statistics(Base): # type: ignore state = Column(Float()) sum = Column(Float()) - __table_args__ = ( - # Used for fetching statistics for a certain entity at a specific time - Index("ix_statistics_statistic_id_start", "metadata_id", "start"), - ) - @staticmethod def from_stats(metadata_id, start, stats): """Create object from a statistics.""" @@ -255,10 +242,6 @@ class Statistics(Base): # type: ignore class StatisticsMeta(Base): # type: ignore """Statistics meta data.""" - __table_args__ = { - "mysql_default_charset": "utf8mb4", - "mysql_collate": "utf8mb4_unicode_ci", - } __tablename__ = TABLE_STATISTICS_META id = Column(Integer, primary_key=True) statistic_id = Column(String(255), index=True) @@ -282,6 +265,7 @@ class StatisticsMeta(Base): # type: ignore class RecorderRuns(Base): # type: ignore """Representation of recorder run.""" + __table_args__ = (Index("ix_recorder_runs_start_end", "start", "end"),) __tablename__ = TABLE_RECORDER_RUNS run_id = Column(Integer, Identity(), primary_key=True) start = Column(DateTime(timezone=True), default=dt_util.utcnow) @@ -289,8 +273,6 @@ class RecorderRuns(Base): # type: ignore closed_incorrect = Column(Boolean, default=False) created = Column(DateTime(timezone=True), default=dt_util.utcnow) - __table_args__ = (Index("ix_recorder_runs_start_end", "start", "end"),) - def __repr__(self) -> str: """Return string representation of instance for debugging.""" end = ( From 8b54d958f35367bf31aec168f3e641427b486ecc Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 2 Jul 2021 17:58:59 +0200 Subject: [PATCH 022/134] Bumped version to 2021.7.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 89fccd434ed..4e64162c756 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b0" +PATCH_VERSION: Final = "0b1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 4b3ce4763d80347b617b378a2f21ffa87e75d6ef Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 2 Jul 2021 20:56:51 +0200 Subject: [PATCH 023/134] Abort existing reauth flow on entry removal (#52407) --- homeassistant/config_entries.py | 13 +++++++++++++ tests/test_config_entries.py | 24 ++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 49892937217..2bb8c4f3e29 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -819,6 +819,19 @@ class ConfigEntries: dev_reg.async_clear_config_entry(entry_id) ent_reg.async_clear_config_entry(entry_id) + # If the configuration entry is removed during reauth, it should + # abort any reauth flow that is active for the removed entry. + for progress_flow in self.hass.config_entries.flow.async_progress(): + context = progress_flow.get("context") + if ( + context + and context["source"] == SOURCE_REAUTH + and "entry_id" in context + and context["entry_id"] == entry_id + and "flow_id" in progress_flow + ): + self.hass.config_entries.flow.async_abort(progress_flow["flow_id"]) + # After we have fully removed an "ignore" config entry we can try and rediscover it so that a # user is able to immediately start configuring it. We do this by starting a new flow with # the 'unignore' step. If the integration doesn't implement async_step_unignore then diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 615b97fb990..7bcc83048a4 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -337,6 +337,30 @@ async def test_remove_entry(hass, manager): assert not entity_entry_list +async def test_remove_entry_cancels_reauth(hass, manager): + """Tests that removing a config entry, also aborts existing reauth flows.""" + entry = MockConfigEntry(title="test_title", domain="test") + + mock_setup_entry = AsyncMock(side_effect=ConfigEntryAuthFailed()) + mock_integration(hass, MockModule("test", async_setup_entry=mock_setup_entry)) + mock_entity_platform(hass, "config_flow.test", None) + + entry.add_to_hass(hass) + await entry.async_setup(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert flows[0]["context"]["entry_id"] == entry.entry_id + assert flows[0]["context"]["source"] == config_entries.SOURCE_REAUTH + assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR + + await manager.async_remove(entry.entry_id) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 0 + + async def test_remove_entry_handles_callback_error(hass, manager): """Test that exceptions in the remove callback are handled.""" mock_setup_entry = AsyncMock(return_value=True) From 77c643946b45d596112e6ac59e1f8a64053675c5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 2 Jul 2021 19:21:05 +0200 Subject: [PATCH 024/134] Fix Fritz call deflection list (#52443) --- homeassistant/components/fritz/switch.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index 09bae36a29c..d9690b64069 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -110,7 +110,10 @@ def get_deflections( if not deflection_list: return [] - return [xmltodict.parse(deflection_list["NewDeflectionList"])["List"]["Item"]] + items = xmltodict.parse(deflection_list["NewDeflectionList"])["List"]["Item"] + if not isinstance(items, list): + return [items] + return items def deflection_entities_list( From 4b077b5a391070e9849a9f6433df1ab6b251fd3e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 2 Jul 2021 20:55:40 +0200 Subject: [PATCH 025/134] Fix Statistics recorder migration order (#52449) --- homeassistant/components/recorder/migration.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 30a5162e947..e931abbd2d3 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -347,7 +347,7 @@ def _drop_foreign_key_constraints(connection, engine, table, columns): ) -def _apply_update(engine, session, new_version, old_version): +def _apply_update(engine, session, new_version, old_version): # noqa: C901 """Perform operations to bring schema up to date.""" connection = session.connection() if new_version == 1: @@ -463,12 +463,15 @@ def _apply_update(engine, session, new_version, old_version): # This dropped the statistics table, done again in version 18. pass elif new_version == 18: - if sqlalchemy.inspect(engine).has_table(Statistics.__tablename__): - # Recreate the statistics and statisticsmeta tables - Statistics.__table__.drop(engine) - Statistics.__table__.create(engine) + # Recreate the statisticsmeta tables + if sqlalchemy.inspect(engine).has_table(StatisticsMeta.__tablename__): StatisticsMeta.__table__.drop(engine) - StatisticsMeta.__table__.create(engine) + StatisticsMeta.__table__.create(engine) + + # Recreate the statistics table + if sqlalchemy.inspect(engine).has_table(Statistics.__tablename__): + Statistics.__table__.drop(engine) + Statistics.__table__.create(engine) else: raise ValueError(f"No schema migration defined for version {new_version}") From 95132cc425bcb202b1a8399c663f6db5edb04e08 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 2 Jul 2021 21:07:17 +0200 Subject: [PATCH 026/134] Bumped version to 2021.7.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 4e64162c756..d35c44348c5 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b1" +PATCH_VERSION: Final = "0b2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From d2cef65b6392fe08ca8ae0670b28bee502927618 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Mon, 5 Jul 2021 09:23:02 +0200 Subject: [PATCH 027/134] Bump gios library to version 1.0.2 (#52527) --- homeassistant/components/gios/air_quality.py | 4 ++-- homeassistant/components/gios/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/gios/air_quality.py b/homeassistant/components/gios/air_quality.py index e74cec8e151..00c4a526c46 100644 --- a/homeassistant/components/gios/air_quality.py +++ b/homeassistant/components/gios/air_quality.py @@ -80,7 +80,7 @@ class GiosAirQuality(CoordinatorEntity, AirQualityEntity): @property def air_quality_index(self) -> str | None: """Return the air quality index.""" - return cast(Optional[str], self.coordinator.data.get(API_AQI, {}).get("value")) + return cast(Optional[str], self.coordinator.data.get(API_AQI).get("value")) @property def particulate_matter_2_5(self) -> float | None: @@ -141,7 +141,7 @@ class GiosAirQuality(CoordinatorEntity, AirQualityEntity): if sensor in self.coordinator.data: self._attrs[f"{SENSOR_MAP[sensor]}_index"] = self.coordinator.data[ sensor - ]["index"] + ].get("index") self._attrs[ATTR_STATION] = self.coordinator.gios.station_name return self._attrs diff --git a/homeassistant/components/gios/manifest.json b/homeassistant/components/gios/manifest.json index 3dfb2a168db..f13da0e3f33 100644 --- a/homeassistant/components/gios/manifest.json +++ b/homeassistant/components/gios/manifest.json @@ -3,7 +3,7 @@ "name": "GIO\u015a", "documentation": "https://www.home-assistant.io/integrations/gios", "codeowners": ["@bieniu"], - "requirements": ["gios==1.0.1"], + "requirements": ["gios==1.0.2"], "config_flow": true, "quality_scale": "platinum", "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index d8cd25d074c..1c5d7449ced 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -678,7 +678,7 @@ georss_qld_bushfire_alert_client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==1.0.1 +gios==1.0.2 # homeassistant.components.gitter gitterpy==0.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f79b8764e58..30beb930b9c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -384,7 +384,7 @@ georss_qld_bushfire_alert_client==0.5 getmac==0.8.2 # homeassistant.components.gios -gios==1.0.1 +gios==1.0.2 # homeassistant.components.glances glances_api==0.2.0 From ebc3e1f6583d68cdc24efb77cf0080f665fda1d7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 5 Jul 2021 13:34:40 +0200 Subject: [PATCH 028/134] Fix Statistics recorder migration path by dropping in pairs (#52453) --- .../components/recorder/migration.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index e931abbd2d3..248c4597b9f 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -463,14 +463,19 @@ def _apply_update(engine, session, new_version, old_version): # noqa: C901 # This dropped the statistics table, done again in version 18. pass elif new_version == 18: - # Recreate the statisticsmeta tables - if sqlalchemy.inspect(engine).has_table(StatisticsMeta.__tablename__): - StatisticsMeta.__table__.drop(engine) - StatisticsMeta.__table__.create(engine) + # Recreate the statistics and statistics meta tables. + # + # Order matters! Statistics has a relation with StatisticsMeta, + # so statistics need to be deleted before meta (or in pair depending + # on the SQL backend); and meta needs to be created before statistics. + if sqlalchemy.inspect(engine).has_table( + StatisticsMeta.__tablename__ + ) or sqlalchemy.inspect(engine).has_table(Statistics.__tablename__): + Base.metadata.drop_all( + bind=engine, tables=[Statistics.__table__, StatisticsMeta.__table__] + ) - # Recreate the statistics table - if sqlalchemy.inspect(engine).has_table(Statistics.__tablename__): - Statistics.__table__.drop(engine) + StatisticsMeta.__table__.create(engine) Statistics.__table__.create(engine) else: raise ValueError(f"No schema migration defined for version {new_version}") From 070991c160ab38b3b15ec49873e3df669bf602fe Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 3 Jul 2021 10:01:41 -0500 Subject: [PATCH 029/134] Bump aiohomekit to 0.4.1 (#52472) - Fixes mdns queries being sent with the original case received on the wire Some responders were case sensitive and needed the original case sent - Reduces mdns traffic --- 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 155f3a4f5f6..39144d6c521 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.4.0"], + "requirements": ["aiohomekit==0.4.1"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/requirements_all.txt b/requirements_all.txt index 1c5d7449ced..5b9d2556af7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -175,7 +175,7 @@ aioguardian==1.0.4 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.4.0 +aiohomekit==0.4.1 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30beb930b9c..9d67b86a9a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -112,7 +112,7 @@ aioguardian==1.0.4 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.4.0 +aiohomekit==0.4.1 # homeassistant.components.emulated_hue # homeassistant.components.http From afb187942a145e76ddb866482847e6d65d6b1034 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Mon, 5 Jul 2021 04:38:31 -0500 Subject: [PATCH 030/134] Revert "Force SimpliSafe to reauthenticate with a password (#51528)" (#52484) This reverts commit 549f779b0679b004f67aca996b107414355a6e36. --- .../components/simplisafe/__init__.py | 73 ++++++++++++------- .../components/simplisafe/config_flow.py | 13 ++-- .../components/simplisafe/strings.json | 2 +- .../simplisafe/translations/en.json | 2 +- .../components/simplisafe/test_config_flow.py | 20 ++--- 5 files changed, 57 insertions(+), 53 deletions(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index b7c2a08f093..983629743e7 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -6,7 +6,7 @@ from simplipy import API from simplipy.errors import EndpointUnavailable, InvalidCredentialsError, SimplipyError import voluptuous as vol -from homeassistant.const import ATTR_CODE, CONF_CODE, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ATTR_CODE, CONF_CODE, CONF_TOKEN, CONF_USERNAME from homeassistant.core import CoreState, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( @@ -107,6 +107,14 @@ SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA = SERVICE_BASE_SCHEMA.extend( CONFIG_SCHEMA = cv.deprecated(DOMAIN) +@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} + ) + + async def async_get_client_id(hass): """Get a client ID (based on the HASS unique ID) for the SimpliSafe API. @@ -134,9 +142,6 @@ async def async_setup_entry(hass, config_entry): # noqa: C901 hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = [] hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = [] - if CONF_PASSWORD not in config_entry.data: - raise ConfigEntryAuthFailed("Config schema change requires re-authentication") - entry_updates = {} if not config_entry.unique_id: # If the config entry doesn't already have a unique ID, set one: @@ -158,24 +163,20 @@ async def async_setup_entry(hass, config_entry): # noqa: C901 client_id = await async_get_client_id(hass) websession = aiohttp_client.async_get_clientsession(hass) - async def async_get_api(): - """Define a helper to get an authenticated SimpliSafe API object.""" - return await API.login_via_credentials( - config_entry.data[CONF_USERNAME], - config_entry.data[CONF_PASSWORD], - client_id=client_id, - session=websession, - ) - try: - api = await async_get_api() - except InvalidCredentialsError as err: - raise ConfigEntryAuthFailed from err + api = await API.login_via_token( + config_entry.data[CONF_TOKEN], client_id=client_id, session=websession + ) + except InvalidCredentialsError: + LOGGER.error("Invalid credentials provided") + return False except SimplipyError as err: LOGGER.error("Config entry failed: %s", err) raise ConfigEntryNotReady from err - simplisafe = SimpliSafe(hass, config_entry, api, async_get_api) + _async_save_refresh_token(hass, config_entry, api.refresh_token) + + simplisafe = SimpliSafe(hass, api, config_entry) try: await simplisafe.async_init() @@ -302,10 +303,10 @@ async def async_reload_entry(hass, config_entry): class SimpliSafe: """Define a SimpliSafe data object.""" - def __init__(self, hass, config_entry, api, async_get_api): + def __init__(self, hass, api, config_entry): """Initialize.""" self._api = api - self._async_get_api = async_get_api + self._emergency_refresh_token_used = False self._hass = hass self._system_notifications = {} self.config_entry = config_entry @@ -382,17 +383,23 @@ class SimpliSafe: for result in results: if isinstance(result, InvalidCredentialsError): - try: - self._api = await self._async_get_api() - return - except InvalidCredentialsError as err: + if self._emergency_refresh_token_used: raise ConfigEntryAuthFailed( - "Unable to re-authenticate with SimpliSafe" - ) from err + "Update failed with stored refresh token" + ) + + LOGGER.warning("SimpliSafe cloud error; trying stored refresh token") + self._emergency_refresh_token_used = True + + try: + await self._api.refresh_access_token( + self.config_entry.data[CONF_TOKEN] + ) + return except SimplipyError as err: - raise UpdateFailed( - f"SimpliSafe error while updating: {err}" - ) from err + raise UpdateFailed( # pylint: disable=raise-missing-from + f"Error while using stored refresh token: {err}" + ) if isinstance(result, EndpointUnavailable): # In case the user attempts an action not allowed in their current plan, @@ -403,6 +410,16 @@ class SimpliSafe: if isinstance(result, SimplipyError): raise UpdateFailed(f"SimpliSafe error while updating: {result}") + if self._api.refresh_token != self.config_entry.data[CONF_TOKEN]: + _async_save_refresh_token( + self._hass, self.config_entry, self._api.refresh_token + ) + + # If we've reached this point using an emergency refresh token, we're in the + # clear and we can discard it: + if self._emergency_refresh_token_used: + self._emergency_refresh_token_used = False + class SimpliSafeEntity(CoordinatorEntity): """Define a base SimpliSafe entity.""" diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index 0faa07221aa..ba51356f770 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -8,7 +8,7 @@ from simplipy.errors import ( import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import aiohttp_client @@ -59,7 +59,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} try: - await self._async_get_simplisafe_api() + simplisafe = await self._async_get_simplisafe_api() except PendingAuthorizationError: LOGGER.info("Awaiting confirmation of MFA email click") return await self.async_step_mfa() @@ -79,7 +79,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_finish( { CONF_USERNAME: self._username, - CONF_PASSWORD: self._password, + CONF_TOKEN: simplisafe.refresh_token, CONF_CODE: self._code, } ) @@ -89,9 +89,6 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): existing_entry = await self.async_set_unique_id(self._username) if existing_entry: self.hass.config_entries.async_update_entry(existing_entry, data=user_input) - self.hass.async_create_task( - self.hass.config_entries.async_reload(existing_entry.entry_id) - ) return self.async_abort(reason="reauth_successful") return self.async_create_entry(title=self._username, data=user_input) @@ -101,7 +98,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="mfa") try: - await self._async_get_simplisafe_api() + simplisafe = await self._async_get_simplisafe_api() except PendingAuthorizationError: LOGGER.error("Still awaiting confirmation of MFA email click") return self.async_show_form( @@ -111,7 +108,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_finish( { CONF_USERNAME: self._username, - CONF_PASSWORD: self._password, + CONF_TOKEN: simplisafe.refresh_token, CONF_CODE: self._code, } ) diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json index 23f85495025..ad973261a0e 100644 --- a/homeassistant/components/simplisafe/strings.json +++ b/homeassistant/components/simplisafe/strings.json @@ -7,7 +7,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "Your access has expired or been revoked. Enter your password to re-link your account.", + "description": "Your access token has expired or been revoked. Enter your password to re-link your account.", "data": { "password": "[%key:common::config_flow::data::password%]" } diff --git a/homeassistant/components/simplisafe/translations/en.json b/homeassistant/components/simplisafe/translations/en.json index 331eb65ca83..b9e274666bb 100644 --- a/homeassistant/components/simplisafe/translations/en.json +++ b/homeassistant/components/simplisafe/translations/en.json @@ -19,7 +19,7 @@ "data": { "password": "Password" }, - "description": "Your access has expired or been revoked. Enter your password to re-link your account.", + "description": "Your access token has expired or been revoked. Enter your password to re-link your account.", "title": "Reauthenticate Integration" }, "user": { diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py index c2397e9f89e..a048e4b0745 100644 --- a/tests/components/simplisafe/test_config_flow.py +++ b/tests/components/simplisafe/test_config_flow.py @@ -10,7 +10,7 @@ from simplipy.errors import ( from homeassistant import data_entry_flow from homeassistant.components.simplisafe import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER -from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME from tests.common import MockConfigEntry @@ -33,11 +33,7 @@ async def test_duplicate_error(hass): MockConfigEntry( domain=DOMAIN, unique_id="user@email.com", - data={ - CONF_USERNAME: "user@email.com", - CONF_PASSWORD: "password", - CONF_CODE: "1234", - }, + data={CONF_USERNAME: "user@email.com", CONF_TOKEN: "12345", CONF_CODE: "1234"}, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -106,11 +102,7 @@ async def test_step_reauth(hass): MockConfigEntry( domain=DOMAIN, unique_id="user@email.com", - data={ - CONF_USERNAME: "user@email.com", - CONF_PASSWORD: "password", - CONF_CODE: "1234", - }, + data={CONF_USERNAME: "user@email.com", CONF_TOKEN: "12345", CONF_CODE: "1234"}, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -128,8 +120,6 @@ async def test_step_reauth(hass): "homeassistant.components.simplisafe.async_setup_entry", return_value=True ), patch( "simplipy.API.login_via_credentials", new=AsyncMock(return_value=mock_api()) - ), patch( - "homeassistant.config_entries.ConfigEntries.async_reload" ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "password"} @@ -161,7 +151,7 @@ async def test_step_user(hass): assert result["title"] == "user@email.com" assert result["data"] == { CONF_USERNAME: "user@email.com", - CONF_PASSWORD: "password", + CONF_TOKEN: "12345abc", CONF_CODE: "1234", } @@ -207,7 +197,7 @@ async def test_step_user_mfa(hass): assert result["title"] == "user@email.com" assert result["data"] == { CONF_USERNAME: "user@email.com", - CONF_PASSWORD: "password", + CONF_TOKEN: "12345abc", CONF_CODE: "1234", } From 36eec7ddbc4f290100596fcf5560d5671e3c83b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Jul 2021 11:40:33 -0500 Subject: [PATCH 031/134] Remove empty hosts and excludes from nmap configuration (#52489) --- homeassistant/components/nmap_tracker/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 399121e4e00..76a7e44f153 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -166,8 +166,10 @@ class NmapDeviceScanner: self._scan_interval = timedelta( seconds=config.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL) ) - self._hosts = cv.ensure_list_csv(config[CONF_HOSTS]) - self._exclude = cv.ensure_list_csv(config[CONF_EXCLUDE]) + hosts_list = cv.ensure_list_csv(config[CONF_HOSTS]) + self._hosts = [host for host in hosts_list if host != ""] + excludes_list = cv.ensure_list_csv(config[CONF_EXCLUDE]) + self._exclude = [exclude for exclude in excludes_list if exclude != ""] self._options = config[CONF_OPTIONS] self.home_interval = timedelta( minutes=cv.positive_int(config[CONF_HOME_INTERVAL]) From 206437b10cab6840aa4b2aa2532015d69cebe8c7 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 5 Jul 2021 11:45:50 +0200 Subject: [PATCH 032/134] Fix MODBUS connection type rtuovertcp does not connect (#52505) * Correct host -> framer. * Use function pointer --- homeassistant/components/modbus/modbus.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 35572baff43..2e5892dbf1d 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -5,6 +5,7 @@ import logging from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient from pymodbus.constants import Defaults from pymodbus.exceptions import ModbusException +from pymodbus.transaction import ModbusRtuFramer from homeassistant.const import ( CONF_DELAY, @@ -224,7 +225,7 @@ class ModbusHub: # network configuration self._pb_params["host"] = client_config[CONF_HOST] if self._config_type == CONF_RTUOVERTCP: - self._pb_params["host"] = "ModbusRtuFramer" + self._pb_params["framer"] = ModbusRtuFramer Defaults.Timeout = client_config[CONF_TIMEOUT] From e140cd9b6ab5d16dbc6b5cf05309e268a5891259 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 4 Jul 2021 09:16:42 -0500 Subject: [PATCH 033/134] Bump HAP-python to 3.5.1 (#52508) - Fixes additional cases of invalid mdns hostnames --- homeassistant/components/homekit/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index d2c2f094a0f..39c40e03614 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit", "documentation": "https://www.home-assistant.io/integrations/homekit", "requirements": [ - "HAP-python==3.5.0", + "HAP-python==3.5.1", "fnvhash==0.1.0", "PyQRCode==1.2.1", "base36==0.1.1", diff --git a/requirements_all.txt b/requirements_all.txt index 5b9d2556af7..0b9e2b92f2d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -14,7 +14,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.1.1 # homeassistant.components.homekit -HAP-python==3.5.0 +HAP-python==3.5.1 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9d67b86a9a4..055ec38f2a1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.2.1 # homeassistant.components.homekit -HAP-python==3.5.0 +HAP-python==3.5.1 # homeassistant.components.flick_electric PyFlick==0.0.2 From d5b419eeda50bc14c2bddaf26db618e395375b50 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 5 Jul 2021 13:28:01 +0200 Subject: [PATCH 034/134] Remove problematic/redudant db migration happning schema 15 (#52541) --- homeassistant/components/recorder/migration.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 248c4597b9f..2ed676bfdb9 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -347,7 +347,7 @@ def _drop_foreign_key_constraints(connection, engine, table, columns): ) -def _apply_update(engine, session, new_version, old_version): # noqa: C901 +def _apply_update(engine, session, new_version, old_version): """Perform operations to bring schema up to date.""" connection = session.connection() if new_version == 1: @@ -451,10 +451,8 @@ def _apply_update(engine, session, new_version, old_version): # noqa: C901 elif new_version == 14: _modify_columns(connection, engine, "events", ["event_type VARCHAR(64)"]) elif new_version == 15: - if sqlalchemy.inspect(engine).has_table(Statistics.__tablename__): - # Recreate the statistics table - Statistics.__table__.drop(engine) - Statistics.__table__.create(engine) + # This dropped the statistics table, done again in version 18. + pass elif new_version == 16: _drop_foreign_key_constraints( connection, engine, TABLE_STATES, ["old_state_id"] From 9368f75cec330ce4bb61086de0167a10a5a2baa7 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 5 Jul 2021 13:41:25 +0200 Subject: [PATCH 035/134] Bumped version to 2021.7.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d35c44348c5..5a58a3e2789 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b2" +PATCH_VERSION: Final = "0b3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 0cd097cd1240193fa66f012199f747bca5747a42 Mon Sep 17 00:00:00 2001 From: Tom Brien Date: Mon, 5 Jul 2021 15:22:41 +0100 Subject: [PATCH 036/134] Update list of supported Coinbase wallet currencies (#52545) --- homeassistant/components/coinbase/const.py | 201 ++++++++++++++++++++- 1 file changed, 199 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index 1f86c8026ec..035706c46ce 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -19,50 +19,247 @@ API_ACCOUNTS_DATA = "data" API_RATES = "rates" WALLETS = { + "1INCH": "1INCH", "AAVE": "AAVE", + "ADA": "ADA", + "AED": "AED", + "AFN": "AFN", "ALGO": "ALGO", + "ALL": "ALL", + "AMD": "AMD", + "AMP": "AMP", + "ANG": "ANG", + "ANKR": "ANKR", + "AOA": "AOA", + "ARS": "ARS", "ATOM": "ATOM", + "AUD": "AUD", + "AWG": "AWG", + "AZN": "AZN", "BAL": "BAL", + "BAM": "BAM", "BAND": "BAND", "BAT": "BAT", + "BBD": "BBD", "BCH": "BCH", + "BDT": "BDT", + "BGN": "BGN", + "BHD": "BHD", + "BIF": "BIF", + "BMD": "BMD", + "BND": "BND", "BNT": "BNT", + "BOB": "BOB", + "BOND": "BOND", + "BRL": "BRL", + "BSD": "BSD", "BSV": "BSV", "BTC": "BTC", - "CGLD": "CLGD", - "CVC": "CVC", + "BTN": "BTN", + "BWP": "BWP", + "BYN": "BYN", + "BYR": "BYR", + "BZD": "BZD", + "CAD": "CAD", + "CDF": "CDF", + "CGLD": "CGLD", + "CHF": "CHF", + "CHZ": "CHZ", + "CLF": "CLF", + "CLP": "CLP", + "CNH": "CNH", + "CNY": "CNY", "COMP": "COMP", + "COP": "COP", + "CRC": "CRC", + "CRV": "CRV", + "CTSI": "CTSI", + "CUC": "CUC", + "CVC": "CVC", + "CVE": "CVE", + "CZK": "CZK", "DAI": "DAI", + "DASH": "DASH", + "DJF": "DJF", + "DKK": "DKK", "DNT": "DNT", + "DOGE": "DOGE", + "DOP": "DOP", + "DOT": "DOT", + "DZD": "DZD", + "EGP": "EGP", + "ENJ": "ENJ", "EOS": "EOS", + "ERN": "ERN", + "ETB": "ETB", "ETC": "ETC", "ETH": "ETH", + "ETH2": "ETH2", "EUR": "EUR", "FIL": "FIL", + "FJD": "FJD", + "FKP": "FKP", + "FORTH": "FORTH", "GBP": "GBP", + "GBX": "GBX", + "GEL": "GEL", + "GGP": "GGP", + "GHS": "GHS", + "GIP": "GIP", + "GMD": "GMD", + "GNF": "GNF", "GRT": "GRT", + "GTC": "GTC", + "GTQ": "GTQ", + "GYD": "GYD", + "HKD": "HKD", + "HNL": "HNL", + "HRK": "HRK", + "HTG": "HTG", + "HUF": "HUF", + "ICP": "ICP", + "IDR": "IDR", + "ILS": "ILS", + "IMP": "IMP", + "INR": "INR", + "IQD": "IQD", + "ISK": "ISK", + "JEP": "JEP", + "JMD": "JMD", + "JOD": "JOD", + "JPY": "JPY", + "KEEP": "KEEP", + "KES": "KES", + "KGS": "KGS", + "KHR": "KHR", + "KMF": "KMF", "KNC": "KNC", + "KRW": "KRW", + "KWD": "KWD", + "KYD": "KYD", + "KZT": "KZT", + "LAK": "LAK", + "LBP": "LBP", "LINK": "LINK", + "LKR": "LKR", + "LPT": "LPT", "LRC": "LRC", + "LRD": "LRD", + "LSL": "LSL", "LTC": "LTC", + "LYD": "LYD", + "MAD": "MAD", "MANA": "MANA", + "MATIC": "MATIC", + "MDL": "MDL", + "MGA": "MGA", + "MIR": "MIR", + "MKD": "MKD", "MKR": "MKR", + "MLN": "MLN", + "MMK": "MMK", + "MNT": "MNT", + "MOP": "MOP", + "MRO": "MRO", + "MTL": "MTL", + "MUR": "MUR", + "MVR": "MVR", + "MWK": "MWK", + "MXN": "MXN", + "MYR": "MYR", + "MZN": "MZN", + "NAD": "NAD", + "NGN": "NGN", + "NIO": "NIO", + "NKN": "NKN", "NMR": "NMR", + "NOK": "NOK", + "NPR": "NPR", "NU": "NU", + "NZD": "NZD", + "OGN": "OGN", "OMG": "OMG", + "OMR": "OMR", "OXT": "OXT", + "PAB": "PAB", + "PEN": "PEN", + "PGK": "PGK", + "PHP": "PHP", + "PKR": "PKR", + "PLN": "PLN", + "PYG": "PYG", + "QAR": "QAR", + "QNT": "QNT", + "REN": "REN", "REP": "REP", "REPV2": "REPV2", + "RLC": "RLC", + "RON": "RON", + "RSD": "RSD", + "RUB": "RUB", + "RWF": "RWF", + "SAR": "SAR", + "SBD": "SBD", + "SCR": "SCR", + "SEK": "SEK", + "SGD": "SGD", + "SHP": "SHP", + "SKL": "SKL", + "SLL": "SLL", "SNX": "SNX", + "SOL": "SOL", + "SOS": "SOS", + "SRD": "SRD", + "SSP": "SSP", + "STD": "STD", + "STORJ": "STORJ", + "SUSHI": "SUSHI", + "SVC": "SVC", + "SZL": "SZL", + "THB": "THB", + "TJS": "TJS", + "TMM": "TMM", + "TMT": "TMT", + "TND": "TND", + "TOP": "TOP", + "TRB": "TRB", + "TRY": "TRY", + "TTD": "TTD", + "TWD": "TWD", + "TZS": "TZS", + "UAH": "UAH", + "UGX": "UGX", "UMA": "UMA", "UNI": "UNI", + "USD": "USD", "USDC": "USDC", + "USDT": "USDT", + "UYU": "UYU", + "UZS": "UZS", + "VES": "VES", + "VND": "VND", + "VUV": "VUV", "WBTC": "WBTC", + "WST": "WST", + "XAF": "XAF", + "XAG": "XAG", + "XAU": "XAU", + "XCD": "XCD", + "XDR": "XDR", "XLM": "XLM", + "XOF": "XOF", + "XPD": "XPD", + "XPF": "XPF", + "XPT": "XPT", "XRP": "XRP", "XTZ": "XTZ", + "YER": "YER", "YFI": "YFI", + "ZAR": "ZAR", + "ZEC": "ZEC", + "ZMW": "ZMW", "ZRX": "ZRX", + "ZWL": "ZWL", } RATES = { From dfce89f2c70f8252e1c178d4643f623a9a0fc439 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Jul 2021 09:17:11 -0500 Subject: [PATCH 037/134] Bump zeroconf to 0.32.1 (#52547) - Changelog: https://github.com/jstasiak/python-zeroconf/compare/0.32.0...0.32.1 - Fixes #52384 --- 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 0f7d18446ee..199275623dc 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.32.0"], + "requirements": ["zeroconf==0.32.1"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2e166af6172..98ce2b67183 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -33,7 +33,7 @@ sqlalchemy==1.4.17 voluptuous-serialize==2.4.0 voluptuous==0.12.1 yarl==1.6.3 -zeroconf==0.32.0 +zeroconf==0.32.1 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 0b9e2b92f2d..f34669d4b9b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2428,7 +2428,7 @@ zeep[async]==4.0.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.32.0 +zeroconf==0.32.1 # homeassistant.components.zha zha-quirks==0.0.58 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 055ec38f2a1..f07189c2753 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1331,7 +1331,7 @@ yeelight==0.6.3 zeep[async]==4.0.0 # homeassistant.components.zeroconf -zeroconf==0.32.0 +zeroconf==0.32.1 # homeassistant.components.zha zha-quirks==0.0.58 From a52b4b0f626d92b0a37bfeb979796457ab43dc63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Klomp?= Date: Mon, 5 Jul 2021 22:04:55 +0200 Subject: [PATCH 038/134] Bump pysma version to 0.6.2 (#52553) --- homeassistant/components/sma/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index 85f6de7cb7c..a48b9ba74ce 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -3,7 +3,7 @@ "name": "SMA Solar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sma", - "requirements": ["pysma==0.6.1"], + "requirements": ["pysma==0.6.2"], "codeowners": ["@kellerza", "@rklomp"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index f34669d4b9b..a0a812f9e19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1756,7 +1756,7 @@ pysignalclirestapi==0.3.4 pyskyqhub==0.1.3 # homeassistant.components.sma -pysma==0.6.1 +pysma==0.6.2 # homeassistant.components.smappee pysmappee==0.2.25 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f07189c2753..6c6e58d1f2f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -998,7 +998,7 @@ pysiaalarm==3.0.0 pysignalclirestapi==0.3.4 # homeassistant.components.sma -pysma==0.6.1 +pysma==0.6.2 # homeassistant.components.smappee pysmappee==0.2.25 From 777cf116aab77407bdb4a7e2bf77ea0416029f16 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Jul 2021 15:16:49 -0500 Subject: [PATCH 039/134] Update the ip/port in the homekit_controller config entry when it changes (#52554) --- .../homekit_controller/config_flow.py | 14 ++++- .../homekit_controller/test_config_flow.py | 57 +++++++++++++++++-- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index ebb14e43378..e8357a4001d 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -236,9 +236,20 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): ) config_num = None + # Set unique-id and error out if it's already configured + existing_entry = await self.async_set_unique_id(normalize_hkid(hkid)) + updated_ip_port = { + "AccessoryIP": discovery_info["host"], + "AccessoryPort": discovery_info["port"], + } + # If the device is already paired and known to us we should monitor c# # (config_num) for changes. If it changes, we check for new entities if paired and hkid in self.hass.data.get(KNOWN_DEVICES, {}): + if existing_entry: + self.hass.config_entries.async_update_entry( + existing_entry, data={**existing_entry.data, **updated_ip_port} + ) conn = self.hass.data[KNOWN_DEVICES][hkid] # When we rediscover the device, let aiohomekit know # that the device is available and we should not wait @@ -262,8 +273,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): 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() + self._abort_if_unique_id_configured(updates=updated_ip_port) self.context["hkid"] = hkid diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 99c6966e827..52685334500 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for homekit_controller config flow.""" from unittest import mock import unittest.mock -from unittest.mock import patch +from unittest.mock import AsyncMock, patch import aiohomekit from aiohomekit.model import Accessories, Accessory @@ -11,6 +11,7 @@ import pytest from homeassistant import config_entries from homeassistant.components.homekit_controller import config_flow +from homeassistant.components.homekit_controller.const import KNOWN_DEVICES from homeassistant.helpers import device_registry from tests.common import MockConfigEntry, mock_device_registry @@ -383,11 +384,16 @@ async def test_discovery_invalid_config_entry(hass, controller): async def test_discovery_already_configured(hass, controller): """Already configured.""" - MockConfigEntry( + entry = MockConfigEntry( domain="homekit_controller", - data={"AccessoryPairingID": "00:00:00:00:00:00"}, + data={ + "AccessoryIP": "4.4.4.4", + "AccessoryPort": 66, + "AccessoryPairingID": "00:00:00:00:00:00", + }, unique_id="00:00:00:00:00:00", - ).add_to_hass(hass) + ) + entry.add_to_hass(hass) device = setup_mock_accessory(controller) discovery_info = get_device_discovery_info(device) @@ -403,6 +409,49 @@ async def test_discovery_already_configured(hass, controller): ) assert result["type"] == "abort" assert result["reason"] == "already_configured" + assert entry.data["AccessoryIP"] == discovery_info["host"] + assert entry.data["AccessoryPort"] == discovery_info["port"] + + +async def test_discovery_already_configured_update_csharp(hass, controller): + """Already configured and csharp changes.""" + entry = MockConfigEntry( + domain="homekit_controller", + data={ + "AccessoryIP": "4.4.4.4", + "AccessoryPort": 66, + "AccessoryPairingID": "AA:BB:CC:DD:EE:FF", + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + connection_mock = AsyncMock() + connection_mock.pairing.connect.reconnect_soon = AsyncMock() + connection_mock.async_refresh_entity_map = AsyncMock() + hass.data[KNOWN_DEVICES] = {"AA:BB:CC:DD:EE:FF": connection_mock} + + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) + + # Set device as already paired + discovery_info["properties"]["sf"] = 0x00 + discovery_info["properties"]["c#"] = 99999 + discovery_info["properties"]["id"] = "AA:BB:CC:DD:EE:FF" + + # Device is discovered + result = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + await hass.async_block_till_done() + + assert entry.data["AccessoryIP"] == discovery_info["host"] + assert entry.data["AccessoryPort"] == discovery_info["port"] + assert connection_mock.async_refresh_entity_map.await_count == 1 @pytest.mark.parametrize("exception,expected", PAIRING_START_ABORT_ERRORS) From 1c9053fef6a635a928e779dc6943d7e08ff0967a Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Mon, 5 Jul 2021 14:00:32 -0400 Subject: [PATCH 040/134] Bump up zha dependencies (#52555) --- 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 b366b73d6c8..68feabb18b4 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -10,7 +10,7 @@ "zha-quirks==0.0.58", "zigpy-cc==0.5.2", "zigpy-deconz==0.12.0", - "zigpy==0.35.0", + "zigpy==0.35.1", "zigpy-xbee==0.13.0", "zigpy-zigate==0.7.3", "zigpy-znp==0.5.1" diff --git a/requirements_all.txt b/requirements_all.txt index a0a812f9e19..064e7b3bc7f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2455,7 +2455,7 @@ zigpy-zigate==0.7.3 zigpy-znp==0.5.1 # homeassistant.components.zha -zigpy==0.35.0 +zigpy==0.35.1 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c6e58d1f2f..990a0e24a8a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1352,7 +1352,7 @@ zigpy-zigate==0.7.3 zigpy-znp==0.5.1 # homeassistant.components.zha -zigpy==0.35.0 +zigpy==0.35.1 # homeassistant.components.zwave_js zwave-js-server-python==0.27.0 From 701fa06584f70f5c9a18dfef11905c4dfeccdf7c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 5 Jul 2021 15:00:57 -0500 Subject: [PATCH 041/134] Bump aiohomekit to 0.4.2 (#52560) - Changelog: https://github.com/Jc2k/aiohomekit/compare/0.4.1...0.4.2 - Fixes: #52548 --- 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 39144d6c521..496d629d112 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.4.1"], + "requirements": ["aiohomekit==0.4.2"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/requirements_all.txt b/requirements_all.txt index 064e7b3bc7f..57c7e6ce466 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -175,7 +175,7 @@ aioguardian==1.0.4 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.4.1 +aiohomekit==0.4.2 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 990a0e24a8a..90a700867ca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -112,7 +112,7 @@ aioguardian==1.0.4 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.4.1 +aiohomekit==0.4.2 # homeassistant.components.emulated_hue # homeassistant.components.http From 979d37dc199e5640f7e306076abc2541ab183a72 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 6 Jul 2021 09:33:00 +0200 Subject: [PATCH 042/134] Fix unavailable entity capable of triggering non-numerical warning in Threshold sensor (#52563) --- .../components/threshold/binary_sensor.py | 5 ++++- .../threshold/test_binary_sensor.py | 19 +++++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 5bd6f77253b..1a53a599394 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -13,6 +13,7 @@ from homeassistant.const import ( CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME, + STATE_UNAVAILABLE, STATE_UNKNOWN, ) from homeassistant.core import callback @@ -100,7 +101,9 @@ class ThresholdSensor(BinarySensorEntity): try: self.sensor_value = ( - None if new_state.state == STATE_UNKNOWN else float(new_state.state) + None + if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] + else float(new_state.state) ) except (ValueError, TypeError): self.sensor_value = None diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py index af8c32a1549..b7c4a871068 100644 --- a/tests/components/threshold/test_binary_sensor.py +++ b/tests/components/threshold/test_binary_sensor.py @@ -1,6 +1,11 @@ """The test for the threshold sensor platform.""" -from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS +from homeassistant.const import ( + ATTR_UNIT_OF_MEASUREMENT, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + TEMP_CELSIUS, +) from homeassistant.setup import async_setup_component @@ -283,7 +288,7 @@ async def test_sensor_in_range_with_hysteresis(hass): assert state.state == "on" -async def test_sensor_in_range_unknown_state(hass): +async def test_sensor_in_range_unknown_state(hass, caplog): """Test if source is within the range.""" config = { "binary_sensor": { @@ -322,6 +327,16 @@ async def test_sensor_in_range_unknown_state(hass): assert state.attributes.get("position") == "unknown" assert state.state == "off" + hass.states.async_set("sensor.test_monitored", STATE_UNAVAILABLE) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.threshold") + + assert state.attributes.get("position") == "unknown" + assert state.state == "off" + + assert "State is not numerical" not in caplog.text + async def test_sensor_lower_zero_threshold(hass): """Test if a lower threshold of zero is set.""" From 2220c8cd3f3ecb649cb1bd9d52a622a24df24782 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 6 Jul 2021 02:41:23 -0400 Subject: [PATCH 043/134] Bump pyeight version to 0.1.9 (#52568) --- 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 fb3762cf738..1c3944a985e 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.8"], + "requirements": ["pyeight==0.1.9"], "codeowners": ["@mezz64"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 57c7e6ce466..c056c39409a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1409,7 +1409,7 @@ pyeconet==0.1.14 pyedimax==0.2.1 # homeassistant.components.eight_sleep -pyeight==0.1.8 +pyeight==0.1.9 # homeassistant.components.emby pyemby==1.7 From 2356c1e52ac27cbe8d1b761d3e8c0d1bded49c3b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 6 Jul 2021 11:52:53 +0200 Subject: [PATCH 044/134] Update frontend to 20210706.0 (#52577) --- 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 42ade2d4ae9..c7283a9503a 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==20210630.0" + "home-assistant-frontend==20210706.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 98ce2b67183..c35ff252b52 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ defusedxml==0.7.1 distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.44.0 -home-assistant-frontend==20210630.0 +home-assistant-frontend==20210706.0 httpx==0.18.0 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index c056c39409a..fea5faf972a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -780,7 +780,7 @@ hole==0.5.1 holidays==0.11.1 # homeassistant.components.frontend -home-assistant-frontend==20210630.0 +home-assistant-frontend==20210706.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 90a700867ca..08a325cc154 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -447,7 +447,7 @@ hole==0.5.1 holidays==0.11.1 # homeassistant.components.frontend -home-assistant-frontend==20210630.0 +home-assistant-frontend==20210706.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From 422de2c56dcbae54c0d39da4bcc04b7fdede2703 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 6 Jul 2021 11:57:50 +0200 Subject: [PATCH 045/134] Bumped version to 2021.7.0b4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5a58a3e2789..1726cb6f48a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b3" +PATCH_VERSION: Final = "0b4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 631e555e2550462de230a9b96a986253028454c7 Mon Sep 17 00:00:00 2001 From: Thibaut Date: Tue, 6 Jul 2021 18:48:48 +0200 Subject: [PATCH 046/134] Update Somfy to reduce calls to /site entrypoint (#51572) Co-authored-by: Franck Nijhof --- homeassistant/components/somfy/__init__.py | 95 +------------------ homeassistant/components/somfy/climate.py | 13 ++- homeassistant/components/somfy/const.py | 1 - homeassistant/components/somfy/coordinator.py | 71 ++++++++++++++ homeassistant/components/somfy/cover.py | 13 ++- homeassistant/components/somfy/entity.py | 73 ++++++++++++++ homeassistant/components/somfy/manifest.json | 2 +- homeassistant/components/somfy/sensor.py | 13 ++- homeassistant/components/somfy/switch.py | 13 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/somfy/test_config_flow.py | 11 +-- 12 files changed, 180 insertions(+), 129 deletions(-) create mode 100644 homeassistant/components/somfy/coordinator.py create mode 100644 homeassistant/components/somfy/entity.py diff --git a/homeassistant/components/somfy/__init__.py b/homeassistant/components/somfy/__init__.py index cc7499c3492..71d7f7f790c 100644 --- a/homeassistant/components/somfy/__init__.py +++ b/homeassistant/components/somfy/__init__.py @@ -1,5 +1,4 @@ """Support for Somfy hubs.""" -from abc import abstractmethod from datetime import timedelta import logging @@ -8,20 +7,16 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_OPTIMISTIC -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import ( config_entry_oauth2_flow, config_validation as cv, device_registry as dr, ) -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) from . import api, config_flow -from .const import API, COORDINATOR, DOMAIN +from .const import COORDINATOR, DOMAIN +from .coordinator import SomfyDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -84,25 +79,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) data = hass.data[DOMAIN] - data[API] = api.ConfigEntrySomfyApi(hass, entry, implementation) - - async def _update_all_devices(): - """Update all the devices.""" - devices = await hass.async_add_executor_job(data[API].get_devices) - previous_devices = data[COORDINATOR].data - # Sometimes Somfy returns an empty list. - if not devices and previous_devices: - _LOGGER.debug( - "No devices returned. Assuming the previous ones are still valid" - ) - return previous_devices - return {dev.id: dev for dev in devices} - - coordinator = DataUpdateCoordinator( + coordinator = SomfyDataUpdateCoordinator( hass, _LOGGER, name="somfy device update", - update_method=_update_all_devices, + client=api.ConfigEntrySomfyApi(hass, entry, implementation), update_interval=SCAN_INTERVAL, ) data[COORDINATOR] = coordinator @@ -140,70 +121,4 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): """Unload a config entry.""" - hass.data[DOMAIN].pop(API, None) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class SomfyEntity(CoordinatorEntity, Entity): - """Representation of a generic Somfy device.""" - - def __init__(self, coordinator, device_id, somfy_api): - """Initialize the Somfy device.""" - super().__init__(coordinator) - self._id = device_id - self.api = somfy_api - - @property - def device(self): - """Return data for the device id.""" - return self.coordinator.data[self._id] - - @property - def unique_id(self) -> str: - """Return the unique id base on the id returned by Somfy.""" - return self._id - - @property - def name(self) -> str: - """Return the name of the device.""" - return self.device.name - - @property - def device_info(self): - """Return device specific attributes. - - Implemented by platform classes. - """ - return { - "identifiers": {(DOMAIN, self.unique_id)}, - "name": self.name, - "model": self.device.type, - "via_device": (DOMAIN, self.device.parent_id), - # For the moment, Somfy only returns their own device. - "manufacturer": "Somfy", - } - - def has_capability(self, capability: str) -> bool: - """Test if device has a capability.""" - capabilities = self.device.capabilities - return bool([c for c in capabilities if c.name == capability]) - - def has_state(self, state: str) -> bool: - """Test if device has a state.""" - states = self.device.states - return bool([c for c in states if c.name == state]) - - @property - def assumed_state(self) -> bool: - """Return if the device has an assumed state.""" - return not bool(self.device.states) - - @callback - def _handle_coordinator_update(self): - """Process an update from the coordinator.""" - self._create_device() - super()._handle_coordinator_update() - - @abstractmethod - def _create_device(self): - """Update the device with the latest data.""" diff --git a/homeassistant/components/somfy/climate.py b/homeassistant/components/somfy/climate.py index 66602aea3e6..0963321100c 100644 --- a/homeassistant/components/somfy/climate.py +++ b/homeassistant/components/somfy/climate.py @@ -23,8 +23,8 @@ from homeassistant.components.climate.const import ( ) from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS -from . import SomfyEntity -from .const import API, COORDINATOR, DOMAIN +from .const import COORDINATOR, DOMAIN +from .entity import SomfyEntity SUPPORTED_CATEGORIES = {Category.HVAC.value} @@ -49,10 +49,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Somfy climate platform.""" domain_data = hass.data[DOMAIN] coordinator = domain_data[COORDINATOR] - api = domain_data[API] climates = [ - SomfyClimate(coordinator, device_id, api) + SomfyClimate(coordinator, device_id) for device_id, device in coordinator.data.items() if SUPPORTED_CATEGORIES & set(device.categories) ] @@ -63,15 +62,15 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class SomfyClimate(SomfyEntity, ClimateEntity): """Representation of a Somfy thermostat device.""" - def __init__(self, coordinator, device_id, api): + def __init__(self, coordinator, device_id): """Initialize the Somfy device.""" - super().__init__(coordinator, device_id, api) + super().__init__(coordinator, device_id) self._climate = None self._create_device() def _create_device(self): """Update the device with the latest data.""" - self._climate = Thermostat(self.device, self.api) + self._climate = Thermostat(self.device, self.coordinator.client) @property def supported_features(self) -> int: diff --git a/homeassistant/components/somfy/const.py b/homeassistant/components/somfy/const.py index 128d6eb76bb..6c7c23e3ab3 100644 --- a/homeassistant/components/somfy/const.py +++ b/homeassistant/components/somfy/const.py @@ -2,4 +2,3 @@ DOMAIN = "somfy" COORDINATOR = "coordinator" -API = "api" diff --git a/homeassistant/components/somfy/coordinator.py b/homeassistant/components/somfy/coordinator.py new file mode 100644 index 00000000000..c9633c4fa4d --- /dev/null +++ b/homeassistant/components/somfy/coordinator.py @@ -0,0 +1,71 @@ +"""Helpers to help coordinate updated.""" +from __future__ import annotations + +from datetime import timedelta +import logging + +from pymfy.api.error import QuotaViolationException, SetupNotFoundException +from pymfy.api.model import Device +from pymfy.api.somfy_api import SomfyApi + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + + +class SomfyDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Somfy data.""" + + def __init__( + self, + hass: HomeAssistant, + logger: logging.Logger, + *, + name: str, + client: SomfyApi, + update_interval: timedelta | None = None, + ) -> None: + """Initialize global data updater.""" + super().__init__( + hass, + logger, + name=name, + update_interval=update_interval, + ) + self.data = {} + self.client = client + self.site_device = {} + self.last_site_index = -1 + + async def _async_update_data(self) -> dict[str, Device]: + """Fetch Somfy data. + + Somfy only allow one call per minute to /site. There is one exception: 2 calls are allowed after site retrieval. + """ + if not self.site_device: + sites = await self.hass.async_add_executor_job(self.client.get_sites) + if not sites: + return {} + self.site_device = {site.id: [] for site in sites} + + site_id = self._site_id + try: + devices = await self.hass.async_add_executor_job( + self.client.get_devices, site_id + ) + self.site_device[site_id] = devices + except SetupNotFoundException: + del self.site_device[site_id] + return await self._async_update_data() + except QuotaViolationException: + self.logger.warning("Quota violation") + + return {dev.id: dev for devices in self.site_device.values() for dev in devices} + + @property + def _site_id(self): + """Return the next site id to retrieve. + + This tweak is required as Somfy does not allow to call the /site entrypoint more than once per minute. + """ + self.last_site_index = (self.last_site_index + 1) % len(self.site_device) + return list(self.site_device.keys())[self.last_site_index] diff --git a/homeassistant/components/somfy/cover.py b/homeassistant/components/somfy/cover.py index d227bc31227..8ed06b3bcd7 100644 --- a/homeassistant/components/somfy/cover.py +++ b/homeassistant/components/somfy/cover.py @@ -21,8 +21,8 @@ from homeassistant.components.cover import ( from homeassistant.const import CONF_OPTIMISTIC, STATE_CLOSED, STATE_OPEN from homeassistant.helpers.restore_state import RestoreEntity -from . import SomfyEntity -from .const import API, COORDINATOR, DOMAIN +from .const import COORDINATOR, DOMAIN +from .entity import SomfyEntity BLIND_DEVICE_CATEGORIES = {Category.INTERIOR_BLIND.value, Category.EXTERIOR_BLIND.value} SHUTTER_DEVICE_CATEGORIES = {Category.EXTERIOR_BLIND.value} @@ -37,10 +37,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Somfy cover platform.""" domain_data = hass.data[DOMAIN] coordinator = domain_data[COORDINATOR] - api = domain_data[API] covers = [ - SomfyCover(coordinator, device_id, api, domain_data[CONF_OPTIMISTIC]) + SomfyCover(coordinator, device_id, domain_data[CONF_OPTIMISTIC]) for device_id, device in coordinator.data.items() if SUPPORTED_CATEGORIES & set(device.categories) ] @@ -51,9 +50,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class SomfyCover(SomfyEntity, RestoreEntity, CoverEntity): """Representation of a Somfy cover device.""" - def __init__(self, coordinator, device_id, api, optimistic): + def __init__(self, coordinator, device_id, optimistic): """Initialize the Somfy device.""" - super().__init__(coordinator, device_id, api) + super().__init__(coordinator, device_id) self.categories = set(self.device.categories) self.optimistic = optimistic self._closed = None @@ -64,7 +63,7 @@ class SomfyCover(SomfyEntity, RestoreEntity, CoverEntity): def _create_device(self) -> Blind: """Update the device with the latest data.""" - self._cover = Blind(self.device, self.api) + self._cover = Blind(self.device, self.coordinator.client) @property def supported_features(self) -> int: diff --git a/homeassistant/components/somfy/entity.py b/homeassistant/components/somfy/entity.py new file mode 100644 index 00000000000..88ff86e8849 --- /dev/null +++ b/homeassistant/components/somfy/entity.py @@ -0,0 +1,73 @@ +"""Entity representing a Somfy device.""" + +from abc import abstractmethod + +from homeassistant.core import callback +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN + + +class SomfyEntity(CoordinatorEntity, Entity): + """Representation of a generic Somfy device.""" + + def __init__(self, coordinator, device_id): + """Initialize the Somfy device.""" + super().__init__(coordinator) + self._id = device_id + + @property + def device(self): + """Return data for the device id.""" + return self.coordinator.data[self._id] + + @property + def unique_id(self) -> str: + """Return the unique id base on the id returned by Somfy.""" + return self._id + + @property + def name(self) -> str: + """Return the name of the device.""" + return self.device.name + + @property + def device_info(self): + """Return device specific attributes. + + Implemented by platform classes. + """ + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "model": self.device.type, + "via_device": (DOMAIN, self.device.parent_id), + # For the moment, Somfy only returns their own device. + "manufacturer": "Somfy", + } + + def has_capability(self, capability: str) -> bool: + """Test if device has a capability.""" + capabilities = self.device.capabilities + return bool([c for c in capabilities if c.name == capability]) + + def has_state(self, state: str) -> bool: + """Test if device has a state.""" + states = self.device.states + return bool([c for c in states if c.name == state]) + + @property + def assumed_state(self) -> bool: + """Return if the device has an assumed state.""" + return not bool(self.device.states) + + @callback + def _handle_coordinator_update(self): + """Process an update from the coordinator.""" + self._create_device() + super()._handle_coordinator_update() + + @abstractmethod + def _create_device(self): + """Update the device with the latest data.""" diff --git a/homeassistant/components/somfy/manifest.json b/homeassistant/components/somfy/manifest.json index 8dad4abd6cc..1adbab49fb2 100644 --- a/homeassistant/components/somfy/manifest.json +++ b/homeassistant/components/somfy/manifest.json @@ -5,7 +5,7 @@ "documentation": "https://www.home-assistant.io/integrations/somfy", "dependencies": ["http"], "codeowners": ["@tetienne"], - "requirements": ["pymfy==0.9.3"], + "requirements": ["pymfy==0.11.0"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/somfy/sensor.py b/homeassistant/components/somfy/sensor.py index 312c425cf87..1817ba3fd8c 100644 --- a/homeassistant/components/somfy/sensor.py +++ b/homeassistant/components/somfy/sensor.py @@ -6,8 +6,8 @@ from pymfy.api.devices.thermostat import Thermostat from homeassistant.components.sensor import SensorEntity from homeassistant.const import DEVICE_CLASS_BATTERY, PERCENTAGE -from . import SomfyEntity -from .const import API, COORDINATOR, DOMAIN +from .const import COORDINATOR, DOMAIN +from .entity import SomfyEntity SUPPORTED_CATEGORIES = {Category.HVAC.value} @@ -16,10 +16,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Somfy sensor platform.""" domain_data = hass.data[DOMAIN] coordinator = domain_data[COORDINATOR] - api = domain_data[API] sensors = [ - SomfyThermostatBatterySensor(coordinator, device_id, api) + SomfyThermostatBatterySensor(coordinator, device_id) for device_id, device in coordinator.data.items() if SUPPORTED_CATEGORIES & set(device.categories) ] @@ -33,15 +32,15 @@ class SomfyThermostatBatterySensor(SomfyEntity, SensorEntity): _attr_device_class = DEVICE_CLASS_BATTERY _attr_unit_of_measurement = PERCENTAGE - def __init__(self, coordinator, device_id, api): + def __init__(self, coordinator, device_id): """Initialize the Somfy device.""" - super().__init__(coordinator, device_id, api) + super().__init__(coordinator, device_id) self._climate = None self._create_device() def _create_device(self): """Update the device with the latest data.""" - self._climate = Thermostat(self.device, self.api) + self._climate = Thermostat(self.device, self.coordinator.client) @property def state(self) -> int: diff --git a/homeassistant/components/somfy/switch.py b/homeassistant/components/somfy/switch.py index 66eef99d6b5..bd0b1dce5d5 100644 --- a/homeassistant/components/somfy/switch.py +++ b/homeassistant/components/somfy/switch.py @@ -4,18 +4,17 @@ from pymfy.api.devices.category import Category from homeassistant.components.switch import SwitchEntity -from . import SomfyEntity -from .const import API, COORDINATOR, DOMAIN +from .const import COORDINATOR, DOMAIN +from .entity import SomfyEntity async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the Somfy switch platform.""" domain_data = hass.data[DOMAIN] coordinator = domain_data[COORDINATOR] - api = domain_data[API] switches = [ - SomfyCameraShutter(coordinator, device_id, api) + SomfyCameraShutter(coordinator, device_id) for device_id, device in coordinator.data.items() if Category.CAMERA.value in device.categories ] @@ -26,14 +25,14 @@ async def async_setup_entry(hass, config_entry, async_add_entities): class SomfyCameraShutter(SomfyEntity, SwitchEntity): """Representation of a Somfy Camera Shutter device.""" - def __init__(self, coordinator, device_id, api): + def __init__(self, coordinator, device_id): """Initialize the Somfy device.""" - super().__init__(coordinator, device_id, api) + super().__init__(coordinator, device_id) self._create_device() def _create_device(self): """Update the device with the latest data.""" - self.shutter = CameraProtect(self.device, self.api) + self.shutter = CameraProtect(self.device, self.coordinator.client) def turn_on(self, **kwargs) -> None: """Turn the entity on.""" diff --git a/requirements_all.txt b/requirements_all.txt index fea5faf972a..c494ca91096 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1590,7 +1590,7 @@ pymelcloud==2.5.3 pymeteoclimatic==0.0.6 # homeassistant.components.somfy -pymfy==0.9.3 +pymfy==0.11.0 # homeassistant.components.xiaomi_tv pymitv==1.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 08a325cc154..eebd4983754 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -904,7 +904,7 @@ pymelcloud==2.5.3 pymeteoclimatic==0.0.6 # homeassistant.components.somfy -pymfy==0.9.3 +pymfy==0.11.0 # homeassistant.components.mochad pymochad==0.2.0 diff --git a/tests/components/somfy/test_config_flow.py b/tests/components/somfy/test_config_flow.py index b7d78883706..6a1c32e4138 100644 --- a/tests/components/somfy/test_config_flow.py +++ b/tests/components/somfy/test_config_flow.py @@ -82,7 +82,9 @@ async def test_full_flow( }, ) - with patch("homeassistant.components.somfy.api.ConfigEntrySomfyApi"): + with patch( + "homeassistant.components.somfy.async_setup_entry", return_value=True + ) as mock_setup_entry: result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result["data"]["auth_implementation"] == DOMAIN @@ -95,12 +97,7 @@ async def test_full_flow( "expires_in": 60, } - assert DOMAIN in hass.config.components - entry = hass.config_entries.async_entries(DOMAIN)[0] - assert entry.state is config_entries.ConfigEntryState.LOADED - - assert await hass.config_entries.async_unload(entry.entry_id) - assert entry.state is config_entries.ConfigEntryState.NOT_LOADED + assert len(mock_setup_entry.mock_calls) == 1 async def test_abort_if_authorization_timeout(hass, current_request_with_host): From e1c14b5a30ffab52ab2febf1918d011697432faf Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 6 Jul 2021 10:33:07 -0400 Subject: [PATCH 047/134] Don't raise when setting HVAC mode without a mode ZwaveValue (#52444) * Don't raise an error when setting HVAC mode without a value * change logic based on discord convo and add tests * tweak --- homeassistant/components/zwave_js/climate.py | 16 ++++++------- tests/components/zwave_js/test_climate.py | 24 ++++++++++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 4ef13276fbe..43363538500 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -469,15 +469,15 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): async def async_set_hvac_mode(self, hvac_mode: str) -> None: """Set new target hvac mode.""" - if not self._current_mode: - # Thermostat(valve) with no support for setting a mode - raise ValueError( - f"Thermostat {self.entity_id} does not support setting a mode" - ) - hvac_mode_value = self._hvac_modes.get(hvac_mode) - if hvac_mode_value is None: + hvac_mode_id = self._hvac_modes.get(hvac_mode) + if hvac_mode_id is None: raise ValueError(f"Received an invalid hvac mode: {hvac_mode}") - await self.info.node.async_set_value(self._current_mode, hvac_mode_value) + + if not self._current_mode: + # Thermostat(valve) has no support for setting a mode, so we make it a no-op + return + + await self.info.node.async_set_value(self._current_mode, hvac_mode_id) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new target preset mode.""" diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index f86052b3692..fefa680ce77 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -382,6 +382,30 @@ async def test_setpoint_thermostat(hass, client, climate_danfoss_lc_13, integrat blocking=True, ) + # Test setting illegal mode raises an error + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: CLIMATE_DANFOSS_LC13_ENTITY, + ATTR_HVAC_MODE: HVAC_MODE_COOL, + }, + blocking=True, + ) + + # Test that setting HVAC_MODE_HEAT works. If the no-op logic didn't work, this would + # raise an error + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + { + ATTR_ENTITY_ID: CLIMATE_DANFOSS_LC13_ENTITY, + ATTR_HVAC_MODE: HVAC_MODE_HEAT, + }, + blocking=True, + ) + assert len(client.async_send_command_no_wait.call_args_list) == 1 args = client.async_send_command_no_wait.call_args_list[0][0][0] assert args["command"] == "node.set_value" From 2c75e3fe99ed4b78ad80579d1d141299c98136c1 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 6 Jul 2021 19:34:56 +0300 Subject: [PATCH 048/134] Fix Sensibo timeout exceptions (#52513) --- homeassistant/components/sensibo/climate.py | 105 ++++++++++---------- 1 file changed, 55 insertions(+), 50 deletions(-) diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index 10ceaa39a38..d34ea040cdc 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -40,7 +40,7 @@ from .const import DOMAIN as SENSIBO_DOMAIN _LOGGER = logging.getLogger(__name__) ALL = ["all"] -TIMEOUT = 10 +TIMEOUT = 8 SERVICE_ASSUME_STATE = "assume_state" @@ -91,17 +91,18 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ) devices = [] try: - for dev in await client.async_get_devices(_INITIAL_FETCH_FIELDS): - if config[CONF_ID] == ALL or dev["id"] in config[CONF_ID]: - devices.append( - SensiboClimate(client, dev, hass.config.units.temperature_unit) - ) + with async_timeout.timeout(TIMEOUT): + for dev in await client.async_get_devices(_INITIAL_FETCH_FIELDS): + if config[CONF_ID] == ALL or dev["id"] in config[CONF_ID]: + devices.append( + SensiboClimate(client, dev, hass.config.units.temperature_unit) + ) except ( aiohttp.client_exceptions.ClientConnectorError, asyncio.TimeoutError, pysensibo.SensiboError, ) as err: - _LOGGER.exception("Failed to connect to Sensibo servers") + _LOGGER.error("Failed to get devices from Sensibo servers") raise PlatformNotReady from err if not devices: @@ -150,6 +151,7 @@ class SensiboClimate(ClimateEntity): self._units = units self._available = False self._do_update(data) + self._failed_update = False @property def supported_features(self): @@ -316,59 +318,35 @@ class SensiboClimate(ClimateEntity): else: return - with async_timeout.timeout(TIMEOUT): - await self._client.async_set_ac_state_property( - self._id, "targetTemperature", temperature, self._ac_states - ) + await self._async_set_ac_state_property("targetTemperature", temperature) async def async_set_fan_mode(self, fan_mode): """Set new target fan mode.""" - with async_timeout.timeout(TIMEOUT): - await self._client.async_set_ac_state_property( - self._id, "fanLevel", fan_mode, self._ac_states - ) + await self._async_set_ac_state_property("fanLevel", fan_mode) async def async_set_hvac_mode(self, hvac_mode): """Set new target operation mode.""" if hvac_mode == HVAC_MODE_OFF: - with async_timeout.timeout(TIMEOUT): - await self._client.async_set_ac_state_property( - self._id, "on", False, self._ac_states - ) + await self._async_set_ac_state_property("on", False) return # Turn on if not currently on. if not self._ac_states["on"]: - with async_timeout.timeout(TIMEOUT): - await self._client.async_set_ac_state_property( - self._id, "on", True, self._ac_states - ) + await self._async_set_ac_state_property("on", True) - with async_timeout.timeout(TIMEOUT): - await self._client.async_set_ac_state_property( - self._id, "mode", HA_TO_SENSIBO[hvac_mode], self._ac_states - ) + await self._async_set_ac_state_property("mode", HA_TO_SENSIBO[hvac_mode]) async def async_set_swing_mode(self, swing_mode): """Set new target swing operation.""" - with async_timeout.timeout(TIMEOUT): - await self._client.async_set_ac_state_property( - self._id, "swing", swing_mode, self._ac_states - ) + await self._async_set_ac_state_property("swing", swing_mode) async def async_turn_on(self): """Turn Sensibo unit on.""" - with async_timeout.timeout(TIMEOUT): - await self._client.async_set_ac_state_property( - self._id, "on", True, self._ac_states - ) + await self._async_set_ac_state_property("on", True) async def async_turn_off(self): """Turn Sensibo unit on.""" - with async_timeout.timeout(TIMEOUT): - await self._client.async_set_ac_state_property( - self._id, "on", False, self._ac_states - ) + await self._async_set_ac_state_property("on", False) async def async_assume_state(self, state): """Set external state.""" @@ -377,14 +355,7 @@ class SensiboClimate(ClimateEntity): ) if change_needed: - with async_timeout.timeout(TIMEOUT): - await self._client.async_set_ac_state_property( - self._id, - "on", - state != HVAC_MODE_OFF, # value - self._ac_states, - True, # assumed_state - ) + await self._async_set_ac_state_property("on", state != HVAC_MODE_OFF, True) if state in [STATE_ON, HVAC_MODE_OFF]: self._external_state = None @@ -396,7 +367,41 @@ class SensiboClimate(ClimateEntity): try: with async_timeout.timeout(TIMEOUT): data = await self._client.async_get_device(self._id, _FETCH_FIELDS) - self._do_update(data) - except (aiohttp.client_exceptions.ClientError, pysensibo.SensiboError): - _LOGGER.warning("Failed to connect to Sensibo servers") + except ( + aiohttp.client_exceptions.ClientError, + asyncio.TimeoutError, + pysensibo.SensiboError, + ): + if self._failed_update: + _LOGGER.warning( + "Failed to update data for device '%s' from Sensibo servers", + self.name, + ) + self._available = False + self.async_write_ha_state() + return + + _LOGGER.debug("First failed update data for device '%s'", self.name) + self._failed_update = True + return + + self._failed_update = False + self._do_update(data) + + async def _async_set_ac_state_property(self, name, value, assumed_state=False): + """Set AC state.""" + try: + with async_timeout.timeout(TIMEOUT): + await self._client.async_set_ac_state_property( + self._id, name, value, self._ac_states, assumed_state + ) + except ( + aiohttp.client_exceptions.ClientError, + asyncio.TimeoutError, + pysensibo.SensiboError, + ) as err: self._available = False + self.async_write_ha_state() + raise Exception( + f"Failed to set AC state for device {self.name} to Sensibo servers" + ) from err From 90f4b3a4ed5ba09af97ca970bbaeb71fc7b00dfc Mon Sep 17 00:00:00 2001 From: ondras12345 Date: Tue, 6 Jul 2021 16:03:54 +0200 Subject: [PATCH 049/134] Fix update of Xiaomi Miio vacuum taking too long (#52539) Home assistant log would get spammed with messages like Update of vacuum.vacuum_name is taking over 10 seconds every 20 seconds if the vacuum was not reachable through the network. See #52353 --- homeassistant/components/xiaomi_miio/vacuum.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py index d0bfc148594..cdd53e784b3 100644 --- a/homeassistant/components/xiaomi_miio/vacuum.py +++ b/homeassistant/components/xiaomi_miio/vacuum.py @@ -507,7 +507,11 @@ class MiroboVacuum(XiaomiMiioEntity, StateVacuumEntity): # Fetch timers separately, see #38285 try: - self._timers = self._device.timer() + # Do not try this if the first fetch timed out. + # Two timeouts take longer than 10 seconds and trigger a warning. + # See #52353 + if self._available: + self._timers = self._device.timer() except DeviceException as exc: _LOGGER.debug( "Unable to fetch timers, this may happen on some devices: %s", exc From 746a52bb27ca42d386aff6ee498134f1387a60ba Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Tue, 6 Jul 2021 11:21:25 -0500 Subject: [PATCH 050/134] Fresh attempt at SimpliSafe auto-relogin (#52567) * Fresh attempt at SimpliSafe auto-relogin * Fix tests --- .../components/simplisafe/__init__.py | 62 +++++-------------- .../components/simplisafe/config_flow.py | 17 ++--- .../components/simplisafe/manifest.json | 2 +- .../components/simplisafe/strings.json | 2 +- .../simplisafe/translations/en.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../components/simplisafe/test_config_flow.py | 43 ++++++------- 8 files changed, 52 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 983629743e7..01e31633a1a 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -2,11 +2,11 @@ import asyncio from uuid import UUID -from simplipy import API +from simplipy import get_api from simplipy.errors import EndpointUnavailable, InvalidCredentialsError, SimplipyError import voluptuous as vol -from homeassistant.const import ATTR_CODE, CONF_CODE, CONF_TOKEN, CONF_USERNAME +from homeassistant.const import ATTR_CODE, CONF_CODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import CoreState, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers import ( @@ -107,14 +107,6 @@ SERVICE_SET_SYSTEM_PROPERTIES_SCHEMA = SERVICE_BASE_SCHEMA.extend( CONFIG_SCHEMA = cv.deprecated(DOMAIN) -@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} - ) - - async def async_get_client_id(hass): """Get a client ID (based on the HASS unique ID) for the SimpliSafe API. @@ -142,6 +134,9 @@ async def async_setup_entry(hass, config_entry): # noqa: C901 hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = [] hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = [] + if CONF_PASSWORD not in config_entry.data: + raise ConfigEntryAuthFailed("Config schema change requires re-authentication") + entry_updates = {} if not config_entry.unique_id: # If the config entry doesn't already have a unique ID, set one: @@ -164,19 +159,19 @@ async def async_setup_entry(hass, config_entry): # noqa: C901 websession = aiohttp_client.async_get_clientsession(hass) try: - api = await API.login_via_token( - config_entry.data[CONF_TOKEN], client_id=client_id, session=websession + api = await get_api( + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + client_id=client_id, + session=websession, ) - except InvalidCredentialsError: - LOGGER.error("Invalid credentials provided") - return False + except InvalidCredentialsError as err: + raise ConfigEntryAuthFailed from err except SimplipyError as err: LOGGER.error("Config entry failed: %s", err) raise ConfigEntryNotReady from err - _async_save_refresh_token(hass, config_entry, api.refresh_token) - - simplisafe = SimpliSafe(hass, api, config_entry) + simplisafe = SimpliSafe(hass, config_entry, api) try: await simplisafe.async_init() @@ -303,10 +298,9 @@ async def async_reload_entry(hass, config_entry): class SimpliSafe: """Define a SimpliSafe data object.""" - def __init__(self, hass, api, config_entry): + def __init__(self, hass, config_entry, api): """Initialize.""" self._api = api - self._emergency_refresh_token_used = False self._hass = hass self._system_notifications = {} self.config_entry = config_entry @@ -383,23 +377,7 @@ class SimpliSafe: for result in results: if isinstance(result, InvalidCredentialsError): - if self._emergency_refresh_token_used: - raise ConfigEntryAuthFailed( - "Update failed with stored refresh token" - ) - - LOGGER.warning("SimpliSafe cloud error; trying stored refresh token") - self._emergency_refresh_token_used = True - - try: - await self._api.refresh_access_token( - self.config_entry.data[CONF_TOKEN] - ) - return - except SimplipyError as err: - raise UpdateFailed( # pylint: disable=raise-missing-from - f"Error while using stored refresh token: {err}" - ) + raise ConfigEntryAuthFailed("Invalid credentials") from result if isinstance(result, EndpointUnavailable): # In case the user attempts an action not allowed in their current plan, @@ -410,16 +388,6 @@ class SimpliSafe: if isinstance(result, SimplipyError): raise UpdateFailed(f"SimpliSafe error while updating: {result}") - if self._api.refresh_token != self.config_entry.data[CONF_TOKEN]: - _async_save_refresh_token( - self._hass, self.config_entry, self._api.refresh_token - ) - - # If we've reached this point using an emergency refresh token, we're in the - # clear and we can discard it: - if self._emergency_refresh_token_used: - self._emergency_refresh_token_used = False - class SimpliSafeEntity(CoordinatorEntity): """Define a base SimpliSafe entity.""" diff --git a/homeassistant/components/simplisafe/config_flow.py b/homeassistant/components/simplisafe/config_flow.py index ba51356f770..ac31779175f 100644 --- a/homeassistant/components/simplisafe/config_flow.py +++ b/homeassistant/components/simplisafe/config_flow.py @@ -1,5 +1,5 @@ """Config flow to configure the SimpliSafe component.""" -from simplipy import API +from simplipy import get_api from simplipy.errors import ( InvalidCredentialsError, PendingAuthorizationError, @@ -8,7 +8,7 @@ from simplipy.errors import ( import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import callback from homeassistant.helpers import aiohttp_client @@ -47,7 +47,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): client_id = await async_get_client_id(self.hass) websession = aiohttp_client.async_get_clientsession(self.hass) - return await API.login_via_credentials( + return await get_api( self._username, self._password, client_id=client_id, @@ -59,7 +59,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors = {} try: - simplisafe = await self._async_get_simplisafe_api() + await self._async_get_simplisafe_api() except PendingAuthorizationError: LOGGER.info("Awaiting confirmation of MFA email click") return await self.async_step_mfa() @@ -79,7 +79,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_finish( { CONF_USERNAME: self._username, - CONF_TOKEN: simplisafe.refresh_token, + CONF_PASSWORD: self._password, CONF_CODE: self._code, } ) @@ -89,6 +89,9 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): existing_entry = await self.async_set_unique_id(self._username) if existing_entry: self.hass.config_entries.async_update_entry(existing_entry, data=user_input) + self.hass.async_create_task( + self.hass.config_entries.async_reload(existing_entry.entry_id) + ) return self.async_abort(reason="reauth_successful") return self.async_create_entry(title=self._username, data=user_input) @@ -98,7 +101,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_show_form(step_id="mfa") try: - simplisafe = await self._async_get_simplisafe_api() + await self._async_get_simplisafe_api() except PendingAuthorizationError: LOGGER.error("Still awaiting confirmation of MFA email click") return self.async_show_form( @@ -108,7 +111,7 @@ class SimpliSafeFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_finish( { CONF_USERNAME: self._username, - CONF_TOKEN: simplisafe.refresh_token, + CONF_PASSWORD: self._password, CONF_CODE: self._code, } ) diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 79e11828eaa..eff37bf1548 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==10.0.0"], + "requirements": ["simplisafe-python==11.0.0"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/simplisafe/strings.json b/homeassistant/components/simplisafe/strings.json index ad973261a0e..23f85495025 100644 --- a/homeassistant/components/simplisafe/strings.json +++ b/homeassistant/components/simplisafe/strings.json @@ -7,7 +7,7 @@ }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "Your access token has expired or been revoked. Enter your password to re-link your account.", + "description": "Your access has expired or been revoked. Enter your password to re-link your account.", "data": { "password": "[%key:common::config_flow::data::password%]" } diff --git a/homeassistant/components/simplisafe/translations/en.json b/homeassistant/components/simplisafe/translations/en.json index b9e274666bb..331eb65ca83 100644 --- a/homeassistant/components/simplisafe/translations/en.json +++ b/homeassistant/components/simplisafe/translations/en.json @@ -19,7 +19,7 @@ "data": { "password": "Password" }, - "description": "Your access token has expired or been revoked. Enter your password to re-link your account.", + "description": "Your access has expired or been revoked. Enter your password to re-link your account.", "title": "Reauthenticate Integration" }, "user": { diff --git a/requirements_all.txt b/requirements_all.txt index c494ca91096..31761ada12d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2106,7 +2106,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==10.0.0 +simplisafe-python==11.0.0 # homeassistant.components.sisyphus sisyphus-control==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eebd4983754..b1b9bd84946 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1155,7 +1155,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==10.0.0 +simplisafe-python==11.0.0 # homeassistant.components.slack slackclient==2.5.0 diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py index a048e4b0745..4d438965806 100644 --- a/tests/components/simplisafe/test_config_flow.py +++ b/tests/components/simplisafe/test_config_flow.py @@ -1,5 +1,5 @@ """Define tests for the SimpliSafe config flow.""" -from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch +from unittest.mock import AsyncMock, patch from simplipy.errors import ( InvalidCredentialsError, @@ -10,18 +10,11 @@ from simplipy.errors import ( from homeassistant import data_entry_flow from homeassistant.components.simplisafe import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER -from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME +from homeassistant.const import CONF_CODE, CONF_PASSWORD, CONF_USERNAME from tests.common import MockConfigEntry -def mock_api(): - """Mock SimpliSafe API class.""" - api = MagicMock() - type(api).refresh_token = PropertyMock(return_value="12345abc") - return api - - async def test_duplicate_error(hass): """Test that errors are shown when duplicates are added.""" conf = { @@ -33,7 +26,11 @@ async def test_duplicate_error(hass): MockConfigEntry( domain=DOMAIN, unique_id="user@email.com", - data={CONF_USERNAME: "user@email.com", CONF_TOKEN: "12345", CONF_CODE: "1234"}, + data={ + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + CONF_CODE: "1234", + }, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -49,7 +46,7 @@ async def test_invalid_credentials(hass): conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} with patch( - "simplipy.API.login_via_credentials", + "homeassistant.components.simplisafe.config_flow.get_api", new=AsyncMock(side_effect=InvalidCredentialsError), ): result = await hass.config_entries.flow.async_init( @@ -102,7 +99,11 @@ async def test_step_reauth(hass): MockConfigEntry( domain=DOMAIN, unique_id="user@email.com", - data={CONF_USERNAME: "user@email.com", CONF_TOKEN: "12345", CONF_CODE: "1234"}, + data={ + CONF_USERNAME: "user@email.com", + CONF_PASSWORD: "password", + CONF_CODE: "1234", + }, ).add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -118,8 +119,8 @@ async def test_step_reauth(hass): with patch( "homeassistant.components.simplisafe.async_setup_entry", return_value=True - ), patch( - "simplipy.API.login_via_credentials", new=AsyncMock(return_value=mock_api()) + ), patch("homeassistant.components.simplisafe.config_flow.get_api"), patch( + "homeassistant.config_entries.ConfigEntries.async_reload" ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={CONF_PASSWORD: "password"} @@ -141,7 +142,7 @@ async def test_step_user(hass): with patch( "homeassistant.components.simplisafe.async_setup_entry", return_value=True ), patch( - "simplipy.API.login_via_credentials", new=AsyncMock(return_value=mock_api()) + "homeassistant.components.simplisafe.config_flow.get_api", new=AsyncMock() ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=conf @@ -151,7 +152,7 @@ async def test_step_user(hass): assert result["title"] == "user@email.com" assert result["data"] == { CONF_USERNAME: "user@email.com", - CONF_TOKEN: "12345abc", + CONF_PASSWORD: "password", CONF_CODE: "1234", } @@ -165,7 +166,7 @@ async def test_step_user_mfa(hass): } with patch( - "simplipy.API.login_via_credentials", + "homeassistant.components.simplisafe.config_flow.get_api", new=AsyncMock(side_effect=PendingAuthorizationError), ): result = await hass.config_entries.flow.async_init( @@ -174,7 +175,7 @@ async def test_step_user_mfa(hass): assert result["step_id"] == "mfa" with patch( - "simplipy.API.login_via_credentials", + "homeassistant.components.simplisafe.config_flow.get_api", new=AsyncMock(side_effect=PendingAuthorizationError), ): # Simulate the user pressing the MFA submit button without having clicked @@ -187,7 +188,7 @@ async def test_step_user_mfa(hass): with patch( "homeassistant.components.simplisafe.async_setup_entry", return_value=True ), patch( - "simplipy.API.login_via_credentials", new=AsyncMock(return_value=mock_api()) + "homeassistant.components.simplisafe.config_flow.get_api", new=AsyncMock() ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -197,7 +198,7 @@ async def test_step_user_mfa(hass): assert result["title"] == "user@email.com" assert result["data"] == { CONF_USERNAME: "user@email.com", - CONF_TOKEN: "12345abc", + CONF_PASSWORD: "password", CONF_CODE: "1234", } @@ -207,7 +208,7 @@ async def test_unknown_error(hass): conf = {CONF_USERNAME: "user@email.com", CONF_PASSWORD: "password"} with patch( - "simplipy.API.login_via_credentials", + "homeassistant.components.simplisafe.config_flow.get_api", new=AsyncMock(side_effect=SimplipyError), ): result = await hass.config_entries.flow.async_init( From 40d9541d9b33dd664aaddd83aa33427ab89dd4bf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Jul 2021 11:28:23 -0500 Subject: [PATCH 051/134] Revert nmap_tracker to 2021.6 version (#52573) * Revert nmap_tracker to 2021.6 version - Its unlikely we will be able to solve #52565 before release * hassfest --- .coveragerc | 3 +- CODEOWNERS | 1 - .../components/nmap_tracker/__init__.py | 396 +----------------- .../components/nmap_tracker/config_flow.py | 223 ---------- .../components/nmap_tracker/device_tracker.py | 271 +++++------- .../components/nmap_tracker/manifest.json | 12 +- homeassistant/generated/config_flows.py | 1 - requirements_all.txt | 10 +- requirements_test_all.txt | 7 - tests/components/nmap_tracker/__init__.py | 1 - .../nmap_tracker/test_config_flow.py | 310 -------------- 11 files changed, 106 insertions(+), 1129 deletions(-) delete mode 100644 homeassistant/components/nmap_tracker/config_flow.py delete mode 100644 tests/components/nmap_tracker/__init__.py delete mode 100644 tests/components/nmap_tracker/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index eb0fbdc1fcf..7c741dc26cd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -691,8 +691,7 @@ omit = homeassistant/components/niko_home_control/light.py homeassistant/components/nilu/air_quality.py homeassistant/components/nissan_leaf/* - homeassistant/components/nmap_tracker/__init__.py - homeassistant/components/nmap_tracker/device_tracker.py + homeassistant/components/nmap_tracker/* homeassistant/components/nmbs/sensor.py homeassistant/components/notion/__init__.py homeassistant/components/notion/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 46eb14dd66a..c651e35dcc3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -332,7 +332,6 @@ homeassistant/components/nextcloud/* @meichthys homeassistant/components/nightscout/* @marciogranzotto homeassistant/components/nilu/* @hfurubotten homeassistant/components/nissan_leaf/* @filcole -homeassistant/components/nmap_tracker/* @bdraco homeassistant/components/nmbs/* @thibmaek homeassistant/components/no_ip/* @fabaff homeassistant/components/noaa_tides/* @jdelaney72 diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py index 76a7e44f153..da699caaa73 100644 --- a/homeassistant/components/nmap_tracker/__init__.py +++ b/homeassistant/components/nmap_tracker/__init__.py @@ -1,395 +1 @@ -"""The Nmap Tracker integration.""" -from __future__ import annotations - -import asyncio -import contextlib -from dataclasses import dataclass -from datetime import datetime, timedelta -import logging - -import aiohttp -from getmac import get_mac_address -from mac_vendor_lookup import AsyncMacLookup -from nmap import PortScanner, PortScannerError - -from homeassistant.components.device_tracker.const import ( - CONF_SCAN_INTERVAL, - CONF_TRACK_NEW, -) -from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS, EVENT_HOMEASSISTANT_STARTED -from homeassistant.core import CoreState, HomeAssistant, callback -from homeassistant.helpers import entity_registry as er -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import format_mac -from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.helpers.event import async_track_time_interval -import homeassistant.util.dt as dt_util - -from .const import ( - CONF_HOME_INTERVAL, - CONF_OPTIONS, - DEFAULT_TRACK_NEW_DEVICES, - DOMAIN, - NMAP_TRACKED_DEVICES, - PLATFORMS, - TRACKER_SCAN_INTERVAL, -) - -# Some version of nmap will fail with 'Assertion failed: htn.toclock_running == true (Target.cc: stopTimeOutClock: 503)\n' -NMAP_TRANSIENT_FAILURE = "Assertion failed: htn.toclock_running == true" -MAX_SCAN_ATTEMPTS = 16 -OFFLINE_SCANS_TO_MARK_UNAVAILABLE = 3 - - -def short_hostname(hostname): - """Return the first part of the hostname.""" - if hostname is None: - return None - return hostname.split(".")[0] - - -def human_readable_name(hostname, vendor, mac_address): - """Generate a human readable name.""" - if hostname: - return short_hostname(hostname) - if vendor: - return f"{vendor} {mac_address[-8:]}" - return f"Nmap Tracker {mac_address}" - - -@dataclass -class NmapDevice: - """Class for keeping track of an nmap tracked device.""" - - mac_address: str - hostname: str - name: str - ipv4: str - manufacturer: str - reason: str - last_update: datetime.datetime - offline_scans: int - - -class NmapTrackedDevices: - """Storage class for all nmap trackers.""" - - def __init__(self) -> None: - """Initialize the data.""" - self.tracked: dict = {} - self.ipv4_last_mac: dict = {} - self.config_entry_owner: dict = {} - - -_LOGGER = logging.getLogger(__name__) - - -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up Nmap Tracker from a config entry.""" - domain_data = hass.data.setdefault(DOMAIN, {}) - devices = domain_data.setdefault(NMAP_TRACKED_DEVICES, NmapTrackedDevices()) - scanner = domain_data[entry.entry_id] = NmapDeviceScanner(hass, entry, devices) - await scanner.async_setup() - entry.async_on_unload(entry.add_update_listener(_async_update_listener)) - hass.config_entries.async_setup_platforms(entry, PLATFORMS) - return True - - -async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry): - """Handle options update.""" - await hass.config_entries.async_reload(entry.entry_id) - - -async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Unload a config entry.""" - unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - if unload_ok: - _async_untrack_devices(hass, entry) - hass.data[DOMAIN].pop(entry.entry_id) - - return unload_ok - - -@callback -def _async_untrack_devices(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Remove tracking for devices owned by this config entry.""" - devices = hass.data[DOMAIN][NMAP_TRACKED_DEVICES] - remove_mac_addresses = [ - mac_address - for mac_address, entry_id in devices.config_entry_owner.items() - if entry_id == entry.entry_id - ] - for mac_address in remove_mac_addresses: - if device := devices.tracked.pop(mac_address, None): - devices.ipv4_last_mac.pop(device.ipv4, None) - del devices.config_entry_owner[mac_address] - - -def signal_device_update(mac_address) -> str: - """Signal specific per nmap tracker entry to signal updates in device.""" - return f"{DOMAIN}-device-update-{mac_address}" - - -class NmapDeviceScanner: - """This class scans for devices using nmap.""" - - def __init__(self, hass, entry, devices): - """Initialize the scanner.""" - self.devices = devices - self.home_interval = None - - self._hass = hass - self._entry = entry - - self._scan_lock = None - self._stopping = False - self._scanner = None - - self._entry_id = entry.entry_id - self._hosts = None - self._options = None - self._exclude = None - self._scan_interval = None - self._track_new_devices = None - - self._known_mac_addresses = {} - self._finished_first_scan = False - self._last_results = [] - self._mac_vendor_lookup = None - - async def async_setup(self): - """Set up the tracker.""" - config = self._entry.options - self._track_new_devices = config.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW_DEVICES) - self._scan_interval = timedelta( - seconds=config.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL) - ) - hosts_list = cv.ensure_list_csv(config[CONF_HOSTS]) - self._hosts = [host for host in hosts_list if host != ""] - excludes_list = cv.ensure_list_csv(config[CONF_EXCLUDE]) - self._exclude = [exclude for exclude in excludes_list if exclude != ""] - self._options = config[CONF_OPTIONS] - self.home_interval = timedelta( - minutes=cv.positive_int(config[CONF_HOME_INTERVAL]) - ) - self._scan_lock = asyncio.Lock() - if self._hass.state == CoreState.running: - await self._async_start_scanner() - return - - self._entry.async_on_unload( - self._hass.bus.async_listen( - EVENT_HOMEASSISTANT_STARTED, self._async_start_scanner - ) - ) - registry = er.async_get(self._hass) - self._known_mac_addresses = { - entry.unique_id: entry.original_name - for entry in registry.entities.values() - if entry.config_entry_id == self._entry_id - } - - @property - def signal_device_new(self) -> str: - """Signal specific per nmap tracker entry to signal new device.""" - return f"{DOMAIN}-device-new-{self._entry_id}" - - @property - def signal_device_missing(self) -> str: - """Signal specific per nmap tracker entry to signal a missing device.""" - return f"{DOMAIN}-device-missing-{self._entry_id}" - - @callback - def _async_get_vendor(self, mac_address): - """Lookup the vendor.""" - oui = self._mac_vendor_lookup.sanitise(mac_address)[:6] - return self._mac_vendor_lookup.prefixes.get(oui) - - @callback - def _async_stop(self): - """Stop the scanner.""" - self._stopping = True - - async def _async_start_scanner(self, *_): - """Start the scanner.""" - self._entry.async_on_unload(self._async_stop) - self._entry.async_on_unload( - async_track_time_interval( - self._hass, - self._async_scan_devices, - self._scan_interval, - ) - ) - self._mac_vendor_lookup = AsyncMacLookup() - with contextlib.suppress((asyncio.TimeoutError, aiohttp.ClientError)): - # We don't care of this fails since its only - # improves the data when we don't have it from nmap - await self._mac_vendor_lookup.load_vendors() - self._hass.async_create_task(self._async_scan_devices()) - - def _build_options(self): - """Build the command line and strip out last results that do not need to be updated.""" - options = self._options - if self.home_interval: - boundary = dt_util.now() - self.home_interval - last_results = [ - device for device in self._last_results if device.last_update > boundary - ] - if last_results: - exclude_hosts = self._exclude + [device.ipv4 for device in last_results] - else: - exclude_hosts = self._exclude - else: - last_results = [] - exclude_hosts = self._exclude - if exclude_hosts: - options += f" --exclude {','.join(exclude_hosts)}" - # Report reason - if "--reason" not in options: - options += " --reason" - # Report down hosts - if "-v" not in options: - options += " -v" - self._last_results = last_results - return options - - async def _async_scan_devices(self, *_): - """Scan devices and dispatch.""" - if self._scan_lock.locked(): - _LOGGER.debug( - "Nmap scanning is taking longer than the scheduled interval: %s", - TRACKER_SCAN_INTERVAL, - ) - return - - async with self._scan_lock: - try: - await self._async_run_nmap_scan() - except PortScannerError as ex: - _LOGGER.error("Nmap scanning failed: %s", ex) - - if not self._finished_first_scan: - self._finished_first_scan = True - await self._async_mark_missing_devices_as_not_home() - - async def _async_mark_missing_devices_as_not_home(self): - # After all config entries have finished their first - # scan we mark devices that were not found as not_home - # from unavailable - now = dt_util.now() - for mac_address, original_name in self._known_mac_addresses.items(): - if mac_address in self.devices.tracked: - continue - self.devices.config_entry_owner[mac_address] = self._entry_id - self.devices.tracked[mac_address] = NmapDevice( - mac_address, - None, - original_name, - None, - self._async_get_vendor(mac_address), - "Device not found in initial scan", - now, - 1, - ) - async_dispatcher_send(self._hass, self.signal_device_missing, mac_address) - - def _run_nmap_scan(self): - """Run nmap and return the result.""" - options = self._build_options() - if not self._scanner: - self._scanner = PortScanner() - _LOGGER.debug("Scanning %s with args: %s", self._hosts, options) - for attempt in range(MAX_SCAN_ATTEMPTS): - try: - result = self._scanner.scan( - hosts=" ".join(self._hosts), - arguments=options, - timeout=TRACKER_SCAN_INTERVAL * 10, - ) - break - except PortScannerError as ex: - if attempt < (MAX_SCAN_ATTEMPTS - 1) and NMAP_TRANSIENT_FAILURE in str( - ex - ): - _LOGGER.debug("Nmap saw transient error %s", NMAP_TRANSIENT_FAILURE) - continue - raise - _LOGGER.debug( - "Finished scanning %s with args: %s", - self._hosts, - options, - ) - return result - - @callback - def _async_increment_device_offline(self, ipv4, reason): - """Mark an IP offline.""" - if not (formatted_mac := self.devices.ipv4_last_mac.get(ipv4)): - return - if not (device := self.devices.tracked.get(formatted_mac)): - # Device was unloaded - return - device.offline_scans += 1 - if device.offline_scans < OFFLINE_SCANS_TO_MARK_UNAVAILABLE: - return - device.reason = reason - async_dispatcher_send(self._hass, signal_device_update(formatted_mac), False) - del self.devices.ipv4_last_mac[ipv4] - - async def _async_run_nmap_scan(self): - """Scan the network for devices and dispatch events.""" - result = await self._hass.async_add_executor_job(self._run_nmap_scan) - if self._stopping: - return - - devices = self.devices - entry_id = self._entry_id - now = dt_util.now() - for ipv4, info in result["scan"].items(): - status = info["status"] - reason = status["reason"] - if status["state"] != "up": - self._async_increment_device_offline(ipv4, reason) - continue - # Mac address only returned if nmap ran as root - mac = info["addresses"].get("mac") or get_mac_address(ip=ipv4) - if mac is None: - self._async_increment_device_offline(ipv4, "No MAC address found") - _LOGGER.info("No MAC address found for %s", ipv4) - continue - - formatted_mac = format_mac(mac) - new = formatted_mac not in devices.tracked - if ( - new - and not self._track_new_devices - and formatted_mac not in devices.tracked - and formatted_mac not in self._known_mac_addresses - ): - continue - - if ( - devices.config_entry_owner.setdefault(formatted_mac, entry_id) - != entry_id - ): - continue - - hostname = info["hostnames"][0]["name"] if info["hostnames"] else ipv4 - vendor = info.get("vendor", {}).get(mac) or self._async_get_vendor(mac) - name = human_readable_name(hostname, vendor, mac) - device = NmapDevice( - formatted_mac, hostname, name, ipv4, vendor, reason, now, 0 - ) - - devices.tracked[formatted_mac] = device - devices.ipv4_last_mac[ipv4] = formatted_mac - self._last_results.append(device) - - if new: - async_dispatcher_send(self._hass, self.signal_device_new, formatted_mac) - else: - async_dispatcher_send( - self._hass, signal_device_update(formatted_mac), True - ) +"""The nmap_tracker component.""" diff --git a/homeassistant/components/nmap_tracker/config_flow.py b/homeassistant/components/nmap_tracker/config_flow.py deleted file mode 100644 index 68e61745b63..00000000000 --- a/homeassistant/components/nmap_tracker/config_flow.py +++ /dev/null @@ -1,223 +0,0 @@ -"""Config flow for Nmap Tracker integration.""" -from __future__ import annotations - -from ipaddress import ip_address, ip_network, summarize_address_range -from typing import Any - -import ifaddr -import voluptuous as vol - -from homeassistant import config_entries -from homeassistant.components.device_tracker.const import ( - CONF_SCAN_INTERVAL, - CONF_TRACK_NEW, -) -from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS -from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult -import homeassistant.helpers.config_validation as cv -from homeassistant.util import get_local_ip - -from .const import ( - CONF_HOME_INTERVAL, - CONF_OPTIONS, - DEFAULT_OPTIONS, - DEFAULT_TRACK_NEW_DEVICES, - DOMAIN, - TRACKER_SCAN_INTERVAL, -) - -DEFAULT_NETWORK_PREFIX = 24 - - -def get_network(): - """Search adapters for the network.""" - adapters = ifaddr.get_adapters() - local_ip = get_local_ip() - network_prefix = ( - get_ip_prefix_from_adapters(local_ip, adapters) or DEFAULT_NETWORK_PREFIX - ) - return str(ip_network(f"{local_ip}/{network_prefix}", False)) - - -def get_ip_prefix_from_adapters(local_ip, adapters): - """Find the network prefix for an adapter.""" - for adapter in adapters: - for ip_cfg in adapter.ips: - if local_ip == ip_cfg.ip: - return ip_cfg.network_prefix - - -def _normalize_ips_and_network(hosts_str): - """Check if a list of hosts are all ips or ip networks.""" - - normalized_hosts = [] - hosts = [host for host in cv.ensure_list_csv(hosts_str) if host != ""] - - for host in sorted(hosts): - try: - start, end = host.split("-", 1) - if "." not in end: - ip_1, ip_2, ip_3, _ = start.split(".", 3) - end = ".".join([ip_1, ip_2, ip_3, end]) - summarize_address_range(ip_address(start), ip_address(end)) - except ValueError: - pass - else: - normalized_hosts.append(host) - continue - - try: - ip_addr = ip_address(host) - except ValueError: - pass - else: - normalized_hosts.append(str(ip_addr)) - continue - - try: - network = ip_network(host) - except ValueError: - return None - else: - normalized_hosts.append(str(network)) - - return normalized_hosts - - -def normalize_input(user_input): - """Validate hosts and exclude are valid.""" - errors = {} - normalized_hosts = _normalize_ips_and_network(user_input[CONF_HOSTS]) - if not normalized_hosts: - errors[CONF_HOSTS] = "invalid_hosts" - else: - user_input[CONF_HOSTS] = ",".join(normalized_hosts) - - normalized_exclude = _normalize_ips_and_network(user_input[CONF_EXCLUDE]) - if normalized_exclude is None: - errors[CONF_EXCLUDE] = "invalid_hosts" - else: - user_input[CONF_EXCLUDE] = ",".join(normalized_exclude) - - return errors - - -async def _async_build_schema_with_user_input(hass, user_input, include_options): - hosts = user_input.get(CONF_HOSTS, await hass.async_add_executor_job(get_network)) - exclude = user_input.get( - CONF_EXCLUDE, await hass.async_add_executor_job(get_local_ip) - ) - schema = { - vol.Required(CONF_HOSTS, default=hosts): str, - vol.Required( - CONF_HOME_INTERVAL, default=user_input.get(CONF_HOME_INTERVAL, 0) - ): int, - vol.Optional(CONF_EXCLUDE, default=exclude): str, - vol.Optional( - CONF_OPTIONS, default=user_input.get(CONF_OPTIONS, DEFAULT_OPTIONS) - ): str, - } - if include_options: - schema.update( - { - vol.Optional( - CONF_TRACK_NEW, - default=user_input.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW_DEVICES), - ): bool, - vol.Optional( - CONF_SCAN_INTERVAL, - default=user_input.get(CONF_SCAN_INTERVAL, TRACKER_SCAN_INTERVAL), - ): vol.All(vol.Coerce(int), vol.Range(min=10, max=3600)), - } - ) - return vol.Schema(schema) - - -class OptionsFlowHandler(config_entries.OptionsFlow): - """Handle a option flow for homekit.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Initialize options flow.""" - self.options = dict(config_entry.options) - - async def async_step_init(self, user_input=None): - """Handle options flow.""" - errors = {} - if user_input is not None: - errors = normalize_input(user_input) - self.options.update(user_input) - - if not errors: - return self.async_create_entry( - title=f"Nmap Tracker {self.options[CONF_HOSTS]}", data=self.options - ) - - return self.async_show_form( - step_id="init", - data_schema=await _async_build_schema_with_user_input( - self.hass, self.options, True - ), - errors=errors, - ) - - -class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): - """Handle a config flow for Nmap Tracker.""" - - VERSION = 1 - - def __init__(self): - """Initialize config flow.""" - self.options = {} - - async def async_step_user( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Handle the initial step.""" - errors = {} - if user_input is not None: - if not self._async_is_unique_host_list(user_input): - return self.async_abort(reason="already_configured") - - errors = normalize_input(user_input) - self.options.update(user_input) - - if not errors: - return self.async_create_entry( - title=f"Nmap Tracker {user_input[CONF_HOSTS]}", - data={}, - options=user_input, - ) - - return self.async_show_form( - step_id="user", - data_schema=await _async_build_schema_with_user_input( - self.hass, self.options, False - ), - errors=errors, - ) - - def _async_is_unique_host_list(self, user_input): - hosts = _normalize_ips_and_network(user_input[CONF_HOSTS]) - for entry in self._async_current_entries(): - if _normalize_ips_and_network(entry.options[CONF_HOSTS]) == hosts: - return False - return True - - async def async_step_import(self, user_input=None): - """Handle import from yaml.""" - if not self._async_is_unique_host_list(user_input): - return self.async_abort(reason="already_configured") - - normalize_input(user_input) - - return self.async_create_entry( - title=f"Nmap Tracker {user_input[CONF_HOSTS]}", data={}, options=user_input - ) - - @staticmethod - @callback - def async_get_options_flow(config_entry): - """Get the options flow for this handler.""" - return OptionsFlowHandler(config_entry) diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py index 350e75adf48..69c65873e51 100644 --- a/homeassistant/components/nmap_tracker/device_tracker.py +++ b/homeassistant/components/nmap_tracker/device_tracker.py @@ -1,40 +1,29 @@ """Support for scanning a network with nmap.""" - +from collections import namedtuple +from datetime import timedelta import logging -from typing import Callable +from getmac import get_mac_address +from nmap import PortScanner, PortScannerError import voluptuous as vol from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN, - PLATFORM_SCHEMA, - SOURCE_TYPE_ROUTER, -) -from homeassistant.components.device_tracker.config_entry import ScannerEntity -from homeassistant.components.device_tracker.const import ( - CONF_NEW_DEVICE_DEFAULTS, - CONF_SCAN_INTERVAL, - CONF_TRACK_NEW, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS -from homeassistant.core import HomeAssistant, callback -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.dispatcher import async_dispatcher_connect - -from . import NmapDeviceScanner, short_hostname, signal_device_update -from .const import ( - CONF_HOME_INTERVAL, - CONF_OPTIONS, - DEFAULT_OPTIONS, - DEFAULT_TRACK_NEW_DEVICES, DOMAIN, - TRACKER_SCAN_INTERVAL, + PLATFORM_SCHEMA, + DeviceScanner, ) +from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) +# Interval in minutes to exclude devices from a scan while they are home +CONF_HOME_INTERVAL = "home_interval" +CONF_OPTIONS = "scan_options" +DEFAULT_OPTIONS = "-F --host-timeout 5s" + + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOSTS): cv.ensure_list, @@ -45,164 +34,100 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -async def async_get_scanner(hass, config): +def get_scanner(hass, config): """Validate the configuration and return a Nmap scanner.""" - validated_config = config[DEVICE_TRACKER_DOMAIN] + return NmapDeviceScanner(config[DOMAIN]) - if CONF_SCAN_INTERVAL in validated_config: - scan_interval = validated_config[CONF_SCAN_INTERVAL].total_seconds() - else: - scan_interval = TRACKER_SCAN_INTERVAL - import_config = { - CONF_HOSTS: ",".join(validated_config[CONF_HOSTS]), - CONF_HOME_INTERVAL: validated_config[CONF_HOME_INTERVAL], - CONF_EXCLUDE: ",".join(validated_config[CONF_EXCLUDE]), - CONF_OPTIONS: validated_config[CONF_OPTIONS], - CONF_SCAN_INTERVAL: scan_interval, - CONF_TRACK_NEW: validated_config.get(CONF_NEW_DEVICE_DEFAULTS, {}).get( - CONF_TRACK_NEW, DEFAULT_TRACK_NEW_DEVICES - ), - } +Device = namedtuple("Device", ["mac", "name", "ip", "last_update"]) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=import_config, + +class NmapDeviceScanner(DeviceScanner): + """This class scans for devices using nmap.""" + + exclude = [] + + def __init__(self, config): + """Initialize the scanner.""" + self.last_results = [] + + self.hosts = config[CONF_HOSTS] + self.exclude = config[CONF_EXCLUDE] + minutes = config[CONF_HOME_INTERVAL] + self._options = config[CONF_OPTIONS] + self.home_interval = timedelta(minutes=minutes) + + _LOGGER.debug("Scanner initialized") + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + _LOGGER.debug("Nmap last results %s", self.last_results) + + return [device.mac for device in self.last_results] + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + filter_named = [ + result.name for result in self.last_results if result.mac == device + ] + + if filter_named: + return filter_named[0] + return None + + def get_extra_attributes(self, device): + """Return the IP of the given device.""" + filter_ip = next( + (result.ip for result in self.last_results if result.mac == device), None ) - ) + return {"ip": filter_ip} - _LOGGER.warning( - "Your Nmap Tracker configuration has been imported into the UI, " - "please remove it from configuration.yaml. " - ) + def _update_info(self): + """Scan the network for devices. + Returns boolean if scanning successful. + """ + _LOGGER.debug("Scanning") -async def async_setup_entry( - hass: HomeAssistant, entry: ConfigEntry, async_add_entities: Callable -) -> None: - """Set up device tracker for Nmap Tracker component.""" - nmap_tracker = hass.data[DOMAIN][entry.entry_id] + scanner = PortScanner() - @callback - def device_new(mac_address): - """Signal a new device.""" - async_add_entities([NmapTrackerEntity(nmap_tracker, mac_address, True)]) + options = self._options - @callback - def device_missing(mac_address): - """Signal a missing device.""" - async_add_entities([NmapTrackerEntity(nmap_tracker, mac_address, False)]) + if self.home_interval: + boundary = dt_util.now() - self.home_interval + last_results = [ + device for device in self.last_results if device.last_update > boundary + ] + if last_results: + exclude_hosts = self.exclude + [device.ip for device in last_results] + else: + exclude_hosts = self.exclude + else: + last_results = [] + exclude_hosts = self.exclude + if exclude_hosts: + options += f" --exclude {','.join(exclude_hosts)}" - entry.async_on_unload( - async_dispatcher_connect(hass, nmap_tracker.signal_device_new, device_new) - ) - entry.async_on_unload( - async_dispatcher_connect( - hass, nmap_tracker.signal_device_missing, device_missing - ) - ) + try: + result = scanner.scan(hosts=" ".join(self.hosts), arguments=options) + except PortScannerError: + return False + now = dt_util.now() + for ipv4, info in result["scan"].items(): + if info["status"]["state"] != "up": + continue + name = info["hostnames"][0]["name"] if info["hostnames"] else ipv4 + # Mac address only returned if nmap ran as root + mac = info["addresses"].get("mac") or get_mac_address(ip=ipv4) + if mac is None: + _LOGGER.info("No MAC address found for %s", ipv4) + continue + last_results.append(Device(mac.upper(), name, ipv4, now)) -class NmapTrackerEntity(ScannerEntity): - """An Nmap Tracker entity.""" + self.last_results = last_results - def __init__( - self, nmap_tracker: NmapDeviceScanner, mac_address: str, active: bool - ) -> None: - """Initialize an nmap tracker entity.""" - self._mac_address = mac_address - self._nmap_tracker = nmap_tracker - self._tracked = self._nmap_tracker.devices.tracked - self._active = active - - @property - def _device(self) -> bool: - """Get latest device state.""" - return self._tracked[self._mac_address] - - @property - def is_connected(self) -> bool: - """Return device status.""" - return self._active - - @property - def name(self) -> str: - """Return device name.""" - return self._device.name - - @property - def unique_id(self) -> str: - """Return device unique id.""" - return self._mac_address - - @property - def ip_address(self) -> str: - """Return the primary ip address of the device.""" - return self._device.ipv4 - - @property - def mac_address(self) -> str: - """Return the mac address of the device.""" - return self._mac_address - - @property - def hostname(self) -> str: - """Return hostname of the device.""" - return short_hostname(self._device.hostname) - - @property - def source_type(self) -> str: - """Return tracker source type.""" - return SOURCE_TYPE_ROUTER - - @property - def device_info(self): - """Return the device information.""" - return { - "connections": {(CONNECTION_NETWORK_MAC, self._mac_address)}, - "default_manufacturer": self._device.manufacturer, - "default_name": self.name, - } - - @property - def should_poll(self) -> bool: - """No polling needed.""" - return False - - @property - def icon(self): - """Return device icon.""" - return "mdi:lan-connect" if self._active else "mdi:lan-disconnect" - - @callback - def async_process_update(self, online: bool) -> None: - """Update device.""" - self._active = online - - @property - def extra_state_attributes(self): - """Return the attributes.""" - return { - "last_time_reachable": self._device.last_update.isoformat( - timespec="seconds" - ), - "reason": self._device.reason, - } - - @callback - def async_on_demand_update(self, online: bool): - """Update state.""" - self.async_process_update(online) - self.async_write_ha_state() - - async def async_added_to_hass(self): - """Register state update callback.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, - signal_device_update(self._mac_address), - self.async_on_demand_update, - ) - ) + _LOGGER.debug("nmap scan successful") + return True diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json index ee05843c4fe..9f81c0facaf 100644 --- a/homeassistant/components/nmap_tracker/manifest.json +++ b/homeassistant/components/nmap_tracker/manifest.json @@ -2,13 +2,7 @@ "domain": "nmap_tracker", "name": "Nmap Tracker", "documentation": "https://www.home-assistant.io/integrations/nmap_tracker", - "requirements": [ - "netmap==0.7.0.2", - "getmac==0.8.2", - "ifaddr==0.1.7", - "mac-vendor-lookup==0.1.11" - ], - "codeowners": ["@bdraco"], - "iot_class": "local_polling", - "config_flow": true + "requirements": ["python-nmap==0.6.1", "getmac==0.8.2"], + "codeowners": [], + "iot_class": "local_polling" } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index aa6d9009574..e71503ce5fc 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -176,7 +176,6 @@ FLOWS = [ "netatmo", "nexia", "nightscout", - "nmap_tracker", "notion", "nuheat", "nuki", diff --git a/requirements_all.txt b/requirements_all.txt index 31761ada12d..1065675c13b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -834,7 +834,6 @@ ibmiotf==0.3.4 icmplib==3.0 # homeassistant.components.network -# homeassistant.components.nmap_tracker ifaddr==0.1.7 # homeassistant.components.iglo @@ -936,9 +935,6 @@ lw12==0.9.2 # homeassistant.components.lyft lyft_rides==0.2 -# homeassistant.components.nmap_tracker -mac-vendor-lookup==0.1.11 - # homeassistant.components.magicseaweed magicseaweed==1.0.3 @@ -1017,9 +1013,6 @@ netdata==0.2.0 # homeassistant.components.discovery netdisco==2.9.0 -# homeassistant.components.nmap_tracker -netmap==0.7.0.2 - # homeassistant.components.nam nettigo-air-monitor==1.0.0 @@ -1869,6 +1862,9 @@ python-mystrom==1.1.2 # homeassistant.components.nest python-nest==4.1.0 +# homeassistant.components.nmap_tracker +python-nmap==0.6.1 + # homeassistant.components.ozw python-openzwave-mqtt[mqtt-client]==1.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1b9bd84946..074d78522e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -481,7 +481,6 @@ iaqualink==0.3.90 icmplib==3.0 # homeassistant.components.network -# homeassistant.components.nmap_tracker ifaddr==0.1.7 # homeassistant.components.influxdb @@ -523,9 +522,6 @@ logi_circle==0.2.2 # homeassistant.components.luftdaten luftdaten==0.6.5 -# homeassistant.components.nmap_tracker -mac-vendor-lookup==0.1.11 - # homeassistant.components.maxcube maxcube-api==0.4.3 @@ -574,9 +570,6 @@ nessclient==0.9.15 # homeassistant.components.discovery netdisco==2.9.0 -# homeassistant.components.nmap_tracker -netmap==0.7.0.2 - # homeassistant.components.nam nettigo-air-monitor==1.0.0 diff --git a/tests/components/nmap_tracker/__init__.py b/tests/components/nmap_tracker/__init__.py deleted file mode 100644 index f5e0c85df31..00000000000 --- a/tests/components/nmap_tracker/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the Nmap Tracker integration.""" diff --git a/tests/components/nmap_tracker/test_config_flow.py b/tests/components/nmap_tracker/test_config_flow.py deleted file mode 100644 index c4e82936b88..00000000000 --- a/tests/components/nmap_tracker/test_config_flow.py +++ /dev/null @@ -1,310 +0,0 @@ -"""Test the Nmap Tracker config flow.""" -from unittest.mock import patch - -import pytest - -from homeassistant import config_entries, data_entry_flow, setup -from homeassistant.components.device_tracker.const import ( - CONF_SCAN_INTERVAL, - CONF_TRACK_NEW, -) -from homeassistant.components.nmap_tracker.const import ( - CONF_HOME_INTERVAL, - CONF_OPTIONS, - DEFAULT_OPTIONS, - DOMAIN, -) -from homeassistant.const import CONF_EXCLUDE, CONF_HOSTS -from homeassistant.core import CoreState, HomeAssistant - -from tests.common import MockConfigEntry - - -@pytest.mark.parametrize( - "hosts", ["1.1.1.1", "192.168.1.0/24", "192.168.1.0/24,192.168.2.0/24"] -) -async def test_form(hass: HomeAssistant, hosts: str) -> None: - """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"] == {} - - schema_defaults = result["data_schema"]({}) - assert CONF_TRACK_NEW not in schema_defaults - assert CONF_SCAN_INTERVAL not in schema_defaults - - with patch( - "homeassistant.components.nmap_tracker.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOSTS: hosts, - CONF_HOME_INTERVAL: 3, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == "create_entry" - assert result2["title"] == f"Nmap Tracker {hosts}" - assert result2["data"] == {} - assert result2["options"] == { - CONF_HOSTS: hosts, - CONF_HOME_INTERVAL: 3, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_range(hass: HomeAssistant) -> None: - """Test we get the form and can take an ip range.""" - 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.nmap_tracker.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOSTS: "192.168.0.5-12", - CONF_HOME_INTERVAL: 3, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == "create_entry" - assert result2["title"] == "Nmap Tracker 192.168.0.5-12" - assert result2["data"] == {} - assert result2["options"] == { - CONF_HOSTS: "192.168.0.5-12", - CONF_HOME_INTERVAL: 3, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4", - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_form_invalid_hosts(hass: HomeAssistant) -> None: - """Test invalid hosts passed in.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["errors"] == {} - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOSTS: "not an ip block", - CONF_HOME_INTERVAL: 3, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == "form" - assert result2["errors"] == {CONF_HOSTS: "invalid_hosts"} - - -async def test_form_already_configured(hass: HomeAssistant) -> None: - """Test duplicate host list.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - config_entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - CONF_HOSTS: "192.168.0.0/20", - CONF_HOME_INTERVAL: 3, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4", - }, - ) - config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["errors"] == {} - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOSTS: "192.168.0.0/20", - CONF_HOME_INTERVAL: 3, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == "abort" - assert result2["reason"] == "already_configured" - - -async def test_form_invalid_excludes(hass: HomeAssistant) -> None: - """Test invalid excludes passed in.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] == "form" - assert result["errors"] == {} - - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_HOSTS: "3.3.3.3", - CONF_HOME_INTERVAL: 3, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "not an exclude", - }, - ) - await hass.async_block_till_done() - - assert result2["type"] == "form" - assert result2["errors"] == {CONF_EXCLUDE: "invalid_hosts"} - - -async def test_options_flow(hass: HomeAssistant) -> None: - """Test we can edit options.""" - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - CONF_HOSTS: "192.168.1.0/24", - CONF_HOME_INTERVAL: 3, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4", - }, - ) - config_entry.add_to_hass(hass) - hass.state = CoreState.stopped - - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - result = await hass.config_entries.options.async_init(config_entry.entry_id) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_FORM - assert result["step_id"] == "init" - - assert result["data_schema"]({}) == { - CONF_EXCLUDE: "4.4.4.4", - CONF_HOME_INTERVAL: 3, - CONF_HOSTS: "192.168.1.0/24", - CONF_SCAN_INTERVAL: 120, - CONF_OPTIONS: "-F --host-timeout 5s", - CONF_TRACK_NEW: True, - } - - with patch( - "homeassistant.components.nmap_tracker.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={ - CONF_HOSTS: "192.168.1.0/24, 192.168.2.0/24", - CONF_HOME_INTERVAL: 5, - CONF_OPTIONS: "-sn", - CONF_EXCLUDE: "4.4.4.4, 5.5.5.5", - CONF_SCAN_INTERVAL: 10, - CONF_TRACK_NEW: False, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert config_entry.options == { - CONF_HOSTS: "192.168.1.0/24,192.168.2.0/24", - CONF_HOME_INTERVAL: 5, - CONF_OPTIONS: "-sn", - CONF_EXCLUDE: "4.4.4.4,5.5.5.5", - CONF_SCAN_INTERVAL: 10, - CONF_TRACK_NEW: False, - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import(hass: HomeAssistant) -> None: - """Test we can import from yaml.""" - await setup.async_setup_component(hass, "persistent_notification", {}) - with patch( - "homeassistant.components.nmap_tracker.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOSTS: "1.2.3.4/20", - CONF_HOME_INTERVAL: 3, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4, 6.4.3.2", - CONF_SCAN_INTERVAL: 2000, - CONF_TRACK_NEW: False, - }, - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == "Nmap Tracker 1.2.3.4/20" - assert result["data"] == {} - assert result["options"] == { - CONF_HOSTS: "1.2.3.4/20", - CONF_HOME_INTERVAL: 3, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4,6.4.3.2", - CONF_SCAN_INTERVAL: 2000, - CONF_TRACK_NEW: False, - } - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_aborts_if_matching(hass: HomeAssistant) -> None: - """Test we can import from yaml.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - CONF_HOSTS: "192.168.0.0/20", - CONF_HOME_INTERVAL: 3, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4", - }, - ) - config_entry.add_to_hass(hass) - await setup.async_setup_component(hass, "persistent_notification", {}) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOSTS: "192.168.0.0/20", - CONF_HOME_INTERVAL: 3, - CONF_OPTIONS: DEFAULT_OPTIONS, - CONF_EXCLUDE: "4.4.4.4, 6.4.3.2", - }, - ) - await hass.async_block_till_done() - - assert result["type"] == "abort" - assert result["reason"] == "already_configured" From 7a503a6c1f9250f413bc06b4a6100199c5c2ccb6 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 6 Jul 2021 17:18:54 +0200 Subject: [PATCH 052/134] Make use of entry id rather than unique id when storing deconz entry in hass.data (#52584) * Make use of entry id rather than unique id when storing entry in hass data * Update homeassistant/components/deconz/services.py Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- homeassistant/components/deconz/__init__.py | 7 ++- homeassistant/components/deconz/gateway.py | 4 +- homeassistant/components/deconz/services.py | 49 +++++++++++---------- tests/components/deconz/test_init.py | 10 ++--- tests/components/deconz/test_services.py | 23 +++++++++- 5 files changed, 56 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 8b47363c7ba..1b9a418fb29 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -20,8 +20,7 @@ async def async_setup_entry(hass, config_entry): Load config, group, light and sensor data for server information. Start websocket for push notification of state changes from deCONZ. """ - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} + hass.data.setdefault(DOMAIN, {}) await async_update_group_unique_id(hass, config_entry) @@ -33,7 +32,7 @@ async def async_setup_entry(hass, config_entry): if not await gateway.async_setup(): return False - hass.data[DOMAIN][config_entry.unique_id] = gateway + hass.data[DOMAIN][config_entry.entry_id] = gateway await gateway.async_update_device_registry() @@ -48,7 +47,7 @@ async def async_setup_entry(hass, config_entry): async def async_unload_entry(hass, config_entry): """Unload deCONZ config entry.""" - gateway = hass.data[DOMAIN].pop(config_entry.unique_id) + gateway = hass.data[DOMAIN].pop(config_entry.entry_id) if not hass.data[DOMAIN]: await async_unload_services(hass) diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 8b057ab9e51..0a7d7e0c849 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -33,8 +33,8 @@ from .errors import AuthenticationRequired, CannotConnect @callback def get_gateway_from_config_entry(hass, config_entry): - """Return gateway with a matching bridge id.""" - return hass.data[DECONZ_DOMAIN][config_entry.unique_id] + """Return gateway with a matching config entry ID.""" + return hass.data[DECONZ_DOMAIN][config_entry.entry_id] class DeconzGateway: diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py index d524354ff0b..a4f4aec6a76 100644 --- a/homeassistant/components/deconz/services.py +++ b/homeassistant/components/deconz/services.py @@ -59,14 +59,29 @@ async def async_setup_services(hass): service = service_call.service service_data = service_call.data + gateway = get_master_gateway(hass) + if CONF_BRIDGE_ID in service_data: + found_gateway = False + bridge_id = normalize_bridge_id(service_data[CONF_BRIDGE_ID]) + + for possible_gateway in hass.data[DOMAIN].values(): + if possible_gateway.bridgeid == bridge_id: + gateway = possible_gateway + found_gateway = True + break + + if not found_gateway: + LOGGER.error("Could not find the gateway %s", bridge_id) + return + if service == SERVICE_CONFIGURE_DEVICE: - await async_configure_service(hass, service_data) + await async_configure_service(gateway, service_data) elif service == SERVICE_DEVICE_REFRESH: - await async_refresh_devices_service(hass, service_data) + await async_refresh_devices_service(gateway) elif service == SERVICE_REMOVE_ORPHANED_ENTRIES: - await async_remove_orphaned_entries_service(hass, service_data) + await async_remove_orphaned_entries_service(gateway) hass.services.async_register( DOMAIN, @@ -102,7 +117,7 @@ async def async_unload_services(hass): hass.services.async_remove(DOMAIN, SERVICE_REMOVE_ORPHANED_ENTRIES) -async def async_configure_service(hass, data): +async def async_configure_service(gateway, data): """Set attribute of device in deCONZ. Entity is used to resolve to a device path (e.g. '/lights/1'). @@ -118,10 +133,6 @@ async def async_configure_service(hass, data): See Dresden Elektroniks REST API documentation for details: http://dresden-elektronik.github.io/deconz-rest-doc/rest/ """ - gateway = get_master_gateway(hass) - if CONF_BRIDGE_ID in data: - gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] - field = data.get(SERVICE_FIELD, "") entity_id = data.get(SERVICE_ENTITY) data = data[SERVICE_DATA] @@ -136,31 +147,21 @@ async def async_configure_service(hass, data): await gateway.api.request("put", field, json=data) -async def async_refresh_devices_service(hass, data): +async def async_refresh_devices_service(gateway): """Refresh available devices from deCONZ.""" - gateway = get_master_gateway(hass) - if CONF_BRIDGE_ID in data: - gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] - gateway.ignore_state_updates = True await gateway.api.refresh_state() gateway.ignore_state_updates = False - gateway.async_add_device_callback(NEW_GROUP, force=True) - gateway.async_add_device_callback(NEW_LIGHT, force=True) - gateway.async_add_device_callback(NEW_SCENE, force=True) - gateway.async_add_device_callback(NEW_SENSOR, force=True) + for new_device_type in [NEW_GROUP, NEW_LIGHT, NEW_SCENE, NEW_SENSOR]: + gateway.async_add_device_callback(new_device_type, force=True) -async def async_remove_orphaned_entries_service(hass, data): +async def async_remove_orphaned_entries_service(gateway): """Remove orphaned deCONZ entries from device and entity registries.""" - gateway = get_master_gateway(hass) - if CONF_BRIDGE_ID in data: - gateway = hass.data[DOMAIN][normalize_bridge_id(data[CONF_BRIDGE_ID])] - device_registry, entity_registry = await asyncio.gather( - hass.helpers.device_registry.async_get_registry(), - hass.helpers.entity_registry.async_get_registry(), + gateway.hass.helpers.device_registry.async_get_registry(), + gateway.hass.helpers.entity_registry.async_get_registry(), ) entity_entries = async_entries_for_config_entry( diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index 6583372d7bd..814ec588b1e 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -61,8 +61,8 @@ async def test_setup_entry_successful(hass, aioclient_mock): config_entry = await setup_deconz_integration(hass, aioclient_mock) assert hass.data[DECONZ_DOMAIN] - assert config_entry.unique_id in hass.data[DECONZ_DOMAIN] - assert hass.data[DECONZ_DOMAIN][config_entry.unique_id].master + assert config_entry.entry_id in hass.data[DECONZ_DOMAIN] + assert hass.data[DECONZ_DOMAIN][config_entry.entry_id].master async def test_setup_entry_multiple_gateways(hass, aioclient_mock): @@ -80,8 +80,8 @@ async def test_setup_entry_multiple_gateways(hass, aioclient_mock): ) assert len(hass.data[DECONZ_DOMAIN]) == 2 - assert hass.data[DECONZ_DOMAIN][config_entry.unique_id].master - assert not hass.data[DECONZ_DOMAIN][config_entry2.unique_id].master + assert hass.data[DECONZ_DOMAIN][config_entry.entry_id].master + assert not hass.data[DECONZ_DOMAIN][config_entry2.entry_id].master async def test_unload_entry(hass, aioclient_mock): @@ -112,7 +112,7 @@ async def test_unload_entry_multiple_gateways(hass, aioclient_mock): assert await async_unload_entry(hass, config_entry) assert len(hass.data[DECONZ_DOMAIN]) == 1 - assert hass.data[DECONZ_DOMAIN][config_entry2.unique_id].master + assert hass.data[DECONZ_DOMAIN][config_entry2.entry_id].master async def test_update_group_unique_id(hass): diff --git a/tests/components/deconz/test_services.py b/tests/components/deconz/test_services.py index 7ad9c82b08c..8a696da9eb4 100644 --- a/tests/components/deconz/test_services.py +++ b/tests/components/deconz/test_services.py @@ -152,8 +152,27 @@ async def test_configure_service_with_entity_and_field(hass, aioclient_mock): assert aioclient_mock.mock_calls[1][2] == {"on": True, "attr1": 10, "attr2": 20} +async def test_configure_service_with_faulty_bridgeid(hass, aioclient_mock): + """Test that service fails on a bad bridge id.""" + await setup_deconz_integration(hass, aioclient_mock) + aioclient_mock.clear_requests() + + data = { + CONF_BRIDGE_ID: "Bad bridge id", + SERVICE_FIELD: "/lights/1", + SERVICE_DATA: {"on": True}, + } + + await hass.services.async_call( + DECONZ_DOMAIN, SERVICE_CONFIGURE_DEVICE, service_data=data + ) + await hass.async_block_till_done() + + assert len(aioclient_mock.mock_calls) == 0 + + async def test_configure_service_with_faulty_field(hass, aioclient_mock): - """Test that service invokes pydeconz with the correct path and data.""" + """Test that service fails on a bad field.""" await setup_deconz_integration(hass, aioclient_mock) data = {SERVICE_FIELD: "light/2", SERVICE_DATA: {}} @@ -166,7 +185,7 @@ async def test_configure_service_with_faulty_field(hass, aioclient_mock): async def test_configure_service_with_faulty_entity(hass, aioclient_mock): - """Test that service invokes pydeconz with the correct path and data.""" + """Test that service on a non existing entity.""" await setup_deconz_integration(hass, aioclient_mock) aioclient_mock.clear_requests() From bad2525a6d99168529b7d43a915f283552db92e7 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Tue, 6 Jul 2021 15:49:22 +0200 Subject: [PATCH 053/134] Fix Fritz Wi-Fi 6 networks with same name as other Wi-Fi (#52588) --- homeassistant/components/fritz/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index d9690b64069..16eaecb178d 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -245,7 +245,7 @@ def wifi_entities_list( ) -> list[FritzBoxWifiSwitch]: """Get list of wifi entities.""" _LOGGER.debug("Setting up %s switches", SWITCH_TYPE_WIFINETWORK) - std_table = {"ac": "5Ghz", "n": "2.4Ghz"} + std_table = {"ax": "Wifi6", "ac": "5Ghz", "n": "2.4Ghz"} networks: dict = {} for i in range(4): if not ("WLANConfiguration" + str(i)) in fritzbox_tools.connection.services: From b14b284e62659f84d3e0c41bbc096b33f992c721 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 6 Jul 2021 18:51:38 +0200 Subject: [PATCH 054/134] Bumped version to 2021.7.0b5 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1726cb6f48a..bb46c3b6b87 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b4" +PATCH_VERSION: Final = "0b5" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From dd26bfb92b52d74c0b2e3aa281d52647353cab89 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 7 Jul 2021 01:34:14 -0700 Subject: [PATCH 055/134] Fix mysensors rgb light (#52604) * remove assert self._white as not all RGB will have a white channel * suggested change * Update homeassistant/components/mysensors/light.py Co-authored-by: Erik Montnemery Co-authored-by: Franck Nijhof Co-authored-by: Erik Montnemery --- homeassistant/components/mysensors/light.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py index 81089f052b6..b08d94cebb0 100644 --- a/homeassistant/components/mysensors/light.py +++ b/homeassistant/components/mysensors/light.py @@ -132,7 +132,6 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): def _turn_on_rgb_and_w(self, hex_template: str, **kwargs: Any) -> None: """Turn on RGB or RGBW child device.""" assert self._hs - assert self._white is not None rgb = list(color_util.color_hs_to_RGB(*self._hs)) white = self._white hex_color = self._values.get(self.value_type) @@ -151,8 +150,10 @@ class MySensorsLight(mysensors.device.MySensorsEntity, LightEntity): if hex_template == "%02x%02x%02x%02x": if new_white is not None: rgb.append(new_white) - else: + elif white is not None: rgb.append(white) + else: + rgb.append(0) hex_color = hex_template % tuple(rgb) if len(rgb) > 3: white = rgb.pop() From a7ee86730c7b57edbb21077222c70821879939ce Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 7 Jul 2021 02:30:48 -0400 Subject: [PATCH 056/134] Bump up ZHA dependencies (#52611) --- 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 68feabb18b4..d37abea2310 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -7,7 +7,7 @@ "bellows==0.25.0", "pyserial==3.5", "pyserial-asyncio==0.5", - "zha-quirks==0.0.58", + "zha-quirks==0.0.59", "zigpy-cc==0.5.2", "zigpy-deconz==0.12.0", "zigpy==0.35.1", diff --git a/requirements_all.txt b/requirements_all.txt index 1065675c13b..c917457162c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2427,7 +2427,7 @@ zengge==0.2 zeroconf==0.32.1 # homeassistant.components.zha -zha-quirks==0.0.58 +zha-quirks==0.0.59 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 074d78522e0..67fba539962 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1327,7 +1327,7 @@ zeep[async]==4.0.0 zeroconf==0.32.1 # homeassistant.components.zha -zha-quirks==0.0.58 +zha-quirks==0.0.59 # homeassistant.components.zha zigpy-cc==0.5.2 From a794c09a0f44dbe375c544b0bf14d85557463ff4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Jul 2021 02:23:24 -0500 Subject: [PATCH 057/134] Fix deadlock at shutdown with python 3.9 (#52613) --- homeassistant/runner.py | 10 ---------- homeassistant/util/executor.py | 2 +- tests/util/test_executor.py | 12 ++++++------ 3 files changed, 7 insertions(+), 17 deletions(-) diff --git a/homeassistant/runner.py b/homeassistant/runner.py index 86bebecb7b1..5eae0b1b2da 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -73,16 +73,6 @@ class HassEventLoopPolicy(asyncio.DefaultEventLoopPolicy): # type: ignore[valid loop.set_default_executor = warn_use( # type: ignore loop.set_default_executor, "sets default executor on the event loop" ) - - # Shut down executor when we shut down loop - orig_close = loop.close - - def close() -> None: - executor.logged_shutdown() - orig_close() - - loop.close = close # type: ignore - return loop diff --git a/homeassistant/util/executor.py b/homeassistant/util/executor.py index c25c6b9c13f..9277e396bc4 100644 --- a/homeassistant/util/executor.py +++ b/homeassistant/util/executor.py @@ -62,7 +62,7 @@ def join_or_interrupt_threads( class InterruptibleThreadPoolExecutor(ThreadPoolExecutor): """A ThreadPoolExecutor instance that will not deadlock on shutdown.""" - def logged_shutdown(self) -> None: + def shutdown(self, *args, **kwargs) -> None: # type: ignore """Shutdown backport from cpython 3.9 with interrupt support added.""" with self._shutdown_lock: # type: ignore[attr-defined] self._shutdown = True diff --git a/tests/util/test_executor.py b/tests/util/test_executor.py index 911145ecc4e..eaa48c75d1a 100644 --- a/tests/util/test_executor.py +++ b/tests/util/test_executor.py @@ -24,7 +24,7 @@ async def test_executor_shutdown_can_interrupt_threads(caplog): for _ in range(100): sleep_futures.append(iexecutor.submit(_loop_sleep_in_executor)) - iexecutor.logged_shutdown() + iexecutor.shutdown() for future in sleep_futures: with pytest.raises((concurrent.futures.CancelledError, SystemExit)): @@ -45,13 +45,13 @@ async def test_executor_shutdown_only_logs_max_attempts(caplog): iexecutor.submit(_loop_sleep_in_executor) with patch.object(executor, "EXECUTOR_SHUTDOWN_TIMEOUT", 0.3): - iexecutor.logged_shutdown() + iexecutor.shutdown() assert "time.sleep(0.2)" in caplog.text assert ( caplog.text.count("is still running at shutdown") == executor.MAX_LOG_ATTEMPTS ) - iexecutor.logged_shutdown() + iexecutor.shutdown() async def test_executor_shutdown_does_not_log_shutdown_on_first_attempt(caplog): @@ -65,7 +65,7 @@ async def test_executor_shutdown_does_not_log_shutdown_on_first_attempt(caplog): for _ in range(5): iexecutor.submit(_do_nothing) - iexecutor.logged_shutdown() + iexecutor.shutdown() assert "is still running at shutdown" not in caplog.text @@ -83,9 +83,9 @@ async def test_overall_timeout_reached(caplog): start = time.monotonic() with patch.object(executor, "EXECUTOR_SHUTDOWN_TIMEOUT", 0.5): - iexecutor.logged_shutdown() + iexecutor.shutdown() finish = time.monotonic() assert finish - start < 1 - iexecutor.logged_shutdown() + iexecutor.shutdown() From 998ffeb21d10a643f2e70d8a2a189dbd125662ac Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 7 Jul 2021 09:46:59 +0200 Subject: [PATCH 058/134] Fix broadlink creating duplicate unique IDs (#52621) --- homeassistant/components/broadlink/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py index 1f599d6d108..1576c8b8418 100644 --- a/homeassistant/components/broadlink/switch.py +++ b/homeassistant/components/broadlink/switch.py @@ -146,7 +146,6 @@ class BroadlinkSwitch(BroadlinkEntity, SwitchEntity, RestoreEntity, ABC): self._attr_assumed_state = True self._attr_device_class = DEVICE_CLASS_SWITCH self._attr_name = f"{self._device.name} Switch" - self._attr_unique_id = self._device.unique_id @property def is_on(self): @@ -215,6 +214,7 @@ class BroadlinkSP1Switch(BroadlinkSwitch): def __init__(self, device): """Initialize the switch.""" super().__init__(device, 1, 0) + self._attr_unique_id = self._device.unique_id async def _async_send_packet(self, packet): """Send a packet to the device.""" From f7c844d728ebaedcfa973d2852de5fb6028220b9 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 7 Jul 2021 10:43:45 +0200 Subject: [PATCH 059/134] Update frontend to 20210707.0 (#52624) --- 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 c7283a9503a..7af6e2bc733 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==20210706.0" + "home-assistant-frontend==20210707.0" ], "dependencies": [ "api", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c35ff252b52..908cd379886 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -17,7 +17,7 @@ defusedxml==0.7.1 distro==1.5.0 emoji==1.2.0 hass-nabucasa==0.44.0 -home-assistant-frontend==20210706.0 +home-assistant-frontend==20210707.0 httpx==0.18.0 ifaddr==0.1.7 jinja2==3.0.1 diff --git a/requirements_all.txt b/requirements_all.txt index c917457162c..5efcc2d3d3a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -780,7 +780,7 @@ hole==0.5.1 holidays==0.11.1 # homeassistant.components.frontend -home-assistant-frontend==20210706.0 +home-assistant-frontend==20210707.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 67fba539962..f084564b39e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -447,7 +447,7 @@ hole==0.5.1 holidays==0.11.1 # homeassistant.components.frontend -home-assistant-frontend==20210706.0 +home-assistant-frontend==20210707.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.10 From a048809ca7db0ed7b1304b3216e2a531cffa5d47 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 7 Jul 2021 11:21:23 +0200 Subject: [PATCH 060/134] Bumped version to 2021.7.0b6 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index bb46c3b6b87..def1d6410e5 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b5" +PATCH_VERSION: Final = "0b6" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 342366750b0e37338376b71dd68f1e1eada45cd6 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 7 Jul 2021 13:09:52 +0200 Subject: [PATCH 061/134] Bumped version to 2021.7.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index def1d6410e5..27eafd0287e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0b6" +PATCH_VERSION: Final = "0" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 5185fa75d3afaeb15b88edc62755bee8e6ddfdb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ana=C3=AFs=20Betts?= Date: Wed, 7 Jul 2021 17:25:52 +0200 Subject: [PATCH 062/134] Fix service registration typo in Nuki integration (#52631) --- homeassistant/components/nuki/lock.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 9cb1bd01524..25644a49f0f 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -59,6 +59,9 @@ async def async_setup_entry(hass, entry, async_add_entities): vol.Optional(ATTR_UNLATCH, default=False): cv.boolean, }, "lock_n_go", + ) + + platform.async_register_entity_service( "set_continuous_mode", { vol.Required(ATTR_ENABLE): cv.boolean, From 84be418bf1e3c009c8abc46c7a2110f038cab51b Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 7 Jul 2021 20:19:31 +0200 Subject: [PATCH 063/134] Fix Fritz default consider home value (#52648) --- homeassistant/components/fritz/common.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 776c7a7a22e..332c9b795f8 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -195,12 +195,13 @@ class FritzBoxTools: """Scan for new devices and return a list of found device ids.""" _LOGGER.debug("Checking devices for FRITZ!Box router %s", self.host) + _default_consider_home = DEFAULT_CONSIDER_HOME.total_seconds() if self._options: consider_home = self._options.get( - CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME.total_seconds() + CONF_CONSIDER_HOME, _default_consider_home ) else: - consider_home = DEFAULT_CONSIDER_HOME + consider_home = _default_consider_home new_device = False for known_host in self._update_info(): From 8173dd06fe7b33739314e56c24fd6aa717f9cd95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 7 Jul 2021 20:18:43 +0200 Subject: [PATCH 064/134] Handle KeyError when accessing device information (#52650) --- homeassistant/components/ecovacs/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py index 964dd7a3f2a..8a6475c0192 100644 --- a/homeassistant/components/ecovacs/__init__.py +++ b/homeassistant/components/ecovacs/__init__.py @@ -59,8 +59,8 @@ def setup(hass, config): for device in devices: _LOGGER.info( "Discovered Ecovacs device on account: %s with nickname %s", - device["did"], - device["nick"], + device.get("did"), + device.get("nick"), ) vacbot = VacBot( ecovacs_api.uid, @@ -77,7 +77,8 @@ def setup(hass, config): """Shut down open connections to Ecovacs XMPP server.""" for device in hass.data[ECOVACS_DEVICES]: _LOGGER.info( - "Shutting down connection to Ecovacs device %s", device.vacuum["did"] + "Shutting down connection to Ecovacs device %s", + device.vacuum.get("did"), ) device.disconnect() From b9827a5b2eb64d91bc4483166fad2a5f73b74c3e Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 8 Jul 2021 02:15:56 -0500 Subject: [PATCH 065/134] Warn if `interface_addr` remains in Sonos configuration (#52652) --- homeassistant/components/sonos/__init__.py | 26 +++++++++++++++------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 218ddaa8e15..ec16ec5bd87 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -52,14 +52,17 @@ CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { - MP_DOMAIN: vol.Schema( - { - vol.Optional(CONF_ADVERTISE_ADDR): cv.string, - vol.Optional(CONF_INTERFACE_ADDR): cv.string, - vol.Optional(CONF_HOSTS): vol.All( - cv.ensure_list_csv, [cv.string] - ), - } + MP_DOMAIN: vol.All( + cv.deprecated(CONF_INTERFACE_ADDR), + vol.Schema( + { + vol.Optional(CONF_ADVERTISE_ADDR): cv.string, + vol.Optional(CONF_INTERFACE_ADDR): cv.string, + vol.Optional(CONF_HOSTS): vol.All( + cv.ensure_list_csv, [cv.string] + ), + } + ), ) } ) @@ -126,6 +129,13 @@ async def async_setup_entry( # noqa: C901 if advertise_addr: pysonos.config.EVENT_ADVERTISE_IP = advertise_addr + if deprecated_address := config.get(CONF_INTERFACE_ADDR): + _LOGGER.warning( + "'%s' is deprecated, enable %s in the Network integration (https://www.home-assistant.io/integrations/network/)", + CONF_INTERFACE_ADDR, + deprecated_address, + ) + async def _async_stop_event_listener(event: Event) -> None: await asyncio.gather( *[speaker.async_unsubscribe() for speaker in data.discovered.values()], From ef309a7c1208d1b41f881a5ddcff5834f597e05d Mon Sep 17 00:00:00 2001 From: jjlawren Date: Thu, 8 Jul 2021 04:56:50 -0500 Subject: [PATCH 066/134] Ignore unused keys from Sonos device properties callback (#52660) * Ignore known but unused keys from device callback * Fix bug, add test --- homeassistant/components/sonos/speaker.py | 5 +++++ tests/components/sonos/test_sensor.py | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index ec59d946c09..14adbc337fb 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -71,6 +71,7 @@ SUBSCRIPTION_SERVICES = [ "zoneGroupTopology", ] UNAVAILABLE_VALUES = {"", "NOT_IMPLEMENTED", None} +UNUSED_DEVICE_KEYS = ["SPID", "TargetRoomName"] _LOGGER = logging.getLogger(__name__) @@ -407,6 +408,10 @@ class SonosSpeaker: """Update device properties from an event.""" if more_info := event.variables.get("more_info"): battery_dict = dict(x.split(":") for x in more_info.split(",")) + for unused in UNUSED_DEVICE_KEYS: + battery_dict.pop(unused, None) + if not battery_dict: + return if "BattChg" not in battery_dict: _LOGGER.debug( "Unknown device properties update for %s (%s), please report an issue: '%s'", diff --git a/tests/components/sonos/test_sensor.py b/tests/components/sonos/test_sensor.py index 8d402b589b0..80f050fe6fc 100644 --- a/tests/components/sonos/test_sensor.py +++ b/tests/components/sonos/test_sensor.py @@ -103,3 +103,23 @@ async def test_device_payload_without_battery( await hass.async_block_till_done() assert bad_payload in caplog.text + + +async def test_device_payload_without_battery_and_ignored_keys( + hass, config_entry, config, soco, battery_event, caplog +): + """Test device properties event update without battery info and ignored keys.""" + soco.get_battery_info.return_value = None + + await setup_platform(hass, config_entry, config) + + subscription = soco.deviceProperties.subscribe.return_value + sub_callback = subscription.callback + + ignored_payload = "SPID:InCeiling,TargetRoomName:Bouncy House" + battery_event.variables["more_info"] = ignored_payload + + sub_callback(battery_event) + await hass.async_block_till_done() + + assert ignored_payload not in caplog.text From 57f6a96e3178b2ebf7708a2f936f5e50dd5e690e Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 8 Jul 2021 10:09:30 +0200 Subject: [PATCH 067/134] Ensure Forecast.Solar returns an iso formatted timestamp (#52669) --- homeassistant/components/forecast_solar/sensor.py | 6 +++++- tests/components/forecast_solar/test_sensor.py | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/forecast_solar/sensor.py b/homeassistant/components/forecast_solar/sensor.py index a6b1927926e..b32f1f341be 100644 --- a/homeassistant/components/forecast_solar/sensor.py +++ b/homeassistant/components/forecast_solar/sensor.py @@ -1,6 +1,8 @@ """Support for the Forecast.Solar sensor service.""" from __future__ import annotations +from datetime import datetime + from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_IDENTIFIERS, ATTR_MANUFACTURER, ATTR_NAME @@ -64,5 +66,7 @@ class ForecastSolarSensorEntity(CoordinatorEntity, SensorEntity): @property def state(self) -> StateType: """Return the state of the sensor.""" - state: StateType = getattr(self.coordinator.data, self._sensor.key) + state: StateType | datetime = getattr(self.coordinator.data, self._sensor.key) + if isinstance(state, datetime): + return state.isoformat() return state diff --git a/tests/components/forecast_solar/test_sensor.py b/tests/components/forecast_solar/test_sensor.py index a3513b86a5d..31c367678c1 100644 --- a/tests/components/forecast_solar/test_sensor.py +++ b/tests/components/forecast_solar/test_sensor.py @@ -70,7 +70,7 @@ async def test_sensors( assert entry assert state assert entry.unique_id == f"{entry_id}_power_highest_peak_time_today" - assert state.state == "2021-06-27 13:00:00+00:00" + assert state.state == "2021-06-27T13:00:00+00:00" assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Highest Power Peak Time - Today" assert state.attributes.get(ATTR_STATE_CLASS) is None assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TIMESTAMP @@ -82,7 +82,7 @@ async def test_sensors( assert entry assert state assert entry.unique_id == f"{entry_id}_power_highest_peak_time_tomorrow" - assert state.state == "2021-06-27 14:00:00+00:00" + assert state.state == "2021-06-27T14:00:00+00:00" assert ( state.attributes.get(ATTR_FRIENDLY_NAME) == "Highest Power Peak Time - Tomorrow" ) From 9f4014190729e599780ddf60eeb8550a6a11b48f Mon Sep 17 00:00:00 2001 From: avee87 Date: Thu, 8 Jul 2021 09:01:06 +0100 Subject: [PATCH 068/134] Use iso-formatted times in MetOffice weather forecast (#52672) * Fixed raw datetime in MetOffice weather forecast * Use datetime in sensor attribute --- homeassistant/components/metoffice/weather.py | 2 +- tests/components/metoffice/const.py | 3 +- tests/components/metoffice/test_sensor.py | 18 +- tests/components/metoffice/test_weather.py | 194 +++++++++--------- 4 files changed, 103 insertions(+), 114 deletions(-) diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py index 0b1933c665f..b02539f0e31 100644 --- a/homeassistant/components/metoffice/weather.py +++ b/homeassistant/components/metoffice/weather.py @@ -47,7 +47,7 @@ async def async_setup_entry( def _build_forecast_data(timestep): data = {} - data[ATTR_FORECAST_TIME] = timestep.date + data[ATTR_FORECAST_TIME] = timestep.date.isoformat() if timestep.weather: data[ATTR_FORECAST_CONDITION] = _get_weather_condition(timestep.weather.value) if timestep.precipitation: diff --git a/tests/components/metoffice/const.py b/tests/components/metoffice/const.py index 5d8d781b042..c9a173e3f12 100644 --- a/tests/components/metoffice/const.py +++ b/tests/components/metoffice/const.py @@ -2,8 +2,7 @@ from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME -DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S%z" -TEST_DATETIME_STRING = "2020-04-25 12:00:00+0000" +TEST_DATETIME_STRING = "2020-04-25T12:00:00+00:00" TEST_API_KEY = "test-metoffice-api-key" diff --git a/tests/components/metoffice/test_sensor.py b/tests/components/metoffice/test_sensor.py index e603d0f93f6..201c5922d33 100644 --- a/tests/components/metoffice/test_sensor.py +++ b/tests/components/metoffice/test_sensor.py @@ -6,7 +6,6 @@ from homeassistant.components.metoffice.const import ATTRIBUTION, DOMAIN from . import NewDateTime from .const import ( - DATETIME_FORMAT, KINGSLYNN_SENSOR_RESULTS, METOFFICE_CONFIG_KINGSLYNN, METOFFICE_CONFIG_WAVERTREE, @@ -54,13 +53,10 @@ async def test_one_sensor_site_running(hass, requests_mock, legacy_patchable_tim for running_id in running_sensor_ids: sensor = hass.states.get(running_id) sensor_id = sensor.attributes.get("sensor_id") - sensor_name, sensor_value = WAVERTREE_SENSOR_RESULTS[sensor_id] + _, sensor_value = WAVERTREE_SENSOR_RESULTS[sensor_id] assert sensor.state == sensor_value - assert ( - sensor.attributes.get("last_update").strftime(DATETIME_FORMAT) - == TEST_DATETIME_STRING - ) + assert sensor.attributes.get("last_update").isoformat() == TEST_DATETIME_STRING assert sensor.attributes.get("site_id") == "354107" assert sensor.attributes.get("site_name") == TEST_SITE_NAME_WAVERTREE assert sensor.attributes.get("attribution") == ATTRIBUTION @@ -115,11 +111,10 @@ async def test_two_sensor_sites_running(hass, requests_mock, legacy_patchable_ti sensor = hass.states.get(running_id) sensor_id = sensor.attributes.get("sensor_id") if sensor.attributes.get("site_id") == "354107": - sensor_name, sensor_value = WAVERTREE_SENSOR_RESULTS[sensor_id] + _, sensor_value = WAVERTREE_SENSOR_RESULTS[sensor_id] assert sensor.state == sensor_value assert ( - sensor.attributes.get("last_update").strftime(DATETIME_FORMAT) - == TEST_DATETIME_STRING + sensor.attributes.get("last_update").isoformat() == TEST_DATETIME_STRING ) assert sensor.attributes.get("sensor_id") == sensor_id assert sensor.attributes.get("site_id") == "354107" @@ -127,11 +122,10 @@ async def test_two_sensor_sites_running(hass, requests_mock, legacy_patchable_ti assert sensor.attributes.get("attribution") == ATTRIBUTION else: - sensor_name, sensor_value = KINGSLYNN_SENSOR_RESULTS[sensor_id] + _, sensor_value = KINGSLYNN_SENSOR_RESULTS[sensor_id] assert sensor.state == sensor_value assert ( - sensor.attributes.get("last_update").strftime(DATETIME_FORMAT) - == TEST_DATETIME_STRING + sensor.attributes.get("last_update").isoformat() == TEST_DATETIME_STRING ) assert sensor.attributes.get("sensor_id") == sensor_id assert sensor.attributes.get("site_id") == "322380" diff --git a/tests/components/metoffice/test_weather.py b/tests/components/metoffice/test_weather.py index 6d4187c7023..21b2196804c 100644 --- a/tests/components/metoffice/test_weather.py +++ b/tests/components/metoffice/test_weather.py @@ -9,7 +9,6 @@ from homeassistant.util import utcnow from . import NewDateTime from .const import ( - DATETIME_FORMAT, METOFFICE_CONFIG_KINGSLYNN, METOFFICE_CONFIG_WAVERTREE, WAVERTREE_SENSOR_RESULTS, @@ -74,11 +73,11 @@ async def test_site_cannot_update(hass, requests_mock, legacy_patchable_time): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - entity = hass.states.get("weather.met_office_wavertree_3_hourly") - assert entity + weather = hass.states.get("weather.met_office_wavertree_3_hourly") + assert weather - entity = hass.states.get("weather.met_office_wavertree_daily") - assert entity + weather = hass.states.get("weather.met_office_wavertree_daily") + assert weather requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=3hourly", text="") requests_mock.get("/public/data/val/wxfcs/all/json/354107?res=daily", text="") @@ -87,11 +86,11 @@ async def test_site_cannot_update(hass, requests_mock, legacy_patchable_time): async_fire_time_changed(hass, future_time) await hass.async_block_till_done() - entity = hass.states.get("weather.met_office_wavertree_3_hourly") - assert entity.state == STATE_UNAVAILABLE + weather = hass.states.get("weather.met_office_wavertree_3_hourly") + assert weather.state == STATE_UNAVAILABLE - entity = hass.states.get("weather.met_office_wavertree_daily") - assert entity.state == STATE_UNAVAILABLE + weather = hass.states.get("weather.met_office_wavertree_daily") + assert weather.state == STATE_UNAVAILABLE @patch( @@ -126,50 +125,49 @@ async def test_one_weather_site_running(hass, requests_mock, legacy_patchable_ti await hass.async_block_till_done() # Wavertree 3-hourly weather platform expected results - entity = hass.states.get("weather.met_office_wavertree_3_hourly") - assert entity + weather = hass.states.get("weather.met_office_wavertree_3_hourly") + assert weather - assert entity.state == "sunny" - assert entity.attributes.get("temperature") == 17 - assert entity.attributes.get("wind_speed") == 9 - assert entity.attributes.get("wind_bearing") == "SSE" - assert entity.attributes.get("visibility") == "Good - 10-20" - assert entity.attributes.get("humidity") == 50 + assert weather.state == "sunny" + assert weather.attributes.get("temperature") == 17 + assert weather.attributes.get("wind_speed") == 9 + assert weather.attributes.get("wind_bearing") == "SSE" + assert weather.attributes.get("visibility") == "Good - 10-20" + assert weather.attributes.get("humidity") == 50 # Forecasts added - just pick out 1 entry to check - assert len(entity.attributes.get("forecast")) == 35 + assert len(weather.attributes.get("forecast")) == 35 assert ( - entity.attributes.get("forecast")[26]["datetime"].strftime(DATETIME_FORMAT) - == "2020-04-28 21:00:00+0000" + weather.attributes.get("forecast")[26]["datetime"] + == "2020-04-28T21:00:00+00:00" ) - assert entity.attributes.get("forecast")[26]["condition"] == "cloudy" - assert entity.attributes.get("forecast")[26]["temperature"] == 10 - assert entity.attributes.get("forecast")[26]["wind_speed"] == 4 - assert entity.attributes.get("forecast")[26]["wind_bearing"] == "NNE" + assert weather.attributes.get("forecast")[26]["condition"] == "cloudy" + assert weather.attributes.get("forecast")[26]["temperature"] == 10 + assert weather.attributes.get("forecast")[26]["wind_speed"] == 4 + assert weather.attributes.get("forecast")[26]["wind_bearing"] == "NNE" # Wavertree daily weather platform expected results - entity = hass.states.get("weather.met_office_wavertree_daily") - assert entity + weather = hass.states.get("weather.met_office_wavertree_daily") + assert weather - assert entity.state == "sunny" - assert entity.attributes.get("temperature") == 19 - assert entity.attributes.get("wind_speed") == 9 - assert entity.attributes.get("wind_bearing") == "SSE" - assert entity.attributes.get("visibility") == "Good - 10-20" - assert entity.attributes.get("humidity") == 50 + assert weather.state == "sunny" + assert weather.attributes.get("temperature") == 19 + assert weather.attributes.get("wind_speed") == 9 + assert weather.attributes.get("wind_bearing") == "SSE" + assert weather.attributes.get("visibility") == "Good - 10-20" + assert weather.attributes.get("humidity") == 50 # Also has Forecasts added - again, just pick out 1 entry to check - assert len(entity.attributes.get("forecast")) == 8 + assert len(weather.attributes.get("forecast")) == 8 assert ( - entity.attributes.get("forecast")[7]["datetime"].strftime(DATETIME_FORMAT) - == "2020-04-29 12:00:00+0000" + weather.attributes.get("forecast")[7]["datetime"] == "2020-04-29T12:00:00+00:00" ) - assert entity.attributes.get("forecast")[7]["condition"] == "rainy" - assert entity.attributes.get("forecast")[7]["temperature"] == 13 - assert entity.attributes.get("forecast")[7]["wind_speed"] == 13 - assert entity.attributes.get("forecast")[7]["wind_bearing"] == "SE" + assert weather.attributes.get("forecast")[7]["condition"] == "rainy" + assert weather.attributes.get("forecast")[7]["temperature"] == 13 + assert weather.attributes.get("forecast")[7]["wind_speed"] == 13 + assert weather.attributes.get("forecast")[7]["wind_bearing"] == "SE" @patch( @@ -216,93 +214,91 @@ async def test_two_weather_sites_running(hass, requests_mock, legacy_patchable_t await hass.async_block_till_done() # Wavertree 3-hourly weather platform expected results - entity = hass.states.get("weather.met_office_wavertree_3_hourly") - assert entity + weather = hass.states.get("weather.met_office_wavertree_3_hourly") + assert weather - assert entity.state == "sunny" - assert entity.attributes.get("temperature") == 17 - assert entity.attributes.get("wind_speed") == 9 - assert entity.attributes.get("wind_bearing") == "SSE" - assert entity.attributes.get("visibility") == "Good - 10-20" - assert entity.attributes.get("humidity") == 50 + assert weather.state == "sunny" + assert weather.attributes.get("temperature") == 17 + assert weather.attributes.get("wind_speed") == 9 + assert weather.attributes.get("wind_bearing") == "SSE" + assert weather.attributes.get("visibility") == "Good - 10-20" + assert weather.attributes.get("humidity") == 50 # Forecasts added - just pick out 1 entry to check - assert len(entity.attributes.get("forecast")) == 35 + assert len(weather.attributes.get("forecast")) == 35 assert ( - entity.attributes.get("forecast")[18]["datetime"].strftime(DATETIME_FORMAT) - == "2020-04-27 21:00:00+0000" + weather.attributes.get("forecast")[18]["datetime"] + == "2020-04-27T21:00:00+00:00" ) - assert entity.attributes.get("forecast")[18]["condition"] == "sunny" - assert entity.attributes.get("forecast")[18]["temperature"] == 9 - assert entity.attributes.get("forecast")[18]["wind_speed"] == 4 - assert entity.attributes.get("forecast")[18]["wind_bearing"] == "NW" + assert weather.attributes.get("forecast")[18]["condition"] == "sunny" + assert weather.attributes.get("forecast")[18]["temperature"] == 9 + assert weather.attributes.get("forecast")[18]["wind_speed"] == 4 + assert weather.attributes.get("forecast")[18]["wind_bearing"] == "NW" # Wavertree daily weather platform expected results - entity = hass.states.get("weather.met_office_wavertree_daily") - assert entity + weather = hass.states.get("weather.met_office_wavertree_daily") + assert weather - assert entity.state == "sunny" - assert entity.attributes.get("temperature") == 19 - assert entity.attributes.get("wind_speed") == 9 - assert entity.attributes.get("wind_bearing") == "SSE" - assert entity.attributes.get("visibility") == "Good - 10-20" - assert entity.attributes.get("humidity") == 50 + assert weather.state == "sunny" + assert weather.attributes.get("temperature") == 19 + assert weather.attributes.get("wind_speed") == 9 + assert weather.attributes.get("wind_bearing") == "SSE" + assert weather.attributes.get("visibility") == "Good - 10-20" + assert weather.attributes.get("humidity") == 50 # Also has Forecasts added - again, just pick out 1 entry to check - assert len(entity.attributes.get("forecast")) == 8 + assert len(weather.attributes.get("forecast")) == 8 assert ( - entity.attributes.get("forecast")[7]["datetime"].strftime(DATETIME_FORMAT) - == "2020-04-29 12:00:00+0000" + weather.attributes.get("forecast")[7]["datetime"] == "2020-04-29T12:00:00+00:00" ) - assert entity.attributes.get("forecast")[7]["condition"] == "rainy" - assert entity.attributes.get("forecast")[7]["temperature"] == 13 - assert entity.attributes.get("forecast")[7]["wind_speed"] == 13 - assert entity.attributes.get("forecast")[7]["wind_bearing"] == "SE" + assert weather.attributes.get("forecast")[7]["condition"] == "rainy" + assert weather.attributes.get("forecast")[7]["temperature"] == 13 + assert weather.attributes.get("forecast")[7]["wind_speed"] == 13 + assert weather.attributes.get("forecast")[7]["wind_bearing"] == "SE" # King's Lynn 3-hourly weather platform expected results - entity = hass.states.get("weather.met_office_king_s_lynn_3_hourly") - assert entity + weather = hass.states.get("weather.met_office_king_s_lynn_3_hourly") + assert weather - assert entity.state == "sunny" - assert entity.attributes.get("temperature") == 14 - assert entity.attributes.get("wind_speed") == 2 - assert entity.attributes.get("wind_bearing") == "E" - assert entity.attributes.get("visibility") == "Very Good - 20-40" - assert entity.attributes.get("humidity") == 60 + assert weather.state == "sunny" + assert weather.attributes.get("temperature") == 14 + assert weather.attributes.get("wind_speed") == 2 + assert weather.attributes.get("wind_bearing") == "E" + assert weather.attributes.get("visibility") == "Very Good - 20-40" + assert weather.attributes.get("humidity") == 60 # Also has Forecast added - just pick out 1 entry to check - assert len(entity.attributes.get("forecast")) == 35 + assert len(weather.attributes.get("forecast")) == 35 assert ( - entity.attributes.get("forecast")[18]["datetime"].strftime(DATETIME_FORMAT) - == "2020-04-27 21:00:00+0000" + weather.attributes.get("forecast")[18]["datetime"] + == "2020-04-27T21:00:00+00:00" ) - assert entity.attributes.get("forecast")[18]["condition"] == "cloudy" - assert entity.attributes.get("forecast")[18]["temperature"] == 10 - assert entity.attributes.get("forecast")[18]["wind_speed"] == 7 - assert entity.attributes.get("forecast")[18]["wind_bearing"] == "SE" + assert weather.attributes.get("forecast")[18]["condition"] == "cloudy" + assert weather.attributes.get("forecast")[18]["temperature"] == 10 + assert weather.attributes.get("forecast")[18]["wind_speed"] == 7 + assert weather.attributes.get("forecast")[18]["wind_bearing"] == "SE" # King's Lynn daily weather platform expected results - entity = hass.states.get("weather.met_office_king_s_lynn_daily") - assert entity + weather = hass.states.get("weather.met_office_king_s_lynn_daily") + assert weather - assert entity.state == "cloudy" - assert entity.attributes.get("temperature") == 9 - assert entity.attributes.get("wind_speed") == 4 - assert entity.attributes.get("wind_bearing") == "ESE" - assert entity.attributes.get("visibility") == "Very Good - 20-40" - assert entity.attributes.get("humidity") == 75 + assert weather.state == "cloudy" + assert weather.attributes.get("temperature") == 9 + assert weather.attributes.get("wind_speed") == 4 + assert weather.attributes.get("wind_bearing") == "ESE" + assert weather.attributes.get("visibility") == "Very Good - 20-40" + assert weather.attributes.get("humidity") == 75 # All should have Forecast added - again, just picking out 1 entry to check - assert len(entity.attributes.get("forecast")) == 8 + assert len(weather.attributes.get("forecast")) == 8 assert ( - entity.attributes.get("forecast")[5]["datetime"].strftime(DATETIME_FORMAT) - == "2020-04-28 12:00:00+0000" + weather.attributes.get("forecast")[5]["datetime"] == "2020-04-28T12:00:00+00:00" ) - assert entity.attributes.get("forecast")[5]["condition"] == "cloudy" - assert entity.attributes.get("forecast")[5]["temperature"] == 11 - assert entity.attributes.get("forecast")[5]["wind_speed"] == 7 - assert entity.attributes.get("forecast")[5]["wind_bearing"] == "ESE" + assert weather.attributes.get("forecast")[5]["condition"] == "cloudy" + assert weather.attributes.get("forecast")[5]["temperature"] == 11 + assert weather.attributes.get("forecast")[5]["wind_speed"] == 7 + assert weather.attributes.get("forecast")[5]["wind_bearing"] == "ESE" From 2038fb04b14749d7176212656c2afd8f27f0e635 Mon Sep 17 00:00:00 2001 From: Stefan Agner Date: Thu, 8 Jul 2021 11:39:56 +0200 Subject: [PATCH 069/134] Fix precipitation calculation for hourly forecast (#52676) It seems that hourly forecast have precipitation in 3h blocks. --- .../components/openweathermap/weather_update_coordinator.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 98f39290d22..73edc9fae75 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -191,6 +191,8 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): """Get rain data from weather data.""" if "all" in rain: return round(rain["all"], 2) + if "3h" in rain: + return round(rain["3h"], 2) if "1h" in rain: return round(rain["1h"], 2) return 0 @@ -201,6 +203,8 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): if snow: if "all" in snow: return round(snow["all"], 2) + if "3h" in snow: + return round(snow["3h"], 2) if "1h" in snow: return round(snow["1h"], 2) return 0 From 286c068f6fefe8a6a814835beaa7f2ec1a6eaa6b Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Thu, 8 Jul 2021 00:20:27 -0700 Subject: [PATCH 070/134] Move recorder.py import to runtime (#52682) --- homeassistant/components/stream/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index d8e4cb2cdb2..c7ca853c20c 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -43,7 +43,6 @@ from .const import ( ) from .core import PROVIDERS, IdleTimer, StreamOutput from .hls import async_setup_hls -from .recorder import RecorderOutput _LOGGER = logging.getLogger(__name__) @@ -265,6 +264,10 @@ class Stream: ) -> None: """Make a .mp4 recording from a provided stream.""" + # Keep import here so that we can import stream integration without installing reqs + # pylint: disable=import-outside-toplevel + from .recorder import RecorderOutput + # Check for file access if not self.hass.config.is_allowed_path(video_path): raise HomeAssistantError(f"Can't write {video_path}, no access to path!") From 5666e8b15510d4a53c624998785c21f065a8414a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 7 Jul 2021 18:45:39 -0500 Subject: [PATCH 071/134] Bump simplisafe-python to 11.0.1 (#52684) --- 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 eff37bf1548..02713b106bd 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==11.0.0"], + "requirements": ["simplisafe-python==11.0.1"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 5efcc2d3d3a..e0c259a6c3e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2102,7 +2102,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==11.0.0 +simplisafe-python==11.0.1 # homeassistant.components.sisyphus sisyphus-control==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f084564b39e..f69139d0148 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1148,7 +1148,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==11.0.0 +simplisafe-python==11.0.1 # homeassistant.components.slack slackclient==2.5.0 From 578ba6b065ce99551211f39e7919ea7f553a2d6e Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Thu, 8 Jul 2021 00:11:56 -0700 Subject: [PATCH 072/134] pyWeMo version bump (0.6.5) (#52701) --- homeassistant/components/wemo/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json index bd153294282..3d051fcc6dc 100644 --- a/homeassistant/components/wemo/manifest.json +++ b/homeassistant/components/wemo/manifest.json @@ -3,7 +3,7 @@ "name": "Belkin WeMo", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/wemo", - "requirements": ["pywemo==0.6.3"], + "requirements": ["pywemo==0.6.5"], "ssdp": [ { "manufacturer": "Belkin International Inc." diff --git a/requirements_all.txt b/requirements_all.txt index e0c259a6c3e..0c4bc904edf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1966,7 +1966,7 @@ pyvolumio==0.1.3 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.6.3 +pywemo==0.6.5 # homeassistant.components.wilight pywilight==0.0.70 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f69139d0148..b1bda645531 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1084,7 +1084,7 @@ pyvolumio==0.1.3 pywebpush==1.9.2 # homeassistant.components.wemo -pywemo==0.6.3 +pywemo==0.6.5 # homeassistant.components.wilight pywilight==0.0.70 From a4c563106deff7d1f7fb1040d42318789fc12d2a Mon Sep 17 00:00:00 2001 From: Jon Gilmore <7232986+JonGilmore@users.noreply.github.com> Date: Thu, 8 Jul 2021 08:18:08 -0500 Subject: [PATCH 073/134] Bump pylutron to 0.2.8 fixing python 3.9 incompatibility (#52702) --- homeassistant/components/lutron/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index db1c9090ce8..83c4ee72345 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -2,7 +2,7 @@ "domain": "lutron", "name": "Lutron", "documentation": "https://www.home-assistant.io/integrations/lutron", - "requirements": ["pylutron==0.2.7"], + "requirements": ["pylutron==0.2.8"], "codeowners": ["@JonGilmore"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 0c4bc904edf..89110d15ea2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1562,7 +1562,7 @@ pyloopenergy==0.2.1 pylutron-caseta==0.10.0 # homeassistant.components.lutron -pylutron==0.2.7 +pylutron==0.2.8 # homeassistant.components.mailgun pymailgunner==1.4 From 3b5c6039bb452b0abd60f98c3d1304090cd3acb8 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 8 Jul 2021 11:58:51 +0200 Subject: [PATCH 074/134] Add check for _client existence in modbus (#52719) --- .coveragerc | 1 + homeassistant/components/modbus/modbus.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.coveragerc b/.coveragerc index 7c741dc26cd..e05d97b66fb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -634,6 +634,7 @@ omit = homeassistant/components/mjpeg/camera.py homeassistant/components/mochad/* homeassistant/components/modbus/climate.py + homeassistant/components/modbus/modbus.py homeassistant/components/modem_callerid/sensor.py homeassistant/components/motion_blinds/__init__.py homeassistant/components/motion_blinds/const.py diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 2e5892dbf1d..0826f4d5794 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -310,6 +310,8 @@ class ModbusHub: """Convert async to sync pymodbus call.""" if self._config_delay: return None + if not self._client: + return None if not self._client.is_socket_open(): return None async with self._lock: From 15976555eb549d153a1ef338ef993eb7e4dd2ced Mon Sep 17 00:00:00 2001 From: Tom Matheussen Date: Thu, 8 Jul 2021 15:05:43 +0200 Subject: [PATCH 075/134] Fix KNX Fan features (#52732) * Fan entity should return support features * Revert "Fan entity should return support features" This reverts commit 3ad0e87708fbf1847aaa26e3bc76fcac365a1640. * Restore supported_features for KNX fan --- homeassistant/components/knx/fan.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/knx/fan.py b/homeassistant/components/knx/fan.py index f787795e1e8..21ede700fdd 100644 --- a/homeassistant/components/knx/fan.py +++ b/homeassistant/components/knx/fan.py @@ -66,9 +66,6 @@ class KNXFan(KnxEntity, FanEntity): # FanSpeedMode.STEP if max_step is set self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None - self._attr_supported_features = SUPPORT_SET_SPEED - if self._device.supports_oscillation: - self._attr_supported_features |= SUPPORT_OSCILLATE self._attr_unique_id = str(self._device.speed.group_address) async def async_set_percentage(self, percentage: int) -> None: @@ -79,6 +76,16 @@ class KNXFan(KnxEntity, FanEntity): else: await self._device.set_speed(percentage) + @property + def supported_features(self) -> int: + """Flag supported features.""" + flags = SUPPORT_SET_SPEED + + if self._device.supports_oscillation: + flags |= SUPPORT_OSCILLATE + + return flags + @property def percentage(self) -> int | None: """Return the current speed as a percentage.""" From 594584645d5a4e60e8ccf3f29fcbabe96b1d5c24 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 9 Jul 2021 00:44:49 +1200 Subject: [PATCH 076/134] Esphome fix camera image (#52738) --- homeassistant/components/esphome/camera.py | 4 ++-- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py index 7afd89bf9be..f047d5c1bdd 100644 --- a/homeassistant/components/esphome/camera.py +++ b/homeassistant/components/esphome/camera.py @@ -77,7 +77,7 @@ class EsphomeCamera(Camera, EsphomeBaseEntity): await self._image_cond.wait() if not self.available: return None - return self._state.image[:] + return self._state.data[:] async def _async_camera_stream_image(self) -> bytes | None: """Return a single camera image in a stream.""" @@ -88,7 +88,7 @@ class EsphomeCamera(Camera, EsphomeBaseEntity): await self._image_cond.wait() if not self.available: return None - return self._state.image[:] + return self._state.data[:] async def handle_async_mjpeg_stream(self, request): """Serve an HTTP MJPEG stream from the camera.""" diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e69299a4a43..e48cd4847c8 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==4.0.1"], + "requirements": ["aioesphomeapi==5.0.0"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/requirements_all.txt b/requirements_all.txt index 89110d15ea2..9d2ad7b154b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -160,7 +160,7 @@ aioeafm==0.1.2 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==4.0.1 +aioesphomeapi==5.0.0 # homeassistant.components.flo aioflo==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b1bda645531..3bad09bcaf5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -100,7 +100,7 @@ aioeafm==0.1.2 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==4.0.1 +aioesphomeapi==5.0.0 # homeassistant.components.flo aioflo==0.4.1 From 5b2164b3a2e04993fd507c7e85269eb7ab0fd93b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 8 Jul 2021 15:25:03 +0200 Subject: [PATCH 077/134] Bumped version to 2021.7.1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 27eafd0287e..373f382a06e 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "0" +PATCH_VERSION: Final = "1" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From f447adc5b66e37ed4359885d5fcbb2d89fc63298 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 12 Jul 2021 10:09:45 -0500 Subject: [PATCH 078/134] Ignore Sonos Boost devices during discovery (#52845) --- homeassistant/components/sonos/__init__.py | 4 ++++ homeassistant/components/sonos/speaker.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index ec16ec5bd87..040ee321206 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -46,6 +46,7 @@ _LOGGER = logging.getLogger(__name__) CONF_ADVERTISE_ADDR = "advertise_addr" CONF_INTERFACE_ADDR = "interface_addr" +DISCOVERY_IGNORED_MODELS = ["Sonos Boost"] CONFIG_SCHEMA = vol.Schema( @@ -233,6 +234,9 @@ async def async_setup_entry( # noqa: C901 @callback def _async_discovered_player(info): + if info.get("modelName") in DISCOVERY_IGNORED_MODELS: + _LOGGER.debug("Ignoring device: %s", info.get("friendlyName")) + return uid = info.get(ssdp.ATTR_UPNP_UDN) if uid.startswith("uuid:"): uid = uid[5:] diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index 14adbc337fb..b1483c0f5d3 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -511,7 +511,7 @@ class SonosSpeaker: await self.async_unsubscribe() if not will_reconnect: - self.hass.data[DATA_SONOS].ssdp_known.remove(self.soco.uid) + self.hass.data[DATA_SONOS].ssdp_known.discard(self.soco.uid) self.async_write_entity_states() async def async_rebooted(self, soco: SoCo) -> None: From edf517681cb350f9a8af93f7cefece5f91f262b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Jul 2021 06:24:12 -1000 Subject: [PATCH 079/134] Add zeroconf discovery to Sonos (#52655) --- homeassistant/components/sonos/__init__.py | 192 ++++++++++-------- homeassistant/components/sonos/config_flow.py | 48 ++++- homeassistant/components/sonos/const.py | 3 + homeassistant/components/sonos/helpers.py | 19 ++ homeassistant/components/sonos/manifest.json | 3 +- homeassistant/components/sonos/speaker.py | 25 ++- homeassistant/components/sonos/strings.json | 1 + .../components/sonos/translations/en.json | 1 + homeassistant/generated/zeroconf.py | 5 + tests/components/sonos/test_config_flow.py | 92 +++++++++ tests/components/sonos/test_helpers.py | 17 ++ 11 files changed, 315 insertions(+), 91 deletions(-) create mode 100644 tests/components/sonos/test_config_flow.py create mode 100644 tests/components/sonos/test_helpers.py diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index 040ee321206..3d810c7e1a3 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -31,6 +31,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send, dispatcher_s from .alarms import SonosAlarms from .const import ( DATA_SONOS, + DATA_SONOS_DISCOVERY_MANAGER, DISCOVERY_INTERVAL, DOMAIN, PLATFORMS, @@ -91,7 +92,7 @@ class SonosData: self.alarms: dict[str, SonosAlarms] = {} self.topology_condition = asyncio.Condition() self.hosts_heartbeat = None - self.ssdp_known: set[str] = set() + self.discovery_known: set[str] = set() self.boot_counts: dict[str, int] = {} @@ -111,9 +112,7 @@ async def async_setup(hass, config): return True -async def async_setup_entry( # noqa: C901 - hass: HomeAssistant, entry: ConfigEntry -) -> bool: +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Sonos from a config entry.""" pysonos.config.EVENTS_MODULE = events_asyncio @@ -123,7 +122,6 @@ async def async_setup_entry( # noqa: C901 data = hass.data[DATA_SONOS] config = hass.data[DOMAIN].get("media_player", {}) hosts = config.get(CONF_HOSTS, []) - discovery_lock = asyncio.Lock() _LOGGER.debug("Reached async_setup_entry, config=%s", config) advertise_addr = config.get(CONF_ADVERTISE_ADDR) @@ -137,153 +135,181 @@ async def async_setup_entry( # noqa: C901 deprecated_address, ) - async def _async_stop_event_listener(event: Event) -> None: + manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = SonosDiscoveryManager( + hass, entry, data, hosts + ) + hass.async_create_task(manager.setup_platforms_and_discovery()) + return True + + +def _create_soco(ip_address: str, source: SoCoCreationSource) -> SoCo | None: + """Create a soco instance and return if successful.""" + try: + soco = pysonos.SoCo(ip_address) + # Ensure that the player is available and UID is cached + _ = soco.uid + _ = soco.volume + return soco + except (OSError, SoCoException) as ex: + _LOGGER.warning( + "Failed to connect to %s player '%s': %s", source.value, ip_address, ex + ) + return None + + +class SonosDiscoveryManager: + """Manage sonos discovery.""" + + def __init__( + self, hass: HomeAssistant, entry: ConfigEntry, data: SonosData, hosts: list[str] + ) -> None: + """Init discovery manager.""" + self.hass = hass + self.entry = entry + self.data = data + self.hosts = hosts + self.discovery_lock = asyncio.Lock() + + async def _async_stop_event_listener(self, event: Event) -> None: await asyncio.gather( - *[speaker.async_unsubscribe() for speaker in data.discovered.values()], + *[speaker.async_unsubscribe() for speaker in self.data.discovered.values()], return_exceptions=True, ) if events_asyncio.event_listener: await events_asyncio.event_listener.async_stop() - def _stop_manual_heartbeat(event: Event) -> None: - if data.hosts_heartbeat: - data.hosts_heartbeat() - data.hosts_heartbeat = None + def _stop_manual_heartbeat(self, event: Event) -> None: + if self.data.hosts_heartbeat: + self.data.hosts_heartbeat() + self.data.hosts_heartbeat = None - def _discovered_player(soco: SoCo) -> None: + def _discovered_player(self, soco: SoCo) -> None: """Handle a (re)discovered player.""" try: speaker_info = soco.get_speaker_info(True) _LOGGER.debug("Adding new speaker: %s", speaker_info) - speaker = SonosSpeaker(hass, soco, speaker_info) - data.discovered[soco.uid] = speaker + speaker = SonosSpeaker(self.hass, soco, speaker_info) + self.data.discovered[soco.uid] = speaker for coordinator, coord_dict in [ - (SonosAlarms, data.alarms), - (SonosFavorites, data.favorites), + (SonosAlarms, self.data.alarms), + (SonosFavorites, self.data.favorites), ]: if soco.household_id not in coord_dict: - new_coordinator = coordinator(hass, soco.household_id) + new_coordinator = coordinator(self.hass, soco.household_id) new_coordinator.setup(soco) coord_dict[soco.household_id] = new_coordinator speaker.setup() except (OSError, SoCoException): _LOGGER.warning("Failed to add SonosSpeaker using %s", soco, exc_info=True) - def _create_soco(ip_address: str, source: SoCoCreationSource) -> SoCo | None: - """Create a soco instance and return if successful.""" - try: - soco = pysonos.SoCo(ip_address) - # Ensure that the player is available and UID is cached - _ = soco.uid - _ = soco.volume - return soco - except (OSError, SoCoException) as ex: - _LOGGER.warning( - "Failed to connect to %s player '%s': %s", source.value, ip_address, ex - ) - return None - - def _manual_hosts(now: datetime.datetime | None = None) -> None: + def _manual_hosts(self, now: datetime.datetime | None = None) -> None: """Players from network configuration.""" - for host in hosts: + for host in self.hosts: ip_addr = socket.gethostbyname(host) known_uid = next( ( uid - for uid, speaker in data.discovered.items() + for uid, speaker in self.data.discovered.items() if speaker.soco.ip_address == ip_addr ), None, ) if known_uid: - dispatcher_send(hass, f"{SONOS_SEEN}-{known_uid}") + dispatcher_send(self.hass, f"{SONOS_SEEN}-{known_uid}") else: soco = _create_soco(ip_addr, SoCoCreationSource.CONFIGURED) if soco and soco.is_visible: - _discovered_player(soco) + self._discovered_player(soco) - data.hosts_heartbeat = hass.helpers.event.call_later( - DISCOVERY_INTERVAL.total_seconds(), _manual_hosts + self.data.hosts_heartbeat = self.hass.helpers.event.call_later( + DISCOVERY_INTERVAL.total_seconds(), self._manual_hosts ) @callback - def _async_signal_update_groups(event): - async_dispatcher_send(hass, SONOS_GROUP_UPDATE) + def _async_signal_update_groups(self, _event): + async_dispatcher_send(self.hass, SONOS_GROUP_UPDATE) - def _discovered_ip(ip_address): + def _discovered_ip(self, ip_address): soco = _create_soco(ip_address, SoCoCreationSource.DISCOVERED) if soco and soco.is_visible: - _discovered_player(soco) + self._discovered_player(soco) - async def _async_create_discovered_player(uid, discovered_ip, boot_seqnum): + async def _async_create_discovered_player(self, uid, discovered_ip, boot_seqnum): """Only create one player at a time.""" - async with discovery_lock: - if uid not in data.discovered: - await hass.async_add_executor_job(_discovered_ip, discovered_ip) + async with self.discovery_lock: + if uid not in self.data.discovered: + await self.hass.async_add_executor_job( + self._discovered_ip, discovered_ip + ) return - if boot_seqnum and boot_seqnum > data.boot_counts[uid]: - data.boot_counts[uid] = boot_seqnum - if soco := await hass.async_add_executor_job( + if boot_seqnum and boot_seqnum > self.data.boot_counts[uid]: + self.data.boot_counts[uid] = boot_seqnum + if soco := await self.hass.async_add_executor_job( _create_soco, discovered_ip, SoCoCreationSource.REBOOTED ): - async_dispatcher_send(hass, f"{SONOS_REBOOTED}-{uid}", soco) + async_dispatcher_send(self.hass, f"{SONOS_REBOOTED}-{uid}", soco) else: - async_dispatcher_send(hass, f"{SONOS_SEEN}-{uid}") + async_dispatcher_send(self.hass, f"{SONOS_SEEN}-{uid}") @callback - def _async_discovered_player(info): - if info.get("modelName") in DISCOVERY_IGNORED_MODELS: - _LOGGER.debug("Ignoring device: %s", info.get("friendlyName")) - return + def _async_ssdp_discovered_player(self, info): + discovered_ip = urlparse(info[ssdp.ATTR_SSDP_LOCATION]).hostname + boot_seqnum = info.get("X-RINCON-BOOTSEQ") uid = info.get(ssdp.ATTR_UPNP_UDN) if uid.startswith("uuid:"): uid = uid[5:] - if boot_seqnum := info.get("X-RINCON-BOOTSEQ"): - boot_seqnum = int(boot_seqnum) - data.boot_counts.setdefault(uid, boot_seqnum) - if uid not in data.ssdp_known: - _LOGGER.debug("New discovery: %s", info) - data.ssdp_known.add(uid) - discovered_ip = urlparse(info[ssdp.ATTR_SSDP_LOCATION]).hostname - asyncio.create_task( - _async_create_discovered_player(uid, discovered_ip, boot_seqnum) + self.async_discovered_player( + info, discovered_ip, uid, boot_seqnum, info.get("modelName") ) - async def setup_platforms_and_discovery(): + @callback + def async_discovered_player(self, info, discovered_ip, uid, boot_seqnum, model): + """Handle discovery via ssdp or zeroconf.""" + if model in DISCOVERY_IGNORED_MODELS: + _LOGGER.debug("Ignoring device: %s", info) + return + if boot_seqnum: + boot_seqnum = int(boot_seqnum) + self.data.boot_counts.setdefault(uid, boot_seqnum) + if uid not in self.data.discovery_known: + _LOGGER.debug("New discovery uid=%s: %s", uid, info) + self.data.discovery_known.add(uid) + asyncio.create_task( + self._async_create_discovered_player(uid, discovered_ip, boot_seqnum) + ) + + async def setup_platforms_and_discovery(self): + """Set up platforms and discovery.""" await asyncio.gather( *[ - hass.config_entries.async_forward_entry_setup(entry, platform) + self.hass.config_entries.async_forward_entry_setup(self.entry, platform) for platform in PLATFORMS ] ) - entry.async_on_unload( - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, _async_signal_update_groups + self.entry.async_on_unload( + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, self._async_signal_update_groups ) ) - entry.async_on_unload( - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, _async_stop_event_listener + self.entry.async_on_unload( + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self._async_stop_event_listener ) ) _LOGGER.debug("Adding discovery job") - if hosts: - entry.async_on_unload( - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, _stop_manual_heartbeat + if self.hosts: + self.entry.async_on_unload( + self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self._stop_manual_heartbeat ) ) - await hass.async_add_executor_job(_manual_hosts) + await self.hass.async_add_executor_job(self._manual_hosts) return - entry.async_on_unload( + self.entry.async_on_unload( ssdp.async_register_callback( - hass, _async_discovered_player, {"st": UPNP_ST} + self.hass, self._async_ssdp_discovered_player, {"st": UPNP_ST} ) ) - - hass.async_create_task(setup_platforms_and_discovery()) - - return True diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py index 5037abb79aa..1ba750c24be 100644 --- a/homeassistant/components/sonos/config_flow.py +++ b/homeassistant/components/sonos/config_flow.py @@ -1,10 +1,19 @@ """Config flow for SONOS.""" +import logging + import pysonos +from homeassistant import config_entries +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_entry_flow +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.config_entry_flow import DiscoveryFlowHandler +from homeassistant.helpers.typing import DiscoveryInfoType -from .const import DOMAIN +from .const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN +from .helpers import hostname_to_uid + +_LOGGER = logging.getLogger(__name__) async def _async_has_devices(hass: HomeAssistant) -> bool: @@ -13,4 +22,37 @@ async def _async_has_devices(hass: HomeAssistant) -> bool: return bool(result) -config_entry_flow.register_discovery_flow(DOMAIN, "Sonos", _async_has_devices) +class SonosDiscoveryFlowHandler(DiscoveryFlowHandler): + """Sonos discovery flow that callsback zeroconf updates.""" + + def __init__(self) -> None: + """Init discovery flow.""" + super().__init__(DOMAIN, "Sonos", _async_has_devices) + + async def async_step_zeroconf( + self, discovery_info: DiscoveryInfoType + ) -> FlowResult: + """Handle a flow initialized by zeroconf.""" + hostname = discovery_info["hostname"] + if hostname is None or not hostname.startswith("Sonos-"): + return self.async_abort(reason="not_sonos_device") + await self.async_set_unique_id(self._domain, raise_on_progress=False) + host = discovery_info[CONF_HOST] + properties = discovery_info["properties"] + boot_seqnum = properties.get("bootseq") + model = properties.get("model") + uid = hostname_to_uid(hostname) + _LOGGER.debug( + "Calling async_discovered_player for %s with uid=%s and boot_seqnum=%s", + host, + uid, + boot_seqnum, + ) + if discovery_manager := self.hass.data.get(DATA_SONOS_DISCOVERY_MANAGER): + discovery_manager.async_discovered_player( + properties, host, uid, boot_seqnum, model + ) + return await self.async_step_discovery(discovery_info) + + +config_entries.HANDLERS.register(DOMAIN)(SonosDiscoveryFlowHandler) diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py index 9072f4cab02..aca4b9b39ae 100644 --- a/homeassistant/components/sonos/const.py +++ b/homeassistant/components/sonos/const.py @@ -26,6 +26,7 @@ UPNP_ST = "urn:schemas-upnp-org:device:ZonePlayer:1" DOMAIN = "sonos" DATA_SONOS = "sonos_media_player" +DATA_SONOS_DISCOVERY_MANAGER = "sonos_discovery_manager" PLATFORMS = {BINARY_SENSOR_DOMAIN, MP_DOMAIN, SENSOR_DOMAIN, SWITCH_DOMAIN} SONOS_ARTIST = "artists" @@ -154,3 +155,5 @@ SCAN_INTERVAL = datetime.timedelta(seconds=10) DISCOVERY_INTERVAL = datetime.timedelta(seconds=60) SEEN_EXPIRE_TIME = 3.5 * DISCOVERY_INTERVAL SUBSCRIPTION_TIMEOUT = 1200 + +MDNS_SERVICE = "_sonos._tcp.local." diff --git a/homeassistant/components/sonos/helpers.py b/homeassistant/components/sonos/helpers.py index ac8cd00d9db..675a3e8e9f2 100644 --- a/homeassistant/components/sonos/helpers.py +++ b/homeassistant/components/sonos/helpers.py @@ -9,6 +9,9 @@ from pysonos.exceptions import SoCoException, SoCoUPnPException from homeassistant.exceptions import HomeAssistantError +UID_PREFIX = "RINCON_" +UID_POSTFIX = "01400" + _LOGGER = logging.getLogger(__name__) @@ -36,3 +39,19 @@ def soco_error(errorcodes: list[str] | None = None) -> Callable: return wrapper return decorator + + +def uid_to_short_hostname(uid: str) -> str: + """Convert a Sonos uid to a short hostname.""" + hostname_uid = uid + if hostname_uid.startswith(UID_PREFIX): + hostname_uid = hostname_uid[len(UID_PREFIX) :] + if hostname_uid.endswith(UID_POSTFIX): + hostname_uid = hostname_uid[: -len(UID_POSTFIX)] + return f"Sonos-{hostname_uid}" + + +def hostname_to_uid(hostname: str) -> str: + """Convert a Sonos hostname to a uid.""" + baseuid = hostname.split("-")[1].replace(".local.", "") + return f"{UID_PREFIX}{baseuid}{UID_POSTFIX}" diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index a3b031ac07b..b1b0bc8a202 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -5,7 +5,8 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "requirements": ["pysonos==0.0.51"], "dependencies": ["ssdp"], - "after_dependencies": ["plex"], + "after_dependencies": ["plex", "zeroconf"], + "zeroconf": ["_sonos._tcp.local."], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index b1483c0f5d3..19f65f963c3 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -19,6 +19,7 @@ from pysonos.music_library import MusicLibrary from pysonos.plugins.sharelink import ShareLinkPlugin from pysonos.snapshot import Snapshot +from homeassistant.components import zeroconf from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.media_player import DOMAIN as MP_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -37,6 +38,7 @@ from .const import ( BATTERY_SCAN_INTERVAL, DATA_SONOS, DOMAIN, + MDNS_SERVICE, PLATFORMS, SCAN_INTERVAL, SEEN_EXPIRE_TIME, @@ -56,7 +58,7 @@ from .const import ( SUBSCRIPTION_TIMEOUT, ) from .favorites import SonosFavorites -from .helpers import soco_error +from .helpers import soco_error, uid_to_short_hostname EVENT_CHARGING = { "CHARGING": True, @@ -498,12 +500,27 @@ class SonosSpeaker: self, now: datetime.datetime | None = None, will_reconnect: bool = False ) -> None: """Make this player unavailable when it was not seen recently.""" - self._share_link_plugin = None - if self._seen_timer: self._seen_timer() self._seen_timer = None + hostname = uid_to_short_hostname(self.soco.uid) + zcname = f"{hostname}.{MDNS_SERVICE}" + aiozeroconf = await zeroconf.async_get_async_instance(self.hass) + if await aiozeroconf.async_get_service_info(MDNS_SERVICE, zcname): + # We can still see the speaker via zeroconf check again later. + self._seen_timer = self.hass.helpers.event.async_call_later( + SEEN_EXPIRE_TIME.total_seconds(), self.async_unseen + ) + return + + _LOGGER.debug( + "No activity and could not locate %s on the network. Marking unavailable", + zcname, + ) + + self._share_link_plugin = None + if self._poll_timer: self._poll_timer() self._poll_timer = None @@ -511,7 +528,7 @@ class SonosSpeaker: await self.async_unsubscribe() if not will_reconnect: - self.hass.data[DATA_SONOS].ssdp_known.discard(self.soco.uid) + self.hass.data[DATA_SONOS].discovery_known.discard(self.soco.uid) self.async_write_entity_states() async def async_rebooted(self, soco: SoCo) -> None: diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json index 12812d66692..fb73e30421f 100644 --- a/homeassistant/components/sonos/strings.json +++ b/homeassistant/components/sonos/strings.json @@ -6,6 +6,7 @@ } }, "abort": { + "not_sonos_device": "Discovered device is not a Sonos device", "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" } diff --git a/homeassistant/components/sonos/translations/en.json b/homeassistant/components/sonos/translations/en.json index 38aecd5e965..181ddc2f5bf 100644 --- a/homeassistant/components/sonos/translations/en.json +++ b/homeassistant/components/sonos/translations/en.json @@ -2,6 +2,7 @@ "config": { "abort": { "no_devices_found": "No devices found on the network", + "not_sonos_device": "Discovered device is not a Sonos device", "single_instance_allowed": "Already configured. Only a single configuration possible." }, "step": { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 11fd47469f8..536485f7f55 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -186,6 +186,11 @@ ZEROCONF = { "name": "brother*" } ], + "_sonos._tcp.local.": [ + { + "domain": "sonos" + } + ], "_spotify-connect._tcp.local.": [ { "domain": "spotify" diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py new file mode 100644 index 00000000000..9dd308ae28f --- /dev/null +++ b/tests/components/sonos/test_config_flow.py @@ -0,0 +1,92 @@ +"""Test the sonos config flow.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from homeassistant import config_entries, core, setup +from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER, DOMAIN + + +@patch("homeassistant.components.sonos.config_flow.pysonos.discover", return_value=True) +async def test_user_form(discover_mock: MagicMock, hass: core.HomeAssistant): + """Test we get the user initiated form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] is None + with patch( + "homeassistant.components.sonos.async_setup", + return_value=True, + ) as mock_setup, patch( + "homeassistant.components.sonos.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Sonos" + assert result2["data"] == {} + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_zeroconf_form(hass: core.HomeAssistant): + """Test we pass sonos devices to the discovery manager.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + mock_manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = MagicMock() + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + "host": "192.168.4.2", + "hostname": "Sonos-aaa", + "properties": {"bootseq": "1234"}, + }, + ) + assert result["type"] == "form" + assert result["errors"] is None + + with patch( + "homeassistant.components.sonos.async_setup", + return_value=True, + ) as mock_setup, patch( + "homeassistant.components.sonos.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + await hass.async_block_till_done() + + assert result2["type"] == "create_entry" + assert result2["title"] == "Sonos" + assert result2["data"] == {} + + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert len(mock_manager.mock_calls) == 2 + + +async def test_zeroconf_form_not_sonos(hass: core.HomeAssistant): + """Test we abort on non-sonos devices.""" + mock_manager = hass.data[DATA_SONOS_DISCOVERY_MANAGER] = MagicMock() + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data={ + "host": "192.168.4.2", + "hostname": "not-aaa", + "properties": {"bootseq": "1234"}, + }, + ) + assert result["type"] == "abort" + assert result["reason"] == "not_sonos_device" + assert len(mock_manager.mock_calls) == 0 diff --git a/tests/components/sonos/test_helpers.py b/tests/components/sonos/test_helpers.py new file mode 100644 index 00000000000..858657e01c0 --- /dev/null +++ b/tests/components/sonos/test_helpers.py @@ -0,0 +1,17 @@ +"""Test the sonos config flow.""" +from __future__ import annotations + +from homeassistant.components.sonos.helpers import ( + hostname_to_uid, + uid_to_short_hostname, +) + + +async def test_uid_to_short_hostname(): + """Test we can convert a uid to a short hostname.""" + assert uid_to_short_hostname("RINCON_347E5C0CF1E301400") == "Sonos-347E5C0CF1E3" + + +async def test_uid_to_hostname(): + """Test we can convert a hostname to a uid.""" + assert hostname_to_uid("Sonos-347E5C0CF1E3.local.") == "RINCON_347E5C0CF1E301400" From 757388cc319ae36cc4744bce9128cf1df20e0215 Mon Sep 17 00:00:00 2001 From: apaperclip <67401560+apaperclip@users.noreply.github.com> Date: Thu, 8 Jul 2021 12:10:05 -0400 Subject: [PATCH 080/134] Remove scale calculation for climacell cloud cover (#52752) --- homeassistant/components/climacell/const.py | 1 - homeassistant/components/climacell/weather.py | 2 -- tests/components/climacell/test_sensor.py | 2 +- tests/components/climacell/test_weather.py | 4 ++-- 4 files changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/climacell/const.py b/homeassistant/components/climacell/const.py index 062de93375b..057cef5e993 100644 --- a/homeassistant/components/climacell/const.py +++ b/homeassistant/components/climacell/const.py @@ -214,7 +214,6 @@ CC_SENSOR_TYPES = [ ATTR_FIELD: CC_ATTR_CLOUD_COVER, ATTR_NAME: "Cloud Cover", CONF_UNIT_OF_MEASUREMENT: PERCENTAGE, - ATTR_SCALE: 1 / 100, }, { ATTR_FIELD: CC_ATTR_WIND_GUST, diff --git a/homeassistant/components/climacell/weather.py b/homeassistant/components/climacell/weather.py index 1b3b50e8566..be03b53ef72 100644 --- a/homeassistant/components/climacell/weather.py +++ b/homeassistant/components/climacell/weather.py @@ -207,8 +207,6 @@ class BaseClimaCellWeatherEntity(ClimaCellEntity, WeatherEntity): distance_convert(self.wind_gust, LENGTH_MILES, LENGTH_KILOMETERS), 4 ) cloud_cover = self.cloud_cover - if cloud_cover is not None: - cloud_cover /= 100 return { ATTR_CLOUD_COVER: cloud_cover, ATTR_WIND_GUST: wind_gust, diff --git a/tests/components/climacell/test_sensor.py b/tests/components/climacell/test_sensor.py index d93bdb5fae8..c642457b63e 100644 --- a/tests/components/climacell/test_sensor.py +++ b/tests/components/climacell/test_sensor.py @@ -182,7 +182,7 @@ async def test_v4_sensor( check_sensor_state(hass, PRESSURE_SURFACE_LEVEL, "997.9688") check_sensor_state(hass, GHI, "0.0") check_sensor_state(hass, CLOUD_BASE, "1.1909") - check_sensor_state(hass, CLOUD_COVER, "1.0") + check_sensor_state(hass, CLOUD_COVER, "100") check_sensor_state(hass, CLOUD_CEILING, "1.1909") check_sensor_state(hass, WIND_GUST, "5.6506") check_sensor_state(hass, PRECIPITATION_TYPE, "rain") diff --git a/tests/components/climacell/test_weather.py b/tests/components/climacell/test_weather.py index fa1ef9dc490..90efdea3c8c 100644 --- a/tests/components/climacell/test_weather.py +++ b/tests/components/climacell/test_weather.py @@ -228,7 +228,7 @@ async def test_v3_weather( assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 9.9940 assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 320.31 assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 14.6289 - assert weather_state.attributes[ATTR_CLOUD_COVER] == 1 + assert weather_state.attributes[ATTR_CLOUD_COVER] == 100 assert weather_state.attributes[ATTR_WIND_GUST] == 24.0758 assert weather_state.attributes[ATTR_PRECIPITATION_TYPE] == "rain" @@ -391,6 +391,6 @@ async def test_v4_weather( assert weather_state.attributes[ATTR_WEATHER_VISIBILITY] == 13.1162 assert weather_state.attributes[ATTR_WEATHER_WIND_BEARING] == 315.14 assert weather_state.attributes[ATTR_WEATHER_WIND_SPEED] == 15.0152 - assert weather_state.attributes[ATTR_CLOUD_COVER] == 1 + assert weather_state.attributes[ATTR_CLOUD_COVER] == 100 assert weather_state.attributes[ATTR_WIND_GUST] == 20.3421 assert weather_state.attributes[ATTR_PRECIPITATION_TYPE] == "rain" From f4fa0d7789bc28173a617adc05236a1636cd532a Mon Sep 17 00:00:00 2001 From: Jc2k Date: Thu, 8 Jul 2021 17:26:25 +0100 Subject: [PATCH 081/134] Fix homebridge devices becoming unavailable frequently (#52753) Update to aiohomekit 0.4.3 and make sure service type UUID is normalised before comparison Co-authored-by: J. Nick Koston --- .../components/homekit_controller/manifest.json | 2 +- homeassistant/components/homekit_controller/sensor.py | 3 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/homekit_controller/test_sensor.py | 10 ++++++++++ 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 496d629d112..2d40cc8a235 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.4.2"], + "requirements": ["aiohomekit==0.4.3"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py index fe98b75130c..b21010e9b1e 100644 --- a/homeassistant/components/homekit_controller/sensor.py +++ b/homeassistant/components/homekit_controller/sensor.py @@ -44,7 +44,8 @@ SIMPLE_SENSOR = { "unit": TEMP_CELSIUS, # This sensor is only for temperature characteristics that are not part # of a temperature sensor service. - "probe": lambda char: char.service.type != ServicesTypes.TEMPERATURE_SENSOR, + "probe": lambda char: char.service.type + != ServicesTypes.get_uuid(ServicesTypes.TEMPERATURE_SENSOR), }, } diff --git a/requirements_all.txt b/requirements_all.txt index 9d2ad7b154b..2fbaa378fbc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -175,7 +175,7 @@ aioguardian==1.0.4 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.4.2 +aiohomekit==0.4.3 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bad09bcaf5..47e667e5bfe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -112,7 +112,7 @@ aioguardian==1.0.4 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.4.2 +aiohomekit==0.4.3 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py index a79e94c4bb7..604c83e54f7 100644 --- a/tests/components/homekit_controller/test_sensor.py +++ b/tests/components/homekit_controller/test_sensor.py @@ -86,6 +86,16 @@ async def test_temperature_sensor_read_state(hass, utcnow): assert state.attributes["device_class"] == DEVICE_CLASS_TEMPERATURE +async def test_temperature_sensor_not_added_twice(hass, utcnow): + """A standalone temperature sensor should not get a characteristic AND a service entity.""" + helper = await setup_test_component( + hass, create_temperature_sensor_service, suffix="temperature" + ) + + for state in hass.states.async_all(): + assert state.entity_id == helper.entity_id + + async def test_humidity_sensor_read_state(hass, utcnow): """Test reading the state of a HomeKit humidity sensor accessory.""" helper = await setup_test_component( From 8af63cd9a00c88a3b590b3d93cdb5960ff89580c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Jul 2021 09:39:51 -1000 Subject: [PATCH 082/134] Fix nexia thermostats humidify without dehumidify support (#52758) --- .coveragerc | 1 + homeassistant/components/nexia/climate.py | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index e05d97b66fb..06f6bca0eec 100644 --- a/.coveragerc +++ b/.coveragerc @@ -687,6 +687,7 @@ omit = homeassistant/components/netgear_lte/* homeassistant/components/netio/switch.py homeassistant/components/neurio_energy/sensor.py + homeassistant/components/nexia/climate.py homeassistant/components/nextcloud/* homeassistant/components/nfandroidtv/notify.py homeassistant/components/niko_home_control/light.py diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index 2dff498f281..e27a1816a8e 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -203,7 +203,10 @@ class NexiaZone(NexiaThermostatZoneEntity, ClimateEntity): def set_humidity(self, humidity): """Dehumidify target.""" - self._thermostat.set_dehumidify_setpoint(humidity / 100.0) + if self._thermostat.has_dehumidify_support(): + self._thermostat.set_dehumidify_setpoint(humidity / 100.0) + else: + self._thermostat.set_humidify_setpoint(humidity / 100.0) self._signal_thermostat_update() @property From 763f8ac6a8fcc4163e2a216580af679050dcfbe7 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Fri, 9 Jul 2021 10:51:46 +0100 Subject: [PATCH 083/134] Support certain homekit devices that emit invalid JSON (#52759) --- 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 2d40cc8a235..816ec2db4d9 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.4.3"], + "requirements": ["aiohomekit==0.5.0"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/requirements_all.txt b/requirements_all.txt index 2fbaa378fbc..df8db3bf1b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -175,7 +175,7 @@ aioguardian==1.0.4 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.4.3 +aiohomekit==0.5.0 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 47e667e5bfe..c04a82524b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -112,7 +112,7 @@ aioguardian==1.0.4 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.4.3 +aiohomekit==0.5.0 # homeassistant.components.emulated_hue # homeassistant.components.http From 87d4544531edf2151860c59d474d85dcadb084a8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 11 Jul 2021 11:03:48 -1000 Subject: [PATCH 084/134] Send ssdp requests to ipv4 broadcast as well (#52760) * Send ssdp requests to 255.255.255.255 as well - This matches pysonos behavior and may fix reports of inability to discover some sonos devices https://github.com/amelchio/pysonos/blob/master/pysonos/discovery.py#L120 * Update homeassistant/components/ssdp/__init__.py --- homeassistant/components/ssdp/__init__.py | 17 ++++++++++++- tests/components/ssdp/test_init.py | 31 +++++++++++++---------- 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index d03f8967311..9896ec4177e 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -29,6 +29,8 @@ from .flow import FlowDispatcher, SSDPFlow DOMAIN = "ssdp" SCAN_INTERVAL = timedelta(seconds=60) +IPV4_BROADCAST = IPv4Address("255.255.255.255") + # Attributes for accessing info from SSDP response ATTR_SSDP_LOCATION = "ssdp_location" ATTR_SSDP_ST = "ssdp_st" @@ -236,7 +238,20 @@ class Scanner: async_callback=self._async_process_entry, source_ip=source_ip ) ) - + try: + IPv4Address(source_ip) + except ValueError: + continue + # Some sonos devices only seem to respond if we send to the broadcast + # address. This matches pysonos' behavior + # https://github.com/amelchio/pysonos/blob/d4329b4abb657d106394ae69357805269708c996/pysonos/discovery.py#L120 + self._ssdp_listeners.append( + SSDPListener( + async_callback=self._async_process_entry, + source_ip=source_ip, + target_ip=IPV4_BROADCAST, + ) + ) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.async_stop) self.hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, self.flow_dispatcher.async_start diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index 6c019f1f311..568a2261fee 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -295,15 +295,15 @@ async def test_start_stop_scanner(async_start_mock, async_search_mock, hass): await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() - assert async_start_mock.call_count == 1 - assert async_search_mock.call_count == 1 + assert async_start_mock.call_count == 2 + assert async_search_mock.call_count == 2 hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() - assert async_start_mock.call_count == 1 - assert async_search_mock.call_count == 1 + assert async_start_mock.call_count == 2 + assert async_search_mock.call_count == 2 async def test_unexpected_exception_while_fetching(hass, aioclient_mock, caplog): @@ -459,11 +459,11 @@ async def test_scan_with_registered_callback(hass, aioclient_mock, caplog): await hass.async_block_till_done() assert hass.state == CoreState.running - assert len(integration_callbacks) == 3 - assert len(integration_callbacks_from_cache) == 3 - assert len(integration_match_all_callbacks) == 3 + assert len(integration_callbacks) == 5 + assert len(integration_callbacks_from_cache) == 5 + assert len(integration_match_all_callbacks) == 5 assert len(integration_match_all_not_present_callbacks) == 0 - assert len(match_any_callbacks) == 3 + assert len(match_any_callbacks) == 5 assert len(not_matching_integration_callbacks) == 0 assert integration_callbacks[0] == { ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", @@ -546,7 +546,7 @@ async def test_unsolicited_ssdp_registered_callback(hass, aioclient_mock, caplog assert hass.state == CoreState.running assert ( - len(integration_callbacks) == 2 + len(integration_callbacks) == 4 ) # unsolicited callbacks without st are not cached assert integration_callbacks[0] == { "UDN": "uuid:RINCON_1111BB963FD801400", @@ -635,7 +635,7 @@ async def test_scan_second_hit(hass, aioclient_mock, caplog): async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) await hass.async_block_till_done() - assert len(integration_callbacks) == 2 + assert len(integration_callbacks) == 4 assert integration_callbacks[0] == { ssdp.ATTR_UPNP_DEVICE_TYPE: "Paulus", ssdp.ATTR_SSDP_EXT: "", @@ -781,7 +781,12 @@ async def test_async_detect_interfaces_setting_empty_route(hass): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert {create_args[0][1]["source_ip"], create_args[1][1]["source_ip"]} == { - IPv4Address("192.168.1.5"), - IPv6Address("2001:db8::"), + argset = set() + for argmap in create_args: + argset.add((argmap[1].get("source_ip"), argmap[1].get("target_ip"))) + + assert argset == { + (IPv6Address("2001:db8::"), None), + (IPv4Address("192.168.1.5"), IPv4Address("255.255.255.255")), + (IPv4Address("192.168.1.5"), None), } From ce20ca7bb683838439b9a071dfb036e2513ee96a Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Fri, 9 Jul 2021 11:54:40 +0200 Subject: [PATCH 085/134] Bump dependency to properly handle current and voltage not being reported on some zhapower endpoints (#52764) --- homeassistant/components/deconz/manifest.json | 10 +++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index ad57b1bd903..fbd420e4b16 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -3,13 +3,17 @@ "name": "deCONZ", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/deconz", - "requirements": ["pydeconz==80"], + "requirements": [ + "pydeconz==81" + ], "ssdp": [ { "manufacturer": "Royal Philips Electronics" } ], - "codeowners": ["@Kane610"], + "codeowners": [ + "@Kane610" + ], "quality_scale": "platinum", "iot_class": "local_push" -} +} \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index df8db3bf1b2..344c93ddecb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1375,7 +1375,7 @@ pydaikin==2.4.4 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==80 +pydeconz==81 # homeassistant.components.delijn pydelijn==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c04a82524b2..678d3b63fd7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -770,7 +770,7 @@ pycoolmasternet-async==0.1.2 pydaikin==2.4.4 # homeassistant.components.deconz -pydeconz==80 +pydeconz==81 # homeassistant.components.dexcom pydexcom==0.2.0 From df3c8586b5e3ec2c112bfdaf00e13799d8014137 Mon Sep 17 00:00:00 2001 From: Brandon Rothweiler Date: Fri, 9 Jul 2021 01:55:26 -0400 Subject: [PATCH 086/134] Upgrade pymazda to 0.2.0 (#52775) --- homeassistant/components/mazda/__init__.py | 6 ++++-- homeassistant/components/mazda/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/mazda/test_init.py | 4 ++-- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mazda/__init__.py b/homeassistant/components/mazda/__init__.py index d704cfb7f44..921dd4c06c5 100644 --- a/homeassistant/components/mazda/__init__.py +++ b/homeassistant/components/mazda/__init__.py @@ -50,7 +50,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: region = entry.data[CONF_REGION] websession = aiohttp_client.async_get_clientsession(hass) - mazda_client = MazdaAPI(email, password, region, websession) + mazda_client = MazdaAPI( + email, password, region, websession=websession, use_cached_vehicle_list=True + ) try: await mazda_client.validate_credentials() @@ -166,7 +168,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER, name=DOMAIN, update_method=async_update_data, - update_interval=timedelta(seconds=60), + update_interval=timedelta(seconds=180), ) hass.data.setdefault(DOMAIN, {}) diff --git a/homeassistant/components/mazda/manifest.json b/homeassistant/components/mazda/manifest.json index dd169159bc8..cc12653f5cb 100644 --- a/homeassistant/components/mazda/manifest.json +++ b/homeassistant/components/mazda/manifest.json @@ -3,7 +3,7 @@ "name": "Mazda Connected Services", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/mazda", - "requirements": ["pymazda==0.1.6"], + "requirements": ["pymazda==0.2.0"], "codeowners": ["@bdr99"], "quality_scale": "platinum", "iot_class": "cloud_polling" diff --git a/requirements_all.txt b/requirements_all.txt index 344c93ddecb..d968079f526 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1571,7 +1571,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.mazda -pymazda==0.1.6 +pymazda==0.2.0 # homeassistant.components.mediaroom pymediaroom==0.6.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 678d3b63fd7..a2ecb78e251 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -888,7 +888,7 @@ pymailgunner==1.4 pymata-express==1.19 # homeassistant.components.mazda -pymazda==0.1.6 +pymazda==0.2.0 # homeassistant.components.melcloud pymelcloud==2.5.3 diff --git a/tests/components/mazda/test_init.py b/tests/components/mazda/test_init.py index 0c47ae8f2e0..8b135f15e80 100644 --- a/tests/components/mazda/test_init.py +++ b/tests/components/mazda/test_init.py @@ -97,7 +97,7 @@ async def test_update_auth_failure(hass: HomeAssistant): "homeassistant.components.mazda.MazdaAPI.get_vehicles", side_effect=MazdaAuthenticationException("Login failed"), ): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=61)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=181)) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() @@ -136,7 +136,7 @@ async def test_update_general_failure(hass: HomeAssistant): "homeassistant.components.mazda.MazdaAPI.get_vehicles", side_effect=Exception("Unknown exception"), ): - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=61)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=181)) await hass.async_block_till_done() entity = hass.states.get("sensor.my_mazda3_fuel_remaining_percentage") From f7c65e7c8ba120d7af21da9e0e04d8417d7d6d69 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Fri, 9 Jul 2021 11:38:38 +0200 Subject: [PATCH 087/134] Fix ESPHome Camera not merging image packets (#52783) --- homeassistant/components/esphome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e48cd4847c8..d8a22534001 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -3,7 +3,7 @@ "name": "ESPHome", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/esphome", - "requirements": ["aioesphomeapi==5.0.0"], + "requirements": ["aioesphomeapi==5.0.1"], "zeroconf": ["_esphomelib._tcp.local."], "codeowners": ["@OttoWinter", "@jesserockz"], "after_dependencies": ["zeroconf", "tag"], diff --git a/requirements_all.txt b/requirements_all.txt index d968079f526..4f1f3bc4523 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -160,7 +160,7 @@ aioeafm==0.1.2 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==5.0.0 +aioesphomeapi==5.0.1 # homeassistant.components.flo aioflo==0.4.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a2ecb78e251..d1583ce3ddb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -100,7 +100,7 @@ aioeafm==0.1.2 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==5.0.0 +aioesphomeapi==5.0.1 # homeassistant.components.flo aioflo==0.4.1 From 473f109428acc70e7d6d489298c27658a102cde5 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 9 Jul 2021 19:12:51 +0200 Subject: [PATCH 088/134] Fix Neato parameter for token refresh (#52785) * Fix param * cleanup --- homeassistant/components/neato/__init__.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py index f61db94332b..28569e0f1d7 100644 --- a/homeassistant/components/neato/__init__.py +++ b/homeassistant/components/neato/__init__.py @@ -77,9 +77,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) ) - session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation) - - neato_session = api.ConfigEntryAuth(hass, entry, session) + neato_session = api.ConfigEntryAuth(hass, entry, implementation) hass.data[NEATO_DOMAIN][entry.entry_id] = neato_session hub = NeatoHub(hass, Account(neato_session)) From 798f3eada28e061c410783717bb88a6957a1efbf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Jul 2021 04:30:54 -1000 Subject: [PATCH 089/134] Add the Trane brand to nexia (#52805) --- homeassistant/components/nexia/config_flow.py | 20 +++++++++++++++---- homeassistant/components/nexia/const.py | 3 ++- homeassistant/components/nexia/manifest.json | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 22 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/nexia/config_flow.py b/homeassistant/components/nexia/config_flow.py index 18c20a8f92a..4e48123a5de 100644 --- a/homeassistant/components/nexia/config_flow.py +++ b/homeassistant/components/nexia/config_flow.py @@ -1,7 +1,7 @@ """Config flow for Nexia integration.""" import logging -from nexia.const import BRAND_ASAIR, BRAND_NEXIA +from nexia.const import BRAND_ASAIR, BRAND_NEXIA, BRAND_TRANE from nexia.home import NexiaHome from requests.exceptions import ConnectTimeout, HTTPError import voluptuous as vol @@ -9,7 +9,13 @@ import voluptuous as vol from homeassistant import config_entries, core, exceptions from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import BRAND_ASAIR_NAME, BRAND_NEXIA_NAME, CONF_BRAND, DOMAIN +from .const import ( + BRAND_ASAIR_NAME, + BRAND_NEXIA_NAME, + BRAND_TRANE_NAME, + CONF_BRAND, + DOMAIN, +) from .util import is_invalid_auth_code _LOGGER = logging.getLogger(__name__) @@ -19,7 +25,11 @@ DATA_SCHEMA = vol.Schema( vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, vol.Required(CONF_BRAND, default=BRAND_NEXIA): vol.In( - {BRAND_NEXIA: BRAND_NEXIA_NAME, BRAND_ASAIR: BRAND_ASAIR_NAME} + { + BRAND_NEXIA: BRAND_NEXIA_NAME, + BRAND_ASAIR: BRAND_ASAIR_NAME, + BRAND_TRANE: BRAND_TRANE_NAME, + } ), } ) @@ -31,7 +41,9 @@ async def validate_input(hass: core.HomeAssistant, data): Data has the keys from DATA_SCHEMA with values provided by the user. """ - state_file = hass.config.path(f"nexia_config_{data[CONF_USERNAME]}.conf") + state_file = hass.config.path( + f"{data[CONF_BRAND]}_config_{data[CONF_USERNAME]}.conf" + ) try: nexia_home = NexiaHome( username=data[CONF_USERNAME], diff --git a/homeassistant/components/nexia/const.py b/homeassistant/components/nexia/const.py index d6e3e5f8008..22b24c3b764 100644 --- a/homeassistant/components/nexia/const.py +++ b/homeassistant/components/nexia/const.py @@ -33,4 +33,5 @@ SIGNAL_ZONE_UPDATE = "NEXIA_CLIMATE_ZONE_UPDATE" SIGNAL_THERMOSTAT_UPDATE = "NEXIA_CLIMATE_THERMOSTAT_UPDATE" BRAND_NEXIA_NAME = "Nexia" -BRAND_ASAIR_NAME = "American Standard" +BRAND_ASAIR_NAME = "American Standard Home" +BRAND_TRANE_NAME = "Trane Home" diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index ed1247ee9e3..eb471597ec6 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -1,7 +1,7 @@ { "domain": "nexia", - "name": "Nexia/American Standard", - "requirements": ["nexia==0.9.7"], + "name": "Nexia/American Standard/Trane", + "requirements": ["nexia==0.9.9"], "codeowners": ["@bdraco"], "documentation": "https://www.home-assistant.io/integrations/nexia", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index 4f1f3bc4523..b0dc9aba1de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1020,7 +1020,7 @@ nettigo-air-monitor==1.0.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==0.9.7 +nexia==0.9.9 # homeassistant.components.nextcloud nextcloudmonitor==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d1583ce3ddb..1640fedfb3f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -574,7 +574,7 @@ netdisco==2.9.0 nettigo-air-monitor==1.0.0 # homeassistant.components.nexia -nexia==0.9.7 +nexia==0.9.9 # homeassistant.components.notify_events notify-events==1.0.4 From 320ca4012420d55887e2bdae18d61718aaf8b15b Mon Sep 17 00:00:00 2001 From: Ron Klinkien Date: Mon, 12 Jul 2021 20:21:10 +0200 Subject: [PATCH 090/134] Bump python-fireservicerota to 0.0.42 (#52807) --- homeassistant/components/fireservicerota/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fireservicerota/manifest.json b/homeassistant/components/fireservicerota/manifest.json index 0e2259b6b5e..f35be9e839f 100644 --- a/homeassistant/components/fireservicerota/manifest.json +++ b/homeassistant/components/fireservicerota/manifest.json @@ -3,7 +3,7 @@ "name": "FireServiceRota", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fireservicerota", - "requirements": ["pyfireservicerota==0.0.40"], + "requirements": ["pyfireservicerota==0.0.42"], "codeowners": ["@cyberjunky"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index b0dc9aba1de..e4f09e78560 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1423,7 +1423,7 @@ pyezviz==0.1.8.9 pyfido==2.1.1 # homeassistant.components.fireservicerota -pyfireservicerota==0.0.40 +pyfireservicerota==0.0.42 # homeassistant.components.flexit pyflexit==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1640fedfb3f..ee0253a6b24 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -791,7 +791,7 @@ pyezviz==0.1.8.9 pyfido==2.1.1 # homeassistant.components.fireservicerota -pyfireservicerota==0.0.40 +pyfireservicerota==0.0.42 # homeassistant.components.flume pyflume==0.5.5 From 4e82659f3adf0ab78e465e94e85457eb9fd8a581 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Fri, 9 Jul 2021 22:37:56 -0400 Subject: [PATCH 091/134] Bump up ZHA depdencies (#52818) --- 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 d37abea2310..081941d94fe 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -10,7 +10,7 @@ "zha-quirks==0.0.59", "zigpy-cc==0.5.2", "zigpy-deconz==0.12.0", - "zigpy==0.35.1", + "zigpy==0.35.2", "zigpy-xbee==0.13.0", "zigpy-zigate==0.7.3", "zigpy-znp==0.5.1" diff --git a/requirements_all.txt b/requirements_all.txt index e4f09e78560..97ea28aa9a5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2451,7 +2451,7 @@ zigpy-zigate==0.7.3 zigpy-znp==0.5.1 # homeassistant.components.zha -zigpy==0.35.1 +zigpy==0.35.2 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ee0253a6b24..3287e678551 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1345,7 +1345,7 @@ zigpy-zigate==0.7.3 zigpy-znp==0.5.1 # homeassistant.components.zha -zigpy==0.35.1 +zigpy==0.35.2 # homeassistant.components.zwave_js zwave-js-server-python==0.27.0 From 279c34f6061cac40043ad2a0d75912b2d63afd49 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sat, 10 Jul 2021 22:31:42 +0200 Subject: [PATCH 092/134] Update arcam lib to 0.7.0 (#52829) --- homeassistant/components/arcam_fmj/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/arcam_fmj/manifest.json b/homeassistant/components/arcam_fmj/manifest.json index d38ceceba73..6685ea240eb 100644 --- a/homeassistant/components/arcam_fmj/manifest.json +++ b/homeassistant/components/arcam_fmj/manifest.json @@ -3,7 +3,7 @@ "name": "Arcam FMJ Receivers", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/arcam_fmj", - "requirements": ["arcam-fmj==0.5.3"], + "requirements": ["arcam-fmj==0.7.0"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1", diff --git a/requirements_all.txt b/requirements_all.txt index 97ea28aa9a5..1210c323fab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -290,7 +290,7 @@ aprslib==0.6.46 aqualogic==2.6 # homeassistant.components.arcam_fmj -arcam-fmj==0.5.3 +arcam-fmj==0.7.0 # homeassistant.components.arris_tg2492lg arris-tg2492lg==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3287e678551..d8d21fe294e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -191,7 +191,7 @@ apprise==0.9.3 aprslib==0.6.46 # homeassistant.components.arcam_fmj -arcam-fmj==0.5.3 +arcam-fmj==0.7.0 # homeassistant.components.dlna_dmr # homeassistant.components.ssdp From 725a021c2e3c3bce37ac4ea4283f76d1ad95c57e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Jul 2021 04:13:07 -1000 Subject: [PATCH 093/134] Bump aiohomekit to 0.5.1 to solve performance regression (#52878) - Changelog: https://github.com/Jc2k/aiohomekit/compare/0.5.0...0.5.1 - Note that #52759 will need to be cherry-picked under this commit --- 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 816ec2db4d9..4bc61f53cc0 100644 --- a/homeassistant/components/homekit_controller/manifest.json +++ b/homeassistant/components/homekit_controller/manifest.json @@ -3,7 +3,7 @@ "name": "HomeKit Controller", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/homekit_controller", - "requirements": ["aiohomekit==0.5.0"], + "requirements": ["aiohomekit==0.5.1"], "zeroconf": ["_hap._tcp.local."], "after_dependencies": ["zeroconf"], "codeowners": ["@Jc2k", "@bdraco"], diff --git a/requirements_all.txt b/requirements_all.txt index 1210c323fab..66ca1e7bdfd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -175,7 +175,7 @@ aioguardian==1.0.4 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.5.0 +aiohomekit==0.5.1 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d8d21fe294e..32982c43b01 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -112,7 +112,7 @@ aioguardian==1.0.4 aioharmony==0.2.7 # homeassistant.components.homekit_controller -aiohomekit==0.5.0 +aiohomekit==0.5.1 # homeassistant.components.emulated_hue # homeassistant.components.http From d1eadd28b2ff78b340e24b9ec9759ca6b0488e43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 11 Jul 2021 22:33:03 +0200 Subject: [PATCH 094/134] Bump pyhaversion to 21.7.0 (#52880) --- homeassistant/components/version/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/version/manifest.json b/homeassistant/components/version/manifest.json index 6f36c337a76..de43a47d505 100644 --- a/homeassistant/components/version/manifest.json +++ b/homeassistant/components/version/manifest.json @@ -3,7 +3,7 @@ "name": "Version", "documentation": "https://www.home-assistant.io/integrations/version", "requirements": [ - "pyhaversion==21.5.0" + "pyhaversion==21.7.0" ], "codeowners": [ "@fabaff", diff --git a/requirements_all.txt b/requirements_all.txt index 66ca1e7bdfd..f9fa5e0a2a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1466,7 +1466,7 @@ pygtfs==0.1.6 pygti==0.9.2 # homeassistant.components.version -pyhaversion==21.5.0 +pyhaversion==21.7.0 # homeassistant.components.heos pyheos==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 32982c43b01..086f91afaff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -819,7 +819,7 @@ pygatt[GATTTOOL]==4.0.5 pygti==0.9.2 # homeassistant.components.version -pyhaversion==21.5.0 +pyhaversion==21.7.0 # homeassistant.components.heos pyheos==0.7.2 From 2a5b447bfdde131d1bd62ca76ec3dc327e348541 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 12 Jul 2021 08:17:50 +0200 Subject: [PATCH 095/134] Prefer using xy over hs when supported by light (#52883) --- homeassistant/components/deconz/light.py | 8 ++++-- tests/components/deconz/test_light.py | 34 +++++++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index 90d5e82af71..058147189e6 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -26,6 +26,7 @@ from homeassistant.components.light import ( ) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.util.color import color_hs_to_xy from .const import ( COVER_TYPES, @@ -189,8 +190,11 @@ class DeconzBaseLight(DeconzDevice, LightEntity): data["ct"] = kwargs[ATTR_COLOR_TEMP] if ATTR_HS_COLOR in kwargs: - data["hue"] = int(kwargs[ATTR_HS_COLOR][0] / 360 * 65535) - data["sat"] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255) + if COLOR_MODE_XY in self._attr_supported_color_modes: + data["xy"] = color_hs_to_xy(*kwargs[ATTR_HS_COLOR]) + else: + data["hue"] = int(kwargs[ATTR_HS_COLOR][0] / 360 * 65535) + data["sat"] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255) if ATTR_XY_COLOR in kwargs: data["xy"] = kwargs[ATTR_XY_COLOR] diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 2beb339dd4f..42dc04fc7ae 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -371,6 +371,34 @@ async def test_light_state_change(hass, aioclient_mock, mock_deconz_websocket): @pytest.mark.parametrize( "input,expected", [ + ( # Turn on light with hue and sat + { + "light_on": True, + "service": SERVICE_TURN_ON, + "call": { + ATTR_ENTITY_ID: "light.hue_go", + ATTR_HS_COLOR: (20, 30), + }, + }, + { + "on": True, + "xy": (0.411, 0.351), + }, + ), + ( # Turn on light with XY color + { + "light_on": True, + "service": SERVICE_TURN_ON, + "call": { + ATTR_ENTITY_ID: "light.hue_go", + ATTR_XY_COLOR: (0.411, 0.351), + }, + }, + { + "on": True, + "xy": (0.411, 0.351), + }, + ), ( # Turn on light with short color loop { "light_on": False, @@ -811,9 +839,8 @@ async def test_groups(hass, aioclient_mock, input, expected): }, }, { - "hue": 45510, "on": True, - "sat": 127, + "xy": (0.235, 0.164), }, ), ( # Turn on group with short color loop @@ -827,9 +854,8 @@ async def test_groups(hass, aioclient_mock, input, expected): }, }, { - "hue": 45510, "on": True, - "sat": 127, + "xy": (0.235, 0.164), }, ), ], From ed200ee4fd477b05ba403677edf0741413115452 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Sun, 11 Jul 2021 16:27:46 -0400 Subject: [PATCH 096/134] Bump zwave-js-server-python to 0.27.1 (#52885) --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index a48d0513c17..d719e3976a4 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -3,7 +3,7 @@ "name": "Z-Wave JS", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_js", - "requirements": ["zwave-js-server-python==0.27.0"], + "requirements": ["zwave-js-server-python==0.27.1"], "codeowners": ["@home-assistant/z-wave"], "dependencies": ["http", "websocket_api"], "iot_class": "local_push" diff --git a/requirements_all.txt b/requirements_all.txt index f9fa5e0a2a0..d146a539d7b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2457,4 +2457,4 @@ zigpy==0.35.2 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.27.0 +zwave-js-server-python==0.27.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 086f91afaff..789dad4910b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1348,4 +1348,4 @@ zigpy-znp==0.5.1 zigpy==0.35.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.27.0 +zwave-js-server-python==0.27.1 From fd848911a68655b3da34ddbe44d42e3b301940fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 12 Jul 2021 15:57:26 +0200 Subject: [PATCH 097/134] Surepetcare, fix set_lock_state (#52912) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/surepetcare/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/surepetcare/__init__.py b/homeassistant/components/surepetcare/__init__.py index 8f0c2311518..e9a2c5b73a1 100644 --- a/homeassistant/components/surepetcare/__init__.py +++ b/homeassistant/components/surepetcare/__init__.py @@ -158,10 +158,10 @@ class SurePetcareAPI: # https://github.com/PyCQA/pylint/issues/2062 # pylint: disable=no-member if state == LockState.UNLOCKED.name.lower(): - await self.surepy.unlock(flap_id) + await self.surepy.sac.unlock(flap_id) elif state == LockState.LOCKED_IN.name.lower(): - await self.surepy.lock_in(flap_id) + await self.surepy.sac.lock_in(flap_id) elif state == LockState.LOCKED_OUT.name.lower(): - await self.surepy.lock_out(flap_id) + await self.surepy.sac.lock_out(flap_id) elif state == LockState.LOCKED_ALL.name.lower(): - await self.surepy.lock(flap_id) + await self.surepy.sac.lock(flap_id) From 3301783c57ab1aac0bf1c894108ac0f6cdc1d2e4 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Mon, 12 Jul 2021 14:02:56 -0400 Subject: [PATCH 098/134] Bump pyinsteon to 1.0.11 (#52927) --- homeassistant/components/insteon/manifest.json | 10 +++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index dc564ae0d70..353cd55c747 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -2,8 +2,12 @@ "domain": "insteon", "name": "Insteon", "documentation": "https://www.home-assistant.io/integrations/insteon", - "requirements": ["pyinsteon==1.0.9"], - "codeowners": ["@teharris1"], + "requirements": [ + "pyinsteon==1.0.11" + ], + "codeowners": [ + "@teharris1" + ], "config_flow": true, "iot_class": "local_push" -} +} \ No newline at end of file diff --git a/requirements_all.txt b/requirements_all.txt index d146a539d7b..1016c73fc84 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1490,7 +1490,7 @@ pyialarm==1.9.0 pyicloud==0.10.2 # homeassistant.components.insteon -pyinsteon==1.0.9 +pyinsteon==1.0.11 # homeassistant.components.intesishome pyintesishome==1.7.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 789dad4910b..40b8cb00d7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -837,7 +837,7 @@ pyialarm==1.9.0 pyicloud==0.10.2 # homeassistant.components.insteon -pyinsteon==1.0.9 +pyinsteon==1.0.11 # homeassistant.components.ipma pyipma==2.0.5 From 0b04e0d5da7926d0e789f659832c90833ea4b41e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Jul 2021 10:03:13 -1000 Subject: [PATCH 099/134] Fix recorder purge with sqlite3 < 3.32.0 (#52929) --- homeassistant/components/recorder/const.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py index 026628a32df..eab3c30e99e 100644 --- a/homeassistant/components/recorder/const.py +++ b/homeassistant/components/recorder/const.py @@ -7,4 +7,9 @@ DOMAIN = "recorder" CONF_DB_INTEGRITY_CHECK = "db_integrity_check" # The maximum number of rows (events) we purge in one delete statement -MAX_ROWS_TO_PURGE = 1000 + +# sqlite3 has a limit of 999 until version 3.32.0 +# in https://github.com/sqlite/sqlite/commit/efdba1a8b3c6c967e7fae9c1989c40d420ce64cc +# We can increase this back to 1000 once most +# have upgraded their sqlite version +MAX_ROWS_TO_PURGE = 998 From 86589b401bd2bbdd927dd7f03f4c186f695c133f Mon Sep 17 00:00:00 2001 From: jjlawren Date: Mon, 12 Jul 2021 15:50:51 -0500 Subject: [PATCH 100/134] Bump pysonos to 0.0.52 (#52934) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index b1b0bc8a202..e873b43839a 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -3,7 +3,7 @@ "name": "Sonos", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sonos", - "requirements": ["pysonos==0.0.51"], + "requirements": ["pysonos==0.0.52"], "dependencies": ["ssdp"], "after_dependencies": ["plex", "zeroconf"], "zeroconf": ["_sonos._tcp.local."], diff --git a/requirements_all.txt b/requirements_all.txt index 1016c73fc84..8634b6957ea 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1773,7 +1773,7 @@ pysnmp==4.4.12 pysoma==0.0.10 # homeassistant.components.sonos -pysonos==0.0.51 +pysonos==0.0.52 # homeassistant.components.spc pyspcwebgw==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 40b8cb00d7d..b9799999a79 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1006,7 +1006,7 @@ pysmartthings==0.7.6 pysoma==0.0.10 # homeassistant.components.sonos -pysonos==0.0.51 +pysonos==0.0.52 # homeassistant.components.spc pyspcwebgw==0.4.0 From e095b9a1b9977dfed69be363f78d2b758d40f723 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 12 Jul 2021 13:57:47 -0700 Subject: [PATCH 101/134] Bumped version to 2021.7.2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 373f382a06e..2c90713249c 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "1" +PATCH_VERSION: Final = "2" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 8bd73709a7a9028b6e23c4add8448b526c90f9b3 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 13 Jul 2021 13:31:17 -0400 Subject: [PATCH 102/134] Update ZHA to support zigpy 0.34.0 device initialization (#52610) * Handle `None` node descriptors * Skip loading uninitialized devices * Fix unit test incorrectly handling unset cluster `ep_attribute` * Revert filtering devices by status during startup --- homeassistant/components/zha/core/device.py | 39 ++++++++++++++------- tests/components/zha/common.py | 17 +++++---- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 0c572bfba8a..c6166419e39 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -183,11 +183,12 @@ class ZHADevice(LogMixin): return self._zigpy_device.model @property - def manufacturer_code(self): + def manufacturer_code(self) -> int | None: """Return the manufacturer code for the device.""" - if self._zigpy_device.node_desc.is_valid: - return self._zigpy_device.node_desc.manufacturer_code - return None + if self._zigpy_device.node_desc is None: + return None + + return self._zigpy_device.node_desc.manufacturer_code @property def nwk(self): @@ -210,17 +211,20 @@ class ZHADevice(LogMixin): return self._zigpy_device.last_seen @property - def is_mains_powered(self): + def is_mains_powered(self) -> bool | None: """Return true if device is mains powered.""" + if self._zigpy_device.node_desc is None: + return None + return self._zigpy_device.node_desc.is_mains_powered @property - def device_type(self): + def device_type(self) -> str: """Return the logical device type for the device.""" - node_descriptor = self._zigpy_device.node_desc - return ( - node_descriptor.logical_type.name if node_descriptor.is_valid else UNKNOWN - ) + if self._zigpy_device.node_desc is None: + return UNKNOWN + + return self._zigpy_device.node_desc.logical_type.name @property def power_source(self): @@ -230,18 +234,27 @@ class ZHADevice(LogMixin): ) @property - def is_router(self): + def is_router(self) -> bool | None: """Return true if this is a routing capable device.""" + if self._zigpy_device.node_desc is None: + return None + return self._zigpy_device.node_desc.is_router @property - def is_coordinator(self): + def is_coordinator(self) -> bool | None: """Return true if this device represents the coordinator.""" + if self._zigpy_device.node_desc is None: + return None + return self._zigpy_device.node_desc.is_coordinator @property - def is_end_device(self): + def is_end_device(self) -> bool | None: """Return true if this device is an end device.""" + if self._zigpy_device.node_desc is None: + return None + return self._zigpy_device.node_desc.is_end_device @property diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index eb65cc4fd2e..5180e9dbc07 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -3,8 +3,8 @@ import asyncio import time from unittest.mock import AsyncMock, Mock -from zigpy.device import Device as zigpy_dev -from zigpy.endpoint import Endpoint as zigpy_ep +import zigpy.device as zigpy_dev +import zigpy.endpoint as zigpy_ep import zigpy.profiles.zha import zigpy.types import zigpy.zcl @@ -27,7 +27,7 @@ class FakeEndpoint: self.out_clusters = {} self._cluster_attr = {} self.member_of = {} - self.status = 1 + self.status = zigpy_ep.Status.ZDO_INIT self.manufacturer = manufacturer self.model = model self.profile_id = zigpy.profiles.zha.PROFILE_ID @@ -57,7 +57,7 @@ class FakeEndpoint: @property def __class__(self): """Fake being Zigpy endpoint.""" - return zigpy_ep + return zigpy_ep.Endpoint @property def unique_id(self): @@ -65,8 +65,8 @@ class FakeEndpoint: return self.device.ieee, self.endpoint_id -FakeEndpoint.add_to_group = zigpy_ep.add_to_group -FakeEndpoint.remove_from_group = zigpy_ep.remove_from_group +FakeEndpoint.add_to_group = zigpy_ep.Endpoint.add_to_group +FakeEndpoint.remove_from_group = zigpy_ep.Endpoint.remove_from_group def patch_cluster(cluster): @@ -125,12 +125,11 @@ class FakeDevice: self.lqi = 255 self.rssi = 8 self.last_seen = time.time() - self.status = 2 + self.status = zigpy_dev.Status.ENDPOINTS_INIT self.initializing = False self.skip_configuration = False self.manufacturer = manufacturer self.model = model - self.node_desc = zigpy.zdo.types.NodeDescriptor() self.remove_from_group = AsyncMock() if node_desc is None: node_desc = b"\x02@\x807\x10\x7fd\x00\x00*d\x00\x00" @@ -138,7 +137,7 @@ class FakeDevice: self.neighbors = [] -FakeDevice.add_to_group = zigpy_dev.add_to_group +FakeDevice.add_to_group = zigpy_dev.Device.add_to_group def get_zha_gateway(hass): From 29fb5e0cb258dc31f419656bcebe1082e020002e Mon Sep 17 00:00:00 2001 From: jan iversen Date: Wed, 14 Jul 2021 11:00:16 +0200 Subject: [PATCH 103/134] copy() --> deepcopy(). (#52794) --- homeassistant/components/modbus/modbus.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 0826f4d5794..f2b033bec3e 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -1,5 +1,6 @@ """Support for Modbus.""" import asyncio +from copy import deepcopy import logging from pymodbus.client.sync import ModbusSerialClient, ModbusTcpClient, ModbusUdpClient @@ -196,7 +197,7 @@ class ModbusHub: self._config_name = client_config[CONF_NAME] self._config_type = client_config[CONF_TYPE] self._config_delay = client_config[CONF_DELAY] - self._pb_call = PYMODBUS_CALL.copy() + self._pb_call = deepcopy(PYMODBUS_CALL) self._pb_class = { CONF_SERIAL: ModbusSerialClient, CONF_TCP: ModbusTcpClient, From 1d67e6653801a4d2e310c19ade7ed25afbc3c98e Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 13 Jul 2021 21:45:42 +0200 Subject: [PATCH 104/134] only allow one active call in each platform. (#52823) --- .coveragerc | 3 +++ homeassistant/components/modbus/base_platform.py | 6 ++++++ homeassistant/components/modbus/binary_sensor.py | 6 ++++++ homeassistant/components/modbus/climate.py | 7 ++++++- homeassistant/components/modbus/cover.py | 5 +++++ 5 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.coveragerc b/.coveragerc index 06f6bca0eec..dc35999768f 100644 --- a/.coveragerc +++ b/.coveragerc @@ -633,6 +633,9 @@ omit = homeassistant/components/mitemp_bt/sensor.py homeassistant/components/mjpeg/camera.py homeassistant/components/mochad/* + homeassistant/components/modbus/base_platform.py + homeassistant/components/modbus/binary_sensor.py + homeassistant/components/modbus/cover.py homeassistant/components/modbus/climate.py homeassistant/components/modbus/modbus.py homeassistant/components/modem_callerid/sensor.py diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index ed2f6e69863..4ff7adbdd29 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -51,6 +51,7 @@ class BasePlatform(Entity): self._value = None self._available = True self._scan_interval = int(entry[CONF_SCAN_INTERVAL]) + self._call_active = False @abstractmethod async def async_update(self, now=None): @@ -160,9 +161,14 @@ class BaseSwitch(BasePlatform, RestoreEntity): self.async_write_ha_state() return + # do not allow multiple active calls to the same platform + if self._call_active: + return + self._call_active = True result = await self._hub.async_pymodbus_call( self._slave, self._verify_address, 1, self._verify_type ) + self._call_active = False if result is None: self._available = False self.async_write_ha_state() diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index bc586e2f24d..0188210be8a 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -54,9 +54,15 @@ class ModbusBinarySensor(BasePlatform, RestoreEntity, BinarySensorEntity): async def async_update(self, now=None): """Update the state of the sensor.""" + + # do not allow multiple active calls to the same platform + if self._call_active: + return + self._call_active = True result = await self._hub.async_pymodbus_call( self._slave, self._address, 1, self._input_type ) + self._call_active = False if result is None: self._available = False self.async_write_ha_state() diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 5c99ac86d6c..42430f609cf 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -185,13 +185,18 @@ class ModbusThermostat(BasePlatform, RestoreEntity, ClimateEntity): """Update Target & Current Temperature.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval + + # do not allow multiple active calls to the same platform + if self._call_active: + return + self._call_active = True self._target_temperature = await self._async_read_register( CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register ) self._current_temperature = await self._async_read_register( self._input_type, self._address ) - + self._call_active = False self.async_write_ha_state() async def _async_read_register(self, register_type, register) -> float | None: diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 88c8fd77ae8..bd150434dc1 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -149,9 +149,14 @@ class ModbusCover(BasePlatform, CoverEntity, RestoreEntity): """Update the state of the cover.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval + # do not allow multiple active calls to the same platform + if self._call_active: + return + self._call_active = True result = await self._hub.async_pymodbus_call( self._slave, self._address, 1, self._input_type ) + self._call_active = False if result is None: self._available = False self.async_write_ha_state() From 47b5866d85d8e6cd82b56c7078b9698c75d04468 Mon Sep 17 00:00:00 2001 From: Doug Hoffman Date: Wed, 14 Jul 2021 04:45:47 -0400 Subject: [PATCH 105/134] Bump pyatv to 0.8.1 (#52849) * Bump pyatv to 0.8.1 * Update apple_tv tests for new create_session location * Update test_user_adds_unusable_device to try device with no services pyatv >=0.8.0 considers AirPlay a valid service and no longer fails under the previous conditions. --- homeassistant/components/apple_tv/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/apple_tv/conftest.py | 13 +++++-------- tests/components/apple_tv/test_config_flow.py | 6 +++--- 5 files changed, 11 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index 963cbb9be33..d4eb322f4d7 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -3,7 +3,7 @@ "name": "Apple TV", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/apple_tv", - "requirements": ["pyatv==0.7.7"], + "requirements": ["pyatv==0.8.1"], "zeroconf": ["_mediaremotetv._tcp.local.", "_touch-able._tcp.local."], "after_dependencies": ["discovery"], "codeowners": ["@postlund"], diff --git a/requirements_all.txt b/requirements_all.txt index 8634b6957ea..cd4092d7c30 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1318,7 +1318,7 @@ pyatmo==5.2.0 pyatome==0.1.1 # homeassistant.components.apple_tv -pyatv==0.7.7 +pyatv==0.8.1 # homeassistant.components.bbox pybbox==0.0.5-alpha diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b9799999a79..2ec48ca38b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -743,7 +743,7 @@ pyatag==0.3.5.3 pyatmo==5.2.0 # homeassistant.components.apple_tv -pyatv==0.7.7 +pyatv==0.8.1 # homeassistant.components.blackbird pyblackbird==0.5 diff --git a/tests/components/apple_tv/conftest.py b/tests/components/apple_tv/conftest.py index db543007fb2..f07fa7d70bb 100644 --- a/tests/components/apple_tv/conftest.py +++ b/tests/components/apple_tv/conftest.py @@ -2,7 +2,8 @@ from unittest.mock import patch -from pyatv import conf, net +from pyatv import conf +from pyatv.support.http import create_session import pytest from .common import MockPairingHandler, create_conf @@ -39,7 +40,7 @@ def pairing(): async def _pair(config, protocol, loop, session=None, **kwargs): handler = MockPairingHandler( - await net.create_session(session), config.get_service(protocol) + await create_session(session), config.get_service(protocol) ) handler.always_fail = mock_pair.always_fail return handler @@ -121,11 +122,7 @@ def dmap_device_with_credentials(mock_scan): @pytest.fixture -def airplay_device(mock_scan): +def device_with_no_services(mock_scan): """Mock pyatv.scan.""" - mock_scan.result.append( - create_conf( - "127.0.0.1", "AirPlay Device", conf.AirPlayService("airplayid", port=7777) - ) - ) + mock_scan.result.append(create_conf("127.0.0.1", "Invalid Device")) yield mock_scan diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index 615a1f404f5..45edaa36251 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -236,15 +236,15 @@ async def test_user_adds_existing_device(hass, mrp_device): assert result2["errors"] == {"base": "already_configured"} -async def test_user_adds_unusable_device(hass, airplay_device): - """Test that it is not possible to add pure AirPlay device.""" +async def test_user_adds_unusable_device(hass, device_with_no_services): + """Test that it is not possible to add device with no services.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"device_input": "AirPlay Device"}, + {"device_input": "Invalid Device"}, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["errors"] == {"base": "no_usable_service"} From d2e82edb58e8551c2a6051b5dbf25fee13dae613 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 12 Jul 2021 05:25:16 -1000 Subject: [PATCH 106/134] Handle dhcp packets without a hostname (#52882) * Handle dhcp packets without a hostname - Since some integrations only match on OUI we want to make sure they still see devices that do not request a specific hostname * Update tests/components/dhcp/test_init.py * Update homeassistant/components/dhcp/__init__.py Co-authored-by: Franck Nijhof Co-authored-by: Franck Nijhof --- homeassistant/components/dhcp/__init__.py | 8 +-- tests/components/dhcp/test_init.py | 64 +++++++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 5d0b31c8788..7003038593b 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -248,10 +248,10 @@ class DeviceTrackerWatcher(WatcherBase): return ip_address = attributes.get(ATTR_IP) - hostname = attributes.get(ATTR_HOST_NAME) + hostname = attributes.get(ATTR_HOST_NAME, "") mac_address = attributes.get(ATTR_MAC) - if ip_address is None or hostname is None or mac_address is None: + if ip_address is None or mac_address is None: return self.process_client(ip_address, hostname, _format_mac(mac_address)) @@ -328,10 +328,10 @@ class DHCPWatcher(WatcherBase): return ip_address = _decode_dhcp_option(options, REQUESTED_ADDR) or packet[IP].src - hostname = _decode_dhcp_option(options, HOSTNAME) + hostname = _decode_dhcp_option(options, HOSTNAME) or "" mac_address = _format_mac(packet[Ether].src) - if ip_address is None or hostname is None or mac_address is None: + if ip_address is None or mac_address is None: return self.process_client(ip_address, hostname, mac_address) diff --git a/tests/components/dhcp/test_init.py b/tests/components/dhcp/test_init.py index 122e81786c2..0da383c758a 100644 --- a/tests/components/dhcp/test_init.py +++ b/tests/components/dhcp/test_init.py @@ -81,6 +81,47 @@ RAW_DHCP_RENEWAL = ( b"\x43\x37\x08\x01\x21\x03\x06\x1c\x33\x3a\x3b\xff" ) +# 60:6b:bd:59:e4:b4 192.168.107.151 +RAW_DHCP_REQUEST_WITHOUT_HOSTNAME = ( + b"\xff\xff\xff\xff\xff\xff\x60\x6b\xbd\x59\xe4\xb4\x08\x00\x45\x00" + b"\x02\x40\x00\x00\x00\x00\x40\x11\x78\xae\x00\x00\x00\x00\xff\xff" + b"\xff\xff\x00\x44\x00\x43\x02\x2c\x02\x04\x01\x01\x06\x00\xff\x92" + b"\x7e\x31\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x60\x6b\xbd\x59\xe4\xb4\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x63\x82\x53\x63\x35\x01\x03\x3d\x07\x01" + b"\x60\x6b\xbd\x59\xe4\xb4\x3c\x25\x75\x64\x68\x63\x70\x20\x31\x2e" + b"\x31\x34\x2e\x33\x2d\x56\x44\x20\x4c\x69\x6e\x75\x78\x20\x56\x44" + b"\x4c\x69\x6e\x75\x78\x2e\x31\x2e\x32\x2e\x31\x2e\x78\x32\x04\xc0" + b"\xa8\x6b\x97\x36\x04\xc0\xa8\x6b\x01\x37\x07\x01\x03\x06\x0c\x0f" + b"\x1c\x2a\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" +) + async def test_dhcp_match_hostname_and_macaddress(hass): """Test matching based on hostname and macaddress.""" @@ -182,6 +223,29 @@ async def test_dhcp_match_macaddress(hass): } +async def test_dhcp_match_macaddress_without_hostname(hass): + """Test matching based on macaddress only.""" + dhcp_watcher = dhcp.DHCPWatcher( + hass, {}, [{"domain": "mock-domain", "macaddress": "606BBD*"}] + ) + + packet = Ether(RAW_DHCP_REQUEST_WITHOUT_HOSTNAME) + + with patch.object(hass.config_entries.flow, "async_init") as mock_init: + dhcp_watcher.handle_dhcp_packet(packet) + + assert len(mock_init.mock_calls) == 1 + assert mock_init.mock_calls[0][1][0] == "mock-domain" + assert mock_init.mock_calls[0][2]["context"] == { + "source": config_entries.SOURCE_DHCP + } + assert mock_init.mock_calls[0][2]["data"] == { + dhcp.IP_ADDRESS: "192.168.107.151", + dhcp.HOSTNAME: "", + dhcp.MAC_ADDRESS: "606bbd59e4b4", + } + + async def test_dhcp_nomatch(hass): """Test not matching based on macaddress only.""" dhcp_watcher = dhcp.DHCPWatcher( From ddf563c247ce445c1a16b6a962d1cc22f1ddcbb5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 15 Jul 2021 02:43:35 -1000 Subject: [PATCH 107/134] Add OUIs for legacy samsungtv (#52928) --- .../components/samsungtv/manifest.json | 6 +- homeassistant/generated/dhcp.py | 16 +++ .../components/samsungtv/test_config_flow.py | 106 +++++++++++++++--- 3 files changed, 110 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 4ffe940f946..133baccf4fb 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -18,7 +18,11 @@ "dhcp": [ { "hostname": "tizen*" - } + }, + {"macaddress": "8CC8CD*"}, + {"macaddress": "606BBD*"}, + {"macaddress": "F47B5E*"}, + {"macaddress": "4844F7*"} ], "codeowners": [ "@escoand", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 82b09e5f7ef..dbdaaf6da5e 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -175,6 +175,22 @@ DHCP = [ "domain": "samsungtv", "hostname": "tizen*" }, + { + "domain": "samsungtv", + "macaddress": "8CC8CD*" + }, + { + "domain": "samsungtv", + "macaddress": "606BBD*" + }, + { + "domain": "samsungtv", + "macaddress": "F47B5E*" + }, + { + "domain": "samsungtv", + "macaddress": "4844F7*" + }, { "domain": "screenlogic", "hostname": "pentair: *", diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index a0d2875ca59..1c9fdbcd0c5 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -3,7 +3,7 @@ import socket from unittest.mock import Mock, PropertyMock, call, patch from samsungctl.exceptions import AccessDenied, UnhandledResponse -from samsungtvws.exceptions import ConnectionFailure +from samsungtvws.exceptions import ConnectionFailure, HttpApiError from websocket import WebSocketException, WebSocketProtocolException from homeassistant import config_entries @@ -86,6 +86,7 @@ MOCK_SSDP_DATA_WRONGMODEL = { ATTR_UPNP_UDN: "uuid:0d1cef00-00dc-1000-9c80-4844f7b172df", } MOCK_DHCP_DATA = {IP_ADDRESS: "fake_host", MAC_ADDRESS: "aa:bb:cc:dd:ee:ff"} +EXISTING_IP = "192.168.40.221" MOCK_ZEROCONF_DATA = { CONF_HOST: "fake_host", CONF_PORT: 1234, @@ -99,7 +100,13 @@ MOCK_ZEROCONF_DATA = { MOCK_OLD_ENTRY = { CONF_HOST: "fake_host", CONF_ID: "0d1cef00-00dc-1000-9c80-4844f7b172de_old", - CONF_IP_ADDRESS: "fake_ip_old", + CONF_IP_ADDRESS: EXISTING_IP, + CONF_METHOD: "legacy", + CONF_PORT: None, +} +MOCK_LEGACY_ENTRY = { + CONF_HOST: EXISTING_IP, + CONF_ID: "0d1cef00-00dc-1000-9c80-4844f7b172de_old", CONF_METHOD: "legacy", CONF_PORT: None, } @@ -306,17 +313,22 @@ async def test_ssdp_noprefix(hass: HomeAssistant, remote: Mock): assert result["type"] == "form" assert result["step_id"] == "confirm" - # entry was added - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input="whatever" - ) - assert result["type"] == "create_entry" - assert result["title"] == "fake2_model" - assert result["data"][CONF_HOST] == "fake2_host" - assert result["data"][CONF_NAME] == "fake2_model" - assert result["data"][CONF_MANUFACTURER] == "Samsung fake2_manufacturer" - assert result["data"][CONF_MODEL] == "fake2_model" - assert result["result"].unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172df" + with patch( + "homeassistant.components.samsungtv.bridge.Remote.__enter__", + return_value=True, + ): + + # entry was added + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input="whatever" + ) + assert result["type"] == "create_entry" + assert result["title"] == "fake2_model" + assert result["data"][CONF_HOST] == "fake2_host" + assert result["data"][CONF_NAME] == "fake2_model" + assert result["data"][CONF_MANUFACTURER] == "Samsung fake2_manufacturer" + assert result["data"][CONF_MODEL] == "fake2_model" + assert result["result"].unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172df" async def test_ssdp_legacy_missing_auth(hass: HomeAssistant, remote: Mock): @@ -867,7 +879,7 @@ async def test_update_old_entry(hass: HomeAssistant, remote: Mock): assert len(config_entries_domain) == 1 assert entry is config_entries_domain[0] assert entry.data[CONF_ID] == "0d1cef00-00dc-1000-9c80-4844f7b172de_old" - assert entry.data[CONF_IP_ADDRESS] == "fake_ip_old" + assert entry.data[CONF_IP_ADDRESS] == EXISTING_IP assert not entry.unique_id assert await async_setup_component(hass, DOMAIN, {}) is True @@ -998,6 +1010,69 @@ async def test_update_missing_mac_added_unique_id_preserved_from_zeroconf( assert entry.unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" +async def test_update_legacy_missing_mac_from_dhcp(hass, remote: Mock): + """Test missing mac added.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_LEGACY_ENTRY, + unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.samsungtv.async_setup", + return_value=True, + ) as mock_setup, patch( + "homeassistant.components.samsungtv.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data={IP_ADDRESS: EXISTING_IP, MAC_ADDRESS: "aa:bb:cc:dd:ee:ff"}, + ) + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + assert entry.data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" + assert entry.unique_id == "0d1cef00-00dc-1000-9c80-4844f7b172de" + + +async def test_update_legacy_missing_mac_from_dhcp_no_unique_id(hass, remote: Mock): + """Test missing mac added when there is no unique id.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=MOCK_LEGACY_ENTRY, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.samsungtv.bridge.SamsungTVWS.rest_device_info", + side_effect=HttpApiError, + ), patch( + "homeassistant.components.samsungtv.bridge.Remote.__enter__", + return_value=True, + ), patch( + "homeassistant.components.samsungtv.async_setup", + return_value=True, + ) as mock_setup, patch( + "homeassistant.components.samsungtv.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data={IP_ADDRESS: EXISTING_IP, MAC_ADDRESS: "aa:bb:cc:dd:ee:ff"}, + ) + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == "abort" + assert result["reason"] == "not_supported" + assert entry.data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" + assert entry.unique_id is None + + async def test_form_reauth_legacy(hass, remote: Mock): """Test reauthenticate legacy.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY) @@ -1068,9 +1143,6 @@ async def test_form_reauth_websocket_cannot_connect(hass, remotews: Mock): ) await hass.async_block_till_done() - import pprint - - pprint.pprint(result2) assert result2["type"] == "form" assert result2["errors"] == {"base": RESULT_AUTH_MISSING} From cea22a9d5e9785e18d96ad3422f5534bd327519c Mon Sep 17 00:00:00 2001 From: Ron Klinkien Date: Tue, 13 Jul 2021 20:21:50 +0200 Subject: [PATCH 108/134] Bump python-fireservicerota to 0.0.43 (#52966) --- homeassistant/components/fireservicerota/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/fireservicerota/manifest.json b/homeassistant/components/fireservicerota/manifest.json index f35be9e839f..1eea9fbfbf1 100644 --- a/homeassistant/components/fireservicerota/manifest.json +++ b/homeassistant/components/fireservicerota/manifest.json @@ -3,7 +3,7 @@ "name": "FireServiceRota", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/fireservicerota", - "requirements": ["pyfireservicerota==0.0.42"], + "requirements": ["pyfireservicerota==0.0.43"], "codeowners": ["@cyberjunky"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index cd4092d7c30..120b4706da5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1423,7 +1423,7 @@ pyezviz==0.1.8.9 pyfido==2.1.1 # homeassistant.components.fireservicerota -pyfireservicerota==0.0.42 +pyfireservicerota==0.0.43 # homeassistant.components.flexit pyflexit==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2ec48ca38b7..eabfc4f055f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -791,7 +791,7 @@ pyezviz==0.1.8.9 pyfido==2.1.1 # homeassistant.components.fireservicerota -pyfireservicerota==0.0.42 +pyfireservicerota==0.0.43 # homeassistant.components.flume pyflume==0.5.5 From f17ed626bb8d64998f5dd01bd1a193da69276ea4 Mon Sep 17 00:00:00 2001 From: jjlawren Date: Tue, 13 Jul 2021 13:22:31 -0500 Subject: [PATCH 109/134] More graceful exception handling in Plex library sensors (#52969) --- homeassistant/components/plex/sensor.py | 8 +++++++ tests/components/plex/test_sensor.py | 31 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index 95ba0a65ef0..7b01d48c862 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -2,6 +2,7 @@ import logging from plexapi.exceptions import NotFound +import requests.exceptions from homeassistant.components.sensor import SensorEntity from homeassistant.helpers.debounce import Debouncer @@ -171,6 +172,13 @@ class PlexLibrarySectionSensor(SensorEntity): self._available = True except NotFound: self._available = False + except requests.exceptions.RequestException as err: + _LOGGER.error( + "Could not update library sensor for '%s': %s", + self.library_section.title, + err, + ) + self._available = False self.async_write_ha_state() def _update_state_and_attrs(self): diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py index 5fa50892f32..39a2901e72d 100644 --- a/tests/components/plex/test_sensor.py +++ b/tests/components/plex/test_sensor.py @@ -1,6 +1,8 @@ """Tests for Plex sensors.""" from datetime import timedelta +import requests.exceptions + from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_UNAVAILABLE from homeassistant.helpers import entity_registry as er @@ -15,6 +17,7 @@ LIBRARY_UPDATE_PAYLOAD = {"StatusNotification": [{"title": "Library scan complet async def test_library_sensor_values( hass, + caplog, setup_plex_server, mock_websocket, requests_mock, @@ -63,6 +66,34 @@ async def test_library_sensor_values( assert library_tv_sensor.attributes["seasons"] == 1 assert library_tv_sensor.attributes["shows"] == 1 + # Handle `requests` exception + requests_mock.get( + "/library/sections/2/all?includeCollections=0&type=2", + exc=requests.exceptions.ReadTimeout, + ) + trigger_plex_update( + mock_websocket, msgtype="status", payload=LIBRARY_UPDATE_PAYLOAD + ) + await hass.async_block_till_done() + + library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows") + assert library_tv_sensor.state == STATE_UNAVAILABLE + + assert "Could not update library sensor" in caplog.text + + # Ensure sensor updates properly when it recovers + requests_mock.get( + "/library/sections/2/all?includeCollections=0&type=2", + text=library_tvshows_size, + ) + trigger_plex_update( + mock_websocket, msgtype="status", payload=LIBRARY_UPDATE_PAYLOAD + ) + await hass.async_block_till_done() + + library_tv_sensor = hass.states.get("sensor.plex_server_1_library_tv_shows") + assert library_tv_sensor.state == "10" + # Handle library deletion requests_mock.get( "/library/sections/2/all?includeCollections=0&type=2", status_code=404 From 4ddfaf41eeb02fa7b78bdd97a5338cd797214f32 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Tue, 13 Jul 2021 14:27:04 -0400 Subject: [PATCH 110/134] Fix issue connecting to Insteon Hub v2 (#52970) --- homeassistant/components/insteon/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 353cd55c747..4643a8c662a 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -3,7 +3,7 @@ "name": "Insteon", "documentation": "https://www.home-assistant.io/integrations/insteon", "requirements": [ - "pyinsteon==1.0.11" + "pyinsteon==1.0.12" ], "codeowners": [ "@teharris1" diff --git a/requirements_all.txt b/requirements_all.txt index 120b4706da5..1f790a84243 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1490,7 +1490,7 @@ pyialarm==1.9.0 pyicloud==0.10.2 # homeassistant.components.insteon -pyinsteon==1.0.11 +pyinsteon==1.0.12 # homeassistant.components.intesishome pyintesishome==1.7.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eabfc4f055f..bf0664a4e58 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -837,7 +837,7 @@ pyialarm==1.9.0 pyicloud==0.10.2 # homeassistant.components.insteon -pyinsteon==1.0.11 +pyinsteon==1.0.12 # homeassistant.components.ipma pyipma==2.0.5 From 4e2042b63d229148acd933e058105dc0178c9bb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Klomp?= Date: Tue, 13 Jul 2021 19:33:53 +0200 Subject: [PATCH 111/134] Bump pysma to 0.6.4 (#52973) --- homeassistant/components/sma/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sma/manifest.json b/homeassistant/components/sma/manifest.json index a48b9ba74ce..985a0506574 100644 --- a/homeassistant/components/sma/manifest.json +++ b/homeassistant/components/sma/manifest.json @@ -3,7 +3,7 @@ "name": "SMA Solar", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sma", - "requirements": ["pysma==0.6.2"], + "requirements": ["pysma==0.6.4"], "codeowners": ["@kellerza", "@rklomp"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 1f790a84243..6b237f03f11 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1749,7 +1749,7 @@ pysignalclirestapi==0.3.4 pyskyqhub==0.1.3 # homeassistant.components.sma -pysma==0.6.2 +pysma==0.6.4 # homeassistant.components.smappee pysmappee==0.2.25 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bf0664a4e58..ba98cbe4aa5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -991,7 +991,7 @@ pysiaalarm==3.0.0 pysignalclirestapi==0.3.4 # homeassistant.components.sma -pysma==0.6.2 +pysma==0.6.4 # homeassistant.components.smappee pysmappee==0.2.25 From 3050d9350a849b6bb3fbe2794f7f4370bdcfcd91 Mon Sep 17 00:00:00 2001 From: Peter Nijssen Date: Wed, 14 Jul 2021 00:10:23 +0200 Subject: [PATCH 112/134] Update pyrainbird to 0.4.3 (#52990) --- homeassistant/components/rainbird/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json index 120e38e8058..d7d3c064ad7 100644 --- a/homeassistant/components/rainbird/manifest.json +++ b/homeassistant/components/rainbird/manifest.json @@ -2,7 +2,7 @@ "domain": "rainbird", "name": "Rain Bird", "documentation": "https://www.home-assistant.io/integrations/rainbird", - "requirements": ["pyrainbird==0.4.2"], + "requirements": ["pyrainbird==0.4.3"], "codeowners": ["@konikvranik"], "iot_class": "local_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 6b237f03f11..cd39fd4d224 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1696,7 +1696,7 @@ pyqwikswitch==0.93 pyrail==0.0.3 # homeassistant.components.rainbird -pyrainbird==0.4.2 +pyrainbird==0.4.3 # homeassistant.components.recswitch pyrecswitch==1.0.2 From 762f5a5d18d4aa4e038474d11c78eba4da9c7abb Mon Sep 17 00:00:00 2001 From: Andre Lengwenus Date: Wed, 14 Jul 2021 19:59:11 +0200 Subject: [PATCH 113/134] Bump pypck to 0.7.10 (#53013) --- homeassistant/components/lcn/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index 092e07eb5d2..1adc407d692 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -3,7 +3,7 @@ "name": "LCN", "config_flow": false, "documentation": "https://www.home-assistant.io/integrations/lcn", - "requirements": ["pypck==0.7.9"], + "requirements": ["pypck==0.7.10"], "codeowners": ["@alengwenus"], "iot_class": "local_push" } diff --git a/requirements_all.txt b/requirements_all.txt index cd39fd4d224..f57ac367ca3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1669,7 +1669,7 @@ pyownet==0.10.0.post1 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.7.9 +pypck==0.7.10 # homeassistant.components.pjlink pypjlink2==1.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ba98cbe4aa5..1bec3389bbc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -950,7 +950,7 @@ pyowm==3.2.0 pyownet==0.10.0.post1 # homeassistant.components.lcn -pypck==0.7.9 +pypck==0.7.10 # homeassistant.components.plaato pyplaato==0.0.15 From c90fa90faf85dd3b976ed23ceb9849c23b492339 Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Wed, 14 Jul 2021 20:01:16 +0200 Subject: [PATCH 114/134] fix for timestamp not present in SIA (#53015) --- homeassistant/components/sia/utils.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sia/utils.py b/homeassistant/components/sia/utils.py index 66fdd7d95be..6b87c9cb1fc 100644 --- a/homeassistant/components/sia/utils.py +++ b/homeassistant/components/sia/utils.py @@ -6,6 +6,8 @@ from typing import Any from pysiaalarm import SIAEvent +from homeassistant.util.dt import utcnow + from .const import ATTR_CODE, ATTR_ID, ATTR_MESSAGE, ATTR_TIMESTAMP, ATTR_ZONE PING_INTERVAL_MARGIN = 30 @@ -42,7 +44,9 @@ def get_event_data_from_sia_event(event: SIAEvent) -> dict[str, Any]: "code": event.code, "message": event.message, "x_data": event.x_data, - "timestamp": event.timestamp.isoformat(), + "timestamp": event.timestamp.isoformat() + if event.timestamp + else utcnow().isoformat(), "event_qualifier": event.event_qualifier, "event_type": event.event_type, "partition": event.partition, From 480215714684287eed9870a1d4ffde6ba2332b78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 14 Jul 2021 19:58:02 +0200 Subject: [PATCH 115/134] Co2signal, set SCAN_INTERVAL (#53023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * limit co2signal, wip Signed-off-by: Daniel Hjelseth Høyer * limit co2signal Signed-off-by: Daniel Hjelseth Høyer * limit co2signal Signed-off-by: Daniel Hjelseth Høyer --- homeassistant/components/co2signal/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index 980ffa8549b..e9cfdb87983 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -1,4 +1,5 @@ """Support for the CO2signal platform.""" +from datetime import timedelta import logging import CO2Signal @@ -17,6 +18,7 @@ import homeassistant.helpers.config_validation as cv CONF_COUNTRY_CODE = "country_code" _LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(minutes=3) ATTRIBUTION = "Data provided by CO2signal" From acf705a95862c8e233bfedbbaf3679b98887bdaa Mon Sep 17 00:00:00 2001 From: Eduard van Valkenburg Date: Thu, 15 Jul 2021 09:31:17 +0200 Subject: [PATCH 116/134] Another SIA fix for timestamp not present. (#53045) --- homeassistant/components/sia/utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sia/utils.py b/homeassistant/components/sia/utils.py index 6b87c9cb1fc..9150099656c 100644 --- a/homeassistant/components/sia/utils.py +++ b/homeassistant/components/sia/utils.py @@ -25,7 +25,9 @@ def get_attr_from_sia_event(event: SIAEvent) -> dict[str, Any]: ATTR_CODE: event.code, ATTR_MESSAGE: event.message, ATTR_ID: event.id, - ATTR_TIMESTAMP: event.timestamp.isoformat(), + ATTR_TIMESTAMP: event.timestamp.isoformat() + if event.timestamp + else utcnow().isoformat(), } From 2da660b76ec1f561d408037d404e3ded9f366fa8 Mon Sep 17 00:00:00 2001 From: da-anda Date: Thu, 15 Jul 2021 14:41:04 +0200 Subject: [PATCH 117/134] Fix knx expose feature not correctly falling back to default value (#53046) --- homeassistant/components/knx/expose.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/knx/expose.py b/homeassistant/components/knx/expose.py index 5b57e2b0b4c..5b92f9f1f6a 100644 --- a/homeassistant/components/knx/expose.py +++ b/homeassistant/components/knx/expose.py @@ -100,10 +100,8 @@ class KNXExposeSensor: def _init_expose_state(self) -> None: """Initialize state of the exposure.""" init_state = self.hass.states.get(self.entity_id) - init_value = self._get_expose_value(init_state) - self.device.sensor_value.value = ( - init_value if init_value is not None else self.expose_default - ) + state_value = self._get_expose_value(init_state) + self.device.sensor_value.value = state_value @callback def shutdown(self) -> None: @@ -116,12 +114,13 @@ class KNXExposeSensor: def _get_expose_value(self, state: State | None) -> StateType: """Extract value from state.""" if state is None or state.state in (STATE_UNKNOWN, STATE_UNAVAILABLE): - return None - value = ( - state.state - if self.expose_attribute is None - else state.attributes.get(self.expose_attribute) - ) + value = self.expose_default + else: + value = ( + state.state + if self.expose_attribute is None + else state.attributes.get(self.expose_attribute, self.expose_default) + ) if self.type == "binary": if value in (1, STATE_ON, "True"): return True @@ -150,9 +149,7 @@ class KNXExposeSensor: async def _async_set_knx_value(self, value: StateType) -> None: """Set new value on xknx ExposeSensor.""" if value is None: - if self.expose_default is None: - return - value = self.expose_default + return await self.device.set(value) From c5070da20e8f9ae8e2240eeddc800bd3214fba95 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 15 Jul 2021 10:27:18 -0700 Subject: [PATCH 118/134] Expose Spotify as a service (#53063) --- homeassistant/components/spotify/media_player.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py index 1c92e2ce51a..c88aa453d2c 100644 --- a/homeassistant/components/spotify/media_player.py +++ b/homeassistant/components/spotify/media_player.py @@ -266,6 +266,7 @@ class SpotifyMediaPlayer(MediaPlayerEntity): "manufacturer": "Spotify AB", "model": model, "name": self._name, + "entry_type": "service", } @property From a6ad08f5b65e3518c7c5328b7d4c163a5c3fa7b8 Mon Sep 17 00:00:00 2001 From: Vincent Le Bourlot Date: Thu, 15 Jul 2021 23:24:54 +0200 Subject: [PATCH 119/134] Increase polling interval to prevent reaching daily limit (#53066) * increase polling interval to prevent reaching daily limit * update test accordingly --- homeassistant/components/home_plus_control/__init__.py | 2 +- tests/components/home_plus_control/test_switch.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/home_plus_control/__init__.py b/homeassistant/components/home_plus_control/__init__.py index 954203e9b10..718900533aa 100644 --- a/homeassistant/components/home_plus_control/__init__.py +++ b/homeassistant/components/home_plus_control/__init__.py @@ -133,7 +133,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: name="home_plus_control_module", update_method=async_update_data, # Polling interval. Will only be polled if there are subscribers. - update_interval=timedelta(seconds=60), + update_interval=timedelta(seconds=300), ) hass_entry_data[DATA_COORDINATOR] = coordinator diff --git a/tests/components/home_plus_control/test_switch.py b/tests/components/home_plus_control/test_switch.py index aec23f0d32a..75d416ba2b1 100644 --- a/tests/components/home_plus_control/test_switch.py +++ b/tests/components/home_plus_control/test_switch.py @@ -146,7 +146,7 @@ async def test_plant_topology_reduction_change( return_value=mock_modules, ) as mock_check: async_fire_time_changed( - hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=100) + hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=400) ) await hass.async_block_till_done() assert len(mock_check.mock_calls) == 1 @@ -208,7 +208,7 @@ async def test_plant_topology_increase_change( return_value=mock_modules, ) as mock_check: async_fire_time_changed( - hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=100) + hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=400) ) await hass.async_block_till_done() assert len(mock_check.mock_calls) == 1 @@ -268,7 +268,7 @@ async def test_module_status_unavailable(hass, mock_config_entry, mock_modules): return_value=mock_modules, ) as mock_check: async_fire_time_changed( - hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=100) + hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=400) ) await hass.async_block_till_done() assert len(mock_check.mock_calls) == 1 @@ -339,7 +339,7 @@ async def test_module_status_available( return_value=mock_modules, ) as mock_check: async_fire_time_changed( - hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=100) + hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=400) ) await hass.async_block_till_done() assert len(mock_check.mock_calls) == 1 @@ -443,7 +443,7 @@ async def test_update_with_api_error( side_effect=HomePlusControlApiError, ) as mock_check: async_fire_time_changed( - hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=100) + hass, dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=400) ) await hass.async_block_till_done() assert len(mock_check.mock_calls) == 1 From 1295daa10ec97289b24670fb2af9b60f8046aad8 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 16 Jul 2021 09:03:28 +0200 Subject: [PATCH 120/134] Add light white parameter to light/services.yaml (#53075) --- homeassistant/components/light/services.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index e2a8a94a74a..778203a1c93 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -265,6 +265,17 @@ turn_on: min: -100 max: 100 unit_of_measurement: "%" + white: + name: White + description: + Set the light to white mode and change its brightness, where 0 turns + the light off, 1 is the minimum brightness and 255 is the maximum + brightness supported by the light. + advanced: true + selector: + number: + min: 0 + max: 255 profile: name: Profile description: Name of a light profile to use. From ae40ba6a74974fa94106338b79f5443f1f9861a4 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Fri, 16 Jul 2021 09:46:32 +0200 Subject: [PATCH 121/134] Bumped version to 2021.7.3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2c90713249c..2fb83613a3f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "2" +PATCH_VERSION: Final = "3" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0) From 0e297c288f3507f1e8b6db33ce496b24398a4b3b Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 19 Jul 2021 13:01:50 +0200 Subject: [PATCH 122/134] Allow pymodbus to reconnect in running system (not startup) (#53020) Allow pymodbus to reconnect (not during startup). --- homeassistant/components/modbus/modbus.py | 13 +++-- tests/components/modbus/test_init.py | 67 ----------------------- 2 files changed, 8 insertions(+), 72 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index f2b033bec3e..8d2ea46e293 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -249,17 +249,22 @@ class ModbusHub: for entry in self._pb_call.values(): entry[ENTRY_FUNC] = getattr(self._client, entry[ENTRY_NAME]) + await self.async_connect_task() + return True + + async def async_connect_task(self): + """Try to connect, and retry if needed.""" async with self._lock: if not await self.hass.async_add_executor_job(self._pymodbus_connect): - self._log_error("initial connect failed, no retry", error_state=False) - return False + err = f"{self._config_name} connect failed, retry in pymodbus" + self._log_error(err, error_state=False) + return # Start counting down to allow modbus requests. if self._config_delay: self._async_cancel_listener = async_call_later( self.hass, self._config_delay, self.async_end_delay ) - return True @callback def async_end_delay(self, args): @@ -313,8 +318,6 @@ class ModbusHub: return None if not self._client: return None - if not self._client.is_socket_open(): - return None async with self._lock: result = await self.hass.async_add_executor_job( self._pymodbus_call, unit, address, value, use_call diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 435b8446b6b..6349d6bffe3 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -514,35 +514,6 @@ async def test_pymodbus_constructor_fail(hass, caplog): assert mock_pb.called -@pytest.mark.parametrize( - "do_connect,do_exception,do_text", - [ - [False, None, "initial connect failed, no retry"], - [True, ModbusException("no connect"), "Modbus Error: no connect"], - ], -) -async def test_pymodbus_connect_fail( - hass, do_connect, do_exception, do_text, caplog, mock_pymodbus -): - """Run test for failing pymodbus connect.""" - config = { - DOMAIN: [ - { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, - } - ] - } - caplog.set_level(logging.ERROR) - mock_pymodbus.connect.return_value = do_connect - mock_pymodbus.connect.side_effect = do_exception - assert await async_setup_component(hass, DOMAIN, config) is False - await hass.async_block_till_done() - assert caplog.messages[0].startswith(f"Pymodbus: {do_text}") - assert caplog.records[0].levelname == "ERROR" - - async def test_pymodbus_close_fail(hass, caplog, mock_pymodbus): """Run test for failing pymodbus close.""" config = { @@ -562,44 +533,6 @@ async def test_pymodbus_close_fail(hass, caplog, mock_pymodbus): # Close() is called as part of teardown -async def test_disconnect(hass, mock_pymodbus): - """Run test for startup delay.""" - - # the purpose of this test is to test a device disconnect - # We "hijiack" a binary_sensor to make a proper blackbox test. - entity_id = f"{BINARY_SENSOR_DOMAIN}.{TEST_SENSOR_NAME}" - config = { - DOMAIN: [ - { - CONF_TYPE: "tcp", - CONF_HOST: TEST_HOST, - CONF_PORT: 5501, - CONF_NAME: TEST_MODBUS_NAME, - CONF_BINARY_SENSORS: [ - { - CONF_INPUT_TYPE: CALL_TYPE_COIL, - CONF_NAME: f"{TEST_SENSOR_NAME}", - CONF_ADDRESS: 52, - }, - ], - } - ] - } - mock_pymodbus.read_coils.return_value = ReadResult([0x01]) - mock_pymodbus.is_socket_open.return_value = False - now = dt_util.utcnow() - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): - assert await async_setup_component(hass, DOMAIN, config) is True - await hass.async_block_till_done() - - # pass first scan_interval - now = now + timedelta(seconds=20) - with mock.patch("homeassistant.helpers.event.dt_util.utcnow", return_value=now): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - - async def test_delay(hass, mock_pymodbus): """Run test for startup delay.""" From 6eb2fd7603c469e40bbb63d6deec2df8536559ac Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 19 Jul 2021 10:32:21 +0200 Subject: [PATCH 123/134] Fix groups reporting incorrect supported color modes (#53088) --- homeassistant/components/deconz/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/deconz/test_light.py | 13 ++++++++++++- 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index fbd420e4b16..0ae9e8c98b0 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/deconz", "requirements": [ - "pydeconz==81" + "pydeconz==82" ], "ssdp": [ { diff --git a/requirements_all.txt b/requirements_all.txt index f57ac367ca3..12c3a273788 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1375,7 +1375,7 @@ pydaikin==2.4.4 pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==81 +pydeconz==82 # homeassistant.components.delijn pydelijn==0.6.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1bec3389bbc..71ec78d2c81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -770,7 +770,7 @@ pycoolmasternet-async==0.1.2 pydaikin==2.4.4 # homeassistant.components.deconz -pydeconz==81 +pydeconz==82 # homeassistant.components.dexcom pydexcom==0.2.0 diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index 42dc04fc7ae..c2b12651fc0 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -742,7 +742,18 @@ async def test_groups(hass, aioclient_mock, input, expected): "name": "Group", "type": "LightGroup", "state": {"all_on": False, "any_on": True}, - "action": {}, + "action": { + "alert": "none", + "bri": 127, + "colormode": "hs", + "ct": 0, + "effect": "none", + "hue": 0, + "on": True, + "sat": 127, + "scene": None, + "xy": [0, 0], + }, "scenes": [], "lights": input["lights"], }, From 8a009f13742ccf01c3a7b757ec9ac249777ac75e Mon Sep 17 00:00:00 2001 From: Eric Severance Date: Tue, 20 Jul 2021 00:20:47 -0700 Subject: [PATCH 124/134] Handle all WeMo ensure_long_press_virtual_device exceptions (#53094) * Handle all exceptions around the WeMo ensure_long_press_virtual_device method * Don't use a bare exception Co-authored-by: Martin Hjelmare * Log exception Co-authored-by: Martin Hjelmare --- homeassistant/components/wemo/wemo_device.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/wemo/wemo_device.py b/homeassistant/components/wemo/wemo_device.py index 3b0fbdcbe55..6fd1f4d5512 100644 --- a/homeassistant/components/wemo/wemo_device.py +++ b/homeassistant/components/wemo/wemo_device.py @@ -1,7 +1,7 @@ """Home Assistant wrapper for a pyWeMo device.""" import logging -from pywemo import PyWeMoException, WeMoDevice +from pywemo import WeMoDevice from pywemo.subscribe import EVENT_TYPE_LONG_PRESS from homeassistant.config_entries import ConfigEntry @@ -81,8 +81,10 @@ async def async_register_device( if device.supports_long_press: try: await hass.async_add_executor_job(wemo.ensure_long_press_virtual_device) - except PyWeMoException: - _LOGGER.warning( + # Temporarily handling all exceptions for #52996 & pywemo/pywemo/issues/276 + # Replace this with `except: PyWeMoException` after upstream has been fixed. + except Exception: # pylint: disable=broad-except + _LOGGER.exception( "Failed to enable long press support for device: %s", wemo.name ) device.supports_long_press = False From 1c05329b5dc2b874124b3d87ce040d97dd5b64e7 Mon Sep 17 00:00:00 2001 From: jgriff2 Date: Sun, 18 Jul 2021 14:13:13 -0700 Subject: [PATCH 125/134] Fix remote rpi gpio input type (#53108) * Fix issue #45770 - Change sensor from Button to DigitalInput * Change references from button to sensor --- .../components/remote_rpi_gpio/__init__.py | 8 ++++---- .../components/remote_rpi_gpio/binary_sensor.py | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/remote_rpi_gpio/__init__.py b/homeassistant/components/remote_rpi_gpio/__init__.py index afa60e44bed..b11625eb7b2 100644 --- a/homeassistant/components/remote_rpi_gpio/__init__.py +++ b/homeassistant/components/remote_rpi_gpio/__init__.py @@ -1,5 +1,5 @@ """Support for controlling GPIO pins of a Raspberry Pi.""" -from gpiozero import LED, Button +from gpiozero import LED, DigitalInputDevice from gpiozero.pins.pigpio import PiGPIOFactory CONF_BOUNCETIME = "bouncetime" @@ -38,7 +38,7 @@ def setup_input(address, port, pull_mode, bouncetime): pull_gpio_up = False try: - return Button( + return DigitalInputDevice( port, pull_up=pull_gpio_up, bounce_time=bouncetime, @@ -56,6 +56,6 @@ def write_output(switch, value): switch.off() -def read_input(button): +def read_input(sensor): """Read a value from a GPIO.""" - return button.is_pressed + return sensor.value diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py index 86966ec4d87..aeff838d68d 100644 --- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py +++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py @@ -42,12 +42,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): devices = [] for port_num, port_name in ports.items(): try: - button = remote_rpi_gpio.setup_input( + remote_sensor = remote_rpi_gpio.setup_input( address, port_num, pull_mode, bouncetime ) except (ValueError, IndexError, KeyError, OSError): return - new_sensor = RemoteRPiGPIOBinarySensor(port_name, button, invert_logic) + new_sensor = RemoteRPiGPIOBinarySensor(port_name, remote_sensor, invert_logic) devices.append(new_sensor) add_entities(devices, True) @@ -56,23 +56,23 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class RemoteRPiGPIOBinarySensor(BinarySensorEntity): """Represent a binary sensor that uses a Remote Raspberry Pi GPIO.""" - def __init__(self, name, button, invert_logic): + def __init__(self, name, sensor, invert_logic): """Initialize the RPi binary sensor.""" self._name = name self._invert_logic = invert_logic self._state = False - self._button = button + self._sensor = sensor async def async_added_to_hass(self): """Run when entity about to be added to hass.""" def read_gpio(): """Read state from GPIO.""" - self._state = remote_rpi_gpio.read_input(self._button) + self._state = remote_rpi_gpio.read_input(self._sensor) self.schedule_update_ha_state() - self._button.when_released = read_gpio - self._button.when_pressed = read_gpio + self._sensor.when_deactivated = read_gpio + self._sensor.when_activated = read_gpio @property def should_poll(self): @@ -97,6 +97,6 @@ class RemoteRPiGPIOBinarySensor(BinarySensorEntity): def update(self): """Update the GPIO state.""" try: - self._state = remote_rpi_gpio.read_input(self._button) + self._state = remote_rpi_gpio.read_input(self._sensor) except requests.exceptions.ConnectionError: return From f3cb202136eef97b94689411ce7143f4e0f86ea7 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 19 Jul 2021 10:54:31 +0200 Subject: [PATCH 126/134] More restrictive state updates of UniFi uptime sensor (#53111) * More restrictive state updates of uptime sensor * Remove commented out old version of uptime test --- homeassistant/components/unifi/sensor.py | 28 ++++++++ tests/components/unifi/test_sensor.py | 91 +++++++++++++++--------- 2 files changed, 87 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 8d34d3cabd7..338f695a2b4 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -133,6 +133,34 @@ class UniFiUpTimeSensor(UniFiClient, SensorEntity): _attr_device_class = DEVICE_CLASS_TIMESTAMP + def __init__(self, client, controller): + """Set up tracked client.""" + super().__init__(client, controller) + + self.last_updated_time = self.client.uptime + + @callback + def async_update_callback(self) -> None: + """Update sensor when time has changed significantly. + + This will help avoid unnecessary updates to the state machine. + """ + update_state = True + + if self.client.uptime < 1000000000: + if self.client.uptime > self.last_updated_time: + update_state = False + else: + if self.client.uptime <= self.last_updated_time: + update_state = False + + self.last_updated_time = self.client.uptime + + if not update_state: + return None + + super().async_update_callback() + @property def name(self) -> str: """Return the name of the client.""" diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index eec4fba7df9..fbf697e295f 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -4,6 +4,7 @@ from datetime import datetime from unittest.mock import patch from aiounifi.controller import MESSAGE_CLIENT, MESSAGE_CLIENT_REMOVED +import pytest from homeassistant.components.device_tracker import DOMAIN as TRACKER_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN @@ -134,19 +135,29 @@ async def test_bandwidth_sensors(hass, aioclient_mock, mock_unifi_websocket): assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 -async def test_uptime_sensors(hass, aioclient_mock, mock_unifi_websocket): +@pytest.mark.parametrize( + "initial_uptime,event_uptime,new_uptime", + [ + # Uptime listed in epoch time should never change + (1609462800, 1609462800, 1612141200), + # Uptime counted in seconds increases with every event + (60, 64, 60), + ], +) +async def test_uptime_sensors( + hass, + aioclient_mock, + mock_unifi_websocket, + initial_uptime, + event_uptime, + new_uptime, +): """Verify that uptime sensors are working as expected.""" - client1 = { + uptime_client = { "mac": "00:00:00:00:00:01", "name": "client1", "oui": "Producer", - "uptime": 1609506061, - } - client2 = { - "hostname": "Client2", - "mac": "00:00:00:00:00:02", - "oui": "Producer", - "uptime": 60, + "uptime": initial_uptime, } options = { CONF_ALLOW_BANDWIDTH_SENSORS: False, @@ -155,32 +166,50 @@ async def test_uptime_sensors(hass, aioclient_mock, mock_unifi_websocket): CONF_TRACK_DEVICES: False, } - now = datetime(2021, 1, 1, 1, tzinfo=dt_util.UTC) + now = datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC) with patch("homeassistant.util.dt.now", return_value=now): config_entry = await setup_unifi_integration( hass, aioclient_mock, options=options, - clients_response=[client1, client2], + clients_response=[uptime_client], ) - assert len(hass.states.async_all()) == 3 - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 - assert hass.states.get("sensor.client1_uptime").state == "2021-01-01T13:01:01+00:00" - assert hass.states.get("sensor.client2_uptime").state == "2021-01-01T00:59:00+00:00" + assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + assert hass.states.get("sensor.client1_uptime").state == "2021-01-01T01:00:00+00:00" - # Verify state update + # Verify normal new event doesn't change uptime + # 4 seconds has passed - client1["uptime"] = 1609506062 - mock_unifi_websocket( - data={ - "meta": {"message": MESSAGE_CLIENT}, - "data": [client1], - } - ) - await hass.async_block_till_done() + uptime_client["uptime"] = event_uptime + now = datetime(2021, 1, 1, 1, 1, 4, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.now", return_value=now): + mock_unifi_websocket( + data={ + "meta": {"message": MESSAGE_CLIENT}, + "data": [uptime_client], + } + ) + await hass.async_block_till_done() - assert hass.states.get("sensor.client1_uptime").state == "2021-01-01T13:01:02+00:00" + assert hass.states.get("sensor.client1_uptime").state == "2021-01-01T01:00:00+00:00" + + # Verify new event change uptime + # 1 month has passed + + uptime_client["uptime"] = new_uptime + now = datetime(2021, 2, 1, 1, 1, 0, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.now", return_value=now): + mock_unifi_websocket( + data={ + "meta": {"message": MESSAGE_CLIENT}, + "data": [uptime_client], + } + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.client1_uptime").state == "2021-02-01T01:00:00+00:00" # Disable option @@ -191,7 +220,6 @@ async def test_uptime_sensors(hass, aioclient_mock, mock_unifi_websocket): assert len(hass.states.async_all()) == 1 assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 0 assert hass.states.get("sensor.client1_uptime") is None - assert hass.states.get("sensor.client2_uptime") is None # Enable option @@ -200,14 +228,13 @@ async def test_uptime_sensors(hass, aioclient_mock, mock_unifi_websocket): hass.config_entries.async_update_entry(config_entry, options=options.copy()) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 3 - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 + assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 assert hass.states.get("sensor.client1_uptime") - assert hass.states.get("sensor.client2_uptime") # Try to add the sensors again, using a signal - clients_connected = {client1["mac"], client2["mac"]} + clients_connected = {uptime_client["mac"]} devices_connected = set() controller = hass.data[UNIFI_DOMAIN][config_entry.entry_id] @@ -220,8 +247,8 @@ async def test_uptime_sensors(hass, aioclient_mock, mock_unifi_websocket): ) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 3 - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 + assert len(hass.states.async_all()) == 2 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 async def test_remove_sensors(hass, aioclient_mock, mock_unifi_websocket): From ffe0d72667720a1950fc8bd865a48583db6b8174 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 18 Jul 2021 15:12:05 -0600 Subject: [PATCH 127/134] Bump simplisafe-python to 11.0.2 (#53121) * Bump simplisafe-python to 11.0.2 * Fix CI --- homeassistant/components/simplisafe/__init__.py | 8 ++++++-- homeassistant/components/simplisafe/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/simplisafe/__init__.py b/homeassistant/components/simplisafe/__init__.py index 01e31633a1a..d4e43631b78 100644 --- a/homeassistant/components/simplisafe/__init__.py +++ b/homeassistant/components/simplisafe/__init__.py @@ -3,7 +3,11 @@ import asyncio from uuid import UUID from simplipy import get_api -from simplipy.errors import EndpointUnavailable, InvalidCredentialsError, SimplipyError +from simplipy.errors import ( + EndpointUnavailableError, + InvalidCredentialsError, + SimplipyError, +) import voluptuous as vol from homeassistant.const import ATTR_CODE, CONF_CODE, CONF_PASSWORD, CONF_USERNAME @@ -379,7 +383,7 @@ class SimpliSafe: if isinstance(result, InvalidCredentialsError): raise ConfigEntryAuthFailed("Invalid credentials") from result - if isinstance(result, EndpointUnavailable): + if isinstance(result, EndpointUnavailableError): # In case the user attempts an action not allowed in their current plan, # we merely log that message at INFO level (so the user is aware, # but not spammed with ERROR messages that they cannot change): diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 02713b106bd..3ec1e38ad4d 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==11.0.1"], + "requirements": ["simplisafe-python==11.0.2"], "codeowners": ["@bachya"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 12c3a273788..f3cb139dcd8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2102,7 +2102,7 @@ simplehound==0.3 simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==11.0.1 +simplisafe-python==11.0.2 # homeassistant.components.sisyphus sisyphus-control==3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 71ec78d2c81..fd1f7f0b592 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1148,7 +1148,7 @@ sharkiqpy==0.1.8 simplehound==0.3 # homeassistant.components.simplisafe -simplisafe-python==11.0.1 +simplisafe-python==11.0.2 # homeassistant.components.slack slackclient==2.5.0 From 255d5a533991813ba308948be059765f3cd43f09 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 18 Jul 2021 09:37:34 -1000 Subject: [PATCH 128/134] Bump nexia to 0.9.10 to fix asair login (#53122) --- homeassistant/components/nexia/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json index eb471597ec6..a453ec7f1df 100644 --- a/homeassistant/components/nexia/manifest.json +++ b/homeassistant/components/nexia/manifest.json @@ -1,7 +1,7 @@ { "domain": "nexia", "name": "Nexia/American Standard/Trane", - "requirements": ["nexia==0.9.9"], + "requirements": ["nexia==0.9.10"], "codeowners": ["@bdraco"], "documentation": "https://www.home-assistant.io/integrations/nexia", "config_flow": true, diff --git a/requirements_all.txt b/requirements_all.txt index f3cb139dcd8..4e85bc97bb0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1020,7 +1020,7 @@ nettigo-air-monitor==1.0.0 neurio==0.3.1 # homeassistant.components.nexia -nexia==0.9.9 +nexia==0.9.10 # homeassistant.components.nextcloud nextcloudmonitor==1.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fd1f7f0b592..fb89d024430 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -574,7 +574,7 @@ netdisco==2.9.0 nettigo-air-monitor==1.0.0 # homeassistant.components.nexia -nexia==0.9.9 +nexia==0.9.10 # homeassistant.components.notify_events notify-events==1.0.4 From 5e4f02b0f9e859a794eecc7184f3b26a8c194f9c Mon Sep 17 00:00:00 2001 From: Ben <512997+benleb@users.noreply.github.com> Date: Sun, 18 Jul 2021 08:49:07 +0200 Subject: [PATCH 129/134] Bump surepy to 0.7.0 (#53123) --- homeassistant/components/surepetcare/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/surepetcare/manifest.json b/homeassistant/components/surepetcare/manifest.json index 1f0804e0581..ee97e1ac627 100644 --- a/homeassistant/components/surepetcare/manifest.json +++ b/homeassistant/components/surepetcare/manifest.json @@ -3,6 +3,6 @@ "name": "Sure Petcare", "documentation": "https://www.home-assistant.io/integrations/surepetcare", "codeowners": ["@benleb", "@danielhiversen"], - "requirements": ["surepy==0.6.0"], + "requirements": ["surepy==0.7.0"], "iot_class": "cloud_polling" } diff --git a/requirements_all.txt b/requirements_all.txt index 4e85bc97bb0..c8e5379f47b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2210,7 +2210,7 @@ sucks==0.9.4 sunwatcher==0.2.1 # homeassistant.components.surepetcare -surepy==0.6.0 +surepy==0.7.0 # homeassistant.components.swiss_hydrological_data swisshydrodata==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fb89d024430..d01baf84f96 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1215,7 +1215,7 @@ subarulink==0.3.12 sunwatcher==0.2.1 # homeassistant.components.surepetcare -surepy==0.6.0 +surepy==0.7.0 # homeassistant.components.synology_dsm synologydsm-api==1.0.2 From a0411aab4be4076889947712840e2a670ec5ee22 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 18 Jul 2021 19:00:02 +0200 Subject: [PATCH 130/134] Upgrade pysonos to 0.0.53 (#53137) --- homeassistant/components/sonos/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index e873b43839a..a35faee4ad6 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -3,7 +3,7 @@ "name": "Sonos", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sonos", - "requirements": ["pysonos==0.0.52"], + "requirements": ["pysonos==0.0.53"], "dependencies": ["ssdp"], "after_dependencies": ["plex", "zeroconf"], "zeroconf": ["_sonos._tcp.local."], diff --git a/requirements_all.txt b/requirements_all.txt index c8e5379f47b..031bddd6f49 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1773,7 +1773,7 @@ pysnmp==4.4.12 pysoma==0.0.10 # homeassistant.components.sonos -pysonos==0.0.52 +pysonos==0.0.53 # homeassistant.components.spc pyspcwebgw==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d01baf84f96..f6f27012e50 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1006,7 +1006,7 @@ pysmartthings==0.7.6 pysoma==0.0.10 # homeassistant.components.sonos -pysonos==0.0.52 +pysonos==0.0.53 # homeassistant.components.spc pyspcwebgw==0.4.0 From df72eb7ebbf60233c2ce12ea9309efc57561cd09 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Jul 2021 10:45:07 -0700 Subject: [PATCH 131/134] Correctly detect is not home (#53279) --- .../components/device_tracker/device_condition.py | 12 ++++++------ .../device_tracker/test_device_condition.py | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/device_tracker/device_condition.py b/homeassistant/components/device_tracker/device_condition.py index 714d6d7f016..afa899444f6 100644 --- a/homeassistant/components/device_tracker/device_condition.py +++ b/homeassistant/components/device_tracker/device_condition.py @@ -11,7 +11,6 @@ from homeassistant.const import ( CONF_ENTITY_ID, CONF_TYPE, STATE_HOME, - STATE_NOT_HOME, ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import condition, config_validation as cv, entity_registry @@ -62,14 +61,15 @@ def async_condition_from_config( """Create a function to test a device condition.""" if config_validation: config = CONDITION_SCHEMA(config) - if config[CONF_TYPE] == "is_home": - state = STATE_HOME - else: - state = STATE_NOT_HOME + + reverse = config[CONF_TYPE] == "is_not_home" @callback def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: """Test if an entity is a certain state.""" - return condition.state(hass, config[ATTR_ENTITY_ID], state) + result = condition.state(hass, config[ATTR_ENTITY_ID], STATE_HOME) + if reverse: + result = not result + return result return test_is_state diff --git a/tests/components/device_tracker/test_device_condition.py b/tests/components/device_tracker/test_device_condition.py index 2cd4aceeb07..7e3f79712c4 100644 --- a/tests/components/device_tracker/test_device_condition.py +++ b/tests/components/device_tracker/test_device_condition.py @@ -3,7 +3,7 @@ import pytest import homeassistant.components.automation as automation from homeassistant.components.device_tracker import DOMAIN -from homeassistant.const import STATE_HOME, STATE_NOT_HOME +from homeassistant.const import STATE_HOME from homeassistant.helpers import device_registry from homeassistant.setup import async_setup_component @@ -119,7 +119,7 @@ async def test_if_state(hass, calls): assert len(calls) == 1 assert calls[0].data["some"] == "is_home - event - test_event1" - hass.states.async_set("device_tracker.entity", STATE_NOT_HOME) + hass.states.async_set("device_tracker.entity", "school") hass.bus.async_fire("test_event1") hass.bus.async_fire("test_event2") await hass.async_block_till_done() From bffef87103c6e2fdbd57eaac0870873b8527dc08 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Wed, 21 Jul 2021 23:29:27 +0200 Subject: [PATCH 132/134] Upgrade to async-upnp-client==0.19.1 (#53288) --- homeassistant/components/dlna_dmr/manifest.json | 2 +- homeassistant/components/ssdp/manifest.json | 2 +- homeassistant/components/upnp/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 87730aa1316..d11b32a6dd5 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -2,7 +2,7 @@ "domain": "dlna_dmr", "name": "DLNA Digital Media Renderer", "documentation": "https://www.home-assistant.io/integrations/dlna_dmr", - "requirements": ["async-upnp-client==0.19.0"], + "requirements": ["async-upnp-client==0.19.1"], "codeowners": [], "iot_class": "local_push" } diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index faadfac5c0c..432686d9027 100644 --- a/homeassistant/components/ssdp/manifest.json +++ b/homeassistant/components/ssdp/manifest.json @@ -4,7 +4,7 @@ "documentation": "https://www.home-assistant.io/integrations/ssdp", "requirements": [ "defusedxml==0.7.1", - "async-upnp-client==0.19.0" + "async-upnp-client==0.19.1" ], "dependencies": ["network"], "after_dependencies": ["zeroconf"], diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index b252f5082cb..810a53c9e28 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -3,7 +3,7 @@ "name": "UPnP/IGD", "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/upnp", - "requirements": ["async-upnp-client==0.19.0"], + "requirements": ["async-upnp-client==0.19.1"], "dependencies": ["ssdp"], "codeowners": ["@StevenLooman"], "ssdp": [ diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 908cd379886..ec9ab9d062e 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -4,7 +4,7 @@ aiodiscover==1.4.2 aiohttp==3.7.4.post0 aiohttp_cors==0.7.0 astral==2.2 -async-upnp-client==0.19.0 +async-upnp-client==0.19.1 async_timeout==3.0.1 attrs==21.2.0 awesomeversion==21.4.0 diff --git a/requirements_all.txt b/requirements_all.txt index 031bddd6f49..7081245a660 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -304,7 +304,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.dlna_dmr # homeassistant.components.ssdp # homeassistant.components.upnp -async-upnp-client==0.19.0 +async-upnp-client==0.19.1 # homeassistant.components.supla asyncpysupla==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6f27012e50..41b4b8fa0b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -196,7 +196,7 @@ arcam-fmj==0.7.0 # homeassistant.components.dlna_dmr # homeassistant.components.ssdp # homeassistant.components.upnp -async-upnp-client==0.19.0 +async-upnp-client==0.19.1 # homeassistant.components.aurora auroranoaa==0.0.2 From 464986921e703598888020405a2454538107794f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Jul 2021 19:22:06 -1000 Subject: [PATCH 133/134] Fix homekit locks not being created from when setup from the UI (#53301) --- homeassistant/components/homekit/config_flow.py | 8 +++++++- homeassistant/components/homekit/strings.json | 2 +- homeassistant/components/homekit/translations/en.json | 2 +- homeassistant/components/homekit/util.py | 5 ++--- tests/components/homekit/test_config_flow.py | 8 +++++--- 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 506b4f5c7a8..c7c3e5da833 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN +from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER_DOMAIN from homeassistant.components.remote import DOMAIN as REMOTE_DOMAIN from homeassistant.config_entries import SOURCE_IMPORT @@ -54,7 +55,12 @@ MODE_EXCLUDE = "exclude" INCLUDE_EXCLUDE_MODES = [MODE_EXCLUDE, MODE_INCLUDE] -DOMAINS_NEED_ACCESSORY_MODE = [CAMERA_DOMAIN, MEDIA_PLAYER_DOMAIN, REMOTE_DOMAIN] +DOMAINS_NEED_ACCESSORY_MODE = [ + CAMERA_DOMAIN, + LOCK_DOMAIN, + MEDIA_PLAYER_DOMAIN, + REMOTE_DOMAIN, +] NEVER_BRIDGED_DOMAINS = [CAMERA_DOMAIN] CAMERA_ENTITY_PREFIX = f"{CAMERA_DOMAIN}." diff --git a/homeassistant/components/homekit/strings.json b/homeassistant/components/homekit/strings.json index 56bc5438eac..3c9671c93e2 100644 --- a/homeassistant/components/homekit/strings.json +++ b/homeassistant/components/homekit/strings.json @@ -43,7 +43,7 @@ "data": { "include_domains": "Domains to include" }, - "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player and camera.", + "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player, activity based remote, lock, and camera.", "title": "Select domains to be included" }, "pairing": { diff --git a/homeassistant/components/homekit/translations/en.json b/homeassistant/components/homekit/translations/en.json index aa78c3e4adc..cee1e64ad56 100644 --- a/homeassistant/components/homekit/translations/en.json +++ b/homeassistant/components/homekit/translations/en.json @@ -12,7 +12,7 @@ "data": { "include_domains": "Domains to include" }, - "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player and camera.", + "description": "Choose the domains to be included. All supported entities in the domain will be included. A separate HomeKit instance in accessory mode will be created for each tv media player, activity based remote, lock, and camera.", "title": "Select domains to be included" } } diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 673abc5da67..6585e9e9c4e 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -499,12 +499,11 @@ def accessory_friendly_name(hass_name, accessory): def state_needs_accessory_mode(state): """Return if the entity represented by the state must be paired in accessory mode.""" - if state.domain == CAMERA_DOMAIN: + if state.domain in (CAMERA_DOMAIN, LOCK_DOMAIN): return True return ( - state.domain == LOCK_DOMAIN - or state.domain == MEDIA_PLAYER_DOMAIN + state.domain == MEDIA_PLAYER_DOMAIN and state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_TV or state.domain == REMOTE_DOMAIN and state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & SUPPORT_ACTIVITY diff --git a/tests/components/homekit/test_config_flow.py b/tests/components/homekit/test_config_flow.py index c06e8aaa5ad..f3707f9f71e 100644 --- a/tests/components/homekit/test_config_flow.py +++ b/tests/components/homekit/test_config_flow.py @@ -144,6 +144,7 @@ async def test_setup_creates_entries_for_accessory_mode_devices(hass): """Test we can setup a new instance and we create entries for accessory mode devices.""" hass.states.async_set("camera.one", "on") hass.states.async_set("camera.existing", "on") + hass.states.async_set("lock.new", "on") hass.states.async_set("media_player.two", "on", {"device_class": "tv"}) hass.states.async_set("remote.standard", "on") hass.states.async_set("remote.activity", "on", {"supported_features": 4}) @@ -180,7 +181,7 @@ async def test_setup_creates_entries_for_accessory_mode_devices(hass): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {"include_domains": ["camera", "media_player", "light", "remote"]}, + {"include_domains": ["camera", "media_player", "light", "lock", "remote"]}, ) assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM assert result2["step_id"] == "pairing" @@ -207,7 +208,7 @@ async def test_setup_creates_entries_for_accessory_mode_devices(hass): "filter": { "exclude_domains": [], "exclude_entities": [], - "include_domains": ["media_player", "light", "remote"], + "include_domains": ["media_player", "light", "lock", "remote"], "include_entities": [], }, "exclude_accessory_mode": True, @@ -225,7 +226,8 @@ async def test_setup_creates_entries_for_accessory_mode_devices(hass): # 4 - camera.one in accessory mode # 5 - media_player.two in accessory mode # 6 - remote.activity in accessory mode - assert len(mock_setup_entry.mock_calls) == 6 + # 7 - lock.new in accessory mode + assert len(mock_setup_entry.mock_calls) == 7 async def test_import(hass): From d4ef0be6e9cc43704e279740a31449f77a8212b7 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 21 Jul 2021 22:55:47 -0700 Subject: [PATCH 134/134] Bumped version to 2021.7.4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 2fb83613a3f..9ad1ceb8a15 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -5,7 +5,7 @@ from typing import Final MAJOR_VERSION: Final = 2021 MINOR_VERSION: Final = 7 -PATCH_VERSION: Final = "3" +PATCH_VERSION: Final = "4" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}" REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 8, 0)